# 010 — P1 Engine (built 2026-10-09)

The recommendation engine without AI: `wcs/foryou/`. Pure Python except `api.py` (TMDb) and the store reads; no Kodi
UI. Output is one **snapshot** (JSON in `meta`, key `foryou:snapshot`) that the skin rows of P3 only read.

## 1. Pipeline (`engine.build`)

```
signals.load()  ->  seed details (TMDb, cached)  ->  taste profile (pos / neg vectors)
   -> candidates: TMDb recommendations p1+p2 and filtered "similar" of the seeds, seed-keyword discover,
      profile-keyword discover, works of the household's creators, genre discover, gems, new, trending
   -> drop: seen / in lists / disliked / hidden, adult, no poster or backdrop, not released
   -> light score -> details of the best 200 + Top 10 + 25 per special kind and media -> one title per saga
   -> full score, match %, reason -> rows per chip mode (all / movie / tv) -> snapshot
```

A first build makes ~430 TMDb calls in ~5 s (6 threads); later builds are mostly cached (`api.py`: lists 12 h,
details and collections 14 days, genre names 30 days; errors are never cached).

## 2. Signals (`signals.py`)

| Signal | Weight | Excluded from the page |
|---|---|---|
| film watched (playcount) | 1.0 × fade (half-life 240 days, floor 0.35), ×1.15 rewatched | yes |
| film started (resume) | 0.55 × fade | yes |
| series: watched / started episodes | min(1.6, 0.45 + 0.12 w + 0.05 s) × fade | yes |
| only in the list | 0.25 | yes |
| short try (< resume threshold) | 0.12 × fade | no |
| 👍 like / first-run pick | 2.0 / 1.6 (no fade) | yes |
| 👎 dislike | −2.0 (negative profile) | yes |
| Nezajímá mě (hide) | none | yes |

Feedback lives in the new table `feedback` (store schema v4, `store.set_feedback / get_feedback`); the UI comes in P4.

## 3. Features (`features.py`)

Genres (series genres folded onto film ids), keywords (15, minus a stoplist of meta tags), top-5 cast, director /
series creator, collection, original language, decade, origin country, TV network, film studio, media type.
Weights in `WEIGHTS`; IDF over the candidate pool makes rare features (a creator) count more than "Drama".

## 4. Score (`score.py`)

`score = (0.47 content + 0.28 co-occurrence + 0.25 quality) × penalties × saga bonus × daily jitter`

- **content**: cosine of the title vs. the positive profile minus 0.8 × vs. the negative one (IDF weighted).
- **co-occurrence**: Σ seeds weight × rank decay of TMDb recommendations (similar ×0.35); a creator's work counts
  0.5, a profile-keyword hit 0.3. Both normalised by the 95th percentile.
- **quality**: Bayesian rating (prior 6.6 / 250 votes).
- **penalties**: blocked TV genres (news, reality, soap, talk) = 0; niche genres (animation, documentary, music,
  kids, TV film) and moods (horror, crime, war, western) outside the taste; foreign language outside the taste;
  old titles outside the taste's decades (< 1960 = 0); few votes; low rating; no Czech overview / title.
- **match %**: 58–98 by score / best score; 0 (hidden) when there is no taste yet (cold start).
- **reason**: the strongest seed (`because_watched` / `because_liked` / `because_list`), else creator, collection,
  actor, the most similar seed of the same media with a shared genre (`similar_to`), a specific genre (not the
  broad ones), `trending`, `quality`. Stored as `{kind, arg, seed?}`; texts are rendered in P3 (cs / sk / en).

## 5. Sagas

One title per collection: the first part after the last watched one (HP 1 → HP 2, Terminátor 2 → 3,
Matrix → Reloaded), fetched even when TMDb did not recommend it; it leads its seed's row (bonus ×1.15). A saga
whose earlier part waits unwatched in the list (Stmívání) is left out. Films under 60 min (shorts, specials) go.

## 6. Rows (`compose.py`)

Display order: Pro tebe (20), Top 10 v ČR, Protože jsi viděl #1, Na dnešní večer, #2, Nově s CZ dabingem,
Skryté klenoty, #3. Fill order: Top 10, the three seed rows, new, gems, Pro tebe, tonight (scarce rows first).
A title is once per page, remakes / second versions included; rows under 6 titles are dropped; each row is shown
best match first.

- **Top 10 v ČR**: TMDb trending week (films, series) + trending series today + popular films on CZ streaming
  (reciprocal rank); not personal; only titles from the last 3 years, a sane language, rating ≥ 6.
- **Protože jsi viděl X**: recent watched / liked seeds with ≥ 6 titles; TMDb recommendations first, similar only
  to fill; 12 titles.
- **Pro tebe**: only titles with a link (seed, creator, keyword, saga or content ≥ 0.3); diversity re-rank
  (k-th title with the same reason ×0.72^k), at most 2 titles of one creator.
- **Na dnešní večer** (title by slot: Na ráno / odpoledne / dnešní večer / noc): runtime windows per slot; the mixed
  evening is films only; evening and night without family / animation unless the household watches them.
- **Nově**: released in the last 300 days (≥ 20 days ago) and linked to the taste; falls back to the best new ones.
  "S CZ dabingem" becomes true in P2.
- **Skryté klenoty**: rating ≥ 7, ≤ 3000 votes (series ≤ 600), less popular than the pool median, not new, not a
  famous creator's work, not a chart title, home language, no niche genre.

## 7. Snapshot

`{version, built_at, day, slot, personal, seeds[], seed_titles{}, genres{id: name}, taste[], modes{all|movie|tv:
[row]}, items{key: card + match + reason + kinds + score + parts}, stats}`; row = `{id, kind, items[], seed?,
slot?, reason?}` (a row reason overrides the item reason inside that row). `engine.save / load`.

## 8. Verification

- Unit tests: `python3 -m unittest scripts/library_harness/test_foryou.py` (15: store feedback, signals, penalties,
  slots, a full build on a fake TMDb, cache reuse, cold start, save / load, errors not cached).
- Live harness (copy of the live `library.db` in `scripts/foryou_harness/work/`, never writes the live one):
  `python3 scripts/foryou_harness/foryou_harness.py build [--hour H] [--mode all|movie|tv] [--empty] [--sync]`,
  `seeds`, `explain movie:603`, `like|dislike|hide|pick|clear tv:1396`.
- LLM judge (costs credit): `python3 scripts/foryou_harness/judge.py [--model claude-opus-5-5]` rates every row
  as a picky Czech viewer. Tuning on 2026-10-09 took the rows from 4–7 to 5–8 / 10; the overall stays 6, mostly
  for obscure titles without a Czech version (P2 removes them) and for one-part-per-saga (by design).

## 9. Open for later phases

- P2: `compose.rows(available=...)` takes the Webshare check; the language badge goes into the snapshot items.
- P3: the service builds the snapshot (on start, every 6 h, on slot change, after feedback / a finished play);
  reason and row texts as addon strings; hero logo from `TMDbLogo`.
- P4: feedback UI writes `store.set_feedback` and triggers a rebuild.
