File size: 25,625 Bytes
7b5a4f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# Questro Recommender API β€” Integration Guide (improved_8)

> **Engine generation**: `improved_8` (architecture file `cl_epidtn_recommender_improved_8.py`, artifacts dir `artifacts_improved8/`, checkpoint `improved_8epochs.pt`)
> **Runtime `model_version`**: `improved_8` (echoed in every response)
> **Base URL**: `http://<ML_HOST>:7749`
> **Protocol**: REST / JSON (FastAPI)
> **Auth**: None (internal network only)

---

## Quick Start

```bash
# Start the server
uvicorn recommender_api_improved8:app --host 0.0.0.0 --port 7749

# Health check
curl http://localhost:7749/health

# Get recommendations (star ratings)
curl -X POST http://localhost:7749/recommend \
  -H "Content-Type: application/json" \
  -d '{
    "user": {
      "ratings": [
        {"item_id": "game_271590", "title": "Grand Theft Auto V", "stars": 5.0},
        {"item_id": "movie_155", "title": "The Dark Knight", "stars": 4.5}
      ]
    },
    "k": 10,
    "domain": "game",
    "blocked_genres": ["Horror", "War"]
  }'
```

---

## Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/health` | Liveness + model-loaded status |
| `GET` | `/genres` | List blockable genres/tags (for the parental-controls UI) |
| `POST` | `/recommend` | Personalised recommendations with pagination + genre blocking |
| `POST` | `/recommend/rerank` | Re-rank a RAG-fetched candidate list (RAG tool) |
| `POST` | `/catalog/add` | Hot-add cold-start items at runtime |
| `POST` | `/admin/reload` | Reload artifacts from disk (clears hot-added items) |

---

### 1. `GET /health`

Health check β€” verify the model is loaded before sending requests.

**Response:**

```json
{
  "status": "ok",
  "model_loaded": true,
  "n_items": 138541,
  "n_genres_tracked": 138541,
  "text_index_loaded": true,
  "model_version": "improved_8",
  "hot_added_count": 0
}
```

| Field | Type | Description |
|-------|------|-------------|
| `status` | `string` | Always `"ok"` when the server responds |
| `model_loaded` | `bool` | `false` during startup β€” wait until `true` |
| `n_items` | `int` | Total items in catalog (movies + games) |
| `n_genres_tracked` | `int` | Items with genre/tag data for blocking |
| `text_index_loaded` | `bool` | Whether text embeddings are available (enhances quality) |
| `model_version` | `string` | Engine version string (`"improved_8"`) |
| `hot_added_count` | `int` | Items added at runtime via `/catalog/add` since the last load |

---

### 2. `POST /recommend`

**The main endpoint.** Send a user profile and get personalised, genre-filtered recommendations with pagination.

#### Request Body

```json
{
  "user": {
    "age": 21,
    "gender": "Male",
    "profession": "Student",
    "country": "Egypt",
    "movie_genres_fav": "Action|Adventure|Comedy",
    "movie_genres_disliked": "Horror|War",
    "game_genres_fav": "Action|RPG|Shooter",
    "game_genres_disliked": "Card|Educational",
    "ratings": [
      {"item_id": "game_271590", "title": "Grand Theft Auto V", "type": "game", "rating": "5 Stars"},
      {"item_id": "movie_155", "title": "The Dark Knight", "stars": 4.5},
      {"item_id": "movie_680", "source": "wishlist"},
      {"item_id": "game_99999", "source": "ignore"}
    ]
  },
  "k": 10,
  "offset": 0,
  "domain": "movie",
  "blocked_genres": ["Horror", "Crime"]
}
```

#### User Profile Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `age` | `int` | No | User's age (1–120) |
| `gender` | `string` | No | Gender |
| `profession` | `string` | No | Profession / occupation |
| `country` | `string` | No | Country |
| `movie_genres_fav` | `string` | No | Pipe-separated favourite movie genres |
| `movie_genres_disliked` | `string` | No | Pipe-separated disliked movie genres |
| `game_genres_fav` | `string` | No | Pipe-separated favourite game genres |
| `game_genres_disliked` | `string` | No | Pipe-separated disliked game genres |
| `ratings` | `RatingItem[]` | βœ… Yes | At least 1 rating (see below) |

#### Rating Item Fields

Each item in `ratings` supports **three input modes** β€” use whichever is convenient:

| Field | Type | Description |
|-------|------|-------------|
| `item_id` | `string` | **Required.** Format: `"movie_{id}"`, `"movie:{id}"`, `"game_{id}"`, or `"game:{id}"` |
| `title` | `string` | Optional, for logging |
| `type` | `"movie" \| "game"` | Optional domain hint (inferred from `item_id` prefix if omitted) |
| `rating` | `string` | **Mode 1**: Survey label (see table below) |
| `stars` | `float` | **Mode 2**: Numeric rating 1.0–5.0 |
| `source` | `string` | **Mode 3**: `"rating"`, `"wishlist"`, or `"ignore"` |

#### Rating Labels Reference

| Label | Numeric Equivalent | Weight |
|-------|--------------------|--------|
| `"5 Stars"` | 5.0 | +1.0 |
| `"4 Stars"` | 4.0 | +0.5 |
| `"Didn't watch but would watch"` | 3.5 | +0.25 |
| `"Didn't play but would play"` | 3.5 | +0.25 |
| `"3 Stars"` | 3.0 | 0.0 |
| `"2 Stars"` | 2.0 | βˆ’0.5 |
| `"Didn't watch and wouldn't watch"` | 1.5 | βˆ’0.75 |
| `"Didn't play and wouldn't play"` | 1.5 | βˆ’0.75 |
| `"1 Star"` | 1.0 | βˆ’1.0 |

> 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).

#### Source Signals

| `source` value | Meaning | Equivalent Label |
|---|---|---|
| `"wishlist"` | User saved / wishlisted the item | "Didn't watch but would watch" (+0.25) |
| `"ignore"` | User blocked / ignored the item | "Didn't watch and wouldn't watch" (βˆ’0.75) |
| `"rating"` or `null` | Normal rating β€” uses `rating` or `stars` field | β€” |

#### Request Parameters

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `k` | `int` | No | `10` | Results per page (1–100) |
| `offset` | `int` | No | `0` | Pagination offset. Page 1 = 0, page 2 = k, etc. |
| `domain` | `string \| null` | No | `null` | `"movie"`, `"game"`, or `null` for cross-domain |
| `blocked_genres` | `string[] \| null` | No | `null` | Genres/tags to exclude (case-insensitive) |

#### Response Body

```json
{
  "count": 10,
  "total_available": 85,
  "domain": "movie",
  "offset": 0,
  "k": 10,
  "recommendations": [
    {
      "item_id": 157336,
      "item_key": "movie:157336",
      "title": "Interstellar (2014)",
      "domain": "movie",
      "score": 0.872451
    }
  ],
  "signals_used": 3,
  "blocked_genres": ["crime", "horror"],
  "model_version": "improved_8",
  "has_more": true
}
```

| Field | Type | Description |
|-------|------|-------------|
| `count` | `int` | Items in this page |
| `total_available` | `int` | Total results available (after genre filtering) |
| `domain` | `string \| null` | Echoes the requested domain filter |
| `offset` | `int` | Current offset |
| `k` | `int` | Requested page size |
| `has_more` | `bool` | `true` if more pages exist beyond this one |
| `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. |
| `recommendations[].item_key` | `string` | Internal key: `movie:{id}` or `game:{id}` |
| `recommendations[].title` | `string` | Human-readable title |
| `recommendations[].domain` | `string` | `"movie"` or `"game"` |
| `recommendations[].score` | `float` | Relevance score, rounded to 6 dp (higher = better) |
| `signals_used` | `int` | How many submitted ratings mapped to the catalog |
| `blocked_genres` | `string[]` | Genres that were blocked (lowercased) |
| `model_version` | `string` | `"improved_8"` |

> **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.

> **Pagination example:**
> - Page 1: `{"k": 10, "offset": 0}` β†’ items 1–10
> - Page 2: `{"k": 10, "offset": 10}` β†’ items 11–20
> - Stop when `has_more` is `false`

---

### 3. `POST /recommend/rerank` β€” RAG Tool

**Re-rank a pre-fetched candidate list** using the recommender model. Use this as a tool in your RAG pipeline.

#### Workflow

```
1. RAG retrieves a broad list of candidate items (e.g. "top 50 sci-fi games")
2. POST them to /recommend/rerank with the user's profile
3. The recommender scores each candidate against the user's taste
4. Items are returned ranked by personalised relevance
5. Blocked genres are filtered out; the user's own history items are removed
```

#### Request Body

```json
{
  "user": {
    "ratings": [
      {"item_id": "game_271590", "stars": 5.0},
      {"item_id": "movie_155", "stars": 4.0}
    ]
  },
  "candidate_items": [
    {"item_id": "game_1091500", "title": "Cyberpunk 2077"},
    {"item_id": "game_292030", "title": "The Witcher 3"},
    {"item_id": "game_374320", "title": "Dark Souls III"},
    {"item_id": "movie_27205", "title": "Inception"}
  ],
  "blocked_genres": ["Horror"],
  "k": 3
}
```

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `user` | `UserProfile` | βœ… Yes | β€” | Same user profile as `/recommend` |
| `candidate_items` | `CandidateItem[]` | βœ… Yes | β€” | Items to score (at least 1) |
| `candidate_items[].item_id` | `string` | βœ… Yes | β€” | `"movie_{id}"` / `"movie:{id}"` / `"game_{id}"` / `"game:{id}"` |
| `candidate_items[].title` | `string` | No | β€” | Optional title |
| `blocked_genres` | `string[] \| null` | No | `null` | Genres/tags to block |
| `k` | `int \| null` | No | `null` | Max results. `null` = return all ranked |

#### Response Body

```json
{
  "count": 3,
  "recommendations": [
    {"item_id": 1091500, "item_key": "game:1091500", "title": "Cyberpunk 2077", "domain": "game", "score": 0.91},
    {"item_id": 292030, "item_key": "game:292030", "title": "The Witcher 3", "domain": "game", "score": 0.87},
    {"item_id": 27205, "item_key": "movie:27205", "title": "Inception", "domain": "movie", "score": 0.83}
  ],
  "signals_used": 2,
  "candidates_submitted": 4,
  "candidates_matched": 4,
  "blocked_genres": ["horror"],
  "model_version": "improved_8"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `count` | `int` | Final results after filtering |
| `recommendations[]` | `object[]` | Same shape as `/recommend` (`item_id`, `item_key`, `title`, `domain`, `score`) |
| `signals_used` | `int` | User ratings that mapped to the catalog |
| `candidates_submitted` | `int` | How many candidates you sent |
| `candidates_matched` | `int` | How many were found in the model catalog |
| `blocked_genres` | `string[]` | Genres that were blocked (lowercased) |
| `model_version` | `string` | `"improved_8"` |

> **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."

---

### 4. `POST /catalog/add` β€” Hot-Add Items

**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.

#### Workflow

```
1. RAG retrieves items from an external DB the ML model wasn't trained on
2. POST the missing items to /catalog/add with metadata/text
3. The server expands its tensors and (if sentence-transformers is installed and a
   text index is loaded) computes text embeddings so the items can be scored (0-shot)
4. The items can now be returned by /recommend or /recommend/rerank
```

#### Request Body

```json
{
  "items": [
    {
      "item_id": "game_1091500",
      "title": "Cyberpunk 2077",
      "domain": "game",
      "description": "An open-world, action-adventure story set in Night City...",
      "genres": "Action|RPG",
      "tags": "sci-fi|open world|cyberpunk",
      "provider_id": 1091500
    }
  ]
}
```

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `items` | `CatalogNewItem[]` | βœ… Yes | β€” | 1–500 items per request |
| `items[].item_id` | `string` | βœ… Yes | β€” | `"movie_{id}"` / `"movie:{id}"` / `"game_{id}"` / `"game:{id}"` |
| `items[].title` | `string` | βœ… Yes | β€” | Human-readable title |
| `items[].domain` | `"movie" \| "game" \| null` | No | inferred from `item_id` | Optional domain override |
| `items[].description` | `string` | No | `""` | Synopsis (used for text embedding) |
| `items[].genres` | `string` | No | `""` | Pipe- or comma-separated genres, e.g. `"Action\|RPG"` |
| `items[].tags` | `string` | No | `""` | Pipe- or comma-separated tags |
| `items[].provider_id` | `int \| null` | No | `null` | TMDB ID for movies, RAWG ID for games β€” surfaced as `item_id` in later responses |

#### Response Body

```json
{
  "added": ["game:1091500"],
  "already_exists": [],
  "failed": {},
  "n_items": 138542,
  "text_index_updated": true
}
```

| Field | Type | Description |
|-------|------|-------------|
| `added` | `string[]` | Internal keys (`domain:id`) that were registered this call |
| `already_exists` | `string[]` | Keys that were already in the catalog (skipped) |
| `failed` | `object` | Map of `item_id` β†’ error reason for items that could not be added |
| `n_items` | `int` | Total catalog size after the operation |
| `text_index_updated` | `bool` | `true` if text embeddings were computed and written for the new items |

> **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`.

---

### 5. `GET /genres`

List all genres/tags available for blocking. Use this to populate the parental-controls UI.

**Response:**

```json
{
  "genres": ["action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "fantasy", "horror", "indie", "mystery", "rpg", "racing", "romance", "sci-fi", "shooter", "simulation", "sports", "strategy", "thriller", "war", "western"]
}
```

> 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.

---

### 6. `POST /admin/reload`

Reload all on-disk artifacts (model, indices, metadata). **Runtime hot-added items are intentionally cleared** so the server returns to the persisted artifact state.

**Response:**

```json
{
  "status": "ok",
  "n_items": 138541,
  "text_index_loaded": true,
  "hot_added_count": 0
}
```

| Field | Type | Description |
|-------|------|-------------|
| `status` | `string` | `"ok"` on success |
| `n_items` | `int` | Catalog size after reload |
| `text_index_loaded` | `bool` | Whether text embeddings were reloaded |
| `hot_added_count` | `int` | Reset to `0` after reload |

---

## How Recommendations Are Scored

A few behaviours are worth knowing when integrating:

- **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.
- **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.
- **History exclusion.** In `/recommend/rerank`, candidates already present in the user's rating history are dropped before scoring.
- **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.

---

## Integration Guide

### Mapping Your IDs to the API

| Your Database | API `item_id` format | Example |
|---|---|---|
| Movie (TMDB ID) | `"movie_{TMDB_Id}"` | `"movie_155"` |
| Movie (MovieLens ID) | `"movie_{movieId}"` | `"movie_1199"` |
| Game (Steam App ID) | `"game_{app_id}"` | `"game_271590"` |

> Responses return the provider ID in the `item_id` field β€” **TMDB ID for movies, RAWG ID for games** β€” alongside the internal `item_key`.

### Converting Your Signals to API Ratings

| User Action in Your App | API Rating |
|---|---|
| Rated 1–5 stars | `{"item_id": "...", "stars": 4.0}` |
| Liked | `{"item_id": "...", "stars": 4.0}` |
| Watched / Played | `{"item_id": "...", "stars": 4.0}` |
| Wishlisted / Saved | `{"item_id": "...", "source": "wishlist"}` |
| Ignored / Blocked | `{"item_id": "...", "source": "ignore"}` |
| Disliked | `{"item_id": "...", "stars": 1.0}` |

### Parental Controls

Send `blocked_genres` with every request for child accounts. Populate the picker from `GET /genres`.

| Restriction Level | `blocked_genres` |
|---|---|
| Child-safe | `["horror", "crime", "war", "thriller"]` |
| Teen-safe | `["horror"]` |
| No restrictions | `null` or `[]` |

The API guarantees:
- **No item with a blocked genre/tag will appear** in results.
- The **requested `k` items are returned** after filtering (the model over-fetches internally).
- **Case-insensitive** matching: `"Horror"`, `"HORROR"`, `"horror"` are all equivalent.

### Typical Backend Flow

```
1. User opens "For You" page
2. Backend queries its own DB for user's ratings/likes/watches/wishlists/ignores
3. Backend maps each item to {item_id, stars/source}
4. Backend reads the user's parental control settings β†’ blocked_genres
5. Backend POSTs to /recommend with offset=0, k=20
6. Backend receives item_id values (TMDB/RAWG provider IDs)
7. Backend looks up those IDs in its own Movies/Games table
8. Backend returns rich item data (posters, descriptions) to frontend
9. On scroll / "Load More", backend POSTs again with offset=20
```

### RAG Tool Integration

```
1. User asks chatbot: "Recommend me games like Skyrim"
2. RAG retrieves top 50 RPG games from your vector DB
3. (Optional) RAG hot-adds any items missing from the model via /catalog/add
4. RAG calls POST /recommend/rerank with the user profile + 50 candidates
5. API returns the items re-ranked by the user's personal taste
   (empty list β‡’ fall back to the original FAISS order)
6. RAG picks top 5 and formats the response
```

---

## C# Backend Integration Example

```csharp
public class RecommenderClient
{
    private readonly HttpClient _http;
    private readonly string _baseUrl;

    public RecommenderClient(string baseUrl)
    {
        _baseUrl = baseUrl.TrimEnd('/');
        _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
    }

    public async Task<RecommendResponse?> GetRecommendationsAsync(
        UserProfile user,
        int k = 10,
        int offset = 0,
        string? domain = null,
        List<string>? blockedGenres = null)
    {
        var request = new
        {
            user,
            k,
            offset,
            domain,
            blocked_genres = blockedGenres
        };

        var json = JsonSerializer.Serialize(request,
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });

        var response = await _http.PostAsync(
            $"{_baseUrl}/recommend",
            new StringContent(json, Encoding.UTF8, "application/json"));

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<RecommendResponse>();
    }

    public async Task<RerankResponse?> RerankAsync(
        UserProfile user,
        List<CandidateItem> candidates,
        int? k = null,
        List<string>? blockedGenres = null)
    {
        var request = new
        {
            user,
            candidate_items = candidates,
            k,
            blocked_genres = blockedGenres
        };

        var json = JsonSerializer.Serialize(request,
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });

        var response = await _http.PostAsync(
            $"{_baseUrl}/recommend/rerank",
            new StringContent(json, Encoding.UTF8, "application/json"));

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<RerankResponse>();
    }
}

// Each recommendation exposes item_id (TMDB/RAWG provider id), item_key, title, domain, score.
// Usage β€” recommendations with pagination:
var user = BuildUserProfile(userId);  // your helper
var page1 = await _recommender.GetRecommendationsAsync(
    user, k: 20, offset: 0,
    domain: "movie",
    blockedGenres: userSettings.BlockedGenres);

// Show page1.Recommendations to user...

if (page1.HasMore)
{
    var page2 = await _recommender.GetRecommendationsAsync(
        user, k: 20, offset: 20,
        domain: "movie",
        blockedGenres: userSettings.BlockedGenres);
}

// Usage β€” RAG reranking:
var ragResults = await _vectorDb.SearchAsync("sci-fi games", top: 50);
var candidates = ragResults.Select(r => new CandidateItem
{
    ItemId = $"game_{r.SteamAppId}",
    Title = r.Title
}).ToList();

var reranked = await _recommender.RerankAsync(
    user, candidates, k: 5,
    blockedGenres: userSettings.BlockedGenres);
```

---

## Error Responses

| Code | When |
|------|------|
| `422` | No ratings provided, all items unmapped, all candidates already in history, invalid rating label, stars out of range |
| `503` | Model / catalog still loading (check `/health` first) |

> 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.

---

## Deployment Notes

| Item | Value |
|------|-------|
| **Server start** | `uvicorn recommender_api_improved8:app --host 0.0.0.0 --port 7749` |
| **Required files** | `recommender_api_improved8.py`, `cl_epidtn_recommender_improved_8.py` |
| **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` |
| **Dependencies** | `pip install torch fastapi uvicorn pydantic pandas` (plus `sentence-transformers` for cold-start text scoring) |
| **Text encoder** | `sentence-transformers/all-MiniLM-L6-v2` (cold-start hot-add embeddings) |
| **Swagger docs** | `http://<HOST>:7749/docs` (auto-generated) |
| **Cold start time** | ~15–30 seconds (model + text embeddings loading) |
| **Avg response time** | ~50–150ms per request |

### Configurable Environment Variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `ARTIFACTS_DIR` | `artifacts_improved8` | Base directory for artifacts |
| `MODEL_CHECKPOINT` | `<dir>/improved_8epochs.pt` | Trained model checkpoint |
| `TEXT_EMBEDDINGS_PATH` | `<dir>/improved_item_text_embeddings.pt` | Optional content embeddings |
| `TEXT_ENCODER_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Cold-start text encoder |
| `MAX_RECS` | `100` | Hard cap on internal fetch size |
| `OVERFETCH_MULTIPLIER` | `5` | Extra candidates fetched before genre filtering |
| `TITLE_FAMILY_BOOST` | `0.40` | Score boost for same-franchise neighbours |
| `TITLE_FAMILY_EXTRA_CANDIDATES` | `50` | Max extra franchise candidates injected |

---

## FAQ

**Q: What if a user has no ratings yet?**
A: The API requires at least 1 rating. For new users, show popular/trending items until they interact with something, then call `/recommend`.

**Q: What if a submitted item_id isn't in the model catalog?**
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`).

**Q: Can I mix stars and survey labels in the same request?**
A: Yes. Each rating item is independent. You can use `stars` for some, `rating` labels for others, and `source: "wishlist"/"ignore"` for others.

**Q: How does pagination work?**
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.

**Q: Are blocked genres guaranteed to be excluded?**
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).

**Q: Why does `/recommend/rerank` sometimes return an empty list?**
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.

**Q: Is the API stateless?**
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.

**Q: What's the rerank endpoint for?**
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.