From 75d6146c97f19623a4a82ce4cd5eb16358f5ed20 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:18:24 -0400 Subject: [PATCH 1/6] Update dependency urllib3 to v2.7.0 [SECURITY] (#3329) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 001d7183cf..040b4f9fab 100644 --- a/uv.lock +++ b/uv.lock @@ -5132,11 +5132,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [package.optional-dependencies] From 7800189e17487f88b28cf653b9f49d3de4961228 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:44:07 -0400 Subject: [PATCH 2/6] Update dependency litellm to v1.83.10 [SECURITY] (#3331) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 52 +++++++++++++++++++++++++------------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2bafe84139..792a5996c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "langchain>=0.3.11,<0.4", "langchain-experimental>=0.3.4,<0.4", "langchain-openai>=0.3.2,<0.4", - "litellm==1.83.7", + "litellm==1.83.10", "llama-index>=0.14.0,<0.15", "llama-index-llms-openai>=0.6.0,<0.7", "lxml>=6.0.0,<7", diff --git a/uv.lock b/uv.lock index 040b4f9fab..3492026abd 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -27,25 +27,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, ] [[package]] @@ -2115,7 +2115,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.7" +version = "1.83.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2131,9 +2131,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/16/fc51935e406887079f0d7c20427b9a15b9fc1d558e68b4e7072331b70db4/litellm-1.83.10.tar.gz", hash = "sha256:d54eaa98f93a1eb02decf593dbb525fa1ddd4cf03686c1d5c7bb69c2a9ba2a41", size = 14726546, upload-time = "2026-04-19T02:36:28.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/8dd69d5b1ab11f206a9c9f21b6fd191bcdd4fb3ed90b8efbf3a1291fd47c/litellm-1.83.10-py3-none-any.whl", hash = "sha256:55203a7b5551efec8f2fccde29ee045ba057e768591e0b6b9fe1d12f00685ff8", size = 16334780, upload-time = "2026-04-19T02:36:25.274Z" }, ] [[package]] @@ -2677,7 +2677,7 @@ requires-dist = [ { name = "langchain-experimental", specifier = ">=0.3.4,<0.4" }, { name = "langchain-litellm", specifier = ">=0.5.1" }, { name = "langchain-openai", specifier = ">=0.3.2,<0.4" }, - { name = "litellm", specifier = "==1.83.7" }, + { name = "litellm", specifier = "==1.83.10" }, { name = "llama-index", specifier = ">=0.14.0,<0.15" }, { name = "llama-index-llms-openai", specifier = ">=0.6.0,<0.7" }, { name = "lxml", specifier = ">=6.0.0,<7" }, @@ -3092,7 +3092,7 @@ wheels = [ [[package]] name = "openai" -version = "2.30.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3104,9 +3104,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] From 2afc28bd5f79d61e5937fe42bbe2e69711ace991 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:44:26 -0400 Subject: [PATCH 3/6] Update dependency granian to v2.7.4 [SECURITY] (#3309) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- uv.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/uv.lock b/uv.lock index 3492026abd..731ac992d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1437,23 +1437,23 @@ wheels = [ [[package]] name = "granian" -version = "2.7.2" +version = "2.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/19/d4ea523715ba8dd2ed295932cc3dda6bb197060f78aada6e886ff08587b2/granian-2.7.2.tar.gz", hash = "sha256:cdae2f3a26fa998d41fefad58f1d1c84a0b035a6cc9377addd81b51ba82f927f", size = 128969, upload-time = "2026-02-24T23:04:23.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/ed/37f5d7d887ec9159dd8f5b1c9c38cee711d51016d203959f2d51c536a33b/granian-2.7.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a836f3f8ebfe61cb25d9afb655f2e5d3851154fd2ad97d47bb4fb202817212fc", size = 6451593, upload-time = "2026-02-24T23:02:36.203Z" }, - { url = "https://files.pythonhosted.org/packages/1e/06/84ee67a68504836a52c48ec3b4b2b406cbd927c9b43aae89d82db8d097a0/granian-2.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09b1c543ba30886dea515a156baf6d857bbb8b57dbfd8b012c578b93c80ef0c3", size = 6101239, upload-time = "2026-02-24T23:02:37.636Z" }, - { url = "https://files.pythonhosted.org/packages/ed/50/ece7dc8efe144542cd626b88b1475b649e2eaa3eb5f7541ca57390151b05/granian-2.7.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d334d4fbefb97001e78aa8067deafb107b867c102ba2120b4b2ec989fa58a89", size = 7079443, upload-time = "2026-02-24T23:02:39.651Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/0f37b531d3cc96b8538cca2dc86eda92102e0ee345b30aa689354194a4cb/granian-2.7.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c86081d8c87989db69650e9d0e50ed925b8cd5dad21e0a86aa72d7a45f45925", size = 6428683, upload-time = "2026-02-24T23:02:41.827Z" }, - { url = "https://files.pythonhosted.org/packages/47/09/228626706554b389407270e2a6b19b7dee06d6890e8c01a39c6a785827fd/granian-2.7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9eda33dca2c8bc6471bb6e9e25863077bca3877a1bba4069cd5e0ee2de41765", size = 6959520, upload-time = "2026-02-24T23:02:43.488Z" }, - { url = "https://files.pythonhosted.org/packages/61/c0/a639ceabd59b8acae2d71b5c918fcb2d42f8ef98994eedcf9a8b6813731d/granian-2.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9cf69aaff6f632074ffbe7c1ee214e50f64be36101b7cb8253eeec1d460f2dba", size = 6991548, upload-time = "2026-02-24T23:02:44.954Z" }, - { url = "https://files.pythonhosted.org/packages/b1/99/a35ed838a3095dcad02ae3944d19ebafe1d5a98cdc72bb61835fb5faf933/granian-2.7.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f761a748cc7f3843b430422d2539da679daf5d3ef0259a101b90d5e55a0aafa7", size = 7121475, upload-time = "2026-02-24T23:02:46.991Z" }, - { url = "https://files.pythonhosted.org/packages/ce/24/3952c464432b904ec1cf537d2bd80d2dfde85524fa428ab9db2b5afe653c/granian-2.7.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:41c7b8390b78647fe34662ed7296e1465dad4a5112af9b0ecf8e367083d6c76a", size = 7243647, upload-time = "2026-02-24T23:02:49.165Z" }, - { url = "https://files.pythonhosted.org/packages/c9/fa/ab39e39c6b78eab6b42cf5bb36f56badde2aaafc3807f03f781d00e7861a/granian-2.7.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a052ed466da5922cb443435a95a0c751566943278a6f22cef3d2e19d4e7ecdea", size = 7048915, upload-time = "2026-02-24T23:02:50.773Z" }, - { url = "https://files.pythonhosted.org/packages/39/64/4502918f7d92a7e668d9e2fba83e2decbbf44c8ea896bacd8551d64f1d29/granian-2.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:1e438096c36ed6aa4f6c0c8dde22bebe08ac008d08257517b15182c262a08cfa", size = 4150398, upload-time = "2026-02-24T23:02:52.199Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/db/0c/27aa25280b6c1f323312e83088304da8a7f3e5c1e568d3a560365ec6fa67/granian-2.7.4.tar.gz", hash = "sha256:1dc0530d7ae6b0ae43aafafe771ac0b8c38af68bbd71ab355828817faf13aac1", size = 128212, upload-time = "2026-04-23T11:55:55.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/d9/148024fd3a8bd974bb5c68a0cb48d15df7763fd1364bf090ccc2d423028a/granian-2.7.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c2f40aaecf2ba3d8232e55181c8f6db7bc68d9112a419ab8d5f9e2f33f631f5", size = 6374067, upload-time = "2026-04-23T11:54:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/c53b61a7cb67d33677d96913438eca3d79de1b1b7173a361fcdf2753ade7/granian-2.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8111d5e74b27721e0fdda3edba7c154d44c41b469466857ca3c51b088e3846b", size = 6046338, upload-time = "2026-04-23T11:54:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/5c9dc91b9c9a05bf6ed0b795d30f4bb8f290d61502779a89ed2fd75f9fb6/granian-2.7.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74adbb6c1920dbf4271b824135639318b2a20ff5e33bc35639a8e2928a777234", size = 7000585, upload-time = "2026-04-23T11:54:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7c/c770593b24a472ab5265a44546f56079757efbf89f8e8b2229a8443e453b/granian-2.7.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b778d356b61e0389c823016ad2be50a634b80d3d28a33922f7ac39553e828ad", size = 6255544, upload-time = "2026-04-23T11:54:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/15/46/796147587edb494a330294cb001cf68520ad8296a7da91d80ec672ac8615/granian-2.7.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3607b091c4ef225ee99150f3b02cb827de8d677b52fc75f0b28893244f7bab27", size = 6875124, upload-time = "2026-04-23T11:54:13.967Z" }, + { url = "https://files.pythonhosted.org/packages/c5/25/b867f624886e11053e7a6235244de26fd864a136e65d12295e728b3e5005/granian-2.7.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3d3cf4fe3cafd9b874d8b749c66c790cbf2b4225f2a7d9fb284c51b77a8e938d", size = 6982394, upload-time = "2026-04-23T11:54:15.733Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e1/5746bfe202bd2f6a1506346463ce52dd015c2b5d03d07a53ecf0fddefa3f/granian-2.7.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:846c9cbfea8684ab13d21d66855ad06dc077fb95b5590e7f5040e79994d6429d", size = 6991457, upload-time = "2026-04-23T11:54:17.325Z" }, + { url = "https://files.pythonhosted.org/packages/e0/45/fc6992839d367b6ae8fa8d88b5e70ec293162c3a2e0e6b90fc426f228df2/granian-2.7.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:d34d97cfe4a7805ecb5b1b1684f3f197bb4baf019d2a9f18e34fd1d697a03a7f", size = 7148499, upload-time = "2026-04-23T11:54:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/fe/12/16ffd64a1213858d4cf824767b398758be807dd1a6df5a303dc76994b6d6/granian-2.7.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f11336e4bcd8ef5c5143b075b5260e37e8431eb36d68564cc39416ca526c797f", size = 7006829, upload-time = "2026-04-23T11:54:20.804Z" }, + { url = "https://files.pythonhosted.org/packages/95/9a/f2fcda200f8739ddf25be72591b7a28897be0ffd952a76ec655e5f877144/granian-2.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:9e0a4370773ec4a0e92a55a33fc700b60003e335480e5c7fe941f4bc3dda2e18", size = 4026771, upload-time = "2026-04-23T11:54:22.36Z" }, ] [package.optional-dependencies] From ef1dcc89b3bb66be56c1940494d22e610ab3c2c9 Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Tue, 12 May 2026 14:50:05 -0400 Subject: [PATCH 4/6] Min score for vector learning resources endpoint (#3285) * adding cutoff to serializer * adding param to search method * refactor of sync search view * ensuring all results are returned if there is a query with score_cutoff * ensuring all results are returned if there is a query with score_cutoff * spec update * adding frontend changes * fix test * fix tests * introduce manual aggregation method * re-introduce spacing at bottom * fix facet behavior * fix lint * fix lint * adding docstring * refactor to not use pandas * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix facet sorting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update spec * style lint * avoid call to aggregations * move import to top * adding test for aggregation buckets * only include the threshold if greater than 0 * refactor min score default values. add min score to settings * enforce limit and remove count call to fetch total * add max limit setting * add test * udpate spec * fix test after refactor * set default to min score * fix test * spec update * fix test * fix sorting * moving min score to constant --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontends/api/src/generated/v0/api.ts | 18 ++ .../app-pages/SearchPage/SearchPage.test.tsx | 72 ++++++ .../SearchDisplay/SearchDisplay.tsx | 225 ++++++++++++++--- main/settings.py | 4 + openapi/specs/v0.yaml | 8 + vector_search/constants.py | 3 + vector_search/serializers.py | 7 + vector_search/utils.py | 12 +- vector_search/utils_test.py | 2 +- vector_search/views.py | 236 +++++++++++++++--- vector_search/views_test.py | 147 ++++++++++- 11 files changed, 656 insertions(+), 78 deletions(-) diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 434b7e64c5..b4db550aaa 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -12031,6 +12031,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( * @param {string} [readable_id] The readable id of the resource * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `document` - document * @param {Array} [resource_type_group] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material + * @param {number} [score_cutoff] The minimum score a result must have to be returned * @param {VectorLearningResourcesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @param {boolean | null} [title__isnull] Filter to learning resources where title is null/not null * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -12059,6 +12060,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( readable_id?: string, resource_type?: Array, resource_type_group?: Array, + score_cutoff?: number, sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, topic?: Array, @@ -12161,6 +12163,10 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( localVarQueryParameter["resource_type_group"] = resource_type_group } + if (score_cutoff !== undefined) { + localVarQueryParameter["score_cutoff"] = score_cutoff + } + if (sortby !== undefined) { localVarQueryParameter["sortby"] = sortby } @@ -12227,6 +12233,7 @@ export const VectorLearningResourcesSearchApiFp = function ( * @param {string} [readable_id] The readable id of the resource * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `document` - document * @param {Array} [resource_type_group] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material + * @param {number} [score_cutoff] The minimum score a result must have to be returned * @param {VectorLearningResourcesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @param {boolean | null} [title__isnull] Filter to learning resources where title is null/not null * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -12255,6 +12262,7 @@ export const VectorLearningResourcesSearchApiFp = function ( readable_id?: string, resource_type?: Array, resource_type_group?: Array, + score_cutoff?: number, sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, topic?: Array, @@ -12288,6 +12296,7 @@ export const VectorLearningResourcesSearchApiFp = function ( readable_id, resource_type, resource_type_group, + score_cutoff, sortby, title__isnull, topic, @@ -12354,6 +12363,7 @@ export const VectorLearningResourcesSearchApiFactory = function ( requestParameters.readable_id, requestParameters.resource_type, requestParameters.resource_type_group, + requestParameters.score_cutoff, requestParameters.sortby, requestParameters.title__isnull, requestParameters.topic, @@ -12511,6 +12521,13 @@ export interface VectorLearningResourcesSearchApiVectorLearningResourcesSearchRe */ readonly resource_type_group?: Array + /** + * The minimum score a result must have to be returned + * @type {number} + * @memberof VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieve + */ + readonly score_cutoff?: number + /** * if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @type {'next_start_date' | 'views' | 'created_on' | '-next_start_date' | '-views' | '-created_on'} @@ -12581,6 +12598,7 @@ export class VectorLearningResourcesSearchApi extends BaseAPI { requestParameters.readable_id, requestParameters.resource_type, requestParameters.resource_type_group, + requestParameters.score_cutoff, requestParameters.sortby, requestParameters.title__isnull, requestParameters.topic, diff --git a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx index dd7a8dfd17..84b7e21a29 100644 --- a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx +++ b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx @@ -12,6 +12,7 @@ import type { LearningResourcesSearchResponse, PaginatedLearningResourceOfferorDetailList, } from "api" +import { ResourceTypeEnum } from "api" import invariant from "tiny-invariant" import { Permission } from "api/hooks/user" import { @@ -1084,6 +1085,77 @@ describe("UniversalAIBanner", () => { }) }) + test("Vector Hybrid Search with a query hides pagination and filters results locally", async () => { + const physicsTopic = factories.learningResources.topic({ name: "Physics" }) + const chemistryTopic = factories.learningResources.topic({ + name: "Chemistry", + }) + const resources = [ + factories.learningResources.resource({ + title: "Physics Result", + resource_type: ResourceTypeEnum.Course, + topics: [physicsTopic], + }), + factories.learningResources.resource({ + title: "Chemistry Result", + resource_type: ResourceTypeEnum.Course, + topics: [chemistryTopic], + }), + ] + + setMockApiResponses({ + search: { + count: 2, + metadata: { + aggregations: { + topic: [ + { key: "Physics", doc_count: 1 }, + { key: "Chemistry", doc_count: 1 }, + ], + resource_type_group: [{ key: "course", doc_count: 2 }], + }, + suggestions: [], + }, + results: resources, + }, + }) + setMockResponse.get(urls.userMe.get(), { + is_learning_path_editor: true, + is_authenticated: true, + }) + + const { location } = renderWithProviders(, { + url: "?vector_search=true&q=test", + }) + + await screen.findByText("Physics Result") + await screen.findByText("Chemistry Result") + expect( + screen.queryByRole("navigation", { name: /pagination/i }), + ).not.toBeInTheDocument() + + const initialVectorRequestCount = makeRequest.mock.calls.filter( + ([method, url]) => + method === "get" && url.includes(urls.search.vectorResources()), + ).length + + await user.click(screen.getByRole("button", { name: /Topic/i })) + await user.click(screen.getByRole("checkbox", { name: "Physics 1" })) + + const searchParams = new URLSearchParams(location.current.search) + expect(searchParams.get("vector_search")).toBe("true") + expect(searchParams.get("q")).toBe("test") + expect(searchParams.get("topic")).toBe("Physics") + expect(await screen.findByText("Physics Result")).toBeVisible() + expect(screen.queryByText("Chemistry Result")).not.toBeInTheDocument() + + const finalVectorRequestCount = makeRequest.mock.calls.filter( + ([method, url]) => + method === "get" && url.includes(urls.search.vectorResources()), + ).length + expect(finalVectorRequestCount).toBe(initialVectorRequestCount) + }) + test("clicking Learn More fires cta_clicked with label and readableId", async () => { allowConsoleErrors() mockedUseFeatureFlagEnabled.mockReturnValue(true) diff --git a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx index c6c8cb2e27..cc0c9df830 100644 --- a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -566,6 +566,135 @@ const toVectorSearchParams = ( hybrid_search: true, }) +const VECTOR_CLIENT_FILTER_FACETS = [ + "resource_type", + "certification_type", + "delivery", + "department", + "topic", + "offered_by", + "free", + "professional", + "resource_category", + "resource_type_group", +] as const + +type VectorClientFilterFacet = (typeof VECTOR_CLIENT_FILTER_FACETS)[number] + +const toUnfacetedVectorSearchParams = ( + params: ReturnType & { sortby?: string }, +): VectorSearchRequest => { + const { + offset: _offset, + limit: _limit, + ...vectorParams + } = toVectorSearchParams(params) + + return Object.fromEntries( + Object.entries(vectorParams).filter( + ([key]) => + !VECTOR_CLIENT_FILTER_FACETS.includes(key as VectorClientFilterFacet), + ), + ) as VectorSearchRequest +} + +const normalizeParamValues = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.map(String) + } + if (value === null || value === undefined || value === "") { + return [] + } + return [String(value)] +} + +const getResourceFacetValues = ( + resource: LearningResource, + facet: string, +): string[] => { + switch (facet) { + case "certification_type": + return normalizeParamValues( + "certification_type" in resource + ? resource.certification_type?.code + : undefined, + ) + case "delivery": + return normalizeParamValues( + "delivery" in resource ? resource.delivery?.map((d) => d.code) : [], + ) + case "department": + return normalizeParamValues( + resource.departments?.map((d) => d.department_id), + ) + case "offered_by": + return normalizeParamValues(resource.offered_by?.code) + case "topic": + return normalizeParamValues(resource.topics?.map((t) => t.name)) + case "free": + case "professional": + case "resource_type": + case "resource_category": + case "resource_type_group": + return normalizeParamValues(resource[facet]) + default: + return [] + } +} + +const matchesVectorClientFilters = ( + resource: LearningResource, + params: ReturnType, + excludedFacet?: string, +) => + VECTOR_CLIENT_FILTER_FACETS.every((facet) => { + if (facet === excludedFacet) { + return true + } + const selectedValues = normalizeParamValues(params[facet]) + if (selectedValues.length === 0) { + return true + } + const resourceValues = getResourceFacetValues(resource, facet) + return selectedValues.some((value) => resourceValues.includes(value)) + }) + +const hasVectorClientFilters = (params: ReturnType) => + VECTOR_CLIENT_FILTER_FACETS.some( + (facet) => normalizeParamValues(params[facet]).length > 0, + ) + +const getVectorClientAggregations = ( + allResults: LearningResource[], + params: ReturnType, + aggregationNames: string[], +) => { + return Object.fromEntries( + aggregationNames.map((name) => { + const resultsForFacet = allResults.filter((resource) => + matchesVectorClientFilters(resource, params, name), + ) + const counts = new Map() + for (const resource of resultsForFacet) { + for (const value of getResourceFacetValues(resource, name)) { + counts.set(value, (counts.get(value) ?? 0) + 1) + } + } + return [ + name, + Array.from(counts.entries()) + .map(([key, docCount]) => ({ + key, + doc_count: docCount, + })) + .sort( + (a, b) => b.doc_count - a.doc_count || a.key.localeCompare(b.key), + ), + ] + }), + ) +} + interface SearchDisplayProps { page: number setPage: (newPage: number) => void @@ -642,9 +771,17 @@ const SearchDisplay: React.FC = ({ const wantsVectorSearch = searchParams.get("vector_search") === "true" const isVectorSearch = wantsVectorSearch && user?.is_learning_path_editor + const isVectorQuerySearch = + isVectorSearch && + typeof allParams.q === "string" && + allParams.q.trim() !== "" const queryOptions = isVectorSearch - ? learningResourceQueries.vectorSearch(toVectorSearchParams(allParams)) + ? learningResourceQueries.vectorSearch( + isVectorQuerySearch + ? toUnfacetedVectorSearchParams(allParams) + : toVectorSearchParams(allParams), + ) : learningResourceQueries.search(allParams as LRSearchRequest) // @ts-expect-error Typescript has trouble unifying the different query key types @@ -690,6 +827,36 @@ const SearchDisplay: React.FC = ({ }, }) + const displayData = useMemo(() => { + if (!isVectorQuerySearch || !data) { + return data + } + + const allResults = data.results ?? [] + const results = allResults.filter((resource) => + matchesVectorClientFilters(resource, allParams), + ) + const hasClientFilters = hasVectorClientFilters(allParams) + + return { + ...data, + count: hasClientFilters ? results.length : data.count, + next: null, + previous: null, + results, + metadata: { + ...data.metadata, + aggregations: hasClientFilters + ? getVectorClientAggregations( + allResults, + allParams, + allParams.aggregations, + ) + : data.metadata.aggregations, + }, + } + }, [allParams, data, isVectorQuerySearch]) + useEffect(() => { if (onFetchTimeChange) { onFetchTimeChange(data?.time ?? null) @@ -974,7 +1141,7 @@ const SearchDisplay: React.FC = ({ facetManifest={facetManifest} activeFacets={requestParams} onFacetChange={toggleParamValue} - facetOptions={data?.metadata.aggregations ?? {}} + facetOptions={displayData?.metadata.aggregations ?? {}} /> {user?.is_learning_path_editor ? AdminOptions(expandAdminOptions, setExpandAdminOptions, adminParams) @@ -1006,14 +1173,14 @@ const SearchDisplay: React.FC = ({ * the count when data is loaded even if count is same as previous * count. */} - {isFetching || isLoading ? "" : `${data?.count} results`} + {isFetching || isLoading ? "" : `${displayData?.count} results`} setPage(1)} /> {sortDropdown} @@ -1085,9 +1252,9 @@ const SearchDisplay: React.FC = ({ ))} - ) : data && (data.results?.length ?? 0) > 0 ? ( + ) : displayData && (displayData.results?.length ?? 0) > 0 ? ( - {data.results.map((resource: LearningResource) => ( + {displayData.results.map((resource: LearningResource) => (
  • = ({ )} - { - setPage(newPage) - setTimeout(() => { - scrollHook.current?.scrollIntoView({ - block: "center", - behavior: "smooth", - }) - }, 0) - }} - renderItem={(item) => ( - - )} - /> + {!isVectorQuerySearch && ( + { + setPage(newPage) + setTimeout(() => { + scrollHook.current?.scrollIntoView({ + block: "center", + behavior: "smooth", + }) + }, 0) + }} + renderItem={(item) => ( + + )} + /> + )} diff --git a/main/settings.py b/main/settings.py index c22153921f..90e44e0657 100644 --- a/main/settings.py +++ b/main/settings.py @@ -836,6 +836,10 @@ def get_all_config_keys(): VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT = get_int( name="VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT", default=500 ) + +# hard limit for special cases where we need to return all results without pagination +VECTOR_SEARCH_PAGE_MAX_LIMIT = get_int("VECTOR_SEARCH_PAGE_MAX_LIMIT", 200) + # toggle to use requests (default for local) or webdriver which renders js elements EMBEDDINGS_EXTERNAL_FETCH_USE_WEBDRIVER = get_bool( "EMBEDDINGS_EXTERNAL_FETCH_USE_WEBDRIVER", default=False diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 95f1587eb4..bd2e324c81 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -1423,6 +1423,14 @@ paths: * `learning_material` - Learning Material description: "The category of learning resource \n\n* `course`\ \ - Course\n* `program` - Program\n* `learning_material` - Learning Material" + - in: query + name: score_cutoff + schema: + type: number + format: double + minimum: 0 + default: 0.1 + description: The minimum score a result must have to be returned - in: query name: sortby schema: diff --git a/vector_search/constants.py b/vector_search/constants.py index e740589373..2194f632f8 100644 --- a/vector_search/constants.py +++ b/vector_search/constants.py @@ -163,3 +163,6 @@ # Indexing threshold ratio QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO = 0.4 + +# the minimum similarity score +VECTOR_SEARCH_MIN_SCORE = 0.1 diff --git a/vector_search/serializers.py b/vector_search/serializers.py index 6e66a7687a..539bff6c8c 100644 --- a/vector_search/serializers.py +++ b/vector_search/serializers.py @@ -24,6 +24,7 @@ QDRANT_CONTENT_FILE_PARAM_MAP, QDRANT_LEARNING_RESOURCE_SORTBY_FIELDS, QDRANT_RESOURCE_PARAM_MAP, + VECTOR_SEARCH_MIN_SCORE, ) @@ -224,6 +225,12 @@ class LearningResourcesVectorSearchRequestSerializer( default=False, help_text="Whether to use a hybrid search", ) + score_cutoff = serializers.FloatField( + required=False, + default=VECTOR_SEARCH_MIN_SCORE, + min_value=0, + help_text="The minimum score a result must have to be returned", + ) def validate(self, attrs): return _validate_result_window(attrs) diff --git a/vector_search/utils.py b/vector_search/utils.py index c235595a04..344b95a48d 100644 --- a/vector_search/utils.py +++ b/vector_search/utils.py @@ -927,11 +927,13 @@ def process_batch(docs_batch, summaries_list): def _resource_vector_hits(search_result): - hits = [ - readable_id - for readable_id in (hit.payload.get("readable_id") for hit in search_result) - if readable_id - ] + hits = list( + dict.fromkeys( + readable_id + for readable_id in (hit.payload.get("readable_id") for hit in search_result) + if readable_id + ) + ) """ Always lookup learning resources by readable_id for portability in case we load points from external systems diff --git a/vector_search/utils_test.py b/vector_search/utils_test.py index e2303e8851..46cfb7f37d 100644 --- a/vector_search/utils_test.py +++ b/vector_search/utils_test.py @@ -1470,7 +1470,7 @@ def test_vector_search_hybrid(mocker, client): mock_search_result = mocker.MagicMock() mock_search_result.points = [] mock_qdrant.query_points.return_value = mock_search_result - mock_qdrant.count.return_value = models.CountResult(count=0) + mock_qdrant.count.return_value = models.CountResult(count=1) mock_dense_encoder = mocker.patch("vector_search.views.dense_encoder")() mock_dense_encoder.clear_cache() diff --git a/vector_search/views.py b/vector_search/views.py index f88ac8a1a9..5494ecfec1 100644 --- a/vector_search/views.py +++ b/vector_search/views.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections import Counter from functools import wraps from itertools import chain @@ -18,10 +19,13 @@ from learning_resources.constants import GROUP_CONTENT_FILE_CONTENT_VIEWERS from main.utils import cache_page_for_anonymous_users from vector_search.constants import ( + COLLECTION_PARAM_MAP, CONTENT_FILES_COLLECTION_NAME, CONTENT_FILES_RETRIEVE_PAYLOAD, + QDRANT_RESOURCE_PARAM_MAP, RESOURCES_COLLECTION_NAME, RESOURCES_RETRIEVE_PAYLOAD, + VECTOR_SEARCH_MIN_SCORE, ) from vector_search.serializers import ( ContentFileVectorSearchRequestSerializer, @@ -105,6 +109,7 @@ async def _build_search_params( # noqa: PLR0913 encoder_dense, encoder_sparse, hybrid_search, + score_cutoff: float | None, ): search_params = { "collection_name": search_collection, @@ -126,6 +131,9 @@ async def _build_search_params( # noqa: PLR0913 "limit": limit, } + if type(score_cutoff) is float and score_cutoff >= VECTOR_SEARCH_MIN_SCORE: + search_params["score_threshold"] = score_cutoff + if hybrid_search: sparse_query, dense_query = await asyncio.gather( sync_to_async(encoder_sparse.embed, thread_sensitive=False)( @@ -135,23 +143,24 @@ async def _build_search_params( # noqa: PLR0913 query_string ), ) - if order_by: + prefetch_params = [ + models.Prefetch( + filter=search_filter, + query=sparse_query, + using=encoder_sparse.model_short_name(), + limit=prefetch_limit, + ), + models.Prefetch( + filter=search_filter, + query=dense_query, + using=encoder_dense.model_short_name(), + limit=prefetch_limit, + ), + ] + if order_by and "score_threshold" not in search_params: # Nest: vector prefetches → fusion prefetch → order_by query search_params["prefetch"] = models.Prefetch( - prefetch=[ - models.Prefetch( - filter=search_filter, - query=sparse_query, - using=encoder_sparse.model_short_name(), - limit=prefetch_limit, - ), - models.Prefetch( - filter=search_filter, - query=dense_query, - using=encoder_dense.model_short_name(), - limit=prefetch_limit, - ), - ], + prefetch=prefetch_params, query=models.FusionQuery(fusion=models.Fusion.RRF), limit=prefetch_limit, ) @@ -159,20 +168,7 @@ async def _build_search_params( # noqa: PLR0913 order_by=self._format_order_by(order_by) ) else: - search_params["prefetch"] = [ - models.Prefetch( - filter=search_filter, - query=sparse_query, - using=encoder_sparse.model_short_name(), - limit=prefetch_limit, - ), - models.Prefetch( - filter=search_filter, - query=dense_query, - using=encoder_dense.model_short_name(), - limit=prefetch_limit, - ), - ] + search_params["prefetch"] = prefetch_params search_params["query"] = models.FusionQuery(fusion=models.Fusion.RRF) else: dense_query = await sync_to_async(encoder_dense.embed_query)(query_string) @@ -214,7 +210,13 @@ async def _execute_group_search(self, client, search_params, params): return search_result async def _execute_scroll_search( # noqa: PLR0913 - self, client, search_collection, search_filter, limit, offset, order_by + self, + client, + search_collection, + search_filter, + limit, + offset, + order_by, ): # Build common scroll kwargs scroll_kwargs = { @@ -257,7 +259,7 @@ async def _execute_scroll_search( # noqa: PLR0913 break return search_result[:limit] - async def async_vector_search( # noqa: PLR0913 + async def _async_vector_hits( # noqa: PLR0913 self, query_string: str, params: dict, @@ -265,9 +267,13 @@ async def async_vector_search( # noqa: PLR0913 limit: int = 10, offset: int = 0, search_collection=RESOURCES_COLLECTION_NAME, + score_cutoff: float | None = None, *, hybrid_search: bool = False, ): + """ + Execute vector search and return hydrated hits + """ client = async_qdrant_client() encoder_dense = dense_encoder() encoder_sparse = sparse_encoder() @@ -300,6 +306,7 @@ async def async_vector_search( # noqa: PLR0913 encoder_dense, encoder_sparse, hybrid_search, + score_cutoff, ) if "group_by" in params: @@ -320,18 +327,111 @@ async def async_vector_search( # noqa: PLR0913 else: # No query string — use scroll API search_result = await self._execute_scroll_search( - client, search_collection, search_filter, limit, offset, order_by + client, + search_collection, + search_filter, + limit, + offset, + order_by, ) - hits_coroutine = ( - sync_to_async(_resource_vector_hits)(search_result) - if search_collection == RESOURCES_COLLECTION_NAME - else sync_to_async(_content_file_vector_hits)(search_result) + if search_collection == RESOURCES_COLLECTION_NAME: + return await sync_to_async(_resource_vector_hits)(search_result) + else: + return await sync_to_async(_content_file_vector_hits)(search_result) + + def _extract_values(self, obj, qdrant_field): + """ + Extract values from a nested dictionary based on a path like 'topics[].name' + """ + if not qdrant_field: + return [] + + parts = qdrant_field.split(".") + values = [obj] + + for part in parts: + is_array = part.endswith("[]") + key = part[:-2] if is_array else part + + next_values = [] + for v in values: + if not isinstance(v, dict): + continue + item = v.get(key) + if item is None: + continue + + if isinstance(item, list): + next_values.extend(item) + else: + next_values.append(item) + values = next_values + + return values + + async def _async_vector_resource_counts( + self, hits, params, search_collection=RESOURCES_COLLECTION_NAME + ): + """ + Compute total count and aggregations/facets based on + hits returned from the vector search. This is a fallback + for when the aggregation counts are inaccurate + due to Qdrant's approximate search. + """ + total_count = len(hits) + aggregation_keys = params.get("aggregations") or [] + aggregations = {} + + if not aggregation_keys or not hits: + return { + "total": {"value": total_count}, + "aggregations": aggregations, + } + + param_map = COLLECTION_PARAM_MAP.get( + search_collection, QDRANT_RESOURCE_PARAM_MAP ) + for agg_key in aggregation_keys: + qdrant_field = param_map.get(agg_key) + if not qdrant_field: + continue + + counter = Counter() + for hit in hits: + seen = set() + for val in self._extract_values(hit, qdrant_field): + if isinstance(val, (str, int, float, bool)): + key = str(val).lower() if isinstance(val, bool) else str(val) + if key not in seen: + seen.add(key) + counter[key] += 1 + + aggregations[agg_key] = [ + {"key": k, "doc_count": v} for k, v in counter.most_common() + ] + + return { + "total": {"value": total_count}, + "aggregations": aggregations, + } + + async def _async_vector_counts( + self, + params: dict, + search_collection=RESOURCES_COLLECTION_NAME, + ): + """ + Compute total count and aggregations/facets + """ + client = async_qdrant_client() + search_filter = qdrant_query_conditions( + params, collection_name=search_collection + ) aggregation_keys = params.get("aggregations") or [] - hits, count_result, aggregations = await asyncio.gather( - hits_coroutine, + + count_result, aggregations = await asyncio.gather( client.count( collection_name=search_collection, count_filter=search_filter, @@ -345,11 +445,68 @@ async def async_vector_search( # noqa: PLR0913 ) return { - "hits": hits, "total": {"value": count_result.count}, "aggregations": aggregations or {}, } + async def async_vector_search( # noqa: PLR0913 + self, + query_string: str, + params: dict, + order_by: str | None = None, + limit: int = 10, + offset: int = 0, + search_collection=RESOURCES_COLLECTION_NAME, + score_cutoff: float | None = None, + *, + hybrid_search: bool = False, + ): + if ( + query_string + and type(score_cutoff) is float + and score_cutoff >= VECTOR_SEARCH_MIN_SCORE + ): + hits = await self._async_vector_hits( + query_string, + params, + order_by=order_by, + limit=settings.VECTOR_SEARCH_PAGE_MAX_LIMIT, + offset=0, + search_collection=search_collection, + score_cutoff=score_cutoff, + hybrid_search=hybrid_search, + ) + counts = await self._async_vector_resource_counts( + hits, params, search_collection=search_collection + ) + + return { + "hits": hits, + **counts, + } + + hits, counts = await asyncio.gather( + self._async_vector_hits( + query_string, + params, + order_by=order_by, + limit=limit, + offset=offset, + search_collection=search_collection, + score_cutoff=score_cutoff, + hybrid_search=hybrid_search, + ), + self._async_vector_counts( + params, + search_collection=search_collection, + ), + ) + + return { + "hits": hits, + **counts, + } + def handle_exception(self, exc): if isinstance(exc, UnexpectedResponse) and ( isinstance(exc.status_code, int) and 400 <= exc.status_code < 500 # noqa: PLR2004 @@ -398,6 +555,7 @@ async def get(self, request): offset=offset, params=request_data.data, hybrid_search=hybrid_search, + score_cutoff=request_data.data.get("score_cutoff", 0), ) if request_data.data.get("dev_mode"): return Response(response) diff --git a/vector_search/views_test.py b/vector_search/views_test.py index 9bf9b121d9..03b50c048a 100644 --- a/vector_search/views_test.py +++ b/vector_search/views_test.py @@ -1,3 +1,5 @@ +import asyncio + import pytest from django.contrib.auth.models import Group from django.urls import reverse @@ -405,10 +407,17 @@ def test_vector_search_sortby_parameter(mocker, client, query_string, hybrid_sea "q": query_string, "sortby": "-views", "hybrid_search": hybrid_search, + "score_cutoff": 0, } - - client.get( - reverse("vector_search:v0:vector_learning_resources_search"), data=params + view = QdrantView() + asyncio.run( + view.async_vector_search( + query_string, + params, + order_by="-views", + score_cutoff=0, + hybrid_search=hybrid_search, + ) ) if query_string: @@ -452,7 +461,6 @@ def test_vector_search_sortby_pagination(mocker, client): ) params = { - "q": "test", "sortby": "-created_on", "limit": 20, "offset": 60, @@ -462,7 +470,7 @@ def test_vector_search_sortby_pagination(mocker, client): reverse("vector_search:v0:vector_learning_resources_search"), data=params ) - call_kwargs = mock_qdrant.query_points.mock_calls[0].kwargs + call_kwargs = mock_qdrant.scroll.mock_calls[0].kwargs # Should request offset+limit results, not just limit assert call_kwargs["limit"] == 80 # 60 + 20 @@ -471,6 +479,42 @@ def test_vector_search_sortby_pagination(mocker, client): assert "offset" not in call_kwargs +def test_vector_search_with_score_cutoff_enforces_max_limit(mocker, client, settings): + """A query with a score cutoff should enforce VECTOR_SEARCH_PAGE_MAX_LIMIT.""" + + mock_qdrant = mocker.patch( + "qdrant_client.AsyncQdrantClient", return_value=mocker.AsyncMock() + )() + + settings.VECTOR_SEARCH_PAGE_MAX_LIMIT = 5 + + mock_result = mocker.MagicMock() + mock_result.points = [] + mock_qdrant.query_points = mocker.AsyncMock(return_value=mock_result) + mock_qdrant.scroll = mocker.AsyncMock(return_value=([], None)) + # count is no longer called in this branch + mocker.patch( + "vector_search.views.async_qdrant_client", + return_value=mock_qdrant, + ) + + params = { + "q": "test", + "limit": 10, + "offset": 20, + "score_cutoff": 0.5, + } + + client.get( + reverse("vector_search:v0:vector_learning_resources_search"), data=params + ) + + call_kwargs = mock_qdrant.query_points.mock_calls[0].kwargs + assert call_kwargs["limit"] == 5 + assert call_kwargs["offset"] == 0 + assert call_kwargs["score_threshold"] == 0.5 + + def test_vector_search_sortby_scroll_pagination(mocker, client): """Test that sortby with offset on scroll (no query) uses client-side slicing. @@ -519,3 +563,96 @@ def test_vector_search_sortby_scroll_pagination(mocker, client): # query_points should not be called (no query string) assert mock_qdrant.query_points.call_count == 0 + + +def test_async_vector_resource_counts_aggregation_buckets(): + """ + _async_vector_resource_counts should compute correct aggregation + buckets from hydrated hits that contain multi-valued fields + (topics, delivery) and scalar fields (free). + """ + view = QdrantView() + + # Simulate hydrated hits (dicts keyed by payload field paths) + hits = [ + { + "readable_id": "course-1", + "topics": [{"name": "Mathematics"}, {"name": "Physics"}], + "delivery": [{"code": "online"}, {"code": "in_person"}], + "free": True, + }, + { + "readable_id": "course-2", + "topics": [{"name": "Mathematics"}, {"name": "Social Science"}], + "delivery": [{"code": "online"}], + "free": False, + }, + { + "readable_id": "course-3", + "topics": [{"name": "Physics"}], + "delivery": [{"code": "in_person"}], + "free": True, + }, + ] + + params = {"aggregations": ["topic", "delivery", "free"]} + + result = asyncio.run( + view._async_vector_resource_counts(hits, params) # noqa: SLF001 + ) + + assert result["total"]["value"] == 3 + + # Build lookup dicts for easier assertions + topic_buckets = {b["key"]: b["doc_count"] for b in result["aggregations"]["topic"]} + delivery_buckets = { + b["key"]: b["doc_count"] for b in result["aggregations"]["delivery"] + } + free_buckets = {b["key"]: b["doc_count"] for b in result["aggregations"]["free"]} + + # topic: Mathematics appears in 2 hits, Physics in 2, Social Science in 1 + assert topic_buckets == { + "Mathematics": 2, + "Physics": 2, + "Social Science": 1, + } + + # delivery: online in 2 hits, in_person in 2 + assert delivery_buckets == {"online": 2, "in_person": 2} + + # free: True (→ "true") in 2 hits, False (→ "false") in 1 + assert free_buckets == {"true": 2, "false": 1} + + +def test_vector_search_no_score_cutoff_omits_score_threshold( + mocker, client, django_user_model +): + """Test that if score_cutoff is not explicitly passed, score_threshold is omitted from qdrant query""" + mock_qdrant = mocker.patch( + "qdrant_client.AsyncQdrantClient", return_value=mocker.AsyncMock() + )() + + mock_result = mocker.MagicMock() + mock_result.points = [] + mock_qdrant.query_points = mocker.AsyncMock(return_value=mock_result) + mock_qdrant.scroll = mocker.AsyncMock(return_value=([], None)) + mock_qdrant.count = mocker.AsyncMock(return_value=CountResult(count=42)) + mocker.patch( + "vector_search.views.async_qdrant_client", + return_value=mock_qdrant, + ) + user = django_user_model.objects.create() + group, _ = Group.objects.get_or_create(name=GROUP_CONTENT_FILE_CONTENT_VIEWERS) + group.user_set.add(user) + client.force_login(user) + + params = { + "q": "test", + "limit": 10, + "offset": 20, + } + + client.get(reverse("vector_search:v0:vector_content_files_search"), data=params) + + call_kwargs = mock_qdrant.query_points.mock_calls[0].kwargs + assert "score_threshold" not in call_kwargs From ea674f4e2f8d00f1141c197cba821ba0fd3adb95 Mon Sep 17 00:00:00 2001 From: zawan-ila <87228907+zawan-ila@users.noreply.github.com> Date: Wed, 13 May 2026 12:03:36 +0500 Subject: [PATCH 5/6] Adds a feature flag to link ocw course urls to corresponding learn urls (/courses/o/*) (#3263) --- frontends/main/src/common/feature_flags.ts | 1 + frontends/main/src/common/urls.ts | 4 + .../CallToActionSection.test.tsx | 105 ++++++++++++++++++ .../CallToActionSection.tsx | 14 +++ .../InfoSection.test.tsx | 53 +++++++++ .../LearningResourceExpanded/InfoSection.tsx | 22 +++- 6 files changed, 195 insertions(+), 4 deletions(-) diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts index 978bcf6d12..29661aa2d3 100644 --- a/frontends/main/src/common/feature_flags.ts +++ b/frontends/main/src/common/feature_flags.ts @@ -13,6 +13,7 @@ export enum FeatureFlags { VideoShorts = "video-shorts", MitxOnlineProductPages = "mitxonline-product-pages", CourseOutlineSection = "course-outline-section", + OcwProductPages = "ocw-product-pages", VideoPlaylistPage = "video-playlist-page", PodcastDetailPage = "podcast-detail-page", } diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index d2a334a86f..f697fd5927 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -271,3 +271,7 @@ export const programPageView = (program: { : PROGRAM_PAGE_VIEW return generatePath(pattern, { readableId: program.readable_id }) } +export const ocwLearnPageView = (ocwUrl: string) => { + const url = new URL(ocwUrl) + return url.pathname.replace(/^\/courses/, "/courses/o") +} diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx index 46dca3a62a..56fb63fabc 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx @@ -372,6 +372,111 @@ describe("CallToActionSection", () => { }) }) + describe("OCW product pages", () => { + const ocwSlug = + "16-01-unified-engineering-i-ii-iii-iv-fall-2005-spring-2006" + const ocwUrl = `https://ocw.mit.edu/courses/${ocwSlug}/` + + const ocwResource: typeof factories.learningResources.resource = ( + overrides, + ) => { + return factories.learningResources.resource({ + platform: { code: PlatformEnum.Ocw }, + url: ocwUrl, + ...overrides, + }) + } + + it("links to internal OCW page when flag is ON for OCW course", () => { + mockUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.OcwProductPages, + ) + const resource = ocwResource({ resource_type: ResourceTypeEnum.Course }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { name: "Access Course Materials" }) + expect(link).toHaveAttribute("href", `/courses/o/${ocwSlug}`) + expect(link.getAttribute("href")).not.toContain("utm_") + }) + + it("uses external URL with UTM params when flag is OFF for OCW course", () => { + mockUseFeatureFlagEnabled.mockReturnValue(false) + const resource = ocwResource({ resource_type: ResourceTypeEnum.Course }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { name: "Access Course Materials" }) + const href = link.getAttribute("href") + expect(href).toContain("ocw.mit.edu") + expect(href).toContain("utm_source=mit-learn") + expect(href).toContain("utm_medium=referral") + }) + + it("does not transform non-OCW course URL when only OcwProductPages flag is ON", () => { + mockUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.OcwProductPages, + ) + const resource = factories.learningResources.resource({ + platform: { code: PlatformEnum.Mitxonline }, + resource_type: ResourceTypeEnum.Course, + url: "https://courses.mitxonline.mit.edu/learn/course/some-course/", + }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { name: "Learn More" }) + const href = link.getAttribute("href") + expect(href).toContain("mitxonline.mit.edu") + expect(href).not.toContain("/courses/o/") + }) + + it("transforms OCW non-course URL when flag is ON", () => { + mockUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.OcwProductPages, + ) + const resource = ocwResource({ + resource_type: ResourceTypeEnum.Video, + resource_category: "Lecture Video", + url: `${ocwUrl}resources/abc-def`, + }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { + name: "Watch Video", + }) + const href = link.getAttribute("href") + expect(href).toContain(`/courses/o/${ocwSlug}/resources/abc-def`) + expect(href).not.toContain("ocw.mit.edu") + expect(href).not.toContain("utm_") + }) + }) + describe("PostHog integration", () => { it("calls posthog.capture when CTA link is clicked", () => { const originalPostHogKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx index 1cb3aff9f8..6e5e2fab09 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx @@ -43,6 +43,7 @@ import { videoPlaylistPageView, podcastPageView, podcastEpisodePageView, + ocwLearnPageView, } from "@/common/urls" import { DisplayModeEnum } from "@mitodl/mitxonline-api-axios/v2" import { FeatureFlags } from "@/common/feature_flags" @@ -313,6 +314,7 @@ const appendUtmParams = (url?: string | null, resourceTitle?: string) => { const getResourceUrl = ( resource: LearningResource, { + ocwProductPages, mitxonlineProductPages, showVideoPlaylistPage, showPodcastPage, @@ -320,6 +322,7 @@ const getResourceUrl = ( mitxonlineProductPages?: boolean showVideoPlaylistPage?: boolean showPodcastPage?: boolean + ocwProductPages?: boolean }, ) => { if ( @@ -371,6 +374,15 @@ const getResourceUrl = ( ) } } + + if ( + ocwProductPages && + resource.platform?.code === PlatformEnum.Ocw && + resource.url + ) { + return ocwLearnPageView(resource.url) + } + return resource.url } @@ -398,6 +410,7 @@ const CallToActionSection = ({ const posthog = usePostHog() const [shareExpanded, setShareExpanded] = useState(false) const [copyText, setCopyText] = useState("Copy Link") + const ocwProductPages = useFeatureFlagEnabled(FeatureFlags.OcwProductPages) const mitxonlineProductPages = useFeatureFlagEnabled( FeatureFlags.MitxOnlineProductPages, ) @@ -435,6 +448,7 @@ const CallToActionSection = ({ mitxonlineProductPages, showVideoPlaylistPage, showPodcastPage, + ocwProductPages, }), resource.title, ) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx index d00bcada0c..5804a7a45b 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx @@ -14,6 +14,13 @@ import { } from "api" import { factories } from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { ocwLearnPageView } from "@/common/urls" + +jest.mock("posthog-js/react") + +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) // This is a pipe followed by a zero-width space const SEPARATOR = "|​" @@ -36,6 +43,10 @@ const formatTestDate = (isoDate: string): string => { return date.toLocaleDateString("en-US", options) } +beforeEach(() => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) +}) + describe("Learning resource info section pricing", () => { test("Free course, no certificate", () => { renderWithTheme() @@ -451,6 +462,48 @@ describe("Learning resource info section parent course", () => { const section = screen.getByTestId("drawer-info-items") expect(within(section).queryByText("Parent Course:")).toBeNull() }) + + test.each([ + { + caseName: + "uses original parent course URL when OCW product pages flag is disabled", + ocwProductPagesEnabled: false, + expectedHref: (url: string) => url, + }, + { + caseName: + "uses OCW Learn parent course URL when OCW product pages flag is enabled", + ocwProductPagesEnabled: true, + expectedHref: (url: string) => ocwLearnPageView(url), + }, + ])("$caseName", ({ ocwProductPagesEnabled, expectedHref }) => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => ocwProductPagesEnabled && flag === FeatureFlags.OcwProductPages, + ) + + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Document, + platform: { + code: PlatformEnum.Ocw, + name: "OCW", + }, + url: "https://ocw.mit.edu/courses/test-course", + content_files: [ + factories.learningResources.contentFile({ + run_title: "Test Course Title", + course_number: ["TEST-101"], + }), + ], + }) + + renderWithTheme() + + const link = screen.getByRole("link", { + name: "TEST-101: Test Course Title", + }) + invariant(resource.url) + expect(link).toHaveAttribute("href", expectedHref(resource.url)) + }) }) describe("Offered by section", () => { diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx index 0413bab68f..304462c950 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react" import styled from "@emotion/styled" import ISO6391 from "iso-639-1" +import { useFeatureFlagEnabled } from "posthog-js/react" import { RemixiconComponentType, RiVerifiedBadgeLine, @@ -36,6 +37,8 @@ import { formattedParentCourseName, } from "ol-utilities" import { theme, Link } from "ol-components" +import { ocwLearnPageView } from "@/common/urls" +import { FeatureFlags } from "@/common/feature_flags" import DifferingRunsTable from "./DifferingRunsTable" const SeparatorContainer = styled.span({ @@ -145,7 +148,10 @@ const Certificate = styled.div({ }, }) -type InfoSelector = (resource: LearningResource) => React.ReactNode +type InfoSelector = ( + resource: LearningResource, + ocwProductPages?: boolean, +) => React.ReactNode type InfoItemConfig = { label: string | ((resource: LearningResource) => string) @@ -528,11 +534,17 @@ const INFO_ITEMS: InfoItemConfig = [ { label: "Parent Course:", Icon: RiBookLine, - selector: (resource: LearningResource) => { + selector: (resource: LearningResource, ocwProductPages?: boolean) => { const name = formattedParentCourseName(resource) if (!name || !resource.url) return name + + const href = + ocwProductPages && resource.platform?.code === PlatformEnum.Ocw + ? ocwLearnPageView(resource.url) + : resource.url + return ( - + {name} ) @@ -579,6 +591,8 @@ const InfoItem = ({ label, Icon, value }: InfoItemProps) => { } const InfoSection = ({ resource }: { resource?: LearningResource }) => { + const ocwProductPages = useFeatureFlagEnabled(FeatureFlags.OcwProductPages) + if (!resource) { return null } @@ -586,7 +600,7 @@ const InfoSection = ({ resource }: { resource?: LearningResource }) => { const infoItems = INFO_ITEMS.map(({ label, Icon, selector }) => ({ label: typeof label === "function" ? label(resource) : label, Icon, - value: selector(resource), + value: selector(resource, ocwProductPages), })).filter(({ value }) => value !== null && value !== "") if (infoItems.length === 0) { From 4efaf20b922ed14c4c4c776eb00435ac5df3ee3a Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 13 May 2026 09:12:59 +0000 Subject: [PATCH 6/6] Release 0.67.3 --- RELEASE.rst | 9 +++++++++ main/settings.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 4f3beb35c0..60546d9a25 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 0.67.3 +-------------- + +- Adds a feature flag to link ocw course urls to corresponding learn urls (/courses/o/*) (#3263) +- Min score for vector learning resources endpoint (#3285) +- Update dependency granian to v2.7.4 [SECURITY] (#3309) +- Update dependency litellm to v1.83.10 [SECURITY] (#3331) +- Update dependency urllib3 to v2.7.0 [SECURITY] (#3329) + Version 0.67.2 (Released May 12, 2026) -------------- diff --git a/main/settings.py b/main/settings.py index 90e44e0657..607a751eb8 100644 --- a/main/settings.py +++ b/main/settings.py @@ -35,7 +35,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.67.2" +VERSION = "0.67.3" log = logging.getLogger()