marquee-ai 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marquee_ai-1.0.0/PKG-INFO +136 -0
- marquee_ai-1.0.0/README.md +113 -0
- marquee_ai-1.0.0/marquee/__init__.py +51 -0
- marquee_ai-1.0.0/marquee/api.py +386 -0
- marquee_ai-1.0.0/marquee/cli.py +288 -0
- marquee_ai-1.0.0/marquee/contract.py +515 -0
- marquee_ai-1.0.0/marquee/harvest.py +284 -0
- marquee_ai-1.0.0/marquee/ingest.py +123 -0
- marquee_ai-1.0.0/marquee/retriever.py +434 -0
- marquee_ai-1.0.0/marquee/schema.py +100 -0
- marquee_ai-1.0.0/marquee_ai.egg-info/PKG-INFO +136 -0
- marquee_ai-1.0.0/marquee_ai.egg-info/SOURCES.txt +16 -0
- marquee_ai-1.0.0/marquee_ai.egg-info/dependency_links.txt +1 -0
- marquee_ai-1.0.0/marquee_ai.egg-info/entry_points.txt +2 -0
- marquee_ai-1.0.0/marquee_ai.egg-info/requires.txt +11 -0
- marquee_ai-1.0.0/marquee_ai.egg-info/top_level.txt +1 -0
- marquee_ai-1.0.0/pyproject.toml +37 -0
- marquee_ai-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: marquee-ai
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Marquee -- Plex-style recommendation rows for a media server library
|
|
5
|
+
Author: KernelMedia
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://huggingface.co/KernelMedia
|
|
8
|
+
Keywords: recommendations,media-server,plex,jellyfin,llm
|
|
9
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Multimedia :: Video
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: numpy>=1.24
|
|
15
|
+
Requires-Dist: requests>=2.31
|
|
16
|
+
Requires-Dist: sentence-transformers>=3.0
|
|
17
|
+
Provides-Extra: api
|
|
18
|
+
Requires-Dist: fastapi>=0.110; extra == "api"
|
|
19
|
+
Requires-Dist: uvicorn>=0.27; extra == "api"
|
|
20
|
+
Requires-Dist: pydantic>=2.6; extra == "api"
|
|
21
|
+
Provides-Extra: harvest
|
|
22
|
+
Requires-Dist: tqdm>=4.66; extra == "harvest"
|
|
23
|
+
|
|
24
|
+
# Marquee
|
|
25
|
+
|
|
26
|
+
Turns one item in a media library into Plex-style rows of other items in **that same
|
|
27
|
+
library**. Built for self-hosted media servers.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
IN: "The Sopranos"
|
|
31
|
+
|
|
32
|
+
OUT:
|
|
33
|
+
Shows like The Sopranos Gomorrah, Boardwalk Empire, The Wire, ZeroZeroZero
|
|
34
|
+
More from Tim Van Patten Game of Thrones, Black Mirror, Boardwalk Empire
|
|
35
|
+
More with Edie Falco Nurse Jackie, Oz
|
|
36
|
+
Shot by Alik Sakharov Game of Thrones, House of Cards
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Two halves, both required
|
|
40
|
+
|
|
41
|
+
A **retriever** decides what goes in the rows, by joining your library on shared cast,
|
|
42
|
+
crew and franchise and by embedding similarity. A small fine-tuned **model** decides
|
|
43
|
+
which rows are worth showing, their order, and what to call them.
|
|
44
|
+
|
|
45
|
+
The model never sees item ids — it answers with index references into candidates it was
|
|
46
|
+
handed, so it cannot invent a title that isn't in your library.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install marquee-ai
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Model weights: [KernelMedia/marquee-ai](https://huggingface.co/KernelMedia/marquee-ai)
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# the model (needs ollama)
|
|
58
|
+
ollama create marquee-4b -f Modelfile
|
|
59
|
+
|
|
60
|
+
# metadata -- free TMDB key from themoviedb.org/settings/api
|
|
61
|
+
echo 'TMDB_API_KEY=your_key' >> .env
|
|
62
|
+
marquee harvest --out tmdb_full.jsonl # ~55 min, ~120k titles
|
|
63
|
+
|
|
64
|
+
# join it to your server's library
|
|
65
|
+
marquee ingest --server library.jsonl --catalog tmdb_full.jsonl \
|
|
66
|
+
--out lib.jsonl --mode requestable
|
|
67
|
+
|
|
68
|
+
# serve
|
|
69
|
+
marquee serve --library lib.jsonl --model marquee-4b --port 8080
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Your server exports one JSON object per line — only `tmdb_id` and `media_type` are
|
|
73
|
+
required:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"id": "myserver-04471", "tmdb_id": 335984, "media_type": "movie"}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Anything missing from the harvest is fetched from TMDB on demand at ingest, so coverage
|
|
80
|
+
is complete however you filtered.
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
POST /recommend {"seed_id": "myserver-04471", "row_size": 20}
|
|
86
|
+
GET /search?q=Scorsese # matches titles AND people
|
|
87
|
+
GET /person?name=Roger%20Deakins
|
|
88
|
+
GET /health
|
|
89
|
+
DELETE /cache # after a library rescan
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`/search` and `/person` are pure index lookups — no model call, effectively instant.
|
|
93
|
+
Only `/recommend` uses the GPU.
|
|
94
|
+
|
|
95
|
+
Items carry an `owned` flag, so a UI can offer a Request button on anything not on the
|
|
96
|
+
server. Ambiguous titles return **409 with the candidates** rather than guessing —
|
|
97
|
+
`Godzilla` matches three films. Narrow with `year` and `media_type`, or write
|
|
98
|
+
`"Top Gun (1986)"` directly.
|
|
99
|
+
|
|
100
|
+
## Use it as a library
|
|
101
|
+
|
|
102
|
+
The HTTP API is optional. If your server is Python, skip it:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from marquee import Library
|
|
106
|
+
from marquee.contract import messages, parse, repair, expand_rows
|
|
107
|
+
|
|
108
|
+
lib = Library.load("lib.jsonl")
|
|
109
|
+
lib.build_embeddings(cache=Path("lib.emb.npz")) # once at startup
|
|
110
|
+
|
|
111
|
+
request = lib.build_request(seed_id, semantic_k=14, row_size=20)
|
|
112
|
+
raw = call_your_llm(messages(request)) # any OpenAI-compatible endpoint
|
|
113
|
+
rows = expand_rows(repair(parse(raw), request), request, 20)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Requirements
|
|
117
|
+
|
|
118
|
+
| | |
|
|
119
|
+
|---|---|
|
|
120
|
+
| VRAM | ~3.5 GB (Q4_K_M). Runs on a 4 GB card, or CPU. |
|
|
121
|
+
| RAM | ~2.2 GB for a 120k-title catalog |
|
|
122
|
+
| Latency | ~1 s per request on an RTX 3060; cached responses are instant |
|
|
123
|
+
|
|
124
|
+
Full setup and troubleshooting: [SETUP.md](https://huggingface.co/KernelMedia/marquee-ai/blob/main/SETUP.md)
|
|
125
|
+
|
|
126
|
+
## Data
|
|
127
|
+
|
|
128
|
+
You harvest TMDB yourself with your own free key; no catalog is distributed. TMDB is
|
|
129
|
+
free for non-commercial use and requires attribution — commercial use needs a written
|
|
130
|
+
agreement with them.
|
|
131
|
+
|
|
132
|
+
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
|
133
|
+
|
|
134
|
+
## Licence
|
|
135
|
+
|
|
136
|
+
Apache-2.0.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Marquee
|
|
2
|
+
|
|
3
|
+
Turns one item in a media library into Plex-style rows of other items in **that same
|
|
4
|
+
library**. Built for self-hosted media servers.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
IN: "The Sopranos"
|
|
8
|
+
|
|
9
|
+
OUT:
|
|
10
|
+
Shows like The Sopranos Gomorrah, Boardwalk Empire, The Wire, ZeroZeroZero
|
|
11
|
+
More from Tim Van Patten Game of Thrones, Black Mirror, Boardwalk Empire
|
|
12
|
+
More with Edie Falco Nurse Jackie, Oz
|
|
13
|
+
Shot by Alik Sakharov Game of Thrones, House of Cards
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Two halves, both required
|
|
17
|
+
|
|
18
|
+
A **retriever** decides what goes in the rows, by joining your library on shared cast,
|
|
19
|
+
crew and franchise and by embedding similarity. A small fine-tuned **model** decides
|
|
20
|
+
which rows are worth showing, their order, and what to call them.
|
|
21
|
+
|
|
22
|
+
The model never sees item ids — it answers with index references into candidates it was
|
|
23
|
+
handed, so it cannot invent a title that isn't in your library.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install marquee-ai
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Model weights: [KernelMedia/marquee-ai](https://huggingface.co/KernelMedia/marquee-ai)
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# the model (needs ollama)
|
|
35
|
+
ollama create marquee-4b -f Modelfile
|
|
36
|
+
|
|
37
|
+
# metadata -- free TMDB key from themoviedb.org/settings/api
|
|
38
|
+
echo 'TMDB_API_KEY=your_key' >> .env
|
|
39
|
+
marquee harvest --out tmdb_full.jsonl # ~55 min, ~120k titles
|
|
40
|
+
|
|
41
|
+
# join it to your server's library
|
|
42
|
+
marquee ingest --server library.jsonl --catalog tmdb_full.jsonl \
|
|
43
|
+
--out lib.jsonl --mode requestable
|
|
44
|
+
|
|
45
|
+
# serve
|
|
46
|
+
marquee serve --library lib.jsonl --model marquee-4b --port 8080
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Your server exports one JSON object per line — only `tmdb_id` and `media_type` are
|
|
50
|
+
required:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{"id": "myserver-04471", "tmdb_id": 335984, "media_type": "movie"}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Anything missing from the harvest is fetched from TMDB on demand at ingest, so coverage
|
|
57
|
+
is complete however you filtered.
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
POST /recommend {"seed_id": "myserver-04471", "row_size": 20}
|
|
63
|
+
GET /search?q=Scorsese # matches titles AND people
|
|
64
|
+
GET /person?name=Roger%20Deakins
|
|
65
|
+
GET /health
|
|
66
|
+
DELETE /cache # after a library rescan
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`/search` and `/person` are pure index lookups — no model call, effectively instant.
|
|
70
|
+
Only `/recommend` uses the GPU.
|
|
71
|
+
|
|
72
|
+
Items carry an `owned` flag, so a UI can offer a Request button on anything not on the
|
|
73
|
+
server. Ambiguous titles return **409 with the candidates** rather than guessing —
|
|
74
|
+
`Godzilla` matches three films. Narrow with `year` and `media_type`, or write
|
|
75
|
+
`"Top Gun (1986)"` directly.
|
|
76
|
+
|
|
77
|
+
## Use it as a library
|
|
78
|
+
|
|
79
|
+
The HTTP API is optional. If your server is Python, skip it:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from marquee import Library
|
|
83
|
+
from marquee.contract import messages, parse, repair, expand_rows
|
|
84
|
+
|
|
85
|
+
lib = Library.load("lib.jsonl")
|
|
86
|
+
lib.build_embeddings(cache=Path("lib.emb.npz")) # once at startup
|
|
87
|
+
|
|
88
|
+
request = lib.build_request(seed_id, semantic_k=14, row_size=20)
|
|
89
|
+
raw = call_your_llm(messages(request)) # any OpenAI-compatible endpoint
|
|
90
|
+
rows = expand_rows(repair(parse(raw), request), request, 20)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Requirements
|
|
94
|
+
|
|
95
|
+
| | |
|
|
96
|
+
|---|---|
|
|
97
|
+
| VRAM | ~3.5 GB (Q4_K_M). Runs on a 4 GB card, or CPU. |
|
|
98
|
+
| RAM | ~2.2 GB for a 120k-title catalog |
|
|
99
|
+
| Latency | ~1 s per request on an RTX 3060; cached responses are instant |
|
|
100
|
+
|
|
101
|
+
Full setup and troubleshooting: [SETUP.md](https://huggingface.co/KernelMedia/marquee-ai/blob/main/SETUP.md)
|
|
102
|
+
|
|
103
|
+
## Data
|
|
104
|
+
|
|
105
|
+
You harvest TMDB yourself with your own free key; no catalog is distributed. TMDB is
|
|
106
|
+
free for non-commercial use and requires attribution — commercial use needs a written
|
|
107
|
+
agreement with them.
|
|
108
|
+
|
|
109
|
+
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
|
110
|
+
|
|
111
|
+
## Licence
|
|
112
|
+
|
|
113
|
+
Apache-2.0.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Marquee -- Plex-style recommendation rows for a media server library.
|
|
2
|
+
|
|
3
|
+
The model is only half of this. It decides which rows to show and what to call them;
|
|
4
|
+
the retriever decides what goes in them. Ship them together or neither works.
|
|
5
|
+
|
|
6
|
+
from marquee import Library, build_rows
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .contract import (
|
|
10
|
+
CONTRACT_VERSION,
|
|
11
|
+
MAX_ROW_ITEMS,
|
|
12
|
+
MAX_ROWS,
|
|
13
|
+
ROW_ORDER,
|
|
14
|
+
SYSTEM_PROMPT,
|
|
15
|
+
expand_rows,
|
|
16
|
+
messages,
|
|
17
|
+
parse,
|
|
18
|
+
render_human,
|
|
19
|
+
repair,
|
|
20
|
+
response_format,
|
|
21
|
+
to_compact,
|
|
22
|
+
validate,
|
|
23
|
+
verify_model,
|
|
24
|
+
)
|
|
25
|
+
from .retriever import Library
|
|
26
|
+
from .schema import MediaItem, Person, read_jsonl, write_jsonl
|
|
27
|
+
|
|
28
|
+
__version__ = "1.0.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CONTRACT_VERSION",
|
|
32
|
+
"verify_model",
|
|
33
|
+
"to_compact",
|
|
34
|
+
"Library",
|
|
35
|
+
"MediaItem",
|
|
36
|
+
"Person",
|
|
37
|
+
"read_jsonl",
|
|
38
|
+
"write_jsonl",
|
|
39
|
+
"SYSTEM_PROMPT",
|
|
40
|
+
"ROW_ORDER",
|
|
41
|
+
"MAX_ROWS",
|
|
42
|
+
"MAX_ROW_ITEMS",
|
|
43
|
+
"messages",
|
|
44
|
+
"parse",
|
|
45
|
+
"repair",
|
|
46
|
+
"validate",
|
|
47
|
+
"expand_rows",
|
|
48
|
+
"render_human",
|
|
49
|
+
"response_format",
|
|
50
|
+
"__version__",
|
|
51
|
+
]
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Marquee HTTP API.
|
|
2
|
+
|
|
3
|
+
What a media server actually talks to. Wraps the retriever, the prompt contract and
|
|
4
|
+
the model behind one endpoint:
|
|
5
|
+
|
|
6
|
+
POST /recommend {"seed_id": "myserver-04471", "row_size": 20}
|
|
7
|
+
-> {"rows": [{"type", "title", "items": [{"id", "title", "owned", ...}]}]}
|
|
8
|
+
|
|
9
|
+
The model runs behind any OpenAI-compatible endpoint -- Ollama is the expected one
|
|
10
|
+
for self-hosters:
|
|
11
|
+
|
|
12
|
+
ollama serve
|
|
13
|
+
uvicorn serve.api:app --host 0.0.0.0 --port 8080
|
|
14
|
+
|
|
15
|
+
Config via environment:
|
|
16
|
+
MARQUEE_LIBRARY path to the library JSONL from data/ingest.py (required)
|
|
17
|
+
MARQUEE_MODEL model name (default curator-4b)
|
|
18
|
+
MARQUEE_BASE_URL OpenAI-compatible endpoint (default http://localhost:11434/v1)
|
|
19
|
+
MARQUEE_EMB_CACHE embedding cache path (default alongside the library)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from collections import OrderedDict
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import requests
|
|
30
|
+
from fastapi import FastAPI, HTTPException
|
|
31
|
+
from pydantic import BaseModel, Field
|
|
32
|
+
|
|
33
|
+
from .retriever import Library
|
|
34
|
+
from .contract import (
|
|
35
|
+
CONTRACT_VERSION,
|
|
36
|
+
ValidationError,
|
|
37
|
+
expand_rows,
|
|
38
|
+
messages,
|
|
39
|
+
parse,
|
|
40
|
+
repair,
|
|
41
|
+
response_format,
|
|
42
|
+
verify_model,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Recommendations for an item are stable until the library changes, so serving the
|
|
46
|
+
# same seed twice should never cost a second model call. This is the single biggest
|
|
47
|
+
# latency win available without retraining.
|
|
48
|
+
CACHE_SIZE = int(os.environ.get("MARQUEE_CACHE_SIZE", "2048"))
|
|
49
|
+
|
|
50
|
+
LIBRARY_PATH = os.environ.get("MARQUEE_LIBRARY", "")
|
|
51
|
+
MODEL = os.environ.get("MARQUEE_MODEL", "marquee-4b")
|
|
52
|
+
BASE_URL = os.environ.get("MARQUEE_BASE_URL", "http://localhost:11434/v1")
|
|
53
|
+
|
|
54
|
+
app = FastAPI(title="Marquee", version="1.0")
|
|
55
|
+
_lib: Library | None = None
|
|
56
|
+
_cache: "OrderedDict[tuple, dict]" = OrderedDict()
|
|
57
|
+
_hits = _misses = 0
|
|
58
|
+
_verified: tuple[bool, str] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _raw_call(msgs: list[dict]) -> str:
|
|
62
|
+
"""Model call with NO grammar, used by the self-test."""
|
|
63
|
+
resp = requests.post(
|
|
64
|
+
BASE_URL.rstrip("/") + "/chat/completions",
|
|
65
|
+
json={"model": MODEL, "messages": msgs, "temperature": 0.1, "max_tokens": 1700},
|
|
66
|
+
timeout=180,
|
|
67
|
+
)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
return resp.json()["choices"][0]["message"]["content"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.on_event("startup")
|
|
73
|
+
def _startup_selftest() -> None:
|
|
74
|
+
"""Fail loudly at boot rather than serving quietly wrong rows.
|
|
75
|
+
|
|
76
|
+
Uses a real item from the loaded library: a synthetic probe is far enough out of
|
|
77
|
+
distribution that a healthy model can fail it.
|
|
78
|
+
"""
|
|
79
|
+
global _verified
|
|
80
|
+
try:
|
|
81
|
+
lib = library()
|
|
82
|
+
seed = max(lib.items, key=lambda i: i.popularity)
|
|
83
|
+
probe = lib.build_request(seed.id, semantic_k=10)
|
|
84
|
+
except Exception as exc: # noqa: BLE001
|
|
85
|
+
_verified = (False, f"could not build a probe request: {exc}")
|
|
86
|
+
else:
|
|
87
|
+
_verified = verify_model(_raw_call, probe)
|
|
88
|
+
ok, msg = _verified
|
|
89
|
+
print(f"[marquee] contract v{CONTRACT_VERSION} self-test: "
|
|
90
|
+
f"{'PASS' if ok else 'FAIL'} - {msg}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RecommendRequest(BaseModel):
|
|
94
|
+
seed_id: str | None = Field(None, description="library id -- always unambiguous, prefer this")
|
|
95
|
+
title: str | None = Field(None, description="fallback lookup when you have no id")
|
|
96
|
+
year: int | None = Field(None, description="disambiguates remakes when matching by title")
|
|
97
|
+
media_type: str | None = Field(None, description="movie | tv, to narrow a title match")
|
|
98
|
+
row_size: int = Field(0, ge=0, le=50, description="top rows up to N items; 0 = model's own")
|
|
99
|
+
owned_only: bool = Field(False, description="hide items not on this server")
|
|
100
|
+
semantic_k: int = Field(14, ge=4, le=40)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Item(BaseModel):
|
|
104
|
+
id: str
|
|
105
|
+
title: str
|
|
106
|
+
owned: bool = True
|
|
107
|
+
media_type: str = ""
|
|
108
|
+
year: int | None = None
|
|
109
|
+
rating: float = 0.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Row(BaseModel):
|
|
113
|
+
type: str
|
|
114
|
+
title: str
|
|
115
|
+
items: list[Item]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RecommendResponse(BaseModel):
|
|
119
|
+
seed: Item
|
|
120
|
+
rows: list[Row]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def library() -> Library:
|
|
124
|
+
global _lib
|
|
125
|
+
if _lib is None:
|
|
126
|
+
if not LIBRARY_PATH:
|
|
127
|
+
raise HTTPException(500, "MARQUEE_LIBRARY is not set")
|
|
128
|
+
path = Path(LIBRARY_PATH)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
raise HTTPException(500, f"library not found: {path}")
|
|
131
|
+
lib = Library.load(path)
|
|
132
|
+
cache = Path(os.environ.get("MARQUEE_EMB_CACHE", path.with_suffix(".emb.npz")))
|
|
133
|
+
lib.build_embeddings(cache=cache)
|
|
134
|
+
_lib = lib
|
|
135
|
+
return _lib
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _call_model(msgs: list[dict]) -> str:
|
|
139
|
+
resp = requests.post(
|
|
140
|
+
BASE_URL.rstrip("/") + "/chat/completions",
|
|
141
|
+
json={
|
|
142
|
+
"model": MODEL,
|
|
143
|
+
"messages": msgs,
|
|
144
|
+
"temperature": 0.1,
|
|
145
|
+
"max_tokens": 1700,
|
|
146
|
+
"response_format": response_format(),
|
|
147
|
+
},
|
|
148
|
+
timeout=180,
|
|
149
|
+
)
|
|
150
|
+
if not resp.ok:
|
|
151
|
+
raise HTTPException(502, f"model backend {resp.status_code}: {resp.text[:200]}")
|
|
152
|
+
return resp.json()["choices"][0]["message"]["content"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.get("/health")
|
|
156
|
+
def health() -> dict:
|
|
157
|
+
lib = library()
|
|
158
|
+
owned = sum(1 for i in lib.items if i.owned)
|
|
159
|
+
ok, msg = _verified or (None, "not run")
|
|
160
|
+
return {
|
|
161
|
+
"status": "ok" if ok else "degraded",
|
|
162
|
+
"contract_version": CONTRACT_VERSION,
|
|
163
|
+
"model_self_test": {"passed": ok, "detail": msg},
|
|
164
|
+
"cache": {"entries": len(_cache), "hits": _hits, "misses": _misses,
|
|
165
|
+
"max": CACHE_SIZE},
|
|
166
|
+
"library_items": len(lib.items),
|
|
167
|
+
"owned": owned,
|
|
168
|
+
"requestable": len(lib.items) - owned,
|
|
169
|
+
"model": MODEL,
|
|
170
|
+
"backend": BASE_URL,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def split_title_year(text: str) -> tuple[str, int | None]:
|
|
175
|
+
"""Accept "Top Gun (1986)" as well as a bare title.
|
|
176
|
+
|
|
177
|
+
Writing the year in parentheses is how people habitually disambiguate remakes,
|
|
178
|
+
so treat it as the year filter rather than as part of the title to match.
|
|
179
|
+
"""
|
|
180
|
+
m = re.search(r"^(.*?)\s*\((\d{4})\)\s*$", text.strip())
|
|
181
|
+
if m:
|
|
182
|
+
return m.group(1).strip(), int(m.group(2))
|
|
183
|
+
return text.strip(), None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _find(lib: Library, title: str, year: int | None, media_type: str | None) -> list:
|
|
187
|
+
"""Title lookup, narrowed by year and media type when given.
|
|
188
|
+
|
|
189
|
+
Exact title matches win outright: searching "Dune" should not also return
|
|
190
|
+
"Dune: Part Two" when an exact "Dune" exists.
|
|
191
|
+
"""
|
|
192
|
+
title_text, inline_year = split_title_year(title)
|
|
193
|
+
year = year if year is not None else inline_year
|
|
194
|
+
q = title_text.lower()
|
|
195
|
+
pool = [i for i in lib.items if q in i.title.lower()]
|
|
196
|
+
exact = [i for i in pool if i.title.lower() == q]
|
|
197
|
+
if exact:
|
|
198
|
+
pool = exact
|
|
199
|
+
if year is not None:
|
|
200
|
+
pool = [i for i in pool if i.year == year]
|
|
201
|
+
if media_type:
|
|
202
|
+
pool = [i for i in pool if i.media_type == media_type]
|
|
203
|
+
return sorted(pool, key=lambda i: -i.popularity)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.get("/search")
|
|
207
|
+
def search(
|
|
208
|
+
q: str,
|
|
209
|
+
media_type: str | None = None,
|
|
210
|
+
kind: str = "all",
|
|
211
|
+
limit: int = 20,
|
|
212
|
+
) -> dict:
|
|
213
|
+
"""Search titles and people.
|
|
214
|
+
|
|
215
|
+
`kind` is "title", "person" or "all". A query like "Scorsese" matches no title but
|
|
216
|
+
plenty of credits, so searching both by default is what a user actually expects.
|
|
217
|
+
"""
|
|
218
|
+
lib = library()
|
|
219
|
+
out: dict = {"query": q, "titles": [], "people": []}
|
|
220
|
+
|
|
221
|
+
if kind in ("title", "all"):
|
|
222
|
+
out["titles"] = [
|
|
223
|
+
{"id": i.id, "title": i.title, "year": i.year, "media_type": i.media_type,
|
|
224
|
+
"rating": round(i.rating, 1), "owned": i.owned}
|
|
225
|
+
for i in _find(lib, q, None, media_type)[:limit]
|
|
226
|
+
]
|
|
227
|
+
if kind in ("person", "all"):
|
|
228
|
+
out["people"] = lib.find_people(q, limit=limit)
|
|
229
|
+
|
|
230
|
+
out["count"] = len(out["titles"]) + len(out["people"])
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.get("/person")
|
|
235
|
+
def person(
|
|
236
|
+
key: str | None = None,
|
|
237
|
+
name: str | None = None,
|
|
238
|
+
media_type: str | None = None,
|
|
239
|
+
owned_only: bool = False,
|
|
240
|
+
limit: int = 40,
|
|
241
|
+
by_role: bool = False,
|
|
242
|
+
) -> dict:
|
|
243
|
+
"""Everything a person is credited on.
|
|
244
|
+
|
|
245
|
+
Films and shows in one rating-ranked list by default, deduplicated across roles.
|
|
246
|
+
`by_role=true` splits it into per-role shelves instead.
|
|
247
|
+
|
|
248
|
+
No model call: this is a pure index lookup, so it is effectively instant.
|
|
249
|
+
"""
|
|
250
|
+
lib = library()
|
|
251
|
+
if not key:
|
|
252
|
+
if not name:
|
|
253
|
+
raise HTTPException(400, "provide key or name")
|
|
254
|
+
hits = lib.find_people(name, limit=5)
|
|
255
|
+
if not hits:
|
|
256
|
+
raise HTTPException(404, f"no person matching {name!r}")
|
|
257
|
+
if len(hits) > 1 and hits[0]["name"].lower() != name.strip().lower():
|
|
258
|
+
raise HTTPException(409, {"error": "ambiguous name", "matches": hits})
|
|
259
|
+
key = hits[0]["key"]
|
|
260
|
+
|
|
261
|
+
if not by_role:
|
|
262
|
+
credits = lib.person_credits(key, limit=limit, media_type=media_type,
|
|
263
|
+
owned_only=owned_only)
|
|
264
|
+
if not credits:
|
|
265
|
+
raise HTTPException(404, f"no credits for {key!r}")
|
|
266
|
+
return {
|
|
267
|
+
"person": lib.person_names.get(key, key),
|
|
268
|
+
"count": len(credits),
|
|
269
|
+
"credits": credits,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
rows = lib.person_rows(key, max_per_row=limit, media_type=media_type,
|
|
273
|
+
owned_only=owned_only)
|
|
274
|
+
if not rows:
|
|
275
|
+
raise HTTPException(404, f"no credits for {key!r}")
|
|
276
|
+
|
|
277
|
+
titles = {
|
|
278
|
+
"director": "Directed by {name}", "author": "Written by {name}",
|
|
279
|
+
"actor": "Starring {name}", "writer": "Written by {name}",
|
|
280
|
+
"cinematographer": "Shot by {name}", "composer": "Scored by {name}",
|
|
281
|
+
"narrator": "Narrated by {name}",
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
"person": lib.person_names.get(key, key),
|
|
285
|
+
"rows": [
|
|
286
|
+
{
|
|
287
|
+
"role": r["role"],
|
|
288
|
+
"title": titles.get(r["role"], "{name}").format(name=r["anchor"]),
|
|
289
|
+
"items": [
|
|
290
|
+
{"id": i, "title": lib.by_id[i].title, "year": lib.by_id[i].year,
|
|
291
|
+
"media_type": lib.by_id[i].media_type,
|
|
292
|
+
"rating": round(lib.by_id[i].rating, 1),
|
|
293
|
+
"owned": lib.by_id[i].owned}
|
|
294
|
+
for i in r["items"]
|
|
295
|
+
],
|
|
296
|
+
}
|
|
297
|
+
for r in rows
|
|
298
|
+
],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@app.delete("/cache")
|
|
303
|
+
def clear_cache() -> dict:
|
|
304
|
+
"""Call this after a library rescan -- cached rows go stale when items change."""
|
|
305
|
+
n = len(_cache)
|
|
306
|
+
_cache.clear()
|
|
307
|
+
return {"cleared": n}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.post("/recommend", response_model=RecommendResponse)
|
|
311
|
+
def recommend(req: RecommendRequest) -> RecommendResponse:
|
|
312
|
+
global _hits, _misses
|
|
313
|
+
lib = library()
|
|
314
|
+
|
|
315
|
+
if req.seed_id:
|
|
316
|
+
if req.seed_id not in lib.by_id:
|
|
317
|
+
raise HTTPException(404, f"no library item with id {req.seed_id!r}")
|
|
318
|
+
seed = lib.by_id[req.seed_id]
|
|
319
|
+
elif req.title:
|
|
320
|
+
matches = _find(lib, req.title, req.year, req.media_type)
|
|
321
|
+
if not matches:
|
|
322
|
+
raise HTTPException(404, f"no library item matching {req.title!r}")
|
|
323
|
+
# 2.2% of titles collide (remakes, and occasional duplicate catalog entries).
|
|
324
|
+
# Guessing by popularity silently returns the wrong film, so say so instead
|
|
325
|
+
# and let the caller pick -- /search exists for exactly this.
|
|
326
|
+
if len(matches) > 1:
|
|
327
|
+
raise HTTPException(
|
|
328
|
+
409,
|
|
329
|
+
{
|
|
330
|
+
"error": f"{len(matches)} items match {req.title!r}; pass seed_id, "
|
|
331
|
+
"or narrow with year/media_type",
|
|
332
|
+
"matches": [
|
|
333
|
+
{"id": m.id, "title": m.title, "year": m.year,
|
|
334
|
+
"media_type": m.media_type}
|
|
335
|
+
for m in matches[:10]
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
seed = matches[0]
|
|
340
|
+
else:
|
|
341
|
+
raise HTTPException(400, "provide seed_id or title")
|
|
342
|
+
|
|
343
|
+
key = (seed.id, req.row_size, req.owned_only, req.semantic_k)
|
|
344
|
+
if key in _cache:
|
|
345
|
+
_cache.move_to_end(key)
|
|
346
|
+
_hits += 1
|
|
347
|
+
return RecommendResponse(**_cache[key])
|
|
348
|
+
_misses += 1
|
|
349
|
+
|
|
350
|
+
request = lib.build_request(
|
|
351
|
+
seed.id,
|
|
352
|
+
semantic_k=req.semantic_k,
|
|
353
|
+
row_size=req.row_size,
|
|
354
|
+
owned_only=req.owned_only,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
response = parse(_call_model(messages(request)))
|
|
359
|
+
except ValidationError as exc:
|
|
360
|
+
raise HTTPException(502, f"model returned unusable output: {exc}") from exc
|
|
361
|
+
|
|
362
|
+
# Repair before serving: the same normalisation used to build the training data,
|
|
363
|
+
# so a stray row never reaches the client.
|
|
364
|
+
response = repair(response, request)
|
|
365
|
+
if req.row_size:
|
|
366
|
+
response = expand_rows(response, request, req.row_size)
|
|
367
|
+
|
|
368
|
+
def as_item(item_id: str) -> Item:
|
|
369
|
+
it = lib.by_id[item_id]
|
|
370
|
+
return Item(
|
|
371
|
+
id=it.id, title=it.title, owned=it.owned,
|
|
372
|
+
media_type=it.media_type, year=it.year, rating=round(it.rating, 1),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
result = RecommendResponse(
|
|
376
|
+
seed=as_item(seed.id),
|
|
377
|
+
rows=[
|
|
378
|
+
Row(type=r["type"], title=r["title"], items=[as_item(i) for i in r["items"]])
|
|
379
|
+
for r in response.get("rows", [])
|
|
380
|
+
],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
_cache[key] = result.model_dump()
|
|
384
|
+
while len(_cache) > CACHE_SIZE:
|
|
385
|
+
_cache.popitem(last=False)
|
|
386
|
+
return result
|