BluoCaroot commited on
Commit
52235b0
·
0 Parent(s):

model final final final final

Browse files
.dockerignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python caches
2
+ __pycache__/
3
+ **/__pycache__/
4
+ *.pyc
5
+ *.pyo
6
+
7
+ # Duplicate ~950 MB backup copy of the artifacts — the API loads from
8
+ # artifacts_improved8/ root, not this directory.
9
+ artifacts_improved8/_backup_pretrained/
10
+
11
+ # Notebooks, docs, and the offline catalog-build script (not needed to serve).
12
+ *.ipynb
13
+ *.md
14
+ build_full_catalog.py
15
+
16
+ # Git / editor
17
+ .git/
18
+ .gitignore
19
+ .vscode/
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ artifacts_improved8/* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # CL-EPIDTN recommender model API (improved_8) — CPU-only FastAPI/Uvicorn image.
4
+ FROM python:3.11-slim
5
+
6
+ # --- Environment ---------------------------------------------------------
7
+ ENV PYTHONUNBUFFERED=1 \
8
+ PYTHONDONTWRITEBYTECODE=1 \
9
+ PIP_NO_CACHE_DIR=1 \
10
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
11
+ PORT=7749 \
12
+ ARTIFACTS_DIR=artifacts_improved8 \
13
+ # Bake the Hugging Face cache into the image so the text encoder used by
14
+ # /catalog/add is available offline and without a runtime download.
15
+ HF_HOME=/app/hf_cache
16
+
17
+ WORKDIR /app
18
+
19
+ # --- System dependencies -------------------------------------------------
20
+ # build-essential covers any package without a prebuilt wheel; curl powers the healthcheck.
21
+ RUN apt-get update \
22
+ && apt-get install -y --no-install-recommends build-essential curl \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ # --- Python dependencies -------------------------------------------------
26
+ # CPU-only torch first (the GPU build is huge and unnecessary for serving),
27
+ # then the rest of the dependencies from PyPI.
28
+ RUN pip install --index-url https://download.pytorch.org/whl/cpu torch==2.6.0
29
+
30
+ COPY requirements.docker.txt ./
31
+ RUN pip install -r requirements.docker.txt
32
+
33
+ # Pre-download the text encoder used for catalog hot-add so the container does
34
+ # not need to fetch it from Hugging Face at runtime.
35
+ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')"
36
+
37
+ # --- Model artifacts -----------------------------------------------------
38
+ # The ~1.2 GB artifacts exceed the Space repo storage limit, so they are NOT
39
+ # bundled. Pull them from the public model repo at build time instead. This
40
+ # layer is placed before COPY so code changes don't re-trigger the download.
41
+ ENV MODEL_REPO=zeyadgamal00/CL-EPIDTN
42
+ RUN python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='$MODEL_REPO', repo_type='model', allow_patterns=['artifacts_improved8/**'], local_dir='/app')"
43
+
44
+ # --- Application ---------------------------------------------------------
45
+ # Copies the API + model code only (artifacts already downloaded above; the
46
+ # artifacts dir is excluded from the build context via .dockerignore).
47
+ COPY . .
48
+
49
+ EXPOSE 7749
50
+
51
+ # /health reports model_loaded once artifacts finish loading at startup.
52
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=180s --retries=3 \
53
+ CMD curl -fsS http://localhost:${PORT}/health || exit 1
54
+
55
+ CMD ["sh", "-c", "uvicorn recommender_api_improved8:app --host 0.0.0.0 --port ${PORT}"]
README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Questro Recommender Model API
3
+ emoji: 🎬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7749
8
+ pinned: false
9
+ ---
10
+
11
+ # Questro Recommender Model API (CL-EPIDTN, improved_8)
12
+
13
+ FastAPI service that scores and re-ranks movie/game candidates for the Questro
14
+ RAG pipeline. It exposes `/recommend`, `/recommend/rerank`, `/catalog/add`,
15
+ `/genres`, and `/health` on port **7749**.
16
+
17
+ The full catalog (189,753 items) is baked into the artifacts in
18
+ `artifacts_improved8/`, so the model never needs to hot-add items at runtime.
19
+ See [RECOMMENDER_API_IMPROVED8_DOCS.md](RECOMMENDER_API_IMPROVED8_DOCS.md) for
20
+ the API reference and `build_full_catalog.py` for how the catalog is rebuilt.
RECOMMENDER_API_IMPROVED8_DOCS.md ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Questro Recommender API — Integration Guide (improved_8)
2
+
3
+ > **Engine generation**: `improved_8` (architecture file `cl_epidtn_recommender_improved_8.py`, artifacts dir `artifacts_improved8/`, checkpoint `improved_8epochs.pt`)
4
+ > **Runtime `model_version`**: `improved_8` (echoed in every response)
5
+ > **Base URL**: `http://<ML_HOST>:7749`
6
+ > **Protocol**: REST / JSON (FastAPI)
7
+ > **Auth**: None (internal network only)
8
+
9
+ ---
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Start the server
15
+ uvicorn recommender_api_improved8:app --host 0.0.0.0 --port 7749
16
+
17
+ # Health check
18
+ curl http://localhost:7749/health
19
+
20
+ # Get recommendations (star ratings)
21
+ curl -X POST http://localhost:7749/recommend \
22
+ -H "Content-Type: application/json" \
23
+ -d '{
24
+ "user": {
25
+ "ratings": [
26
+ {"item_id": "game_271590", "title": "Grand Theft Auto V", "stars": 5.0},
27
+ {"item_id": "movie_155", "title": "The Dark Knight", "stars": 4.5}
28
+ ]
29
+ },
30
+ "k": 10,
31
+ "domain": "game",
32
+ "blocked_genres": ["Horror", "War"]
33
+ }'
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Endpoints
39
+
40
+ | Method | Path | Purpose |
41
+ |--------|------|---------|
42
+ | `GET` | `/health` | Liveness + model-loaded status |
43
+ | `GET` | `/genres` | List blockable genres/tags (for the parental-controls UI) |
44
+ | `POST` | `/recommend` | Personalised recommendations with pagination + genre blocking |
45
+ | `POST` | `/recommend/rerank` | Re-rank a RAG-fetched candidate list (RAG tool) |
46
+ | `POST` | `/catalog/add` | Hot-add cold-start items at runtime |
47
+ | `POST` | `/admin/reload` | Reload artifacts from disk (clears hot-added items) |
48
+
49
+ ---
50
+
51
+ ### 1. `GET /health`
52
+
53
+ Health check — verify the model is loaded before sending requests.
54
+
55
+ **Response:**
56
+
57
+ ```json
58
+ {
59
+ "status": "ok",
60
+ "model_loaded": true,
61
+ "n_items": 138541,
62
+ "n_genres_tracked": 138541,
63
+ "text_index_loaded": true,
64
+ "model_version": "improved_8",
65
+ "hot_added_count": 0
66
+ }
67
+ ```
68
+
69
+ | Field | Type | Description |
70
+ |-------|------|-------------|
71
+ | `status` | `string` | Always `"ok"` when the server responds |
72
+ | `model_loaded` | `bool` | `false` during startup — wait until `true` |
73
+ | `n_items` | `int` | Total items in catalog (movies + games) |
74
+ | `n_genres_tracked` | `int` | Items with genre/tag data for blocking |
75
+ | `text_index_loaded` | `bool` | Whether text embeddings are available (enhances quality) |
76
+ | `model_version` | `string` | Engine version string (`"improved_8"`) |
77
+ | `hot_added_count` | `int` | Items added at runtime via `/catalog/add` since the last load |
78
+
79
+ ---
80
+
81
+ ### 2. `POST /recommend`
82
+
83
+ **The main endpoint.** Send a user profile and get personalised, genre-filtered recommendations with pagination.
84
+
85
+ #### Request Body
86
+
87
+ ```json
88
+ {
89
+ "user": {
90
+ "age": 21,
91
+ "gender": "Male",
92
+ "profession": "Student",
93
+ "country": "Egypt",
94
+ "movie_genres_fav": "Action|Adventure|Comedy",
95
+ "movie_genres_disliked": "Horror|War",
96
+ "game_genres_fav": "Action|RPG|Shooter",
97
+ "game_genres_disliked": "Card|Educational",
98
+ "ratings": [
99
+ {"item_id": "game_271590", "title": "Grand Theft Auto V", "type": "game", "rating": "5 Stars"},
100
+ {"item_id": "movie_155", "title": "The Dark Knight", "stars": 4.5},
101
+ {"item_id": "movie_680", "source": "wishlist"},
102
+ {"item_id": "game_99999", "source": "ignore"}
103
+ ]
104
+ },
105
+ "k": 10,
106
+ "offset": 0,
107
+ "domain": "movie",
108
+ "blocked_genres": ["Horror", "Crime"]
109
+ }
110
+ ```
111
+
112
+ #### User Profile Fields
113
+
114
+ | Field | Type | Required | Description |
115
+ |-------|------|----------|-------------|
116
+ | `age` | `int` | No | User's age (1–120) |
117
+ | `gender` | `string` | No | Gender |
118
+ | `profession` | `string` | No | Profession / occupation |
119
+ | `country` | `string` | No | Country |
120
+ | `movie_genres_fav` | `string` | No | Pipe-separated favourite movie genres |
121
+ | `movie_genres_disliked` | `string` | No | Pipe-separated disliked movie genres |
122
+ | `game_genres_fav` | `string` | No | Pipe-separated favourite game genres |
123
+ | `game_genres_disliked` | `string` | No | Pipe-separated disliked game genres |
124
+ | `ratings` | `RatingItem[]` | ✅ Yes | At least 1 rating (see below) |
125
+
126
+ #### Rating Item Fields
127
+
128
+ Each item in `ratings` supports **three input modes** — use whichever is convenient:
129
+
130
+ | Field | Type | Description |
131
+ |-------|------|-------------|
132
+ | `item_id` | `string` | **Required.** Format: `"movie_{id}"`, `"movie:{id}"`, `"game_{id}"`, or `"game:{id}"` |
133
+ | `title` | `string` | Optional, for logging |
134
+ | `type` | `"movie" \| "game"` | Optional domain hint (inferred from `item_id` prefix if omitted) |
135
+ | `rating` | `string` | **Mode 1**: Survey label (see table below) |
136
+ | `stars` | `float` | **Mode 2**: Numeric rating 1.0–5.0 |
137
+ | `source` | `string` | **Mode 3**: `"rating"`, `"wishlist"`, or `"ignore"` |
138
+
139
+ #### Rating Labels Reference
140
+
141
+ | Label | Numeric Equivalent | Weight |
142
+ |-------|--------------------|--------|
143
+ | `"5 Stars"` | 5.0 | +1.0 |
144
+ | `"4 Stars"` | 4.0 | +0.5 |
145
+ | `"Didn't watch but would watch"` | 3.5 | +0.25 |
146
+ | `"Didn't play but would play"` | 3.5 | +0.25 |
147
+ | `"3 Stars"` | 3.0 | 0.0 |
148
+ | `"2 Stars"` | 2.0 | −0.5 |
149
+ | `"Didn't watch and wouldn't watch"` | 1.5 | −0.75 |
150
+ | `"Didn't play and wouldn't play"` | 1.5 | −0.75 |
151
+ | `"1 Star"` | 1.0 | −1.0 |
152
+
153
+ > Numeric `stars` are mapped with `weight = clamp((stars − 3) / 2, −1, 1)`. An item with no `rating`, `stars`, or `source` defaults to a mild positive (+0.25).
154
+
155
+ #### Source Signals
156
+
157
+ | `source` value | Meaning | Equivalent Label |
158
+ |---|---|---|
159
+ | `"wishlist"` | User saved / wishlisted the item | "Didn't watch but would watch" (+0.25) |
160
+ | `"ignore"` | User blocked / ignored the item | "Didn't watch and wouldn't watch" (−0.75) |
161
+ | `"rating"` or `null` | Normal rating — uses `rating` or `stars` field | — |
162
+
163
+ #### Request Parameters
164
+
165
+ | Field | Type | Required | Default | Description |
166
+ |-------|------|----------|---------|-------------|
167
+ | `k` | `int` | No | `10` | Results per page (1–100) |
168
+ | `offset` | `int` | No | `0` | Pagination offset. Page 1 = 0, page 2 = k, etc. |
169
+ | `domain` | `string \| null` | No | `null` | `"movie"`, `"game"`, or `null` for cross-domain |
170
+ | `blocked_genres` | `string[] \| null` | No | `null` | Genres/tags to exclude (case-insensitive) |
171
+
172
+ #### Response Body
173
+
174
+ ```json
175
+ {
176
+ "count": 10,
177
+ "total_available": 85,
178
+ "domain": "movie",
179
+ "offset": 0,
180
+ "k": 10,
181
+ "recommendations": [
182
+ {
183
+ "item_id": 157336,
184
+ "item_key": "movie:157336",
185
+ "title": "Interstellar (2014)",
186
+ "domain": "movie",
187
+ "score": 0.872451
188
+ }
189
+ ],
190
+ "signals_used": 3,
191
+ "blocked_genres": ["crime", "horror"],
192
+ "model_version": "improved_8",
193
+ "has_more": true
194
+ }
195
+ ```
196
+
197
+ | Field | Type | Description |
198
+ |-------|------|-------------|
199
+ | `count` | `int` | Items in this page |
200
+ | `total_available` | `int` | Total results available (after genre filtering) |
201
+ | `domain` | `string \| null` | Echoes the requested domain filter |
202
+ | `offset` | `int` | Current offset |
203
+ | `k` | `int` | Requested page size |
204
+ | `has_more` | `bool` | `true` if more pages exist beyond this one |
205
+ | `recommendations[].item_id` | `int \| null` | **Backend provider ID: TMDB ID for movies, RAWG ID for games.** `null` if the catalog row has no provider ID. |
206
+ | `recommendations[].item_key` | `string` | Internal key: `movie:{id}` or `game:{id}` |
207
+ | `recommendations[].title` | `string` | Human-readable title |
208
+ | `recommendations[].domain` | `string` | `"movie"` or `"game"` |
209
+ | `recommendations[].score` | `float` | Relevance score, rounded to 6 dp (higher = better) |
210
+ | `signals_used` | `int` | How many submitted ratings mapped to the catalog |
211
+ | `blocked_genres` | `string[]` | Genres that were blocked (lowercased) |
212
+ | `model_version` | `string` | `"improved_8"` |
213
+
214
+ > **Resolve display data from `item_id`** (the TMDB/RAWG provider ID) against your own Movies/Games tables. Use `item_key` only when you need the model's internal identifier.
215
+
216
+ > **Pagination example:**
217
+ > - Page 1: `{"k": 10, "offset": 0}` → items 1–10
218
+ > - Page 2: `{"k": 10, "offset": 10}` → items 11–20
219
+ > - Stop when `has_more` is `false`
220
+
221
+ ---
222
+
223
+ ### 3. `POST /recommend/rerank` — RAG Tool
224
+
225
+ **Re-rank a pre-fetched candidate list** using the recommender model. Use this as a tool in your RAG pipeline.
226
+
227
+ #### Workflow
228
+
229
+ ```
230
+ 1. RAG retrieves a broad list of candidate items (e.g. "top 50 sci-fi games")
231
+ 2. POST them to /recommend/rerank with the user's profile
232
+ 3. The recommender scores each candidate against the user's taste
233
+ 4. Items are returned ranked by personalised relevance
234
+ 5. Blocked genres are filtered out; the user's own history items are removed
235
+ ```
236
+
237
+ #### Request Body
238
+
239
+ ```json
240
+ {
241
+ "user": {
242
+ "ratings": [
243
+ {"item_id": "game_271590", "stars": 5.0},
244
+ {"item_id": "movie_155", "stars": 4.0}
245
+ ]
246
+ },
247
+ "candidate_items": [
248
+ {"item_id": "game_1091500", "title": "Cyberpunk 2077"},
249
+ {"item_id": "game_292030", "title": "The Witcher 3"},
250
+ {"item_id": "game_374320", "title": "Dark Souls III"},
251
+ {"item_id": "movie_27205", "title": "Inception"}
252
+ ],
253
+ "blocked_genres": ["Horror"],
254
+ "k": 3
255
+ }
256
+ ```
257
+
258
+ | Field | Type | Required | Default | Description |
259
+ |-------|------|----------|---------|-------------|
260
+ | `user` | `UserProfile` | ✅ Yes | — | Same user profile as `/recommend` |
261
+ | `candidate_items` | `CandidateItem[]` | ✅ Yes | — | Items to score (at least 1) |
262
+ | `candidate_items[].item_id` | `string` | ✅ Yes | — | `"movie_{id}"` / `"movie:{id}"` / `"game_{id}"` / `"game:{id}"` |
263
+ | `candidate_items[].title` | `string` | No | — | Optional title |
264
+ | `blocked_genres` | `string[] \| null` | No | `null` | Genres/tags to block |
265
+ | `k` | `int \| null` | No | `null` | Max results. `null` = return all ranked |
266
+
267
+ #### Response Body
268
+
269
+ ```json
270
+ {
271
+ "count": 3,
272
+ "recommendations": [
273
+ {"item_id": 1091500, "item_key": "game:1091500", "title": "Cyberpunk 2077", "domain": "game", "score": 0.91},
274
+ {"item_id": 292030, "item_key": "game:292030", "title": "The Witcher 3", "domain": "game", "score": 0.87},
275
+ {"item_id": 27205, "item_key": "movie:27205", "title": "Inception", "domain": "movie", "score": 0.83}
276
+ ],
277
+ "signals_used": 2,
278
+ "candidates_submitted": 4,
279
+ "candidates_matched": 4,
280
+ "blocked_genres": ["horror"],
281
+ "model_version": "improved_8"
282
+ }
283
+ ```
284
+
285
+ | Field | Type | Description |
286
+ |-------|------|-------------|
287
+ | `count` | `int` | Final results after filtering |
288
+ | `recommendations[]` | `object[]` | Same shape as `/recommend` (`item_id`, `item_key`, `title`, `domain`, `score`) |
289
+ | `signals_used` | `int` | User ratings that mapped to the catalog |
290
+ | `candidates_submitted` | `int` | How many candidates you sent |
291
+ | `candidates_matched` | `int` | How many were found in the model catalog |
292
+ | `blocked_genres` | `string[]` | Genres that were blocked (lowercased) |
293
+ | `model_version` | `string` | `"improved_8"` |
294
+
295
+ > **Graceful FAISS fallback.** If none of the candidates can be scored by the model — e.g. they are all hot-added items whose indices fall outside the trained embedding range, or a transient CUDA error occurs during scoring — the endpoint returns `count: 0` with an empty `recommendations` list **instead of erroring**. The RAG pipeline should treat an empty rerank result as "fall back to the original FAISS similarity order."
296
+
297
+ ---
298
+
299
+ ### 4. `POST /catalog/add` — Hot-Add Items
300
+
301
+ **Register new items into the running catalog dynamically**, so the recommender can score items it has never been trained on. Ideal for newly released titles or dynamic RAG pipelines.
302
+
303
+ #### Workflow
304
+
305
+ ```
306
+ 1. RAG retrieves items from an external DB the ML model wasn't trained on
307
+ 2. POST the missing items to /catalog/add with metadata/text
308
+ 3. The server expands its tensors and (if sentence-transformers is installed and a
309
+ text index is loaded) computes text embeddings so the items can be scored (0-shot)
310
+ 4. The items can now be returned by /recommend or /recommend/rerank
311
+ ```
312
+
313
+ #### Request Body
314
+
315
+ ```json
316
+ {
317
+ "items": [
318
+ {
319
+ "item_id": "game_1091500",
320
+ "title": "Cyberpunk 2077",
321
+ "domain": "game",
322
+ "description": "An open-world, action-adventure story set in Night City...",
323
+ "genres": "Action|RPG",
324
+ "tags": "sci-fi|open world|cyberpunk",
325
+ "provider_id": 1091500
326
+ }
327
+ ]
328
+ }
329
+ ```
330
+
331
+ | Field | Type | Required | Default | Description |
332
+ |-------|------|----------|---------|-------------|
333
+ | `items` | `CatalogNewItem[]` | ✅ Yes | — | 1–500 items per request |
334
+ | `items[].item_id` | `string` | ✅ Yes | — | `"movie_{id}"` / `"movie:{id}"` / `"game_{id}"` / `"game:{id}"` |
335
+ | `items[].title` | `string` | ✅ Yes | — | Human-readable title |
336
+ | `items[].domain` | `"movie" \| "game" \| null` | No | inferred from `item_id` | Optional domain override |
337
+ | `items[].description` | `string` | No | `""` | Synopsis (used for text embedding) |
338
+ | `items[].genres` | `string` | No | `""` | Pipe- or comma-separated genres, e.g. `"Action\|RPG"` |
339
+ | `items[].tags` | `string` | No | `""` | Pipe- or comma-separated tags |
340
+ | `items[].provider_id` | `int \| null` | No | `null` | TMDB ID for movies, RAWG ID for games — surfaced as `item_id` in later responses |
341
+
342
+ #### Response Body
343
+
344
+ ```json
345
+ {
346
+ "added": ["game:1091500"],
347
+ "already_exists": [],
348
+ "failed": {},
349
+ "n_items": 138542,
350
+ "text_index_updated": true
351
+ }
352
+ ```
353
+
354
+ | Field | Type | Description |
355
+ |-------|------|-------------|
356
+ | `added` | `string[]` | Internal keys (`domain:id`) that were registered this call |
357
+ | `already_exists` | `string[]` | Keys that were already in the catalog (skipped) |
358
+ | `failed` | `object` | Map of `item_id` → error reason for items that could not be added |
359
+ | `n_items` | `int` | Total catalog size after the operation |
360
+ | `text_index_updated` | `bool` | `true` if text embeddings were computed and written for the new items |
361
+
362
+ > **Notes.** Hot-added items are **runtime-only** — they are cleared on `/admin/reload` or a server restart. They start with zero *learned* collaborative embeddings and rank primarily through text similarity, which requires `sentence-transformers` to be installed and a text index to be loaded; otherwise `text_index_updated` is `false`. Items whose new index exceeds the model's trained embedding bounds are kept in the catalog (and still visible to FAISS/RAG) but are skipped by the model's scoring path in `/recommend/rerank`.
363
+
364
+ ---
365
+
366
+ ### 5. `GET /genres`
367
+
368
+ List all genres/tags available for blocking. Use this to populate the parental-controls UI.
369
+
370
+ **Response:**
371
+
372
+ ```json
373
+ {
374
+ "genres": ["action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "fantasy", "horror", "indie", "mystery", "rpg", "racing", "romance", "sci-fi", "shooter", "simulation", "sports", "strategy", "thriller", "war", "western"]
375
+ }
376
+ ```
377
+
378
+ > All genres are **lowercased** and limited to tokens longer than 2 characters. When sending `blocked_genres`, any casing works — the API normalises everything to lowercase.
379
+
380
+ ---
381
+
382
+ ### 6. `POST /admin/reload`
383
+
384
+ Reload all on-disk artifacts (model, indices, metadata). **Runtime hot-added items are intentionally cleared** so the server returns to the persisted artifact state.
385
+
386
+ **Response:**
387
+
388
+ ```json
389
+ {
390
+ "status": "ok",
391
+ "n_items": 138541,
392
+ "text_index_loaded": true,
393
+ "hot_added_count": 0
394
+ }
395
+ ```
396
+
397
+ | Field | Type | Description |
398
+ |-------|------|-------------|
399
+ | `status` | `string` | `"ok"` on success |
400
+ | `n_items` | `int` | Catalog size after reload |
401
+ | `text_index_loaded` | `bool` | Whether text embeddings were reloaded |
402
+ | `hot_added_count` | `int` | Reset to `0` after reload |
403
+
404
+ ---
405
+
406
+ ## How Recommendations Are Scored
407
+
408
+ A few behaviours are worth knowing when integrating:
409
+
410
+ - **Over-fetch before filtering.** When `blocked_genres` is set, the engine fetches `OVERFETCH_MULTIPLIER` × more candidates (default **5×**) before applying genre filtering, so a full page of `k` results is still returned after blocked items are removed.
411
+ - **Title-family franchise boost.** For profiles seeded with a clear franchise (e.g. *Grand Theft Auto V*), the engine adds extra same-franchise candidates and applies a score boost (`TITLE_FAMILY_BOOST`, default **0.40**) so obvious franchise neighbours rank ahead of generic genre matches. Version/edition words ("Remastered", "GOTY", roman numerals, …) are stripped so sequels still match.
412
+ - **History exclusion.** In `/recommend/rerank`, candidates already present in the user's rating history are dropped before scoring.
413
+ - **Cold-start text scoring.** Hot-added items are embedded with a lightweight sentence encoder (`all-MiniLM-L6-v2`) so they can be scored by content similarity even though they have no learned collaborative vector.
414
+
415
+ ---
416
+
417
+ ## Integration Guide
418
+
419
+ ### Mapping Your IDs to the API
420
+
421
+ | Your Database | API `item_id` format | Example |
422
+ |---|---|---|
423
+ | Movie (TMDB ID) | `"movie_{TMDB_Id}"` | `"movie_155"` |
424
+ | Movie (MovieLens ID) | `"movie_{movieId}"` | `"movie_1199"` |
425
+ | Game (Steam App ID) | `"game_{app_id}"` | `"game_271590"` |
426
+
427
+ > Responses return the provider ID in the `item_id` field — **TMDB ID for movies, RAWG ID for games** — alongside the internal `item_key`.
428
+
429
+ ### Converting Your Signals to API Ratings
430
+
431
+ | User Action in Your App | API Rating |
432
+ |---|---|
433
+ | Rated 1–5 stars | `{"item_id": "...", "stars": 4.0}` |
434
+ | Liked | `{"item_id": "...", "stars": 4.0}` |
435
+ | Watched / Played | `{"item_id": "...", "stars": 4.0}` |
436
+ | Wishlisted / Saved | `{"item_id": "...", "source": "wishlist"}` |
437
+ | Ignored / Blocked | `{"item_id": "...", "source": "ignore"}` |
438
+ | Disliked | `{"item_id": "...", "stars": 1.0}` |
439
+
440
+ ### Parental Controls
441
+
442
+ Send `blocked_genres` with every request for child accounts. Populate the picker from `GET /genres`.
443
+
444
+ | Restriction Level | `blocked_genres` |
445
+ |---|---|
446
+ | Child-safe | `["horror", "crime", "war", "thriller"]` |
447
+ | Teen-safe | `["horror"]` |
448
+ | No restrictions | `null` or `[]` |
449
+
450
+ The API guarantees:
451
+ - **No item with a blocked genre/tag will appear** in results.
452
+ - The **requested `k` items are returned** after filtering (the model over-fetches internally).
453
+ - **Case-insensitive** matching: `"Horror"`, `"HORROR"`, `"horror"` are all equivalent.
454
+
455
+ ### Typical Backend Flow
456
+
457
+ ```
458
+ 1. User opens "For You" page
459
+ 2. Backend queries its own DB for user's ratings/likes/watches/wishlists/ignores
460
+ 3. Backend maps each item to {item_id, stars/source}
461
+ 4. Backend reads the user's parental control settings → blocked_genres
462
+ 5. Backend POSTs to /recommend with offset=0, k=20
463
+ 6. Backend receives item_id values (TMDB/RAWG provider IDs)
464
+ 7. Backend looks up those IDs in its own Movies/Games table
465
+ 8. Backend returns rich item data (posters, descriptions) to frontend
466
+ 9. On scroll / "Load More", backend POSTs again with offset=20
467
+ ```
468
+
469
+ ### RAG Tool Integration
470
+
471
+ ```
472
+ 1. User asks chatbot: "Recommend me games like Skyrim"
473
+ 2. RAG retrieves top 50 RPG games from your vector DB
474
+ 3. (Optional) RAG hot-adds any items missing from the model via /catalog/add
475
+ 4. RAG calls POST /recommend/rerank with the user profile + 50 candidates
476
+ 5. API returns the items re-ranked by the user's personal taste
477
+ (empty list ⇒ fall back to the original FAISS order)
478
+ 6. RAG picks top 5 and formats the response
479
+ ```
480
+
481
+ ---
482
+
483
+ ## C# Backend Integration Example
484
+
485
+ ```csharp
486
+ public class RecommenderClient
487
+ {
488
+ private readonly HttpClient _http;
489
+ private readonly string _baseUrl;
490
+
491
+ public RecommenderClient(string baseUrl)
492
+ {
493
+ _baseUrl = baseUrl.TrimEnd('/');
494
+ _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
495
+ }
496
+
497
+ public async Task<RecommendResponse?> GetRecommendationsAsync(
498
+ UserProfile user,
499
+ int k = 10,
500
+ int offset = 0,
501
+ string? domain = null,
502
+ List<string>? blockedGenres = null)
503
+ {
504
+ var request = new
505
+ {
506
+ user,
507
+ k,
508
+ offset,
509
+ domain,
510
+ blocked_genres = blockedGenres
511
+ };
512
+
513
+ var json = JsonSerializer.Serialize(request,
514
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
515
+
516
+ var response = await _http.PostAsync(
517
+ $"{_baseUrl}/recommend",
518
+ new StringContent(json, Encoding.UTF8, "application/json"));
519
+
520
+ response.EnsureSuccessStatusCode();
521
+ return await response.Content.ReadFromJsonAsync<RecommendResponse>();
522
+ }
523
+
524
+ public async Task<RerankResponse?> RerankAsync(
525
+ UserProfile user,
526
+ List<CandidateItem> candidates,
527
+ int? k = null,
528
+ List<string>? blockedGenres = null)
529
+ {
530
+ var request = new
531
+ {
532
+ user,
533
+ candidate_items = candidates,
534
+ k,
535
+ blocked_genres = blockedGenres
536
+ };
537
+
538
+ var json = JsonSerializer.Serialize(request,
539
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
540
+
541
+ var response = await _http.PostAsync(
542
+ $"{_baseUrl}/recommend/rerank",
543
+ new StringContent(json, Encoding.UTF8, "application/json"));
544
+
545
+ response.EnsureSuccessStatusCode();
546
+ return await response.Content.ReadFromJsonAsync<RerankResponse>();
547
+ }
548
+ }
549
+
550
+ // Each recommendation exposes item_id (TMDB/RAWG provider id), item_key, title, domain, score.
551
+ // Usage — recommendations with pagination:
552
+ var user = BuildUserProfile(userId); // your helper
553
+ var page1 = await _recommender.GetRecommendationsAsync(
554
+ user, k: 20, offset: 0,
555
+ domain: "movie",
556
+ blockedGenres: userSettings.BlockedGenres);
557
+
558
+ // Show page1.Recommendations to user...
559
+
560
+ if (page1.HasMore)
561
+ {
562
+ var page2 = await _recommender.GetRecommendationsAsync(
563
+ user, k: 20, offset: 20,
564
+ domain: "movie",
565
+ blockedGenres: userSettings.BlockedGenres);
566
+ }
567
+
568
+ // Usage — RAG reranking:
569
+ var ragResults = await _vectorDb.SearchAsync("sci-fi games", top: 50);
570
+ var candidates = ragResults.Select(r => new CandidateItem
571
+ {
572
+ ItemId = $"game_{r.SteamAppId}",
573
+ Title = r.Title
574
+ }).ToList();
575
+
576
+ var reranked = await _recommender.RerankAsync(
577
+ user, candidates, k: 5,
578
+ blockedGenres: userSettings.BlockedGenres);
579
+ ```
580
+
581
+ ---
582
+
583
+ ## Error Responses
584
+
585
+ | Code | When |
586
+ |------|------|
587
+ | `422` | No ratings provided, all items unmapped, all candidates already in history, invalid rating label, stars out of range |
588
+ | `503` | Model / catalog still loading (check `/health` first) |
589
+
590
+ > Note: an all-unscoreable `/recommend/rerank` request is **not** an error — it returns `200` with `count: 0` so the caller can fall back to FAISS scores.
591
+
592
+ ---
593
+
594
+ ## Deployment Notes
595
+
596
+ | Item | Value |
597
+ |------|-------|
598
+ | **Server start** | `uvicorn recommender_api_improved8:app --host 0.0.0.0 --port 7749` |
599
+ | **Required files** | `recommender_api_improved8.py`, `cl_epidtn_recommender_improved_8.py` |
600
+ | **Artifacts dir** | `./artifacts_improved8/` containing: `improved_8epochs.pt`, `item_index.pt`, `item_to_idx.pkl`, `item_meta.pkl`, `title_lookup.pkl`, `improved_item_text_embeddings.pt` |
601
+ | **Dependencies** | `pip install torch fastapi uvicorn pydantic pandas` (plus `sentence-transformers` for cold-start text scoring) |
602
+ | **Text encoder** | `sentence-transformers/all-MiniLM-L6-v2` (cold-start hot-add embeddings) |
603
+ | **Swagger docs** | `http://<HOST>:7749/docs` (auto-generated) |
604
+ | **Cold start time** | ~15–30 seconds (model + text embeddings loading) |
605
+ | **Avg response time** | ~50–150ms per request |
606
+
607
+ ### Configurable Environment Variables
608
+
609
+ | Variable | Default | Purpose |
610
+ |----------|---------|---------|
611
+ | `ARTIFACTS_DIR` | `artifacts_improved8` | Base directory for artifacts |
612
+ | `MODEL_CHECKPOINT` | `<dir>/improved_8epochs.pt` | Trained model checkpoint |
613
+ | `TEXT_EMBEDDINGS_PATH` | `<dir>/improved_item_text_embeddings.pt` | Optional content embeddings |
614
+ | `TEXT_ENCODER_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Cold-start text encoder |
615
+ | `MAX_RECS` | `100` | Hard cap on internal fetch size |
616
+ | `OVERFETCH_MULTIPLIER` | `5` | Extra candidates fetched before genre filtering |
617
+ | `TITLE_FAMILY_BOOST` | `0.40` | Score boost for same-franchise neighbours |
618
+ | `TITLE_FAMILY_EXTRA_CANDIDATES` | `50` | Max extra franchise candidates injected |
619
+
620
+ ---
621
+
622
+ ## FAQ
623
+
624
+ **Q: What if a user has no ratings yet?**
625
+ A: The API requires at least 1 rating. For new users, show popular/trending items until they interact with something, then call `/recommend`.
626
+
627
+ **Q: What if a submitted item_id isn't in the model catalog?**
628
+ A: It's silently skipped. Check `signals_used` in the response — if it's 0, none of the items were found (the endpoint returns `422`).
629
+
630
+ **Q: Can I mix stars and survey labels in the same request?**
631
+ A: Yes. Each rating item is independent. You can use `stars` for some, `rating` labels for others, and `source: "wishlist"/"ignore"` for others.
632
+
633
+ **Q: How does pagination work?**
634
+ A: Set `offset` and `k`. Page 1 = `offset: 0, k: 20`, page 2 = `offset: 20, k: 20`. The `has_more` field tells you if another page exists. `total_available` gives the total count after filtering.
635
+
636
+ **Q: Are blocked genres guaranteed to be excluded?**
637
+ A: Yes. The API over-fetches internally and filters BEFORE paginating, so you always get `k` results (or fewer only if the entire filtered catalog is exhausted).
638
+
639
+ **Q: Why does `/recommend/rerank` sometimes return an empty list?**
640
+ A: When none of the candidates can be scored by the model (all hot-added/out-of-range, or a transient CUDA error). This is intentional — treat it as a signal to keep the RAG's original FAISS ordering.
641
+
642
+ **Q: Is the API stateless?**
643
+ A: User data is stateless — no per-user data is stored server-side. The **catalog** is mutable, though: `/catalog/add` mutates in-memory state until `/admin/reload` or a restart.
644
+
645
+ **Q: What's the rerank endpoint for?**
646
+ A: It's designed as a tool for your RAG/chatbot pipeline. Your RAG retrieves a broad candidate list (e.g. "top 50 RPG games"), and the recommender personalises the ranking for the specific user.
artifacts_improved8/.gitkeep ADDED
File without changes
artifacts_improved8/improved_8epochs.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0bdceb5b545ce6bfeda28be97f93769d0957842eee4a51209ba6017fac32b319
3
+ size 690839178
artifacts_improved8/improved_item_text_embeddings.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9f09e5470a8246f26f411fea490387e45159626d27848a5933bb3767800696b
3
+ size 228876170
artifacts_improved8/item_index.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40747a1d12d31021d0e6bd0e8f1cf4c1cb7c8a3109227bcb01db92c347eecd96
3
+ size 76292779
artifacts_improved8/item_meta.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2ab33ead574f9e43dced91fdae3beba8823dee31f3d2df796ea65ef072aa05be
3
+ size 211305749
artifacts_improved8/item_to_idx.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1135db845f3a67af749339d760c3c91eebdd3bed2af4627353893cca0d6623d5
3
+ size 2774245
artifacts_improved8/title_lookup.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:351cc07b4eb6aa519df088b5c6467ed9bbf9d8ec2bb0920b6ae253478a5bb8b1
3
+ size 4378344
cl_epidtn_recommender_improved_8.py ADDED
@@ -0,0 +1,2299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ import random
6
+ import re
7
+ import time
8
+ import zipfile
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Iterable, Sequence
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+ from torch.utils.data import DataLoader, Dataset
20
+ from tqdm.auto import tqdm
21
+
22
+
23
+ PAD = 0
24
+
25
+ _METADATA_STOP_WORDS = frozenset({
26
+ "the", "and", "for", "with", "from", "that", "this", "your", "you",
27
+ "game", "movie", "film", "very", "positive", "mostly", "mixed",
28
+ "win", "steam", "deck",
29
+ })
30
+
31
+
32
+ @dataclass
33
+ class RecConfig:
34
+ movielens_zip: str = "ml-32m.zip"
35
+ steam_zip: str = "archive (11).zip"
36
+ max_seq_len: int = 30
37
+ min_movie_rating: float = 3.5
38
+ min_steam_hours: float = 1.0
39
+ max_movielens_rows: int | None = 2_000_000
40
+ max_steam_rows: int | None = 2_000_000
41
+ max_train_samples: int = 1_000_000
42
+ min_user_events: int = 4
43
+ min_item_interactions: int = 5
44
+ embedding_dim: int = 128
45
+ transformer_layers: int = 5
46
+ attention_heads: int = 4
47
+ dropout: float = 0.15
48
+ batch_size: int = 512
49
+ epochs: int = 10
50
+ lr: float = 2e-3
51
+ temperature: float = 0.07
52
+ contrastive_weight: float = 0.15
53
+ amm_weight: float = 0.01
54
+ gradient_clip_norm: float = 1.0
55
+ use_causal_attention: bool = False
56
+ hf_cache_dir: str = "hf_cache"
57
+ hf_home_dir: str = "hf_home"
58
+ survey_csv: str = "users_ratings.csv"
59
+ device: str = "cuda" if torch.cuda.is_available() else "cpu"
60
+
61
+
62
+ def set_seed(seed: int = 42) -> None:
63
+ random.seed(seed)
64
+ np.random.seed(seed)
65
+ torch.manual_seed(seed)
66
+ torch.cuda.manual_seed_all(seed)
67
+
68
+
69
+ def _read_csv_from_zip(zip_path: str | Path, member: str, **kwargs) -> pd.DataFrame:
70
+ with zipfile.ZipFile(zip_path) as zf:
71
+ with zf.open(member) as fh:
72
+ return pd.read_csv(fh, **kwargs)
73
+
74
+
75
+ def load_movielens(cfg: RecConfig) -> tuple[pd.DataFrame, pd.DataFrame]:
76
+ ratings = _read_csv_from_zip(
77
+ cfg.movielens_zip,
78
+ "ml-32m/ratings.csv",
79
+ usecols=["userId", "movieId", "rating", "timestamp"],
80
+ nrows=cfg.max_movielens_rows,
81
+ )
82
+ ratings = ratings.loc[ratings["rating"] >= cfg.min_movie_rating].copy()
83
+ ratings["user_key"] = "ml:" + ratings["userId"].astype(str)
84
+ ratings["item_key"] = "movie:" + ratings["movieId"].astype(str)
85
+ ratings["domain"] = "movie"
86
+ interactions = ratings[["user_key", "item_key", "timestamp", "domain"]]
87
+
88
+ movies = _read_csv_from_zip(cfg.movielens_zip, "ml-32m/movies.csv")
89
+ links = _read_csv_from_zip(cfg.movielens_zip, "ml-32m/links.csv", usecols=["movieId", "tmdbId"])
90
+ movies = movies.merge(links, on="movieId", how="left")
91
+ tags = _read_movielens_tags(cfg.movielens_zip)
92
+ movies = movies.merge(tags, on="movieId", how="left")
93
+ movies["item_key"] = "movie:" + movies["movieId"].astype(str)
94
+ movies["domain"] = "movie"
95
+ movies["user_reviews"] = np.nan
96
+ movies["tmdb_id"] = movies["tmdbId"].astype("Int64")
97
+ if "is_adult" not in movies.columns:
98
+ movies["is_adult"] = False
99
+ movies["is_adult"] = movies["is_adult"].fillna(False).astype(bool)
100
+ movies["tokens"] = (
101
+ movies["genres"].fillna("").str.replace("|", " ", regex=False)
102
+ + " "
103
+ + movies["genres"].fillna("").map(_movie_genre_bridge_tokens)
104
+ + " "
105
+ + movies["tag_tokens"].fillna("")
106
+ + " "
107
+ + movies["title"].fillna("").map(_title_tokens)
108
+ )
109
+ movies["description"] = ""
110
+ items = movies[["item_key", "title", "domain", "tokens", "user_reviews", "description", "tmdb_id", "is_adult"]]
111
+ return interactions, items
112
+
113
+
114
+ def load_steam(cfg: RecConfig) -> tuple[pd.DataFrame, pd.DataFrame]:
115
+ recs = _read_csv_from_zip(
116
+ cfg.steam_zip,
117
+ "recommendations.csv",
118
+ usecols=["app_id", "is_recommended", "hours", "user_id", "date"],
119
+ nrows=cfg.max_steam_rows,
120
+ )
121
+ recs = recs.loc[recs["is_recommended"].astype(str).str.lower().eq("true")].copy()
122
+ # Filter out low-engagement recommendations (< min_steam_hours played)
123
+ recs["hours"] = pd.to_numeric(recs["hours"], errors="coerce").fillna(0)
124
+ recs = recs.loc[recs["hours"] >= cfg.min_steam_hours]
125
+ recs["timestamp"] = pd.to_datetime(recs["date"], errors="coerce").astype("int64") // 10**9
126
+ recs["timestamp"] = recs["timestamp"].fillna(0).astype("int64")
127
+ recs["user_key"] = "steam:" + recs["user_id"].astype(str)
128
+ recs["item_key"] = "game:" + recs["app_id"].astype(str)
129
+ recs["domain"] = "game"
130
+ interactions = recs[["user_key", "item_key", "timestamp", "domain"]]
131
+
132
+ games = _read_csv_from_zip(cfg.steam_zip, "games.csv")
133
+ if 'positive' in games.columns and 'negative' in games.columns:
134
+ total_reviews = pd.to_numeric(games['positive'], errors='coerce').fillna(0) + pd.to_numeric(games['negative'], errors='coerce').fillna(0)
135
+ games = games[total_reviews >= 5]
136
+ elif 'recommendations' in games.columns:
137
+ games = games[pd.to_numeric(games['recommendations'], errors='coerce').fillna(0) >= 5]
138
+
139
+ game_meta = _read_steam_metadata(cfg.steam_zip)
140
+ if not game_meta.empty:
141
+ games = games.merge(game_meta, on="app_id", how="left")
142
+ else:
143
+ games["tag_tokens"] = ""
144
+ games["description"] = ""
145
+ games["item_key"] = "game:" + games["app_id"].astype(str)
146
+ games["domain"] = "game"
147
+ games["tmdb_id"] = pd.NA
148
+ for _col in ("win", "mac", "linux", "steam_deck"):
149
+ if _col not in games.columns:
150
+ games[_col] = False
151
+ if "user_reviews" not in games.columns:
152
+ games["user_reviews"] = np.nan
153
+ if "is_adult" not in games.columns:
154
+ games["is_adult"] = False
155
+ games["is_adult"] = games["is_adult"].fillna(False).astype(bool)
156
+ platform_tokens = (
157
+ pd.Series(np.where(games["win"].fillna(False).astype(bool), " win", ""), index=games.index)
158
+ + np.where(games["mac"].fillna(False).astype(bool), " mac", "")
159
+ + np.where(games["linux"].fillna(False).astype(bool), " linux", "")
160
+ + np.where(games["steam_deck"].fillna(False).astype(bool), " steam_deck", "")
161
+ )
162
+ games["tokens"] = (
163
+ games["tag_tokens"].fillna("")
164
+ + " "
165
+ + games["description"].fillna("").map(lambda x: _text_tokens(x, limit=60))
166
+ + " "
167
+ + games["rating"].fillna("").astype(str).map(_text_tokens)
168
+ + " "
169
+ + games["positive_ratio"].fillna(0).astype(int).map(lambda x: f"posratio_{x // 10}")
170
+ + platform_tokens
171
+ + " "
172
+ + games["title"].fillna("").map(_title_tokens)
173
+ )
174
+ items = games[["item_key", "title", "domain", "tokens", "user_reviews", "description", "tmdb_id", "is_adult"]]
175
+ return interactions, items
176
+
177
+
178
+ def configure_hf_cache(hf_cache_dir: str | Path = "hf_cache", hf_home_dir: str | Path = "hf_home") -> None:
179
+ """Point Hugging Face libraries at the project-local caches."""
180
+ os.environ["HF_DATASETS_CACHE"] = str(Path(hf_cache_dir).resolve())
181
+ os.environ["HF_HOME"] = str(Path(hf_home_dir).resolve())
182
+
183
+
184
+ def _find_cached_arrow(cache_dir: str | Path, dataset_fragment: str, filename: str) -> Path:
185
+ cache = Path(cache_dir)
186
+ # Try exact filename under the dataset directory first
187
+ matches = list(cache.glob(f"**/*{dataset_fragment}*/**/{filename}"))
188
+ if not matches:
189
+ matches = list(cache.glob(f"**/{filename}"))
190
+ if not matches:
191
+ # Fallback: find any train arrow file under a directory matching the dataset
192
+ matches = list(cache.glob(f"**/*{dataset_fragment}*/**/*train*.arrow"))
193
+ if not matches:
194
+ raise FileNotFoundError(
195
+ f"Could not find {filename!r} (or any train arrow) for "
196
+ f"{dataset_fragment!r} under {cache_dir!s}. "
197
+ f"Run download_tmdb_hf_dataset() first."
198
+ )
199
+ return matches[0]
200
+
201
+
202
+ def _normalize_catalog_title(value: str) -> str:
203
+ value = re.sub(r"\((?:19|20)\d{2}\)\s*$", "", str(value))
204
+ return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
205
+
206
+
207
+ def _title_year(value: str) -> int | None:
208
+ match = re.search(r"\((19\d{2}|20\d{2})\)\s*$", str(value))
209
+ return int(match.group(1)) if match else None
210
+
211
+
212
+ def _pipe_text(value) -> str:
213
+ if value is None or (isinstance(value, float) and math.isnan(value)):
214
+ return ""
215
+ return _text_tokens(str(value).replace("|", " ").replace(",", " "))
216
+
217
+
218
+ def download_tmdb_hf_dataset(cache_dir: str | Path = "hf_cache") -> None:
219
+ """Download ada-datadruids/full_tmdb_movies_dataset to the local HF cache.
220
+
221
+ Only needs to run once. Safe to call again — HF caching is idempotent.
222
+
223
+ Requires: ``pip install datasets``
224
+ """
225
+ try:
226
+ from datasets import load_dataset
227
+ except ImportError as exc:
228
+ raise RuntimeError("Install `datasets` first: pip install datasets") from exc
229
+
230
+ cache_dir = Path(cache_dir).resolve()
231
+ cache_dir.mkdir(parents=True, exist_ok=True)
232
+ os.environ["HF_DATASETS_CACHE"] = str(cache_dir)
233
+ os.environ["HF_HOME"] = str(cache_dir)
234
+
235
+ print("[tmdb] downloading ada-datadruids/full_tmdb_movies_dataset …")
236
+ load_dataset("ada-datadruids/full_tmdb_movies_dataset", split="train", cache_dir=str(cache_dir))
237
+ print("[tmdb] download complete.")
238
+
239
+
240
+ def load_cached_hf_movies(cfg: RecConfig) -> pd.DataFrame:
241
+ """Load the cached ada-datadruids/full_tmdb_movies_dataset Arrow file.
242
+
243
+ Columns used from the dataset
244
+ -----------------------------
245
+ id - TMDB movie ID (int64)
246
+ title - movie title
247
+ overview - plot description
248
+ genres - pipe- or comma-separated genre string
249
+ keywords - comma-separated keyword string
250
+ tagline - short marketing tagline
251
+ vote_count - number of votes
252
+ vote_average - average rating (0-10)
253
+ popularity - TMDB popularity score
254
+ poster_path - relative poster URL path
255
+ release_date - release date string (YYYY-MM-DD)
256
+ """
257
+ configure_hf_cache(cfg.hf_cache_dir, cfg.hf_home_dir)
258
+ try:
259
+ from datasets import Dataset
260
+ except ImportError as exc:
261
+ raise RuntimeError("Install `datasets` to read the local Hugging Face Arrow cache.") from exc
262
+ arrow = _find_cached_arrow(
263
+ cfg.hf_cache_dir,
264
+ "ada-datadruids___full_tmdb_movies_dataset",
265
+ "full_tmdb_movies_dataset-train.arrow",
266
+ )
267
+ columns = [
268
+ "id",
269
+ "title",
270
+ "overview",
271
+ "genres",
272
+ "keywords",
273
+ "tagline",
274
+ "vote_count",
275
+ "vote_average",
276
+ "popularity",
277
+ "poster_path",
278
+ "release_date",
279
+ "original_language",
280
+ "adult",
281
+ ]
282
+ ds = Dataset.from_file(str(arrow))
283
+ fetch_cols = [c for c in columns if c in ds.column_names]
284
+ frame = ds.select_columns(fetch_cols).to_pandas()
285
+ frame["tmdb_id"] = pd.to_numeric(frame["id"], errors="coerce").astype("Int64")
286
+ frame["match_title"] = frame["title"].map(_normalize_catalog_title)
287
+ frame["match_year"] = pd.to_datetime(frame["release_date"], errors="coerce").dt.year.astype("Int64")
288
+ frame["vote_count"] = pd.to_numeric(frame.get("vote_count"), errors="coerce")
289
+ frame = frame[frame["vote_count"].fillna(0) >= 5]
290
+ frame["vote_average"] = pd.to_numeric(frame.get("vote_average"), errors="coerce")
291
+
292
+ adult_themes = frame.get('genres', '').astype(str).fillna('') + ", " + frame.get('keywords', '').astype(str).fillna('')
293
+ adult_themes = adult_themes.str.contains(r'\b(NSFW|Nudity|Sexual Content|Adult|sex)\b', case=False, na=False)
294
+ frame["hf_is_adult"] = frame.get("adult", pd.Series(False, index=frame.index)).fillna(False).astype(bool) | adult_themes
295
+
296
+ frame = frame.sort_values(["vote_count", "popularity"], ascending=False, na_position="last")
297
+ return frame.drop_duplicates(["match_title", "match_year"])
298
+
299
+
300
+ def load_cached_hf_rawg(cfg: RecConfig) -> pd.DataFrame:
301
+ """Load useful RAWG fields from the cached Arrow file using memory mapping."""
302
+ configure_hf_cache(cfg.hf_cache_dir, cfg.hf_home_dir)
303
+ try:
304
+ from datasets import Dataset
305
+ except ImportError as exc:
306
+ raise RuntimeError("Install `datasets` to read the local Hugging Face Arrow cache.") from exc
307
+ arrow = _find_cached_arrow(
308
+ cfg.hf_cache_dir,
309
+ "atalaydenknalbant___rawg-games-dataset",
310
+ "rawg-games-dataset-train.arrow",
311
+ )
312
+ columns = [
313
+ "id",
314
+ "name",
315
+ "released",
316
+ "rating",
317
+ "ratings_count",
318
+ "reviews_count",
319
+ "metacritic",
320
+ "platforms",
321
+ "developers",
322
+ "genres",
323
+ "tags",
324
+ "publishers",
325
+ "description_raw",
326
+ "background_image",
327
+ "stores",
328
+ "esrb_rating",
329
+ ]
330
+ ds = Dataset.from_file(str(arrow))
331
+ fetch_cols = [c for c in columns if c in ds.column_names]
332
+ frame = ds.select_columns(fetch_cols).to_pandas()
333
+
334
+ if "stores" in frame.columns:
335
+ frame = frame[~frame["stores"].astype(str).str.contains("itch.io", case=False, na=False)]
336
+
337
+ if "ratings_count" in frame.columns:
338
+ frame["ratings_count"] = pd.to_numeric(frame["ratings_count"], errors="coerce")
339
+ frame = frame[frame["ratings_count"].fillna(0) >= 5]
340
+ elif "reviews_count" in frame.columns:
341
+ frame["reviews_count"] = pd.to_numeric(frame["reviews_count"], errors="coerce")
342
+ frame = frame[frame["reviews_count"].fillna(0) >= 5]
343
+
344
+ adult_tags = frame.get("tags", "").astype(str).str.contains(r'\b(NSFW|Nudity|Sexual Content|Adult|sex)\b', case=False, na=False)
345
+ mature_esrb = frame.get("esrb_rating", "").astype(str).str.contains(r'\b(Adults Only|Mature)\b', case=False, na=False)
346
+ frame["hf_is_adult"] = mature_esrb | adult_tags
347
+
348
+ frame["match_title"] = frame["name"].map(_normalize_catalog_title)
349
+ frame["match_year"] = pd.to_datetime(frame["released"], errors="coerce").dt.year.astype("Int64")
350
+ if "ratings_count" in frame.columns:
351
+ frame = frame.sort_values("ratings_count", ascending=False, na_position="last")
352
+ return frame.drop_duplicates("match_title")
353
+
354
+
355
+ def enrich_item_metadata_from_hf_cache(
356
+ item_meta: pd.DataFrame,
357
+ cfg: RecConfig,
358
+ ) -> tuple[pd.DataFrame, dict[str, int]]:
359
+ """Enrich MovieLens and Steam metadata from the two local HF datasets.
360
+
361
+ Movie rows are joined on normalized title and release year. RAWG does not
362
+ expose Steam app IDs in this cache, so games use a conservative exact
363
+ normalized-title join and retain Steam's interaction/popularity fields.
364
+ """
365
+ out = item_meta.copy()
366
+ out["match_title"] = out["title"].map(_normalize_catalog_title)
367
+ out["match_year"] = out["title"].map(_title_year).astype("Int64")
368
+
369
+ movies = load_cached_hf_movies(cfg).rename(
370
+ columns={
371
+ "overview": "hf_description",
372
+ "genres": "hf_genres",
373
+ "keywords": "hf_keywords",
374
+ "tagline": "hf_tagline",
375
+ "vote_count": "hf_vote_count",
376
+ "vote_average": "hf_vote_average",
377
+ "poster_path": "poster_url",
378
+ "tmdb_id": "hf_tmdb_id",
379
+ }
380
+ )
381
+ movie_columns = [
382
+ "match_title",
383
+ "match_year",
384
+ "hf_description",
385
+ "hf_genres",
386
+ "hf_keywords",
387
+ "hf_tagline",
388
+ "hf_vote_count",
389
+ "hf_vote_average",
390
+ "poster_url",
391
+ "hf_tmdb_id",
392
+ "hf_is_adult",
393
+ ]
394
+ movie_rows = out["domain"].eq("movie")
395
+ enriched_movies = out.loc[movie_rows].merge(movies[movie_columns], on=["match_title", "match_year"], how="left")
396
+ # Back-fill tmdb_id from HF dataset where MovieLens links.csv had no entry
397
+ if "hf_tmdb_id" in enriched_movies.columns:
398
+ enriched_movies["tmdb_id"] = enriched_movies["tmdb_id"].where(
399
+ enriched_movies["tmdb_id"].notna(), enriched_movies["hf_tmdb_id"]
400
+ )
401
+ enriched_movies = enriched_movies.drop(columns=["hf_tmdb_id"], errors="ignore")
402
+
403
+ rawg = load_cached_hf_rawg(cfg).rename(
404
+ columns={
405
+ "id": "rawg_id",
406
+ "description_raw": "hf_description",
407
+ "genres": "hf_genres",
408
+ "tags": "hf_tags",
409
+ "developers": "hf_developers",
410
+ "publishers": "hf_publishers",
411
+ "platforms": "hf_platforms",
412
+ "rating": "rawg_rating",
413
+ "ratings_count": "rawg_ratings_count",
414
+ "reviews_count": "rawg_reviews_count",
415
+ }
416
+ )
417
+ rawg_columns = [
418
+ "match_title",
419
+ "rawg_id",
420
+ "hf_description",
421
+ "hf_genres",
422
+ "hf_tags",
423
+ "hf_developers",
424
+ "hf_publishers",
425
+ "hf_platforms",
426
+ "rawg_rating",
427
+ "rawg_ratings_count",
428
+ "rawg_reviews_count",
429
+ "metacritic",
430
+ "background_image",
431
+ "hf_is_adult",
432
+ ]
433
+ game_rows = out["domain"].eq("game")
434
+ enriched_games = out.loc[game_rows].merge(rawg[rawg_columns], on="match_title", how="left")
435
+
436
+ other_rows = out.loc[~(movie_rows | game_rows)]
437
+ out = pd.concat([enriched_movies, enriched_games, other_rows], ignore_index=True, sort=False)
438
+ existing_description = out["description"].fillna("").astype(str)
439
+ hf_description = out["hf_description"].fillna("").astype(str)
440
+ out["description"] = existing_description.where(existing_description.str.len() >= hf_description.str.len(), hf_description)
441
+ extra_tokens = (
442
+ out.get("hf_genres", pd.Series("", index=out.index)).fillna("").map(_pipe_text)
443
+ + " "
444
+ + out.get("hf_keywords", pd.Series("", index=out.index)).fillna("").map(_pipe_text)
445
+ + " "
446
+ + out.get("hf_tags", pd.Series("", index=out.index)).fillna("").map(_pipe_text)
447
+ + " "
448
+ + out.get("hf_tagline", pd.Series("", index=out.index)).fillna("").map(_pipe_text)
449
+ + " "
450
+ + out.get("hf_developers", pd.Series("", index=out.index)).fillna("").map(_pipe_text)
451
+ + " "
452
+ + out.get("hf_publishers", pd.Series("", index=out.index)).fillna("").map(_pipe_text)
453
+ + " "
454
+ + out["description"].map(lambda value: _text_tokens(value, limit=100))
455
+ )
456
+ out["tokens"] = (out["tokens"].fillna("") + " " + extra_tokens).str.strip()
457
+
458
+ hf_adult = out.get("hf_is_adult", pd.Series(False, index=out.index)).fillna(False).astype(bool)
459
+ if "is_adult" in out.columns:
460
+ out["is_adult"] = out["is_adult"].astype(bool) | hf_adult
461
+ else:
462
+ out["is_adult"] = hf_adult
463
+ out = out.drop(columns=["hf_is_adult"], errors="ignore")
464
+
465
+ stats = {
466
+ "movie_rows": int(movie_rows.sum()),
467
+ "movie_matches": int(enriched_movies["hf_description"].notna().sum()),
468
+ "game_rows": int(game_rows.sum()),
469
+ "game_matches": int(enriched_games["rawg_id"].notna().sum()),
470
+ }
471
+ return out.drop(columns=["match_title", "match_year"], errors="ignore"), stats
472
+
473
+
474
+ def _title_tokens(title: str) -> str:
475
+ title = re.sub(r"\(\d{4}\)", "", str(title).lower())
476
+ return " ".join(t for t in re.findall(r"[a-z0-9]+", title) if len(t) > 2)
477
+
478
+
479
+ def _text_tokens(text: str, limit: int | None = None) -> str:
480
+ tokens = [t for t in re.findall(r"[a-z0-9]+", str(text).lower()) if len(t) > 2]
481
+ if limit is not None:
482
+ tokens = tokens[:limit]
483
+ return " ".join(tokens)
484
+
485
+
486
+ def _tag_tokens(tags) -> str:
487
+ if not isinstance(tags, list):
488
+ return ""
489
+ return " ".join(_text_tokens(tag) for tag in tags)
490
+
491
+
492
+ def _read_movielens_tags(zip_path: str | Path, max_tags_per_movie: int = 40) -> pd.DataFrame:
493
+ try:
494
+ tags = _read_csv_from_zip(zip_path, "ml-32m/tags.csv", usecols=["movieId", "tag"])
495
+ except (KeyError, FileNotFoundError):
496
+ return pd.DataFrame(columns=["movieId", "tag_tokens", "is_adult"])
497
+
498
+ adult_tags = tags["tag"].astype(str).str.contains(r'\b(NSFW|Nudity|Sexual Content|Adult|sex)\b', case=False, na=False)
499
+ is_adult_df = adult_tags.groupby(tags["movieId"]).any().reset_index(name="is_adult")
500
+
501
+ tags["tag"] = tags["tag"].fillna("").astype(str).map(_text_tokens)
502
+ tags = tags.loc[tags["tag"].ne("")]
503
+ tags = tags.drop_duplicates(["movieId", "tag"])
504
+ tag_tokens = (
505
+ tags.groupby("movieId")["tag"]
506
+ .apply(lambda values: " ".join(list(values)[:max_tags_per_movie]))
507
+ .reset_index(name="tag_tokens")
508
+ )
509
+ return tag_tokens.merge(is_adult_df, on="movieId", how="left")
510
+
511
+
512
+ def _read_steam_metadata(zip_path: str | Path) -> pd.DataFrame:
513
+ try:
514
+ with zipfile.ZipFile(zip_path) as zf:
515
+ with zf.open("games_metadata.json") as fh:
516
+ meta = pd.read_json(fh, lines=True)
517
+ except (KeyError, FileNotFoundError):
518
+ return pd.DataFrame(columns=["app_id", "tag_tokens", "description", "is_adult"])
519
+
520
+ adult_tags = meta["tags"].astype(str).str.contains(r'\b(NSFW|Nudity|Sexual Content|Hentai|Adult|sex)\b', case=False, na=False)
521
+ meta["is_adult"] = adult_tags
522
+
523
+ meta["tag_tokens"] = meta["tags"].map(_tag_tokens).fillna("")
524
+ meta["description"] = meta["description"].fillna("").astype(str)
525
+ return meta[["app_id", "tag_tokens", "description", "is_adult"]]
526
+
527
+
528
+ def _movie_genre_bridge_tokens(genres: str) -> str:
529
+ bridge = {
530
+ "Action": "action combat fast shooter fighting",
531
+ "Adventure": "adventure exploration quest puzzle platformer",
532
+ "Animation": "animation animated cartoony colorful cute family",
533
+ "Children": "family friendly casual cute cozy",
534
+ "Comedy": "funny comedy humorous casual party",
535
+ "Crime": "crime detective mystery noir stealth",
536
+ "Documentary": "documentary educational simulation realistic",
537
+ "Drama": "story rich narrative emotional choices",
538
+ "Fantasy": "fantasy magic rpg adventure mythical",
539
+ "Film-Noir": "noir detective mystery dark",
540
+ "Horror": "horror survival dark atmospheric",
541
+ "Musical": "music rhythm soundtrack",
542
+ "Mystery": "mystery detective puzzle investigation",
543
+ "Romance": "romance emotional story rich dating",
544
+ "Sci-Fi": "sci fi science fiction space futuristic",
545
+ "Thriller": "thriller suspense stealth action",
546
+ "War": "war military strategy tactical",
547
+ "Western": "western open world adventure",
548
+ "IMAX": "cinematic immersive",
549
+ }
550
+ out = []
551
+ for genre in str(genres).split("|"):
552
+ out.append(bridge.get(genre, _text_tokens(genre)))
553
+ return " ".join(out)
554
+
555
+
556
+ def build_public_pretraining_data(
557
+ cfg: RecConfig,
558
+ enrich_from_hf: bool = True,
559
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
560
+ ml_inter, ml_items = load_movielens(cfg)
561
+ st_inter, st_items = load_steam(cfg)
562
+ interactions = pd.concat([ml_inter, st_inter], ignore_index=True)
563
+ interactions = interactions.dropna().drop_duplicates()
564
+ # Keep only the most recent interaction per (user, item) pair
565
+ interactions = (
566
+ interactions.sort_values("timestamp")
567
+ .drop_duplicates(subset=["user_key", "item_key"], keep="last")
568
+ .reset_index(drop=True)
569
+ )
570
+ # Remove items with too few interactions (noisy, unlearnable)
571
+ item_counts = interactions["item_key"].value_counts()
572
+ keep_items = set(item_counts[item_counts >= cfg.min_item_interactions].index)
573
+ interactions = interactions.loc[interactions["item_key"].isin(keep_items)]
574
+ item_meta = (
575
+ pd.concat([ml_items, st_items], ignore_index=True)
576
+ .drop_duplicates("item_key")
577
+ .reset_index(drop=True)
578
+ )
579
+ if enrich_from_hf:
580
+ item_meta, stats = enrich_item_metadata_from_hf_cache(item_meta, cfg)
581
+ item_meta.attrs["hf_enrichment_stats"] = stats
582
+ return interactions, item_meta
583
+
584
+
585
+ def enrich_movie_descriptions_from_tmdb(
586
+ item_meta: pd.DataFrame,
587
+ api_key: str | None = None,
588
+ cache_path: str | Path = "artifacts/tmdb_movie_descriptions.csv",
589
+ limit: int | None = None,
590
+ sleep_s: float = 0.03,
591
+ ) -> pd.DataFrame:
592
+ import requests
593
+
594
+ api_key = api_key or os.getenv("TMDB_API_KEY")
595
+ if not api_key:
596
+ raise RuntimeError("Set TMDB_API_KEY or pass api_key=... before fetching TMDB descriptions.")
597
+ if "tmdb_id" not in item_meta.columns:
598
+ raise ValueError("item_meta has no tmdb_id column. Rebuild item_meta with the current loader first.")
599
+
600
+ cache_path = Path(cache_path)
601
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
602
+ if cache_path.exists():
603
+ cache = pd.read_csv(cache_path)
604
+ else:
605
+ cache = pd.DataFrame(columns=["tmdb_id", "description"])
606
+ cached = set(cache["tmdb_id"].dropna().astype(int).tolist())
607
+
608
+ movie_ids = (
609
+ item_meta.loc[item_meta["domain"].eq("movie"), "tmdb_id"]
610
+ .dropna()
611
+ .astype(int)
612
+ .drop_duplicates()
613
+ .tolist()
614
+ )
615
+ missing = [tmdb_id for tmdb_id in movie_ids if tmdb_id not in cached]
616
+ if limit is not None:
617
+ missing = missing[:limit]
618
+
619
+ rows = []
620
+ for tmdb_id in tqdm(missing, desc="fetch TMDB overviews"):
621
+ url = f"https://api.themoviedb.org/3/movie/{tmdb_id}"
622
+ try:
623
+ r = requests.get(url, params={"api_key": api_key, "language": "en-US"}, timeout=20)
624
+ if r.status_code == 404:
625
+ continue
626
+ r.raise_for_status()
627
+ rows.append({"tmdb_id": tmdb_id, "description": r.json().get("overview", "") or ""})
628
+ if len(rows) % 100 == 0:
629
+ cache = pd.concat([cache, pd.DataFrame(rows)], ignore_index=True).drop_duplicates("tmdb_id", keep="last")
630
+ cache.to_csv(cache_path, index=False)
631
+ rows = []
632
+ if sleep_s:
633
+ time.sleep(sleep_s)
634
+ except requests.RequestException:
635
+ continue
636
+
637
+ if rows:
638
+ cache = pd.concat([cache, pd.DataFrame(rows)], ignore_index=True).drop_duplicates("tmdb_id", keep="last")
639
+ cache.to_csv(cache_path, index=False)
640
+
641
+ out = item_meta.copy()
642
+ cache["tmdb_id"] = cache["tmdb_id"].astype("Int64")
643
+ out = out.merge(cache, on="tmdb_id", how="left", suffixes=("", "_tmdb"))
644
+ out["description"] = out["description_tmdb"].fillna(out.get("description", ""))
645
+ out = out.drop(columns=[c for c in ["description_tmdb"] if c in out.columns])
646
+ out["tokens"] = out["tokens"].fillna("") + " " + out["description"].fillna("").map(lambda x: _text_tokens(x, limit=80))
647
+ return out
648
+
649
+
650
+ def build_text_embedding_tensor(
651
+ item_meta: pd.DataFrame,
652
+ item_to_idx: dict[str, int],
653
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
654
+ cache_path: str | Path = "artifacts/item_text_embeddings.pt",
655
+ batch_size: int = 128,
656
+ force_recompute: bool = False,
657
+ ) -> torch.Tensor:
658
+ cache_path = Path(cache_path)
659
+ n_items = max(item_to_idx.values()) + 1
660
+ ordered = item_meta.loc[item_meta["item_key"].isin(item_to_idx)].copy()
661
+ ordered["idx"] = ordered["item_key"].map(item_to_idx)
662
+ ordered = ordered.sort_values("idx")
663
+ signature = {
664
+ "model_name": model_name,
665
+ "item_keys": ordered["item_key"].tolist(),
666
+ "descriptions": ordered["description"].fillna("").tolist(),
667
+ "tokens": ordered["tokens"].fillna("").tolist(),
668
+ }
669
+
670
+ if cache_path.exists() and not force_recompute:
671
+ cached = torch.load(cache_path, map_location="cpu", weights_only=False)
672
+ if cached.get("signature") == signature:
673
+ return cached["tensor"]
674
+
675
+ try:
676
+ from sentence_transformers import SentenceTransformer
677
+ except ImportError as exc:
678
+ raise RuntimeError("Install sentence-transformers first: %pip install -q sentence-transformers") from exc
679
+
680
+ texts = (
681
+ ordered["title"].fillna("")
682
+ + ". "
683
+ + ordered["description"].fillna("")
684
+ + " "
685
+ + ordered["tokens"].fillna("")
686
+ ).tolist()
687
+ encoder = SentenceTransformer(model_name)
688
+ embeddings = encoder.encode(
689
+ texts,
690
+ batch_size=batch_size,
691
+ show_progress_bar=True,
692
+ normalize_embeddings=True,
693
+ convert_to_numpy=True,
694
+ )
695
+
696
+ tensor = torch.zeros((n_items, embeddings.shape[1]), dtype=torch.float32)
697
+ tensor[torch.tensor(ordered["idx"].to_numpy(), dtype=torch.long)] = torch.tensor(embeddings, dtype=torch.float32)
698
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
699
+ torch.save({"signature": signature, "tensor": tensor}, cache_path)
700
+ return tensor
701
+
702
+
703
+ def make_vocab(values: Iterable[str], add_pad: bool = True) -> dict[str, int]:
704
+ start = 1 if add_pad else 0
705
+ vocab = {v: i + start for i, v in enumerate(sorted(set(map(str, values))))}
706
+ if add_pad:
707
+ vocab["<PAD>"] = PAD
708
+ return vocab
709
+
710
+
711
+ SURVEY_RATING_VALUES = {
712
+ "5 Stars": 5.0,
713
+ "4 Stars": 4.0,
714
+ "Didn't watch but would watch": 3.5,
715
+ "Didn't play but would play": 3.5,
716
+ "3 Stars": 3.0,
717
+ "2 Stars": 2.0,
718
+ "Didn't watch and wouldn't watch": 1.5,
719
+ "Didn't play and wouldn't play": 1.5,
720
+ "1 Star": 1.0,
721
+ }
722
+
723
+
724
+ def parse_survey_item_id(value: str) -> str:
725
+ domain, item_id = str(value).split("_", 1)
726
+ if domain not in {"movie", "game"} or not item_id:
727
+ raise ValueError(f"Unsupported survey item id: {value!r}")
728
+ return f"{domain}:{item_id}"
729
+
730
+
731
+ def survey_rating_value(value: str) -> float:
732
+ if value not in SURVEY_RATING_VALUES:
733
+ raise ValueError(f"Unknown survey rating label: {value!r}")
734
+ return SURVEY_RATING_VALUES[value]
735
+
736
+
737
+ def survey_rating_weight(value: str) -> float:
738
+ return max(-1.0, min((survey_rating_value(value) - 3.0) / 2.0, 1.0))
739
+
740
+
741
+ def _split_genres(value) -> list[str]:
742
+ if pd.isna(value):
743
+ return []
744
+ return [part.strip().lower() for part in str(value).split("|") if part.strip()]
745
+
746
+
747
+ def build_genre_vocab(survey: pd.DataFrame) -> dict[str, int]:
748
+ columns = [
749
+ "movie_genres_fav",
750
+ "movie_genres_disliked",
751
+ "game_genres_fav",
752
+ "game_genres_disliked",
753
+ ]
754
+ values = {genre for column in columns for value in survey[column] for genre in _split_genres(value)}
755
+ return {genre: idx for idx, genre in enumerate(sorted(values))}
756
+
757
+
758
+ def genre_preference_vector(row: pd.Series, genre_vocab: dict[str, int]) -> torch.Tensor:
759
+ vector = torch.zeros(len(genre_vocab), dtype=torch.float32)
760
+ for column in ["movie_genres_fav", "game_genres_fav"]:
761
+ for genre in _split_genres(row[column]):
762
+ if genre in genre_vocab:
763
+ vector[genre_vocab[genre]] = 1.0
764
+ for column in ["movie_genres_disliked", "game_genres_disliked"]:
765
+ for genre in _split_genres(row[column]):
766
+ if genre in genre_vocab:
767
+ vector[genre_vocab[genre]] = -1.0
768
+ return vector
769
+
770
+
771
+ def load_survey_user_profiles(
772
+ csv_path: str | Path,
773
+ item_to_idx: dict[str, int] | None = None,
774
+ ) -> tuple[list[dict], dict[str, int]]:
775
+ survey = pd.read_csv(csv_path)
776
+ genre_vocab = build_genre_vocab(survey)
777
+ profiles = []
778
+ for user_number, row in survey.iterrows():
779
+ ratings = json.loads(row["ratings"])
780
+ profile_ratings = []
781
+ for rating in ratings:
782
+ item_key = parse_survey_item_id(rating["item_id"])
783
+ item_id = None if item_to_idx is None else item_to_idx.get(item_key)
784
+ profile_ratings.append(
785
+ {
786
+ "item_key": item_key,
787
+ "item_id": item_id,
788
+ "title": rating.get("title", ""),
789
+ "domain": rating.get("type", item_key.split(":", 1)[0]),
790
+ "label": rating["rating"],
791
+ "value": survey_rating_value(rating["rating"]),
792
+ "weight": survey_rating_weight(rating["rating"]),
793
+ }
794
+ )
795
+ profiles.append(
796
+ {
797
+ "user_key": f"survey:{user_number}",
798
+ "age": int(row["age"]),
799
+ "gender": str(row["gender"]).strip().lower(),
800
+ "profession": str(row["profession"]).strip().lower(),
801
+ "country": str(row["country"]).strip().lower(),
802
+ "movie_genres_fav": _split_genres(row["movie_genres_fav"]),
803
+ "movie_genres_disliked": _split_genres(row["movie_genres_disliked"]),
804
+ "game_genres_fav": _split_genres(row["game_genres_fav"]),
805
+ "game_genres_disliked": _split_genres(row["game_genres_disliked"]),
806
+ "genre_preferences": genre_preference_vector(row, genre_vocab),
807
+ "ratings": profile_ratings,
808
+ }
809
+ )
810
+ return profiles, genre_vocab
811
+
812
+
813
+ def build_survey_interactions(
814
+ csv_path: str | Path,
815
+ positive_threshold: float = 3.5,
816
+ ) -> pd.DataFrame:
817
+ """Convert explicit survey labels into ordered positive pretraining events."""
818
+ profiles, _ = load_survey_user_profiles(csv_path)
819
+ rows = []
820
+ for profile in profiles:
821
+ for position, rating in enumerate(profile["ratings"]):
822
+ if rating["value"] < positive_threshold:
823
+ continue
824
+ rows.append(
825
+ {
826
+ "user_key": profile["user_key"],
827
+ "item_key": rating["item_key"],
828
+ "timestamp": position,
829
+ "domain": rating["domain"],
830
+ "rating_value": rating["value"],
831
+ "rating_weight": rating["weight"],
832
+ }
833
+ )
834
+ return pd.DataFrame(rows)
835
+
836
+
837
+ def survey_catalog_coverage(profiles: Sequence[dict]) -> dict[str, int | float]:
838
+ ratings = [rating for profile in profiles for rating in profile["ratings"]]
839
+ mapped = [rating for rating in ratings if rating.get("item_id") is not None]
840
+ return {
841
+ "ratings": len(ratings),
842
+ "mapped_ratings": len(mapped),
843
+ "unique_items": len({rating["item_key"] for rating in ratings}),
844
+ "mapped_unique_items": len({rating["item_key"] for rating in mapped}),
845
+ "coverage": len(mapped) / max(len(ratings), 1),
846
+ }
847
+
848
+
849
+ def _profile_rating_map(profile: dict) -> dict[int, float]:
850
+ return {
851
+ int(rating["item_id"]): float(rating["weight"])
852
+ for rating in profile["ratings"]
853
+ if rating.get("item_id") is not None
854
+ }
855
+
856
+
857
+ def survey_user_similarity(
858
+ target_profile: dict,
859
+ other_profile: dict,
860
+ rating_weight: float = 0.7,
861
+ genre_weight: float = 0.3,
862
+ overlap_shrinkage: float = 3.0,
863
+ ) -> dict[str, float]:
864
+ """Blend shared-rating cosine similarity with explicit genre preferences."""
865
+ target_ratings = _profile_rating_map(target_profile)
866
+ other_ratings = _profile_rating_map(other_profile)
867
+ shared = sorted(set(target_ratings) & set(other_ratings))
868
+
869
+ rating_similarity = 0.0
870
+ if shared:
871
+ target_values = np.array([target_ratings[item_id] for item_id in shared], dtype=np.float32)
872
+ other_values = np.array([other_ratings[item_id] for item_id in shared], dtype=np.float32)
873
+ denominator = float(np.linalg.norm(target_values) * np.linalg.norm(other_values))
874
+ if denominator > 0:
875
+ raw_similarity = float(target_values @ other_values / denominator)
876
+ shrinkage = len(shared) / (len(shared) + max(float(overlap_shrinkage), 0.0))
877
+ rating_similarity = raw_similarity * shrinkage
878
+
879
+ target_genres = torch.as_tensor(target_profile["genre_preferences"], dtype=torch.float32)
880
+ other_genres = torch.as_tensor(other_profile["genre_preferences"], dtype=torch.float32)
881
+ genre_denominator = float(target_genres.norm() * other_genres.norm())
882
+ genre_similarity = (
883
+ float(torch.dot(target_genres, other_genres) / genre_denominator)
884
+ if genre_denominator > 0
885
+ else 0.0
886
+ )
887
+
888
+ available_rating_weight = float(rating_weight) if shared else 0.0
889
+ available_genre_weight = float(genre_weight) if genre_denominator > 0 else 0.0
890
+ total_weight = available_rating_weight + available_genre_weight
891
+ similarity = 0.0
892
+ if total_weight > 0:
893
+ similarity = (
894
+ available_rating_weight * rating_similarity
895
+ + available_genre_weight * genre_similarity
896
+ ) / total_weight
897
+ return {
898
+ "similarity": float(similarity),
899
+ "rating_similarity": float(rating_similarity),
900
+ "genre_similarity": float(genre_similarity),
901
+ "shared_items": float(len(shared)),
902
+ }
903
+
904
+
905
+ def find_similar_survey_users(
906
+ target_profile: dict,
907
+ profiles: Sequence[dict],
908
+ k: int = 15,
909
+ min_similarity: float = 0.0,
910
+ rating_weight: float = 0.7,
911
+ genre_weight: float = 0.3,
912
+ overlap_shrinkage: float = 3.0,
913
+ ) -> list[dict]:
914
+ neighbors = []
915
+ for other in profiles:
916
+ if other.get("user_key") == target_profile.get("user_key"):
917
+ continue
918
+ similarity = survey_user_similarity(
919
+ target_profile,
920
+ other,
921
+ rating_weight=rating_weight,
922
+ genre_weight=genre_weight,
923
+ overlap_shrinkage=overlap_shrinkage,
924
+ )
925
+ if similarity["similarity"] < min_similarity:
926
+ continue
927
+ neighbors.append(
928
+ {
929
+ "user_key": other["user_key"],
930
+ "profile": other,
931
+ **similarity,
932
+ }
933
+ )
934
+ neighbors.sort(
935
+ key=lambda row: (row["similarity"], row["shared_items"]),
936
+ reverse=True,
937
+ )
938
+ return neighbors[:k]
939
+
940
+
941
+ def recommend_user_cf(
942
+ target_profile: dict,
943
+ profiles: Sequence[dict],
944
+ k: int = 10,
945
+ neighbor_count: int = 15,
946
+ domain: str | None = None,
947
+ min_similarity: float = 0.05,
948
+ min_score: float = 0.0,
949
+ rating_weight: float = 0.7,
950
+ genre_weight: float = 0.3,
951
+ overlap_shrinkage: float = 3.0,
952
+ ) -> tuple[list[tuple[int, float]], list[dict]]:
953
+ """Recommend unseen survey items from the target user's nearest neighbors."""
954
+ if domain not in {None, "movie", "game"}:
955
+ raise ValueError("domain must be None, 'movie', or 'game'.")
956
+ neighbors = find_similar_survey_users(
957
+ target_profile,
958
+ profiles,
959
+ k=neighbor_count,
960
+ min_similarity=min_similarity,
961
+ rating_weight=rating_weight,
962
+ genre_weight=genre_weight,
963
+ overlap_shrinkage=overlap_shrinkage,
964
+ )
965
+ seen = set(_profile_rating_map(target_profile))
966
+ weighted_scores: dict[int, float] = {}
967
+ similarity_sums: dict[int, float] = {}
968
+ support: dict[int, int] = {}
969
+ for neighbor in neighbors:
970
+ similarity = float(neighbor["similarity"])
971
+ for rating in neighbor["profile"]["ratings"]:
972
+ item_id = rating.get("item_id")
973
+ if item_id is None or item_id in seen:
974
+ continue
975
+ if domain is not None and rating["domain"] != domain:
976
+ continue
977
+ weighted_scores[item_id] = weighted_scores.get(item_id, 0.0) + similarity * float(rating["weight"])
978
+ similarity_sums[item_id] = similarity_sums.get(item_id, 0.0) + abs(similarity)
979
+ support[item_id] = support.get(item_id, 0) + 1
980
+
981
+ rows = []
982
+ for item_id, numerator in weighted_scores.items():
983
+ score = numerator / max(similarity_sums[item_id], 1e-8)
984
+ # A small support adjustment prevents single-neighbor items from
985
+ # dominating equally scored items with broader agreement.
986
+ score *= support[item_id] / (support[item_id] + 1.0)
987
+ if score >= min_score:
988
+ rows.append((int(item_id), float(score)))
989
+ rows.sort(key=lambda row: (row[1], support[row[0]]), reverse=True)
990
+ return rows[:k], neighbors
991
+
992
+
993
+ def evaluate_user_cf_leave_one_out(
994
+ profiles: Sequence[dict],
995
+ k: int = 10,
996
+ neighbor_count: int = 15,
997
+ domain: str | None = None,
998
+ min_similarity: float = 0.05,
999
+ ) -> dict[str, float]:
1000
+ """Evaluate user-CF by hiding each survey user's final positive item."""
1001
+ hits, reciprocal_rank, total, users_with_recommendations = 0, 0.0, 0, 0
1002
+ for profile in tqdm(profiles, total=len(profiles), desc="evaluate user CF", leave=False):
1003
+ positive_indices = [
1004
+ index
1005
+ for index, rating in enumerate(profile["ratings"])
1006
+ if rating.get("item_id") is not None
1007
+ and rating["weight"] > 0
1008
+ and (domain is None or rating["domain"] == domain)
1009
+ ]
1010
+ if len(positive_indices) < 2:
1011
+ continue
1012
+ held_out_index = positive_indices[-1]
1013
+ target = int(profile["ratings"][held_out_index]["item_id"])
1014
+ target_profile = {
1015
+ **profile,
1016
+ "ratings": [
1017
+ rating
1018
+ for index, rating in enumerate(profile["ratings"])
1019
+ if index != held_out_index
1020
+ ],
1021
+ }
1022
+ recommendations, _ = recommend_user_cf(
1023
+ target_profile,
1024
+ profiles,
1025
+ k=k,
1026
+ neighbor_count=neighbor_count,
1027
+ domain=domain,
1028
+ min_similarity=min_similarity,
1029
+ )
1030
+ ranked = [item_id for item_id, _ in recommendations]
1031
+ total += 1
1032
+ users_with_recommendations += int(bool(ranked))
1033
+ if target in ranked:
1034
+ rank = ranked.index(target) + 1
1035
+ hits += 1
1036
+ reciprocal_rank += 1.0 / rank
1037
+ return {
1038
+ f"UserCF_HR@{k}": hits / max(total, 1),
1039
+ f"UserCF_MRR@{k}": reciprocal_rank / max(total, 1),
1040
+ "user_cf_users": float(total),
1041
+ "user_cf_coverage": users_with_recommendations / max(total, 1),
1042
+ }
1043
+
1044
+
1045
+ def recommend_hybrid_survey_user(
1046
+ target_profile: dict,
1047
+ profiles: Sequence[dict],
1048
+ model: CLEPIDTN,
1049
+ item_index: torch.Tensor,
1050
+ cfg: RecConfig,
1051
+ k: int = 10,
1052
+ domain: str | None = None,
1053
+ neural_weight: float = 0.75,
1054
+ user_cf_weight: float = 0.25,
1055
+ candidate_multiplier: int = 5,
1056
+ text_index: torch.Tensor | None = None,
1057
+ neighbor_count: int = 15,
1058
+ ) -> tuple[list[tuple[int, float]], dict]:
1059
+ """Fuse neural and survey user-CF rankings with reciprocal-rank fusion."""
1060
+ mapped_ratings = [
1061
+ rating for rating in target_profile["ratings"] if rating.get("item_id") is not None
1062
+ ]
1063
+ if not mapped_ratings:
1064
+ raise ValueError("The survey profile has no items mapped to the model catalog.")
1065
+ history_ids = [int(rating["item_id"]) for rating in mapped_ratings]
1066
+ history_weights = [float(rating["weight"]) for rating in mapped_ratings]
1067
+ pool_size = max(k, k * max(int(candidate_multiplier), 1))
1068
+ neural = recommend_from_history(
1069
+ model,
1070
+ item_index,
1071
+ history_ids,
1072
+ user_id=None,
1073
+ activity=len(history_ids),
1074
+ cfg=cfg,
1075
+ k=pool_size,
1076
+ domain=domain,
1077
+ text_index=text_index,
1078
+ history_weights=history_weights,
1079
+ )
1080
+ user_cf, neighbors = recommend_user_cf(
1081
+ target_profile,
1082
+ profiles,
1083
+ k=pool_size,
1084
+ neighbor_count=neighbor_count,
1085
+ domain=domain,
1086
+ )
1087
+
1088
+ fused: dict[int, float] = {}
1089
+ rank_constant = 20.0
1090
+ for rank, (item_id, _) in enumerate(neural, start=1):
1091
+ fused[item_id] = fused.get(item_id, 0.0) + float(neural_weight) / (rank_constant + rank)
1092
+ for rank, (item_id, _) in enumerate(user_cf, start=1):
1093
+ fused[item_id] = fused.get(item_id, 0.0) + float(user_cf_weight) / (rank_constant + rank)
1094
+ rows = sorted(fused.items(), key=lambda row: row[1], reverse=True)[:k]
1095
+ return rows, {
1096
+ "neural_recommendations": neural,
1097
+ "user_cf_recommendations": user_cf,
1098
+ "neighbors": neighbors,
1099
+ }
1100
+
1101
+
1102
+ def encode_item_metadata(item_meta: pd.DataFrame, item_to_idx: dict[str, int], max_tokens: int = 80):
1103
+ token_counts: dict[str, int] = {}
1104
+ for text in tqdm(
1105
+ item_meta["tokens"].fillna(""),
1106
+ total=len(item_meta),
1107
+ desc="count metadata tokens",
1108
+ leave=False,
1109
+ ):
1110
+ for token in str(text).split():
1111
+ token_counts[token] = token_counts.get(token, 0) + 1
1112
+ kept = [t for t, c in token_counts.items() if c >= 3]
1113
+ token_to_idx = {"<PAD>": PAD, "<UNK>": 1, **{t: i + 2 for i, t in enumerate(sorted(kept))}}
1114
+ domain_to_idx = {"movie": 0, "game": 1}
1115
+
1116
+ n_items = max(item_to_idx.values()) + 1
1117
+ token_ids = np.zeros((n_items, max_tokens), dtype=np.int64)
1118
+ domain_ids = np.zeros(n_items, dtype=np.int64)
1119
+ title_lookup = {}
1120
+
1121
+ for row in tqdm(
1122
+ item_meta.itertuples(index=False),
1123
+ total=len(item_meta),
1124
+ desc="encode item metadata",
1125
+ leave=False,
1126
+ ):
1127
+ idx = item_to_idx.get(row.item_key)
1128
+ if idx is None:
1129
+ continue
1130
+ toks = [token_to_idx.get(t, 1) for t in str(row.tokens).split()[:max_tokens]]
1131
+ token_ids[idx, : len(toks)] = toks
1132
+ domain_ids[idx] = domain_to_idx.get(row.domain, 0)
1133
+ title_lookup[idx] = row.title
1134
+ return token_to_idx, torch.tensor(token_ids), torch.tensor(domain_ids), title_lookup
1135
+
1136
+
1137
+ def make_sequence_samples(interactions: pd.DataFrame, cfg: RecConfig, all_item_keys: Iterable[str] | None = None):
1138
+ user_counts = interactions.groupby("user_key").size()
1139
+ keep_users = set(user_counts[user_counts >= cfg.min_user_events].index)
1140
+ interactions = interactions.loc[interactions["user_key"].isin(keep_users)].copy()
1141
+
1142
+ user_to_idx = make_vocab(interactions["user_key"], add_pad=False)
1143
+ if all_item_keys is None:
1144
+ item_values = interactions["item_key"]
1145
+ else:
1146
+ item_values = pd.concat(
1147
+ [interactions["item_key"], pd.Series(list(all_item_keys), dtype="object")],
1148
+ ignore_index=True,
1149
+ )
1150
+ item_to_idx = make_vocab(item_values, add_pad=True)
1151
+ interactions["u"] = interactions["user_key"].map(user_to_idx).astype(np.int64)
1152
+ interactions["i"] = interactions["item_key"].map(item_to_idx).astype(np.int64)
1153
+ interactions = interactions.sort_values(["u", "timestamp"])
1154
+
1155
+ histories, targets, users, activity = [], [], [], []
1156
+ eval_rows = []
1157
+ grouped = interactions.groupby("u", sort=False)
1158
+ for u, g in tqdm(grouped, total=grouped.ngroups, desc="build sequences", leave=False):
1159
+ seq = g["i"].tolist()
1160
+ if len(seq) < cfg.min_user_events:
1161
+ continue
1162
+ eval_rows.append((u, seq[:-1][-cfg.max_seq_len :], seq[-1]))
1163
+ for pos in range(1, len(seq) - 1):
1164
+ histories.append(seq[:pos][-cfg.max_seq_len :])
1165
+ targets.append(seq[pos])
1166
+ users.append(u)
1167
+ activity.append(min(int(math.log2(len(seq))), 8))
1168
+ if len(targets) >= cfg.max_train_samples:
1169
+ break
1170
+ if len(targets) >= cfg.max_train_samples:
1171
+ break
1172
+
1173
+ return {
1174
+ "user_to_idx": user_to_idx,
1175
+ "item_to_idx": item_to_idx,
1176
+ "histories": histories,
1177
+ "targets": targets,
1178
+ "users": users,
1179
+ "activity": activity,
1180
+ "eval_rows": eval_rows,
1181
+ }
1182
+
1183
+
1184
+ def make_temporal_sequence_splits(
1185
+ interactions: pd.DataFrame,
1186
+ cfg: RecConfig,
1187
+ all_item_keys: Iterable[str] | None = None,
1188
+ ) -> dict:
1189
+ """Create leakage-resistant train/validation/test next-item splits.
1190
+
1191
+ The last event is test, the penultimate event is validation, and training
1192
+ targets come only from earlier events. Users need at least four events for
1193
+ all three partitions.
1194
+ """
1195
+ user_counts = interactions.groupby("user_key").size()
1196
+ keep_users = set(user_counts[user_counts >= cfg.min_user_events].index)
1197
+ frame = interactions.loc[interactions["user_key"].isin(keep_users)].copy()
1198
+ user_to_idx = make_vocab(frame["user_key"], add_pad=False)
1199
+ item_values = frame["item_key"]
1200
+ if all_item_keys is not None:
1201
+ item_values = pd.concat([item_values, pd.Series(list(all_item_keys), dtype="object")], ignore_index=True)
1202
+ item_to_idx = make_vocab(item_values, add_pad=True)
1203
+ frame["u"] = frame["user_key"].map(user_to_idx).astype(np.int64)
1204
+ frame["i"] = frame["item_key"].map(item_to_idx).astype(np.int64)
1205
+ frame = frame.sort_values(["u", "timestamp"])
1206
+
1207
+ histories, targets, users, activity = [], [], [], []
1208
+ validation_rows, test_rows = [], []
1209
+ grouped = frame.groupby("u", sort=False)
1210
+ for u, group in tqdm(grouped, total=grouped.ngroups, desc="build temporal splits", leave=False):
1211
+ sequence = group["i"].tolist()
1212
+ if len(sequence) < 4:
1213
+ continue
1214
+ validation_rows.append((u, sequence[:-2][-cfg.max_seq_len :], sequence[-2]))
1215
+ test_rows.append((u, sequence[:-1][-cfg.max_seq_len :], sequence[-1]))
1216
+ if len(targets) >= cfg.max_train_samples:
1217
+ continue
1218
+ for position in range(1, len(sequence) - 2):
1219
+ histories.append(sequence[:position][-cfg.max_seq_len :])
1220
+ targets.append(sequence[position])
1221
+ users.append(u)
1222
+ activity.append(min(int(math.log2(len(sequence))), 8))
1223
+ if len(targets) >= cfg.max_train_samples:
1224
+ break
1225
+
1226
+ return {
1227
+ "user_to_idx": user_to_idx,
1228
+ "item_to_idx": item_to_idx,
1229
+ "histories": histories,
1230
+ "targets": targets,
1231
+ "users": users,
1232
+ "activity": activity,
1233
+ "validation_rows": validation_rows,
1234
+ "test_rows": test_rows,
1235
+ "eval_rows": validation_rows,
1236
+ }
1237
+
1238
+
1239
+ class SequenceDataset(Dataset):
1240
+ def __init__(self, histories, targets, users, activity, max_seq_len: int):
1241
+ self.histories = histories
1242
+ self.targets = targets
1243
+ self.users = users
1244
+ self.activity = activity
1245
+ self.max_seq_len = max_seq_len
1246
+
1247
+ def __len__(self) -> int:
1248
+ return len(self.targets)
1249
+
1250
+ def __getitem__(self, idx: int):
1251
+ hist = self.histories[idx]
1252
+ padded = [PAD] * (self.max_seq_len - len(hist)) + hist[-self.max_seq_len :]
1253
+ mask = [0] * (self.max_seq_len - len(hist)) + [1] * min(len(hist), self.max_seq_len)
1254
+ return {
1255
+ "history": torch.tensor(padded, dtype=torch.long),
1256
+ "mask": torch.tensor(mask, dtype=torch.bool),
1257
+ "target": torch.tensor(self.targets[idx], dtype=torch.long),
1258
+ "user": torch.tensor(self.users[idx], dtype=torch.long),
1259
+ "activity": torch.tensor(self.activity[idx], dtype=torch.long),
1260
+ }
1261
+
1262
+
1263
+ class CLEPIDTN(nn.Module):
1264
+ def __init__(
1265
+ self,
1266
+ n_items: int,
1267
+ n_users: int,
1268
+ n_tokens: int,
1269
+ item_token_ids: torch.Tensor,
1270
+ item_domain_ids: torch.Tensor,
1271
+ cfg: RecConfig,
1272
+ item_text_embeddings: torch.Tensor | None = None,
1273
+ ):
1274
+ super().__init__()
1275
+ d = cfg.embedding_dim
1276
+ self.cfg = cfg
1277
+ self.item_token_ids = item_token_ids
1278
+ self.item_domain_ids = item_domain_ids
1279
+ self.item_text_embeddings = item_text_embeddings
1280
+ self.item_id = nn.Embedding(n_items, d, padding_idx=PAD)
1281
+ self.user_id = nn.Embedding(n_users, d)
1282
+ self.activity = nn.Embedding(9, d)
1283
+ self.token = nn.Embedding(n_tokens, d, padding_idx=PAD)
1284
+ self.domain = nn.Embedding(2, d)
1285
+ self.pos = nn.Embedding(cfg.max_seq_len, d)
1286
+ text_dim = 0 if item_text_embeddings is None else int(item_text_embeddings.shape[1])
1287
+ self.text_proj = nn.Linear(text_dim, d) if text_dim else None
1288
+
1289
+ enc_layer = nn.TransformerEncoderLayer(
1290
+ d_model=d,
1291
+ nhead=cfg.attention_heads,
1292
+ dim_feedforward=d * 4,
1293
+ dropout=cfg.dropout,
1294
+ batch_first=True,
1295
+ activation="gelu",
1296
+ )
1297
+ # Nested tensors can fail when stochastic augmentation produces heavily
1298
+ # padded batches. Dense tensors are more predictable for our fixed,
1299
+ # left-padded sequence layout.
1300
+ self.sequence_encoder = nn.TransformerEncoder(
1301
+ enc_layer,
1302
+ num_layers=cfg.transformer_layers,
1303
+ enable_nested_tensor=False,
1304
+ )
1305
+ self.user_aug = nn.Sequential(nn.Linear(d * 2, d), nn.GELU(), nn.Linear(d, d))
1306
+ self.item_aug = nn.Sequential(nn.Linear(d * 2, d), nn.GELU(), nn.Linear(d, d))
1307
+ self.user_proj = nn.Sequential(nn.Linear(d * 4, d), nn.GELU(), nn.Dropout(cfg.dropout), nn.Linear(d, d))
1308
+ item_input_dim = d * 4 if self.text_proj is not None else d * 3
1309
+ self.item_proj = nn.Sequential(nn.Linear(item_input_dim, d), nn.GELU(), nn.Dropout(cfg.dropout), nn.Linear(d, d))
1310
+
1311
+ def item_features(
1312
+ self,
1313
+ item_ids: torch.Tensor,
1314
+ mask_tokens: bool = False,
1315
+ return_aux: bool = False,
1316
+ ) -> torch.Tensor | tuple[torch.Tensor, dict[str, torch.Tensor]]:
1317
+ token_ids = self.item_token_ids.to(item_ids.device)[item_ids]
1318
+ if self.training and mask_tokens:
1319
+ keep = torch.rand_like(token_ids.float()) > self.cfg.dropout
1320
+ token_ids = token_ids * keep.long()
1321
+ token_mask = token_ids.ne(PAD).unsqueeze(-1)
1322
+ token_sum = (self.token(token_ids) * token_mask).sum(dim=1)
1323
+ tok = token_sum / token_mask.sum(dim=1).clamp_min(1)
1324
+ dom = self.domain(self.item_domain_ids.to(item_ids.device)[item_ids])
1325
+ iid = self.item_id(item_ids)
1326
+ pav = self.item_aug(torch.cat([iid, tok], dim=-1))
1327
+ parts = [iid, tok + pav, dom]
1328
+ if self.text_proj is not None and self.item_text_embeddings is not None:
1329
+ text = self.item_text_embeddings.to(item_ids.device)[item_ids].float()
1330
+ parts.append(self.text_proj(text))
1331
+ out = F.normalize(self.item_proj(torch.cat(parts, dim=-1)), dim=-1)
1332
+ if return_aux:
1333
+ return out, {"pav": F.normalize(pav, dim=-1)}
1334
+ return out
1335
+
1336
+ def user_features(
1337
+ self,
1338
+ history: torch.Tensor,
1339
+ mask: torch.Tensor,
1340
+ user: torch.Tensor | None,
1341
+ activity: torch.Tensor,
1342
+ augment: bool = False,
1343
+ anonymous: bool = False,
1344
+ return_aux: bool = False,
1345
+ ):
1346
+ hist = history
1347
+ hist_mask = mask
1348
+ if not hist_mask.any(dim=1).all():
1349
+ raise ValueError("Every user history must contain at least one non-padding item.")
1350
+ if self.training and augment:
1351
+ keep = (torch.rand_like(hist.float()) > self.cfg.dropout) & hist_mask
1352
+ emptied = ~keep.any(dim=1)
1353
+ if emptied.any():
1354
+ positions = torch.arange(hist.size(1), device=hist.device).unsqueeze(0)
1355
+ recent_original = positions.masked_fill(~hist_mask, -1).max(dim=1).values
1356
+ empty_rows = torch.nonzero(emptied, as_tuple=False).squeeze(1)
1357
+ keep[empty_rows, recent_original[empty_rows]] = True
1358
+ hist = hist * keep.long()
1359
+ hist_mask = keep
1360
+ x = self.item_id(hist)
1361
+ positions = torch.arange(hist.size(1), device=hist.device).unsqueeze(0)
1362
+ x = x + self.pos(positions)
1363
+ causal_mask = None
1364
+ if self.cfg.use_causal_attention:
1365
+ causal_mask = torch.triu(
1366
+ torch.ones(hist.size(1), hist.size(1), dtype=torch.bool, device=hist.device),
1367
+ diagonal=1,
1368
+ )
1369
+ encoded = self.sequence_encoder(x, mask=causal_mask, src_key_padding_mask=~hist_mask)
1370
+ denom = hist_mask.sum(dim=1).clamp_min(1).unsqueeze(-1)
1371
+ pooled = (encoded * hist_mask.unsqueeze(-1)).sum(dim=1) / denom
1372
+ sequence_positions = torch.arange(hist.size(1), device=hist.device).unsqueeze(0)
1373
+ recent_idx = sequence_positions.masked_fill(~hist_mask, -1).max(dim=1).values.clamp_min(0)
1374
+ recent = encoded[torch.arange(hist.size(0), device=hist.device), recent_idx]
1375
+ if anonymous or user is None:
1376
+ uattr = torch.zeros_like(pooled)
1377
+ activity_vec = torch.zeros_like(pooled)
1378
+ else:
1379
+ activity_vec = self.activity(activity)
1380
+ uattr = self.user_id(user) + activity_vec
1381
+ pav = self.user_aug(torch.cat([uattr, pooled], dim=-1))
1382
+ out = self.user_proj(torch.cat([uattr + pav, pooled, recent, activity_vec], dim=-1))
1383
+ result = F.normalize(out, dim=-1), F.normalize(pooled, dim=-1), F.normalize(uattr, dim=-1)
1384
+ if return_aux:
1385
+ return (*result, {"pav": F.normalize(pav, dim=-1)})
1386
+ return result
1387
+
1388
+ def forward(self, batch):
1389
+ user_vec, seq_vec, attr_vec, user_aux = self.user_features(
1390
+ batch["history"],
1391
+ batch["mask"],
1392
+ batch["user"],
1393
+ batch["activity"],
1394
+ augment=False,
1395
+ return_aux=True,
1396
+ )
1397
+ item_vec, item_aux = self.item_features(batch["target"], mask_tokens=False, return_aux=True)
1398
+ return user_vec, item_vec, seq_vec, attr_vec, user_aux, item_aux
1399
+
1400
+
1401
+ def info_nce(a: torch.Tensor, b: torch.Tensor, temperature: torch.Tensor | float) -> torch.Tensor:
1402
+ logits = a @ b.T / temperature
1403
+ labels = torch.arange(a.size(0), device=a.device)
1404
+ return F.cross_entropy(logits, labels)
1405
+
1406
+
1407
+ def build_training_scheduler(optimizer, loader: DataLoader, cfg: RecConfig):
1408
+ """Create a OneCycleLR scheduler spanning all epochs.
1409
+
1410
+ Linear warmup for the first 5% of steps, then cosine decay to 0.
1411
+ """
1412
+ return torch.optim.lr_scheduler.OneCycleLR(
1413
+ optimizer,
1414
+ max_lr=cfg.lr,
1415
+ total_steps=len(loader) * cfg.epochs,
1416
+ pct_start=0.05,
1417
+ anneal_strategy="cos",
1418
+ )
1419
+
1420
+
1421
+ def train_one_epoch(
1422
+ model: CLEPIDTN,
1423
+ loader: DataLoader,
1424
+ optimizer,
1425
+ cfg: RecConfig,
1426
+ desc: str | None = None,
1427
+ scheduler=None,
1428
+ ) -> float:
1429
+ model.train()
1430
+ total, steps = 0.0, 0
1431
+ progress = tqdm(loader, desc=desc or "train", leave=False)
1432
+ for batch in progress:
1433
+ batch = {k: v.to(cfg.device) for k, v in batch.items()}
1434
+
1435
+ # View 1 (clean): main InfoNCE loss + auxiliary vectors
1436
+ user_vec, item_vec, _, _, user_aux, item_aux = model(batch)
1437
+ main_loss = info_nce(user_vec, item_vec, cfg.temperature)
1438
+
1439
+ # View 2 (augmented): contrastive SSL pairs
1440
+ _, seq_a, attr_a = model.user_features(
1441
+ batch["history"], batch["mask"], batch["user"], batch["activity"],
1442
+ augment=True,
1443
+ )
1444
+ _, seq_b, attr_b = model.user_features(
1445
+ batch["history"], batch["mask"], batch["user"], batch["activity"],
1446
+ augment=True,
1447
+ )
1448
+ item_a = model.item_features(batch["target"], mask_tokens=True)
1449
+ item_b = model.item_features(batch["target"], mask_tokens=True)
1450
+
1451
+ ssl = (
1452
+ info_nce(seq_a, seq_b, cfg.temperature)
1453
+ + info_nce(attr_a, attr_b, cfg.temperature)
1454
+ + info_nce(item_a, item_b, cfg.temperature)
1455
+ ) / 3
1456
+
1457
+ # AMM loss: cosine similarity instead of MSE to avoid collapse
1458
+ amm_loss = (
1459
+ 1.0 - F.cosine_similarity(user_aux["pav"], item_vec.detach(), dim=-1).mean()
1460
+ + 1.0 - F.cosine_similarity(item_aux["pav"], user_vec.detach(), dim=-1).mean()
1461
+ )
1462
+
1463
+ loss = main_loss + cfg.contrastive_weight * ssl + cfg.amm_weight * amm_loss
1464
+ optimizer.zero_grad(set_to_none=True)
1465
+ loss.backward()
1466
+ if cfg.gradient_clip_norm > 0:
1467
+ torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.gradient_clip_norm)
1468
+ optimizer.step()
1469
+ if scheduler is not None:
1470
+ scheduler.step()
1471
+ total += loss.item()
1472
+ steps += 1
1473
+ progress.set_postfix(loss=f"{total / steps:.4f}")
1474
+ return total / max(steps, 1)
1475
+
1476
+
1477
+ @torch.no_grad()
1478
+ def build_item_index(model: CLEPIDTN, batch_size: int = 4096) -> torch.Tensor:
1479
+ """Build a full item index tensor of shape ``(n_items, d)``.
1480
+
1481
+ Position 0 (PAD) is a zero vector. Every other position stores the
1482
+ normalized item-tower output so that ``item_index[item_id]`` gives the
1483
+ correct vector without any offset arithmetic.
1484
+ """
1485
+ model.eval()
1486
+ n_items = model.item_id.num_embeddings
1487
+ device = next(model.parameters()).device
1488
+ vecs = []
1489
+ starts = range(1, n_items, batch_size)
1490
+ for start in tqdm(starts, desc="index items", leave=False):
1491
+ ids = torch.arange(start, min(start + batch_size, n_items), device=device)
1492
+ vecs.append(model.item_features(ids).cpu())
1493
+ item_vecs = torch.cat(vecs, dim=0)
1494
+ # Prepend a zero vector for PAD (index 0) so item_index[item_id] works directly.
1495
+ pad_vec = torch.zeros(1, item_vecs.size(1), dtype=item_vecs.dtype)
1496
+ return torch.cat([pad_vec, item_vecs], dim=0)
1497
+
1498
+
1499
+ def item_index_ids(model: CLEPIDTN) -> torch.Tensor:
1500
+ return torch.arange(1, model.item_id.num_embeddings)
1501
+
1502
+
1503
+ def candidate_item_ids_from_metadata(
1504
+ item_meta: pd.DataFrame,
1505
+ item_to_idx: dict[str, int],
1506
+ domain: str | None = None,
1507
+ min_reviews: int | None = None,
1508
+ ) -> torch.Tensor:
1509
+ candidates = item_meta.copy()
1510
+ if domain is not None:
1511
+ candidates = candidates.loc[candidates["domain"].eq(domain)]
1512
+ if min_reviews is not None and "user_reviews" in candidates.columns:
1513
+ candidates = candidates.loc[candidates["user_reviews"].fillna(0) >= min_reviews]
1514
+ ids = [item_to_idx[k] for k in candidates["item_key"] if k in item_to_idx]
1515
+ if not ids:
1516
+ raise ValueError("No candidates matched the requested metadata filters.")
1517
+ return torch.tensor(sorted(set(ids)), dtype=torch.long)
1518
+
1519
+
1520
+ def recommend_cross_domain_content(
1521
+ item_meta: pd.DataFrame,
1522
+ item_to_idx: dict[str, int],
1523
+ history_ids: list[int],
1524
+ target_domain: str,
1525
+ k: int = 10,
1526
+ text_index: torch.Tensor | None = None,
1527
+ min_reviews: int | None = 500,
1528
+ exclude_addons: bool = True,
1529
+ semantic_weight: float = 0.35,
1530
+ token_weight: float = 0.30,
1531
+ title_weight: float = 0.25,
1532
+ popularity_weight: float = 0.10,
1533
+ history_weights: list[float] | None = None,
1534
+ ) -> list[tuple[int, float]]:
1535
+ """Content-first cross-domain retrieval for cold-start recommendations.
1536
+
1537
+ This is intentionally separate from the trained interaction model. Public
1538
+ MovieLens users and public Steam users are not the same people, so
1539
+ cross-domain recommendations are more reliable when ranked by shared
1540
+ language, tags, descriptions, franchise/entity overlap, and catalog quality.
1541
+ """
1542
+ idx_to_key = {idx: key for key, idx in item_to_idx.items()}
1543
+ meta = item_meta.loc[item_meta["item_key"].isin(item_to_idx)].copy()
1544
+ meta["idx"] = meta["item_key"].map(item_to_idx)
1545
+
1546
+ source_ids, source_weights = _normalize_history_weights(history_ids, history_weights)
1547
+ positive_ids = [idx for idx, weight in zip(source_ids, source_weights) if weight > 0]
1548
+ negative_ids = [idx for idx, weight in zip(source_ids, source_weights) if weight < 0]
1549
+
1550
+ source = meta.loc[meta["idx"].isin(positive_ids)]
1551
+ if source.empty:
1552
+ raise ValueError("No positive source items found. Ratings of 4-5 are needed to build a profile.")
1553
+ negative_source = meta.loc[meta["idx"].isin(negative_ids)]
1554
+
1555
+ candidates = meta.loc[meta["domain"].eq(target_domain)].copy()
1556
+ if min_reviews is not None and "user_reviews" in candidates.columns:
1557
+ candidates = candidates.loc[candidates["user_reviews"].fillna(0) >= min_reviews]
1558
+ if exclude_addons:
1559
+ addon_pattern = r"\b(?:dlc|demo|pack|skin|skins|season pass|expansion|challenge pack|batmobile|soundtrack)\b"
1560
+ candidates = candidates.loc[~candidates["title"].fillna("").str.contains(addon_pattern, case=False, regex=True)]
1561
+ if candidates.empty:
1562
+ raise ValueError("No target-domain candidates remain after filtering.")
1563
+
1564
+ source_tokens = _weighted_metadata_token_scores(source, source_ids, source_weights, positive_only=True)
1565
+ negative_tokens = _weighted_metadata_token_scores(negative_source, source_ids, source_weights, positive_only=False)
1566
+ source_title_tokens = _important_title_tokens(source["title"].fillna("").tolist())
1567
+ if not source_tokens:
1568
+ raise ValueError("Source items have no usable metadata tokens.")
1569
+
1570
+ rows = []
1571
+ candidate_indices = torch.tensor(candidates["idx"].to_numpy(), dtype=torch.long)
1572
+
1573
+ semantic_scores = torch.zeros(len(candidates), dtype=torch.float32)
1574
+ if text_index is not None:
1575
+ hist_indices = torch.tensor(source_ids, dtype=torch.long)
1576
+ hist_weights = torch.tensor(source_weights, dtype=torch.float32).unsqueeze(1)
1577
+ hist_vec = text_index[hist_indices] * hist_weights
1578
+ hist_vec = F.normalize(hist_vec.sum(dim=0, keepdim=True), dim=-1)
1579
+ cand_vec = F.normalize(text_index[candidate_indices], dim=-1)
1580
+ semantic_scores = (hist_vec @ cand_vec.T).squeeze(0).cpu()
1581
+
1582
+ max_reviews = float(np.log1p(candidates["user_reviews"].fillna(0).astype(float)).max() or 1.0)
1583
+ candidate_rows = candidates.itertuples(index=False)
1584
+ for pos, row in enumerate(
1585
+ tqdm(candidate_rows, total=len(candidates), desc="rank cross-domain candidates", leave=False)
1586
+ ):
1587
+ candidate_tokens = set(str(row.tokens).split())
1588
+ token_score = sum(source_tokens.get(t, 0.0) for t in candidate_tokens)
1589
+ token_score -= sum(abs(negative_tokens.get(t, 0.0)) for t in candidate_tokens)
1590
+ token_score = token_score / max(sum(abs(v) for v in source_tokens.values()) ** 0.5, 1.0)
1591
+
1592
+ title_tokens = set(_title_tokens(row.title).split())
1593
+ title_score = min(len(source_title_tokens & title_tokens), 3) / 3.0
1594
+ if source_title_tokens and source_title_tokens.issubset(title_tokens):
1595
+ title_score = 1.0
1596
+
1597
+ reviews = getattr(row, "user_reviews", 0) or 0
1598
+ popularity_score = float(np.log1p(reviews)) / max_reviews
1599
+ score = (
1600
+ semantic_weight * float(semantic_scores[pos])
1601
+ + token_weight * float(token_score)
1602
+ + title_weight * float(title_score)
1603
+ + popularity_weight * popularity_score
1604
+ )
1605
+ rows.append((int(row.idx), float(score)))
1606
+
1607
+ rows.sort(key=lambda x: x[1], reverse=True)
1608
+ return rows[:k]
1609
+
1610
+
1611
+
1612
+ def _normalize_history_weights(
1613
+ history_ids: list[int],
1614
+ history_weights: list[float] | None = None,
1615
+ star_rating_scale: bool = False,
1616
+ ) -> tuple[list[int], list[float]]:
1617
+ """Pair item IDs with normalized weights in [-1, 1].
1618
+
1619
+ Parameters
1620
+ ----------
1621
+ star_rating_scale : bool
1622
+ When *True*, weights are treated as 1-5 star ratings and linearly
1623
+ mapped to [-1, 1] via ``(w - 3) / 2``. When *False* (default),
1624
+ weights are assumed to already be in [-1, 1] and are only clamped.
1625
+ """
1626
+ ids = [int(i) for i in history_ids if i > 0]
1627
+ if history_weights is None:
1628
+ return ids, [1.0] * len(ids)
1629
+ if len(history_weights) != len(history_ids):
1630
+ raise ValueError("history_weights must have the same length as history_ids.")
1631
+ out_ids, out_weights = [], []
1632
+ for item_id, raw_weight in zip(history_ids, history_weights):
1633
+ if item_id <= 0:
1634
+ continue
1635
+ weight = float(raw_weight)
1636
+ if star_rating_scale:
1637
+ weight = (weight - 3.0) / 2.0
1638
+ out_ids.append(int(item_id))
1639
+ out_weights.append(max(-1.0, min(weight, 1.0)))
1640
+ return out_ids, out_weights
1641
+
1642
+
1643
+ def _weighted_metadata_token_scores(
1644
+ rows: pd.DataFrame,
1645
+ history_ids: list[int],
1646
+ history_weights: list[float],
1647
+ positive_only: bool,
1648
+ ) -> dict[str, float]:
1649
+ weight_by_idx = dict(zip(history_ids, history_weights))
1650
+ scores: dict[str, float] = {}
1651
+ for row in rows.itertuples(index=False):
1652
+ weight = float(weight_by_idx.get(int(row.idx), 0.0))
1653
+ if positive_only and weight <= 0:
1654
+ continue
1655
+ if not positive_only and weight >= 0:
1656
+ continue
1657
+ for token in str(row.tokens).split():
1658
+ if len(token) <= 2 or token in _METADATA_STOP_WORDS or token.startswith("posratio_"):
1659
+ continue
1660
+ scores[token] = scores.get(token, 0.0) + weight
1661
+ return scores
1662
+
1663
+
1664
+ def _important_title_tokens(titles: list[str]) -> set[str]:
1665
+ stop = {"the", "and", "part", "movie", "film", "edition", "year"}
1666
+ tokens = set()
1667
+ for title in titles:
1668
+ tokens.update(t for t in _title_tokens(title).split() if len(t) > 2 and t not in stop)
1669
+ return tokens
1670
+
1671
+
1672
+ def add_tmdb_movie_descriptions(
1673
+ cfg: RecConfig,
1674
+ item_meta: pd.DataFrame,
1675
+ cache_path: str | Path = "artifacts/tmdb_movie_descriptions.csv",
1676
+ bearer_token: str | None = None,
1677
+ limit: int | None = None,
1678
+ sleep_seconds: float = 0.025,
1679
+ ) -> pd.DataFrame:
1680
+ import requests
1681
+
1682
+ bearer_token = bearer_token or os.getenv("TMDB_BEARER_TOKEN")
1683
+ if not bearer_token:
1684
+ raise RuntimeError("Set TMDB_BEARER_TOKEN or pass bearer_token=... before fetching TMDB descriptions.")
1685
+
1686
+ cache_path = Path(cache_path)
1687
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
1688
+ if cache_path.exists():
1689
+ cached = pd.read_csv(cache_path)
1690
+ else:
1691
+ cached = pd.DataFrame(columns=["item_key", "tmdbId", "description"])
1692
+
1693
+ cached_keys = set(cached["item_key"].astype(str))
1694
+ links = _read_csv_from_zip(cfg.movielens_zip, "ml-32m/links.csv", usecols=["movieId", "tmdbId"])
1695
+ links = links.dropna(subset=["tmdbId"]).copy()
1696
+ links["tmdbId"] = links["tmdbId"].astype(int)
1697
+ links["item_key"] = "movie:" + links["movieId"].astype(str)
1698
+ links = links.loc[links["item_key"].isin(set(item_meta["item_key"]))]
1699
+ links = links.loc[~links["item_key"].isin(cached_keys)]
1700
+ if limit is not None:
1701
+ links = links.head(limit)
1702
+
1703
+ rows = []
1704
+ headers = {"Authorization": f"Bearer {bearer_token}"}
1705
+ for row in tqdm(links.itertuples(index=False), total=len(links), desc="fetch tmdb descriptions"):
1706
+ try:
1707
+ r = requests.get(
1708
+ f"https://api.themoviedb.org/3/movie/{row.tmdbId}",
1709
+ headers=headers,
1710
+ timeout=20,
1711
+ )
1712
+ if r.status_code == 404:
1713
+ continue
1714
+ r.raise_for_status()
1715
+ data = r.json()
1716
+ rows.append(
1717
+ {
1718
+ "item_key": row.item_key,
1719
+ "tmdbId": row.tmdbId,
1720
+ "description": data.get("overview") or "",
1721
+ }
1722
+ )
1723
+ if sleep_seconds:
1724
+ time.sleep(sleep_seconds)
1725
+ except requests.RequestException:
1726
+ continue
1727
+
1728
+ if rows:
1729
+ cached = pd.concat([cached, pd.DataFrame(rows)], ignore_index=True)
1730
+ cached = cached.drop_duplicates("item_key", keep="last")
1731
+ cached.to_csv(cache_path, index=False)
1732
+
1733
+ enriched = item_meta.copy()
1734
+ enriched = enriched.merge(cached[["item_key", "description"]], on="item_key", how="left", suffixes=("", "_tmdb"))
1735
+ if "description_tmdb" in enriched.columns:
1736
+ enriched["description"] = enriched["description_tmdb"].fillna(enriched.get("description", ""))
1737
+ enriched = enriched.drop(columns=["description_tmdb"])
1738
+ enriched["description"] = enriched["description"].fillna("")
1739
+ enriched["tokens"] = (
1740
+ enriched["tokens"].fillna("")
1741
+ + " "
1742
+ + enriched["description"].map(lambda x: _text_tokens(x, limit=80))
1743
+ )
1744
+ return enriched
1745
+
1746
+
1747
+ def build_text_embedding_index(
1748
+ item_meta: pd.DataFrame,
1749
+ item_to_idx: dict[str, int],
1750
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
1751
+ batch_size: int = 128,
1752
+ device: str | None = None,
1753
+ ) -> tuple[torch.Tensor, torch.Tensor]:
1754
+ try:
1755
+ from sentence_transformers import SentenceTransformer
1756
+ except ImportError as exc:
1757
+ raise ImportError("Install sentence-transformers first: %pip install -q sentence-transformers") from exc
1758
+
1759
+ n_items = max(item_to_idx.values()) + 1
1760
+ rows = []
1761
+ for row in tqdm(
1762
+ item_meta.itertuples(index=False),
1763
+ total=len(item_meta),
1764
+ desc="prepare text index",
1765
+ leave=False,
1766
+ ):
1767
+ item_id = item_to_idx.get(row.item_key)
1768
+ if item_id is None:
1769
+ continue
1770
+ description = getattr(row, "description", "") or ""
1771
+ text = f"{row.title}. {description} {getattr(row, 'tokens', '')}"
1772
+ rows.append((item_id, text))
1773
+
1774
+ rows.sort(key=lambda x: x[0])
1775
+ ids = torch.tensor([r[0] for r in rows], dtype=torch.long)
1776
+ texts = [r[1] for r in rows]
1777
+ encoder = SentenceTransformer(model_name, device=device)
1778
+ emb = encoder.encode(
1779
+ texts,
1780
+ batch_size=batch_size,
1781
+ show_progress_bar=True,
1782
+ convert_to_tensor=True,
1783
+ normalize_embeddings=True,
1784
+ ).cpu()
1785
+
1786
+ index = torch.zeros((n_items, emb.size(1)), dtype=torch.float32)
1787
+ index[ids] = emb
1788
+ return index, ids
1789
+
1790
+
1791
+ @torch.no_grad()
1792
+ def recommend_from_history(
1793
+ model: CLEPIDTN,
1794
+ item_index: torch.Tensor,
1795
+ history_ids: list[int],
1796
+ user_id: int | None,
1797
+ activity: int,
1798
+ cfg: RecConfig,
1799
+ k: int = 10,
1800
+ domain: str | None = None,
1801
+ anonymous_item_weight: float = 0.65,
1802
+ content_boost: float = 0.20,
1803
+ candidate_item_ids: torch.Tensor | None = None,
1804
+ text_index: torch.Tensor | None = None,
1805
+ text_weight: float = 0.25,
1806
+ title_boost: float = 0.25,
1807
+ history_weights: list[float] | None = None,
1808
+ ):
1809
+ model.eval()
1810
+ if candidate_item_ids is None:
1811
+ candidate_item_ids = item_index_ids(model)
1812
+ else:
1813
+ candidate_item_ids = candidate_item_ids.cpu().long()
1814
+ weighted_ids, weighted_values = _normalize_history_weights(history_ids, history_weights)
1815
+ positive_weighted_ids = [idx for idx, weight in zip(weighted_ids, weighted_values) if weight > 0]
1816
+ hist = positive_weighted_ids[-cfg.max_seq_len :]
1817
+ if not hist:
1818
+ # Fall back to all items when no positives exist (e.g. all neutral/negative).
1819
+ hist = weighted_ids[-cfg.max_seq_len :]
1820
+ if not hist:
1821
+ return []
1822
+ padded = [PAD] * (cfg.max_seq_len - len(hist)) + hist
1823
+ mask = [0] * (cfg.max_seq_len - len(hist)) + [1] * len(hist)
1824
+ user_tensor = None if user_id is None else torch.tensor([user_id], device=cfg.device)
1825
+ batch = {
1826
+ "history": torch.tensor([padded], device=cfg.device),
1827
+ "mask": torch.tensor([mask], dtype=torch.bool, device=cfg.device),
1828
+ "user": user_tensor,
1829
+ "activity": torch.tensor([min(activity, 8)], device=cfg.device),
1830
+ }
1831
+ user_vec, _, _ = model.user_features(
1832
+ batch["history"],
1833
+ batch["mask"],
1834
+ batch["user"],
1835
+ batch["activity"],
1836
+ anonymous=user_id is None,
1837
+ )
1838
+ if user_id is None and weighted_ids:
1839
+ hist_ids = torch.tensor(weighted_ids, device=cfg.device)
1840
+ hist_weights = torch.tensor(weighted_values, device=cfg.device, dtype=torch.float32).unsqueeze(1)
1841
+ hist_vec = model.item_features(hist_ids) * hist_weights
1842
+ hist_vec = hist_vec.sum(dim=0, keepdim=True)
1843
+ hist_vec = F.normalize(hist_vec, dim=-1)
1844
+ w = max(0.0, min(float(anonymous_item_weight), 1.0))
1845
+ user_vec = F.normalize((1.0 - w) * user_vec + w * hist_vec, dim=-1)
1846
+ # item_index now has shape (n_items, d) with PAD at index 0 — no offset needed.
1847
+ scores = (user_vec.cpu() @ item_index[candidate_item_ids].T).squeeze(0)
1848
+ if user_id is None and weighted_ids and content_boost > 0:
1849
+ item_tokens = model.item_token_ids[candidate_item_ids].cpu()
1850
+ pos_tokens = model.item_token_ids[[i for i, weight in zip(weighted_ids, weighted_values) if weight > 0]].cpu().flatten()
1851
+ neg_tokens = model.item_token_ids[[i for i, weight in zip(weighted_ids, weighted_values) if weight < 0]].cpu().flatten()
1852
+ pos_tokens = pos_tokens[(pos_tokens != PAD) & (pos_tokens != 1)].unique()
1853
+ neg_tokens = neg_tokens[(neg_tokens != PAD) & (neg_tokens != 1)].unique()
1854
+ if len(pos_tokens) > 0:
1855
+ overlap = torch.isin(item_tokens, pos_tokens).float().sum(dim=1)
1856
+ if len(neg_tokens) > 0:
1857
+ # Only penalize tokens that are exclusive to disliked items,
1858
+ # so shared tokens like "action" don't suppress liked genres.
1859
+ neg_only_tokens = neg_tokens[~torch.isin(neg_tokens, pos_tokens)]
1860
+ if len(neg_only_tokens) > 0:
1861
+ overlap = overlap - torch.isin(item_tokens, neg_only_tokens).float().sum(dim=1)
1862
+ lengths = (item_tokens > 1).float().sum(dim=1).clamp_min(1.0)
1863
+ overlap = overlap / lengths.sqrt()
1864
+ scores = scores + float(content_boost) * overlap
1865
+ if text_index is None and getattr(model, "item_text_embeddings", None) is not None:
1866
+ text_index = model.item_text_embeddings.cpu()
1867
+ if text_index is not None and weighted_ids and text_weight > 0:
1868
+ hist_ids = torch.tensor(weighted_ids, dtype=torch.long)
1869
+ hist_weights = torch.tensor(weighted_values, dtype=torch.float32).unsqueeze(1)
1870
+ hist_text = (text_index[hist_ids] * hist_weights).sum(dim=0, keepdim=True)
1871
+ hist_text = F.normalize(hist_text, dim=-1)
1872
+ candidate_text = F.normalize(text_index[candidate_item_ids], dim=-1)
1873
+ text_scores = (hist_text @ candidate_text.T).squeeze(0)
1874
+ scores = scores + float(text_weight) * text_scores
1875
+ if user_id is None and positive_weighted_ids and title_boost > 0:
1876
+ hist_tokens = model.item_token_ids[positive_weighted_ids].cpu().flatten()
1877
+ item_tokens = model.item_token_ids[candidate_item_ids].cpu()
1878
+ hist_tokens = hist_tokens[(hist_tokens != PAD) & (hist_tokens != 1)].unique()
1879
+ if len(hist_tokens) > 0:
1880
+ # Title tokens are appended *last* in the token string, so use
1881
+ # the tail of the token tensor (the head contains genre/tag tokens).
1882
+ titleish = torch.isin(item_tokens[:, -12:], hist_tokens).float().sum(dim=1).clamp(max=3.0) / 3.0
1883
+ scores = scores + float(title_boost) * titleish
1884
+ if weighted_ids:
1885
+ blocked_items = torch.tensor(weighted_ids, dtype=torch.long)
1886
+ blocked = torch.isin(candidate_item_ids, blocked_items)
1887
+ scores[blocked] = -1e9
1888
+ if domain is not None:
1889
+ domain_to_idx = {"movie": 0, "game": 1}
1890
+ if domain not in domain_to_idx:
1891
+ raise ValueError(f"domain must be one of {sorted(domain_to_idx)}, got {domain!r}")
1892
+ item_domains = model.item_domain_ids[candidate_item_ids].cpu()
1893
+ domain_mask = item_domains == domain_to_idx[domain]
1894
+ if not domain_mask.any():
1895
+ raise ValueError(f"No {domain!r} candidates are available in this item index.")
1896
+ scores[~domain_mask] = -1e9
1897
+ valid_count = int((scores > -1e8).sum().item())
1898
+ if valid_count == 0:
1899
+ raise ValueError("No valid recommendation candidates remain after filtering.")
1900
+ k = min(k, valid_count)
1901
+ values, offsets = torch.topk(scores, k)
1902
+ item_ids = candidate_item_ids[offsets].tolist()
1903
+ return list(zip(item_ids, values.tolist()))
1904
+
1905
+
1906
+ @torch.no_grad()
1907
+ def evaluate_retrieval(
1908
+ model: CLEPIDTN,
1909
+ item_index: torch.Tensor,
1910
+ eval_rows: Sequence[tuple[int, list[int], int]],
1911
+ cfg: RecConfig,
1912
+ ks: Sequence[int] = (10, 50),
1913
+ candidate_item_ids: torch.Tensor | None = None,
1914
+ max_users: int | None = None,
1915
+ eval_batch_size: int = 256,
1916
+ ) -> dict[str, float]:
1917
+ """Batched evaluation — encodes users in GPU batches and scores with one matmul."""
1918
+ model.eval()
1919
+ max_k = max(ks)
1920
+ rows = eval_rows if max_users is None else eval_rows[:max_users]
1921
+
1922
+ # Filter valid rows
1923
+ valid = [(u, h, t) for u, h, t in rows if h and t > 0]
1924
+ if not valid:
1925
+ return {"users": 0.0, "MRR": 0.0, **{f"HR@{k}": 0.0 for k in ks},
1926
+ **{f"Recall@{k}": 0.0 for k in ks}, **{f"NDCG@{k}": 0.0 for k in ks}}
1927
+
1928
+ # Build candidate index on CPU
1929
+ if candidate_item_ids is None:
1930
+ candidate_item_ids = torch.arange(1, item_index.size(0))
1931
+ cand_vecs = item_index[candidate_item_ids] # (n_cand, d)
1932
+
1933
+ # Map targets to candidate offsets for fast lookup
1934
+ cand_id_to_offset = {int(cid): off for off, cid in enumerate(candidate_item_ids.tolist())}
1935
+
1936
+ hits = {k: 0.0 for k in ks}
1937
+ ndcg = {k: 0.0 for k in ks}
1938
+ reciprocal_rank = 0.0
1939
+ total = 0
1940
+
1941
+ for batch_start in range(0, len(valid), eval_batch_size):
1942
+ batch_rows = valid[batch_start : batch_start + eval_batch_size]
1943
+ B = len(batch_rows)
1944
+
1945
+ # Pad histories and build masks
1946
+ padded_batch = []
1947
+ mask_batch = []
1948
+ user_batch = []
1949
+ activity_batch = []
1950
+ target_offsets = [] # offset in candidate_item_ids, or -1
1951
+
1952
+ for user_id, history, target in batch_rows:
1953
+ h = history[-cfg.max_seq_len:]
1954
+ pad_len = cfg.max_seq_len - len(h)
1955
+ padded_batch.append([PAD] * pad_len + h)
1956
+ mask_batch.append([0] * pad_len + [1] * len(h))
1957
+ user_batch.append(user_id)
1958
+ activity_batch.append(min(len(history), 8))
1959
+ target_offsets.append(cand_id_to_offset.get(target, -1))
1960
+
1961
+ # Encode users on GPU
1962
+ hist_t = torch.tensor(padded_batch, dtype=torch.long, device=cfg.device)
1963
+ mask_t = torch.tensor(mask_batch, dtype=torch.bool, device=cfg.device)
1964
+ user_t = torch.tensor(user_batch, dtype=torch.long, device=cfg.device)
1965
+ act_t = torch.tensor(activity_batch, dtype=torch.long, device=cfg.device)
1966
+
1967
+ with torch.no_grad():
1968
+ user_vecs, _, _ = model.user_features(hist_t, mask_t, user_t, act_t)
1969
+ user_vecs = user_vecs.cpu() # (B, d)
1970
+
1971
+ # Score all candidates at once: (B, d) @ (d, n_cand) -> (B, n_cand)
1972
+ scores = user_vecs @ cand_vecs.T
1973
+
1974
+ # Get top-k per user
1975
+ topk_vals, topk_idx = torch.topk(scores, min(max_k, scores.size(1)), dim=1)
1976
+
1977
+ for i in range(B):
1978
+ target_off = target_offsets[i]
1979
+ if target_off < 0:
1980
+ continue # target not in candidates
1981
+ total += 1
1982
+ ranked_offsets = topk_idx[i].tolist()
1983
+ if target_off in ranked_offsets:
1984
+ rank = ranked_offsets.index(target_off) + 1
1985
+ reciprocal_rank += 1.0 / rank
1986
+ for k in ks:
1987
+ if rank <= k:
1988
+ hits[k] += 1.0
1989
+ ndcg[k] += 1.0 / math.log2(rank + 1)
1990
+
1991
+ metrics = {"users": float(total), "MRR": reciprocal_rank / max(total, 1)}
1992
+ for k in ks:
1993
+ metrics[f"HR@{k}"] = hits[k] / max(total, 1)
1994
+ metrics[f"Recall@{k}"] = hits[k] / max(total, 1)
1995
+ metrics[f"NDCG@{k}"] = ndcg[k] / max(total, 1)
1996
+ return metrics
1997
+
1998
+
1999
+ def build_popularity_rankings(
2000
+ interactions: pd.DataFrame,
2001
+ item_to_idx: dict[str, int],
2002
+ domain_by_item_key: dict[str, str] | None = None,
2003
+ ) -> dict[str | None, list[int]]:
2004
+ counts = interactions["item_key"].value_counts()
2005
+ rows = [(item_to_idx[key], int(count)) for key, count in counts.items() if key in item_to_idx]
2006
+ rows.sort(key=lambda value: value[1], reverse=True)
2007
+ rankings: dict[str | None, list[int]] = {None: [item_id for item_id, _ in rows]}
2008
+ if domain_by_item_key is not None:
2009
+ for domain in sorted(set(domain_by_item_key.values())):
2010
+ rankings[domain] = [
2011
+ item_to_idx[key]
2012
+ for key in counts.index
2013
+ if key in item_to_idx and domain_by_item_key.get(key) == domain
2014
+ ]
2015
+ return rankings
2016
+
2017
+
2018
+ def evaluate_popularity_baseline(
2019
+ rankings: dict[str | None, list[int]] | list[int],
2020
+ eval_rows: Sequence[tuple[int, list[int], int]],
2021
+ ks: Sequence[int] = (10, 50),
2022
+ ) -> dict[str, float]:
2023
+ ranking = rankings[None] if isinstance(rankings, dict) else rankings
2024
+ max_k = max(ks)
2025
+ hits = {k: 0.0 for k in ks}
2026
+ ndcg = {k: 0.0 for k in ks}
2027
+ total = 0
2028
+ for _, history, target in tqdm(
2029
+ eval_rows,
2030
+ total=len(eval_rows),
2031
+ desc="evaluate popularity",
2032
+ leave=False,
2033
+ ):
2034
+ seen = set(history)
2035
+ candidates = [item_id for item_id in ranking if item_id not in seen][:max_k]
2036
+ total += 1
2037
+ if target in candidates:
2038
+ rank = candidates.index(target) + 1
2039
+ for k in ks:
2040
+ if rank <= k:
2041
+ hits[k] += 1.0
2042
+ ndcg[k] += 1.0 / math.log2(rank + 1)
2043
+ metrics = {"users": float(total)}
2044
+ for k in ks:
2045
+ metrics[f"Popularity_HR@{k}"] = hits[k] / max(total, 1)
2046
+ metrics[f"Popularity_NDCG@{k}"] = ndcg[k] / max(total, 1)
2047
+ return metrics
2048
+
2049
+
2050
+ def build_token_knn_index(
2051
+ item_token_ids: torch.Tensor,
2052
+ candidate_item_ids: torch.Tensor | None = None,
2053
+ ) -> dict[int, set[int]]:
2054
+ if candidate_item_ids is None:
2055
+ candidate_item_ids = torch.arange(1, item_token_ids.size(0))
2056
+ index = {}
2057
+ candidate_ids = candidate_item_ids.tolist()
2058
+ for item_id in tqdm(candidate_ids, total=len(candidate_ids), desc="build token KNN", leave=False):
2059
+ tokens = item_token_ids[item_id]
2060
+ index[int(item_id)] = set(tokens[(tokens != PAD) & (tokens != 1)].tolist())
2061
+ return index
2062
+
2063
+
2064
+ def recommend_item_knn(
2065
+ history_ids: list[int],
2066
+ token_index: dict[int, set[int]],
2067
+ k: int = 10,
2068
+ history_weights: list[float] | None = None,
2069
+ ) -> list[tuple[int, float]]:
2070
+ source_ids, weights = _normalize_history_weights(history_ids, history_weights)
2071
+ profile_scores: dict[int, float] = {}
2072
+ for item_id, weight in zip(source_ids, weights):
2073
+ if weight <= 0:
2074
+ continue
2075
+ for token in token_index.get(item_id, set()):
2076
+ profile_scores[token] = profile_scores.get(token, 0.0) + weight
2077
+ blocked = set(source_ids)
2078
+ rows = []
2079
+ denom = max(sum(abs(value) for value in profile_scores.values()) ** 0.5, 1.0)
2080
+ for item_id, tokens in tqdm(
2081
+ token_index.items(),
2082
+ total=len(token_index),
2083
+ desc="score token KNN",
2084
+ leave=False,
2085
+ ):
2086
+ if item_id in blocked:
2087
+ continue
2088
+ score = sum(profile_scores.get(token, 0.0) for token in tokens) / denom
2089
+ rows.append((item_id, float(score)))
2090
+ rows.sort(key=lambda value: value[1], reverse=True)
2091
+ return rows[:k]
2092
+
2093
+
2094
+ def evaluate_survey_leave_one_out(
2095
+ profiles: Sequence[dict],
2096
+ model: CLEPIDTN,
2097
+ item_index: torch.Tensor,
2098
+ cfg: RecConfig,
2099
+ k: int = 10,
2100
+ ) -> dict[str, float]:
2101
+ hits, total = 0, 0
2102
+ for profile in tqdm(profiles, total=len(profiles), desc="evaluate survey", leave=False):
2103
+ positives = [
2104
+ rating
2105
+ for rating in profile["ratings"]
2106
+ if rating.get("item_id") is not None and rating["weight"] > 0
2107
+ ]
2108
+ if len(positives) < 2:
2109
+ continue
2110
+ target = positives[-1]["item_id"]
2111
+ history = [rating["item_id"] for rating in positives[:-1]]
2112
+ weights = [rating["weight"] for rating in positives[:-1]]
2113
+ try:
2114
+ recs = recommend_from_history(
2115
+ model,
2116
+ item_index,
2117
+ history,
2118
+ user_id=None,
2119
+ activity=len(history),
2120
+ cfg=cfg,
2121
+ k=k,
2122
+ history_weights=weights,
2123
+ )
2124
+ except (ValueError, RuntimeError):
2125
+ # Skip users whose history is too sparse or entirely negative
2126
+ # after weight normalization.
2127
+ continue
2128
+ if not recs:
2129
+ total += 1
2130
+ continue
2131
+ hits += int(target in [item_id for item_id, _ in recs])
2132
+ total += 1
2133
+ return {f"Survey_HR@{k}": hits / max(total, 1), "survey_users": float(total)}
2134
+
2135
+
2136
+ def anonymous_history_from_titles(query_titles: list[str], item_meta: pd.DataFrame, item_to_idx: dict[str, int]) -> list[int]:
2137
+ out = []
2138
+ titles = item_meta[["item_key", "title"]].copy()
2139
+ titles["norm"] = titles["title"].fillna("").map(lambda x: re.sub(r"[^a-z0-9]+", " ", str(x).lower()).strip())
2140
+ for q in query_titles:
2141
+ qn = re.sub(r"[^a-z0-9]+", " ", q.lower()).strip()
2142
+ hit = titles.loc[titles["norm"].str.contains(re.escape(qn), na=False)].head(1)
2143
+ if not hit.empty:
2144
+ out.append(item_to_idx[hit.iloc[0]["item_key"]])
2145
+ return out
2146
+
2147
+
2148
+ def anonymous_history_from_ratings(
2149
+ title_ratings: dict[str, float] | list[tuple[str, float]],
2150
+ item_meta: pd.DataFrame,
2151
+ item_to_idx: dict[str, int],
2152
+ ) -> tuple[list[int], list[float]]:
2153
+ pairs = title_ratings.items() if isinstance(title_ratings, dict) else title_ratings
2154
+ history_ids, ratings = [], []
2155
+ for title, rating in pairs:
2156
+ matched = anonymous_history_from_titles([title], item_meta, item_to_idx)
2157
+ if matched:
2158
+ history_ids.append(matched[0])
2159
+ ratings.append(float(rating))
2160
+ return history_ids, ratings
2161
+
2162
+
2163
+ def item_from_external_id(
2164
+ source: str,
2165
+ external_id: int,
2166
+ item_meta: pd.DataFrame,
2167
+ item_to_idx: dict[str, int],
2168
+ ) -> dict:
2169
+ """Resolve a TMDB, RAWG, MovieLens, or Steam ID into the model catalog."""
2170
+ source = source.strip().lower()
2171
+ column_by_source = {
2172
+ "tmdb": "tmdb_id",
2173
+ "rawg": "rawg_id",
2174
+ "movielens": "item_key",
2175
+ "movie": "item_key",
2176
+ "steam": "item_key",
2177
+ "game": "item_key",
2178
+ }
2179
+ if source not in column_by_source:
2180
+ raise ValueError(f"source must be one of {sorted(column_by_source)}, got {source!r}")
2181
+
2182
+ if source in {"movielens", "movie"}:
2183
+ matches = item_meta.loc[item_meta["item_key"].eq(f"movie:{int(external_id)}")]
2184
+ elif source in {"steam", "game"}:
2185
+ matches = item_meta.loc[item_meta["item_key"].eq(f"game:{int(external_id)}")]
2186
+ else:
2187
+ column = column_by_source[source]
2188
+ if column not in item_meta.columns:
2189
+ raise ValueError(
2190
+ f"{column!r} is unavailable. Build metadata with HF enrichment for RAWG "
2191
+ "or MovieLens links for TMDB."
2192
+ )
2193
+ numeric_ids = pd.to_numeric(item_meta[column], errors="coerce")
2194
+ matches = item_meta.loc[numeric_ids.eq(int(external_id))]
2195
+
2196
+ matches = matches.loc[matches["item_key"].isin(item_to_idx)].copy()
2197
+ if matches.empty:
2198
+ raise KeyError(f"No catalog item mapped from {source} id {external_id}.")
2199
+ if len(matches) > 1:
2200
+ popularity = pd.to_numeric(matches.get("user_reviews"), errors="coerce").fillna(0)
2201
+ matches = matches.loc[[popularity.idxmax()]]
2202
+ row = matches.iloc[0]
2203
+ return {
2204
+ "item_id": int(item_to_idx[row["item_key"]]),
2205
+ "item_key": row["item_key"],
2206
+ "title": row["title"],
2207
+ "domain": row["domain"],
2208
+ "source": source,
2209
+ "external_id": int(external_id),
2210
+ }
2211
+
2212
+
2213
+ def history_from_external_ratings(
2214
+ external_ratings: Sequence[tuple[str, int, float]],
2215
+ item_meta: pd.DataFrame,
2216
+ item_to_idx: dict[str, int],
2217
+ ignore_missing: bool = False,
2218
+ ) -> tuple[list[int], list[float], list[dict]]:
2219
+ """Resolve `(source, external_id, rating)` triples for anonymous inference."""
2220
+ history_ids, ratings, resolved = [], [], []
2221
+ for source, external_id, rating in external_ratings:
2222
+ try:
2223
+ item = item_from_external_id(source, external_id, item_meta, item_to_idx)
2224
+ except KeyError:
2225
+ if ignore_missing:
2226
+ continue
2227
+ raise
2228
+ history_ids.append(item["item_id"])
2229
+ ratings.append(float(rating))
2230
+ resolved.append(item)
2231
+ if not history_ids:
2232
+ raise ValueError("None of the supplied external IDs mapped to the model catalog.")
2233
+ return history_ids, ratings, resolved
2234
+
2235
+
2236
+ def recommend_from_external_ratings(
2237
+ model: CLEPIDTN,
2238
+ item_index: torch.Tensor,
2239
+ external_ratings: Sequence[tuple[str, int, float]],
2240
+ item_meta: pd.DataFrame,
2241
+ item_to_idx: dict[str, int],
2242
+ cfg: RecConfig,
2243
+ k: int = 10,
2244
+ domain: str | None = None,
2245
+ text_index: torch.Tensor | None = None,
2246
+ ignore_missing: bool = False,
2247
+ **recommend_kwargs,
2248
+ ) -> tuple[list[tuple[int, float]], list[dict]]:
2249
+ history_ids, ratings, resolved = history_from_external_ratings(
2250
+ external_ratings,
2251
+ item_meta,
2252
+ item_to_idx,
2253
+ ignore_missing=ignore_missing,
2254
+ )
2255
+ # External ratings are 1-5 star ratings; convert to [-1, 1] weights
2256
+ # before passing to recommend_from_history.
2257
+ _, normalized_weights = _normalize_history_weights(
2258
+ history_ids, ratings, star_rating_scale=True,
2259
+ )
2260
+ recommendations = recommend_from_history(
2261
+ model,
2262
+ item_index,
2263
+ history_ids,
2264
+ user_id=None,
2265
+ activity=len(history_ids),
2266
+ cfg=cfg,
2267
+ k=k,
2268
+ domain=domain,
2269
+ text_index=text_index,
2270
+ history_weights=normalized_weights,
2271
+ **recommend_kwargs,
2272
+ )
2273
+ return recommendations, resolved
2274
+
2275
+
2276
+ def maybe_enrich_with_rawg(app_id: int) -> dict:
2277
+ import requests
2278
+
2279
+ key = os.getenv("RAWG_API_KEY")
2280
+ if not key:
2281
+ raise RuntimeError("Set RAWG_API_KEY before calling RAWG enrichment.")
2282
+ r = requests.get(f"https://api.rawg.io/api/games/{app_id}", params={"key": key}, timeout=20)
2283
+ r.raise_for_status()
2284
+ return r.json()
2285
+
2286
+
2287
+ def maybe_enrich_with_tmdb(tmdb_id: int) -> dict:
2288
+ import requests
2289
+
2290
+ token = os.getenv("TMDB_BEARER_TOKEN")
2291
+ if not token:
2292
+ raise RuntimeError("Set TMDB_BEARER_TOKEN before calling TMDB enrichment.")
2293
+ r = requests.get(
2294
+ f"https://api.themoviedb.org/3/movie/{tmdb_id}",
2295
+ headers={"Authorization": f"Bearer {token}"},
2296
+ timeout=20,
2297
+ )
2298
+ r.raise_for_status()
2299
+ return r.json()
recommender_api_improved8.py ADDED
@@ -0,0 +1,1330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ recommender_api_improved8.py
3
+ ============================
4
+ FastAPI server for the CL-EPIDTN recommender (improved_8).
5
+ No QuestroDb dependency — all user signals arrive in the request body.
6
+
7
+ Features
8
+ --------
9
+ - Accepts users_ratings.csv-style profiles (survey labels) AND numeric stars.
10
+ - Wishlist / ignore-list items mapped to "Didn't watch but would watch" /
11
+ "Didn't watch and wouldn't watch" signals automatically.
12
+ - Parental-control genre/tag blocking (always case-insensitive).
13
+ - Pagination via `offset` parameter so the backend can fetch more pages.
14
+ - RAG reranking endpoint: score a pre-fetched candidate list with the model.
15
+ - API-safe IDs: accepts `movie_123`, `movie:123`, and returns string IDs.
16
+ - Runtime catalog hot-add for cold-start items.
17
+
18
+ Start with:
19
+ uvicorn recommender_api_improved8:app --host 0.0.0.0 --port 7749 --reload
20
+
21
+ Artifacts directory: ./artifacts_improved8/
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import os
28
+ import pickle
29
+ import re
30
+ import threading
31
+ from contextlib import asynccontextmanager
32
+ from typing import Literal
33
+
34
+ import pandas as pd
35
+ import torch
36
+ import torch.nn as nn
37
+ from fastapi import FastAPI, HTTPException
38
+ from fastapi.middleware.cors import CORSMiddleware
39
+ from pydantic import BaseModel, Field, field_validator
40
+
41
+ from cl_epidtn_recommender_improved_8 import (
42
+ CLEPIDTN,
43
+ PAD,
44
+ RecConfig,
45
+ SURVEY_RATING_VALUES,
46
+ recommend_from_history,
47
+ survey_rating_weight,
48
+ )
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Configuration
53
+ # ---------------------------------------------------------------------------
54
+
55
+ ARTIFACTS_DIR = os.getenv("ARTIFACTS_DIR", "artifacts_improved8")
56
+
57
+ CONFIG = {
58
+ "model_checkpoint": os.getenv(
59
+ "MODEL_CHECKPOINT",
60
+ os.path.join(ARTIFACTS_DIR, "improved_8epochs.pt"),
61
+ ),
62
+ "item_meta_path": os.getenv(
63
+ "ITEM_META_PATH",
64
+ os.path.join(ARTIFACTS_DIR, "item_meta.pkl"),
65
+ ),
66
+ "item_index_path": os.getenv(
67
+ "ITEM_INDEX_PATH",
68
+ os.path.join(ARTIFACTS_DIR, "item_index.pt"),
69
+ ),
70
+ "item_to_idx_path": os.getenv(
71
+ "ITEM_TO_IDX_PATH",
72
+ os.path.join(ARTIFACTS_DIR, "item_to_idx.pkl"),
73
+ ),
74
+ "title_lookup_path": os.getenv(
75
+ "TITLE_LOOKUP_PATH",
76
+ os.path.join(ARTIFACTS_DIR, "title_lookup.pkl"),
77
+ ),
78
+ "text_embeddings_path": os.getenv(
79
+ "TEXT_EMBEDDINGS_PATH",
80
+ os.path.join(ARTIFACTS_DIR, "improved_item_text_embeddings.pt"),
81
+ ),
82
+ "model_version": "improved_8",
83
+ "max_recs": int(os.getenv("MAX_RECS", "100")),
84
+ "text_encoder_model": os.getenv(
85
+ "TEXT_ENCODER_MODEL",
86
+ "sentence-transformers/all-MiniLM-L6-v2",
87
+ ),
88
+ # Over-fetch multiplier: fetch this many more candidates before filtering
89
+ # so that blocked-genre filtering still returns the requested `k` items.
90
+ "overfetch_multiplier": int(os.getenv("OVERFETCH_MULTIPLIER", "5")),
91
+ # Title-family calibration helps single-seed profiles prefer obvious
92
+ # franchise neighbors before broad genre matches like "open world action".
93
+ "title_family_boost": float(os.getenv("TITLE_FAMILY_BOOST", "0.40")),
94
+ "title_family_extra_candidates": int(os.getenv("TITLE_FAMILY_EXTRA_CANDIDATES", "50")),
95
+ }
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Rating label mappings — matches users_ratings.csv exactly
100
+ # ---------------------------------------------------------------------------
101
+
102
+ # Survey text labels → numeric weight in [-1, 1]
103
+ _LABEL_WEIGHTS: dict[str, float] = {
104
+ label: survey_rating_weight(label) for label in SURVEY_RATING_VALUES
105
+ }
106
+
107
+ # Star ratings (1–5) → weight in [-1, 1]
108
+ def _stars_to_weight(stars: float) -> float:
109
+ return max(-1.0, min((stars - 3.0) / 2.0, 1.0))
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Genre / tag lookup builder
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _build_item_genre_lookup(
117
+ item_meta: pd.DataFrame,
118
+ item_to_idx: dict[str, int],
119
+ ) -> dict[int, set[str]]:
120
+ """Build item_id → set of lowercase genre/tag strings.
121
+
122
+ Sources (all lowercased):
123
+ - `hf_genres` column (pipe- or comma-separated)
124
+ - `hf_tags` column (pipe- or comma-separated)
125
+ - first 20 tokens from the `tokens` column (genre-like keywords)
126
+ """
127
+ lookup: dict[int, set[str]] = {}
128
+ for row in item_meta.itertuples(index=False):
129
+ idx = item_to_idx.get(row.item_key)
130
+ if idx is None:
131
+ continue
132
+ genres: set[str] = set()
133
+ for col_name in ("hf_genres", "hf_tags"):
134
+ value = getattr(row, col_name, None)
135
+ if value and not (isinstance(value, float) and math.isnan(value)):
136
+ for part in re.split(r"[|,]", str(value)):
137
+ part = part.strip().lower()
138
+ if part:
139
+ genres.add(part)
140
+ # Also extract the first tokens which are usually genre keywords
141
+ tokens_value = getattr(row, "tokens", "")
142
+ if tokens_value and isinstance(tokens_value, str):
143
+ for tok in tokens_value.split()[:20]:
144
+ tok = tok.strip().lower()
145
+ if len(tok) > 2:
146
+ genres.add(tok)
147
+ lookup[idx] = genres
148
+ return lookup
149
+
150
+
151
+ def _filter_blocked_genres(
152
+ recommendations: list[tuple[int, float]],
153
+ blocked_genres: set[str],
154
+ genre_lookup: dict[int, set[str]],
155
+ ) -> list[tuple[int, float]]:
156
+ """Remove items whose genre set intersects the blocked set."""
157
+ if not blocked_genres:
158
+ return recommendations
159
+ return [
160
+ (item_id, score)
161
+ for item_id, score in recommendations
162
+ if not genre_lookup.get(item_id, set()).intersection(blocked_genres)
163
+ ]
164
+
165
+
166
+ def _clean_int_id(value) -> int | None:
167
+ if value is None or pd.isna(value):
168
+ return None
169
+ try:
170
+ return int(value)
171
+ except (TypeError, ValueError):
172
+ return None
173
+
174
+
175
+ def _build_provider_id_lookup(
176
+ item_meta: pd.DataFrame,
177
+ item_to_idx: dict[str, int],
178
+ ) -> dict[int, dict[str, int | None]]:
179
+ lookup: dict[int, dict[str, int | None]] = {}
180
+ for row in item_meta.itertuples(index=False):
181
+ idx = item_to_idx.get(row.item_key)
182
+ if idx is None:
183
+ continue
184
+ lookup[idx] = {
185
+ "tmdb_id": _clean_int_id(getattr(row, "tmdb_id", None)),
186
+ "rawg_id": _clean_int_id(getattr(row, "rawg_id", None)),
187
+ }
188
+ return lookup
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Application state
193
+ # ---------------------------------------------------------------------------
194
+
195
+ class AppState:
196
+ model: CLEPIDTN | None = None
197
+ item_index: torch.Tensor | None = None
198
+ text_index: torch.Tensor | None = None
199
+ item_meta: pd.DataFrame | None = None
200
+ item_to_idx: dict[str, int] | None = None
201
+ idx_to_key: dict[int, str] | None = None
202
+ title_lookup: dict[int, str] | None = None
203
+ provider_id_lookup: dict[int, dict[str, int | None]] | None = None
204
+ item_genre_lookup: dict[int, set[str]] | None = None
205
+ cfg: RecConfig | None = None
206
+ lock = threading.RLock()
207
+ hot_added_count: int = 0
208
+
209
+
210
+ state = AppState()
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Startup / shutdown
215
+ # ---------------------------------------------------------------------------
216
+
217
+ @asynccontextmanager
218
+ async def lifespan(app: FastAPI):
219
+ _load_artifacts()
220
+ yield
221
+
222
+
223
+ def _load_artifacts() -> None:
224
+ with state.lock:
225
+ cfg = RecConfig()
226
+ state.cfg = cfg
227
+
228
+ # item_to_idx
229
+ with open(CONFIG["item_to_idx_path"], "rb") as f:
230
+ state.item_to_idx = pickle.load(f)
231
+ state.idx_to_key = {v: k for k, v in state.item_to_idx.items()}
232
+
233
+ # item_meta
234
+ with open(CONFIG["item_meta_path"], "rb") as f:
235
+ state.item_meta = pickle.load(f)
236
+
237
+ # title_lookup
238
+ if os.path.exists(CONFIG["title_lookup_path"]):
239
+ with open(CONFIG["title_lookup_path"], "rb") as f:
240
+ state.title_lookup = pickle.load(f)
241
+ else:
242
+ state.title_lookup = {}
243
+
244
+ # text embeddings (optional — enhances content-based scoring)
245
+ text_emb_path = CONFIG["text_embeddings_path"]
246
+ if os.path.exists(text_emb_path):
247
+ raw = torch.load(text_emb_path, map_location="cpu", weights_only=False)
248
+ if isinstance(raw, dict) and "tensor" in raw:
249
+ state.text_index = raw["tensor"]
250
+ elif isinstance(raw, torch.Tensor):
251
+ state.text_index = raw
252
+ else:
253
+ state.text_index = None
254
+ print(f"[startup] text embeddings loaded: {state.text_index.shape if state.text_index is not None else 'N/A'}")
255
+ else:
256
+ state.text_index = None
257
+
258
+ # item_index
259
+ state.item_index = torch.load(
260
+ CONFIG["item_index_path"], map_location="cpu", weights_only=False,
261
+ )
262
+
263
+ # model
264
+ checkpoint = torch.load(
265
+ CONFIG["model_checkpoint"], map_location=cfg.device, weights_only=False,
266
+ )
267
+ model: CLEPIDTN = checkpoint["model"]
268
+ model.to(cfg.device)
269
+ model.eval()
270
+ state.model = model
271
+
272
+ # genre lookup
273
+ state.item_genre_lookup = _build_item_genre_lookup(
274
+ state.item_meta, state.item_to_idx,
275
+ )
276
+ state.provider_id_lookup = _build_provider_id_lookup(
277
+ state.item_meta, state.item_to_idx,
278
+ )
279
+ state.hot_added_count = 0
280
+
281
+ print(
282
+ f"[startup] model loaded | "
283
+ f"{len(state.item_to_idx):,} items | "
284
+ f"item_index {state.item_index.shape} | "
285
+ f"genres tracked: {len(state.item_genre_lookup):,} items"
286
+ )
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # FastAPI app
291
+ # ---------------------------------------------------------------------------
292
+
293
+ app = FastAPI(
294
+ title="Questro Recommender API (improved_8)",
295
+ version=CONFIG["model_version"],
296
+ description=(
297
+ "CL-EPIDTN recommendation engine with parental-control genre blocking, "
298
+ "pagination, and a RAG reranking tool."
299
+ ),
300
+ lifespan=lifespan,
301
+ )
302
+
303
+ app.add_middleware(
304
+ CORSMiddleware,
305
+ allow_origins=["*"],
306
+ allow_methods=["*"],
307
+ allow_headers=["*"],
308
+ )
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Pydantic models
313
+ # ---------------------------------------------------------------------------
314
+
315
+ class RatingItem(BaseModel):
316
+ """A single item rating — supports BOTH survey labels and numeric stars.
317
+
318
+ Provide exactly ONE of `rating` (survey label) or `stars` (numeric).
319
+ You can also use `source` to signal wishlist/ignore items.
320
+ """
321
+ item_id: str = Field(
322
+ description=(
323
+ 'Item identifier in the format "movie_123", "movie:123", '
324
+ '"game_123", or "game:123".'
325
+ ),
326
+ )
327
+ title: str | None = Field(
328
+ default=None,
329
+ description="Human-readable title (optional, for logging only).",
330
+ )
331
+ type: Literal["movie", "game"] | None = Field(
332
+ default=None,
333
+ description='Domain hint. Inferred from item_id prefix if omitted.',
334
+ )
335
+ rating: str | None = Field(
336
+ default=None,
337
+ description=(
338
+ "Survey-style label. One of: "
339
+ '"5 Stars", "4 Stars", "3 Stars", "2 Stars", "1 Star", '
340
+ '"Didn\'t watch but would watch", "Didn\'t play but would play", '
341
+ '"Didn\'t watch and wouldn\'t watch", "Didn\'t play and wouldn\'t play".'
342
+ ),
343
+ )
344
+ stars: float | None = Field(
345
+ default=None,
346
+ ge=1.0,
347
+ le=5.0,
348
+ description="Numeric star rating (1.0–5.0). Alternative to `rating`.",
349
+ )
350
+ source: Literal["rating", "wishlist", "ignore"] | None = Field(
351
+ default=None,
352
+ description=(
353
+ 'Signal source. "wishlist" → treated as "would watch/play" (3.5 stars). '
354
+ '"ignore" → treated as "wouldn\'t watch/play" (1.5 stars). '
355
+ '"rating" or null → uses `rating` or `stars` field.'
356
+ ),
357
+ )
358
+
359
+ @field_validator("rating", mode="before")
360
+ @classmethod
361
+ def _validate_label(cls, v):
362
+ if v is not None and v not in SURVEY_RATING_VALUES:
363
+ raise ValueError(
364
+ f"Invalid rating label: {v!r}. "
365
+ f"Must be one of: {list(SURVEY_RATING_VALUES.keys())}"
366
+ )
367
+ return v
368
+
369
+
370
+ class UserProfile(BaseModel):
371
+ """User profile matching users_ratings.csv schema."""
372
+ age: int | None = Field(default=None, ge=1, le=120)
373
+ gender: str | None = None
374
+ profession: str | None = None
375
+ country: str | None = None
376
+ movie_genres_fav: str | None = Field(
377
+ default=None,
378
+ description='Pipe-separated favourite movie genres, e.g. "Action|Comedy".',
379
+ )
380
+ movie_genres_disliked: str | None = Field(
381
+ default=None,
382
+ description='Pipe-separated disliked movie genres.',
383
+ )
384
+ game_genres_fav: str | None = Field(
385
+ default=None,
386
+ description='Pipe-separated favourite game genres.',
387
+ )
388
+ game_genres_disliked: str | None = Field(
389
+ default=None,
390
+ description='Pipe-separated disliked game genres.',
391
+ )
392
+ ratings: list[RatingItem] = Field(
393
+ min_length=1,
394
+ description="User's interaction history (at least 1 item).",
395
+ )
396
+
397
+
398
+ class RecommendRequest(BaseModel):
399
+ """Request body for /recommend."""
400
+ user: UserProfile
401
+ domain: Literal["movie", "game"] | None = Field(
402
+ default=None,
403
+ description='Filter to "movie" or "game". Omit for cross-domain.',
404
+ )
405
+ k: int = Field(
406
+ default=10,
407
+ ge=1,
408
+ le=100,
409
+ description="Number of recommendations per page.",
410
+ )
411
+ offset: int = Field(
412
+ default=0,
413
+ ge=0,
414
+ description="Pagination offset. 0 = first page, k = second page, etc.",
415
+ )
416
+ blocked_genres: list[str] | None = Field(
417
+ default=None,
418
+ description=(
419
+ "Genres/tags to block (parental controls). "
420
+ "Case-insensitive. Pass null or omit for no blocking."
421
+ ),
422
+ )
423
+
424
+
425
+ class CandidateItem(BaseModel):
426
+ """An item from the RAG's candidate list."""
427
+ item_id: str = Field(
428
+ description='Item identifier, e.g. "movie_155", "movie:155", "game_271590", or "game:271590".',
429
+ )
430
+ title: str | None = Field(default=None, description="Optional title.")
431
+
432
+
433
+ class CatalogNewItem(BaseModel):
434
+ """Register a catalog item that was not present when improved_8 was trained."""
435
+ item_id: str = Field(
436
+ description='API/internal item ID. Accepts "movie_123", "movie:123", "game_123", or "game:123".',
437
+ )
438
+ title: str
439
+ domain: Literal["movie", "game"] | None = Field(
440
+ default=None,
441
+ description="Optional domain override. Inferred from item_id when omitted.",
442
+ )
443
+ description: str = ""
444
+ genres: str = Field(default="", description='Pipe- or comma-separated genres, e.g. "Action|RPG".')
445
+ tags: str = Field(default="", description="Pipe- or comma-separated tags.")
446
+ provider_id: int | None = Field(
447
+ default=None,
448
+ description="TMDB ID for movies, RAWG ID for games.",
449
+ )
450
+
451
+
452
+ class CatalogAddRequest(BaseModel):
453
+ items: list[CatalogNewItem] = Field(min_length=1, max_length=500)
454
+
455
+
456
+ class CatalogAddResponse(BaseModel):
457
+ added: list[str]
458
+ already_exists: list[str]
459
+ failed: dict[str, str]
460
+ n_items: int
461
+ text_index_updated: bool
462
+
463
+
464
+ class ReloadResponse(BaseModel):
465
+ status: str
466
+ n_items: int
467
+ text_index_loaded: bool
468
+ hot_added_count: int
469
+
470
+
471
+ class RerankRequest(BaseModel):
472
+ """Request body for /recommend/rerank (RAG tool)."""
473
+ user: UserProfile
474
+ candidate_items: list[CandidateItem] = Field(
475
+ min_length=1,
476
+ description="Items fetched by the RAG to be re-ranked by the recommender.",
477
+ )
478
+ blocked_genres: list[str] | None = Field(
479
+ default=None,
480
+ description="Genres/tags to block (case-insensitive).",
481
+ )
482
+ k: int | None = Field(
483
+ default=None,
484
+ ge=1,
485
+ le=100,
486
+ description="Max items to return. null = return all candidates ranked.",
487
+ )
488
+
489
+
490
+ class RecommendationItem(BaseModel):
491
+ item_id: int | None = Field(
492
+ default=None,
493
+ description="Backend provider ID: TMDB ID for movies, RAWG ID for games.",
494
+ )
495
+ item_key: str
496
+ title: str
497
+ domain: Literal["movie", "game"]
498
+ score: float
499
+
500
+
501
+ class RecommendResponse(BaseModel):
502
+ count: int
503
+ total_available: int
504
+ domain: str | None
505
+ offset: int
506
+ k: int
507
+ recommendations: list[RecommendationItem]
508
+ signals_used: int
509
+ blocked_genres: list[str]
510
+ model_version: str
511
+ has_more: bool
512
+
513
+
514
+ class RerankResponse(BaseModel):
515
+ count: int
516
+ recommendations: list[RecommendationItem]
517
+ signals_used: int
518
+ candidates_submitted: int
519
+ candidates_matched: int
520
+ blocked_genres: list[str]
521
+ model_version: str
522
+
523
+
524
+ class HealthResponse(BaseModel):
525
+ status: str
526
+ model_loaded: bool
527
+ n_items: int
528
+ n_genres_tracked: int
529
+ text_index_loaded: bool
530
+ model_version: str
531
+ hot_added_count: int
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # Helpers
536
+ # ---------------------------------------------------------------------------
537
+
538
+ def _require_model() -> None:
539
+ if state.model is None or state.item_index is None:
540
+ raise HTTPException(status_code=503, detail="Model not loaded yet.")
541
+
542
+
543
+ def _parse_item_key(item_id: str, domain_hint: str | None = None) -> str | None:
544
+ """Normalize API IDs to internal item keys (`movie:123`, `game:123`)."""
545
+ value = str(item_id).strip()
546
+ if not value:
547
+ return None
548
+ if ":" in value:
549
+ domain, raw_id = value.split(":", 1)
550
+ elif "_" in value:
551
+ domain, raw_id = value.split("_", 1)
552
+ else:
553
+ return None
554
+ domain = (domain_hint or domain).strip().lower()
555
+ raw_id = raw_id.strip()
556
+ if domain not in {"movie", "game"} or not raw_id:
557
+ return None
558
+ return f"{domain}:{raw_id}"
559
+
560
+
561
+ def _catalog_tokens(*values: str) -> str:
562
+ text = " ".join(value for value in values if value)
563
+ return " ".join(t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) > 2)
564
+
565
+
566
+ _TITLE_VERSION_WORDS = {
567
+ "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x",
568
+ "one", "two", "three", "four", "five",
569
+ "definitive", "edition", "complete", "collection", "remastered",
570
+ "remaster", "reload", "reloaded", "deluxe", "ultimate", "goty",
571
+ "enhanced", "pack", "dlc", "expansion", "pass", "starter",
572
+ "content", "mod", "multiplayer",
573
+ }
574
+
575
+
576
+ def _title_family_tokens(title: str) -> tuple[str, ...]:
577
+ """Extract stable franchise-like title tokens.
578
+
579
+ This intentionally drops version/edition/DLC words so "Grand Theft Auto V"
580
+ can match "Grand Theft Auto IV" without hard-coding either title.
581
+ """
582
+ value = re.sub(r"\((?:19|20)\d{2}\)", " ", str(title).lower())
583
+ value = value.replace("™", " ").replace("®", " ")
584
+ tokens = [
585
+ token
586
+ for token in re.findall(r"[a-z0-9]+", value)
587
+ if len(token) > 1
588
+ and not token.isdigit()
589
+ and token not in _TITLE_VERSION_WORDS
590
+ ]
591
+ return tuple(tokens[:4])
592
+
593
+
594
+ def _family_prefix_score(a: tuple[str, ...], b: tuple[str, ...]) -> float:
595
+ if len(a) < 2 or len(b) < 2:
596
+ return 0.0
597
+ shared_prefix = 0
598
+ for left, right in zip(a, b):
599
+ if left != right:
600
+ break
601
+ shared_prefix += 1
602
+ if shared_prefix >= 3:
603
+ return 1.0
604
+ if shared_prefix == 2:
605
+ return 0.65
606
+ return 0.0
607
+
608
+
609
+ def _positive_history_ids(history_ids: list[int], weights: list[float]) -> list[int]:
610
+ return [idx for idx, weight in zip(history_ids, weights) if weight > 0]
611
+
612
+
613
+ def _title_family_candidates(
614
+ history_ids: list[int],
615
+ weights: list[float],
616
+ domain: str | None,
617
+ ) -> list[int]:
618
+ if state.item_meta is None or state.item_to_idx is None:
619
+ return []
620
+ positive_families = [
621
+ _title_family_tokens((state.title_lookup or {}).get(item_id, ""))
622
+ for item_id in _positive_history_ids(history_ids, weights)
623
+ ]
624
+ positive_families = [family for family in positive_families if len(family) >= 2]
625
+ if not positive_families:
626
+ return []
627
+
628
+ history_set = set(history_ids)
629
+ rows: list[tuple[int, float]] = []
630
+ for row in state.item_meta.itertuples(index=False):
631
+ item_key = getattr(row, "item_key", None)
632
+ if not item_key:
633
+ continue
634
+ item_id = state.item_to_idx.get(item_key)
635
+ if item_id is None or item_id in history_set:
636
+ continue
637
+ row_domain = getattr(row, "domain", None)
638
+ if domain is not None and row_domain != domain:
639
+ continue
640
+ family = _title_family_tokens(getattr(row, "title", ""))
641
+ score = max((_family_prefix_score(src, family) for src in positive_families), default=0.0)
642
+ if score > 0:
643
+ rows.append((item_id, score))
644
+ rows.sort(key=lambda pair: pair[1], reverse=True)
645
+ return [item_id for item_id, _ in rows[: CONFIG["title_family_extra_candidates"]]]
646
+
647
+
648
+ def _apply_title_family_boost(
649
+ recommendations: list[tuple[int, float]],
650
+ history_ids: list[int],
651
+ weights: list[float],
652
+ ) -> list[tuple[int, float]]:
653
+ boost = CONFIG["title_family_boost"]
654
+ if boost <= 0:
655
+ return recommendations
656
+ positive_families = [
657
+ _title_family_tokens((state.title_lookup or {}).get(item_id, ""))
658
+ for item_id in _positive_history_ids(history_ids, weights)
659
+ ]
660
+ positive_families = [family for family in positive_families if len(family) >= 2]
661
+ if not positive_families:
662
+ return recommendations
663
+
664
+ adjusted: list[tuple[int, float]] = []
665
+ for item_id, score in recommendations:
666
+ title = (state.title_lookup or {}).get(item_id, "")
667
+ family = _title_family_tokens(title)
668
+ family_score = max((_family_prefix_score(src, family) for src in positive_families), default=0.0)
669
+ adjusted.append((item_id, float(score) + boost * family_score))
670
+ adjusted.sort(key=lambda pair: pair[1], reverse=True)
671
+ return adjusted
672
+
673
+
674
+ def _genre_set_from_new_item(item: CatalogNewItem) -> set[str]:
675
+ out: set[str] = set()
676
+ for value in (item.genres, item.tags):
677
+ for part in re.split(r"[|,]", value or ""):
678
+ part = part.strip().lower()
679
+ if part:
680
+ out.add(part)
681
+ return out
682
+
683
+
684
+ def _candidate_ids_for_loaded_index() -> torch.Tensor:
685
+ """Use the loaded item_index length, not only the model embedding table length."""
686
+ if state.item_index is None:
687
+ return torch.empty(0, dtype=torch.long)
688
+ return torch.arange(1, state.item_index.size(0), dtype=torch.long)
689
+
690
+
691
+ def _expand_model_for_hot_item(model: CLEPIDTN, new_max_idx: int, domain_ids_map: dict[int, int]) -> None:
692
+ """Grow model lookup tables for a batch of hot-added items in one pass.
693
+
694
+ Args:
695
+ model: The live CLEPIDTN model.
696
+ new_max_idx: The highest new item index in this batch.
697
+ domain_ids_map: {item_idx: domain_id (0=movie,1=game)} for every new item.
698
+
699
+ All intermediate tensors are built on CPU and moved to the target device in a
700
+ single .to() call — this avoids accumulating CUDA async errors from repeated
701
+ per-item GPU allocations.
702
+ """
703
+ device = model.item_id.weight.device
704
+
705
+ # ── item_id embedding ────────────────────────────────────────────────────
706
+ old_emb = model.item_id
707
+ if new_max_idx >= old_emb.num_embeddings:
708
+ new_emb = nn.Embedding(new_max_idx + 1, old_emb.embedding_dim, padding_idx=PAD)
709
+ with torch.no_grad():
710
+ new_emb.weight.zero_()
711
+ new_emb.weight[: old_emb.num_embeddings].copy_(old_emb.weight.data.cpu())
712
+ model.item_id = new_emb.to(device)
713
+
714
+ # ── item_token_ids ───────────────────────────────────────────────────────
715
+ if new_max_idx >= model.item_token_ids.size(0):
716
+ extra = new_max_idx + 1 - model.item_token_ids.size(0)
717
+ pad_rows = torch.zeros(
718
+ (extra, model.item_token_ids.size(1)),
719
+ dtype=model.item_token_ids.dtype,
720
+ )
721
+ model.item_token_ids = torch.cat(
722
+ [model.item_token_ids.cpu(), pad_rows], dim=0
723
+ ).to(device)
724
+
725
+ # ── item_domain_ids ──────────────────────────────────────────────────────
726
+ if new_max_idx >= model.item_domain_ids.size(0):
727
+ extra = new_max_idx + 1 - model.item_domain_ids.size(0)
728
+ # Default 0 (movie); will be overwritten per-item below.
729
+ pad_domains = torch.zeros(extra, dtype=model.item_domain_ids.dtype)
730
+ model.item_domain_ids = torch.cat(
731
+ [model.item_domain_ids.cpu(), pad_domains], dim=0
732
+ ).to(device)
733
+
734
+ for idx, domain_id in domain_ids_map.items():
735
+ model.item_domain_ids[idx] = domain_id
736
+
737
+
738
+ def _encode_catalog_text(items: list[CatalogNewItem]) -> torch.Tensor | None:
739
+ try:
740
+ from sentence_transformers import SentenceTransformer
741
+ except ImportError:
742
+ return None
743
+ # Always encode on CPU — the main CUDA context may be in an error state
744
+ # from a stale device-side assert, and loading a second model to the same
745
+ # GPU device would surface that error here and crash the server.
746
+ encoder = SentenceTransformer(CONFIG["text_encoder_model"], device="cpu")
747
+ texts = [
748
+ f"{item.title}. {item.description} {item.genres} {item.tags}".strip()
749
+ for item in items
750
+ ]
751
+ emb = encoder.encode(texts, normalize_embeddings=True, convert_to_numpy=True)
752
+ return torch.tensor(emb, dtype=torch.float32)
753
+
754
+
755
+ def _resolve_rating_item(item: RatingItem) -> tuple[int, float] | None:
756
+ """Resolve a single RatingItem to (model_item_id, weight).
757
+
758
+ Returns None if the item can't be resolved.
759
+ """
760
+ # Determine weight
761
+ weight: float
762
+ if item.source == "wishlist":
763
+ weight = _LABEL_WEIGHTS.get("Didn't watch but would watch", 0.25)
764
+ elif item.source == "ignore":
765
+ weight = _LABEL_WEIGHTS.get("Didn't watch and wouldn't watch", -0.75)
766
+ elif item.rating is not None:
767
+ weight = _LABEL_WEIGHTS.get(item.rating, 0.0)
768
+ elif item.stars is not None:
769
+ weight = _stars_to_weight(item.stars)
770
+ else:
771
+ # No rating info at all — treat as mild positive
772
+ weight = 0.25
773
+
774
+ item_key = _parse_item_key(item.item_id, item.type)
775
+ if item_key is None:
776
+ return None
777
+
778
+ model_idx = state.item_to_idx.get(item_key)
779
+ if model_idx is None:
780
+ return None
781
+
782
+ return model_idx, weight
783
+
784
+
785
+ def _resolve_user_profile(
786
+ profile: UserProfile,
787
+ ) -> tuple[list[int], list[float]]:
788
+ """Convert a UserProfile's ratings into (history_ids, weights)."""
789
+ history_ids: list[int] = []
790
+ weights: list[float] = []
791
+ for item in profile.ratings:
792
+ result = _resolve_rating_item(item)
793
+ if result is not None:
794
+ history_ids.append(result[0])
795
+ weights.append(result[1])
796
+ return history_ids, weights
797
+
798
+
799
+ def _format_recommendation(
800
+ item_id: int,
801
+ score: float,
802
+ ) -> RecommendationItem:
803
+ """Map a model item_id + score into a response item."""
804
+ item_key = state.idx_to_key.get(item_id, "")
805
+ domain_part, _, _ = item_key.partition(":")
806
+ title = (state.title_lookup or {}).get(item_id, item_key)
807
+ provider_ids = (state.provider_id_lookup or {}).get(item_id, {})
808
+ provider_item_id = (
809
+ provider_ids.get("tmdb_id")
810
+ if domain_part == "movie"
811
+ else provider_ids.get("rawg_id")
812
+ if domain_part == "game"
813
+ else None
814
+ )
815
+ return RecommendationItem(
816
+ item_id=provider_item_id,
817
+ item_key=item_key,
818
+ title=title,
819
+ domain=domain_part if domain_part in {"movie", "game"} else "movie",
820
+ score=round(float(score), 6),
821
+ )
822
+
823
+
824
+ def _normalize_blocked(blocked_genres: list[str] | None) -> set[str]:
825
+ """Return a lowercased set of blocked genres/tags."""
826
+ if not blocked_genres:
827
+ return set()
828
+ return {g.strip().lower() for g in blocked_genres if g.strip()}
829
+
830
+
831
+ # ---------------------------------------------------------------------------
832
+ # Endpoints
833
+ # ---------------------------------------------------------------------------
834
+
835
+ @app.get("/health", response_model=HealthResponse, tags=["Meta"])
836
+ def health():
837
+ """Health check — verify the model is loaded before sending requests."""
838
+ with state.lock:
839
+ return HealthResponse(
840
+ status="ok",
841
+ model_loaded=state.model is not None,
842
+ n_items=len(state.item_to_idx) if state.item_to_idx else 0,
843
+ n_genres_tracked=len(state.item_genre_lookup) if state.item_genre_lookup else 0,
844
+ text_index_loaded=state.text_index is not None,
845
+ model_version=CONFIG["model_version"],
846
+ hot_added_count=state.hot_added_count,
847
+ )
848
+
849
+
850
+ @app.get("/genres", tags=["Meta"], summary="List all genres available for blocking")
851
+ def list_genres():
852
+ """Return all unique genres/tags in the catalog (lowercased).
853
+
854
+ Use this to populate the parental-controls UI.
855
+ """
856
+ if not state.item_genre_lookup:
857
+ return {"genres": []}
858
+ all_genres: set[str] = set()
859
+ for genres in state.item_genre_lookup.values():
860
+ all_genres.update(genres)
861
+ # Return sorted, keeping only meaningful genre-like tokens (len > 2)
862
+ return {"genres": sorted(g for g in all_genres if len(g) > 2)}
863
+
864
+
865
+ @app.post(
866
+ "/admin/reload",
867
+ response_model=ReloadResponse,
868
+ tags=["Admin"],
869
+ summary="Reload model artifacts from disk",
870
+ )
871
+ def reload_artifacts():
872
+ """Reload all on-disk artifacts. Runtime hot-added items are intentionally cleared."""
873
+ _load_artifacts()
874
+ return ReloadResponse(
875
+ status="ok",
876
+ n_items=len(state.item_to_idx) if state.item_to_idx else 0,
877
+ text_index_loaded=state.text_index is not None,
878
+ hot_added_count=state.hot_added_count,
879
+ )
880
+
881
+
882
+ @app.post(
883
+ "/catalog/add",
884
+ response_model=CatalogAddResponse,
885
+ tags=["Catalog"],
886
+ summary="Hot-add cold-start catalog items at runtime",
887
+ )
888
+ def add_catalog_items(request: CatalogAddRequest):
889
+ """Register new catalog items without retraining.
890
+
891
+ Hot-added items receive zero learned embeddings, but can rank through text
892
+ similarity when `sentence-transformers` is installed and text embeddings are
893
+ loaded. They are runtime-only; use `/admin/reload` or restart to return to
894
+ the persisted artifact state.
895
+ """
896
+ _require_model()
897
+
898
+ added: list[str] = []
899
+ already_exists: list[str] = []
900
+ failed: dict[str, str] = {}
901
+ items_to_encode: list[CatalogNewItem] = []
902
+ ids_to_encode: list[int] = []
903
+
904
+ with state.lock:
905
+ if state.item_to_idx is None or state.idx_to_key is None:
906
+ raise HTTPException(status_code=503, detail="Catalog mappings not loaded.")
907
+ if state.item_index is None or state.model is None:
908
+ raise HTTPException(status_code=503, detail="Model index not loaded.")
909
+
910
+ next_idx = max(state.idx_to_key.keys(), default=0) + 1
911
+
912
+ # Collect the highest new index and domain mapping for a single batch
913
+ # expansion after the loop (avoids repeated GPU alloc/cat per item).
914
+ new_max_idx: int = -1
915
+ domain_ids_map: dict[int, int] = {}
916
+ new_meta_rows: list[dict] = []
917
+
918
+ for item in request.items:
919
+ item_key = _parse_item_key(item.item_id, item.domain)
920
+ if item_key is None:
921
+ failed[item.item_id] = "Invalid item_id. Expected movie/game with '_' or ':'."
922
+ continue
923
+ domain, _, _ = item_key.partition(":")
924
+ if item_key in state.item_to_idx:
925
+ already_exists.append(item_key)
926
+ continue
927
+
928
+ new_idx = next_idx
929
+ next_idx += 1
930
+
931
+ state.item_to_idx[item_key] = new_idx
932
+ state.idx_to_key[new_idx] = item_key
933
+ if state.title_lookup is None:
934
+ state.title_lookup = {}
935
+ state.title_lookup[new_idx] = item.title
936
+ new_max_idx = max(new_max_idx, new_idx)
937
+ domain_ids_map[new_idx] = 0 if domain == "movie" else 1
938
+
939
+ if state.item_genre_lookup is None:
940
+ state.item_genre_lookup = {}
941
+ genres = _genre_set_from_new_item(item)
942
+ tokens = _catalog_tokens(item.genres, item.tags, item.title, item.description)
943
+ for tok in tokens.split()[:20]:
944
+ genres.add(tok)
945
+ state.item_genre_lookup[new_idx] = genres
946
+ if state.provider_id_lookup is None:
947
+ state.provider_id_lookup = {}
948
+ state.provider_id_lookup[new_idx] = {
949
+ "tmdb_id": item.provider_id if domain == "movie" else None,
950
+ "rawg_id": item.provider_id if domain == "game" else None,
951
+ }
952
+
953
+ new_row = {
954
+ "item_key": item_key,
955
+ "title": item.title,
956
+ "domain": domain,
957
+ "tokens": tokens,
958
+ "user_reviews": 0,
959
+ "description": item.description,
960
+ "tmdb_id": pd.NA,
961
+ "rawg_id": pd.NA,
962
+ "hf_genres": item.genres,
963
+ "hf_tags": item.tags,
964
+ }
965
+ if domain == "movie" and item.provider_id is not None:
966
+ new_row["tmdb_id"] = item.provider_id
967
+ if domain == "game" and item.provider_id is not None:
968
+ new_row["rawg_id"] = item.provider_id
969
+ new_meta_rows.append(new_row)
970
+
971
+ if state.text_index is not None:
972
+ items_to_encode.append(item)
973
+ ids_to_encode.append(new_idx)
974
+ added.append(item_key)
975
+
976
+ # ── Single-pass tensor expansion for all newly added items ────────
977
+ # Build on CPU then move to device in one .to() call — prevents CUDA
978
+ # async errors from accumulating across per-item GPU allocations.
979
+ if new_max_idx >= 0:
980
+ _device = state.item_index.device
981
+
982
+ try:
983
+ if new_max_idx >= state.item_index.size(0):
984
+ needed = new_max_idx + 1 - state.item_index.size(0)
985
+ zero_vecs = torch.zeros(
986
+ (needed, state.item_index.size(1)),
987
+ dtype=state.item_index.dtype,
988
+ )
989
+ state.item_index = torch.cat(
990
+ [state.item_index.cpu(), zero_vecs], dim=0
991
+ ).to(_device)
992
+ except RuntimeError as _exc:
993
+ # A stale CUDA async error from a previous request can surface
994
+ # here on the first .to(device) call. The catalog state is still
995
+ # valid on CPU; the items will score with zero embeddings and
996
+ # the rerank bounds-guard will keep them out of the CUDA forward.
997
+ print(
998
+ f"Warning: item_index GPU expansion failed (max_idx={new_max_idx}): {_exc}\n"
999
+ "Catalog entries are registered; they will be skipped by rerank."
1000
+ )
1001
+
1002
+ try:
1003
+ _expand_model_for_hot_item(state.model, new_max_idx, domain_ids_map)
1004
+ except RuntimeError as _exc:
1005
+ print(
1006
+ f"Warning: GPU model expansion failed for hot-add batch "
1007
+ f"(max_idx={new_max_idx}): {_exc}\n"
1008
+ "Items are registered in catalog but will score with zero embeddings."
1009
+ )
1010
+
1011
+ if state.text_index is not None and new_max_idx >= state.text_index.size(0):
1012
+ try:
1013
+ needed = new_max_idx + 1 - state.text_index.size(0)
1014
+ zero_text = torch.zeros(
1015
+ (needed, state.text_index.size(1)),
1016
+ dtype=state.text_index.dtype,
1017
+ )
1018
+ state.text_index = torch.cat(
1019
+ [state.text_index.cpu(), zero_text], dim=0
1020
+ ).to(_device)
1021
+ except RuntimeError as _exc:
1022
+ print(f"Warning: text_index GPU expansion failed: {_exc}")
1023
+
1024
+ # Bulk pandas concat — one allocation for the entire batch instead of
1025
+ # one per item (O(N²) → O(N)).
1026
+ if new_meta_rows:
1027
+ state.item_meta = pd.concat(
1028
+ [state.item_meta, pd.DataFrame(new_meta_rows)],
1029
+ ignore_index=True,
1030
+ sort=False,
1031
+ )
1032
+
1033
+ state.hot_added_count += len(added)
1034
+
1035
+ text_index_updated = False
1036
+ if items_to_encode:
1037
+ try:
1038
+ encoded = _encode_catalog_text(items_to_encode)
1039
+ except Exception as _enc_exc:
1040
+ print(f"Warning: text encoding failed, skipping text index update: {_enc_exc}")
1041
+ encoded = None
1042
+ if encoded is not None:
1043
+ try:
1044
+ with state.lock:
1045
+ if state.text_index is not None:
1046
+ if encoded.size(1) != state.text_index.size(1):
1047
+ for item_key in added:
1048
+ failed[item_key] = "Text encoder dimension did not match loaded text index."
1049
+ else:
1050
+ needed = max(ids_to_encode) + 1 - state.text_index.size(0)
1051
+ if needed > 0:
1052
+ pad = torch.zeros(
1053
+ (needed, state.text_index.size(1)),
1054
+ dtype=state.text_index.dtype,
1055
+ )
1056
+ state.text_index = torch.cat([state.text_index, pad], dim=0)
1057
+ state.text_index[ids_to_encode] = encoded.to(state.text_index.dtype)
1058
+ text_index_updated = True
1059
+ except Exception as _tidx_exc:
1060
+ print(f"Warning: text index write failed: {_tidx_exc}")
1061
+
1062
+ return CatalogAddResponse(
1063
+ added=added,
1064
+ already_exists=already_exists,
1065
+ failed=failed,
1066
+ n_items=len(state.item_to_idx) if state.item_to_idx else 0,
1067
+ text_index_updated=text_index_updated,
1068
+ )
1069
+
1070
+
1071
+ @app.post(
1072
+ "/recommend",
1073
+ response_model=RecommendResponse,
1074
+ tags=["Recommendations"],
1075
+ summary="Get personalised recommendations with pagination & parental controls",
1076
+ )
1077
+ def recommend(request: RecommendRequest):
1078
+ """Accept a user profile and return personalised, genre-filtered recommendations.
1079
+
1080
+ - Supports survey labels, numeric stars, and wishlist/ignore signals.
1081
+ - `blocked_genres` removes items matching any blocked genre/tag (case-insensitive).
1082
+ - The API guarantees exactly `k` results (or fewer if the catalog is exhausted),
1083
+ AFTER genre filtering.
1084
+ - Use `offset` for pagination: page 1 = offset 0, page 2 = offset k, etc.
1085
+ """
1086
+ _require_model()
1087
+
1088
+ blocked = _normalize_blocked(request.blocked_genres)
1089
+ desired_total = request.offset + request.k
1090
+ multiplier = CONFIG["overfetch_multiplier"] if blocked else 1
1091
+ fetch_k = min(max(desired_total * multiplier, desired_total + 1), CONFIG["max_recs"] * multiplier)
1092
+
1093
+ # Hold the lock for the entire inference block. /catalog/add swaps tensors
1094
+ # and model attributes (state.item_index, model.item_id, …) under the same
1095
+ # lock; reading them concurrently without the lock risks shape-mismatch
1096
+ # crashes from a mid-swap read. A read-write lock is the next step if this
1097
+ # becomes a throughput bottleneck.
1098
+ with state.lock:
1099
+ # Resolve user ratings
1100
+ history_ids, weights = _resolve_user_profile(request.user)
1101
+ if not history_ids:
1102
+ raise HTTPException(
1103
+ status_code=422,
1104
+ detail="None of the provided items are in the model catalog.",
1105
+ )
1106
+
1107
+ try:
1108
+ raw_recs = recommend_from_history(
1109
+ state.model,
1110
+ state.item_index,
1111
+ history_ids,
1112
+ user_id=None,
1113
+ activity=len(history_ids),
1114
+ cfg=state.cfg,
1115
+ k=fetch_k,
1116
+ domain=request.domain,
1117
+ text_index=state.text_index,
1118
+ history_weights=weights,
1119
+ candidate_item_ids=_candidate_ids_for_loaded_index(),
1120
+ )
1121
+ title_family_ids = _title_family_candidates(history_ids, weights, request.domain)
1122
+ if title_family_ids:
1123
+ candidate_ids = sorted({item_id for item_id, _ in raw_recs}.union(title_family_ids))
1124
+ raw_recs = recommend_from_history(
1125
+ state.model,
1126
+ state.item_index,
1127
+ history_ids,
1128
+ user_id=None,
1129
+ activity=len(history_ids),
1130
+ cfg=state.cfg,
1131
+ k=len(candidate_ids),
1132
+ domain=request.domain,
1133
+ text_index=state.text_index,
1134
+ history_weights=weights,
1135
+ candidate_item_ids=torch.tensor(candidate_ids, dtype=torch.long),
1136
+ )
1137
+ raw_recs = _apply_title_family_boost(raw_recs, history_ids, weights)
1138
+ except ValueError as exc:
1139
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
1140
+
1141
+ # Apply genre blocking
1142
+ if blocked:
1143
+ filtered = _filter_blocked_genres(raw_recs, blocked, state.item_genre_lookup)
1144
+ else:
1145
+ filtered = raw_recs
1146
+
1147
+ # Pagination: slice [offset : offset + k]
1148
+ total_available = len(filtered)
1149
+ page = filtered[request.offset : request.offset + request.k]
1150
+ results = [_format_recommendation(item_id, score) for item_id, score in page]
1151
+
1152
+ return RecommendResponse(
1153
+ count=len(results),
1154
+ total_available=total_available,
1155
+ domain=request.domain,
1156
+ offset=request.offset,
1157
+ k=request.k,
1158
+ recommendations=results,
1159
+ signals_used=len(history_ids),
1160
+ blocked_genres=sorted(blocked) if blocked else [],
1161
+ model_version=CONFIG["model_version"],
1162
+ has_more=(request.offset + request.k) < total_available,
1163
+ )
1164
+
1165
+
1166
+ @app.post(
1167
+ "/recommend/rerank",
1168
+ response_model=RerankResponse,
1169
+ tags=["RAG Tool"],
1170
+ summary="Re-rank a RAG-fetched candidate list using the recommender model",
1171
+ )
1172
+ def rerank(request: RerankRequest):
1173
+ """Score and re-rank a list of externally-fetched items using the user's profile.
1174
+
1175
+ Use this as a tool in your RAG pipeline:
1176
+ 1. Your RAG retrieves a broad list of candidate items.
1177
+ 2. POST them here with the user's profile.
1178
+ 3. The recommender scores each candidate against the user and returns them
1179
+ ranked by personalised relevance, with blocked genres filtered out.
1180
+ """
1181
+ _require_model()
1182
+
1183
+ # Same lock rationale as /recommend: /catalog/add swaps model attributes and
1184
+ # tensor references under state.lock; inference must hold the same lock to
1185
+ # avoid reading a half-swapped tensor.
1186
+ with state.lock:
1187
+ # Resolve user ratings
1188
+ history_ids, weights = _resolve_user_profile(request.user)
1189
+ if not history_ids:
1190
+ raise HTTPException(
1191
+ status_code=422,
1192
+ detail="None of the user's items are in the model catalog.",
1193
+ )
1194
+
1195
+ # Resolve candidate items to model IDs
1196
+ candidate_model_ids: list[int] = []
1197
+ candidate_map: dict[int, CandidateItem] = {}
1198
+ for candidate in request.candidate_items:
1199
+ item_key = _parse_item_key(candidate.item_id)
1200
+ if item_key is None:
1201
+ continue
1202
+ model_idx = state.item_to_idx.get(item_key)
1203
+ if model_idx is not None:
1204
+ candidate_model_ids.append(model_idx)
1205
+ candidate_map[model_idx] = candidate
1206
+
1207
+ if not candidate_model_ids:
1208
+ raise HTTPException(
1209
+ status_code=422,
1210
+ detail="None of the candidate items are in the model catalog.",
1211
+ )
1212
+
1213
+ # Remove candidates that are already in the user's history
1214
+ history_set = set(history_ids)
1215
+ candidate_model_ids = [c for c in candidate_model_ids if c not in history_set]
1216
+
1217
+ if not candidate_model_ids:
1218
+ raise HTTPException(
1219
+ status_code=422,
1220
+ detail="All candidate items are already in the user's history.",
1221
+ )
1222
+
1223
+ # Clamp to the safe scoring range.
1224
+ # Hot-added items whose index exceeds the trained embedding bounds have zero
1225
+ # learned vectors and — more critically — would trigger a CUDA device-side
1226
+ # assert inside item_features() because the embedding kernel asserts
1227
+ # 0 <= idx < num_embeddings. We skip them here; they remain in the catalog
1228
+ # and Gemini/RAG still sees their FAISS similarity scores.
1229
+ n_safe = min(
1230
+ state.model.item_id.num_embeddings,
1231
+ state.model.item_token_ids.size(0),
1232
+ state.model.item_domain_ids.size(0),
1233
+ state.item_index.size(0),
1234
+ )
1235
+ scoreable = [mid for mid in candidate_model_ids if mid < n_safe]
1236
+ unscoreable = [mid for mid in candidate_model_ids if mid >= n_safe]
1237
+
1238
+ if unscoreable:
1239
+ print(
1240
+ f"Rerank: {len(unscoreable)} hot-added candidates skipped "
1241
+ f"(indices {min(unscoreable)}–{max(unscoreable)} beyond safe "
1242
+ f"range {n_safe}). They will use FAISS scores."
1243
+ )
1244
+
1245
+ if not scoreable:
1246
+ # All candidates are hot-added items — return empty so RAG falls back
1247
+ # to raw FAISS scores rather than crashing.
1248
+ return RerankResponse(
1249
+ count=0,
1250
+ recommendations=[],
1251
+ signals_used=len(history_ids),
1252
+ candidates_submitted=len(request.candidate_items),
1253
+ candidates_matched=len(candidate_map),
1254
+ blocked_genres=[],
1255
+ model_version=CONFIG["model_version"],
1256
+ )
1257
+
1258
+ # Bounds-check history too. recommend_from_history passes history_ids
1259
+ # to model.item_features() on the same CUDA path as candidates — an
1260
+ # out-of-range history index (e.g. user rated a hot-added item) poisons
1261
+ # the CUDA context just as badly as an out-of-range candidate would.
1262
+ safe_hist = [(h, w) for h, w in zip(history_ids, weights) if h < n_safe]
1263
+ if not safe_hist:
1264
+ return RerankResponse(
1265
+ count=0,
1266
+ recommendations=[],
1267
+ signals_used=0,
1268
+ candidates_submitted=len(request.candidate_items),
1269
+ candidates_matched=len(candidate_map),
1270
+ blocked_genres=[],
1271
+ model_version=CONFIG["model_version"],
1272
+ )
1273
+ history_ids = [h for h, w in safe_hist]
1274
+ weights = [w for h, w in safe_hist]
1275
+
1276
+ # Score candidates using the model
1277
+ candidate_tensor = torch.tensor(
1278
+ sorted(set(scoreable)), dtype=torch.long,
1279
+ )
1280
+
1281
+ try:
1282
+ scored = recommend_from_history(
1283
+ state.model,
1284
+ state.item_index,
1285
+ history_ids,
1286
+ user_id=None,
1287
+ activity=len(history_ids),
1288
+ cfg=state.cfg,
1289
+ k=len(candidate_tensor),
1290
+ domain=None, # don't domain-filter — candidates are already curated
1291
+ text_index=state.text_index,
1292
+ history_weights=weights,
1293
+ candidate_item_ids=candidate_tensor,
1294
+ )
1295
+ except (ValueError, RuntimeError) as exc:
1296
+ if "CUDA" in str(exc) or "device-side" in str(exc):
1297
+ # A stale CUDA async error surfaced during scoring.
1298
+ # Return empty so RAG falls back to FAISS scores rather than crash.
1299
+ print(f"Rerank: CUDA error during model scoring, falling back to FAISS. {exc}")
1300
+ return RerankResponse(
1301
+ count=0,
1302
+ recommendations=[],
1303
+ signals_used=len(history_ids),
1304
+ candidates_submitted=len(request.candidate_items),
1305
+ candidates_matched=len(candidate_map),
1306
+ blocked_genres=[],
1307
+ model_version=CONFIG["model_version"],
1308
+ )
1309
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
1310
+
1311
+ # Apply genre blocking
1312
+ blocked = _normalize_blocked(request.blocked_genres)
1313
+ if blocked:
1314
+ scored = _filter_blocked_genres(scored, blocked, state.item_genre_lookup)
1315
+
1316
+ # Limit results
1317
+ if request.k is not None:
1318
+ scored = scored[: request.k]
1319
+
1320
+ results = [_format_recommendation(item_id, score) for item_id, score in scored]
1321
+
1322
+ return RerankResponse(
1323
+ count=len(results),
1324
+ recommendations=results,
1325
+ signals_used=len(history_ids),
1326
+ candidates_submitted=len(request.candidate_items),
1327
+ candidates_matched=len(candidate_map),
1328
+ blocked_genres=sorted(blocked) if blocked else [],
1329
+ model_version=CONFIG["model_version"],
1330
+ )
requirements.docker.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime dependencies for the CL-EPIDTN recommender API (improved_8).
2
+ # CPU torch is installed separately in the Dockerfile from the PyTorch CPU index.
3
+ # Versions are pinned to match the recommender service for a consistent stack.
4
+ fastapi==0.137.0
5
+ uvicorn==0.49.0
6
+ pydantic==2.13.4
7
+ pandas==3.0.3
8
+ numpy==1.26.4
9
+ # Required to unpickle item_meta.pkl (pandas data uses pyarrow-backed dtypes).
10
+ pyarrow==24.0.0
11
+ tqdm==4.68.2
12
+
13
+ # Text encoder for catalog hot-add (sentence-transformers/all-MiniLM-L6-v2)
14
+ sentence-transformers==5.5.1
15
+ transformers==5.12.0
16
+ tokenizers==0.22.2
17
+ huggingface_hub==1.19.0
18
+ safetensors==0.8.0
19
+ scikit-learn==1.9.0
20
+ scipy==1.17.1