From 76f02dd4e251e62412642e20a76f34aa03e934be Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 27 Jul 2026 18:50:31 +0530 Subject: [PATCH 1/4] deps: require hotdata-framework>=0.9.0 and hotdata>=0.8.0 The lock already carried 0.9.0/0.8.0 after #35, but the floors stayed at >=0.3.0/>=0.4.1, so a resolve that ignores the lock could still select framework 0.4.1. That version uploads through `POST /v1/files`, a route the API no longer serves, so `load_managed_table` fails with a bare `Not Found`. 0.9.0 uses the upload session/finalize flow. Also adds a `demo` dependency group (langchain, langchain-openai) for the end-to-end script; the package itself still depends only on langchain-core. --- pyproject.toml | 9 +- uv.lock | 556 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 555 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a91a533..816d814 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } dependencies = [ - "hotdata-framework>=0.3.0", - "hotdata>=0.4.1", + "hotdata-framework>=0.9.0", + "hotdata>=0.8.0", "langchain-core>=1.0", ] @@ -21,6 +21,10 @@ dev = [ "ruff>=0.5", "mypy>=1.5", ] +demo = [ + "langchain>=1.0", + "langchain-openai>=1.0", +] [tool.uv] default-groups = ["dev"] @@ -41,6 +45,7 @@ select = ["E", "W", "F", "I", "B", "UP", "N", "C4", "DTZ", "T20", "RET", "SIM", [tool.ruff.lint.per-file-ignores] "examples/**/*.py" = ["T201"] "scripts/**/*.py" = ["T201", "E501"] +"demo/**/*.py" = ["T201"] [tool.mypy] python_version = "3.10" diff --git a/uv.lock b/uv.lock index 31b994b..e4de636 100644 --- a/uv.lock +++ b/uv.lock @@ -200,6 +200,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -262,6 +271,10 @@ dependencies = [ ] [package.dev-dependencies] +demo = [ + { name = "langchain" }, + { name = "langchain-openai" }, +] dev = [ { name = "mypy" }, { name = "pytest" }, @@ -270,12 +283,16 @@ dev = [ [package.metadata] requires-dist = [ - { name = "hotdata", specifier = ">=0.4.1" }, - { name = "hotdata-framework", specifier = ">=0.3.0" }, + { name = "hotdata", specifier = ">=0.8.0" }, + { name = "hotdata-framework", specifier = ">=0.9.0" }, { name = "langchain-core", specifier = ">=1.0" }, ] [package.metadata.requires-dev] +demo = [ + { name = "langchain", specifier = ">=1.0" }, + { name = "langchain-openai", specifier = ">=1.0" }, +] dev = [ { name = "mypy", specifier = ">=1.5" }, { name = "pytest", specifier = ">=8.0" }, @@ -328,6 +345,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/d8/b959609e44012a42b1f3e5ba98ea3b33c7e41e6d4b77cd8f00fd19b1d3ad/jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c", size = 310082, upload-time = "2026-06-29T13:02:31.356Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3d/4d7f5667ea0e0548534ba880b84bb3d12924fd133aa83ad6c6c80fca3d76/jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b", size = 315643, upload-time = "2026-06-29T13:02:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/9b/83/bed2dcb5c9f3e1ccfcbc67dda48265fe7d5ad0c9cadda5fe95f6e3b87f94/jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84", size = 341363, upload-time = "2026-06-29T13:02:34.853Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/6bb3c3dda668ebc0445689c81a2b0f26a82b10843d67ed9c9b2c3edc177f/jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c", size = 365483, upload-time = "2026-06-29T13:02:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/8a045ccb39164e70dcdae696413b661771f148b68b12b175c3a04d901937/jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126", size = 461219, upload-time = "2026-06-29T13:02:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/e7/99/22292dbbf0ed0c610cfe5ddc7f3bd67237a412f121318f865196e62a07bd/jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c", size = 374905, upload-time = "2026-06-29T13:02:40.357Z" }, + { url = "https://files.pythonhosted.org/packages/29/ac/2f55ccb1f0eeafa6d89d24caf52f6f0944a59290ee199e9ade62177dca42/jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de", size = 348320, upload-time = "2026-06-29T13:02:41.923Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/7d88b9174c40064fabc07c84a9b62e6b10f5644562ec0e0a29392edbe978/jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244", size = 356519, upload-time = "2026-06-29T13:02:43.436Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/c4a33aeef513a9d5e26e31534e0bcc752d6ea0e54c94ddb7b68bade669c2/jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f", size = 394204, upload-time = "2026-06-29T13:02:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c6c23e76ebb3766b111bc399437bbc9f870a76e2a92e10b2a5f561d57372/jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131", size = 521477, upload-time = "2026-06-29T13:02:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/0001c8c0c5976af2625bb1cfb1895e8ec693b6589fe4574b8e6fc2c85501/jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b", size = 552187, upload-time = "2026-06-29T13:02:48.144Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/311b718e07e85740e48619c0632b36f7e0b8d113984499e436452ed13a9a/jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9", size = 206513, upload-time = "2026-06-29T13:02:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/db/7f/ac680eeb0777dc0eb7dc824800ba27880d7f6bc712e362d34ad8ee559f36/jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26", size = 199505, upload-time = "2026-06-29T13:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -349,9 +465,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "langchain" +version = "1.3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/68/a6dbad9c22df4087a0f9e79ddd46226c442b30128bfeee538d5889492a73/langchain-1.3.14.tar.gz", hash = "sha256:1b6696c72ba3bbbce54d745e0180742c9f6ece8bbc59ed5a46c3e20b9a435929", size = 645181, upload-time = "2026-07-16T13:28:18.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ec/0f942e78a621f8e3162ff1ed24284f469aaf51fb4607ee5831c626f2b2bc/langchain-1.3.14-py3-none-any.whl", hash = "sha256:4d10dbe91005952cddd56d0dc77aa108964da6bae90ab20063653957e901f782", size = 139560, upload-time = "2026-07-16T13:28:16.498Z" }, +] + [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -364,21 +494,94 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/fc/84e23e8adff5adcd7792273ad610c4972ec534658a52698fb8db6defa87a/langchain_core-1.5.1.tar.gz", hash = "sha256:b0df382704c6403c1e0c9603415bce09290455d4aeeb38f350d15f78ca597483", size = 972219, upload-time = "2026-07-23T20:13:22.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/1e/1eb8833dc59b9c1f49da89a4a1ad7510440c9eb57fb539ea2203294a1acd/langchain_core-1.5.1-py3-none-any.whl", hash = "sha256:c5ec8f51dd05124f950c9afd0fd8bb3f7be4e405eeb868d481b2f8ff652cb9d2", size = 561634, upload-time = "2026-07-23T20:13:20.717Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/1b/a83bf6cae4632363cef0b6f2ee1b4f62c8a5ebcf22cd8ef24430a736c2a8/langchain_openai-1.4.1.tar.gz", hash = "sha256:6d16be615d997db80294731b8e768783f1fb8e0313668e64acd50cd68acbad20", size = 3262416, upload-time = "2026-07-23T20:31:13.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/8b604dc8be2735c8ae5c655e520066231057d1301958c20c776e62bd00fb/langchain_openai-1.4.1-py3-none-any.whl", hash = "sha256:8528bb34cc78fdfd2d895573c7917f9441cbb82db5f18ae0e6b3b75d95bdefb3", size = 122067, upload-time = "2026-07-23T20:31:11.809Z" }, ] [[package]] name = "langchain-protocol" -version = "0.0.15" +version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] @@ -709,6 +912,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] +[[package]] +name = "openai" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/d4d1835488c0350424009dac5095b9a3e173bee12fd2e421ee27e2142c42/openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16", size = 1093427, upload-time = "2026-07-23T20:15:50.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c", size = 1648520, upload-time = "2026-07-23T20:15:48.052Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -790,6 +1012,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1252,6 +1530,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/13/bbf7d9d1887fe4a3693527c6caa232c197ea9da91f1212e9672eff60329d/regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b", size = 494009, upload-time = "2026-07-19T00:16:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/a3/19/783688e75a2bec15d50aec0d5e7e317d363808bc82a6eb6750b897bfcd7b/regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52", size = 295287, upload-time = "2026-07-19T00:16:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/cefe4f051302ca298d3f3e79ed6dbd933ac84485b9515acdd6a52d70cef7/regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665", size = 290633, upload-time = "2026-07-19T00:16:17.182Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1e/1045ca2cabb12e8ec41ad0d138e9f3ff1eb079d30a6f51ccf7d709b44aad/regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0", size = 785300, upload-time = "2026-07-19T00:16:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/20e5bca184e90bf1bd187efdb53363f4a7b7b34f01d54ced5740caf104bd/regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951", size = 854079, upload-time = "2026-07-19T00:16:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/09/9b/5a2e59678be3b24aa6a42b2c6d66a48daa212593e9f4096fe7ba577fa9b1/regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3", size = 899496, upload-time = "2026-07-19T00:16:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/48/9a/7317f14ed8ed9fd998d1978b4802b07bc4d79216353c435dbcc1ddd1301f/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c", size = 793541, upload-time = "2026-07-19T00:16:22.991Z" }, + { url = "https://files.pythonhosted.org/packages/6f/53/833c2db3e274d3c191f4c42fe5bfa358e4c8b617d5d7312d31334965fc46/regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6", size = 785515, upload-time = "2026-07-19T00:16:24.654Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b9/efb2f9fa151d71db09d4015e1fb92fee47416f01c12164836bbd23e2f3c2/regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175", size = 769556, upload-time = "2026-07-19T00:16:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/45610c263f8eadb84e4a1fabd904d81d5176226faa9104ef498bf8a8b285/regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6", size = 774130, upload-time = "2026-07-19T00:16:27.786Z" }, + { url = "https://files.pythonhosted.org/packages/40/95/1b40d87c7a9e5480bec7a87bce9fd67fc3f14b5f106c8ee66d660249072f/regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095", size = 848694, upload-time = "2026-07-19T00:16:29.412Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/bb45968addd5b394ef9cd9184bd9c65ade1a819dbb2b92b71ad52a0c7907/regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0", size = 758505, upload-time = "2026-07-19T00:16:31.006Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/33386c672fbf43e21602135a0f29a97ee251a483f007fe51d10e9b2dbc93/regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a", size = 836985, upload-time = "2026-07-19T00:16:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/22/f1/9112b86e9bb075619862e8e42b604794389f1958faa68fb69bde505dd90e/regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902", size = 782610, upload-time = "2026-07-19T00:16:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f9/13d460d8a385ca0b0be9e6be80a90968b9293b3e30895543ad2d1d1653e4/regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e", size = 266772, upload-time = "2026-07-19T00:16:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/9f/90/29addd7a03e1aea402c1f31467e25c80caabc8c3735b88a23cf73b0aa9c2/regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db", size = 277967, upload-time = "2026-07-19T00:16:36.847Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ba/ecfce06fe66c122bc6f77ae284887a9282e4411fe1e6268c5266611ca054/regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6", size = 276963, upload-time = "2026-07-19T00:16:38.315Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1313,6 +1712,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -1322,6 +1730,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/e3/03c90dadcf5b3f82b83cee9adee60ef666b329c654f58c066af44eae0287/tiktoken-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:47b1df8d73390a24f94980c75158cdd5c56d256f16d55f30cb49c230caba9ba4", size = 1036627, upload-time = "2026-05-15T04:50:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/760463e5b2e8ad2bc229ae0a17ecb06727b6cbc094f08d8f65844315632e/tiktoken-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7d40c6c5aab171dcd6eb8455bc567bde404bb9def60cdb8c1299cc782b242bb9", size = 984699, upload-time = "2026-05-15T04:50:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/de/8a/8895f342a6b6aabd1a358e672f6f077b3ae51d0c63ca605d142db3bcd8ab/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b842981fa91accdffd48ff6408a977b7a91c3fbda55d353c3c68114d5c9d69e", size = 1118690, upload-time = "2026-05-15T04:50:14.234Z" }, + { url = "https://files.pythonhosted.org/packages/51/e0/92557768fb0801f0d9dd9243cb9b6d342900b05e4b1006d4771f49ce233e/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed5a30027cb4d8c7ca8b273d4766f3db3cf58fad9e9f3b1a68a351ffb54873d5", size = 1138423, upload-time = "2026-05-15T04:50:15.668Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b9/a3d99feeedb032ffd09cd6652077f86bdee9a70dd0b990b2b272b445d4c3/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7ab10f4a21c2999846940113f6dbd72e0fa06a24119feddd74cc47e85818e06d", size = 1185077, upload-time = "2026-05-15T04:50:17.19Z" }, + { url = "https://files.pythonhosted.org/packages/cc/93/bab868277d475dc6d2aaacd34cdd239c282f4908dcc8702e0a3311a8e032/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a2937ad042d49d50eac6e1ba07c5661d4bd3942a5b1e0c0d08475c4df83676e1", size = 1241702, upload-time = "2026-05-15T04:50:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/27e9f7e0ed76e501cfefc9fb2112df4c7bf70ca96945b15ecb7615aac860/tiktoken-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:44733b99bfd72b590cd0936b1c01b3b4dd73122db2d544bc1ceeb18a7678c910", size = 876565, upload-time = "2026-05-15T04:50:20.268Z" }, + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1376,6 +1845,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tqdm" +version = "4.69.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/84/da0e5038228fa34dfd77c5026b173ed035d2a3ba31f1077590c013de2bff/tqdm-4.69.1.tar.gz", hash = "sha256:2be21080a0ce17e902c2f1baeb6a74bf551b67bbdfa4bc0109fad471d0b4cb0d", size = 793046, upload-time = "2026-07-24T14:22:02.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452, upload-time = "2026-07-24T14:22:00.048Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1528,6 +2009,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/e7/3937b9a9d6745b94dbe7b86531e098db8c53b77c8d07df7daa9577a47b8e/uuid_utils-0.16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:680799a9ade01d69c53cb9d41392ced24919d4f600bfab5060b61fca37510097", size = 178508, upload-time = "2026-05-19T07:43:43.774Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "xxhash" version = "3.7.0" From 10bbfd6c1ae423a67517d68ffa2f2f02968b258b Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 27 Jul 2026 18:55:18 +0530 Subject: [PATCH 2/4] feat: full-text search and schema discovery tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the engine's existing BM25 index to LangChain, adds a schema introspection tool, and rewrites the tool descriptions so the model can plan against the engine's real contract. `hotdata_search_text` (hotdata_langchain/search.py) wraps `bm25_search(...)`. The corpus is pinned when the tool is built rather than chosen by the model: nothing in the tool surface lets an agent discover which columns carry a BM25 index, and the engine errors outright instead of falling back to a scan when one is missing. Register the factory per corpus to expose several. Three engine behaviours are encoded in the SQL it builds — results come back in rowid order so ranking is requested explicitly; `k` is passed as the `bm25_search` fourth argument because `ORDER BY` blocks limit pushdown and the scan would otherwise fall back to a much larger default bound; and the projection defaults to the searched column so a wide table cannot flood the context. `hotdata_describe_tables` (hotdata_langchain/schema.py) reads `information_schema`, listing tables with their column counts, or one table's columns and types. Registered by default. Without it an agent guesses column names from the shape of data it has already seen. The tool descriptions now state the dialect, the supported constructs, where to look schema up, and the two constraints that silently produce wrong calls: SQL cannot rank text, and an aggregate query must reference a column (`COUNT(*)` alone is rejected). Every claim was checked against a live workspace and is pinned by tests/test_descriptions.py. This is not cosmetic — with a one-line description an agent wrote Postgres full-text SQL, which the engine rejects, and the run aborted; with the contract stated it takes the search-then-SQL path with no system-prompt guidance at all. The database tools also steer callers towards ids, since names are non-unique display labels. The search description deliberately names no mechanism, and a test enforces that it never says bm25/vector/hnsw, so hybrid retrieval can land underneath without changing the contract the model was given. Also fixes two things that were already broken on main: the README quickstart used `create_tool_calling_agent`/`AgentExecutor`, neither of which exists in LangChain v1, and both the quickstart and examples/langchain_basic.py ran SQL with no database scope, which the API now rejects. demo/ runs the whole path against a real workspace — managed database, data load, index build, direct search, then an agent choosing between search and SQL. docs/ carries the verified engine contract and the roadmap, checked in while the repo is small so the reasoning stays with the code. --- CHANGELOG.md | 40 ++++ README.md | 98 ++++++++- demo/README.md | 124 ++++++++++++ demo/bm25_search_demo.py | 343 ++++++++++++++++++++++++++++++++ docs/README.md | 14 ++ docs/ai-native-layer-roadmap.md | 136 +++++++++++++ docs/engine-contract.md | 123 ++++++++++++ docs/vectorstore-plan.md | 268 +++++++++++++++++++++++++ examples/langchain_basic.py | 18 +- hotdata_langchain/__init__.py | 22 ++ hotdata_langchain/schema.py | 128 ++++++++++++ hotdata_langchain/search.py | 195 ++++++++++++++++++ hotdata_langchain/tools.py | 125 +++++++++++- tests/conftest.py | 14 ++ tests/test_descriptions.py | 81 ++++++++ tests/test_schema.py | 183 +++++++++++++++++ tests/test_search.py | 341 +++++++++++++++++++++++++++++++ tests/test_tools.py | 1 + 18 files changed, 2238 insertions(+), 16 deletions(-) create mode 100644 demo/README.md create mode 100644 demo/bm25_search_demo.py create mode 100644 docs/README.md create mode 100644 docs/ai-native-layer-roadmap.md create mode 100644 docs/engine-contract.md create mode 100644 docs/vectorstore-plan.md create mode 100644 hotdata_langchain/schema.py create mode 100644 hotdata_langchain/search.py create mode 100644 tests/test_descriptions.py create mode 100644 tests/test_schema.py create mode 100644 tests/test_search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8041402..c814e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Full-text search tool backed by the engine's BM25 index. `make_hotdata_tools` grows + `search_table`/`search_column`/`search_columns`/`search_k`/`search_tool_name` and appends a + `hotdata_search_text` tool when a table and column are given; `make_hotdata_search_tool` + builds one directly, so several searchable corpora can be registered side by side. The + corpus is pinned at construction rather than chosen by the model, because nothing in the + tool surface lets an agent discover which columns carry a BM25 index. +- `bm25_search_sql` and `bm25_search_json` for building and running a ranked search without + going through a tool. `bm25_search_json` returns the same `{"metadata", "rows"}` envelope as + `execute_sql_json`. +- `hotdata_describe_tables` tool (registered by default, `describe_tables=False` to omit) and + `describe_tables_json`/`make_hotdata_describe_tables_tool`. With no argument it lists the + scoped database's tables and their column counts; with a table name it returns that table's + columns and types, capped so one wide table cannot flood the model's context. Reads + `information_schema`, so it needs no extra permissions. Without it an agent guesses column + names off the shape of the data it has already seen. +- `demo/` — an end-to-end script that creates a managed database, loads the public SF Airbnb + fixture, builds a BM25 index, invokes the search tool, and then hands both the search and + SQL tools to a LangChain agent. + +### Changed +- Tool descriptions now state the engine's actual contract instead of a one-line summary. + `hotdata_execute_sql` names the dialect and the supported constructs, points at the search + tool for text relevance when one is registered, and warns that an aggregate query must + reference a column (`COUNT(*)` alone is rejected). The database tools steer callers towards + ids, since names are non-unique display labels. Every claim was verified against a live + engine and is pinned by `tests/test_descriptions.py`. Without this an agent reaches for + `to_tsvector` and the query fails; with it, the correct search-then-SQL path is taken with + no system-prompt guidance at all. +- Require `hotdata-framework>=0.9.0` / `hotdata>=0.8.0`. The pinned 0.4.1 uploaded through + `POST /v1/files`, which the API no longer serves, so `load_managed_table` failed with a bare + `Not Found`; 0.9.0 uses the session/finalize upload flow. + +### Fixed + +- README quickstart used `create_tool_calling_agent`/`AgentExecutor`, neither of which exists + in LangChain v1; replaced with `create_agent`. +- README and `examples/langchain_basic.py` ran SQL without a database scope, which the API + now rejects with `a database is required`. Both now scope their queries. ## [0.2.2] - 2026-06-27 diff --git a/README.md b/README.md index 524f3df..6a6adb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # hotdata-langchain -Give your [LangChain](https://python.langchain.com/) agents access to [Hotdata](https://hotdata.dev) — run SQL against your workspace connections and work with managed databases. +Give your [LangChain](https://python.langchain.com/) agents access to [Hotdata](https://hotdata.dev) — run SQL against your workspace connections, full-text search indexed columns, and work with managed databases. ## Install @@ -14,18 +14,27 @@ Set `HOTDATA_API_KEY` in your environment. Optionally set `HOTDATA_WORKSPACE` to ## Quickstart +The package itself depends only on `langchain-core`, and works with any tool-calling model. +Running an agent additionally needs the `langchain` package and the integration for whichever +model provider you use. + ```python -from langchain.agents import AgentExecutor, create_tool_calling_agent +from langchain.agents import create_agent import hotdata_langchain as hl client = hl.from_env() -tools = hl.make_hotdata_tools(client) +tools = hl.make_hotdata_tools(client, database="sales") -agent = create_tool_calling_agent(llm=your_llm, tools=tools, prompt=your_prompt) -executor = AgentExecutor(agent=agent, tools=tools) -result = executor.invoke({"input": "How many rows are in the orders table?"}) +agent = create_agent(model=your_model, tools=tools) +result = agent.invoke( + {"messages": [{"role": "user", "content": "Which product categories have the most orders?"}]} +) +print(result["messages"][-1].content) ``` +Queries run against a database scope, so pass `database=` (a managed database name or id). +`hl.from_env().list_managed_databases()` shows what is available in the workspace. + ## Tools `make_hotdata_tools(client)` returns a list of LangChain `StructuredTool` objects ready to pass to any agent: @@ -36,13 +45,33 @@ result = executor.invoke({"input": "How many rows are in the orders table?"}) | `hotdata_list_managed_databases` | List available managed databases | | `hotdata_create_managed_database` | Create a new managed database | | `hotdata_load_managed_table` | Load a parquet file into a managed table | +| `hotdata_describe_tables` | List tables, or one table's columns and types | +| `hotdata_search_text` | Full-text search an indexed column, ranked by relevance (opt-in — see below) | + +The descriptions carry the engine's contract — dialect, what SQL can and cannot do, and where +to look things up — so an agent does not need a system prompt explaining the query engine. + +## Letting the agent discover the schema + +`hotdata_describe_tables` is registered by default. Called with no arguments it lists every +table with its column count; called with a table name it returns that table's columns and +types. Without it an agent has to guess column names, and a guess that misses fails the query. + +```python +tools = hl.make_hotdata_tools(client, database="sales") # included +tools = hl.make_hotdata_tools(client, database="sales", describe_tables=False) # omitted +``` + +It reads `information_schema` in whichever database the tools are scoped to, so it needs no +extra permissions. With it turned off, the SQL tool's description tells the agent to query +`information_schema` directly instead. ## Calling tools directly You can also invoke tools outside of an agent loop: ```python -tools = {t.name: t for t in hl.make_hotdata_tools(client)} +tools = {t.name: t for t in hl.make_hotdata_tools(client, database="sales")} result = tools["hotdata_execute_sql"].invoke({"sql": "SELECT * FROM orders LIMIT 10"}) print(result) # JSON rows @@ -60,9 +89,59 @@ tools["hotdata_load_managed_table"].invoke({ }) ``` +## Full-text search + +Point the agent at a text column carrying a BM25 index and it gets a search tool alongside SQL: + +```python +tools = hl.make_hotdata_tools( + client, + database="sf_airbnb", + search_table="default.public.listings", # catalog.schema.table + search_column="description", # must have a BM25 index + search_columns=["id", "name", "price", "description"], # what each hit returns + search_k=5, +) + +hits = {t.name: t for t in tools}["hotdata_search_text"].invoke( + {"query": "cozy apartment with a view"} +) +``` + +Rows come back ranked, each with a `score`. The agent supplies only `query` and an optional +`k`; the table and column are fixed when you build the tool. That is deliberate — nothing in +the tool surface lets an agent discover which columns are indexed, and the engine errors +outright rather than falling back to a scan when a column has no BM25 index. + +Inside a managed database the built-in catalog is always `default`, so a managed table reads +as `default..` when `database=` scopes the query to it. + +For more than one searchable corpus, build the tools yourself and give each a distinct name +and description — the agent then routes on the descriptions: + +```python +tools = [ + *hl.make_hotdata_tools(client, database="sf_airbnb"), + hl.make_hotdata_search_tool( + client, table="default.public.listings", column="description", + name="search_listings", database="sf_airbnb", + ), + hl.make_hotdata_search_tool( + client, table="default.public.reviews", column="comments", + name="search_reviews", database="sf_airbnb", + ), +] +``` + +Provisioning the index itself is not yet part of this package; create it through the Hotdata +API or CLI. `demo/` has a script that does the whole flow — managed database, data load, BM25 +index, then an agent that picks between search and SQL. + ## Scoping queries to a managed database -Pass `database=` so all SQL the agent runs resolves against a specific managed database: +`database=` resolves all SQL the agent runs against a specific managed database, by name or +id. The API requires a database scope, so queries fail with `a database is required` without +it: ```python tools = hl.make_hotdata_tools(client, database="sales") @@ -83,6 +162,9 @@ uv run python examples/langchain_basic.py uv run python examples/langchain_managed_db.py ``` +For a full end-to-end run against a real workspace — data load, BM25 index build, then an +agent choosing between search and SQL — see [`demo/`](demo/README.md). + ## Development ```bash diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..cfdc5f1 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,124 @@ +# BM25 search demo + +Takes a Hotdata workspace from empty to a LangChain agent that full-text searches real +data, in one script. Uses the public San Francisco Airbnb listings fixture (7,535 rows) +and builds a BM25 index over the free-text `description` column. + +## Run it + +Steps 1–5 need only a Hotdata key and exercise the tool directly, no model involved: + +```bash +uv run --group demo --env-file .env python demo/bm25_search_demo.py +``` + +The agent step (6) runs when you name a tool-calling model. Any provider works — install +its LangChain integration, set its key, and pass `:`: + +```bash +uv run --group demo --env-file .env python demo/bm25_search_demo.py \ + --model ':' +``` + +The script is safe to re-run: it reuses the managed database, skips the load when the +table already has rows, and reuses an existing index. + +```bash +# different search text, more hits +uv run --group demo --env-file .env python demo/bm25_search_demo.py \ + --query "quiet garden studio near the park" --k 10 + +# stop before the agent step even with a model set +uv run --group demo --env-file .env python demo/bm25_search_demo.py --skip-agent + +# tear down the managed database it created +uv run --group demo --env-file .env python demo/bm25_search_demo.py --cleanup + +# trace the run into a named LangSmith project +LANGSMITH_TRACING=true LANGSMITH_PROJECT=hotdata-langchain-bm25 \ + uv run --group demo --env-file .env python demo/bm25_search_demo.py +``` + +## Credentials + +| Variable | Needed for | Notes | +|---|---|---| +| `HOTDATA_API_KEY` | steps 1–5 | everything except the agent run | +| `HOTDATA_WORKSPACE` | optional | pins a workspace; first available otherwise | +| your model provider's key | step 6 | skipped without it, or without `--model` | +| `LANGSMITH_API_KEY` + `LANGSMITH_TRACING=true` | optional | traces the run to LangSmith | +| `LANGSMITH_PROJECT` | optional | project the traces land in (default: `default`) | + +BM25 needs no embedding provider — just a string column — so there is no extra +credential for the index itself, unlike a vector index. LangSmith is observability +only; the agent still needs a model provider key of its own. + +With tracing on, every tool call becomes a run in the LangSmith project — the search +tool shows up as a `tool` run named `hotdata_search_text` carrying its query and the +ranked JSON it returned, so the generated SQL and the agent's choice between search +and SQL are both inspectable after the fact. + +## What each step does + +1. **Managed database** — creates `langchain_bm25_demo` with `public.listings` declared + up front, so the load materialises into it directly. +2. **Listings data** — downloads the fixture parquet (cached in the system temp dir) and + loads it into the managed table. +3. **BM25 index** — creates a `bm25` index on `description` through `IndexesApi` and + polls until it reports ready. This step is a hard prerequisite: `bm25_search` has no + brute-force fallback and errors outright when no index exists. +4. **Tools** — reads the real table schema and narrows the projection to a handful of + useful columns. The listings table is 85 columns wide; returning all of them would + flood the agent's context. +5. **Direct tool invocation** — prints the generated SQL, then the ranked hits with + their BM25 scores. Proves the tool against the live engine without an LLM in the loop. +6. **Agent run** — gives a LangChain agent both `hotdata_search_text` and + `hotdata_execute_sql` and asks a question neither answers alone: find listings whose + descriptions mention a quiet garden studio, then report listing counts and average + review scores across *all* listings in those neighbourhoods. The aggregate spans the + whole dataset, not the handful search returned, so the agent has to use search to + identify the neighbourhoods and SQL to aggregate over them. The printed tool calls + show which pathway it picked for which part. + + Set the model with `--model` or `DEMO_MODEL`; any tool-calling model works, and the + step is skipped when its provider key is absent. + + **What this step reliably demonstrates is the routing**, not the arithmetic. Across + five runs the agent called the search tool every time and reached for the schema tool + in four, which is the behaviour the demo exists to show. The final table was right in + four of five — one run aggregated over the handful of matched listings instead of all + listings in those neighbourhoods. Answering a compound question correctly is a property + of the model, not of these tools, so read the printed tool calls as the result and treat + the prose answer as illustrative. + +## What makes the agent run work + +**The tool descriptions, not the system prompt.** The demo's system prompt says only who +the agent is — it deliberately says nothing about which tool to use or how the engine +behaves. That guidance lives in the tool descriptions, so any application gets it without +having to teach the model about the query engine. + +It matters. An earlier version of this demo had a one-line SQL tool description and a +system prompt spelling out the rules; the model still reached for Postgres full-text +idioms (`to_tsvector`/`plainto_tsquery`), which the engine rejects with +`Invalid function 'to_tsvector'`. With the constraint stated in the SQL tool's own +description instead, the model uses the search tool and feeds its results into SQL — +even with no system-prompt guidance at all. + +**Tool errors have to reach the model.** The tools raise on failure, and an exception out +of a tool aborts the whole graph — so the demo wraps them (`with_error_feedback`) to +return the error as a message instead. The wrapper also digs the engine's real message +out of the exception chain: the framework raises `RuntimeError("Bad Request")` while the +useful text sits in the underlying API response body. Descriptions lower the failure +rate; readable errors are what let the model recover from what slips through. + +## Why the generated SQL looks the way it does + +Step 5 prints the query. Two details in it are load-bearing: + +- **`ORDER BY score DESC`** — the engine returns hits in rowid order, not ranked, so + ranking has to be asked for explicitly. +- **`k` appearing twice** — once as `bm25_search(...)`'s fourth argument and once as a + trailing `LIMIT`. The fourth argument is what actually bounds the search, because + `ORDER BY` blocks limit pushdown; relying on the trailing `LIMIT` alone would let the + scan fall back to the engine's much larger default bound. diff --git a/demo/bm25_search_demo.py b/demo/bm25_search_demo.py new file mode 100644 index 0000000..0f6b942 --- /dev/null +++ b/demo/bm25_search_demo.py @@ -0,0 +1,343 @@ +"""End-to-end demo of the Hotdata BM25 search tool, from empty workspace to agent. + +Stands up everything the tool needs, then exercises it twice — once by invoking the +tool directly, once through a LangChain agent that also has the SQL tool and has to +pick between them. + + uv run --group demo --env-file .env python demo/bm25_search_demo.py + +The agent step works with any tool-calling model — pass one with --model or DEMO_MODEL +— and is skipped when its provider key is not set; every step before it needs only +HOTDATA_API_KEY. Set LANGSMITH_API_KEY and LANGSMITH_TRACING=true to trace the run to +LangSmith. +""" + +from __future__ import annotations + +import argparse +import json +import os +import tempfile +import time +import urllib.request +from pathlib import Path +from typing import Any + +import hotdata + +import hotdata_langchain as hl + +PARQUET_URL = "https://www.hotdata.dev/data/sf-airbnb-listings.parquet" +USER_AGENT = "hotdata-langchain-demo" +DATABASE = "langchain_bm25_demo" +SCHEMA = "public" +TABLE = "listings" +SEARCH_COLUMN = "description" +INDEX_NAME = "listings_description_bm25" + +# The managed database is addressable as the `default` catalog inside its own scope, +# so the search tool's table reference is catalog-qualified against that. +TABLE_REF = f"default.{SCHEMA}.{TABLE}" + +# Narrow the columns each hit returns: the listings table is 85 columns wide and all +# of them would land in the agent's context. Filtered against the real schema below. +# `price` is excluded deliberately — it is NULL for every row in this fixture. +PREFERRED_COLUMNS = ["id", "name", "room_type", "neighbourhood_cleansed", "description"] + +DEFAULT_QUERY = "cozy apartment with a view" +# Env var holding the provider's key, looked up from the provider prefix of --model so the +# agent step can be skipped with a useful message rather than failing inside the provider. +# An unlisted provider simply skips the pre-check. +PROVIDER_KEY_VARS = { + "anthropic": "ANTHROPIC_API_KEY", + "google_genai": "GOOGLE_API_KEY", + "groq": "GROQ_API_KEY", + "mistralai": "MISTRAL_API_KEY", + "openai": "OPENAI_API_KEY", +} +INDEX_TIMEOUT_SECONDS = 600 + +# Deliberately not answerable from search results alone: the ratings and counts span +# every listing in the matched neighbourhoods, not just the handful search returned. +# That forces the agent to use search to identify the neighbourhoods and SQL to +# aggregate over them, which is the routing behaviour the demo exists to show. +AGENT_TASK = ( + "Find listings whose descriptions mention a quiet garden studio, and tell me which " + "neighbourhoods they are in. Then, across all listings in those neighbourhoods, " + "report the number of listings and the average review score for each one." +) + + +def step(message: str) -> None: + print(f"\n=== {message} ===") + + +def download_parquet() -> Path: + path = Path(tempfile.gettempdir()) / "sf-airbnb-listings.parquet" + if path.exists() and path.stat().st_size > 0: + print(f"Using cached fixture at {path} ({path.stat().st_size / 1_000_000:.1f} MB)") + return path + print(f"Downloading {PARQUET_URL}") + # The default urllib user-agent is rejected with a 403 by the asset host. + request = urllib.request.Request(PARQUET_URL, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=180) as response: + path.write_bytes(response.read()) + print(f"Saved {path} ({path.stat().st_size / 1_000_000:.1f} MB)") + return path + + +def find_database(client: hl.HotdataClient, name: str) -> Any | None: + for db in client.list_managed_databases(): + if db.description == name or db.id == name: + return db + return None + + +def ensure_database(client: hl.HotdataClient) -> Any: + existing = find_database(client, DATABASE) + if existing is not None: + print(f"Reusing managed database {DATABASE!r} (id={existing.id})") + return existing + db = client.create_managed_database(description=DATABASE, schema=SCHEMA, tables=[TABLE]) + print(f"Created managed database {DATABASE!r} (id={db.id}) with {SCHEMA}.{TABLE} declared") + return db + + +def load_listings(client: hl.HotdataClient, parquet: Path) -> None: + loaded = client.load_managed_table(DATABASE, TABLE, schema=SCHEMA, file=str(parquet)) + print(f"Loaded {loaded.row_count} rows into {loaded.full_name}") + + +def table_columns(client: hl.HotdataClient) -> list[str]: + result = client.execute_sql(f"SELECT * FROM {TABLE_REF} LIMIT 1", database=DATABASE) + return list(result.columns) + + +def ensure_bm25_index(client: hl.HotdataClient, connection_id: str) -> None: + indexes_api = hotdata.IndexesApi(client.api) + existing = indexes_api.list_indexes(connection_id, SCHEMA, TABLE).indexes + match = next((idx for idx in existing if idx.index_name == INDEX_NAME), None) + + if match is None: + print(f"Creating BM25 index {INDEX_NAME!r} on {SCHEMA}.{TABLE}.{SEARCH_COLUMN}") + indexes_api.create_index( + connection_id, + SCHEMA, + TABLE, + hotdata.CreateIndexRequest( + index_name=INDEX_NAME, + columns=[SEARCH_COLUMN], + index_type="bm25", + var_async=True, + ), + ) + else: + print(f"Index {INDEX_NAME!r} already exists (status={match.status})") + + deadline = time.monotonic() + INDEX_TIMEOUT_SECONDS + while True: + indexes = indexes_api.list_indexes(connection_id, SCHEMA, TABLE).indexes + current = next((idx for idx in indexes if idx.index_name == INDEX_NAME), None) + if current is not None and current.status == hotdata.IndexStatus.READY: + print(f"Index ready (type={current.index_type}, columns={current.columns})") + return + if time.monotonic() > deadline: + raise TimeoutError( + f"index {INDEX_NAME!r} not ready after {INDEX_TIMEOUT_SECONDS}s " + f"(status={getattr(current, 'status', 'missing')})" + ) + status = getattr(current, "status", "missing") + print(f" waiting for index build (status={status})…") + time.sleep(5) + + +def print_hits(payload: str, *, query: str) -> None: + parsed = json.loads(payload) + rows = parsed["rows"] + print(f"Top {len(rows)} hits for {query!r} (took {parsed['metadata']['execution_time_ms']}ms)") + for rank, row in enumerate(rows, start=1): + description = str(row.get(SEARCH_COLUMN, "")) + snippet = description[:110].replace("\n", " ") + label = row.get("name") or row.get("id") or "—" + print(f" {rank}. score={row['score']:.3f} {label}") + print(f" {snippet}…") + + +def engine_error_message(exc: BaseException) -> str: + """Pull the engine's own message out of a failed call. + + The framework raises ``RuntimeError(e.reason)`` — "Bad Request" — while the useful + text ("Invalid function 'to_tsvector'. Did you mean 'to_char'?") stays in the + ApiException body further down the ``__cause__`` chain. + """ + node: BaseException | None = exc + while node is not None: + body = getattr(node, "body", None) + if body: + try: + return str(json.loads(body)["error"]["message"]) + except (ValueError, KeyError, TypeError): + return str(body)[:500] + node = node.__cause__ + return str(exc) + + +def with_error_feedback(tools: list[Any]) -> list[Any]: + """Return tools that hand failures back to the model instead of raising. + + An agent that cannot see why a call failed cannot correct it, and an exception + out of a tool aborts the whole graph. Arguably belongs in the package itself. + """ + + def wrap(tool: Any) -> Any: + inner = tool.func + + def safe(*args: Any, **kwargs: Any) -> str: + try: + return str(inner(*args, **kwargs)) + except Exception as e: + message = engine_error_message(e) + print(f" [tool error fed back to the model] {message[:120]}") + return json.dumps({"error": message}) + + return tool.model_copy(update={"func": safe}) + + return [wrap(t) for t in tools] + + +def run_agent(tools: list[Any], *, model: str) -> None: + from langchain.agents import create_agent + + tracing = os.environ.get("LANGSMITH_TRACING", "").lower() in {"1", "true", "yes"} + has_langsmith_key = bool(os.environ.get("LANGSMITH_API_KEY")) + project = os.environ.get("LANGSMITH_PROJECT", "default") + print(f"LangSmith tracing={tracing} (api key present={has_langsmith_key}, project={project!r})") + + agent = create_agent( + model=model, + tools=with_error_feedback(tools), + # Role only. Which tool to reach for, and the engine's constraints, come from + # the tool descriptions themselves — an app should not have to teach the model + # how the query engine behaves. + system_prompt=( + "You are a data analyst working with a dataset of San Francisco Airbnb " + "listings. Cite the concrete numbers you retrieve." + ), + ) + result = agent.invoke({"messages": [{"role": "user", "content": AGENT_TASK}]}) + + print("\n--- tool calls the agent made ---") + for message in result["messages"]: + for call in getattr(message, "tool_calls", None) or []: + print(f" {call['name']}({json.dumps(call['args'])[:160]})") + + print("\n--- final answer ---") + print(result["messages"][-1].content) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--query", default=DEFAULT_QUERY, help="search text to run") + parser.add_argument("--k", type=int, default=5, help="how many ranked hits to return") + parser.add_argument( + "--model", + default=os.environ.get("DEMO_MODEL"), + help="tool-calling model for the agent step, e.g. ':' " + "(or set DEMO_MODEL); the agent step is skipped without one", + ) + parser.add_argument("--skip-agent", action="store_true", help="stop after direct tool use") + parser.add_argument("--reload", action="store_true", help="reload the parquet even if loaded") + parser.add_argument( + "--cleanup", action="store_true", help="delete the demo managed database and exit" + ) + args = parser.parse_args() + + client = hl.from_env() + print(f"Connected to {client.host} (workspace={client.workspace_id})") + + if args.cleanup: + existing = find_database(client, DATABASE) + if existing is None: + print(f"No managed database named {DATABASE!r} to delete") + else: + client.delete_managed_database(existing.id) + print(f"Deleted managed database {DATABASE!r} (id={existing.id})") + client.close() + return + + try: + step("1. Managed database") + db = ensure_database(client) + + step("2. Listings data") + already_loaded = False + if not args.reload: + # Probing with a row read rather than COUNT(*): the engine rejects a + # projection that is aggregates only. + try: + probe = client.execute_sql(f"SELECT id FROM {TABLE_REF} LIMIT 1", database=DATABASE) + already_loaded = bool(probe.rows) + if already_loaded: + print(f"{TABLE_REF} already holds data; skipping load (--reload to force)") + except Exception as e: + print(f"Table not queryable yet ({type(e).__name__}); loading fixture") + if not already_loaded: + load_listings(client, download_parquet()) + + step("3. BM25 index") + ensure_bm25_index(client, db.default_connection_id) + + step("4. Tools") + available = table_columns(client) + columns = [c for c in PREFERRED_COLUMNS if c in available] + if SEARCH_COLUMN not in columns: + columns.append(SEARCH_COLUMN) + print(f"Table has {len(available)} columns; projecting {columns}") + + tools = hl.make_hotdata_tools( + client, + database=DATABASE, + max_rows=args.k, + search_table=TABLE_REF, + search_column=SEARCH_COLUMN, + search_columns=columns, + search_k=args.k, + ) + by_name = {tool.name: tool for tool in tools} + print(f"Tools exposed to the agent: {sorted(by_name)}") + + step("5. Direct tool invocation") + search = by_name["hotdata_search_text"] + sql = hl.bm25_search_sql( + table=TABLE_REF, + column=SEARCH_COLUMN, + query=args.query, + k=args.k, + columns=columns, + ) + print(f"SQL the tool will run:\n {sql}") + print_hits(search.invoke({"query": args.query}), query=args.query) + + done = "Everything above is the tool working end to end against the real engine." + if args.skip_agent: + print("\n--skip-agent set; stopping before the agent run") + return + if not args.model: + print("\nNo model given — skipping the agent run.") + print("Pass --model ':' (or set DEMO_MODEL) to run it.") + print(done) + return + provider_key_var = PROVIDER_KEY_VARS.get(args.model.split(":", 1)[0]) + if provider_key_var and not os.environ.get(provider_key_var): + print(f"\n{provider_key_var} is not set — skipping the agent run.") + print(done) + return + + step("6. LangChain agent choosing between search and SQL") + run_agent(tools, model=args.model) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ff2814f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# docs/ + +Working notes for the AI-native query layer effort, checked in deliberately while the repo is +small so the reasoning stays with the code rather than in chat logs. Expect to prune these once +the roadmap is largely delivered. + +| Doc | What it is | +|---|---| +| [`engine-contract.md`](./engine-contract.md) | What the Hotdata SQL and search surface actually does, verified against a live workspace. The source of truth the tool descriptions encode — read this before asserting engine behaviour. | +| [`ai-native-layer-roadmap.md`](./ai-native-layer-roadmap.md) | Tiered plan for the layer, the routing decomposition, and the cross-repo work it surfaced (issues #36–#42). | +| [`vectorstore-plan.md`](./vectorstore-plan.md) | Design for `HotdataVectorStore` — schema, methods, SQL path, testing. Not yet implemented. | + +These are point-in-time notes, not specifications. Where one states engine behaviour it carries +the date it was verified; re-check against a live workspace before relying on it. diff --git a/docs/ai-native-layer-roadmap.md b/docs/ai-native-layer-roadmap.md new file mode 100644 index 0000000..444f080 --- /dev/null +++ b/docs/ai-native-layer-roadmap.md @@ -0,0 +1,136 @@ +# AI-native query layer — near-term roadmap + +## Context + +The team's shared vision for `hotdata-langchain` is a single `query_hotdata(...)` tool where +"the agent decides what to ask, not how to fetch it" — routing across four pathways (SQL, +full-text/BM25, vector/semantic, point lookups), merging/ranking results, with caching and +permissions underneath. Caching (`HotdataToolCache`) is built but still an unmerged draft +(PR #33, currently parked); `HotdataVectorStore` is planned (see +[`vectorstore-plan.md`](./vectorstore-plan.md)). + +A verification pass (2026-07-22) checked three previously-unverified assumptions directly +against the codebase, across `monopoly`, `runtimedb`, `datafusion-vector-search-ext`, +`hotdata-ibis`, and this repo: + +- **BM25/full-text is not a gap — it's already shipped and mature.** `runtimedb` has a real + Tantivy-backed full-text index (`src/bm25/`), a `bm25_search(...)` DataFusion table + function, its own `IndexType::Bm25` catalog entry, and a passing e2e test. `monopoly`'s + control plane is a thin proxy — RuntimeDB owns validation/dispatch. This repo just doesn't + expose it as a tool yet. +- **Point lookups are a scoped refactor, not a redesign.** The abstraction + (`PointLookupProvider::fetch_by_keys` in `datafusion-vector-search-ext`) is already + general-purpose; the coupling is in `runtimedb`'s vector-index build pipeline. Tracked as + [hotdata-langchain#34](https://github.com/hotdata-dev/hotdata-langchain/issues/34) + (implementation lands in `runtimedb`). +- **Routing/planning across pathways is genuinely greenfield** — nothing in the stack does + intent-based pathway selection today. `vector_search()`/`bm25_search()` are both explicit + SQL functions the caller must name; this repo's tools are fully independent with zero + shared dispatch. The one reusable precedent is `runtimedb`'s + `LazyTableProvider::select_best_index` (a narrow, single-heuristic strategy picker) and the + catalog's `IndexType` enum, which already models `Sorted`/`Bm25`/`Vector` side by side. + +## Routing, resolved into three separate problems (2026-07-27) + +The earlier "is routing one problem?" framing dissolved once the engine was read directly. +See [`engine-contract.md`](./engine-contract.md) for the verified specifics. + +- **Sorted index vs. table scan — already handled, by the planner.** Nobody decides. The + sorted index has no callable function; it is a transparent substitution inside + `hotdata_execute_sql`. A "search by number" tool would expose a choice that does not exist. +- **SQL vs. search — the model decides, and it needs telling.** This is an intent + difference no engine can recover from SQL. It is also *not* self-evident to the model: + text matching is expressible as computation (`LIKE`, `tsvector`), so an unguided agent + matches text in SQL and fails. Fixed by stating the constraint in the tool description, + which measurably works (see engine-contract.md's last section). +- **BM25 vs. vector — nobody should decide; fuse them.** Both take free text and return + ranked rows, and they fail differently: BM25 misses paraphrase, vector misses rare exact + tokens (ids, proper nouns). The established answer is `EnsembleRetriever`-style reciprocal + rank fusion. Fusion must operate on **ranks, not scores** — BM25 is unbounded (~8–11 + observed) and cosine is 0–2, so the scales are not comparable. + +The consequence for the tool surface: **one text-search capability that fuses underneath**, +not two tools the model picks between. `hotdata_search_text`'s description deliberately +names no mechanism (a test enforces that it never says "bm25"/"vector"/"hnsw"), so the +retrieval strategy can change without changing the contract the model was given. + +Fusion goes **client-side first**. Measured on a real 3-call agent run: 7,057 ms total, 65% +in model calls, 35% in tools — but each tool call was ~1,200 ms wall against 49–79 ms of +engine execution, so ~1,100 ms is round trip. A naive client-side hybrid therefore adds a +full extra round trip; issuing the two searches concurrently recovers most of it. Engine-side +`hybrid_search()` would remove it entirely and would benefit `hotdata-ibis` and dlt too, but +it is the optimization to do *after* the fusion parameters (RRF constant, dedup key, +tie-breaking) are settled empirically. + +## Checklist + +### Tier 1 — buildable now, zero blockers (this repo + `sdk-python`) + +- [x] **BM25 tool.** Shipped as `hotdata_langchain/search.py` — `hotdata_search_text`, with the + corpus pinned at construction (nothing lets an agent discover which columns are indexed, + and the engine errors outright when one is missing). +- [x] **Tool descriptions that carry the engine's contract.** The highest-leverage item found + so far, and not originally on this list. A 12-token SQL description produced a failed + run; the contract version produces the correct search-then-SQL path with no system + prompt at all. Pinned by `tests/test_descriptions.py`. +- [x] **Schema discovery.** `hotdata_describe_tables` over `information_schema`, registered by + default. Without it an agent guesses column names — and got away with it only because + the demo fixture is a famous public dataset. +- [ ] **`HotdataVectorStore` MVP.** Fully scoped in [`vectorstore-plan.md`](./vectorstore-plan.md) + — `add_texts`, `similarity_search(_by_vector)`, `get_by_ids`, `delete`, `from_texts`, + equality metadata filtering. No open design questions left, no code written yet. +- [ ] **`create_index` on `HotdataClient` (`sdk-python`).** Generalize the originally-scoped + `create_vector_index` into `create_index(..., index_type=...)` — `CreateIndexRequest` + already accepts `"bm25"` as well as `"vector"`, so one SDK method covers self-provisioning + for both instead of building it twice. + +### Tier 2 — needs scoped backend work, not exploratory + +- [ ] **Semantic search tool, then hybrid fusion.** Tracked as + [#39](https://github.com/hotdata-dev/hotdata-langchain/issues/39). Reciprocal-rank-fusion + merge over BM25 and vector, exposed as one tool rather than two the model chooses between. + No intent classification needed — the first real, demoable slice of the "routing" vision. +- [ ] **Point-lookup generalization in `runtimedb`.** Tracked as + [hotdata-langchain#34](https://github.com/hotdata-dev/hotdata-langchain/issues/34). + Decouple the lookup-sidecar build from the vector-index pipeline, loosen the registry, + add a `point_lookup(...)` UDTF. + +### Tier 3 — not ready to scope yet, needs a decision first + +- [ ] **Structured-intent routing** (deciding *when* to reach into SQL/point-lookup, à la + `SelfQueryRetriever`). Deferred until the hybrid retriever (Tier 2) shows whether a + classifier is actually needed. +- [ ] **Permissions.** Entirely untouched by shipped or planned work; no scoping done. +- [ ] **Cross-source joins.** Engine-level question, likely beyond what client-side LangChain + code alone can provide. `attach_database_catalog` may already be the supported route for + the cross-*database* case; unverified. + +## Cross-repo work this surfaced + +Everything below came out of building the BM25 tool and running it against production. The +per-item evidence lives in the linked issues; the verified engine behaviour behind them is in +[`engine-contract.md`](./engine-contract.md). + +Grouped by code surface: + +- **`hotdata-framework` client gaps** ([#36](https://github.com/hotdata-dev/hotdata-langchain/issues/36)) — errors discard the engine's message (raises + `e.reason`, "Bad Request", losing the actionable text); no `create_index`; + `resolve_managed_database` falls back to matching non-unique display names; `from_env()` + silently picks a workspace. The first is the one with demonstrated impact on agent behaviour. +- **`runtimedb` engine gaps** ([#37](https://github.com/hotdata-dev/hotdata-langchain/issues/37)) — ungrouped `COUNT(*)`/`COUNT(1)` rejected while + `COUNT()` works (probable bug, and the shape an agent writes first); no + hybrid/RRF primitive for the eventual server-side fusion. +- **id-first addressing in this repo** ([#38](https://github.com/hotdata-dev/hotdata-langchain/issues/38)) — breaking; mirrors the `hotdata-dlt-destination` + change (its PR #59) that removed by-name resolution entirely. Worth landing before the next + release rather than shipping two breaking versions. +- **Retrieval surface** ([#39](https://github.com/hotdata-dev/hotdata-langchain/issues/39)) — vector search tool, then client-side hybrid fusion over it and BM25. +- **Discovery surface** ([#40](https://github.com/hotdata-dev/hotdata-langchain/issues/40)) — report which columns are searchable in `hotdata_describe_tables`. + Newly unblocked: indexes are invisible to SQL but `IndexesApi.list_indexes` returns them, so + this needs no engine change. It is what would let the search corpus stop being pinned. +- **Tool-layer robustness** ([#41](https://github.com/hotdata-dev/hotdata-langchain/issues/41)) — fold the demo's `with_error_feedback` into the package (a raising + tool aborts the whole LangGraph run, and `handle_tool_error` only catches `ToolException`); + URL-based table loads, since the demo has to download its fixture by hand and an agent + cannot. + +Already tracked elsewhere and deliberately not duplicated: point-lookup generalization +(hotdata-langchain#34) and the sorted-index cost model (runtimedb#481). diff --git a/docs/engine-contract.md b/docs/engine-contract.md new file mode 100644 index 0000000..aeb854e --- /dev/null +++ b/docs/engine-contract.md @@ -0,0 +1,123 @@ +# Engine contract — what the SQL and search surface actually does + +Every claim here was checked against a live workspace (`api.hotdata.dev`, RuntimeDB behind it) +rather than read off a spec, because several of them contradict what the docs or the dialect +imply. They are the facts the tool descriptions in `hotdata_langchain/` encode, so if one of +them changes, a description somewhere is now lying to the model. + +Last verified 2026-07-27 against `hotdata-framework` 0.9.0 / `hotdata` 0.8.0. + +## SQL + +Postgres dialect, and the following are confirmed working: joins, CTEs, subqueries, `GROUP BY`, +window functions, `ORDER BY`/`LIMIT`, ordinary scalar functions, `LIKE` and `ILIKE`, and +schema-qualified as well as bare table names. + +Two constraints matter enough to state in a tool description: + +- **An aggregate query must reference at least one column.** `SELECT COUNT(*) FROM t` and + `SELECT COUNT(1) FROM t` are rejected with `must either specify a row count or at least one + column`; `SELECT COUNT(id) FROM t`, `SELECT MIN(id), MAX(id) FROM t` and + `SELECT room_type, COUNT(*) … GROUP BY room_type` all work. The failing shape is the one an + agent writes first, so the description gives the workaround. +- **There is no full-text matching in SQL.** No `to_tsvector`, no `plainto_tsquery`. The engine + answers with `Invalid function 'to_tsvector'. Did you mean 'to_char'?`. `LIKE`/`ILIKE` work as + substring tests but cannot rank. + +**A database scope is required.** An unscoped query fails with `a database is required: set the +X-Database-Id header or the database_id body field`. Inside a managed database the built-in +catalog is always `default`. + +## Full-text search + +```sql +bm25_search('catalog.schema.table', 'column', 'query text' [, limit]) +``` + +Returns the table's columns plus a trailing `score` (Float32). Three properties shape +`hotdata_langchain/search.py`: + +- **Results are not sorted.** Rows come back in rowid order, like SQLite FTS5. Verified: without + `ORDER BY` the scores came back `8.788, 8.092, 8.034, 8.254, 8.496`. Ranking must be asked for. +- **The fourth argument is the real bound.** BM25 is top-k, so tantivy needs the bound before + planning. A bare `LIMIT n` pushes down and drives it, but `ORDER BY score DESC LIMIT n` does + not — the sort blocks limit pushdown and the scan falls back to the engine's much larger + default. Correctness is unaffected (explicit-`k` and trailing-`LIMIT` returned identical + top-3), and at 7.5k rows the cost was not measurable (40 ms vs 38 ms median), so this is a + scan-bound difference rather than an observed slowdown. Passing `k` explicitly is free, so we do. +- **The index is a hard prerequisite.** No brute-force fallback: a column without a BM25 index + gives `No BM25 index found on column 'name' for .public.listings`. This differs from + vector search, where scalar distance UDFs still work without an index. + +Scores are comparable within one result set, not across queries. Observed BM25 range on real +data: roughly 8–11. Cosine distance is 0–2. **Never compare or average across the two** — this is +why fusion must work on ranks (RRF), not scores. + +## Index types and how each is reached + +RuntimeDB has three (`IndexType` in `src/catalog/manager.rs`: `Sorted`, `Bm25`, `Vector`), but +they are not three of the same kind of thing: + +| Index | Reached by | Named by the caller? | +|---|---|---| +| Sorted | the planner substitutes the sorted parquet when a pushed-down filter matches the index's **leading** sort column | no — transparent | +| BM25 | `bm25_search(...)` table function | yes | +| Vector | `vector_search(...)` table function | yes | + +So the sorted index needs no tool: it is already served through `hotdata_execute_sql`. There is +no callable function for it. + +**There is no cross-modality routing in the engine.** `LazyTableProvider::select_best_index` and +`IndexAwareManagedProvider::select_catalog_index` query the catalog with +`list_indexes(..., Some(IndexType::Sorted))` — they only ever see sorted indexes, and choose +index-scan versus table-scan. The code's own comment notes a proper cost model is still needed +(runtimedb#481). Nothing in the engine chooses between BM25, vector and sorted, and a grep for +hybrid/RRF/fusion across the engine finds nothing. + +## Schema and index discovery + +Working in SQL: `information_schema.tables`, `information_schema.columns` (with +`table_catalog`, `table_schema`, `table_name`, `column_name`, `ordinal_position`, `data_type`, +`is_nullable`), `SHOW TABLES`, and `DESCRIBE
`. `hotdata_langchain/schema.py` builds on +`information_schema.columns`, so it needs no extra permissions. + +**Indexes are not visible in SQL** — no `pg_indexes`, no `information_schema.indexes`. They are +only reachable through the control plane, `IndexesApi.list_indexes(connection_id, schema, table)`, +which returns index name, type, columns and status. This is why an agent cannot currently +discover which columns are searchable, and why the search tool pins its corpus. + +## Databases and workspaces + +- **One client can query many databases.** `execute_sql(sql, database=...)` takes the scope per + call; the same client read from two different managed databases in one session. +- **Cross-database references inside a single query fail** by default: + `SELECT id FROM f1_db.public.drivers` from within another database's scope gives + `table 'f1_db.public.drivers' not found`. `DatabasesApi` does expose + `attach_database_catalog`/`detach_database_catalog`, and `bm25_search`'s scope resolution + translates "an attachment alias or `default`", so attachment is presumably the supported route + — **not verified here**. +- **Database names are not unique.** `name` is a display label; `resolve_managed_database` tries + the id first and then scans `list_databases()` matching on name. Ids are the only safe handle. +- **`from_env()` picks a workspace silently** when `HOTDATA_WORKSPACE` is unset — first active, + else first overall, no warning. `HotdataClient(api_key, workspace_id)` takes it explicitly. + +## Error reporting + +The framework raises `RuntimeError(e.reason)`, which is the bare HTTP reason (`"Bad Request"`). +The engine's actual message survives only in the underlying `ApiException`'s `body`, further down +the `__cause__` chain. This is not cosmetic: an agent shown `"Bad Request"` cannot correct +itself, while the real text (`Invalid function 'to_tsvector'…`) is directly actionable. See the +cross-repo list in [`ai-native-layer-roadmap.md`](./ai-native-layer-roadmap.md). + +## What an agent does without guidance + +Both observed with a small tool-calling model and the tools from `make_hotdata_tools`: + +- **It matches text in SQL.** With a one-line SQL tool description — even *with* a system prompt + spelling out the rule — it wrote `to_tsvector`/`plainto_tsquery`, the query failed, and the + exception aborted the whole LangGraph run. With the constraint in the SQL tool's own + description it uses the search tool correctly, with no system-prompt guidance at all. +- **It guesses column names.** It produced `AVG(review_scores_rating)` for a column that was + never in any tool output — correct only because the SF Airbnb fixture is a well-known public + dataset. On proprietary data that guess fails. With `hotdata_describe_tables` registered it + calls the overview, drills into the table, and then writes the query. diff --git a/docs/vectorstore-plan.md b/docs/vectorstore-plan.md new file mode 100644 index 0000000..5dccbc0 --- /dev/null +++ b/docs/vectorstore-plan.md @@ -0,0 +1,268 @@ +# `HotdataVectorStore` — implementation plan + +Status: plan / not yet built. Not committed — decide later whether this belongs in the repo +long-term or stays a local reference doc (mirrors the convention used in +`hotdata-dlt-destination/docs/vector-search-exploration.md`). + +## Problem & positioning + +Hotdata is working with LangChain on deeper ecosystem integration. Phase 1 of that work — `HotdataToolCache`/`cached()`, a Hotdata-backed cache for +arbitrary LangChain tool calls — shipped as draft PR #33. The team has validated two +directions coming out of that: "Hotdata as a tool for LangChain" (the existing 4-tool +foundation in `make_hotdata_tools`) and "Tool caching" (Phase 1 itself). + +`VectorStore`/RAG is the next priority, chosen specifically because it converges with Rohan's +own recent vector-search engineering: + +- **`datafusion-vector-search-ext`** — the DataFusion extension that makes USearch HNSW ANN + search a first-class SQL operator (`ORDER BY (col, query) LIMIT k`, transparently + rewritten into an index lookup). PR #31 (merged 2026-07-21) fixed the optimizer rule to see + through `SubqueryAlias` nodes, which is what makes the fast path reachable from SQL generated + by anything that aliases tables (ibis, ORMs, BI tools). +- **`runtimedb`** — the deployed query engine; PR #953 bumped its pin to pick up #31. **Merged + and confirmed live in production** — the fast path is no longer pending. +- **`hotdata-ibis`** — gets its own read-side vector helper layer (a `semantic_search()` + + distance-UDF module), planned and owned separately by Rohan; this plan cross-references it + but doesn't depend on it. +- **`hotdata-dlt-destination`** — already flows `list` embedding columns through its + write path untouched; a differentiated auto-embed-on-ingest adapter is a separate, later + piece. + +Put together, these converge on one story: one fast, DataFusion-backed engine, addressable +from SQL, ibis, and now LangChain's own `VectorStore` primitive — not three disconnected +integrations. + +**Bigger-picture context (not yet planned, understanding-only as of 2026-07-22):** the team +has separately articulated a longer-term vision of Hotdata as an "AI-native query layer" for +LangChain — a single tool that routes across SQL, full-text, vector, and point-lookup +pathways with its own query planning and permissions, rather than several discrete tools the +agent picks between. `HotdataVectorStore` (this doc) and `HotdataToolCache` are partial +building blocks toward that vision (the SQL and caching pieces, plus this doc's vector +pathway) — not the vision itself. That larger design is intentionally not scoped here; it's +tracked separately until it moves from understanding to planning. + +**This document covers `HotdataVectorStore` only** — a new class in `hotdata_langchain`. It +does not cover the `hotdata-ibis` helper or the `hotdata-dlt-destination` adapter in +implementation detail; see "Cross-repo dependency tracking" below for how those relate. + +## `HotdataVectorStore` design + +### File and constructor + +New file: `hotdata_langchain/vectorstore.py` (sibling to `cache.py`, not folded into +`databases.py`). Constructor mirrors `HotdataToolCache`'s `database`/`database_id`/`table`/`schema` +pattern from `cache.py`: + +```python +HotdataVectorStore( + client: HotdataClient, + embedding: Embeddings, + *, + database: str = "langchain_vectorstore", + database_id: str | None = None, + table: str = "vectors", + schema: str = DEFAULT_SCHEMA, + distance: Literal["cosine", "l2", "dot"] = "cosine", + metadata_columns: Mapping[str, Literal["string", "int", "float", "bool"]] | None = None, +) +``` + +`embedding` is held on `self`, not passed per-call — the universal LangChain convention, and +what lets `similarity_search(self, query, k=4, **kwargs)` match the ABC's fixed signature. + +### Storage schema + +One managed table, key = `["id"]` (enables `mode="upsert"`/`"delete"` exactly like +`HotdataToolCache`'s `cache_key` pattern): + +| column | type | purpose | +|---|---|---| +| `id` | `string` | LangChain doc id / managed-table key | +| `content` | `string` | `page_content` | +| `metadata_json` | `string` | full metadata dict (`json.dumps(..., default=_json_default)`), always kept in full for read-back fidelity | +| `embedding` | `list` | confirmed to round-trip through `load_managed_table` via a live spike in a sibling repo | +| *(promoted metadata columns)* | typed per `metadata_columns` | denormalized copy of declared metadata keys, so `WHERE` can target a real typed column — see Filtering below | + +### Methods (verified against installed `langchain_core==1.4.0` source, not docs) + +Only `similarity_search` and `from_texts` are truly `@abstractmethod` on `VectorStore`. +Everything else has a default or raises `NotImplementedError` until overridden. + +- **`add_texts`** (implement; `add_documents` derives for free from it, confirmed via the base + class's own delegation check). `self._embedding.embed_documents(texts)` → one pyarrow table + (id/content/metadata_json/embedding/promoted columns) → temp parquet → + `client.load_managed_table(..., mode="upsert", key=["id"])`. Same shape as + `HotdataToolCache.set()`. Generate ids via `uuid.uuid4().hex` when omitted — never `None` (the + key column can't be null). +- **`similarity_search` / `similarity_search_by_vector` / `similarity_search_with_score(_by_vector)`** + — implement all explicitly rather than relying on ABC defaults. See "SQL-path decision" below + for the query shape. +- **`_select_relevance_score_fn`** — mapped off `self._distance`. Default constructor value is + `cosine` specifically because its score function (`1 - distance`) needs no scale assumption; + see the `l2` caveat below. +- **`get_by_ids(ids)`** — `WHERE id IN (...)`, no vector math involved; the simplest method, + built first. +- **`delete(ids=None, **kwargs)`** — **requires** `ids` (raises if omitted; no "delete + everything" in v1, mirroring `HotdataToolCache`'s stance of never exposing an unbounded + destructive operation). Backed by `load_managed_table(..., mode="delete", key=["id"])`. + Raises on backend failure — deletes do **not** fail open (unlike the cache's fail-open + policy: silently reporting a delete succeeded when it didn't is actively dangerous, a cache + miss is not). +- **`from_texts(cls, texts, embedding, metadatas=None, *, ids=None, **kwargs)`** — classmethod; + `client` threaded through `**kwargs` (the ABC's sanctioned per-implementation extension + point, same pattern every real integration uses for constructor args the ABC can't + standardize). Builds the store, calls `add_texts`, returns it. Index creation (if requested) + happens strictly after `add_texts` — see "Dimension binding" below. +- **MMR (`max_marginal_relevance_search_by_vector`)** — **not free.** The ABC raises + `NotImplementedError` by default (confirmed by reading `InMemoryVectorStore`, LangChain's own + reference implementation) — every real implementation fetches `fetch_k` candidates *with + their raw embedding vectors* and runs + `langchain_core.vectorstores.utils.maximal_marginal_relevance`. This needs its own query + branch that *does* select the `embedding` column, which breaks the "never surface the vector + column" rule the primary read path relies on for the engine's fast-path rewrite (engine issue + #508) — so this branch is always brute-force by design. Acceptable: `fetch_k` defaults small + and is caller-bounded, so a full scan over a bounded candidate set is cheap. Own phase, own + PR — a distinct correctness surface (raw-vector round-trip on read, which the primary path + never needs), not bundled with the MVP. +- Everything else (`add_documents`, async variants via thread-pool wrapping, `as_retriever()`, + `similarity_search_with_relevance_scores`) is free from the base class — verified by tests + that they delegate correctly, no new code required. + +**Internal plumbing**: reuse `HotdataToolCache`'s `_ensure_ready()`/`_resolve_and_declare()` +pattern verbatim — resolve-or-create the managed database, best-effort `add_managed_table` with +`key=["id"]`, swallow "already declared" failures at `logger.debug`. + +### SQL-path decision + +**Build every read query as a scalar-UDF `ORDER BY ... LIMIT`, not the `vector_search_vector(...)` +table function:** + +```sql +SELECT id, content, metadata_json, , + (embedding, ARRAY[...]) AS dist +FROM "default".""."
" +[WHERE = ] +ORDER BY dist ASC +LIMIT +``` + +using the engine's index-independent scalar distance UDFs (`cosine_distance`, `l2_distance`, +`negative_dot_product` — confirmed to work as plain row-by-row functions even with **no index +at all**, always correct, just a full-table brute-force scan without one). + +Why this over the table function: this shape is correct from row one with zero +preconditions, and it transparently upgrades to the HNSW fast path the moment a +matching-metric index exists on that column — one code path, no index-vs-no-index branching +to build or test. The `vector_search_vector(...)` table function, by contrast, errors loudly +("no loaded vector index") if the index doesn't exist yet, which would make a freshly +constructed `HotdataVectorStore` unusable out of the box — a bad default for a +partnership-facing integration. The raw `embedding` column is never selected in this path +(engine issue #508: a vector column in the output declines the fast-path rewrite). + +### Metadata filtering (v1 scope) + +Equality filters only, and only on keys explicitly declared via the constructor's +`metadata_columns` (which promotes them to real typed columns at write time). +`filter={"key": value}` on a key not in `metadata_columns` raises `ValueError` immediately — +fail loudly at call time, not silently-wrong at query time. Free-form/undeclared metadata keys +are simply not filterable in v1. + +Filter predicates always go in the *same* query, in `WHERE`, ahead of `ORDER BY`/`LIMIT` — +never as an outer query wrapping an already-computed top-k result, which would silently +return fewer than `k` rows (a filter applied after top-k selection can only shrink the result, +never re-fill it). Whether a `WHERE`-filtered query still triggers the HNSW fast path is +explicitly **unverified** (attribute-filtered ANN is a harder capability many engines don't +support natively) — brute-force-but-correct is an accepted v1 cost, not a blocker, consistent +with the engine's own "brute force is always correct, just not accelerated" design. + +Ids and filter literals are charset-validated before SQL interpolation (mirroring `cache.py`'s +`_KEY_PATTERN` philosophy — reject anything outside a conservative charset rather than +attempt general SQL escaping). The query vector itself is never user-controlled text; it's a +list of floats we format ourselves. + +### Dimension binding + +A vector's dimension is only knowable after the first `embed_documents` call, but +`create_index` needs `dimensions` up front. Sequencing inside `from_texts`: (1) construct the +store, (2) call `add_texts` (embeds and writes rows — dimension is now known from the vectors +just embedded), (3) only then, if `create_index=True` was requested, create the index with the +now-known dimension. Index creation never precedes the first write. + +### `l2` relevance-score caveat + +The engine's `l2_distance` is **squared** L2 (no `sqrt`), but `VectorStore`'s default +`_euclidean_relevance_score_fn` assumes true (unsquared) Euclidean distance on +unit-normalized embeddings — using `l2` as the configured metric would produce a +wrong-scale relevance score unless corrected, and correcting it properly requires knowing +embedding normalization we don't control. Constructor defaults to `cosine` for this reason +(`1 - distance` is exact, no scale assumption); `l2`/`dot` remain available but flagged in the +docstring rather than silently "fixed." + +## Testing strategy + +No live embedding-provider credentials are available in this repo's `.env` (Hotdata +credentials only). Unit tests mirror `tests/test_cache.py`'s fixture style exactly: a fake +`HotdataClient` (`MagicMock`) backed by an in-memory dict, where `load_managed_table` does a +*real* `pq.read_table(file).to_pylist()` (exercising the `list` round-trip `cache.py` +never needed) and `execute_sql` does real SQL-shape parsing rather than a bare mocked return +value. For embeddings, use `langchain_core.embeddings.DeterministicFakeEmbedding` (confirmed +present in the installed `langchain_core`, zero new dependency) rather than a bespoke fake. + +Coverage: schema/type correctness on write; exact SQL shape on read (distance aliased, +embedding column absent from `SELECT`, filter predicate inside the same query); `ValueError` +on an undeclared filter key or a malformed id; `delete` requiring `ids`; `from_texts` +round-tripping end to end; MMR selecting the embedding column and calling into +`maximal_marginal_relevance` with the right shapes. + +**Live verification** (once real credentials or a local cluster are available — not part of +this repo's CI): round-trip `add_texts`/`similarity_search` against a real workspace with a +real embedding; `EXPLAIN` the primary query before and after provisioning a matching-metric +index, confirming the plan shows the USearch-rewritten node. Since `runtimedb` PR #953 is now +merged and live in production, this is no longer blocked on a pending deploy — it's now +directly verifiable once `HotdataVectorStore` itself exists, rather than a claim asserted from +a sibling repo's spike. `EXPLAIN` a `WHERE`-filtered query to settle whether filtered queries +still hit the fast path. + +## Phasing + +1. **MVP** — `add_texts`, `similarity_search(_by_vector)`, + `similarity_search_with_score(_by_vector)`, `get_by_ids`, `delete`, `from_texts` (no + self-provisioned index yet), promoted-column equality filtering, full unit-test suite, + `examples/langchain_vectorstore.py`, README section, CHANGELOG entry. A complete, correct, + mergeable `VectorStore` on its own — `as_retriever()`, chains, and evals all work once this + lands, independent of anything below. +2. **MMR** — its own PR; isolated correctness surface (the raw-vector read path). +3. **Self-provisioning** — gated on the `sdk-python` dependency below. Adds + `from_texts(..., create_index=True)`. +4. Docs/examples are pulled into Phase 1 rather than deferred to the end — this is the piece + the LangChain conversation will exercise first. + +## Cross-repo dependency tracking + +- **`sdk-python` (`hotdata_framework.HotdataClient`) — in scope, ours to build.** A + `create_vector_index` addition. Confirmed via full grep: zero existing index-related code in + the package today. The raw generated `hotdata.api.indexes_api.IndexesApi.create_index` + + `CreateIndexRequest` already support everything needed (`columns`, `metric`, `dimensions`, + `embedding_provider_id`, `output_column`, an async job-polling path). Follows + `create_managed_database`'s exact shape: resolve `database` → `default_connection_id`, build + the request, call the raw API, wrap `ApiException` → `RuntimeError(api_error_message(e))`, + return a frozen dataclass built field-by-field from `IndexInfoResponse` (or poll + `SubmitJobResponse.status_url` for the async path, reusing the existing polling-loop style + already in `client.py`). This addition only blocks Phase 3 (self-provisioning) above — + Phases 1–2 work today regardless, against an existing index, a not-yet-existing index, or no + index ever, by construction of the SQL-path decision above. +- **`runtimedb` PR #953 — merged and live in production.** Pin-bump to pick up + `datafusion-vector-search-ext` PR #31, confirmed deployed. Our SQL was designed to be + correct either way (the scalar UDFs work with no index at all) — this just means the HNSW + fast path is now actually live for any query matching the contract above, not merely + pending. No longer a dependency to track. +- **`hotdata-ibis` vector helper layer — external, owned separately, tracked for consistency + only.** Not a dependency of this work. Cross-referenced so both surfaces target the same + engine contract (same distance-function names, same "never select the vector column" + constraint) rather than drifting apart. +- **`hotdata-dlt-destination` write-side embedding adapter — external, future, tracked.** + Still a design sketch (`hotdata_adapter(data, embed=[...])`). Once built, completes the full + pipeline this plan enables end to end: dlt ingests and auto-embeds on the way in → + `HotdataVectorStore` reads it out for a LangChain agent. Not a blocker for this work — this + plan's MVP works against precomputed embeddings loaded any way (including today's dlt + destination, unchanged). diff --git a/examples/langchain_basic.py b/examples/langchain_basic.py index a8e9427..27003d9 100644 --- a/examples/langchain_basic.py +++ b/examples/langchain_basic.py @@ -5,14 +5,20 @@ def main() -> None: client = hl.from_env() - tools = hl.make_hotdata_tools(client) - by_name = {tool.name: tool for tool in tools} - sql_tool = by_name["hotdata_execute_sql"] - print(sql_tool.invoke({"sql": "SELECT 1 AS ok"})) + tools = {tool.name: tool for tool in hl.make_hotdata_tools(client)} + print(tools["hotdata_list_managed_databases"].invoke({})) - list_tool = by_name["hotdata_list_managed_databases"] - print(list_tool.invoke({})) + # Queries need a database scope, so pick one from the workspace. + databases = client.list_managed_databases() + if not databases: + print("No managed databases in this workspace; create one to run a query.") + client.close() + return + + scoped = hl.make_hotdata_tools(client, database=databases[0].id) + by_name = {tool.name: tool for tool in scoped} + print(by_name["hotdata_execute_sql"].invoke({"sql": "SELECT 1 AS ok"})) client.close() diff --git a/hotdata_langchain/__init__.py b/hotdata_langchain/__init__.py index 1ec683c..818cbc7 100644 --- a/hotdata_langchain/__init__.py +++ b/hotdata_langchain/__init__.py @@ -16,6 +16,19 @@ load_result_summary, managed_database_summary, ) +from hotdata_langchain.schema import ( + DEFAULT_DESCRIBE_TOOL_NAME, + describe_tables_json, + make_hotdata_describe_tables_tool, +) +from hotdata_langchain.search import ( + DEFAULT_SEARCH_LIMIT, + DEFAULT_SEARCH_TOOL_NAME, + SCORE_COLUMN, + bm25_search_json, + bm25_search_sql, + make_hotdata_search_tool, +) from hotdata_langchain.tools import ( execute_sql_json, make_hotdata_tools, @@ -23,15 +36,24 @@ ) __all__ = [ + "DEFAULT_DESCRIBE_TOOL_NAME", + "DEFAULT_SEARCH_LIMIT", + "DEFAULT_SEARCH_TOOL_NAME", + "SCORE_COLUMN", "HotdataClient", "QueryResult", "__version__", + "bm25_search_json", + "bm25_search_sql", "create_managed_database", + "describe_tables_json", "execute_sql_json", "from_env", "list_managed_databases_json", "load_managed_table", "load_result_summary", + "make_hotdata_describe_tables_tool", + "make_hotdata_search_tool", "make_hotdata_tools", "managed_database_summary", "result_rows_for_llm", diff --git a/hotdata_langchain/schema.py b/hotdata_langchain/schema.py new file mode 100644 index 0000000..9235e87 --- /dev/null +++ b/hotdata_langchain/schema.py @@ -0,0 +1,128 @@ +"""Schema introspection so an agent can learn what it is allowed to query.""" + +from __future__ import annotations + +import json +import re + +from hotdata_framework import HotdataClient +from langchain_core.tools import StructuredTool + +DEFAULT_DESCRIBE_TOOL_NAME = "hotdata_describe_tables" + +#: Cap on columns returned for a single table, so one wide table cannot flood the context. +DEFAULT_MAX_COLUMNS = 200 + +_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def _split_table(table: str) -> tuple[str | None, str]: + """Split ``schema.table`` or a bare ``table`` into its parts, validating both.""" + parts = table.split(".") + if len(parts) > 2: + raise ValueError( + "table must be 'table' or 'schema.table' (the database is already scoped), " + f"got {table!r}" + ) + for part in parts: + if not _IDENTIFIER_RE.fullmatch(part): + raise ValueError(f"table must be made of bare SQL identifiers, got {table!r}") + return (parts[0], parts[1]) if len(parts) == 2 else (None, parts[0]) + + +def table_overview_sql() -> str: + """Return SQL listing every table in the scoped database with its column count.""" + return ( + "SELECT table_schema, table_name, COUNT(column_name) AS column_count " + "FROM information_schema.columns " + "GROUP BY table_schema, table_name " + "ORDER BY table_schema, table_name" + ) + + +def table_columns_sql(table: str, *, limit: int = DEFAULT_MAX_COLUMNS) -> str: + """Return SQL listing one table's columns and types, in declaration order.""" + schema, name = _split_table(table) + where = f"WHERE table_name = '{name}'" + if schema is not None: + where += f" AND table_schema = '{schema}'" + return ( + f"SELECT table_schema, table_name, column_name, data_type " + f"FROM information_schema.columns {where} " + f"ORDER BY table_schema, table_name, ordinal_position " + f"LIMIT {limit}" + ) + + +def describe_tables_json( + client: HotdataClient, + *, + table: str | None = None, + database: str | None = None, + max_columns: int = DEFAULT_MAX_COLUMNS, +) -> str: + """Describe the scoped database's tables, or one table's columns, as JSON. + + Without ``table`` this returns every table with its column count — a cheap map of + what exists. With ``table`` it returns that table's columns and types in + declaration order, capped at ``max_columns`` so a wide table cannot flood the + model's context; the payload says so when the cap truncated the list. + """ + if table is None: + result = client.execute_sql(table_overview_sql(), database=database) + tables = [ + { + "table": f"{row['table_schema']}.{row['table_name']}", + "column_count": row["column_count"], + } + for row in result.to_records() + ] + return json.dumps({"tables": tables}, indent=2) + + result = client.execute_sql(table_columns_sql(table, limit=max_columns), database=database) + records = result.to_records() + if not records: + return json.dumps( + {"table": table, "columns": [], "error": f"no table named {table!r} in this database"}, + indent=2, + ) + payload: dict[str, object] = { + "table": f"{records[0]['table_schema']}.{records[0]['table_name']}", + "columns": [{"name": r["column_name"], "type": r["data_type"]} for r in records], + } + if len(records) == max_columns: + payload["truncated_at"] = max_columns + return json.dumps(payload, indent=2) + + +def default_describe_description() -> str: + """Return the agent-facing description for the schema tool.""" + return ( + "Discover what data is available before writing a query. Called with no " + "arguments it lists every table with how many columns it has; called with a " + "table name ('listings' or 'public.listings') it returns that table's columns " + "and their types.\n" + "Use it whenever you are unsure a table or column exists — guessing a column " + "name that is not there makes the query fail." + ) + + +def make_hotdata_describe_tables_tool( + client: HotdataClient, + *, + database: str | None = None, + name: str = DEFAULT_DESCRIBE_TOOL_NAME, + description: str | None = None, + max_columns: int = DEFAULT_MAX_COLUMNS, +) -> StructuredTool: + """Return a LangChain tool that reports the scoped database's tables and columns.""" + + def hotdata_describe_tables(table: str | None = None) -> str: + """List the tables in the database, or one table's columns and types.""" + return describe_tables_json(client, table=table, database=database, max_columns=max_columns) + + return StructuredTool.from_function( + func=hotdata_describe_tables, + name=name, + description=description or default_describe_description(), + ) diff --git a/hotdata_langchain/search.py b/hotdata_langchain/search.py new file mode 100644 index 0000000..8352080 --- /dev/null +++ b/hotdata_langchain/search.py @@ -0,0 +1,195 @@ +"""Full-text (BM25) search helpers and tools for LangChain agents.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence + +from hotdata_framework import HotdataClient +from langchain_core.tools import StructuredTool + +#: Column the engine appends to every ``bm25_search`` result, holding the BM25 relevance score. +SCORE_COLUMN = "score" + +#: Default number of ranked hits requested when a caller does not specify one. +DEFAULT_SEARCH_LIMIT = 5 + +DEFAULT_SEARCH_TOOL_NAME = "hotdata_search_text" + +_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +_TABLE_REF_RE = re.compile( + r"[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*" +) + + +def _validate_table_ref(table: str) -> str: + """Return ``table`` if it is a bare ``catalog.schema.table`` reference, else raise.""" + if not _TABLE_REF_RE.fullmatch(table): + raise ValueError( + "table must be a fully qualified 'catalog.schema.table' reference " + f"of bare identifiers, got {table!r}" + ) + return table + + +def _validate_identifier(value: str, *, label: str) -> str: + """Return ``value`` if it is a bare SQL identifier, else raise.""" + if not _IDENTIFIER_RE.fullmatch(value): + raise ValueError(f"{label} must be a bare SQL identifier, got {value!r}") + return value + + +def _quote_literal(value: str) -> str: + """Return ``value`` as a single-quoted SQL string literal with quotes doubled.""" + if "\x00" in value: + raise ValueError("search text may not contain null bytes") + return "'" + value.replace("'", "''") + "'" + + +def _projection(column: str, columns: Sequence[str] | None) -> list[str]: + selected = list(columns) if columns is not None else [column] + if not selected: + raise ValueError("columns must not be empty") + for name in selected: + _validate_identifier(name, label="column") + return [*(name for name in selected if name != SCORE_COLUMN), SCORE_COLUMN] + + +def bm25_search_sql( + *, + table: str, + column: str, + query: str, + k: int = DEFAULT_SEARCH_LIMIT, + columns: Sequence[str] | None = None, +) -> str: + """Build the SQL for a ranked BM25 top-k search over an indexed text column. + + ``table`` is a fully qualified ``catalog.schema.table`` reference. Inside a managed + database the built-in catalog is always ``default``, so a managed table reads as + ``default.public.listings`` when the query is scoped to that database. + + ``column`` must be a column carrying a BM25 index; the engine has no brute-force + fallback and errors when no index exists. ``columns`` selects which table columns + come back (defaulting to the searched column alone); ``score`` is always appended + last and never duplicated. + + ``k`` is emitted twice, and both are load-bearing. It is passed as the ``bm25_search`` + fourth argument, which bounds the search tantivy runs, and again as a trailing + ``LIMIT``. The explicit argument is what actually caps the scan: ``ORDER BY`` blocks + limit pushdown, so a query relying on the trailing ``LIMIT`` alone falls back to the + engine's much larger default bound. + + Raises ``ValueError`` for identifiers that are not bare SQL identifiers, for a + non-positive ``k``, and for search text containing null bytes. + """ + _validate_table_ref(table) + _validate_identifier(column, label="column") + if k < 1: + raise ValueError(f"k must be >= 1, got {k}") + + projection = _projection(column, columns) + return ( + f"SELECT {', '.join(projection)} " + f"FROM bm25_search(" + f"{_quote_literal(table)}, {_quote_literal(column)}, {_quote_literal(query)}, {k}) " + f"ORDER BY {SCORE_COLUMN} DESC " + f"LIMIT {k}" + ) + + +def bm25_search_json( + client: HotdataClient, + *, + table: str, + column: str, + query: str, + k: int = DEFAULT_SEARCH_LIMIT, + columns: Sequence[str] | None = None, + max_rows: int = 100, + database: str | None = None, +) -> str: + """Run a BM25 search and return ``{"metadata": ..., "rows": [...]}`` as JSON. + + Mirrors the envelope :func:`hotdata_langchain.tools.execute_sql_json` returns, so an + agent sees one result shape across every Hotdata tool. Rows arrive ranked by + ``score`` descending. + """ + sql = bm25_search_sql(table=table, column=column, query=query, k=k, columns=columns) + result = client.execute_sql(sql, database=database) + payload = { + "metadata": result.metadata_dict(), + "rows": result.to_records(max_rows=max_rows), + } + return json.dumps(payload, indent=2) + + +def default_search_description(table: str, column: str) -> str: + """Return the agent-facing tool description used when no override is given. + + Describes the capability ("find rows whose text is relevant") rather than the index + behind it, so the contract the model is given survives the retrieval strategy + changing underneath it. + """ + return ( + f"Find rows of {table} whose '{column}' text is relevant to a natural-language " + "query. This is the only way to match on what the text says — SQL cannot rank " + "rows by textual relevance.\n" + "Returns the best-matching rows ordered by a 'score' column, highest first; " + "scores are comparable within one result set but not across queries. Ask for " + "more with 'k' when you need a wider net.\n" + "Use it to identify which rows are relevant, then take the values you need from " + "the results into the SQL tool for filters, joins and aggregates." + ) + + +def make_hotdata_search_tool( + client: HotdataClient, + *, + table: str, + column: str, + columns: Sequence[str] | None = None, + k: int = DEFAULT_SEARCH_LIMIT, + name: str = DEFAULT_SEARCH_TOOL_NAME, + description: str | None = None, + max_rows: int = 100, + database: str | None = None, +) -> StructuredTool: + """Return a LangChain tool that full-text searches one indexed column. + + The corpus is pinned here rather than chosen by the model: nothing in the tool + surface lets an agent discover which columns carry a BM25 index, and the engine + errors outright when one is missing. The agent supplies only ``query`` and an + optional ``k``. + + Register the factory more than once, with distinct ``name`` and ``description`` + values, to expose several searchable corpora; the agent then routes on the + descriptions. + """ + _validate_table_ref(table) + _validate_identifier(column, label="column") + if k < 1: + raise ValueError(f"k must be >= 1, got {k}") + if columns is not None: + _projection(column, columns) + default_k = k + + def hotdata_search_text(query: str, k: int | None = None) -> str: + """Search indexed text by relevance and return ranked rows as JSON.""" + return bm25_search_json( + client, + table=table, + column=column, + query=query, + k=default_k if k is None else k, + columns=columns, + max_rows=max_rows, + database=database, + ) + + return StructuredTool.from_function( + func=hotdata_search_text, + name=name, + description=description or default_search_description(table, column), + ) diff --git a/hotdata_langchain/tools.py b/hotdata_langchain/tools.py index dc0d518..b6c4027 100644 --- a/hotdata_langchain/tools.py +++ b/hotdata_langchain/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Sequence from typing import Any from hotdata_framework import DEFAULT_SCHEMA, HotdataClient, QueryResult @@ -15,6 +16,62 @@ load_result_summary, managed_database_summary, ) +from hotdata_langchain.schema import ( + DEFAULT_DESCRIBE_TOOL_NAME, + make_hotdata_describe_tables_tool, +) +from hotdata_langchain.search import ( + DEFAULT_SEARCH_LIMIT, + DEFAULT_SEARCH_TOOL_NAME, + make_hotdata_search_tool, +) + + +def sql_tool_description( + search_tool_name: str | None = None, + describe_tool_name: str | None = DEFAULT_DESCRIBE_TOOL_NAME, +) -> str: + """Return the agent-facing description for the SQL tool. + + States the engine's capabilities positively rather than listing what is absent, so + the description does not turn into a false claim as the SQL surface grows. The two + constraints it does name are the ones that silently produce wrong tool calls: SQL + cannot rank text, and an aggregate query that references no column is rejected. + + ``search_tool_name`` is named as the place to do text matching only when a search + tool is actually registered alongside this one. When it is, `LIKE` is framed as a + filter on text you already know rather than a way to find relevant rows: stating + only that it "works" was observed to pull the model into `ILIKE '%word%'` instead of + searching, which returns unranked results and misses related wording. + """ + text_guidance = ( + f"To find which rows are about something, use the {search_tool_name} tool — it " + f"ranks by relevance — and then pass the values it returns into SQL as literals. " + f"SQL cannot rank text. LIKE and ILIKE only test for a literal substring you " + f"already know, so they are a filter, not a substitute for searching: " + f"ILIKE '%word%' returns unranked rows and misses the related wording a search " + f"would find." + if search_tool_name + else "LIKE and ILIKE test for a literal substring, but SQL cannot rank rows by " + "how well their text matches a phrase." + ) + discovery = ( + f"Do not guess table or column names — get them from the {describe_tool_name} tool" + if describe_tool_name + else "Do not guess table or column names — read them from " + "information_schema.tables and information_schema.columns, or DESCRIBE
" + ) + return ( + "Run a read-only SQL query and return the rows as JSON. PostgreSQL dialect: " + "joins, CTEs, subqueries, GROUP BY, window functions, ORDER BY/LIMIT and the " + "usual scalar functions all work.\n" + f"{text_guidance}\n" + "An aggregate query must reference at least one column: COUNT(*) and COUNT(1) " + "are rejected on their own, so write COUNT() or add a GROUP BY.\n" + "Tables are addressed as catalog.schema.table; inside a managed database the " + "catalog is always 'default' (schema-qualified names also resolve). " + f"{discovery}." + ) def result_rows_for_llm(result: QueryResult, *, max_rows: int = 20) -> list[dict[str, Any]]: @@ -41,8 +98,30 @@ def make_hotdata_tools( *, max_rows: int = 100, database: str | None = None, + search_table: str | None = None, + search_column: str | None = None, + search_columns: Sequence[str] | None = None, + search_k: int = DEFAULT_SEARCH_LIMIT, + search_tool_name: str = DEFAULT_SEARCH_TOOL_NAME, + describe_tables: bool = True, ) -> list[StructuredTool]: - """Return LangChain tools for SQL and managed database workflows.""" + """Return LangChain tools for SQL and managed database workflows. + + ``describe_tables`` (on by default) adds a schema-introspection tool, so the agent + can look up tables and columns instead of guessing them. It reads + ``information_schema`` in whichever database the tools are scoped to. + + Passing both ``search_table`` and ``search_column`` appends a full-text search tool + bound to that column, which requires a BM25 index on it. ``search_columns`` selects + the columns each hit returns (default: the searched column). Supplying only one of + ``search_table``/``search_column`` raises ``ValueError``. + + For more than one searchable corpus, call + :func:`hotdata_langchain.search.make_hotdata_search_tool` directly per corpus and + extend this list. + """ + if (search_table is None) != (search_column is None): + raise ValueError("search_table and search_column must be provided together") def hotdata_execute_sql(sql: str) -> str: """Run SQL against the Hotdata workspace and return JSON rows.""" @@ -83,21 +162,63 @@ def hotdata_load_managed_table( ) return json.dumps(load_result_summary(loaded), indent=2) - return [ + has_search = search_table is not None and search_column is not None + tools = [ StructuredTool.from_function( func=hotdata_execute_sql, name="hotdata_execute_sql", + description=sql_tool_description( + search_tool_name if has_search else None, + DEFAULT_DESCRIBE_TOOL_NAME if describe_tables else None, + ), ), StructuredTool.from_function( func=hotdata_list_managed_databases, name="hotdata_list_managed_databases", + description=( + "List the managed databases in this workspace. Returns each database's " + "'id' and its human-readable 'description'. Names are display labels and " + "are not unique — pass the 'id' to other tools, never the description." + ), ), StructuredTool.from_function( func=hotdata_create_managed_database, name="hotdata_create_managed_database", + description=( + "Create a managed database to hold tables you load. 'name' is a display " + "label; the response carries the 'id' to use with the other tools. Declare " + "the tables you intend to load up front as a comma- or newline-separated " + "list, so data loads straight into them." + ), ), StructuredTool.from_function( func=hotdata_load_managed_table, name="hotdata_load_managed_table", + description=( + "Load a parquet file from the local filesystem into a table that was " + "declared on a managed database, replacing whatever the table held. " + "'database' should be a database id. Only local parquet paths are " + "accepted — not URLs, and not other file formats." + ), ), ] + + if describe_tables: + tools.append(make_hotdata_describe_tables_tool(client, database=database)) + + if has_search: + assert search_table is not None and search_column is not None + tools.append( + make_hotdata_search_tool( + client, + table=search_table, + column=search_column, + columns=search_columns, + k=search_k, + name=search_tool_name, + max_rows=max_rows, + database=database, + ) + ) + + return tools diff --git a/tests/conftest.py b/tests/conftest.py index b1620af..1455fbc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,20 @@ def sample_result() -> QueryResult: ) +@pytest.fixture +def search_result() -> QueryResult: + return QueryResult( + columns=["description", "score"], + rows=[["Cozy apartment with a view", 8.5], ["Cozy studio, great light", 4.25]], + row_count=2, + result_id="res_bm25", + query_run_id="run_bm25", + execution_time_ms=8, + warning=None, + error_message=None, + ) + + @pytest.fixture def mock_client(sample_result: QueryResult): client = MagicMock() diff --git a/tests/test_descriptions.py b/tests/test_descriptions.py new file mode 100644 index 0000000..8200f33 --- /dev/null +++ b/tests/test_descriptions.py @@ -0,0 +1,81 @@ +"""The tool descriptions are the contract the model plans against. + +Each claim asserted here was verified against a live engine; a description that drifts +from the engine's real behaviour misleads the model silently, so the wording that +encodes a real constraint is pinned rather than left free. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from hotdata_langchain.tools import make_hotdata_tools, sql_tool_description + +TABLE = "default.public.listings" +COLUMN = "description" + + +def descriptions(**kwargs: object) -> dict[str, str]: + client = MagicMock() + return {t.name: t.description or "" for t in make_hotdata_tools(client, **kwargs)} # type: ignore[arg-type] + + +def test_every_tool_is_described() -> None: + for name, description in descriptions().items(): + assert len(description) > 40, f"{name} has a description too thin to plan against" + + +def test_sql_description_states_the_dialect() -> None: + assert "postgresql dialect" in sql_tool_description().lower() + + +def test_sql_description_warns_that_aggregates_need_a_column() -> None: + """Verified live: ungrouped COUNT(*)/COUNT(1) are rejected, COUNT() works.""" + description = sql_tool_description() + assert "COUNT(*)" in description + assert "COUNT()" in description + assert "GROUP BY" in description + + +def test_sql_description_points_at_the_search_tool_when_one_exists() -> None: + """Verified live: an unguided agent reaches for to_tsvector, which the engine rejects.""" + description = descriptions(search_table=TABLE, search_column=COLUMN)["hotdata_execute_sql"] + assert "hotdata_search_text" in description + assert "LIKE" in description + + +def test_sql_description_frames_like_as_a_filter_not_a_search() -> None: + """Saying only that LIKE "works" was observed to pull the model away from searching.""" + description = descriptions(search_table=TABLE, search_column=COLUMN)["hotdata_execute_sql"] + assert "not a substitute for searching" in description + # The relevance route must be stated before LIKE is mentioned at all. + assert description.index("hotdata_search_text") < description.index("LIKE") + + +def test_sql_description_omits_the_search_tool_when_none_is_registered() -> None: + description = descriptions()["hotdata_execute_sql"] + assert "hotdata_search_text" not in description + assert "LIKE" in description + + +def test_sql_description_follows_a_custom_search_tool_name() -> None: + description = descriptions( + search_table=TABLE, search_column=COLUMN, search_tool_name="search_listings" + )["hotdata_execute_sql"] + assert "search_listings" in description + assert "hotdata_search_text" not in description + + +def test_database_descriptions_steer_towards_ids() -> None: + """Database names are display labels and are not unique; ids are the safe handle.""" + described = descriptions() + assert "id" in described["hotdata_list_managed_databases"] + assert "not unique" in described["hotdata_list_managed_databases"] + assert "database id" in described["hotdata_load_managed_table"].lower() + + +def test_load_description_states_the_accepted_input() -> None: + """Verified: the tool takes a local parquet path, not a URL.""" + description = descriptions()["hotdata_load_managed_table"].lower() + assert "parquet" in description + assert "url" in description diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..f563897 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest +from hotdata_framework import QueryResult + +from hotdata_langchain.schema import ( + DEFAULT_MAX_COLUMNS, + default_describe_description, + describe_tables_json, + make_hotdata_describe_tables_tool, + table_columns_sql, + table_overview_sql, +) +from hotdata_langchain.tools import make_hotdata_tools + + +def result(columns: list[str], rows: list[list[object]]) -> QueryResult: + return QueryResult( + columns=columns, + rows=rows, + row_count=len(rows), + result_id="res", + query_run_id="run", + execution_time_ms=3, + warning=None, + error_message=None, + ) + + +OVERVIEW = result( + ["table_schema", "table_name", "column_count"], + [["public", "listings", 85], ["public", "reviews", 6]], +) +COLUMNS = result( + ["table_schema", "table_name", "column_name", "data_type"], + [ + ["public", "listings", "id", "Int64"], + ["public", "listings", "description", "LargeUtf8"], + ], +) + + +def executed_sql(client: MagicMock) -> str: + sql = client.execute_sql.call_args.args[0] + assert isinstance(sql, str) + return sql + + +# --- SQL construction ------------------------------------------------------------- + + +def test_overview_sql_groups_by_table() -> None: + sql = table_overview_sql() + assert "information_schema.columns" in sql + # COUNT(column_name), not COUNT(*): the engine rejects an aggregate that names no column. + assert "COUNT(column_name)" in sql + assert "GROUP BY table_schema, table_name" in sql + + +def test_columns_sql_filters_by_bare_table_name() -> None: + sql = table_columns_sql("listings") + assert "WHERE table_name = 'listings'" in sql + assert "table_schema =" not in sql + assert "ORDER BY table_schema, table_name, ordinal_position" in sql + + +def test_columns_sql_filters_by_schema_qualified_name() -> None: + sql = table_columns_sql("public.listings") + assert "WHERE table_name = 'listings' AND table_schema = 'public'" in sql + + +def test_columns_sql_applies_the_column_cap() -> None: + assert table_columns_sql("listings").endswith(f"LIMIT {DEFAULT_MAX_COLUMNS}") + assert table_columns_sql("listings", limit=10).endswith("LIMIT 10") + + +@pytest.mark.parametrize("table", ["a.b.c", "list ings", "list'ings", "1listings", "", "public."]) +def test_columns_sql_rejects_bad_table_reference(table: str) -> None: + with pytest.raises(ValueError): + table_columns_sql(table) + + +# --- JSON payloads ---------------------------------------------------------------- + + +def test_describe_without_table_lists_tables_and_counts() -> None: + client = MagicMock() + client.execute_sql.return_value = OVERVIEW + payload = json.loads(describe_tables_json(client)) + assert payload == { + "tables": [ + {"table": "public.listings", "column_count": 85}, + {"table": "public.reviews", "column_count": 6}, + ] + } + + +def test_describe_with_table_lists_columns_and_types() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS + payload = json.loads(describe_tables_json(client, table="listings")) + assert payload["table"] == "public.listings" + assert payload["columns"] == [ + {"name": "id", "type": "Int64"}, + {"name": "description", "type": "LargeUtf8"}, + ] + assert "truncated_at" not in payload + + +def test_describe_reports_truncation_at_the_cap() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS + payload = json.loads(describe_tables_json(client, table="listings", max_columns=2)) + assert payload["truncated_at"] == 2 + + +def test_describe_reports_an_unknown_table_rather_than_empty_success() -> None: + client = MagicMock() + client.execute_sql.return_value = result( + ["table_schema", "table_name", "column_name", "data_type"], [] + ) + payload = json.loads(describe_tables_json(client, table="nope")) + assert payload["columns"] == [] + assert "no table named" in payload["error"] + + +def test_describe_scopes_queries_to_the_database() -> None: + client = MagicMock() + client.execute_sql.return_value = OVERVIEW + describe_tables_json(client, database="sf_airbnb") + assert client.execute_sql.call_args.kwargs == {"database": "sf_airbnb"} + + +# --- Tool surface ----------------------------------------------------------------- + + +def test_describe_tool_shape() -> None: + tool = make_hotdata_describe_tables_tool(MagicMock()) + assert tool.name == "hotdata_describe_tables" + assert set(tool.args) == {"table"} + assert tool.description == default_describe_description() + + +def test_describe_tool_defaults_to_the_overview() -> None: + client = MagicMock() + client.execute_sql.return_value = OVERVIEW + tool = make_hotdata_describe_tables_tool(client) + payload = json.loads(tool.invoke({})) + assert [t["table"] for t in payload["tables"]] == ["public.listings", "public.reviews"] + assert "GROUP BY" in executed_sql(client) + + +def test_describe_tool_drills_into_one_table() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS + tool = make_hotdata_describe_tables_tool(client) + tool.invoke({"table": "public.listings"}) + assert "WHERE table_name = 'listings'" in executed_sql(client) + + +def test_describe_tool_is_registered_by_default() -> None: + names = {t.name for t in make_hotdata_tools(MagicMock())} + assert "hotdata_describe_tables" in names + + +def test_describe_tool_can_be_turned_off() -> None: + names = {t.name for t in make_hotdata_tools(MagicMock(), describe_tables=False)} + assert "hotdata_describe_tables" not in names + + +def test_sql_description_points_at_the_schema_tool_when_registered() -> None: + tools = {t.name: t for t in make_hotdata_tools(MagicMock())} + assert "hotdata_describe_tables" in (tools["hotdata_execute_sql"].description or "") + + +def test_sql_description_falls_back_to_information_schema_without_the_tool() -> None: + tools = {t.name: t for t in make_hotdata_tools(MagicMock(), describe_tables=False)} + description = tools["hotdata_execute_sql"].description or "" + assert "information_schema" in description + assert "hotdata_describe_tables" not in description diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..7113d8d --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import json +import re +from typing import Any +from unittest.mock import MagicMock + +import pytest +from hotdata_framework import QueryResult + +from hotdata_langchain.search import ( + DEFAULT_SEARCH_LIMIT, + bm25_search_json, + bm25_search_sql, + default_search_description, + make_hotdata_search_tool, +) +from hotdata_langchain.tools import make_hotdata_tools + +TABLE = "default.public.listings" +COLUMN = "description" +QUERY = "cozy apartment" + +# Matches the four-argument bm25_search(...) call, capturing the trailing limit. The +# fourth argument is what bounds the engine's search; a trailing SQL LIMIT does not +# reach the scan through ORDER BY. +_SEARCH_CALL_RE = re.compile( + r"bm25_search\('[^']*', '[^']*', '.*', (\d+)\)", +) + + +def executed_sql(client: MagicMock) -> str: + sql = client.execute_sql.call_args.args[0] + assert isinstance(sql, str) + return sql + + +# --- SQL construction ------------------------------------------------------------- + + +def test_bm25_search_sql_full_shape() -> None: + assert bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, k=5) == ( + "SELECT description, score " + "FROM bm25_search('default.public.listings', 'description', 'cozy apartment', 5) " + "ORDER BY score DESC " + "LIMIT 5" + ) + + +def test_bm25_search_sql_passes_k_as_explicit_fourth_argument() -> None: + """The search bound must be an argument, not only a trailing LIMIT.""" + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, k=7) + match = _SEARCH_CALL_RE.search(sql) + assert match is not None, f"bm25_search call is not in four-argument form: {sql}" + assert match.group(1) == "7" + assert sql.endswith("LIMIT 7") + + +def test_bm25_search_sql_orders_by_score_descending() -> None: + """The engine returns hits in rowid order, so ranking has to be requested.""" + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY) + assert "ORDER BY score DESC" in sql + assert sql.index("ORDER BY score DESC") < sql.index("LIMIT") + + +def test_bm25_search_sql_defaults_k_to_search_limit() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY) + assert sql.endswith(f"LIMIT {DEFAULT_SEARCH_LIMIT}") + + +def test_bm25_search_sql_defaults_projection_to_searched_column_and_score() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY) + assert sql.startswith("SELECT description, score FROM") + + +def test_bm25_search_sql_projects_requested_columns_with_score_last() -> None: + sql = bm25_search_sql( + table=TABLE, + column=COLUMN, + query=QUERY, + columns=["id", "name", "description"], + ) + assert sql.startswith("SELECT id, name, description, score FROM") + + +def test_bm25_search_sql_does_not_duplicate_score_column() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, columns=["id", "score", "name"]) + projection = sql[len("SELECT ") : sql.index(" FROM")] + assert projection == "id, name, score" + + +def test_bm25_search_sql_escapes_single_quotes_in_query() -> None: + sql = bm25_search_sql(table=TABLE, column=COLUMN, query="host's place") + assert "'host''s place'" in sql + + +def test_bm25_search_sql_neutralises_quote_injection() -> None: + """LLM-authored search text reaches a SQL literal, so quotes must not terminate it.""" + sql = bm25_search_sql(table=TABLE, column=COLUMN, query="x') OR 1=1 --") + assert "'x'') OR 1=1 --'" in sql + assert _SEARCH_CALL_RE.search(sql) is not None + assert sql.count("bm25_search(") == 1 + + +def test_bm25_search_sql_rejects_null_byte_in_query() -> None: + with pytest.raises(ValueError, match="null bytes"): + bm25_search_sql(table=TABLE, column=COLUMN, query="a\x00b") + + +@pytest.mark.parametrize( + "table", + [ + "listings", + "public.listings", + "default.public.listings.extra", + "default.public.'; DROP TABLE x; --", + "default..listings", + "", + ], +) +def test_bm25_search_sql_rejects_bad_table_reference(table: str) -> None: + with pytest.raises(ValueError, match=r"catalog\.schema\.table"): + bm25_search_sql(table=table, column=COLUMN, query=QUERY) + + +@pytest.mark.parametrize("column", ["desc ription", "desc'ription", "1description", ""]) +def test_bm25_search_sql_rejects_bad_column(column: str) -> None: + with pytest.raises(ValueError, match="bare SQL identifier"): + bm25_search_sql(table=TABLE, column=column, query=QUERY) + + +def test_bm25_search_sql_rejects_bad_projection_column() -> None: + with pytest.raises(ValueError, match="bare SQL identifier"): + bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, columns=["id; DROP TABLE x"]) + + +@pytest.mark.parametrize("k", [0, -1]) +def test_bm25_search_sql_rejects_non_positive_k(k: int) -> None: + with pytest.raises(ValueError, match="k must be >= 1"): + bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, k=k) + + +def test_bm25_search_sql_rejects_empty_columns() -> None: + with pytest.raises(ValueError, match="columns must not be empty"): + bm25_search_sql(table=TABLE, column=COLUMN, query=QUERY, columns=[]) + + +# --- JSON envelope ---------------------------------------------------------------- + + +def test_bm25_search_json_returns_metadata_and_rows( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + payload = json.loads(bm25_search_json(mock_client, table=TABLE, column=COLUMN, query=QUERY)) + assert set(payload) == {"metadata", "rows"} + assert payload["metadata"]["row_count"] == 2 + assert payload["rows"][0] == {"description": "Cozy apartment with a view", "score": 8.5} + + +def test_bm25_search_json_scopes_query_to_database( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + bm25_search_json(mock_client, table=TABLE, column=COLUMN, query=QUERY, database="sf_airbnb") + assert mock_client.execute_sql.call_args.kwargs == {"database": "sf_airbnb"} + + +def test_bm25_search_json_truncates_rows_to_max_rows( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + payload = json.loads( + bm25_search_json(mock_client, table=TABLE, column=COLUMN, query=QUERY, max_rows=1) + ) + assert len(payload["rows"]) == 1 + assert payload["metadata"]["row_count"] == 2 + + +# --- Tool surface ----------------------------------------------------------------- + + +def test_search_tool_name_and_arguments(mock_client: MagicMock) -> None: + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN) + assert tool.name == "hotdata_search_text" + assert set(tool.args) == {"query", "k"} + assert tool.args["query"]["type"] == "string" + + +def test_search_tool_description_grounds_the_agent(mock_client: MagicMock) -> None: + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN) + assert tool.description == default_search_description(TABLE, COLUMN) + assert COLUMN in tool.description + assert TABLE in tool.description + + +def test_search_description_steers_away_from_matching_text_in_sql() -> None: + """The observed failure mode is an agent doing text matching in SQL instead.""" + description = default_search_description(TABLE, COLUMN).lower() + assert "sql cannot rank rows by textual relevance" in description + assert "score" in description + + +def test_search_description_names_the_capability_not_the_index() -> None: + """The contract has to outlive the retrieval strategy behind it.""" + description = default_search_description(TABLE, COLUMN).lower() + for mechanism in ("bm25", "tantivy", "hnsw", "vector", "embedding"): + assert mechanism not in description, f"description leaks the mechanism {mechanism!r}" + + +def test_search_tool_accepts_description_override(mock_client: MagicMock) -> None: + tool = make_hotdata_search_tool( + mock_client, table=TABLE, column=COLUMN, description="Search Airbnb blurbs." + ) + assert tool.description == "Search Airbnb blurbs." + + +def test_search_tool_invocation_builds_ranked_sql( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool( + mock_client, table=TABLE, column=COLUMN, columns=["id", "description"] + ) + payload = json.loads(tool.invoke({"query": QUERY})) + assert payload["rows"][0]["score"] == 8.5 + assert executed_sql(mock_client) == ( + "SELECT id, description, score " + "FROM bm25_search('default.public.listings', 'description', 'cozy apartment', 5) " + "ORDER BY score DESC " + "LIMIT 5" + ) + + +def test_search_tool_uses_constructor_k_when_agent_omits_it( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=3) + tool.invoke({"query": QUERY}) + assert executed_sql(mock_client).endswith("LIMIT 3") + + +def test_search_tool_lets_agent_override_k( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=3) + tool.invoke({"query": QUERY, "k": 10}) + sql = executed_sql(mock_client) + match = _SEARCH_CALL_RE.search(sql) + assert match is not None + assert match.group(1) == "10" + assert sql.endswith("LIMIT 10") + + +def test_search_tool_validates_corpus_at_construction(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match=r"catalog\.schema\.table"): + make_hotdata_search_tool(mock_client, table="listings", column=COLUMN) + + +def test_search_tool_validates_k_at_construction(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match="k must be >= 1"): + make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=0) + + +def test_search_tool_validates_columns_at_construction(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match="bare SQL identifier"): + make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, columns=["bad col"]) + + +# --- Wiring into make_hotdata_tools ----------------------------------------------- + + +def tool_names(tools: list[Any]) -> set[str]: + return {tool.name for tool in tools} + + +def test_make_hotdata_tools_omits_search_tool_by_default(mock_client: MagicMock) -> None: + assert "hotdata_search_text" not in tool_names(make_hotdata_tools(mock_client)) + + +def test_make_hotdata_tools_appends_search_tool_when_configured(mock_client: MagicMock) -> None: + tools = make_hotdata_tools(mock_client, search_table=TABLE, search_column=COLUMN) + assert tool_names(tools) == { + "hotdata_execute_sql", + "hotdata_list_managed_databases", + "hotdata_create_managed_database", + "hotdata_load_managed_table", + "hotdata_describe_tables", + "hotdata_search_text", + } + + +def test_make_hotdata_tools_honours_custom_search_tool_name(mock_client: MagicMock) -> None: + tools = make_hotdata_tools( + mock_client, + search_table=TABLE, + search_column=COLUMN, + search_tool_name="search_listings", + ) + assert "search_listings" in tool_names(tools) + + +@pytest.mark.parametrize( + ("table", "column"), + [(TABLE, None), (None, COLUMN)], +) +def test_make_hotdata_tools_requires_both_search_arguments( + mock_client: MagicMock, table: str | None, column: str | None +) -> None: + with pytest.raises(ValueError, match="must be provided together"): + make_hotdata_tools(mock_client, search_table=table, search_column=column) + + +def test_make_hotdata_tools_shares_database_scope_with_search( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tools = { + tool.name: tool + for tool in make_hotdata_tools( + mock_client, database="sf_airbnb", search_table=TABLE, search_column=COLUMN + ) + } + tools["hotdata_search_text"].invoke({"query": QUERY}) + assert mock_client.execute_sql.call_args.kwargs == {"database": "sf_airbnb"} + + +def test_make_hotdata_tools_shares_max_rows_with_search( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tools = { + tool.name: tool + for tool in make_hotdata_tools( + mock_client, max_rows=1, search_table=TABLE, search_column=COLUMN + ) + } + payload = json.loads(tools["hotdata_search_text"].invoke({"query": QUERY})) + assert len(payload["rows"]) == 1 diff --git a/tests/test_tools.py b/tests/test_tools.py index 3afa1c9..7d0b400 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -95,6 +95,7 @@ def test_make_hotdata_tools(mock_client, sample_result): "hotdata_list_managed_databases", "hotdata_create_managed_database", "hotdata_load_managed_table", + "hotdata_describe_tables", } json.loads(by_name["hotdata_execute_sql"].invoke({"sql": "select 1"})) From f5f4accbdd2d2834e70c93fbfdbd4ef47cc2feac Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 27 Jul 2026 19:13:47 +0530 Subject: [PATCH 3/4] fix: address review findings on search, schema and the demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp a model-supplied `k` to `max_rows`. It fed straight into `bm25_search(..., k)`, which is what bounds the scan, so a hallucinated `k: 100000` had the engine rank and ship rows that Python then discarded. The caller's own `k` stays untouched; only the model's is clamped. - `describe_tables` no longer reports a complete schema as truncated. A table with exactly `max_columns` columns returned exactly that many rows and got flagged, telling the model part of the schema was missing — the one thing likely to send it back to guessing column names. It now reads one row past the cap to tell exact-fit from truncated. - The demo passed `max_rows=args.k`, which caps the SQL tool as well as search. With the default `--k 5` the agent's aggregate silently came back truncated to five rows while `metadata.row_count` still reported the true count. Search hits and SQL rows are different budgets; the SQL one is now separate. Verified against the live engine: the aggregate returns ten neighbourhood rows where it previously returned five. - The demo hand-rolled schema discovery with `SELECT * ... LIMIT 1`; it now uses `describe_tables_json`, exercising the tool it advertises. - The README's multi-corpus recipe called `make_hotdata_tools` with no `search_table`/`search_column`, so `sql_tool_description` fell back to its no-search branch and never named the search tools — losing exactly the steer the descriptions exist to provide. It now configures the first corpus through the factory and adds further ones alongside. - `IDENTIFIER_RE`/`validate_identifier`/`quote_literal` move to `_sql.py`. Two copies of the validator standing between model-authored strings and a SQL literal is the kind of duplication that drifts. --- README.md | 13 +++++++++---- demo/bm25_search_demo.py | 14 +++++++++++--- hotdata_langchain/_sql.py | 25 ++++++++++++++++++++++++ hotdata_langchain/schema.py | 17 ++++++++++------- hotdata_langchain/search.py | 33 +++++++++++++------------------- tests/test_schema.py | 24 ++++++++++++++++++++--- tests/test_search.py | 38 +++++++++++++++++++++++++++++++++++++ 7 files changed, 127 insertions(+), 37 deletions(-) create mode 100644 hotdata_langchain/_sql.py diff --git a/README.md b/README.md index 6a6adb0..e1b1ff2 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,15 @@ and description — the agent then routes on the descriptions: ```python tools = [ - *hl.make_hotdata_tools(client, database="sf_airbnb"), - hl.make_hotdata_search_tool( - client, table="default.public.listings", column="description", - name="search_listings", database="sf_airbnb", + # Configure the first corpus here, so the SQL tool's description still names a search + # tool to defer text matching to. Passing no search_table/search_column drops that, + # and the agent goes back to trying to match text in SQL. + *hl.make_hotdata_tools( + client, + database="sf_airbnb", + search_table="default.public.listings", + search_column="description", + search_tool_name="search_listings", ), hl.make_hotdata_search_tool( client, table="default.public.reviews", column="comments", diff --git a/demo/bm25_search_demo.py b/demo/bm25_search_demo.py index 0f6b942..f12fe8c 100644 --- a/demo/bm25_search_demo.py +++ b/demo/bm25_search_demo.py @@ -56,6 +56,8 @@ "openai": "OPENAI_API_KEY", } INDEX_TIMEOUT_SECONDS = 600 +#: Row budget for the SQL tool, separate from the search tool's `k`. +SQL_MAX_ROWS = 100 # Deliberately not answerable from search results alone: the ratings and counts span # every listing in the matched neighbourhoods, not just the handful search returned. @@ -109,8 +111,11 @@ def load_listings(client: hl.HotdataClient, parquet: Path) -> None: def table_columns(client: hl.HotdataClient) -> list[str]: - result = client.execute_sql(f"SELECT * FROM {TABLE_REF} LIMIT 1", database=DATABASE) - return list(result.columns) + """Return the table's column names, through the same tool the agent gets.""" + described = json.loads( + hl.describe_tables_json(client, table=f"{SCHEMA}.{TABLE}", database=DATABASE) + ) + return [column["name"] for column in described["columns"]] def ensure_bm25_index(client: hl.HotdataClient, connection_id: str) -> None: @@ -297,7 +302,10 @@ def main() -> None: tools = hl.make_hotdata_tools( client, database=DATABASE, - max_rows=args.k, + # Not args.k: max_rows also caps the SQL tool, and the agent's aggregate in + # step 6 groups over whole neighbourhoods, which a search-sized budget would + # silently truncate. Search hits and SQL rows are different budgets. + max_rows=SQL_MAX_ROWS, search_table=TABLE_REF, search_column=SEARCH_COLUMN, search_columns=columns, diff --git a/hotdata_langchain/_sql.py b/hotdata_langchain/_sql.py new file mode 100644 index 0000000..ecc64a6 --- /dev/null +++ b/hotdata_langchain/_sql.py @@ -0,0 +1,25 @@ +"""Shared SQL identifier and literal handling. + +These sit between model-authored strings and a SQL literal, so they live in one place +rather than being reimplemented per module where the two copies could drift apart. +""" + +from __future__ import annotations + +import re + +IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def validate_identifier(value: str, *, label: str) -> str: + """Return ``value`` if it is a bare SQL identifier, else raise ``ValueError``.""" + if not IDENTIFIER_RE.fullmatch(value): + raise ValueError(f"{label} must be a bare SQL identifier, got {value!r}") + return value + + +def quote_literal(value: str) -> str: + """Return ``value`` as a single-quoted SQL string literal, with quotes doubled.""" + if "\x00" in value: + raise ValueError("SQL string literals may not contain null bytes") + return "'" + value.replace("'", "''") + "'" diff --git a/hotdata_langchain/schema.py b/hotdata_langchain/schema.py index 9235e87..7ff4fc5 100644 --- a/hotdata_langchain/schema.py +++ b/hotdata_langchain/schema.py @@ -3,18 +3,17 @@ from __future__ import annotations import json -import re from hotdata_framework import HotdataClient from langchain_core.tools import StructuredTool +from hotdata_langchain._sql import validate_identifier + DEFAULT_DESCRIBE_TOOL_NAME = "hotdata_describe_tables" #: Cap on columns returned for a single table, so one wide table cannot flood the context. DEFAULT_MAX_COLUMNS = 200 -_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") - def _split_table(table: str) -> tuple[str | None, str]: """Split ``schema.table`` or a bare ``table`` into its parts, validating both.""" @@ -25,8 +24,7 @@ def _split_table(table: str) -> tuple[str | None, str]: f"got {table!r}" ) for part in parts: - if not _IDENTIFIER_RE.fullmatch(part): - raise ValueError(f"table must be made of bare SQL identifiers, got {table!r}") + validate_identifier(part, label="table") return (parts[0], parts[1]) if len(parts) == 2 else (None, parts[0]) @@ -79,18 +77,23 @@ def describe_tables_json( ] return json.dumps({"tables": tables}, indent=2) - result = client.execute_sql(table_columns_sql(table, limit=max_columns), database=database) + # One row past the cap, so a table with exactly `max_columns` columns is reported as + # complete rather than flagged as truncated — telling the model part of the schema is + # missing is the one thing likely to send it back to guessing. + result = client.execute_sql(table_columns_sql(table, limit=max_columns + 1), database=database) records = result.to_records() if not records: return json.dumps( {"table": table, "columns": [], "error": f"no table named {table!r} in this database"}, indent=2, ) + truncated = len(records) > max_columns + records = records[:max_columns] payload: dict[str, object] = { "table": f"{records[0]['table_schema']}.{records[0]['table_name']}", "columns": [{"name": r["column_name"], "type": r["data_type"]} for r in records], } - if len(records) == max_columns: + if truncated: payload["truncated_at"] = max_columns return json.dumps(payload, indent=2) diff --git a/hotdata_langchain/search.py b/hotdata_langchain/search.py index 8352080..af798ed 100644 --- a/hotdata_langchain/search.py +++ b/hotdata_langchain/search.py @@ -9,6 +9,8 @@ from hotdata_framework import HotdataClient from langchain_core.tools import StructuredTool +from hotdata_langchain._sql import quote_literal, validate_identifier + #: Column the engine appends to every ``bm25_search`` result, holding the BM25 relevance score. SCORE_COLUMN = "score" @@ -17,7 +19,6 @@ DEFAULT_SEARCH_TOOL_NAME = "hotdata_search_text" -_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") _TABLE_REF_RE = re.compile( r"[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*" ) @@ -33,26 +34,12 @@ def _validate_table_ref(table: str) -> str: return table -def _validate_identifier(value: str, *, label: str) -> str: - """Return ``value`` if it is a bare SQL identifier, else raise.""" - if not _IDENTIFIER_RE.fullmatch(value): - raise ValueError(f"{label} must be a bare SQL identifier, got {value!r}") - return value - - -def _quote_literal(value: str) -> str: - """Return ``value`` as a single-quoted SQL string literal with quotes doubled.""" - if "\x00" in value: - raise ValueError("search text may not contain null bytes") - return "'" + value.replace("'", "''") + "'" - - def _projection(column: str, columns: Sequence[str] | None) -> list[str]: selected = list(columns) if columns is not None else [column] if not selected: raise ValueError("columns must not be empty") for name in selected: - _validate_identifier(name, label="column") + validate_identifier(name, label="column") return [*(name for name in selected if name != SCORE_COLUMN), SCORE_COLUMN] @@ -85,7 +72,7 @@ def bm25_search_sql( non-positive ``k``, and for search text containing null bytes. """ _validate_table_ref(table) - _validate_identifier(column, label="column") + validate_identifier(column, label="column") if k < 1: raise ValueError(f"k must be >= 1, got {k}") @@ -93,7 +80,7 @@ def bm25_search_sql( return ( f"SELECT {', '.join(projection)} " f"FROM bm25_search(" - f"{_quote_literal(table)}, {_quote_literal(column)}, {_quote_literal(query)}, {k}) " + f"{quote_literal(table)}, {quote_literal(column)}, {quote_literal(query)}, {k}) " f"ORDER BY {SCORE_COLUMN} DESC " f"LIMIT {k}" ) @@ -166,11 +153,17 @@ def make_hotdata_search_tool( Register the factory more than once, with distinct ``name`` and ``description`` values, to expose several searchable corpora; the agent then routes on the descriptions. + + A ``k`` the model supplies is clamped to ``max_rows``, since anything above it would + have the engine rank and ship rows that are then discarded before the model sees + them. The caller's own ``k`` is trusted and left alone. """ _validate_table_ref(table) - _validate_identifier(column, label="column") + validate_identifier(column, label="column") if k < 1: raise ValueError(f"k must be >= 1, got {k}") + if max_rows < 1: + raise ValueError(f"max_rows must be >= 1, got {max_rows}") if columns is not None: _projection(column, columns) default_k = k @@ -182,7 +175,7 @@ def hotdata_search_text(query: str, k: int | None = None) -> str: table=table, column=column, query=query, - k=default_k if k is None else k, + k=default_k if k is None else max(1, min(k, max_rows)), columns=columns, max_rows=max_rows, database=database, diff --git a/tests/test_schema.py b/tests/test_schema.py index f563897..2b79876 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -110,11 +110,29 @@ def test_describe_with_table_lists_columns_and_types() -> None: assert "truncated_at" not in payload -def test_describe_reports_truncation_at_the_cap() -> None: +def test_describe_reports_truncation_only_when_rows_exceed_the_cap() -> None: + """A table with exactly max_columns columns is complete, not truncated.""" client = MagicMock() - client.execute_sql.return_value = COLUMNS + client.execute_sql.return_value = COLUMNS # two column rows payload = json.loads(describe_tables_json(client, table="listings", max_columns=2)) - assert payload["truncated_at"] == 2 + assert len(payload["columns"]) == 2 + assert "truncated_at" not in payload + + +def test_describe_reports_truncation_and_trims_to_the_cap() -> None: + client = MagicMock() + client.execute_sql.return_value = COLUMNS # two column rows, cap of one + payload = json.loads(describe_tables_json(client, table="listings", max_columns=1)) + assert payload["truncated_at"] == 1 + assert [c["name"] for c in payload["columns"]] == ["id"] + + +def test_describe_queries_one_row_past_the_cap() -> None: + """Distinguishing exact-fit from truncated needs the extra row.""" + client = MagicMock() + client.execute_sql.return_value = COLUMNS + describe_tables_json(client, table="listings", max_columns=25) + assert executed_sql(client).endswith("LIMIT 26") def test_describe_reports_an_unknown_table_rather_than_empty_success() -> None: diff --git a/tests/test_search.py b/tests/test_search.py index 7113d8d..1186010 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -254,6 +254,44 @@ def test_search_tool_lets_agent_override_k( assert sql.endswith("LIMIT 10") +def test_search_tool_clamps_a_model_supplied_k_to_max_rows( + mock_client: MagicMock, search_result: QueryResult +) -> None: + """Above max_rows the engine would rank rows that are discarded before the model sees them.""" + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, max_rows=10) + tool.invoke({"query": QUERY, "k": 100_000}) + sql = executed_sql(mock_client) + match = _SEARCH_CALL_RE.search(sql) + assert match is not None + assert match.group(1) == "10" + assert sql.endswith("LIMIT 10") + + +def test_search_tool_leaves_a_caller_supplied_k_alone( + mock_client: MagicMock, search_result: QueryResult +) -> None: + """The caller is trusted; only the model's k is clamped.""" + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, k=50, max_rows=10) + tool.invoke({"query": QUERY}) + assert executed_sql(mock_client).endswith("LIMIT 50") + + +def test_search_tool_keeps_a_clamped_k_positive( + mock_client: MagicMock, search_result: QueryResult +) -> None: + mock_client.execute_sql.return_value = search_result + tool = make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN) + tool.invoke({"query": QUERY, "k": 0}) + assert executed_sql(mock_client).endswith("LIMIT 1") + + +def test_search_tool_rejects_a_non_positive_max_rows(mock_client: MagicMock) -> None: + with pytest.raises(ValueError, match="max_rows must be >= 1"): + make_hotdata_search_tool(mock_client, table=TABLE, column=COLUMN, max_rows=0) + + def test_search_tool_validates_corpus_at_construction(mock_client: MagicMock) -> None: with pytest.raises(ValueError, match=r"catalog\.schema\.table"): make_hotdata_search_tool(mock_client, table="listings", column=COLUMN) From 3796c8fdc68afbc6030c05fc76a0e29ecf573af3 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Mon, 27 Jul 2026 19:26:34 +0530 Subject: [PATCH 4/4] fix: guard max_columns, and re-measure the demo on the fixed row budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact-fit fix in the previous commit introduced a crash: reading one row past the cap means `max_columns=0` queries `LIMIT 1`, gets a row, passes the empty-result guard, slices the list to empty and then indexes it — `IndexError`. Before that change the same call emitted `LIMIT 0` and fell into the "no table named" branch, which was wrong but did not raise. `max_columns` is now validated in both `describe_tables_json` and the tool factory, mirroring how `make_hotdata_search_tool` already guards `max_rows`. demo/README.md's five-run figures were gathered before the search and SQL row budgets were decoupled, so they described a configuration that no longer exists. Re-measured against the current one: the search and schema tools are called in 5/5 runs and the aggregate matches the whole-neighbourhood figures in 5/5, up from 4/5. Two runs recovered from a bad query after the error was fed back. The text now reports those numbers and is explicit that five runs are not a guarantee, since answering a compound question is a property of the model. The unbounded table overview raised in the same review is recorded on #40 rather than fixed here: the no-argument path returns a row per table with no cap, while the per-table path caps columns. --- demo/README.md | 17 ++++++++++------- hotdata_langchain/schema.py | 11 ++++++++++- tests/test_schema.py | 13 +++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/demo/README.md b/demo/README.md index cfdc5f1..4770417 100644 --- a/demo/README.md +++ b/demo/README.md @@ -83,13 +83,16 @@ and SQL are both inspectable after the fact. Set the model with `--model` or `DEMO_MODEL`; any tool-calling model works, and the step is skipped when its provider key is absent. - **What this step reliably demonstrates is the routing**, not the arithmetic. Across - five runs the agent called the search tool every time and reached for the schema tool - in four, which is the behaviour the demo exists to show. The final table was right in - four of five — one run aggregated over the handful of matched listings instead of all - listings in those neighbourhoods. Answering a compound question correctly is a property - of the model, not of these tools, so read the printed tool calls as the result and treat - the prose answer as illustrative. + **What this step demonstrates is the routing.** Across five runs of the current + configuration the agent called the search tool and the schema tool every time, and the + final table matched the whole-neighbourhood figures every time. Two of those runs also + recovered from a bad query after the error was handed back to them. + + Do not read five runs as a guarantee. Answering a compound question correctly is a + property of the model rather than of these tools, and an earlier configuration — which + shared one row budget between the search and SQL tools, silently truncating the + aggregate — got it right in only four of five. The printed tool calls are the reliable + part; treat the prose answer as illustrative. ## What makes the agent run work diff --git a/hotdata_langchain/schema.py b/hotdata_langchain/schema.py index 7ff4fc5..3302bd6 100644 --- a/hotdata_langchain/schema.py +++ b/hotdata_langchain/schema.py @@ -65,7 +65,11 @@ def describe_tables_json( what exists. With ``table`` it returns that table's columns and types in declaration order, capped at ``max_columns`` so a wide table cannot flood the model's context; the payload says so when the cap truncated the list. + + Raises ``ValueError`` for a non-positive ``max_columns``. """ + if max_columns < 1: + raise ValueError(f"max_columns must be >= 1, got {max_columns}") if table is None: result = client.execute_sql(table_overview_sql(), database=database) tables = [ @@ -118,7 +122,12 @@ def make_hotdata_describe_tables_tool( description: str | None = None, max_columns: int = DEFAULT_MAX_COLUMNS, ) -> StructuredTool: - """Return a LangChain tool that reports the scoped database's tables and columns.""" + """Return a LangChain tool that reports the scoped database's tables and columns. + + Fails fast on a non-positive ``max_columns`` rather than at first invocation. + """ + if max_columns < 1: + raise ValueError(f"max_columns must be >= 1, got {max_columns}") def hotdata_describe_tables(table: str | None = None) -> str: """List the tables in the database, or one table's columns and types.""" diff --git a/tests/test_schema.py b/tests/test_schema.py index 2b79876..c312ad8 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -127,6 +127,19 @@ def test_describe_reports_truncation_and_trims_to_the_cap() -> None: assert [c["name"] for c in payload["columns"]] == ["id"] +def test_describe_rejects_a_non_positive_max_columns() -> None: + """Reading one row past the cap makes a zero cap slice to empty and then index it.""" + client = MagicMock() + client.execute_sql.return_value = COLUMNS + with pytest.raises(ValueError, match="max_columns must be >= 1"): + describe_tables_json(client, table="listings", max_columns=0) + + +def test_describe_tool_rejects_a_non_positive_max_columns() -> None: + with pytest.raises(ValueError, match="max_columns must be >= 1"): + make_hotdata_describe_tables_tool(MagicMock(), max_columns=0) + + def test_describe_queries_one_row_past_the_cap() -> None: """Distinguishing exact-fit from truncated needs the extra row.""" client = MagicMock()