flickpick 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flickpick/data.py ADDED
@@ -0,0 +1,129 @@
1
+ """Download (with a cache) and parse the MovieLens datasets the build uses.
2
+
3
+ Zips are cached under ``<data home>/raw/`` -- ``~/.local/share/flickpick/raw/`` unless
4
+ the ``FLICKPICK_DATA_HOME`` environment variable says otherwise -- through the ``graze``
5
+ URL cache when it is installed, else plain ``urllib``. CSVs are streamed out of the
6
+ zip with ``zipfile``, so the 900 MB ``ratings.csv`` of ml-32m is never held as bytes.
7
+
8
+ MovieLens licence: research and non-commercial use, and redistributed transformations
9
+ carry the same conditions (https://grouplens.org/datasets/movielens/).
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import urllib.request
15
+ import zipfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ import pandas as pd
20
+
21
+ DATA_HOME_ENVVAR = "FLICKPICK_DATA_HOME"
22
+ DFLT_DATA_HOME = "~/.local/share/flickpick"
23
+ MOVIELENS_BASE_URL = "https://files.grouplens.org/datasets/movielens/"
24
+ MOVIELENS_HOME = "https://grouplens.org/datasets/movielens/"
25
+ MOVIELENS_LICENCE = "research, non-commercial, share-alike on transformations"
26
+
27
+ SAMPLE_DATASET = "ml-latest-small"
28
+ FULL_DATASET = "ml-32m"
29
+ DATASETS = {
30
+ name: f"{MOVIELENS_BASE_URL}{name}.zip" for name in (SAMPLE_DATASET, FULL_DATASET)
31
+ }
32
+
33
+ _DTYPES = {
34
+ "ratings": {
35
+ "userId": "int32",
36
+ "movieId": "int32",
37
+ "rating": "float32",
38
+ "timestamp": "int64",
39
+ },
40
+ "movies": {"movieId": "int32", "title": "string", "genres": "string"},
41
+ "links": {"movieId": "int32", "imdbId": "Int64", "tmdbId": "Int64"},
42
+ "tags": {
43
+ "userId": "int32",
44
+ "movieId": "int32",
45
+ "tag": "string",
46
+ "timestamp": "int64",
47
+ },
48
+ }
49
+
50
+
51
+ def data_home() -> Path:
52
+ """The package's data folder (``$FLICKPICK_DATA_HOME`` or ``~/.local/share/flickpick``)."""
53
+ return Path(os.environ.get(DATA_HOME_ENVVAR, DFLT_DATA_HOME)).expanduser()
54
+
55
+
56
+ def raw_dir() -> Path:
57
+ """Where downloaded source archives are cached."""
58
+ return data_home() / "raw"
59
+
60
+
61
+ def _download(url: str, path: Path) -> Path:
62
+ try:
63
+ import graze
64
+ except ImportError:
65
+ graze = None
66
+ print(f"Downloading {url} -> {path}", file=sys.stderr)
67
+ if graze is not None:
68
+ return Path(graze.graze(url, cache_key=str(path), return_key=True))
69
+ tmp = path.with_suffix(path.suffix + ".part")
70
+ urllib.request.urlretrieve(url, tmp)
71
+ tmp.replace(path)
72
+ return path
73
+
74
+
75
+ def fetch_dataset(name: str = SAMPLE_DATASET, *, cache_dir=None) -> Path:
76
+ """Path to the cached zip of MovieLens dataset ``name``, downloading it if needed."""
77
+ if name not in DATASETS:
78
+ raise ValueError(f"Unknown dataset {name!r}; choose one of {sorted(DATASETS)}")
79
+ folder = Path(cache_dir).expanduser() if cache_dir else raw_dir()
80
+ folder.mkdir(parents=True, exist_ok=True)
81
+ path = folder / f"{name}.zip"
82
+ if path.is_file() and zipfile.is_zipfile(path):
83
+ return path
84
+ return _download(DATASETS[name], path)
85
+
86
+
87
+ @dataclass
88
+ class MovieLens:
89
+ """The four MovieLens tables used by the build, as DataFrames."""
90
+
91
+ name: str
92
+ ratings: pd.DataFrame
93
+ movies: pd.DataFrame
94
+ links: pd.DataFrame
95
+ tags: pd.DataFrame
96
+
97
+
98
+ def _read_member(zf: zipfile.ZipFile, table: str) -> pd.DataFrame:
99
+ members = [
100
+ n for n in zf.namelist() if n.endswith(f"/{table}.csv") or n == f"{table}.csv"
101
+ ]
102
+ if not members:
103
+ raise FileNotFoundError(f"{table}.csv not found in {zf.filename}")
104
+ dtypes = _DTYPES[table]
105
+ with zf.open(members[0]) as f:
106
+ return pd.read_csv(f, dtype=dtypes, usecols=list(dtypes))
107
+
108
+
109
+ def read_movielens(zip_path, *, name: str | None = None) -> MovieLens:
110
+ """Parse ratings, movies, links and tags out of a MovieLens zip."""
111
+ zip_path = Path(zip_path)
112
+ with zipfile.ZipFile(zip_path) as zf:
113
+ tables = {t: _read_member(zf, t) for t in _DTYPES}
114
+ return MovieLens(name=name or zip_path.stem, **tables)
115
+
116
+
117
+ def load_movielens(name: str = SAMPLE_DATASET, *, cache_dir=None) -> MovieLens:
118
+ """Fetch (cached) and parse MovieLens dataset ``name``."""
119
+ return read_movielens(fetch_dataset(name, cache_dir=cache_dir), name=name)
120
+
121
+
122
+ def movielens_source(name: str) -> dict:
123
+ """The manifest ``sources`` entry for a MovieLens dataset."""
124
+ return {
125
+ "name": f"MovieLens {name}",
126
+ "version": name,
127
+ "licence": MOVIELENS_LICENCE,
128
+ "url": MOVIELENS_HOME,
129
+ }
flickpick/defaults.py ADDED
@@ -0,0 +1,67 @@
1
+ """Named defaults shared by the build pipeline, the scorer and the CLI.
2
+
3
+ One frozen dataclass, ``DFLT``, is the single source of truth for every number the
4
+ core contract (``docs/core-contract.md``) and the artifact format
5
+ (``docs/artifact-format.md``) fix. Functions take these as keyword-only defaults, so a
6
+ caller can override any of them without touching this module.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ #: The canonical ratings scale is 0-100; these convert from each source's scale.
12
+ STARS_TO_100 = 20 # Letterboxd and MovieLens: 0.5-5 stars
13
+ TEN_TO_100 = 10 # IMDb: 1-10
14
+ SCALE_MIN, SCALE_MAX = 0, 100 # bounds every imported score must lie in
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Defaults:
19
+ """Every tunable number in one place (see the docs cited on each field)."""
20
+
21
+ # Collaborative filtering (EASE, Steck 2019)
22
+ #: L2 regularisation; the recsys report's probe found 200, 500 and 1000 tie on
23
+ #: NDCG@100 for MovieLens 32M (docs/research/recsys-methods-libraries-evaluation.md §3)
24
+ ease_lambda: float = 500.0
25
+ #: kept neighbours per item row (98% of dense NDCG@100 in the same probe)
26
+ topk: int = 100
27
+ #: number of most-rated items in the CF artifact (they carry ~97% of ratings)
28
+ n_items: int = 10_000
29
+ #: a population rating counts as a "like" at or above this, on the 0-100 scale
30
+ #: (3.5 stars)
31
+ like_threshold: float = 70.0
32
+
33
+ # The user's liked set (core contract: "Defaults that are not seams")
34
+ #: with fewer ratings than this, the fixed like_threshold replaces the median
35
+ min_ratings_for_median: int = 5
36
+
37
+ # Fusion
38
+ #: how each component is normalised over the candidate set before the weighted
39
+ #: sum (core contract: "Defaults that are not seams"); the only value is
40
+ #: "percentile_rank" (average ranks scaled to [0, 1])
41
+ normalisation: str = "percentile_rank"
42
+ w_cf: float = 1.0
43
+ w_semantic: float = 1.0
44
+ w_popularity: float = 0.1
45
+ k: int = 10
46
+ #: number of liked items named in "because you liked ..."
47
+ n_because: int = 3
48
+
49
+ # Embeddings
50
+ embedding_model: str = "BAAI/bge-small-en-v1.5"
51
+ embedding_dim: int = 384
52
+ query_prefix: str = ""
53
+ embed_batch_size: int = 64
54
+ #: cap on the distinct user tags appended to semantic_text (most frequent first)
55
+ max_tags: int = 50
56
+
57
+ # Evaluation
58
+ folds: int = 5
59
+ seed: int = 0
60
+ n_bootstrap: int = 1000
61
+ ci_level: float = 0.95
62
+ #: hit_rate is a per-fold quantity; below this many folds its bootstrap interval
63
+ #: (over folds) is too coarse to report (core contract, Science)
64
+ min_folds_for_fold_ci: int = 10
65
+
66
+
67
+ DFLT = Defaults()
flickpick/ease.py ADDED
@@ -0,0 +1,99 @@
1
+ """EASE (Steck 2019): a closed-form linear item-item model, sparsified to top-k.
2
+
3
+ Given a binary user x item matrix X:
4
+
5
+ G = X^T X + lambda I; P = G^-1; B = -P / diag(P) (column-wise); diag(B) = 0
6
+
7
+ A user's score vector is then ``x B``. ``B`` is dense, so each source row keeps only its
8
+ ``topk`` largest positive weights (the recsys report, section 3, measured 98% of dense
9
+ NDCG@100 at top-100 on MovieLens 32M). Rows of the result are source items, columns
10
+ target items, and each row's entries are sorted by descending weight, which is the
11
+ order ``docs/artifact-format.md`` requires.
12
+ """
13
+
14
+ import numpy as np
15
+ from scipy import sparse
16
+
17
+ from flickpick.artifacts import CF
18
+ from flickpick.defaults import DFLT
19
+
20
+ #: rows processed at a time when sparsifying a dense B (bounds peak memory)
21
+ SPARSIFY_CHUNK_ROWS = 1024
22
+
23
+
24
+ def most_rated_items(X, *, n_items: int = DFLT.n_items) -> np.ndarray:
25
+ """Column indices of the ``n_items`` columns of ``X`` with most nonzeros.
26
+
27
+ Ties are broken by column index, so the result is deterministic.
28
+ """
29
+ counts = np.asarray((sparse.csc_matrix(X) != 0).sum(axis=0)).ravel()
30
+ order = np.lexsort((np.arange(len(counts)), -counts))
31
+ return order[:n_items]
32
+
33
+
34
+ def ease_dense(X, *, lam: float = DFLT.ease_lambda, dtype=np.float64) -> np.ndarray:
35
+ """The dense EASE weight matrix B for a (sparse or dense) binary matrix X."""
36
+ if lam <= 0:
37
+ raise ValueError(f"lam must be positive (it makes G invertible), got {lam}")
38
+ X = sparse.csr_matrix(X, dtype=dtype)
39
+ G = (X.T @ X).toarray()
40
+ G[np.diag_indices_from(G)] += lam
41
+ del X
42
+ B = np.linalg.inv(G) # P; turned into B in place to keep one n x n array alive
43
+ del G
44
+ B /= -np.diag(B).copy()
45
+ B[np.diag_indices_from(B)] = 0.0
46
+ return B
47
+
48
+
49
+ def sparsify_topk(B: np.ndarray, *, topk: int = DFLT.topk) -> CF:
50
+ """Keep each row's ``topk`` largest positive weights, sorted descending, as CSR."""
51
+ n_rows, n_cols = B.shape
52
+ kk = min(topk, n_cols)
53
+ indptr = np.zeros(n_rows + 1, dtype=np.int64)
54
+ rows_idx, rows_val = [], []
55
+ for start in range(0, n_rows, SPARSIFY_CHUNK_ROWS):
56
+ chunk = B[start : start + SPARSIFY_CHUNK_ROWS]
57
+ top = (
58
+ np.argpartition(-chunk, kk - 1, axis=1)[:, :kk]
59
+ if kk < n_cols
60
+ else (np.tile(np.arange(n_cols), (len(chunk), 1)))
61
+ )
62
+ vals = np.take_along_axis(chunk, top, axis=1)
63
+ order = np.lexsort((top, -vals), axis=1)
64
+ top = np.take_along_axis(top, order, axis=1)
65
+ vals = np.take_along_axis(vals, order, axis=1)
66
+ for r in range(len(chunk)):
67
+ keep = vals[r] > 0
68
+ rows_idx.append(top[r][keep])
69
+ rows_val.append(vals[r][keep])
70
+ indptr[start + r + 1] = keep.sum()
71
+ np.cumsum(indptr, out=indptr)
72
+ concat = np.concatenate if rows_idx else (lambda _: np.zeros(0))
73
+ return CF(
74
+ indptr=indptr.astype(np.int32),
75
+ indices=concat(rows_idx).astype(np.int32),
76
+ values=concat(rows_val).astype(np.float32),
77
+ )
78
+
79
+
80
+ def fit_ease(
81
+ X,
82
+ *,
83
+ lam: float = DFLT.ease_lambda,
84
+ topk: int = DFLT.topk,
85
+ n_items: int | None = DFLT.n_items,
86
+ ) -> tuple[CF, np.ndarray]:
87
+ """Fit sparse EASE on X restricted to its ``n_items`` most-rated columns (None: all).
88
+
89
+ Returns ``(cf, item_cols)``: the CSR matrix over the kept items, and the original
90
+ column index of each kept item (row ``r`` of ``cf`` is column ``item_cols[r]``).
91
+ """
92
+ X = sparse.csr_matrix(X)
93
+ cols = (
94
+ np.arange(X.shape[1])
95
+ if n_items is None
96
+ else most_rated_items(X, n_items=n_items)
97
+ )
98
+ B = ease_dense(X[:, cols], lam=lam)
99
+ return sparsify_topk(B, topk=topk), cols
flickpick/embedding.py ADDED
@@ -0,0 +1,82 @@
1
+ """Sentence embeddings of catalogue text and of mood queries.
2
+
3
+ The model is fixed per artifact set (``manifest["embedding"]["model"]``, by default
4
+ ``BAAI/bge-small-en-v1.5``, 384-d). Encoding goes through the in-house ``ef`` package's
5
+ sentence-transformers adapter when ``ef`` is importable, else through
6
+ ``sentence-transformers`` directly; either way it needs the optional ``[embed]`` extra
7
+ (``pip install 'flickpick[embed]'``). Vectors are always returned L2-normalised float32.
8
+ """
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from functools import lru_cache
12
+
13
+ import numpy as np
14
+
15
+ from flickpick.defaults import DFLT
16
+
17
+ INSTALL_HINT = (
18
+ "Embeddings need sentence-transformers: pip install 'flickpick[embed]' "
19
+ "(or build with --no-embeddings)."
20
+ )
21
+
22
+
23
+ def l2_normalise(x: np.ndarray) -> np.ndarray:
24
+ """Rows of ``x`` scaled to unit L2 norm (zero rows stay zero), as float32."""
25
+ x = np.asarray(x, dtype=np.float32)
26
+ norms = np.linalg.norm(x, axis=-1, keepdims=True)
27
+ return (x / np.where(norms == 0, 1, norms)).astype(np.float32)
28
+
29
+
30
+ @lru_cache(maxsize=4)
31
+ def get_encoder(
32
+ model: str = DFLT.embedding_model, *, batch_size: int = DFLT.embed_batch_size
33
+ ) -> Callable[[Sequence[str]], np.ndarray]:
34
+ """A batch text encoder ``texts -> array(n, dim)`` for ``model`` (cached)."""
35
+ try:
36
+ import ef
37
+
38
+ return ef.sentence_transformers_embedder(
39
+ model, normalize=True, batch_size=batch_size
40
+ )
41
+ except ImportError:
42
+ pass
43
+ try:
44
+ from sentence_transformers import SentenceTransformer
45
+ except ImportError as exc:
46
+ raise ImportError(INSTALL_HINT) from exc
47
+ st = SentenceTransformer(model)
48
+
49
+ def encode(texts):
50
+ return st.encode(
51
+ list(texts),
52
+ batch_size=batch_size,
53
+ normalize_embeddings=True,
54
+ convert_to_numpy=True,
55
+ show_progress_bar=False,
56
+ )
57
+
58
+ return encode
59
+
60
+
61
+ def embed_texts(
62
+ texts: Sequence[str], *, model: str = DFLT.embedding_model, prefix: str = ""
63
+ ) -> np.ndarray:
64
+ """L2-normalised float32 embeddings of ``prefix + text`` for each text."""
65
+ encoder = get_encoder(model)
66
+ return l2_normalise(encoder([prefix + t for t in texts]))
67
+
68
+
69
+ def embedding_spec(
70
+ *,
71
+ model: str = DFLT.embedding_model,
72
+ dim: int = DFLT.embedding_dim,
73
+ query_prefix: str = DFLT.query_prefix,
74
+ ) -> dict:
75
+ """The manifest ``embedding`` entry for vectors of ``semantic_text``."""
76
+ return {
77
+ "model": model,
78
+ "dim": int(dim),
79
+ "dtype": "float32",
80
+ "query_prefix": query_prefix,
81
+ "text_field": "semantic_text",
82
+ }
flickpick/importers.py ADDED
@@ -0,0 +1,188 @@
1
+ """Parse ratings exports into ``Rating`` lists on the canonical 0-100 scale.
2
+
3
+ Formats (detected from the header by :func:`detect_format`; header cells are trimmed
4
+ and matched case-insensitively, and the first format whose required columns are all
5
+ present wins, in this order -- the rules of ``docs/core-contract.md``, Importers):
6
+
7
+ - **imdb** -- IMDb's ratings export (requires ``Const``, ``Your Rating``; 1-10, x10).
8
+ - **flickpick** -- ``movie_id,imdb_id,tmdb_id,rating,average_rating,title`` (requires
9
+ ``movie_id``, ``imdb_id``, ``rating``) with ``rating`` already 0-100 and ``imdb_id``
10
+ numeric or ``tt``-prefixed.
11
+ - **letterboxd** -- ``ratings.csv`` or ``diary.csv`` from Letterboxd's data export
12
+ (requires ``Name``, ``Year``, ``Rating``; ``Date`` and ``Letterboxd URI`` optional;
13
+ 0.5-5 stars, x20). No IMDb id: the rating carries ``"Name (Year)"`` as its title and
14
+ is matched by title.
15
+ - **movielens** -- a MovieLens ``ratings.csv`` (requires ``movieId``, ``rating``;
16
+ ``userId`` and ``timestamp`` optional; x20). Items are ``ml:<movieId>``; a file
17
+ holding several users needs ``user_id=``.
18
+
19
+ >>> parse_ratings("Const,Your Rating,Title,Year\\ntt0114369,9,Se7en,1995\\n")
20
+ [Rating(item_id='tt0114369', score=90.0, rated_at=None, title='Se7en (1995)')]
21
+ """
22
+
23
+ import csv
24
+ import io
25
+ from collections.abc import Callable
26
+ from datetime import datetime, timezone
27
+
28
+ from flickpick.defaults import SCALE_MAX, SCALE_MIN, STARS_TO_100, TEN_TO_100
29
+ from flickpick.ratings import ML_PREFIX, TMDB_PREFIX, Rating, imdb_id
30
+
31
+ #: format -> lower-cased columns whose presence identifies it (checked in this order;
32
+ #: the same table as the TypeScript ``SIGNATURES``)
33
+ SIGNATURES = {
34
+ "imdb": {"const", "your rating"},
35
+ "flickpick": {"movie_id", "imdb_id", "rating"},
36
+ "letterboxd": {"name", "year", "rating"},
37
+ "movielens": {"movieid", "rating"},
38
+ }
39
+
40
+
41
+ def _rows(csv_text: str) -> list[dict]:
42
+ reader = csv.DictReader(io.StringIO(csv_text.lstrip("")))
43
+ # header cells are matched stripped and lower-cased (as in ``detect_format``), so
44
+ # "Const, Your Rating" or "RATING" does not silently yield no rows
45
+ reader.fieldnames = [c.strip().lower() for c in reader.fieldnames or []]
46
+ return list(reader)
47
+
48
+
49
+ def _header(csv_text: str) -> set[str]:
50
+ first = next(csv.reader(io.StringIO(csv_text.lstrip(""))), [])
51
+ return {c.strip().lower() for c in first}
52
+
53
+
54
+ def detect_format(csv_text: str) -> str:
55
+ """The name of the export format of ``csv_text`` (a key of ``SIGNATURES``)."""
56
+ header = _header(csv_text)
57
+ for name, required in SIGNATURES.items():
58
+ if required <= header:
59
+ return name
60
+ raise ValueError(
61
+ f"Unrecognised ratings CSV header {sorted(header)}. Expected one of: "
62
+ + "; ".join(f"{n} ({', '.join(sorted(c))})" for n, c in SIGNATURES.items())
63
+ )
64
+
65
+
66
+ def _blank(value) -> bool:
67
+ return value is None or not str(value).strip()
68
+
69
+
70
+ def _score(value, factor: float) -> float:
71
+ """``value`` converted to the 0-100 scale; raises when the result falls outside it."""
72
+ score = float(value) * factor
73
+ if not SCALE_MIN <= score <= SCALE_MAX:
74
+ raise ValueError(
75
+ f"Rating {value!r} (x{factor} = {score}) is outside the "
76
+ f"{SCALE_MIN}-{SCALE_MAX} scale"
77
+ )
78
+ return score
79
+
80
+
81
+ def _with_year(title: str, year) -> str:
82
+ return f"{title} ({year})" if not _blank(year) else title
83
+
84
+
85
+ def parse_letterboxd(csv_text: str) -> list[Rating]:
86
+ """Letterboxd ``ratings.csv`` -> ratings (unrated rows are skipped)."""
87
+ return [
88
+ Rating(
89
+ item_id="",
90
+ score=_score(r["rating"], STARS_TO_100),
91
+ rated_at=r.get("date") or None,
92
+ title=_with_year(r["name"], r["year"]),
93
+ )
94
+ for r in _rows(csv_text)
95
+ if not _blank(r.get("rating"))
96
+ ]
97
+
98
+
99
+ def parse_imdb(csv_text: str) -> list[Rating]:
100
+ """IMDb ratings export -> ratings."""
101
+ return [
102
+ Rating(
103
+ item_id=imdb_id(r["const"]),
104
+ score=_score(r["your rating"], TEN_TO_100),
105
+ rated_at=r.get("date rated") or None,
106
+ title=_with_year(r.get("title") or "", r.get("year"))
107
+ if r.get("title")
108
+ else None,
109
+ )
110
+ for r in _rows(csv_text)
111
+ if not _blank(r.get("your rating"))
112
+ ]
113
+
114
+
115
+ def _iso(timestamp) -> str | None:
116
+ if _blank(timestamp):
117
+ return None
118
+ dt = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
119
+ return dt.isoformat(timespec="seconds")
120
+
121
+
122
+ def parse_movielens(csv_text: str, *, user_id=None) -> list[Rating]:
123
+ """MovieLens ``ratings.csv`` -> ratings of one user (``ml:<movieId>`` ids).
124
+
125
+ ``userId`` is optional; a file holding several users needs ``user_id``.
126
+ """
127
+ rows = _rows(csv_text)
128
+ users = {r["userid"].strip() for r in rows if not _blank(r.get("userid"))}
129
+ if user_id is None and len(users) > 1:
130
+ raise ValueError(
131
+ f"The file holds {len(users)} users; pass user_id= to pick one."
132
+ )
133
+ if user_id is not None:
134
+ rows = [r for r in rows if (r.get("userid") or "").strip() == str(user_id)]
135
+ return [
136
+ Rating(
137
+ item_id=f"{ML_PREFIX}{int(r['movieid'])}",
138
+ score=_score(r["rating"], STARS_TO_100),
139
+ rated_at=_iso(r.get("timestamp")),
140
+ )
141
+ for r in rows
142
+ ]
143
+
144
+
145
+ def _flickpick_id(row: dict) -> str:
146
+ if not _blank(row.get("imdb_id")):
147
+ return imdb_id(row["imdb_id"])
148
+ if not _blank(row.get("movie_id")):
149
+ return f"{ML_PREFIX}{int(row['movie_id'])}"
150
+ if not _blank(row.get("tmdb_id")):
151
+ return f"{TMDB_PREFIX}{int(row['tmdb_id'])}"
152
+ return ""
153
+
154
+
155
+ def parse_flickpick(csv_text: str) -> list[Rating]:
156
+ """The flickpick CSV (the example file's columns) -> ratings (already 0-100)."""
157
+ return [
158
+ Rating(
159
+ item_id=_flickpick_id(r),
160
+ score=_score(r["rating"], 1),
161
+ rated_at=r.get("rated_at") or None,
162
+ title=r.get("title") or None,
163
+ )
164
+ for r in _rows(csv_text)
165
+ if not _blank(r.get("rating"))
166
+ ]
167
+
168
+
169
+ PARSERS: dict[str, Callable[[str], list[Rating]]] = {
170
+ "letterboxd": parse_letterboxd,
171
+ "imdb": parse_imdb,
172
+ "movielens": parse_movielens,
173
+ "flickpick": parse_flickpick,
174
+ }
175
+
176
+
177
+ def parse_ratings(csv_text: str, *, format: str | None = None) -> list[Rating]:
178
+ """Parse ``csv_text`` with the parser for ``format`` (detected when None)."""
179
+ fmt = format or detect_format(csv_text)
180
+ if fmt not in PARSERS:
181
+ raise ValueError(f"Unknown format {fmt!r}; choose one of {sorted(PARSERS)}")
182
+ return PARSERS[fmt](csv_text)
183
+
184
+
185
+ def read_ratings(path, *, format: str | None = None, encoding="utf-8") -> list[Rating]:
186
+ """Read and parse a ratings CSV file."""
187
+ with open(path, encoding=encoding) as f:
188
+ return parse_ratings(f.read(), format=format)