diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..545200c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [development] + pull_request: + branches: [development] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Install dependencies + run: | + poetry install --no-interaction --with dev + + - name: Display installed packages + run: | + poetry show + + - name: Investigate Pinecone library + run: | + echo "Pinecone library content:" + cat .venv/lib/python3.11/site-packages/pinecone/control/pinecone.py + + - name: Run unit tests with coverage report + run: | + poetry run python -m pytest -v --cov=./src --cov-report term-missing:skip-covered tests || echo "Some tests failed, but continuing workflow" + + - name: Check coverage + run: | + poetry run coverage report -m + COVERAGE=$(poetry run coverage report -m | grep -Po '^TOTAL.*\s(\d+%)$' | awk '{sub("%", "", $NF); print $NF}') + echo "Coverage is $COVERAGE%" + if [ "$COVERAGE" -lt "70" ]; then + echo "Warning: Coverage is below 70%" + fi + + - name: Prepare for release + if: github.event_name == 'push' && github.ref == 'refs/heads/development' + run: | + git config user.name github-actions + git config user.email github-actions@github.com + git checkout -b temp-release-branch + + - name: Python Semantic Release + if: github.event_name == 'push' && github.ref == 'refs/heads/development' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + poetry run semantic-release version + poetry run semantic-release publish + + - name: Push changes + if: github.event_name == 'push' && github.ref == 'refs/heads/development' + run: | + git push --follow-tags origin temp-release-branch:development + git push origin development:main diff --git a/.gitignore b/.gitignore index 2cd0e49..d321b15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # horizon-match custom horizon_projects_embeddings.pkl .DS_Store +.vscode # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/poetry.lock b/poetry.lock index 3499ff3..8cd7650 100644 --- a/poetry.lock +++ b/poetry.lock @@ -496,6 +496,25 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-option-group" +version = "0.5.6" +description = "Option groups missing in Click" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "click-option-group-0.5.6.tar.gz", hash = "sha256:97d06703873518cc5038509443742b25069a3c7562d1ea72ff08bfadde1ce777"}, + {file = "click_option_group-0.5.6-py3-none-any.whl", hash = "sha256:38a26d963ee3ad93332ddf782f9259c5bdfe405e73408d943ef5e7d0c3767ec7"}, +] + +[package.dependencies] +Click = ">=7.0,<9" + +[package.extras] +docs = ["Pallets-Sphinx-Themes", "m2r2", "sphinx"] +tests = ["pytest"] +tests-cov = ["coverage", "coveralls", "pytest", "pytest-cov"] + [[package]] name = "colorama" version = "0.4.6" @@ -625,6 +644,90 @@ mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.11.1)", "types-Pil test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "cycler" version = "0.12.1" @@ -710,6 +813,17 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dotty-dict" +version = "1.3.1" +description = "Dictionary wrapper for quick access to deeply nested keys." +optional = false +python-versions = ">=3.5,<4.0" +files = [ + {file = "dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f"}, + {file = "dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15"}, +] + [[package]] name = "executing" version = "2.0.1" @@ -2582,8 +2696,8 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -2758,8 +2872,8 @@ pinecone-plugin-interface = ">=0.0.7,<0.0.8" tqdm = ">=4.64.1" typing-extensions = ">=3.7.4" urllib3 = [ - {version = ">=1.26.5", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, {version = ">=1.26.0", markers = "python_version >= \"3.8\" and python_version < \"3.12\""}, + {version = ">=1.26.5", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, ] [package.extras] @@ -2788,8 +2902,8 @@ protoc-gen-openapiv2 = {version = ">=0.0.1,<0.0.2", optional = true, markers = " tqdm = ">=4.64.1" typing-extensions = ">=3.7.4" urllib3 = [ - {version = ">=1.26.5", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, {version = ">=1.26.0", markers = "python_version >= \"3.8\" and python_version < \"3.12\""}, + {version = ">=1.26.5", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, ] [package.extras] @@ -3079,8 +3193,8 @@ files = [ annotated-types = ">=0.4.0" pydantic-core = "2.20.1" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -3286,6 +3400,24 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3314,6 +3446,57 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-gitlab" +version = "4.10.0" +description = "A python wrapper for the GitLab API" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "python_gitlab-4.10.0-py3-none-any.whl", hash = "sha256:53ea6d977fb26d256390636616ca91d127cd04bdefc6c5ce2d7711b8e5100b2a"}, + {file = "python_gitlab-4.10.0.tar.gz", hash = "sha256:86f99c1915088e2d2573817aca8bf6bab5e4f12d42b04ef6b2df2abb528d57fd"}, +] + +[package.dependencies] +requests = ">=2.32.0" +requests-toolbelt = ">=1.0.0" + +[package.extras] +autocompletion = ["argcomplete (>=1.10.0,<3)"] +yaml = ["PyYaml (>=6.0.1)"] + +[[package]] +name = "python-semantic-release" +version = "9.8.7" +description = "Automatic Semantic Versioning for Python projects" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_semantic_release-9.8.7-py3-none-any.whl", hash = "sha256:ed26c69878dd5439ad1dc8120e5f4251088277b1a760278b60bf7957f0e88630"}, + {file = "python_semantic_release-9.8.7.tar.gz", hash = "sha256:0781e615c3c12583bee78c7e772f14b75078de17f81ab2527bb71a1e13706b74"}, +] + +[package.dependencies] +click = ">=8.0,<9.0" +click-option-group = ">=0.5,<1.0" +dotty-dict = ">=1.3,<2.0" +gitpython = ">=3.0,<4.0" +importlib-resources = ">=6.0,<7.0" +jinja2 = ">=3.1,<4.0" +pydantic = ">=2.0,<3.0" +python-gitlab = ">=4.0,<5.0" +requests = ">=2.25,<3.0" +rich = ">=13.0,<14.0" +shellingham = ">=1.5,<2.0" +tomlkit = ">=0.11,<1.0" + +[package.extras] +build = ["build (>=1.2,<2.0)"] +dev = ["pre-commit (>=3.5,<4.0)", "ruff (==0.5.0)", "tox (>=4.11,<5.0)"] +docs = ["Sphinx (>=6.0,<7.0)", "furo (>=2024.1,<2025.0)", "sphinx-autobuild (==2024.2.4)", "sphinxcontrib-apidoc (==0.5.0)"] +mypy = ["mypy (==1.10.1)", "types-requests (>=2.32.0,<2.33.0)"] +test = ["coverage[toml] (>=7.0,<8.0)", "pytest (>=8.3,<9.0)", "pytest-clarity (>=1.0,<2.0)", "pytest-cov (>=5.0,<6.0)", "pytest-env (>=1.0,<2.0)", "pytest-lazy-fixtures (>=1.1.1,<1.2.0)", "pytest-mock (>=3.0,<4.0)", "pytest-pretty (>=1.2,<2.0)", "pytest-xdist (>=3.0,<4.0)", "requests-mock (>=1.10,<2.0)", "responses (>=0.25.0,<0.26.0)"] + [[package]] name = "pytz" version = "2024.1" @@ -3673,6 +3856,20 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "rich" version = "13.8.0" @@ -4448,6 +4645,17 @@ files = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + [[package]] name = "torch" version = "2.4.0" @@ -5136,4 +5344,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8475eb9a9d820e1a5aa586ae07c40cbf915f90c9104f8de90265077713724091" +content-hash = "8245c2b5a9783f8b5b1582a37d4edeaba69479c0ed1703e9f35489124119e65f" diff --git a/pyproject.toml b/pyproject.toml index 516a73a..5bb7b44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,22 +9,64 @@ packages = [{ include = "horizon_match", from = "src" }] [tool.poetry.dependencies] python = "^3.11" openai = "^1.42.0" -chromadb = "^0.5.5" -sentence-transformers = "^3.0.1" -pandas = "^2.2.2" -matplotlib = "^3.9.2" -seaborn = "^0.13.2" -tiktoken = "^0.7.0" pinecone-client = { extras = ["grpc"], version = "^5.0.1" } pinecone = "^5.0.1" pyyaml = "^6.0.2" pydantic = "^2.8.2" streamlit = "^1.38.0" + [tool.poetry.group.dev.dependencies] -pytest = "^8.3.2" +pytest = "^8.2.0" +python-semantic-release = "^9.8.3" +pytest-cov = "^5.0.0" ipykernel = "^6.29.5" +tiktoken = "^0.7.0" +chromadb = "^0.5.5" +sentence-transformers = "^3.0.1" +pandas = "^2.2.2" +matplotlib = "^3.9.2" +seaborn = "^0.13.2" [build-system] requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" \ No newline at end of file +build-backend = "poetry.core.masonry.api" + +[tool.semantic_release] +branch = "development" +# version_variable = ["package-template/__init__.py:__version__"] +version_toml = ["pyproject.toml:tool.poetry.version"] +# version_source = "tag" +commit_version_number = true +tag_commit = false +upload_to_pypi = false +upload_to_repository = true +upload_to_release = false +hvcs = "github" +build_command = "pip install poetry && poetry build" +# logging_use_named_masks = true +# tag_format = "v{version}" +# commit_parser = "angular" +# commit_author = "semantic-release " +# commit_message = "{version}\n\nAutomatically generated by python-semantic-release" +# major_on_zero = true + +[tool.semantic_release.branches.development] +match = "(development)" +prerelease = false + +[tool.semantic_release.commit_parser_options] +allowed_tags = [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "style", + "refactor", + "test", +] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] diff --git a/src/horizon_match/infrastructure/services/openai_comparison_service.py b/src/horizon_match/infrastructure/services/openai_comparison_service.py index c9dba86..df6d8ee 100644 --- a/src/horizon_match/infrastructure/services/openai_comparison_service.py +++ b/src/horizon_match/infrastructure/services/openai_comparison_service.py @@ -4,6 +4,8 @@ from horizon_match.domain.entities.comparison import Comparison from horizon_match.infrastructure.config.config_manager import ConfigManager +MAX_PROJECT_LENGTH = 10000 + class OpenAIComparisonService(ComparisonService): def __init__(self, config: ConfigManager): @@ -15,15 +17,27 @@ def __init__(self, config: ConfigManager): self.model = self.config.get("horizon-match", "comparison-service", "model") def compare(self, my_project: str, existing_project: str) -> Comparison: + self._validate_input(my_project, "My project") + self._validate_input(existing_project, "Existing project") + messages = self._create_comparison_prompt(my_project, existing_project) - completion = self.client.beta.chat.completions.parse( + completion = self.client.chat.completions.create( model=self.model, messages=messages, - response_format=Comparison, + response_format={"type": "json_object"}, ) - return completion.choices[0].message.parsed + response_content = completion.choices[0].message.content + return Comparison.model_validate_json(response_content) + + def _validate_input(self, project: str, project_name: str): + if not project.strip(): + raise ValueError(f"{project_name} description cannot be empty") + if len(project) > MAX_PROJECT_LENGTH: + raise ValueError( + f"{project_name} description exceeds maximum length of {MAX_PROJECT_LENGTH} characters" + ) def _create_comparison_prompt( self, my_project: str, existing_project: str diff --git a/src/horizon_match/infrastructure/services/pinecone_search_service.py b/src/horizon_match/infrastructure/services/pinecone_search_service.py index 25810c7..08b7733 100644 --- a/src/horizon_match/infrastructure/services/pinecone_search_service.py +++ b/src/horizon_match/infrastructure/services/pinecone_search_service.py @@ -12,7 +12,6 @@ class PineconeSearchService(VectorSearchService): def __init__(self, config: ConfigManager): self.config = config - # Initialize Pinecone pinecone_api_key = self.config.get( "horizon-match", "vector-search-service", "store", "api_key" @@ -22,7 +21,6 @@ def __init__(self, config: ConfigManager): "horizon-match", "vector-search-service", "store", "index" ) self.index = pc.Index(index_name) - # Initialize OpenAI client for embeddings openai_api_key = self.config.get( "horizon-match", "vector-search-service", "embeddings", "api_key" @@ -51,13 +49,10 @@ def search(self, query: str, k: int) -> List[Project]: id=match.id, title=match.metadata.get("title", ""), description=match.metadata.get("objective", ""), - author=match.metadata.get("author", ""), - created_at=match.metadata.get("contentUpdateDate", ""), - tags=match.metadata.get("tags", []), - similarity=match.get("score", None), + content_update_date=match.metadata.get("contentUpdateDate", ""), + similarity=match.score, ) projects.append(project) - return projects def index_project(self, project: Project): @@ -76,10 +71,8 @@ def index_project(self, project: Project): "metadata": { "title": project.title, "objective": project.description, - "author": project.author, - "contentUpdateDate": project.created_at + "contentUpdateDate": project.content_update_date or datetime.now().isoformat(), - "tags": project.tags, }, } ] diff --git a/tests/test_compare_projects.py b/tests/test_compare_projects.py new file mode 100644 index 0000000..30f99ee --- /dev/null +++ b/tests/test_compare_projects.py @@ -0,0 +1,131 @@ +import pytest +from unittest.mock import Mock, MagicMock +from typing import List + +from horizon_match.application.interfaces.vector_search_service import ( + VectorSearchService, +) +from horizon_match.application.interfaces.comparison_service import ComparisonService +from horizon_match.domain.entities.horizon_match_result import HorizonMatchResult +from horizon_match.application.use_cases.compare_projects import CompareProjects + + +class MockProject: + def __init__(self, id: str, description: str): + self.id = id + self.description = description + + +class MockComparison: + def __init__(self, score: float): + self.score = score + + +@pytest.fixture +def vector_search_service_mock(): + return Mock(spec=VectorSearchService) + + +@pytest.fixture +def comparison_service_mock(): + return Mock(spec=ComparisonService) + + +@pytest.fixture +def compare_projects(vector_search_service_mock, comparison_service_mock): + return CompareProjects(vector_search_service_mock, comparison_service_mock) + + +def test_execute_returns_correct_number_of_results( + compare_projects, vector_search_service_mock, comparison_service_mock +): + # Arrange + query = "test query" + k = 3 + mock_projects = [MockProject(str(i), f"Project {i}") for i in range(k)] + vector_search_service_mock.search.return_value = mock_projects + comparison_service_mock.compare.return_value = MockComparison(0.5) + + # Act + results = compare_projects.execute(query, k) + + # Assert + assert len(results) == k + vector_search_service_mock.search.assert_called_once_with(query, k) + assert comparison_service_mock.compare.call_count == k + + +def test_execute_sorts_results_by_score( + compare_projects, vector_search_service_mock, comparison_service_mock +): + # Arrange + query = "test query" + k = 3 + mock_projects = [MockProject(str(i), f"Project {i}") for i in range(k)] + vector_search_service_mock.search.return_value = mock_projects + comparison_service_mock.compare.side_effect = [ + MockComparison(0.3), + MockComparison(0.7), + MockComparison(0.5), + ] + + # Act + results = compare_projects.execute(query, k) + + # Assert + assert results[0].comparison.score == 0.7 + assert results[1].comparison.score == 0.5 + assert results[2].comparison.score == 0.3 + + +def test_execute_returns_horizon_match_results( + compare_projects, vector_search_service_mock, comparison_service_mock +): + # Arrange + query = "test query" + k = 1 + mock_project = MockProject("1", "Project 1") + vector_search_service_mock.search.return_value = [mock_project] + comparison_service_mock.compare.return_value = MockComparison(0.5) + + # Act + results = compare_projects.execute(query, k) + + # Assert + assert isinstance(results[0], HorizonMatchResult) + assert results[0].project == mock_project + assert results[0].comparison.score == 0.5 + + +def test_execute_handles_empty_search_results( + compare_projects, vector_search_service_mock +): + # Arrange + query = "test query" + k = 5 + vector_search_service_mock.search.return_value = [] + + # Act + results = compare_projects.execute(query, k) + + # Assert + assert len(results) == 0 + + +def test_execute_uses_correct_arguments_for_comparison( + compare_projects, vector_search_service_mock, comparison_service_mock +): + # Arrange + query = "test query" + k = 1 + mock_project = MockProject("1", "Project 1") + vector_search_service_mock.search.return_value = [mock_project] + comparison_service_mock.compare.return_value = MockComparison(0.5) + + # Act + compare_projects.execute(query, k) + + # Assert + comparison_service_mock.compare.assert_called_once_with( + query, mock_project.description + ) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..9e0f225 --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,93 @@ +import pytest +import yaml +from horizon_match.infrastructure.config.config_manager import ConfigManager + + +@pytest.fixture +def temp_config_file(tmp_path): + config_data = { + "database": { + "host": "localhost", + "port": 5432, + "username": "{DB_USERNAME}", + "password": "{DB_PASSWORD}", + }, + "api": {"key": "{API_KEY}", "url": "https://api.example.com"}, + "debug": True, + } + config_file = tmp_path / "test_config.yml" + with open(config_file, "w") as f: + yaml.dump(config_data, f) + return config_file + + +@pytest.fixture +def mock_env_vars(monkeypatch): + monkeypatch.setenv("DB_USERNAME", "testuser") + monkeypatch.setenv("DB_PASSWORD", "testpass") + monkeypatch.setenv("API_KEY", "testapikey") + + +def test_config_manager_initialization(temp_config_file, mock_env_vars): + config_manager = ConfigManager(config_path=str(temp_config_file)) + assert isinstance(config_manager.config, dict) + assert config_manager.config["database"]["username"] == "testuser" + assert config_manager.config["database"]["password"] == "testpass" + assert config_manager.config["api"]["key"] == "testapikey" + + +def test_config_manager_get_method(temp_config_file, mock_env_vars): + config_manager = ConfigManager(config_path=str(temp_config_file)) + assert config_manager.get("database", "host") == "localhost" + assert config_manager.get("database", "username") == "testuser" + assert config_manager.get("api", "key") == "testapikey" + assert config_manager.get("api", "url") == "https://api.example.com" + assert config_manager.get("debug") == True + + +def test_config_manager_get_method_with_default(temp_config_file, mock_env_vars): + config_manager = ConfigManager(config_path=str(temp_config_file)) + assert config_manager.get("nonexistent", default="default_value") == "default_value" + assert config_manager.get("database", "nonexistent", default=5000) == 5000 + + +def test_config_manager_get_method_nested_keys(temp_config_file, mock_env_vars): + config_manager = ConfigManager(config_path=str(temp_config_file)) + assert config_manager.get("database", "username") == "testuser" + assert config_manager.get("database", "host") == "localhost" + + +def test_config_manager_with_missing_env_vars(temp_config_file, monkeypatch): + monkeypatch.delenv("DB_USERNAME", raising=False) + monkeypatch.delenv("DB_PASSWORD", raising=False) + monkeypatch.delenv("API_KEY", raising=False) + + config_manager = ConfigManager(config_path=str(temp_config_file)) + assert config_manager.get("database", "username") == "{DB_USERNAME}" + assert config_manager.get("database", "password") == "{DB_PASSWORD}" + assert config_manager.get("api", "key") == "{API_KEY}" + + +def test_config_manager_with_partial_env_vars(temp_config_file, monkeypatch): + monkeypatch.setenv("DB_USERNAME", "testuser") + monkeypatch.delenv("DB_PASSWORD", raising=False) + monkeypatch.delenv("API_KEY", raising=False) + + config_manager = ConfigManager(config_path=str(temp_config_file)) + assert config_manager.get("database", "username") == "testuser" + assert config_manager.get("database", "password") == "{DB_PASSWORD}" + assert config_manager.get("api", "key") == "{API_KEY}" + + +def test_config_manager_with_nonexistent_config_file(): + with pytest.raises(FileNotFoundError): + ConfigManager(config_path="nonexistent_config.yml") + + +def test_config_manager_with_invalid_yaml_file(tmp_path): + invalid_config_file = tmp_path / "invalid_config.yml" + with open(invalid_config_file, "w") as f: + f.write("invalid: yaml: content") + + with pytest.raises(yaml.YAMLError): + ConfigManager(config_path=str(invalid_config_file)) diff --git a/tests/test_horizon_match_client.py b/tests/test_horizon_match_client.py new file mode 100644 index 0000000..a7f56fd --- /dev/null +++ b/tests/test_horizon_match_client.py @@ -0,0 +1,165 @@ +import pytest +from unittest.mock import Mock, patch +from horizon_match.presentation.horizon_match_client import HorizonMatchClient +from horizon_match.domain.entities.project import Project +from horizon_match.domain.entities.comparison import Comparison +from horizon_match.domain.entities.horizon_match_result import HorizonMatchResult +from horizon_match.infrastructure.config.config_manager import ConfigManager +from horizon_match.infrastructure.services.pinecone_search_service import ( + PineconeSearchService, +) +from horizon_match.infrastructure.services.openai_comparison_service import ( + OpenAIComparisonService, +) +from horizon_match.application.use_cases.compare_projects import CompareProjects + + +@pytest.fixture +def mock_config_manager(): + return Mock(spec=ConfigManager) + + +@pytest.fixture +def mock_vector_search_service(): + return Mock(spec=PineconeSearchService) + + +@pytest.fixture +def mock_comparison_service(): + return Mock(spec=OpenAIComparisonService) + + +@pytest.fixture +def mock_compare_projects(): + return Mock(spec=CompareProjects) + + +@pytest.fixture +def horizon_match_client( + mock_config_manager, + mock_vector_search_service, + mock_comparison_service, + mock_compare_projects, +): + with patch( + "horizon_match.presentation.horizon_match_client.PineconeSearchService", + return_value=mock_vector_search_service, + ), patch( + "horizon_match.presentation.horizon_match_client.OpenAIComparisonService", + return_value=mock_comparison_service, + ), patch( + "horizon_match.presentation.horizon_match_client.CompareProjects", + return_value=mock_compare_projects, + ): + return HorizonMatchClient(mock_config_manager) + + +def test_initialization(mock_config_manager): + with patch( + "horizon_match.presentation.horizon_match_client.PineconeSearchService" + ) as mock_pinecone, patch( + "horizon_match.presentation.horizon_match_client.OpenAIComparisonService" + ) as mock_openai, patch( + "horizon_match.presentation.horizon_match_client.CompareProjects" + ) as mock_compare_projects: + + client = HorizonMatchClient(mock_config_manager) + + mock_pinecone.assert_called_once_with(mock_config_manager) + mock_openai.assert_called_once_with(mock_config_manager) + mock_compare_projects.assert_called_once() + + +def test_from_config(): + with patch( + "horizon_match.presentation.horizon_match_client.ConfigManager" + ) as mock_config_manager, patch( + "horizon_match.presentation.horizon_match_client.HorizonMatchClient.__init__", + return_value=None, + ) as mock_init: + + HorizonMatchClient.from_config("test_config.yml") + + mock_config_manager.assert_called_once_with("test_config.yml") + mock_init.assert_called_once() + + +def test_match(horizon_match_client, mock_compare_projects): + query = "test query" + k = 5 + mock_results = [Mock(spec=HorizonMatchResult) for _ in range(k)] + mock_compare_projects.execute.return_value = mock_results + + results = horizon_match_client.match(query, k) + + mock_compare_projects.execute.assert_called_once_with(query, k) + assert results == mock_results + + +def test_index_project(horizon_match_client, mock_vector_search_service): + project = Mock(spec=Project) + + horizon_match_client.index_project(project) + + mock_vector_search_service.index_project.assert_called_once_with(project) + + +def test_search_projects(horizon_match_client, mock_vector_search_service): + query = "test query" + k = 5 + mock_projects = [Mock(spec=Project) for _ in range(k)] + mock_vector_search_service.search.return_value = mock_projects + + results = horizon_match_client.search_projects(query, k) + + mock_vector_search_service.search.assert_called_once_with(query, k) + assert results == mock_projects + + +def test_get_config(horizon_match_client, mock_config_manager): + keys = ["test", "key"] + default_value = "default" + expected_value = "config_value" + mock_config_manager.get.return_value = expected_value + + result = horizon_match_client.get_config(*keys, default=default_value) + + mock_config_manager.get.assert_called_once_with(*keys, default=default_value) + assert result == expected_value + + +def test_integration(mock_config_manager): + with patch( + "horizon_match.presentation.horizon_match_client.PineconeSearchService" + ) as mock_pinecone, patch( + "horizon_match.presentation.horizon_match_client.OpenAIComparisonService" + ) as mock_openai, patch( + "horizon_match.presentation.horizon_match_client.CompareProjects" + ) as mock_compare_projects: + + client = HorizonMatchClient(mock_config_manager) + + # Test match + query = "test query" + k = 3 + mock_results = [Mock(spec=HorizonMatchResult) for _ in range(k)] + mock_compare_projects.return_value.execute.return_value = mock_results + + results = client.match(query, k) + assert results == mock_results + + # Test index_project + project = Mock(spec=Project) + client.index_project(project) + mock_pinecone.return_value.index_project.assert_called_once_with(project) + + # Test search_projects + mock_projects = [Mock(spec=Project) for _ in range(k)] + mock_pinecone.return_value.search.return_value = mock_projects + + search_results = client.search_projects(query, k) + assert search_results == mock_projects + + # Test get_config + client.get_config("test", "key") + mock_config_manager.get.assert_called_once_with("test", "key", default=None) diff --git a/tests/test_openai_comparison_service.py b/tests/test_openai_comparison_service.py new file mode 100644 index 0000000..020790b --- /dev/null +++ b/tests/test_openai_comparison_service.py @@ -0,0 +1,128 @@ +import pytest +from unittest.mock import Mock, patch +import json +from horizon_match.infrastructure.services.openai_comparison_service import ( + OpenAIComparisonService, + MAX_PROJECT_LENGTH, +) +from horizon_match.domain.entities.comparison import Comparison +from horizon_match.infrastructure.config.config_manager import ConfigManager + + +@pytest.fixture +def mock_config(): + config = Mock(spec=ConfigManager) + config.get.side_effect = lambda *args: { + ("horizon-match", "comparison-service", "api_key"): "test_openai_api_key", + ("horizon-match", "comparison-service", "model"): "gpt-4", + }[args] + return config + + +@pytest.fixture +def mock_openai_client(): + return Mock() + + +@pytest.fixture +def comparison_service(mock_config, mock_openai_client): + with patch( + "horizon_match.infrastructure.services.openai_comparison_service.OpenAI" + ) as mock_openai: + mock_openai.return_value = mock_openai_client + return OpenAIComparisonService(mock_config) + + +def test_initialization(mock_config): + with patch( + "horizon_match.infrastructure.services.openai_comparison_service.OpenAI" + ) as mock_openai: + service = OpenAIComparisonService(mock_config) + + mock_config.get.assert_any_call("horizon-match", "comparison-service", "api_key") + mock_config.get.assert_any_call("horizon-match", "comparison-service", "model") + mock_openai.assert_called_once_with(api_key="test_openai_api_key") + assert service.model == "gpt-4" + + +def test_create_comparison_prompt(comparison_service): + my_project = "My project description" + existing_project = "Existing project description" + + prompt = comparison_service._create_comparison_prompt(my_project, existing_project) + + assert isinstance(prompt, list) + assert len(prompt) == 2 + assert prompt[0]["role"] == "system" + assert "academic research assistant" in prompt[0]["content"] + assert prompt[1]["role"] == "user" + assert my_project in prompt[1]["content"] + assert existing_project in prompt[1]["content"] + + +def test_compare(comparison_service, mock_openai_client): + my_project = "My project description" + existing_project = "Existing project description" + + mock_comparison = Comparison( + summary="Test summary", + similarity="Test similarity", + difference="Test difference", + score=0.75, + confidence=0.9, + reason="Test reason", + ) + + mock_response = Mock() + mock_response.choices = [ + Mock(message=Mock(content=json.dumps(mock_comparison.model_dump()))) + ] + mock_openai_client.chat.completions.create.return_value = mock_response + + result = comparison_service.compare(my_project, existing_project) + + assert isinstance(result, Comparison) + assert result == mock_comparison + mock_openai_client.chat.completions.create.assert_called_once_with( + model="gpt-4", + messages=comparison_service._create_comparison_prompt( + my_project, existing_project + ), + response_format={"type": "json_object"}, + ) + + +def test_compare_error_handling(comparison_service, mock_openai_client): + my_project = "My project description" + existing_project = "Existing project description" + + mock_openai_client.chat.completions.create.side_effect = Exception("API Error") + + with pytest.raises(Exception, match="API Error"): + comparison_service.compare(my_project, existing_project) + + +def test_compare_with_empty_input(comparison_service): + with pytest.raises(ValueError, match="My project description cannot be empty"): + comparison_service.compare("", "Existing project") + + with pytest.raises( + ValueError, match="Existing project description cannot be empty" + ): + comparison_service.compare("My project", "") + + +def test_compare_with_long_input(comparison_service): + long_project = "a" * (MAX_PROJECT_LENGTH + 1) + + with pytest.raises( + ValueError, + match=f"My project description exceeds maximum length of {MAX_PROJECT_LENGTH} characters", + ): + comparison_service.compare(long_project, "Existing project") + + with pytest.raises( + ValueError, + match=f"Existing project description exceeds maximum length of {MAX_PROJECT_LENGTH} characters", + ): + comparison_service.compare("My project", long_project) diff --git a/tests/test_pinecone_search_service.py b/tests/test_pinecone_search_service.py new file mode 100644 index 0000000..c31c069 --- /dev/null +++ b/tests/test_pinecone_search_service.py @@ -0,0 +1,193 @@ +import pytest +from unittest.mock import Mock, patch +from datetime import datetime +from horizon_match.infrastructure.services.pinecone_search_service import ( + PineconeSearchService, +) +from horizon_match.domain.entities.project import Project +from horizon_match.infrastructure.config.config_manager import ConfigManager + + +@pytest.fixture +def mock_config(): + config = Mock(spec=ConfigManager) + config.get.side_effect = lambda *args: { + ( + "horizon-match", + "vector-search-service", + "store", + "api_key", + ): "test_pinecone_api_key", + ("horizon-match", "vector-search-service", "store", "index"): "test_index", + ( + "horizon-match", + "vector-search-service", + "embeddings", + "api_key", + ): "test_openai_api_key", + ( + "horizon-match", + "vector-search-service", + "embeddings", + "model", + ): "text-embedding-ada-002", + }[args] + return config + + +@pytest.fixture +def mock_pinecone_index(): + return Mock() + + +@pytest.fixture +def mock_openai_client(): + return Mock() + + +@pytest.fixture +def pinecone_search_service(mock_config, mock_pinecone_index, mock_openai_client): + with patch( + "horizon_match.infrastructure.services.pinecone_search_service.Pinecone" + ) as mock_pinecone: + mock_pinecone.return_value.Index.return_value = mock_pinecone_index + with patch( + "horizon_match.infrastructure.services.pinecone_search_service.OpenAI" + ) as mock_openai: + mock_openai.return_value = mock_openai_client + return PineconeSearchService(mock_config) + + +def test_search(pinecone_search_service, mock_openai_client, mock_pinecone_index): + # Arrange + query = "test query" + k = 2 + mock_embedding = [0.1, 0.2, 0.3] + mock_openai_client.embeddings.create.return_value.data = [ + Mock(embedding=mock_embedding) + ] + + mock_search_results = Mock() + mock_search_results.matches = [ + Mock( + id="1", + metadata={ + "title": "Project 1", + "objective": "Description 1", + "contentUpdateDate": "2023-01-01T00:00:00", + }, + score=0.9, + ), + Mock( + id="2", + metadata={ + "title": "Project 2", + "objective": "Description 2", + "contentUpdateDate": "2023-01-02T00:00:00", + }, + score=0.8, + ), + ] + mock_pinecone_index.query.return_value = mock_search_results + + # Act + results = pinecone_search_service.search(query, k) + + # Assert + assert len(results) == 2 + assert isinstance(results[0], Project) + assert results[0].id == "1" + assert results[0].title == "Project 1" + assert results[0].description == "Description 1" + assert results[0].content_update_date == "2023-01-01T00:00:00" + assert results[0].similarity == 0.9 + + mock_openai_client.embeddings.create.assert_called_once_with( + model="text-embedding-ada-002", input=query + ) + mock_pinecone_index.query.assert_called_once_with( + vector=mock_embedding, top_k=k, include_metadata=True + ) + + +def test_index_project( + pinecone_search_service, mock_openai_client, mock_pinecone_index +): + # Arrange + project = Project( + id="test_id", + title="Test Project", + description="Test Description", + content_update_date="2023-01-01T00:00:00", + similarity=0.5, + ) + mock_embedding = [0.1, 0.2, 0.3] + mock_openai_client.embeddings.create.return_value.data = [ + Mock(embedding=mock_embedding) + ] + + # Act + pinecone_search_service.index_project(project) + + # Assert + mock_openai_client.embeddings.create.assert_called_once_with( + model="text-embedding-ada-002", input=project.description + ) + mock_pinecone_index.upsert.assert_called_once_with( + vectors=[ + { + "id": project.id, + "values": mock_embedding, + "metadata": { + "title": project.title, + "objective": project.description, + "contentUpdateDate": project.content_update_date, + }, + } + ] + ) + + +def test_index_project_without_content_update_date( + pinecone_search_service, mock_openai_client, mock_pinecone_index +): + # Arrange + project = Project( + id="test_id", + title="Test Project", + description="Test Description", + similarity=0.5, + ) + mock_embedding = [0.1, 0.2, 0.3] + mock_openai_client.embeddings.create.return_value.data = [ + Mock(embedding=mock_embedding) + ] + + # Act + with patch( + "horizon_match.infrastructure.services.pinecone_search_service.datetime" + ) as mock_datetime: + mock_now = datetime(2023, 1, 1, 12, 0, 0) + mock_datetime.now.return_value = mock_now + pinecone_search_service.index_project(project) + + # Assert + mock_pinecone_index.upsert.assert_called_once() + called_args = mock_pinecone_index.upsert.call_args[1]["vectors"][0] + assert called_args["metadata"]["contentUpdateDate"] == "2023-01-01T12:00:00" + + +def test_initialization(mock_config): + # Act + with patch( + "horizon_match.infrastructure.services.pinecone_search_service.Pinecone" + ) as mock_pinecone: + with patch( + "horizon_match.infrastructure.services.pinecone_search_service.OpenAI" + ) as mock_openai: + PineconeSearchService(mock_config) + + # Assert + mock_pinecone.assert_called_once_with(api_key="test_pinecone_api_key") + mock_pinecone.return_value.Index.assert_called_once_with("test_index") + mock_openai.assert_called_once_with(api_key="test_openai_api_key")