diff --git a/.darglint2 b/.darglint2 new file mode 100644 index 000000000..4bbb6a31a --- /dev/null +++ b/.darglint2 @@ -0,0 +1,3 @@ +[darglint2] +ignore=DAR003,DAR201,DAR202,DAR301,DAR401 +docstring_style=google diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..cf84e83d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + + + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**System (please complete the following information):** + - OS: [e.g. Mac] + - Version [e.g. 10.14] + - Intelligence Layer Version [e.g. 0.1.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..58c5fa47c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d71548359 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + groups: + minor: + update-types: + - minor + - patch + + - package-ecosystem: "github-actions" + # Workflow files stored in the default location of `.github/workflows`. (You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.) + directory: "/" + schedule: + interval: "daily" + + - package-ecosystem: "npm" + directory: "/trace-viewer/" + schedule: + interval: "daily" + groups: + minor: + update-types: + - minor + - patch + + - package-ecosystem: "docker" + directory: "/trace-viewer/" + schedule: + interval: "daily" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..48461561c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +# Description +No description. + +## Before Merging + - [ ] Review the code changes + - Unused print / comments / TODOs + - Missing docstrings for functions that should have them + - Consistent variable names + - ... + - [ ] Update `changelog.md` if necessary + - [ ] Commit messages should contain a semantic [label](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) and the ticket number + - Consider squashing if this is not the case diff --git a/.github/workflows/artifactory.yml b/.github/workflows/artifactory.yml new file mode 100644 index 000000000..c44510022 --- /dev/null +++ b/.github/workflows/artifactory.yml @@ -0,0 +1,56 @@ +name: Artifactory Deployment of PyPi and trace-viewer + +on: + workflow_dispatch: {} + release: + types: [published] + +env: + ARTIFACTORY_URL: https://alephalpha.jfrog.io + ARTIFACTORY_PYPI_REPOSITORY: "intelligence-layer" + ARTIFACTORY_DOCKER_REGISTRY: alephalpha.jfrog.io/intelligence-layer-images + +jobs: + build-and-push-pypi: + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + steps: + - name: Get Identity Token From Github + run: | + ID_TOKEN=$(curl -sLS -H "User-Agent: actions/oidc-client" -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://alephalpha.jfrog.io" | jq .value | tr -d '"') + echo "ID_TOKEN=${ID_TOKEN}" >> $GITHUB_ENV + - name: Get Jfrog Access Token with Github Identity Token + env: + ID_TOKEN: ${{ env.ID_TOKEN }} + run: | + JFROG_ACCESS_TOKEN=$(curl \ + -X POST \ + -H "Content-type: application/json" \ + $ARTIFACTORY_URL/access/api/v1/oidc/token \ + -d \ + "{\"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\", \"subject_token_type\":\"urn:ietf:params:oauth:token-type:id_token\", \"subject_token\": \"$ID_TOKEN\", \"provider_name\": \"github\"}" \ + | jq .access_token -r) + echo "JFROG_ACCESS_TOKEN=${JFROG_ACCESS_TOKEN}" >> $GITHUB_ENV + - name: Checkout + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + - name: Build package and push to Artifactory + env: + JFROG_ACCESS_TOKEN: ${{ env.JFROG_ACCESS_TOKEN }} + run: | + poetry build + poetry config repositories.artifactory $ARTIFACTORY_URL/artifactory/api/pypi/$ARTIFACTORY_PYPI_REPOSITORY + JFROG_ACCESS_TOKEN_SUBJECT=$(echo $JFROG_ACCESS_TOKEN | awk -F'.' '{print $2}' | sed 's/.\{1,3\}$/&==/' | base64 -d | jq '.sub' -r) + poetry config http-basic.artifactory "$JFROG_ACCESS_TOKEN_SUBJECT" "$JFROG_ACCESS_TOKEN" + poetry publish -r artifactory diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml new file mode 100644 index 000000000..9f5654c8d --- /dev/null +++ b/.github/workflows/daily.yml @@ -0,0 +1,88 @@ +name: "os-support-tests" + +on: + workflow_dispatch: + # Scheduled workflows will only run on the default branch. + schedule: + - cron: '0 0 * * *' # runs once a day at midnight in the timezone of your GitHub repository + +defaults: + run: + shell: bash + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{matrix.os}} + # difference to regular test: no docker, no venv caching + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + - name: Install dependencies + run: | + poetry config installer.max-workers 10 + poetry install --no-interaction + + - name: Run pytest + env: + AA_TOKEN: ${{ secrets.AA_TOKEN }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + ARGILLA_API_URL: "http://localhost:6900/" + ARGILLA_API_KEY: "argilla.apikey" + CLIENT_URL: "https://api.aleph-alpha.com" + run: | + poetry run python -c "import nltk; nltk.download('punkt')" + poetry run pytest -n 10 -m "not docker" + run-notebooks: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{matrix.os}} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + - name: Install dependencies + run: | + poetry config installer.max-workers 10 + poetry install --no-interaction + - name: Configure Poetry for notebooks and run + env: + AA_TOKEN: ${{ secrets.AA_TOKEN }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + ARGILLA_API_URL: "http://localhost:6900/" + ARGILLA_API_KEY: "argilla.apikey" + CLIENT_URL: "https://api.aleph-alpha.com" + run: | + [ -f .env ] && source .env + export AA_TOKEN + # Find all .ipynb files in the directory and pass them to xargs for parallel execution + rm -rf src/documentation/.ipynb_checkpoints + rm -rf src/documentation/how_tos/.ipynb_checkpoints + + find src/documentation -name "*.nbconvert.ipynb" -type f -delete + find src/documentation -name "*.ipynb" ! -name "performance_tips.ipynb" ! -name "human_evaluation.ipynb" ! -name "how_to_human_evaluation_via_argilla.ipynb" | xargs -n 1 -P 6 poetry run jupyter nbconvert --to notebook --execute + find src/documentation -name "*.nbconvert.ipynb" -type f -delete diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml new file mode 100644 index 000000000..cfb5e83a4 --- /dev/null +++ b/.github/workflows/on-push.yml @@ -0,0 +1,14 @@ +name: Intelligence Layer SDK Tests + +on: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + python-tests: + uses: ./.github/workflows/sdk-tests.yml + with: + runner: 'ubuntu-latest' + secrets: inherit diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml new file mode 100644 index 000000000..961b92a81 --- /dev/null +++ b/.github/workflows/sdk-tests.yml @@ -0,0 +1,212 @@ +on: + workflow_call: + inputs: + runner: + type: string + default: "ubuntu-latest" + secrets: + AA_TOKEN: + required: true + HUGGING_FACE_TOKEN: + required: true +jobs: + lint: + defaults: + run: + shell: bash + runs-on: ${{inputs.runner}} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: | + poetry config installer.max-workers 10 + poetry install --no-interaction + + - name: set PY for pre-commit + run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Run linters + run: | + ./scripts/lint.sh + doctest: + defaults: + run: + shell: bash + runs-on: ${{inputs.runner}} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: | + poetry config installer.max-workers 10 + poetry install --no-interaction + - name: install Sphinx + run: sudo apt-get update -y && sudo apt-get install python3-sphinx + - name: run doctest + env: + AA_TOKEN: ${{ secrets.AA_TOKEN }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + CLIENT_URL: "https://api.aleph-alpha.com" + run: ./scripts/doctest.sh + + test: + defaults: + run: + shell: bash + runs-on: ${{inputs.runner}} + services: + argilla-elastic-search: + image: docker.elastic.co/elasticsearch/elasticsearch:8.5.3 + env: + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + discovery.type: "single-node" + xpack.security.enabled: "false" + argilla: + image: argilla/argilla-server:v1.26.1 + ports: + - "6900:6900" + env: + ARGILLA_ELASTICSEARCH: "http://argilla-elastic-search:9200" + ARGILLA_ENABLE_TELEMETRY: 0 + open-telemetry-trace-service: + image: jaegertracing/all-in-one:1.35 + env: + COLLECTOR_OTLP_ENABLED: "true" + ports: + - "4317:4317" + - "4318:4318" + - "16686:16686" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: | + poetry config installer.max-workers 10 + poetry install --no-interaction + + - name: Run pytest + env: + AA_TOKEN: ${{ secrets.AA_TOKEN }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + ARGILLA_API_URL: "http://localhost:6900/" + ARGILLA_API_KEY: "argilla.apikey" + CLIENT_URL: "https://api.aleph-alpha.com" + run: | + ./scripts/test.sh + run-notebooks: + defaults: + run: + shell: bash + runs-on: ${{inputs.runner}} + services: + argilla-elastic-search: + image: docker.elastic.co/elasticsearch/elasticsearch:8.5.3 + env: + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + discovery.type: "single-node" + xpack.security.enabled: "false" + argilla: + image: argilla/argilla-server:v1.26.1 + ports: + - "6900:6900" + env: + ARGILLA_ELASTICSEARCH: "http://argilla-elastic-search:9200" + ARGILLA_ENABLE_TELEMETRY: 0 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: | + poetry config installer.max-workers 10 + poetry install --no-interaction + - name: Configure Poetry for notebooks and run + env: + AA_TOKEN: ${{ secrets.AA_TOKEN }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + ARGILLA_API_URL: "http://localhost:6900/" + ARGILLA_API_KEY: "argilla.apikey" + CLIENT_URL: "https://api.aleph-alpha.com" + run: | + ./scripts/notebook_runner.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0caf99001 --- /dev/null +++ b/.gitignore @@ -0,0 +1,250 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +tests/files/data/web_retriever/wikipedia-splitted/*cache_* + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints +*.nbconvert.ipynb + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# IDE +.vscode/ +Todo.json + +# Cython debug symbols +cython_debug/ + +# Temporary test files +tests/files/tmp/ +debug_logs/ +wandb/ +*index_cache* + +#environment +.DS_STORE +envs* +scratch +pyrightconfig.json + +# Created by https://www.toptal.com/developers/gitignore/api/intellij+all +# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +# End of https://www.toptal.com/developers/gitignore/api/intellij+all +.python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..26f1c13c2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-json + - id: pretty-format-json + files: .json + args: + - --autofix + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + name: isort (python) + args: ["--filter-files"] + verbose: true + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.8.5 + hooks: + - id: nbqa-isort + name: isort (nbs) + args: ["--filter-files"] + additional_dependencies: [isort] + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.3.5 + hooks: + # Run the linter. + - id: ruff + name: ruff-lint + args: [ --fix ] + types_or: [ python, pyi, jupyter ] + # Run the formatter. + - id: ruff-format + types_or: [ python, pyi, jupyter ] + - repo: https://github.com/kynan/nbstripout + rev: 0.7.1 + hooks: + - id: nbstripout + files: ".ipynb" + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + args: + [ + "-L", + "newyorker,te,responde,ist,als,oder,technik,sie,rouge,unter,juli,fiel,couldn,mke, vor,fille,ans", + ] + exclude: '^(poetry\.lock|tests/connectors/retrievers/test_document_index_retriever\.py|src/intelligence_layer/examples/qa/multiple_chunk_qa.py|src/intelligence_layer/examples/summarize/.*|tests/connectors/retrievers/test_document_index_retriever\.py|src/intelligence_layer/examples/classify/keyword_extract.py|tests/examples/summarize/test_single_chunk_few_shot_summarize.py|tests/examples/summarize/very_long_text.txt)$' + - repo: https://github.com/akaihola/darglint2 + rev: v1.8.2 + hooks: + - id: darglint2 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..bc03d9581 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,33 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + # You can also specify other tool versions: + # nodejs: "19" + # rust: "1.64" + # golang: "1.19" + jobs: + post_create_environment: + # Install poetry: + - asdf plugin add poetry + - asdf install poetry latest + - asdf global poetry latest + - poetry config virtualenvs.create false + post_install: + - . "$READTHEDOCS_VIRTUALENV_PATH/bin/activate" && poetry install + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6fe73e127 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,406 @@ +# Changelog + +## Unreleased +... + +### Breaking Changes +... + +### New Features +... + +### Fixes +... + +### Deprecations +... + +## 0.11.0 + +### Breaking Changes +- feature: `HuggingFaceDatasetRepository` now has a parameter `caching`, which caches a examples of a dataset once loaded. This is `True` by default. This drastically reduces network traffic. For a non-breaking change, set it to `False`. +- breaking_change: `MultipleChunkRetrieverQa` does not take `insert_chunk_size`-parameter but instead takes `ExpandChunks`-task +- breaking_change: the `issue_cassification_user_journey` notebook moved to its own repository + +### New Features +- feature: `Llama2InstructModel` to support llama-2 models in Aleph Alpha API +- feature: `Llama3InstructModel` to support llama-3 models in Aleph Alpha API +- feature: `ExpandChunks`-task caches chunked documents by ID +- feature: `DocumentIndexClient` now supports + - `create_index` + - `index_configuration` + - `assign_index_to_collection` + - `delete_index_from_collection` + - `list_assigned_index_names` +- feature: `DocumentIndexRetriever` now supports `index_name` +- feature: `Runner.run_dataset` now has a configurable number of workers via `max_workers` and defaults to the previous value, which is 10. +- feature: In case a `BusyError` is raised during a `complete` the `LimitedConcurrencyClient` will retry until `max_retry_time` is reached. +- feature: `FileTracer` now accepts as `log_file_path` both, a `str` and a `Path` + +### Fixes +- refactor: rename `index` parameter in `DocumentIndex.search()` to `index_name` +- fix: `HuggingFaceRepository` no longer is a dataset repository. This also means that `HuggingFaceAggregationRepository` no longer is a dataset repository. + + +### Deprecations +- `RetrieverBasedQa` is now deprecated and will be removed in future versions. We recommend using `MultipleChunkRetrieverQa` instead. + +## 0.10.0 + +### Breaking Changes +- breaking change: `ExpandChunksOutput` now returns `ChunkWithStartEndIndices` instead of `TextChunk` +- breaking change: `MultipleChunkRetrieverQa`'s `AnswerSource` now contains `EnrichedChunk` instead of just the `TextChunk` +- breaking change: `DocumentIndexClient` method `asymmetric_search()` has been removed +- breaking change: `DocumentIndexRetriever` now additionally needs `index_name` + +### New Features + +### Fixes +- fix: `ChunkWithIndices` now additionally returns end_index +- fix: `DocumentPath` and `CollectionPath` are now immutable + +## 0.9.1 + +### Breaking Changes +- breaking change: `MultipleChunkRetrieverQaOutput` now return `sources` and `search_results` + +### New Features +- feature: `ExpandChunks` task takes a retriever and some search results to expand the chunks to the desired length + +### Fixes +- fix: `ExpectedSearchOutput` has only relevant fields and supports generic document-`ID` rather than just str +- fix: `SearchEvaluationLogic` explicitly compares documents by ids +- fix: In `RecusrsiveSummarize.do_run`, `num_generated_tokens` not uninitialized anymore. [See Issue 743.](https://github.com/Aleph-Alpha/intelligence-layer/issues/743). +- fix: Reverted pydantic to 2.6.* because of FastAPI incompatibility. + +## 0.9.0 + +### Breaking Changes + - breaking change: Renamed the field `chunk` of `AnswerSource` to `search_result` for multi chunk retriever qa. + - breaking change: The implementation of the HuggingFace repository creation and deletion got moved to `HuggingFaceRepository` + +### New Features + - feature: HuggingFaceDataset- & AggregationRepositories now have an explicit `create_repository` function. + - feature: Add `MultipleChunkRetrieverBasedQa`, a task that performs better on faster on retriever-QA, especially with longer context models + +## 0.8.2 + +### New Features + - feature: Add `SearchEvaluationLogic` and `SearchAggregationLogic` to evaluate `Search`-use-cases + - feature: Trace viewer and IL python package are now deployed to artifactory + +### Fixes + - Documentation + - fix: Add missing link to `issue_classification_user_journey` notebook to the tutorials section of README. + - fix: Confusion matrix in `issue_classification_user_journey` now have rounded numbers. + +## 0.8.1 + +### Fixes +- fix: Linting for release version + +## 0.8.0 + +### New Features +- feature: Expose start and end index in DocumentChunk +- feature: Add sorted_scores property to `SingleLabelClassifyOutput`. +- feature: Error information is printed to the console on failed runs and evaluations. +- feature: The stack trace of a failed run/evaluation is included in the `FailedExampleRun`/`FailedExampleEvaluation` object +- feature: The `Runner.run_dataset(..)` and `Evaluator.evaluate_run(..)` have an optional flag `abort_on_error` to stop running/evaluating when an error occurs. +- feature: Add `Runner.failed_runs(..)` and `Evaluator.failed_evaluations(..)` to retrieve all failed run / evaluation lineages +- feature: Add `.successful_example_outputs(..)` and `.failed_example_outputs(..)` to `RunRepository` to match the evaluation repository +- feature: Add optional argument to set an id when creating a `Dataset` via `DatasetRepository.create_dataset(..)` +- feature: Traces now log exceptions using the `ErrorValue` type. + +- Documentation: + - feature: Add info on how to run tests in VSCode + - feature: Add `issue_classification_user_journey` notebook. + - feature: Add documentation of newly added data retrieval methods `how_to_retrieve_data_for_analysis` + - feature: Add documentation of release workflow + +### Fixes +- fix: Fix version number in pyproject.toml in IL +- fix: Fix instructions for installing IL via pip. + +## 0.7.0 + +### Breaking Changes +- breaking change: FScores are now correctly exposed as FScores and no longer as RougeScores +- breaking change: HuggingFaceAggregationRepository and HuggingFaceDatasetRepository now consistently follow the same folder structure as FileDatasetRepository when creating data sets. This means that datasets will be stored in a folder datasets and additional sub-folders named according to the respective dataset ID. +- breaking change: Split run_repository into file_run_repository, in_memory_run_repository. +- breaking change: Split evaluation_repository into argilla_evaluation_repository, file_evaluation_repository and in_memory_evaluation_repository +- breaking change: Split dataset_repository into file_dataset_repository and in_memory_dataset_respository +- breaking change: Split aggregation_respository into file_aggragation_repository and in_memory_aggregation_repository +- breaking change: Renamed evaluation/run.py to evaluation/run_evaluator.py +- breaking change: Split evaluation/domain and distribute it across aggregation, evaluation, dataset and run packages. +- breaking change: Split evaluation/argilla and distribute it across aggregation and evaluation packages. +- breaking change: Split evaluation into separate dataset, run, evaluation and aggregationpackages. +- breaking change: Split evaluation/hugging_face.py into dataset and aggregation repository files in data_storage package. +- breaking change: create_dataset now returns the new Dataset type instead of a dataset ID. +- breaking change: Consistent naming for repository root directories when creating evaluations or aggregations: + - .../eval → .../evaluations and .../aggregation → aggregations. +- breaking change: Core tasks not longer provide defaults for the applied models. +- breaking change: Methods returning entities from repositories now return the results ordered by their IDs. +- breaking change: Renamed crashed_during_eval_count to crashed_during_evaluation_count in AggregationOverview. +- breaking change: Renamed create_evaluation_dataset to initialize_evaluation in EvaluationRepository. +- breaking change: Renamed to_explanation_response to to_explanation_request in ExplainInput. +- breaking change: Removed TextHighlight::text in favor of TextHighlight::start and TextHighlight::end +- breaking change: Removed `IntelligenceApp` and `IntelligenceStarterApp` +- breaking change: RetrieverBasedQa uses now MultiChunkQa instead of generic task pr SingleChunkQa +- breaking change: EvaluationRepository failed_example_evaluations no longer abstract +- breaking change: Elo calculation simplified: + - Payoff from elo package has been removed + - PayoffMatrix from elo package renamed to MatchOutcome + - SingleChunkQa uses logit_bias to promote not answering for German +- breaking change: Remove ChunkOverlap task. +- breaking change: Rename Chunk to TextChunk. +- breaking change: Rename ChunkTask to Chunk . +- breaking change: Rename EchoTask to Echo. +- breaking change: Rename TextHighlightTask to TextHighlightbreaking change: Rename ChunkOverlaptTask to ChunkOverlap + +### New Features +- Aggregation: + - feature: InstructComparisonArgillaAggregationLogic uses full evaluation set instead of sample for aggregation + +- Documentation + + - feature: Added How-To’s (linked in the README): + - how to define a task + - how to implement a task + - how to create a dataset + - how to run a task on a dataset + - how to perform aggregation + - how to evaluate runs + - feature: Restructured and cleaned up README for more conciseness. + - feature: Add illustrations to Concepts.md. + - feature: Added tutorial for adding task to a FastAPI app (linked in README). + - feature: Improved and added various DocStrings. + - feature: Added a README section about the client URL. + - feature: Add python naming convention to README + +- Classify + - feature: PromptBasedClassify now supports changing of the prompt instruction via the instruction parameter. + - feature: Add default model for PromptBasedClassify + - feature: Add default task for PromptBasedClassify + +- Evaluation + - feature: All repositories will return a ValueError when trying to access a dataset that does not exist while also trying to access an entry of the dataset. If only the dataset is retrieved, it will return None. + - `ArgillaEvaluationRepository` now handles failed evaluations. + - feature: Added SingleHuggingfaceDatasetRepository. + - feature: Added HighlightCoverageGrader. + - feature: Added LanguageMatchesGrader. + + - feature: Added prettier default printing behavior of repository entities by providing overloads to __str__ and __repr__ methods. + + - feature: Added abstract HuggingFace repository base-class. + + - feature: Refactoring of HuggingFace repository + + - feature: Added HuggingFaceAggregationRepository. + - feature: Added template method to individual repository + - feature: Added Dataset model to dataset repository. This allows to store a short descriptive name for the dataset for easier identification + - feature: SingleChunkQa internally now uses the same model in TextHighlight by default. + - feature: MeanAccumulator tracks standard deviation and standard error + - feature: EloCalculator now updates ranking after each match + - feature: Add data selection methods to repositories: + - AggregationRepository::aggregation_overviews + - EvaluationRepository::run_overviews + - EvaluationRepository::run_overview_ids + - EvaluationRepository::example_output + - EvaluationRepository::example_outputs + - EvaluationRepository::example_output_ids + - EvaluationRepository::example_trace + - EvaluationRepository::example_tracer + - RunRepository::run_overviews + - RunRepository::run_overview_ids + - RunRepository::example_output + - RunRepository::example_outputs + - RunRepository::example_output_ids + - RunRepository::example_trace + - RunRepository::example_tracer + + - feature: Evaluator continues in case of no successful outputs + +- Q & A + + - feature: Define default parameters for LongContextQa, SingleChunkQa + - feature: Define default task for RetrieverBasedQa + - feature: Define default model for KeyWordExtract, MultiChunkQa, + - feature: Improved focus of highlights in TextHighlight tasks. + - feature: Added filtering for TextHighlight tasks. + - feature: Introduce logit_bias to SingleChunkQa + +- Summarize + - feature: Added RecursiveSummarizeInput. + - feature: Define defaults for SteerableSingleChunkSummarize,SteerableLongContexSummarize, RecursiveSummarize + +- Tracer + - feature: Added better trace viewer integration: + - Add trace storage to trace viewer server + - added submit_to_tracer_viewer method to InMemoryTracer + - UI and navigation improvements for trace viewer + - Add exception handling for tracers during log entry writing + +- Others + + - feature: The following classes are now exposed: + - DocumentChunk + - MultipleChunkQaOutput + - Subanswer + - feature: Simplified internal imports. + - feature: Stream lining of __init__-parameters of all tasks + - Sub-tasks are typically exposed as `__init__`-parameters with sensible defaults. + - Defaults for non-trivial parameters like models or tasks are defined in __init__while the default parameter is None. + - Instead of exposing parameters that are passed on to sub-tasks the sub-task themselves are exposed. + - feature: Update supported models + +### Fixes + +- fix: Fixed exception handling in language detection of LanguageMatchesGrader. +- fix: Fixed a bug that could lead to cut-off highlight ranges in TextHighlight tasks. +- fix: Fixed list_ids methods to use path_to_str +- fix: Disallow traces without end in the trace viewer +- fix: ArgillaClient now correctly uses provided API-URL instead of hard-coded localhost + +## 0.6.0 + +### Breaking Changes + +- breaking change: The evaluation module is moved from core to evaluation . +- breaking change: RetrieverBasedQa task answers now contain document ids in each subanswer +- breaking change: LongcontextSummarize no longer supports the max_loops parameter +- breaking change: Rich Mode Representation + - The LLM-based tasks no longer accept client, but rather an AlephAlphaModel, which holds the client. The available model classes are AlephAlphaModel and LuminousControlModel + - The AlephAlphaModel is responsible for its prompt format, tokenizers, complete task and explain task. These responsibilities were moved into the model classes. + - The default client url is now configurable via the environment variable CLIENT_URL +- breaking change: PromptWithMetadata is removed in favor of RichPrompt . The semantics remain largely unchanged +- breaking change: The compression-dependent long context summarize classes as well as the few-shot summarize class were removed. Use the better-performing steerable summary classes. +- breaking change: Runner, Evaluator & Aggregation + - The EvaluationRepository has been split up. There is now a total of four repositories: dataset , run, evaluation and aggregation. These repositories save information from their respective steps + - The evaluation and evaluation aggregation have been split and are now provided by the classes Evaluator and Aggregator, respectively. These two classes have no abstract methods. The evaluation and aggregation logic is provided by implementing the abstract methods of the classes EvaluationLogic and AggregationLogic which are passed on to an instance of the Evaluator and Aggregator class, respectively. For an example, see the Jupyter notebook xxx. + +### New Features + +- Documentation + - feature: Added an intro to the Intelligence Layer concepts in Concepts.md + - feature: Added documentation on how to execute tasks in parallel. See the performance_tips notebook for more information. +- QA + - feature: RetrieverBasedQa task no longer sources its final from all sources, but only the most relevant. This performed better in evaluation. + - feature: The notebooks for RetrieverBasedQa have been updated to use SingleChunkQa. + - feature: SingleChunkQa now supports a custom no-answer phrase + - feature: MultiChunkQA and LongContextQa allow for more configuration of the used qa-task. + - feature: Make the distance metric configurable in QdrantInMemoryRetriever. + - features: Added list_namespaces to DocumentIndexClient to list all available namespaces in DocumentIndex. +- Evaluation + - feature: The argilla now supports splitting a dataset for multiple people via the split_dataset function + - feature: Utilities for ELO score/ranking calculation + - The build_tournaments utility function has been added to facilitate the computation of ELO scores when evaluating two models. See InstructComparisonArgillaEvaluator for an example how it can be used to compute the ELO scores. + - feature: The Evaluator can run multiple evaluation tasks in parallel. +- Intelligence app + - feature: IntelligenceApp returns 204 if the output is None + - feature: Allow registering tasks with a task dependency in IntelligenceApp. +- Others + - feature: Runner accepts in run_dataset a new parameter num_examples specifying how many of the first n examples should be run. + - feature: Support None as return type in Task + - feature: Added a new task: ChunkOverlapTask splits a longer text into overlapping chunks. + +## 0.5.1 + +Failed deploy + +## 0.5.0 + +### Breaking Changes + +- Document Index search results now properly return `DocumentChunk`s instead of `Document` objects to make it clear it is only a portion of the document. +- `Instruct` and `FewShot` tasks now take the model name in the constructor instead of the input. +- `Dataset`s have now been moved to `DatasetRepository`s, which are responsible for loading and storing datasets. This allows for more flexibility in how datasets are loaded and stored. + +### New Features + +- Introduced an `OpenTelemetryTracer` to allow for sending trace spans to an OpenTelemetry collector. +- Notebook walking through how to use Argilla for human evaluation +- `SteerableLongContextSummarize` task that allows for steering the summarization process by providing a natural language instruction. +- Document index `SearchResult`s now also return the document ID for each chunk, to make it easier to retrieve the full document. +- Retrievers now supply a way to retrieve the full document by ID. +- Introduced the concept of `Accumulator`s to evaluation for incrementally calculating metrics. +- Added `EloCalculator` metrics for calculating Elo scores in evaluation methods. +- Introduced new `HuggingFaceDatasetRepository` for loading datasets from the HuggingFace datasets library. +- Made it easier to evaluate two tasks and or models against each other. + +### Fixes + +- Argilla client properly handles pagination when retrieving records +- Ensured file-based repositories are writing and reading in UTF-8 + + +## 0.4.1 + +Fix missing version bump in the packages + +## 0.4.0 + +### Breaking Changes + +- `Evaluator` methods changed to support asynchronous processing for human eval. To run everything at once, change `evaluator.evaluate()` calls to `evaluator.run_and_evaluate` + - An evaluation also now returns a `EvaluationOverview`, with much more information about the output of the evaluation. +- `EmbeddingBasedClassify`: init arguments swapped places, from `labels_with_examples, client` to `client, label_with_examples` +- `PromptOutput` for `Instruct` tasks now inherits from `CompleteOutput` to make it easier to use more information about the raw completion response. + +### New Features + +- New `IntelligenceApp` builder to quickly spin up a FastAPI server with your `Task`s +- Integration with [Argilla](https://docs.argilla.io/en/latest/index.html) for human evaluation +- `CompleteOutput` and `PromptOutput` now support getting the `generated_tokens` in the completion for downstream calculations. +- Summarization use cases now allow for overriding the default model +- New `RecursiveSummarizer` allows for recursively calling one of the `LongContextSummarize` tasks until certain thresholds are reached + +### Fixes + +- `LimitedConcurrencyClient`'s `from_token` method now supports a custom API host + +## 0.3.0: + +### Breaking Changes + +- `Dataset` is now a protocol. `SequenceDataset` replaces the old `Dataset`. +- The `ident` attribute on `Example` is now `id`. +- `calculate_bleu` function is removed and instead called from a `BleuGrader` +- `calculate_rouge` function is removed and instead called from a `RougeGrader` +- `ClassifyEvaluator` is now called `SingleLabelClassifyEvaluator` +- `Evaluator`s now take and return `Iterator`s instead of `Sequence`s to allow for streaming datasets + +### New Features + +- `Evaluators` now have better handling of dataset processing. + - Errors are handled for individual examples, so that you don't lose the entire run because of one failed task. + - The dataset run now produces an `EvaluationRunOverview` generated by an `EvaluationRepository`, that better captures the aggregated runs and traces. + - There is a `FileEvaluationRepository` and an `InMemoryEvaluationRepository` available for storing your evaluation results +- Support passing `Metadata` field through `DocumentIndexClient` (already supported in the Document Index, new in client only) +- New `MultiLabelClassifyEvaluator` to evaluate classification use cases that support multi-label classification +- `Evaluators` can now be called via the CLI + +### Fixes + +- Fix issue in `EchoTask` regarding concurrent execution causing overrides in the `PromptTemplate` + +## 0.2.0 + +### Breaking Changes + +- `SingleLabelClassify` renamed to `PromptBasedClassify` with new `SingleLabelClassifyOutput` +- `EmbeddingBasedClassify` now outputs `MultiLabelClassifyOutput` to distinguish between the different types of scores produced + +### New Features + +- New `LimitedConcurrencyClient` to better control how many simultaneous API requests are made concurrently, regardless of where they are called within the Task hierarchy +- Basic new `SingleChunkSummarizeEvaluator` and `LongContextSummarizeEvaluator` that can calculate Rouge and Bleu scores when compared with a "golden summary" + +### Fixes + +- Fix issue with Pydantic 2.5 due to ambiguous ordering of types in `PydanticSerializable` type +- Fixed possible deadlock with nested calls to `Task.run_concurrently` + +## 0.1.0 + +Initial release diff --git a/Concepts.md b/Concepts.md new file mode 100644 index 000000000..f45fe4ea2 --- /dev/null +++ b/Concepts.md @@ -0,0 +1,227 @@ +# Concepts + +The main focus of the Intelligence Layer is to enable developers to + +- implement their LLM use cases by building upon and composing existing functionalities +- obtain insights into the runtime behavior of their implementations +- iteratively improve their implementations or compare them to existing implementations by evaluating them against + a given set of examples + +How these focus points are realized in the Intelligence Layer is described in more detail in the following sections. + +## Task + +At the heart of the Intelligence Layer is a `Task`. A task is actually a pretty generic concept that just +transforms an input-parameter to an output like a function in mathematics. + +``` +Task: Input -> Output +``` + +In Python this is realized by an abstract class with type-parameters and the abstract method `do_run` +in which the actual transformation is implemented: + +```Python +class Task(ABC, Generic[Input, Output]): + + @abstractmethod + def do_run(self, input: Input, task_span: TaskSpan) -> Output: + ... +``` + +`Input` and `Output` are normal Python datatypes that can be serialized from and to JSON. For this the Intelligence +Layer relies on [Pydantic](https://docs.pydantic.dev/). The used types are defined in form +of type-aliases PydanticSerializable. + +The second parameter `task_span` is used for [tracing](#Trace) which is described below. + +`do_run` is the method that implements a concrete task and has to be provided by the user. It will be executed by the external interface method `run` of a +task: + +```Python +class Task(ABC, Generic[Input, Output]): + @final + def run(self, input: Input, tracer: Tracer, trace_id: Optional[str] = None) -> Output: + ... +``` + +The signatures of the `do_run` and `run` methods differ only in the [tracing](#Trace) parameters. + +### Levels of abstraction + +Even though the concept is so generic the main purpose for a task is of course to make use of an LLM for the +transformation. Tasks are defined at different levels of abstraction. There are higher level tasks (also called Use Cases) +that reflect a typical user problem and there are lower level tasks that are more about interfacing +with an LLM on a very generic or even technical level. + +Examples for higher level tasks (Use Cases) are: + +- Answering a question based on a given document: `QA: (Document, Question) -> Answer` +- Generate a summary of a given document: `Summary: Document -> Summary` + +Examples for lower level tasks are: + +- Let the model generate text based on an instruction and some context: `Instruct: (Context, Instruction) -> Completion` +- Chunk a text in smaller pieces at optimized boundaries (typically to make it fit into an LLM's context-size): `Chunk: Text -> [Chunk]` + +### Composability + +Typically you would build higher level tasks from lower level tasks. Given a task you can draw a dependency graph +that illustrates which sub-tasks it is using and in turn which sub-tasks they are using. This graph typically forms a hierarchy or +more general a directed acyclic graph. The following drawing shows this graph for the Intelligence Layer's `RecursiveSummarize` +task: + + + + +### Trace + +A task implements a workflow. It processes its input, passes it on to sub-tasks, processes the outputs of the sub-tasks +and builds its own output. This workflow can be represented in a trace. For this a task's `run` method takes a `Tracer` +that takes care of storing details on the steps of this workflow like the tasks that have been invoked along with their +input and output and timing information. The following illustration shows the trace of an MultiChunkQa-task: + + + +To represent this tracing defines the following concepts: + +- A `Tracer` is passed to a task's `run` method and provides methods for opening `Span`s or `TaskSpan`s. +- A `Span` is a `Tracer` and allows to group multiple logs and runtime durations together as a single, logical step in the + workflow. +- A `TaskSpan` is a `Span` that allows to group multiple logs together with the task's specific input and output. + An opened `TaskSpan` is passed to `Task.do_run`. Since a `TaskSpan` is a `Tracer` a `do_run` implementation can pass + this instance on to `run` methods of sub-tasks. + +The following diagram illustrates their relationship: + + + +Each of these concepts is implemented in form of an abstract base class and the Intelligence Layer provides +several concrete implementations that store the actual traces in different backends. For each backend each of the +three abstract classes `Tracer`, `Span` and `TaskSpan` needs to be implemented. Here only the top-level +`Tracer`-implementations are listed: + +- The `NoOpTracer` can be used when tracing information shall not be stored at all. +- The `InMemoryTracer` stores all traces in an in memory data structure and is most helpful in tests or + Jupyter notebooks. +- The `FileTracer` stores all traces in a json-file. +- The `OpenTelemetryTracer` uses an OpenTelemetry + [`Tracer`](https://opentelemetry-python.readthedocs.io/en/latest/api/trace.html#opentelemetry.trace.Tracer) + to store the traces in an OpenTelemetry backend. + + +## Evaluation + +An important part of the Intelligence Layer is tooling that helps to evaluate custom tasks. Evaluation helps +to measure how well the implementation of a task performs given real world examples. The outcome of an entire +evaluation process is an aggregated evaluation result that consists out of metrics aggregated over all examples. + +The evaluation process helps to: + +- optimize a task's implementation by comparing and verifying if changes improve the performance. +- compare the performance of one implementation of a task with that of other (already existing) implementations. +- compare the performance of models for a given task implementation. +- verify how changes to the environment (new model version, new finetuning version) affect the + performance of a task. + + +### Dataset + +The basis of an evaluation is a set of examples for the specific task-type to be evaluated. A single `Example` +consists of: + +- an instance of the `Input` for the specific task and +- optionally an _expected output_ that can be anything that makes sense in context of the specific evaluation (e.g. + in case of classification this could contain the correct classification result, in case of QA this could contain + a _golden answer_, but if an evaluation is only about comparing results with other results of other runs this + could also be empty) + +To enable reproducibility of evaluations datasets are immutable. A single dataset can be used to evaluate all +tasks of the same type, i.e. with the same `Input` and `Output` types. + + +### Evaluation Process + +The Intelligence Layer supports different kinds of evaluation techniques. Most important are: + +- Computing absolute metrics for a task where the aggregated result can be compared with results of previous + result in a way that they can be ordered. Text classification could be a typical use case for this. In that + case the aggregated result could contain metrics like accuracy which can easily compared with other + aggregated results. +- Comparing the individual outputs of different runs (all based on the same dataset) + in a single evaluation process and produce a ranking of all runs as an aggregated result. This technique is useful when it is hard to come up with an absolute metrics to evaluate + a single output, but it is easier to compare two different outputs and decide which one is better. An example + use case could be summarization. + +To support these techniques the Intelligence Layer differentiates between 3 consecutive steps: + +1. Run a task by feeding it all inputs of a dataset and collecting all outputs +2. Evaluate the outputs of one or several runs and produce an evaluation result for each example. Typically a single run is evaluated if absolute + metrics can be computed and several runs are evaluated when the outputs of runs shall be compared. +1. Aggregate the evaluation results of one or several evaluation runs into a single object containing the aggregated + metrics. Aggregating over several evaluation runs supports amending a previous comparison result with + comparisons of new runs without the need to re-execute the previous comparisons again. + +The following table shows how these three steps are represented in code: + +| Step | Executor | Custom Logic | Repository | +|---------|----------|--------------|---------------| +| 1. Run | `Runner` | `Task` | `RunRepository` | +| 2. Evaluate | `Evaluator` | `EvaluationLogic` | `EvaluationRepository` | +| 3. Aggregate | `Aggregator` | `AggregationLogic` | `AggregationRepository` | + +Columns explained +- "Executor" lists concrete implementations provided by the Intelligence Layer. +- "Custom Logic" lists abstract classes that need to be implemented with the custom logic. +- "Repository" lists abstract classes for storing intermediate results. The Intelligence Layer provides + different implementations for these. See the next section for details. + +### Data Storage + +During an evaluation process a lot of intermediate data is created before the final aggregated result can be produced. +To avoid that expensive computations have to be repeated if new results are to be produced based on previous ones +all intermediate results are persisted. For this the different executor-classes make use of repositories. + +There are the following Repositories: + +- The `DatasetRepository` offers methods to manage datasets. The `Runner` uses it to read all `Example`s of a dataset and feeds them to the `Task`. +- The `RunRepository` is responsible for storing a task's output (in form of an `ExampleOutput`) for each `Example` of a dataset + which are created when a `Runner` + runs a task using this dataset. At the end of a run a `RunOverview` is stored containing some metadata concerning the run. + The `Evaluator` reads these outputs given a list of runs it should evaluate to create an evaluation + result for each `Example` of the dataset. +- The `EvaluationRepository` enables the `Evaluator` to store the evaluation result (in form of an `ExampleEvaluation`) for each example along with an `EvaluationOverview`. The `Aggregator` uses this repository to read the evaluation results. +- The `AggregationRepository` stores the `AggregationOverview` containing the aggregated metrics on request of the `Aggregator`. + +The following diagrams illustrate how the different concepts play together in case of the different types of evaluations. + +
+ +
Process of an absolute Evaluation
+
+ +1. The `Runner` reads the `Example`s of a dataset from the `DatasetRepository` and runs a `Task` for each `Example.input` to produce `Output`s. +2. Each `Output` is wrapped in an `ExampleOutput` and stored in the `RunRepository`. +3. The `Evaluator` reads the `ExampleOutput`s for a given run from the + `RunRepository` and the corresponding `Example` from the `DatasetRepository` and uses the `EvaluationLogic` to compute an `Evaluation`. +4. Each `Evaluation` gets wrapped in an `ExampleEvaluation` and stored in the `EvaluationRepository`. +5. The `Aggregator` reads all `ExampleEvaluation`s for a given evaluation and feeds them to the `AggregationLogic` to produce a `AggregatedEvaluation`. +6. The `AggregatedEvalution` is wrapped in an `AggregationOverview` and stored in the `AggregationRepository`. + +The next diagram illustrates the more complex case of a relative evaluation. + +
+ +
Process of a relative Evaluation
+
+ +1. Multiple `Runner`s read the same dataset and produce the corresponding `Output`s for different `Task`s. +2. For each run all `Output`s are stored in the `RunRepository`. +3. The `Evaluator` gets as input previous evaluations (that were produced on basis of the same dataset, but by different `Task`s) and the new runs of the current task. +4. Given the previous evaluations and the new runs the `Evaluator` can read the `ExampleOutput`s of both the new runs + and the runs associated to previous evaluations, collect all that belong to a single `Example` and pass them + along with the `Example` to the `EvaluationLogic` to compute an `Evaluation`. +5. Each `Evaluation` gets wrapped in an `ExampleEvaluation` and is stored in the `EvaluationRepository`. +6. The `Aggregator` reads all `ExampleEvaluation` from all involved evaluations + and feeds them to the `AggregationLogic` to produce a `AggregatedEvaluation`. +7. The `AggregatedEvalution` is wrapped in an `AggregationOverview` and stored in the `AggregationRepository`. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..bb95af133 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,3 @@ +Copyright (C) Aleph Alpha GmbH - All Rights Reserved + +This source code, databases, and other material is protected under international copyright law. All rights reserved and protected by the copyright holders. This file is confidential and only available to authorized individuals with the permission of the copyright holders. If you encounter this file and do not have permission, please contact the copyright holder. diff --git a/README.md b/README.md new file mode 100644 index 000000000..f7d156c83 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# Aleph Alpha Intelligence Layer + +The Aleph Alpha Intelligence Layer️ offers a comprehensive suite of development tools for crafting solutions that harness the capabilities of large language models (LLMs). +With a unified framework for LLM-based workflows, it facilitates seamless AI product development, from prototyping and prompt experimentation to result evaluation and deployment. + +The key features of the Intelligence Layer are: + +- **Composability:** Streamline your journey from prototyping to scalable deployment. The Intelligence Layer SDK offers seamless integration with diverse evaluation methods, manages concurrency, and orchestrates smaller tasks into complex workflows. +- **Evaluability:** Continuously evaluate your AI applications against your quantitaive quality requirements. With the Intelligence Layer SDK you can quickly iterate on different solution strategies, ensuring confidence in the performance of your final product. Take inspiration from the provided evaluations for summary and search when building a custom evaluation logic for your own use case. +- **Traceability:** At the core of the Intelligence Layer is the belief that all AI processes must be auditable and traceable. We provide full observability by seamlessly logging each step of every workflow. This enhances your debugging capabilities and offers greater control post-deployment when examining model responses. +- **Examples:** Get started by following our hands-on examples, demonstrating how to use the Intelligence Layer SDK and interact with its API. + + + +# Table of contents +- [Aleph Alpha Intelligence Layer](#aleph-alpha-intelligence-layer) +- [Table of contents](#table-of-contents) +- [Installation](#installation) + - [Local installation (for development and tutorials)](#local-installation-for-development-and-tutorials) + - [Getting started with the Jupyter Notebooks](#getting-started-with-the-jupyter-notebooks) + - [How to use the Intelligence Layer in your project](#how-to-use-the-intelligence-layer-in-your-project) + - [How to use the Intelligence Layer in Docker](#how-to-use-the-intelligence-layer-in-docker) + - [Via the GitHub repository](#via-the-github-repository) +- [Getting started](#getting-started) + - [Tutorials](#tutorials) + - [How-Tos](#how-tos) +- [Models](#models) +- [Example index](#example-index) +- [References](#references) +- [License](#license) +- [For Developers](#for-developers) + - [Python: Naming Conventions](#python-naming-conventions) + - [Executing tests](#executing-tests) + +# Installation + +## Local installation (for development and tutorials) +Clone the Intelligence Layer repository from github. +```bash +git clone git@github.com:Aleph-Alpha/intelligence-layer.git +``` +The Intelligence Layer uses `poetry` as a package manager. Follow the [official instructions](https://python-poetry.org/docs/#installation) to install it. +Afterwards, simply run `poetry install` to install all dependencies in a virtual environment. +```bash +poetry install +``` +The environment can be activated via `poetry shell`. See the official poetry documentation for more information. + + +### Getting started with the Jupyter Notebooks + +After running the local installation steps, you can set whether you are using the Aleph-Alpha API or an on-prem setup via the environment variables. + +--- +**Using the Aleph-Alpha API** \ + \ +In the Intelligence Layer the Aleph-Alpha API (`https://api.aleph-alpha.com`) is set as default host URL. However, you will need an [Aleph Alpha access token](https://docs.aleph-alpha.com/docs/account/#create-a-new-token) to run the examples. +Set your access token with + +```bash +export AA_TOKEN= +``` + +--- + +**Using an on-prem setup** \ + \ +In case you want to use an on-prem endpoint you will have to change the host URL by setting the `CLIENT_URL` environment variable: + +```bash +export CLIENT_URL= +``` + +The program will warn you in case no `CLIENT_URL` is set explicitly set. + +--- +After correctly setting up the environment variables you can run the jupyter notebooks. +For this, run `jupyter lab` inside the virtual environment and go to the [examples](http://localhost:8888/lab/workspaces/auto-C/tree/src/documentation) directory. + +```bash +cd src/documentation && poetry run jupyter lab +``` + +## How to use the Intelligence Layer in your project +To install this as a dependency in your project, you need a [Github access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). +This token needs the following permissions + + +To install the Aleph-Alpha Intelligence Layer from the JFrog artifactory in you project, you have to add this information to your poetry setup via the following four steps. First, add the artifactory as a source to your project via +```bash +poetry source add --priority=explicit artifactory https://alephalpha.jfrog.io/artifactory/api/pypi/python/simple +``` +Second, to install the poetry environment, export your JFrog credentials to the environment +```bash +export POETRY_HTTP_BASIC_ARTIFACTORY_USERNAME=your@username.here +export POETRY_HTTP_BASIC_ARTIFACTORY_PASSWORD=your-token-here +``` +Third, add the Intelligence Layer to the project +```bash +poetry add --source artifactory intelligence-layer +``` +Fourth, execute +```bash +poetry install +``` + +Now the Intelligence Layer should be available as a Python package and ready to use. + +```py +from intelligence_layer.core import Task +``` + +In VSCode, to enable auto-import up to the second depth, where all symbols are exported, add the following entry to your `./.vscode/settings.json`: + +``` json +"python.analysis.packageIndexDepths": [ + { + "name": "intelligence_layer", + "depth": 2 + } +] +``` +## How to use the Intelligence Layer in Docker + +### Via the GitHub repository + +To use the Intelligence Layer in Docker, a few settings are needed to not leak your Github token. + +You will need your Github token set in your environment. + +In order to modify the `git config` add the following to your docker container: + +```dockerfile +RUN apt-get -y update +RUN apt-get -y install git curl gcc python3-dev +RUN pip install poetry + +RUN poetry install --no-dev --no-interaction --no-ansi \ + && rm -f ~/.gitconfig +``` + +# Getting started + +Not sure where to start? Familiarize yourself with the Intelligence Layer SDK using the below notebook as interactive tutorials. +If you prefer you can also read about the [concepts](Concepts.md) first. + +## Tutorials +The tutorials aim to guide you through implementing several common use-cases with the Intelligence Layer SDK. They introduce you to key concepts and enable you to create your own use-cases. In general the tutorials are build in a way that you can simply hop into the topic you are most interested in. However, for starters we recommend to read through the `Summarization` tutorial first. It explains the core concepts of the intelligence layer in more depth while for the other tutorials we assume that these concepts are known. + +| Order | Topic | Description | Notebook 📓 | +| ----- | ------------------ |------------------------------------------------------|-----------------------------------------------------------------| +| 1 | Summarization | Summarize a document | [summarization.ipynb](./src/documentation/summarization.ipynb) | +| 2 | Question Answering | Various approaches for QA | [qa.ipynb](./src/documentation/qa.ipynb) | +| 3 | Classification | Learn about two methods of classification | [classification.ipynb](./src/documentation/classification.ipynb) | +| 4 | Evaluation | Evaluate LLM-based methodologies | [evaluation.ipynb](./src/documentation/evaluation.ipynb) | +| 5 | Quickstart Task | Build a custom `Task` for your use case | [quickstart_task.ipynb](./src/documentation/quickstart_task.ipynb) | +| 6 | Document Index | Connect your proprietary knowledge base | [document_index.ipynb](./src/documentation/document_index.ipynb) | +| 7 | Human Evaluation | Connect to Argilla for manual evaluation | [human_evaluation.ipynb](./src/documentation/human_evaluation.ipynb) | +| 8 | Performance tips | Contains some small tips for performance | [performance_tips.ipynb](./src/documentation/performance_tips.ipynb) | +| 9 | Deployment | Shows how to deploy a Task in a minimal FastAPI app. | [fastapi_tutorial.ipynb](./src/documentation/fastapi_tutorial.ipynb) | +| 10 | Issue Classification | Deploy a Task in Kubernetes to classify Jira issues | [Found in adjacent repository](https://github.com/Aleph-Alpha/IL-Classification-Journey) | + +## How-Tos +The how-tos are quick lookups about how to do things. Compared to the tutorials, they are shorter and do not explain the concepts they are using in-depth. + +| Tutorial | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| **Tasks** | | +| [...define a task](./src/documentation/how_tos/how_to_define_a_task.ipynb) | How to come up with a new task and formulate it | +| [...implement a task](./src/documentation/how_tos/how_to_implement_a_task.ipynb) | Implement a formulated task and make it run with the Intelligence Layer | +| [...debug and log a task](./src/documentation/how_tos/how_to_log_and_debug_a_task.ipynb) | Tools for logging and debugging in tasks | +| [...run the trace viewer](./src/documentation/how_tos/how_to_run_the_trace_viewer.ipynb) | Downloading and running the trace viewer for debugging traces | +| **Analysis Pipeline** | | +| [...implement a simple evaluation and aggregation logic](./src/documentation/how_tos/how_to_implement_a_simple_evaluation_and_aggregation_logic.ipynb) | Basic examples of evaluation and aggregation logic | +| [...create a dataset](./src/documentation/how_tos/how_to_create_a_dataset.ipynb) | Create a dataset used for running a task | +| [...run a task on a dataset](./src/documentation/how_tos/how_to_run_a_task_on_a_dataset.ipynb) | Run a task on a whole dataset instead of single examples | +| [...evaluate multiple runs](./src/documentation/how_tos/how_to_evaluate_runs.ipynb) | Evaluate (multiple) runs in a single evaluation | +| [...aggregate multiple evaluations](./src/documentation/how_tos/how_to_aggregate_evaluations.ipynb) | Aggregate (multiple) evaluations in a single aggregation | +| [...retrieve data for analysis](./src/documentation/how_tos/how_to_retrieve_data_for_analysis.ipynb) | Retrieve experiment data in multiple different ways | +| [...implement a custom human evaluation](./src/documentation/how_tos/how_to_human_evaluation_via_argilla.ipynb) | Necessary steps to create an evaluation with humans as a judge via Argilla | + +# Models + +Currently, we support a bunch of models accessible via the Aleph Alpha API. Depending on your local setup, you may even have additional models available. + +| Model | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [LuminousControlModel](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.core.html#intelligence_layer.core.LuminousControlModel) | Any control-type model based on the first Luminous generation, specifically `luminous-base-control`, `luminous-extended-control` and `luminous-supreme-control`. Multilingual support. | +| [Llama2InstructModel](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.core.html#intelligence_layer.core.Llama2InstructModel) | Llama-2 based models prompted for one-turn instruction answering. Includes `llama-2-7b-chat`, `llama-2-13b-chat` and `llama-2-70b-chat`. Best suited for English tasks. | +| [Llama3InstructModel](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.core.html#intelligence_layer.core.Llama3InstructModel) | Llama-3 based models prompted for one-turn instruction answering. Includes `llama-3-8b-instruct` and `llama-3-70b-instruct`. Best suited for English tasks and recommended over llama-2 models. | + +# Example index + +To give you a starting point for using the Intelligence Layer, we provide some pre-configured `Task`s that are ready to use out-of-the-box, as well as an accompanying "Getting started" guide in the form of Jupyter Notebooks. + +| Type | Task | Description | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Classify | [EmbeddingBasedClassify](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.EmbeddingBasedClassify) | Classify a short text by computing its similarity with example texts for each class. | +| Classify | [PromptBasedClassify](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.PromptBasedClassify) | Classify a short text by assessing each class' probability using zero-shot prompting. | +| Classify | [PromptBasedClassifyWithDefinitions](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.PromptBasedClassifyWithDefinitions) | Classify a short text by assessing each class' probability using zero-shot prompting. Each class is defined by a natural language description. | +| Classify | [KeywordExtract](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.KeywordExtract) | Generate matching labels for a short text. | +| QA | [MultipleChunkRetrieverQa](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.MultipleChunkRetrieverQa) | Answer a question based on an entire knowledge base. Recommended for most RAG-QA use-cases. | +| QA | [LongContextQa](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.LongContextQa) | Answer a question based on one document of any length. | +| QA | [MultipleChunkQa](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.MultipleChunkQa) | Answer a question based on a list of short texts. | +| QA | [SingleChunkQa](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.SingleChunkQa) | Answer a question based on a short text. | +| QA | [RetrieverBasedQa (deprecated)](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.RetrieverBasedQa) | Answer a question based on a document base using a [BaseRetriever](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.connectors.html#intelligence_layer.connectors.BaseRetriever) implementation. | +| Search | [Search](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.Search) | Search for texts in a document base using a [BaseRetriever](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.connectors.html#intelligence_layer.connectors.BaseRetriever) implementation. | +| Search | [ExpandChunks](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.ExpandChunks) | Expand chunks retrieved with a [BaseRetriever](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.connectors.html#intelligence_layer.connectors.BaseRetriever) implementation. | +| Summarize | [SteerableLongContextSummarize](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.SteerableLongContextSummarize) | Condense a long text into a summary with a natural language instruction. | +| Summarize | [SteerableSingleChunkSummarize](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.SteerableSingleChunkSummarize) | Condense a short text into a summary with a natural language instruction. | +| Summarize | [RecursiveSummarize](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.use_cases.html#intelligence_layer.use_cases.RecursiveSummarize) | Recursively condense a text into a summary. | + +Note that we do not expect the above use cases to solve all of your issues. +Instead, we encourage you to think of our pre-configured use cases as a foundation to fast-track your development process. +By leveraging these tasks, you gain insights into the framework's capabilities and best practices. + +We encourage you to copy and paste these use cases directly into your own project. +From here, you can customize everything, including the prompt, model, and more intricate functional logic. +For more information, check the [tutorials](#tutorials) and the [how-tos](#how-tos) + + + +# References +The full code documentation can be found in our read-the-docs [here](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/) + +# License + +This project can only be used after signing the agreement with Aleph Alpha®. Please refer to the [LICENSE](LICENSE.md) file for more details. + +# For Developers + +## Python: Naming Conventions + +We follow the [PEP 8 – Style Guide for Python Code](https://peps.python.org/pep-0008/). +In addition, there are the following naming conventions: +* Class method names: + * Use only substantives for a method name having no side effects and returning some objects + * E.g., `evaluation_overview` which returns an evaluation overview object + * Use a verb for a method name if it has side effects and return nothing + * E.g., `store_evaluation_overview` which saves a given evaluation overview (and returns nothing) + + + +## Executing tests +**In VSCode** +1. Sidebar > Testing +2. Select pytest as framework for the tests +3. Select `intelligence_layer/tests` as source of the tests + +You can then run the tests from the sidebar. + +**In a terminal** +In order to run a local proxy w.r.t. to the CI pipeline (required to merge) you can run +> scripts/all.sh + +This will run linters and all tests. +The scripts to run single steps can also be found in the `scripts` folder. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..8fa31699e --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,13 @@ +# Release cycle TODOs + +- Update CHANGELOG.md + - We committed to updating the changelog with every relevant merge into main. Check the new entries of the changelog and perform adjustments where necessary. +- Update the "version" field of the project in `pyproject.toml` + - We use [semantic versioning](https://semver.org/) +- Commit the changes and merge to main +- Tag the latest commit on main with the new release number (e.g. v0.6.0) + - `git checkout main, git tag , git push origin ` +- Create a new release draft in GitHub (Tags -> Releases -> Draft a new release) and save it as draft + - Copy the changelog into the release description. Also add a link to the commits since the last release at the bottom of the description. +- Make sure the changes have been merged into the main branch. +- Publish the release. diff --git a/assets/AbsoluteEvaluation.drawio.svg b/assets/AbsoluteEvaluation.drawio.svg new file mode 100644 index 000000000..d7f36f871 --- /dev/null +++ b/assets/AbsoluteEvaluation.drawio.svg @@ -0,0 +1,4 @@ + + + +
Runner
Runner
Dataset
Repository
Dataset...
Run
Repository
Run...
Task

Input ➡ Output
Task...
Evaluator
Evaluator
EvaluationLogic

Example, ExampleOutput ➡
 Evaluation
EvaluationLogic...
Eval
Repository
Eval...
Example*
Example*
ExampleOutput*
ExampleOutput*
ExampleOutput*
ExampleOutput*
Example*
Example*
ExampleEvaluation*
ExampleEvaluation*
Aggregator
Aggregator
AggregationLogic

Evaluation*➡ AggregatedEvaluation
AggregationLogic...
Aggregation
Repository
Aggregatio...
ExampleEvaluation*
ExampleEvaluation*
AggregationOverview
AggregationOverview
1
1
2
2
3
3
4
4
5
5
6
6
Text is not SVG - cannot display
diff --git a/assets/RecursiveSummary.drawio.svg b/assets/RecursiveSummary.drawio.svg new file mode 100644 index 000000000..dc7384ac9 --- /dev/null +++ b/assets/RecursiveSummary.drawio.svg @@ -0,0 +1,4 @@ + + + +
RecursiveSummarize
RecursiveSummarize
SteerableLongContextSummarize
SteerableLongContextSummarize
SteerableSingleChunkSummarize
SteerableSingleChunkSummarize
Chunk
Chunk
Complete
Complete
Text is not SVG - cannot display
diff --git a/assets/RelativeEvaluation.drawio.svg b/assets/RelativeEvaluation.drawio.svg new file mode 100644 index 000000000..c7aa089f4 --- /dev/null +++ b/assets/RelativeEvaluation.drawio.svg @@ -0,0 +1,4 @@ + + + +
Runner
Runner
Dataset
Repository
Dataset...
Run
Repository
Run...
Task

Input ➡ Output
Task...
Evaluator
Evaluator
EvaluationLogic

Example, ExampleOutput ➡
 Evaluation
EvaluationLogic...
Eval
Repository
Eval...
Example*
Example*
Example
Evaluation*
Example...
Aggregator
Aggregator
AggregationLogic

Evaluation*➡ AggregatedEvaluation
AggregationLogic...
Aggregation
Repository
Aggregatio...
Example
Evaluation*
Example...
AggregationOverview
AggregationOverview
Runner
Runner
Task

Input ➡ Output
Task...
Runner
Runner
Task

Input ➡ Output
Task...
New
Runs
New...
Example*
Example*
Previous
Runs
Previous...
New
Runs
New...
Previous
Evals
Previous...
Eval
Repository
Eval...
Single
Stream
Single...
Multiple
Streams
with new
data
Multiple...
Multiple
Streams
with existing
data
Multiple...
Previous
Evals
Previous...
1
1
2
2
3
3
4
4
5
5
6
6
7
7
Text is not SVG - cannot display
diff --git a/assets/TraceViewer.png b/assets/TraceViewer.png new file mode 100644 index 000000000..31e08c7c8 Binary files /dev/null and b/assets/TraceViewer.png differ diff --git a/assets/Tracer.drawio.svg b/assets/Tracer.drawio.svg new file mode 100644 index 000000000..ed6fd5465 --- /dev/null +++ b/assets/Tracer.drawio.svg @@ -0,0 +1,4 @@ + + + +

<<abstract>>
Tracer


+ span(name: str): Span
+ task_span(name: str, input): TaskSpan

<<abstract>>...

<<abstract>>
Span


+ log(message: str, value): void
+ end(): void

<<abstract>>...
Extends
Extends

<<abstract>>
AbstractContextManager


+ __enter__
+ __exit__

<<abstract>>...
Extends
Extends

<<abstract>>
TaskSpan


+ record_output(output): void

<<abstract>>...
Extends
Extends
Intelligence Layer Internal
Intelligence Lay...
Python SDK
Python SDK
Text is not SVG - cannot display
diff --git a/assets/Tracing.drawio.svg b/assets/Tracing.drawio.svg new file mode 100644 index 000000000..a9571a4d7 --- /dev/null +++ b/assets/Tracing.drawio.svg @@ -0,0 +1,4 @@ + + + +
MultiChunkQa

Input                                              ➡                                              Output
MultiChunkQa...
Complete

Input                                     ➡                                 Output
Complete...
Concurrent SingleChunkQa
Concurrent SingleChunkQa
SingleChunkQa

Input                                   ➡                                Output
SingleChunkQa...
Complete

Input         ➡       Output
Complete...
SingleChunkQa

Input                                   ➡                                Output
SingleChunkQa...
Complete

Input         ➡       Output
Complete...
TextHighlight

Input         ➡       Output
TextHighlight...
Text is not SVG - cannot display
diff --git a/assets/argilla_interface.png b/assets/argilla_interface.png new file mode 100644 index 000000000..33fdf7a0a Binary files /dev/null and b/assets/argilla_interface.png differ diff --git a/assets/argilla_splits.png b/assets/argilla_splits.png new file mode 100644 index 000000000..ddf4c1e80 Binary files /dev/null and b/assets/argilla_splits.png differ diff --git a/assets/fork.png b/assets/fork.png new file mode 100644 index 000000000..be08e1d58 Binary files /dev/null and b/assets/fork.png differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..8d288a0c9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,25 @@ +version: "3" +services: + argilla-elastic-search: + image: docker.elastic.co/elasticsearch/elasticsearch:8.5.3 + environment: + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + discovery.type: "single-node" + xpack.security.enabled: "false" + argilla: + image: argilla/argilla-server:v1.26.1 + ports: + - "6900:6900" + environment: + ARGILLA_ELASTICSEARCH: "http://argilla-elastic-search:9200" + ARGILLA_ENABLE_TELEMETRY: 0 + + open-telemetry-trace-service: + container_name: jaeger_1_35 + environment: + COLLECTOR_OTLP_ENABLED: "true" + ports: + - "4317:4317" + - "4318:4318" + - "16686:16686" + image: jaegertracing/all-in-one:1.35 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..3e4b5f71d --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,24 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= # -nvT +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +export SPHINX_APIDOC_OPTIONS=members,show-inheritance + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + # sphinx-apidoc -o . ../src + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + # rm --force `ls *.rst | grep --fixed-strings --invert-match index.rst` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..0ee2b4f85 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,38 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "Intelligence Layer" +copyright = "2023, Aleph Alpha" +author = "Aleph Alpha" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.doctest", +] + +autodoc_default_options = { + "members": True, + "show-inheritance": True, + "inherited-members": "BaseModel,RuntimeError", + # BaseModel attributes where the documentation does not add a lot of value + "exclude-members": "model_config,model_fields,model_computed_fields", +} + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..68eb2fb3f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +.. Intelligence Layer documentation master file, created by + sphinx-quickstart on Fri Oct 27 14:17:00 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Intelligence Layer's documentation! +============================================== + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + intelligence_layer + +Indices and tables +================== + +* :ref:`genindex` diff --git a/docs/intelligence_layer.connectors.rst b/docs/intelligence_layer.connectors.rst new file mode 100644 index 000000000..0e9570b37 --- /dev/null +++ b/docs/intelligence_layer.connectors.rst @@ -0,0 +1,7 @@ +intelligence\_layer.connectors package +====================================== + +Module contents +--------------- + +.. automodule:: intelligence_layer.connectors diff --git a/docs/intelligence_layer.core.rst b/docs/intelligence_layer.core.rst new file mode 100644 index 000000000..cce640699 --- /dev/null +++ b/docs/intelligence_layer.core.rst @@ -0,0 +1,9 @@ +intelligence\_layer.core package +================================ + +Module contents +--------------- + +.. automodule:: intelligence_layer.core + + .. autoclass:: TextChunk diff --git a/docs/intelligence_layer.evaluation.rst b/docs/intelligence_layer.evaluation.rst new file mode 100644 index 000000000..a871a800a --- /dev/null +++ b/docs/intelligence_layer.evaluation.rst @@ -0,0 +1,8 @@ +intelligence\_layer.evaluation package +====================================== + + +Module contents +--------------- + +.. automodule:: intelligence_layer.evaluation diff --git a/docs/intelligence_layer.rst b/docs/intelligence_layer.rst new file mode 100644 index 000000000..0e4ba8eef --- /dev/null +++ b/docs/intelligence_layer.rst @@ -0,0 +1,18 @@ +intelligence\_layer package +=========================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 3 + + intelligence_layer.connectors + intelligence_layer.core + intelligence_layer.evaluation + intelligence_layer.use_cases + +Module contents +--------------- + +.. automodule:: intelligence_layer diff --git a/docs/intelligence_layer.use_cases.rst b/docs/intelligence_layer.use_cases.rst new file mode 100644 index 000000000..b620902f5 --- /dev/null +++ b/docs/intelligence_layer.use_cases.rst @@ -0,0 +1,8 @@ +intelligence\_layer.use\_cases package +====================================== + + +Module contents +--------------- + +.. automodule:: intelligence_layer.examples diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 000000000..32bb24529 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/env.sample b/env.sample new file mode 100644 index 000000000..c687dc0f8 --- /dev/null +++ b/env.sample @@ -0,0 +1,7 @@ +AA_TOKEN=token +CLIENT_URL="https://api.aleph-alpha.com" +ARGILLA_API_URL="http://localhost:6900/" +ARGILLA_API_KEY="argilla.apikey" +HUGGING_FACE_TOKEN=token +# local dev builds run on 5173 +TRACE_VIEWER_URL="http://localhost:3000" diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..cd914a8ed --- /dev/null +++ b/poetry.lock @@ -0,0 +1,5551 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "aiodns" +version = "3.2.0" +description = "Simple DNS resolver for asyncio" +optional = false +python-versions = "*" +files = [ + {file = "aiodns-3.2.0-py3-none-any.whl", hash = "sha256:e443c0c27b07da3174a109fd9e736d69058d808f144d3c9d56dbd1776964c5f5"}, + {file = "aiodns-3.2.0.tar.gz", hash = "sha256:62869b23409349c21b072883ec8998316b234c9a9e36675756e8e317e8768f72"}, +] + +[package.dependencies] +pycares = ">=4.0.0" + +[[package]] +name = "aiohttp" +version = "3.9.5" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiohttp-retry" +version = "2.8.3" +description = "Simple retry client for aiohttp" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiohttp_retry-2.8.3-py3-none-any.whl", hash = "sha256:3aeeead8f6afe48272db93ced9440cf4eda8b6fd7ee2abb25357b7eb28525b45"}, + {file = "aiohttp_retry-2.8.3.tar.gz", hash = "sha256:9a8e637e31682ad36e1ff9f8bcba912fcfc7d7041722bc901a4b948da4d71ea9"}, +] + +[package.dependencies] +aiohttp = "*" + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "alabaster" +version = "0.7.16" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.9" +files = [ + {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, + {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, +] + +[[package]] +name = "aleph-alpha-client" +version = "7.1.0" +description = "python client to interact with Aleph Alpha api endpoints" +optional = false +python-versions = "*" +files = [ + {file = "aleph_alpha_client-7.1.0-py3-none-any.whl", hash = "sha256:2bb130d07dd344cb22d715e147a76a9ed830a1ea97b786535e8c297b6ab4e681"}, + {file = "aleph_alpha_client-7.1.0.tar.gz", hash = "sha256:8d687456da92a83ca2530fe10a72e9b7ed2de721e25c8768e61d95426b60e131"}, +] + +[package.dependencies] +aiodns = ">=3.0.0" +aiohttp = ">=3.8.6" +aiohttp-retry = ">=2.8.3" +packaging = ">=23.2" +Pillow = ">=9.2.0" +python-liquid = ">=1.9.4" +requests = ">=2.28" +tokenizers = ">=0.13.2" +tqdm = ">=v4.62.0" +typing-extensions = ">=4.5.0" +urllib3 = ">=1.26" + +[package.extras] +dev = ["black", "ipykernel", "mypy", "nbconvert", "pytest", "pytest-aiohttp", "pytest-cov", "pytest-dotenv", "pytest-httpserver", "types-Pillow", "types-requests", "types-tqdm"] +docs = ["sphinx", "sphinx-rtd-theme"] +test = ["pytest", "pytest-aiohttp", "pytest-cov", "pytest-dotenv", "pytest-httpserver"] +types = ["mypy", "types-Pillow", "types-requests", "types-tqdm"] + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "anyio" +version = "4.3.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "appnope" +version = "0.1.4" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = ">=3.6" +files = [ + {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, + {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, +] + +[[package]] +name = "argon2-cffi" +version = "23.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, + {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[package.extras] +dev = ["argon2-cffi[tests,typing]", "tox (>4)"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] +tests = ["hypothesis", "pytest"] +typing = ["mypy"] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.6" +files = [ + {file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"}, + {file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"}, +] + +[package.dependencies] +cffi = ">=1.0.1" + +[package.extras] +dev = ["cogapp", "pre-commit", "pytest", "wheel"] +tests = ["pytest"] + +[[package]] +name = "arrow" +version = "1.3.0" +description = "Better dates & times for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, + {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, +] + +[package.dependencies] +python-dateutil = ">=2.7.0" +types-python-dateutil = ">=2.8.10" + +[package.extras] +doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] +test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] + +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + +[[package]] +name = "async-lru" +version = "2.0.4" +description = "Simple LRU cache for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "async-lru-2.0.4.tar.gz", hash = "sha256:b8a59a5df60805ff63220b2a0c5b5393da5521b113cd5465a44eb037d81a5627"}, + {file = "async_lru-2.0.4-py3-none-any.whl", hash = "sha256:ff02944ce3c288c5be660c42dbcca0742b32c3b279d6dceda655190240b99224"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "autopep8" +version = "2.1.0" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +optional = false +python-versions = ">=3.8" +files = [ + {file = "autopep8-2.1.0-py2.py3-none-any.whl", hash = "sha256:2bb76888c5edbcafe6aabab3c47ba534f5a2c2d245c2eddced4a30c4b4946357"}, + {file = "autopep8-2.1.0.tar.gz", hash = "sha256:1fa8964e4618929488f4ec36795c7ff12924a68b8bf01366c094fc52f770b6e7"}, +] + +[package.dependencies] +pycodestyle = ">=2.11.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[[package]] +name = "babel" +version = "2.14.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "bleach" +version = "6.1.0" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bleach-6.1.0-py3-none-any.whl", hash = "sha256:3225f354cfc436b9789c66c4ee030194bee0568fbf9cbdad3bc8b5c26c5f12b6"}, + {file = "bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe"}, +] + +[package.dependencies] +six = ">=1.9.0" +webencodings = "*" + +[package.extras] +css = ["tinycss2 (>=1.1.0,<1.3)"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "comm" +version = "0.2.2" +description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." +optional = false +python-versions = ">=3.8" +files = [ + {file = "comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3"}, + {file = "comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e"}, +] + +[package.dependencies] +traitlets = ">=4" + +[package.extras] +test = ["pytest"] + +[[package]] +name = "contourpy" +version = "1.2.1" +description = "Python library for calculating contours of 2D quadrilateral grids" +optional = false +python-versions = ">=3.9" +files = [ + {file = "contourpy-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd7c23df857d488f418439686d3b10ae2fbf9bc256cd045b37a8c16575ea1040"}, + {file = "contourpy-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b9eb0ca724a241683c9685a484da9d35c872fd42756574a7cfbf58af26677fd"}, + {file = "contourpy-1.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c75507d0a55378240f781599c30e7776674dbaf883a46d1c90f37e563453480"}, + {file = "contourpy-1.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11959f0ce4a6f7b76ec578576a0b61a28bdc0696194b6347ba3f1c53827178b9"}, + {file = "contourpy-1.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb3315a8a236ee19b6df481fc5f997436e8ade24a9f03dfdc6bd490fea20c6da"}, + {file = "contourpy-1.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39f3ecaf76cd98e802f094e0d4fbc6dc9c45a8d0c4d185f0f6c2234e14e5f75b"}, + {file = "contourpy-1.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:94b34f32646ca0414237168d68a9157cb3889f06b096612afdd296003fdd32fd"}, + {file = "contourpy-1.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:457499c79fa84593f22454bbd27670227874cd2ff5d6c84e60575c8b50a69619"}, + {file = "contourpy-1.2.1-cp310-cp310-win32.whl", hash = "sha256:ac58bdee53cbeba2ecad824fa8159493f0bf3b8ea4e93feb06c9a465d6c87da8"}, + {file = "contourpy-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9cffe0f850e89d7c0012a1fb8730f75edd4320a0a731ed0c183904fe6ecfc3a9"}, + {file = "contourpy-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6022cecf8f44e36af10bd9118ca71f371078b4c168b6e0fab43d4a889985dbb5"}, + {file = "contourpy-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef5adb9a3b1d0c645ff694f9bca7702ec2c70f4d734f9922ea34de02294fdf72"}, + {file = "contourpy-1.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6150ffa5c767bc6332df27157d95442c379b7dce3a38dff89c0f39b63275696f"}, + {file = "contourpy-1.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c863140fafc615c14a4bf4efd0f4425c02230eb8ef02784c9a156461e62c965"}, + {file = "contourpy-1.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00e5388f71c1a0610e6fe56b5c44ab7ba14165cdd6d695429c5cd94021e390b2"}, + {file = "contourpy-1.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4492d82b3bc7fbb7e3610747b159869468079fe149ec5c4d771fa1f614a14df"}, + {file = "contourpy-1.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49e70d111fee47284d9dd867c9bb9a7058a3c617274900780c43e38d90fe1205"}, + {file = "contourpy-1.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b59c0ffceff8d4d3996a45f2bb6f4c207f94684a96bf3d9728dbb77428dd8cb8"}, + {file = "contourpy-1.2.1-cp311-cp311-win32.whl", hash = "sha256:7b4182299f251060996af5249c286bae9361fa8c6a9cda5efc29fe8bfd6062ec"}, + {file = "contourpy-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2855c8b0b55958265e8b5888d6a615ba02883b225f2227461aa9127c578a4922"}, + {file = "contourpy-1.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:62828cada4a2b850dbef89c81f5a33741898b305db244904de418cc957ff05dc"}, + {file = "contourpy-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:309be79c0a354afff9ff7da4aaed7c3257e77edf6c1b448a779329431ee79d7e"}, + {file = "contourpy-1.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e785e0f2ef0d567099b9ff92cbfb958d71c2d5b9259981cd9bee81bd194c9a4"}, + {file = "contourpy-1.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cac0a8f71a041aa587410424ad46dfa6a11f6149ceb219ce7dd48f6b02b87a7"}, + {file = "contourpy-1.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af3f4485884750dddd9c25cb7e3915d83c2db92488b38ccb77dd594eac84c4a0"}, + {file = "contourpy-1.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce6889abac9a42afd07a562c2d6d4b2b7134f83f18571d859b25624a331c90b"}, + {file = "contourpy-1.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a1eea9aecf761c661d096d39ed9026574de8adb2ae1c5bd7b33558af884fb2ce"}, + {file = "contourpy-1.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:187fa1d4c6acc06adb0fae5544c59898ad781409e61a926ac7e84b8f276dcef4"}, + {file = "contourpy-1.2.1-cp312-cp312-win32.whl", hash = "sha256:c2528d60e398c7c4c799d56f907664673a807635b857df18f7ae64d3e6ce2d9f"}, + {file = "contourpy-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:1a07fc092a4088ee952ddae19a2b2a85757b923217b7eed584fdf25f53a6e7ce"}, + {file = "contourpy-1.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bb6834cbd983b19f06908b45bfc2dad6ac9479ae04abe923a275b5f48f1a186b"}, + {file = "contourpy-1.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1d59e739ab0e3520e62a26c60707cc3ab0365d2f8fecea74bfe4de72dc56388f"}, + {file = "contourpy-1.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd3db01f59fdcbce5b22afad19e390260d6d0222f35a1023d9adc5690a889364"}, + {file = "contourpy-1.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a12a813949e5066148712a0626895c26b2578874e4cc63160bb007e6df3436fe"}, + {file = "contourpy-1.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe0ccca550bb8e5abc22f530ec0466136379c01321fd94f30a22231e8a48d985"}, + {file = "contourpy-1.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1d59258c3c67c865435d8fbeb35f8c59b8bef3d6f46c1f29f6123556af28445"}, + {file = "contourpy-1.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f32c38afb74bd98ce26de7cc74a67b40afb7b05aae7b42924ea990d51e4dac02"}, + {file = "contourpy-1.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d31a63bc6e6d87f77d71e1abbd7387ab817a66733734883d1fc0021ed9bfa083"}, + {file = "contourpy-1.2.1-cp39-cp39-win32.whl", hash = "sha256:ddcb8581510311e13421b1f544403c16e901c4e8f09083c881fab2be80ee31ba"}, + {file = "contourpy-1.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10a37ae557aabf2509c79715cd20b62e4c7c28b8cd62dd7d99e5ed3ce28c3fd9"}, + {file = "contourpy-1.2.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a31f94983fecbac95e58388210427d68cd30fe8a36927980fab9c20062645609"}, + {file = "contourpy-1.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef2b055471c0eb466033760a521efb9d8a32b99ab907fc8358481a1dd29e3bd3"}, + {file = "contourpy-1.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b33d2bc4f69caedcd0a275329eb2198f560b325605810895627be5d4b876bf7f"}, + {file = "contourpy-1.2.1.tar.gz", hash = "sha256:4d8908b3bee1c889e547867ca4cdc54e5ab6be6d3e078556814a22457f49423c"}, +] + +[package.dependencies] +numpy = ">=1.20" + +[package.extras] +bokeh = ["bokeh", "selenium"] +docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] +mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.8.0)", "types-Pillow"] +test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] +test-no-images = ["pytest", "pytest-cov", "pytest-xdist", "wurlitzer"] + +[[package]] +name = "cycler" +version = "0.12.1" +description = "Composable style cycles" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, + {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, +] + +[package.extras] +docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] +tests = ["pytest", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "datasets" +version = "2.19.0" +description = "HuggingFace community-driven open-source library of datasets" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "datasets-2.19.0-py3-none-any.whl", hash = "sha256:f57c5316e123d4721b970c68c1cb856505f289cda58f5557ffe745b49c011a8e"}, + {file = "datasets-2.19.0.tar.gz", hash = "sha256:0b47e08cc7af2c6800a42cadc4657b22a0afc7197786c8986d703c08d90886a6"}, +] + +[package.dependencies] +aiohttp = "*" +dill = ">=0.3.0,<0.3.9" +filelock = "*" +fsspec = {version = ">=2023.1.0,<=2024.3.1", extras = ["http"]} +huggingface-hub = ">=0.21.2" +multiprocess = "*" +numpy = ">=1.17" +packaging = "*" +pandas = "*" +pyarrow = ">=12.0.0" +pyarrow-hotfix = "*" +pyyaml = ">=5.1" +requests = ">=2.19.0" +tqdm = ">=4.62.1" +xxhash = "*" + +[package.extras] +apache-beam = ["apache-beam (>=2.26.0)"] +audio = ["librosa", "soundfile (>=0.12.1)"] +benchmarks = ["tensorflow (==2.12.0)", "torch (==2.0.1)", "transformers (==4.30.1)"] +dev = ["Pillow (>=6.2.1)", "absl-py", "apache-beam (>=2.26.0)", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.6.4)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "ruff (>=0.3.0)", "s3fs", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "sqlalchemy", "tensorflow (>=2.6.0)", "tiktoken", "torch", "torch (>=2.0.0)", "transformers", "typing-extensions (>=4.6.1)", "zstandard"] +docs = ["s3fs", "tensorflow (>=2.6.0)", "torch", "transformers"] +jax = ["jax (>=0.3.14)", "jaxlib (>=0.3.14)"] +metrics-tests = ["Werkzeug (>=1.0.1)", "accelerate", "bert-score (>=0.3.6)", "jiwer", "langdetect", "mauve-text", "nltk", "requests-file (>=1.5.1)", "rouge-score", "sacrebleu", "sacremoses", "scikit-learn", "scipy", "sentencepiece", "seqeval", "six (>=1.15.0,<1.16.0)", "spacy (>=3.0.0)", "texttable (>=1.6.3)", "tldextract", "tldextract (>=3.1.0)", "toml (>=0.10.1)", "typer (<0.5.0)"] +quality = ["ruff (>=0.3.0)"] +s3 = ["s3fs"] +tensorflow = ["tensorflow (>=2.6.0)"] +tensorflow-gpu = ["tensorflow (>=2.6.0)"] +tests = ["Pillow (>=6.2.1)", "absl-py", "apache-beam (>=2.26.0)", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.6.4)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "sqlalchemy", "tensorflow (>=2.6.0)", "tiktoken", "torch (>=2.0.0)", "transformers", "typing-extensions (>=4.6.1)", "zstandard"] +torch = ["torch"] +vision = ["Pillow (>=6.2.1)"] + +[[package]] +name = "debugpy" +version = "1.8.1" +description = "An implementation of the Debug Adapter Protocol for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "debugpy-1.8.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:3bda0f1e943d386cc7a0e71bfa59f4137909e2ed947fb3946c506e113000f741"}, + {file = "debugpy-1.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dda73bf69ea479c8577a0448f8c707691152e6c4de7f0c4dec5a4bc11dee516e"}, + {file = "debugpy-1.8.1-cp310-cp310-win32.whl", hash = "sha256:3a79c6f62adef994b2dbe9fc2cc9cc3864a23575b6e387339ab739873bea53d0"}, + {file = "debugpy-1.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:7eb7bd2b56ea3bedb009616d9e2f64aab8fc7000d481faec3cd26c98a964bcdd"}, + {file = "debugpy-1.8.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:016a9fcfc2c6b57f939673c874310d8581d51a0fe0858e7fac4e240c5eb743cb"}, + {file = "debugpy-1.8.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd97ed11a4c7f6d042d320ce03d83b20c3fb40da892f994bc041bbc415d7a099"}, + {file = "debugpy-1.8.1-cp311-cp311-win32.whl", hash = "sha256:0de56aba8249c28a300bdb0672a9b94785074eb82eb672db66c8144fff673146"}, + {file = "debugpy-1.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:1a9fe0829c2b854757b4fd0a338d93bc17249a3bf69ecf765c61d4c522bb92a8"}, + {file = "debugpy-1.8.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3ebb70ba1a6524d19fa7bb122f44b74170c447d5746a503e36adc244a20ac539"}, + {file = "debugpy-1.8.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2e658a9630f27534e63922ebf655a6ab60c370f4d2fc5c02a5b19baf4410ace"}, + {file = "debugpy-1.8.1-cp312-cp312-win32.whl", hash = "sha256:caad2846e21188797a1f17fc09c31b84c7c3c23baf2516fed5b40b378515bbf0"}, + {file = "debugpy-1.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:edcc9f58ec0fd121a25bc950d4578df47428d72e1a0d66c07403b04eb93bcf98"}, + {file = "debugpy-1.8.1-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:7a3afa222f6fd3d9dfecd52729bc2e12c93e22a7491405a0ecbf9e1d32d45b39"}, + {file = "debugpy-1.8.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d915a18f0597ef685e88bb35e5d7ab968964b7befefe1aaea1eb5b2640b586c7"}, + {file = "debugpy-1.8.1-cp38-cp38-win32.whl", hash = "sha256:92116039b5500633cc8d44ecc187abe2dfa9b90f7a82bbf81d079fcdd506bae9"}, + {file = "debugpy-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:e38beb7992b5afd9d5244e96ad5fa9135e94993b0c551ceebf3fe1a5d9beb234"}, + {file = "debugpy-1.8.1-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:bfb20cb57486c8e4793d41996652e5a6a885b4d9175dd369045dad59eaacea42"}, + {file = "debugpy-1.8.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd3fdd3f67a7e576dd869c184c5dd71d9aaa36ded271939da352880c012e703"}, + {file = "debugpy-1.8.1-cp39-cp39-win32.whl", hash = "sha256:58911e8521ca0c785ac7a0539f1e77e0ce2df753f786188f382229278b4cdf23"}, + {file = "debugpy-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:6df9aa9599eb05ca179fb0b810282255202a66835c6efb1d112d21ecb830ddd3"}, + {file = "debugpy-1.8.1-py2.py3-none-any.whl", hash = "sha256:28acbe2241222b87e255260c76741e1fbf04fdc3b6d094fcf57b6c6f75ce1242"}, + {file = "debugpy-1.8.1.zip", hash = "sha256:f696d6be15be87aef621917585f9bb94b1dc9e8aced570db1b8a6fc14e8f9b42"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + +[[package]] +name = "dill" +version = "0.3.8" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, + {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "docutils" +version = "0.20.1" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, + {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +files = [ + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "faker" +version = "24.14.0" +description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Faker-24.14.0-py3-none-any.whl", hash = "sha256:7692aa95155109b9348ab94afddd9049df41db64baa4ba6736653e947b52378e"}, + {file = "Faker-24.14.0.tar.gz", hash = "sha256:13676b71346608350accc56e302d55ab7fca0db3f739145c3a3157d9623658a5"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" + +[[package]] +name = "fastapi" +version = "0.110.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.110.2-py3-none-any.whl", hash = "sha256:239403f2c0a3dda07a9420f95157a7f014ddb2b770acdbc984f9bdf3ead7afdb"}, + {file = "fastapi-0.110.2.tar.gz", hash = "sha256:b53d673652da3b65e8cd787ad214ec0fe303cad00d2b529b86ce7db13f17518d"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.37.2,<0.38.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "fastjsonschema" +version = "2.19.1" +description = "Fastest Python implementation of JSON schema" +optional = false +python-versions = "*" +files = [ + {file = "fastjsonschema-2.19.1-py3-none-any.whl", hash = "sha256:3672b47bc94178c9f23dbb654bf47440155d4db9df5f7bc47643315f9c405cd0"}, + {file = "fastjsonschema-2.19.1.tar.gz", hash = "sha256:e3126a94bdc4623d3de4485f8d468a12f02a67921315ddc87836d6e456dc789d"}, +] + +[package.extras] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] + +[[package]] +name = "filelock" +version = "3.13.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, + {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "fonttools" +version = "4.51.0" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fonttools-4.51.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:84d7751f4468dd8cdd03ddada18b8b0857a5beec80bce9f435742abc9a851a74"}, + {file = "fonttools-4.51.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8b4850fa2ef2cfbc1d1f689bc159ef0f45d8d83298c1425838095bf53ef46308"}, + {file = "fonttools-4.51.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5b48a1121117047d82695d276c2af2ee3a24ffe0f502ed581acc2673ecf1037"}, + {file = "fonttools-4.51.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:180194c7fe60c989bb627d7ed5011f2bef1c4d36ecf3ec64daec8302f1ae0716"}, + {file = "fonttools-4.51.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:96a48e137c36be55e68845fc4284533bda2980f8d6f835e26bca79d7e2006438"}, + {file = "fonttools-4.51.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:806e7912c32a657fa39d2d6eb1d3012d35f841387c8fc6cf349ed70b7c340039"}, + {file = "fonttools-4.51.0-cp310-cp310-win32.whl", hash = "sha256:32b17504696f605e9e960647c5f64b35704782a502cc26a37b800b4d69ff3c77"}, + {file = "fonttools-4.51.0-cp310-cp310-win_amd64.whl", hash = "sha256:c7e91abdfae1b5c9e3a543f48ce96013f9a08c6c9668f1e6be0beabf0a569c1b"}, + {file = "fonttools-4.51.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a8feca65bab31479d795b0d16c9a9852902e3a3c0630678efb0b2b7941ea9c74"}, + {file = "fonttools-4.51.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ac27f436e8af7779f0bb4d5425aa3535270494d3bc5459ed27de3f03151e4c2"}, + {file = "fonttools-4.51.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e19bd9e9964a09cd2433a4b100ca7f34e34731e0758e13ba9a1ed6e5468cc0f"}, + {file = "fonttools-4.51.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2b92381f37b39ba2fc98c3a45a9d6383bfc9916a87d66ccb6553f7bdd129097"}, + {file = "fonttools-4.51.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5f6bc991d1610f5c3bbe997b0233cbc234b8e82fa99fc0b2932dc1ca5e5afec0"}, + {file = "fonttools-4.51.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9696fe9f3f0c32e9a321d5268208a7cc9205a52f99b89479d1b035ed54c923f1"}, + {file = "fonttools-4.51.0-cp311-cp311-win32.whl", hash = "sha256:3bee3f3bd9fa1d5ee616ccfd13b27ca605c2b4270e45715bd2883e9504735034"}, + {file = "fonttools-4.51.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f08c901d3866a8905363619e3741c33f0a83a680d92a9f0e575985c2634fcc1"}, + {file = "fonttools-4.51.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4060acc2bfa2d8e98117828a238889f13b6f69d59f4f2d5857eece5277b829ba"}, + {file = "fonttools-4.51.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1250e818b5f8a679ad79660855528120a8f0288f8f30ec88b83db51515411fcc"}, + {file = "fonttools-4.51.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76f1777d8b3386479ffb4a282e74318e730014d86ce60f016908d9801af9ca2a"}, + {file = "fonttools-4.51.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b5ad456813d93b9c4b7ee55302208db2b45324315129d85275c01f5cb7e61a2"}, + {file = "fonttools-4.51.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:68b3fb7775a923be73e739f92f7e8a72725fd333eab24834041365d2278c3671"}, + {file = "fonttools-4.51.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8e2f1a4499e3b5ee82c19b5ee57f0294673125c65b0a1ff3764ea1f9db2f9ef5"}, + {file = "fonttools-4.51.0-cp312-cp312-win32.whl", hash = "sha256:278e50f6b003c6aed19bae2242b364e575bcb16304b53f2b64f6551b9c000e15"}, + {file = "fonttools-4.51.0-cp312-cp312-win_amd64.whl", hash = "sha256:b3c61423f22165541b9403ee39874dcae84cd57a9078b82e1dce8cb06b07fa2e"}, + {file = "fonttools-4.51.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1621ee57da887c17312acc4b0e7ac30d3a4fb0fec6174b2e3754a74c26bbed1e"}, + {file = "fonttools-4.51.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d9298be7a05bb4801f558522adbe2feea1b0b103d5294ebf24a92dd49b78e5"}, + {file = "fonttools-4.51.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee1af4be1c5afe4c96ca23badd368d8dc75f611887fb0c0dac9f71ee5d6f110e"}, + {file = "fonttools-4.51.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c18b49adc721a7d0b8dfe7c3130c89b8704baf599fb396396d07d4aa69b824a1"}, + {file = "fonttools-4.51.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:de7c29bdbdd35811f14493ffd2534b88f0ce1b9065316433b22d63ca1cd21f14"}, + {file = "fonttools-4.51.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cadf4e12a608ef1d13e039864f484c8a968840afa0258b0b843a0556497ea9ed"}, + {file = "fonttools-4.51.0-cp38-cp38-win32.whl", hash = "sha256:aefa011207ed36cd280babfaa8510b8176f1a77261833e895a9d96e57e44802f"}, + {file = "fonttools-4.51.0-cp38-cp38-win_amd64.whl", hash = "sha256:865a58b6e60b0938874af0968cd0553bcd88e0b2cb6e588727117bd099eef836"}, + {file = "fonttools-4.51.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:60a3409c9112aec02d5fb546f557bca6efa773dcb32ac147c6baf5f742e6258b"}, + {file = "fonttools-4.51.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7e89853d8bea103c8e3514b9f9dc86b5b4120afb4583b57eb10dfa5afbe0936"}, + {file = "fonttools-4.51.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56fc244f2585d6c00b9bcc59e6593e646cf095a96fe68d62cd4da53dd1287b55"}, + {file = "fonttools-4.51.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d145976194a5242fdd22df18a1b451481a88071feadf251221af110ca8f00ce"}, + {file = "fonttools-4.51.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5b8cab0c137ca229433570151b5c1fc6af212680b58b15abd797dcdd9dd5051"}, + {file = "fonttools-4.51.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:54dcf21a2f2d06ded676e3c3f9f74b2bafded3a8ff12f0983160b13e9f2fb4a7"}, + {file = "fonttools-4.51.0-cp39-cp39-win32.whl", hash = "sha256:0118ef998a0699a96c7b28457f15546815015a2710a1b23a7bf6c1be60c01636"}, + {file = "fonttools-4.51.0-cp39-cp39-win_amd64.whl", hash = "sha256:599bdb75e220241cedc6faebfafedd7670335d2e29620d207dd0378a4e9ccc5a"}, + {file = "fonttools-4.51.0-py3-none-any.whl", hash = "sha256:15c94eeef6b095831067f72c825eb0e2d48bb4cea0647c1b05c981ecba2bf39f"}, + {file = "fonttools-4.51.0.tar.gz", hash = "sha256:dc0673361331566d7a663d7ce0f6fdcbfbdc1f59c6e3ed1165ad7202ca183c68"}, +] + +[package.extras] +all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres", "pycairo", "scipy"] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr"] +ufo = ["fs (>=2.2.0,<3)"] +unicode = ["unicodedata2 (>=15.1.0)"] +woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] + +[[package]] +name = "fqdn" +version = "1.5.1" +description = "Validates fully-qualified domain names against RFC 1123, so that they are acceptable to modern bowsers" +optional = false +python-versions = ">=2.7, !=3.0, !=3.1, !=3.2, !=3.3, !=3.4, <4" +files = [ + {file = "fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014"}, + {file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"}, +] + +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + +[[package]] +name = "fsspec" +version = "2024.3.1" +description = "File-system specification" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fsspec-2024.3.1-py3-none-any.whl", hash = "sha256:918d18d41bf73f0e2b261824baeb1b124bcf771767e3a26425cd7dec3332f512"}, + {file = "fsspec-2024.3.1.tar.gz", hash = "sha256:f39780e282d7d117ffb42bb96992f8a90795e4d0fb0f661a70ca39fe9c43ded9"}, +] + +[package.dependencies] +aiohttp = {version = "<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1", optional = true, markers = "extra == \"http\""} + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +devel = ["pytest", "pytest-cov"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +tqdm = ["tqdm"] + +[[package]] +name = "googleapis-common-protos" +version = "1.63.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis-common-protos-1.63.0.tar.gz", hash = "sha256:17ad01b11d5f1d0171c06d3ba5c04c54474e883b66b949722b4938ee2694ef4e"}, + {file = "googleapis_common_protos-1.63.0-py2.py3-none-any.whl", hash = "sha256:ae45f75702f7c08b541f750854a678bd8f534a1a6bace6afe975f1d0a82d6632"}, +] + +[package.dependencies] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "grpcio" +version = "1.62.2" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpcio-1.62.2-cp310-cp310-linux_armv7l.whl", hash = "sha256:66344ea741124c38588a664237ac2fa16dfd226964cca23ddc96bd4accccbde5"}, + {file = "grpcio-1.62.2-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:5dab7ac2c1e7cb6179c6bfad6b63174851102cbe0682294e6b1d6f0981ad7138"}, + {file = "grpcio-1.62.2-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:3ad00f3f0718894749d5a8bb0fa125a7980a2f49523731a9b1fabf2b3522aa43"}, + {file = "grpcio-1.62.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e72ddfee62430ea80133d2cbe788e0d06b12f865765cb24a40009668bd8ea05"}, + {file = "grpcio-1.62.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53d3a59a10af4c2558a8e563aed9f256259d2992ae0d3037817b2155f0341de1"}, + {file = "grpcio-1.62.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1511a303f8074f67af4119275b4f954189e8313541da7b88b1b3a71425cdb10"}, + {file = "grpcio-1.62.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b94d41b7412ef149743fbc3178e59d95228a7064c5ab4760ae82b562bdffb199"}, + {file = "grpcio-1.62.2-cp310-cp310-win32.whl", hash = "sha256:a75af2fc7cb1fe25785be7bed1ab18cef959a376cdae7c6870184307614caa3f"}, + {file = "grpcio-1.62.2-cp310-cp310-win_amd64.whl", hash = "sha256:80407bc007754f108dc2061e37480238b0dc1952c855e86a4fc283501ee6bb5d"}, + {file = "grpcio-1.62.2-cp311-cp311-linux_armv7l.whl", hash = "sha256:c1624aa686d4b36790ed1c2e2306cc3498778dffaf7b8dd47066cf819028c3ad"}, + {file = "grpcio-1.62.2-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:1c1bb80299bdef33309dff03932264636450c8fdb142ea39f47e06a7153d3063"}, + {file = "grpcio-1.62.2-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:db068bbc9b1fa16479a82e1ecf172a93874540cb84be69f0b9cb9b7ac3c82670"}, + {file = "grpcio-1.62.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2cc8a308780edbe2c4913d6a49dbdb5befacdf72d489a368566be44cadaef1a"}, + {file = "grpcio-1.62.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0695ae31a89f1a8fc8256050329a91a9995b549a88619263a594ca31b76d756"}, + {file = "grpcio-1.62.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88b4f9ee77191dcdd8810241e89340a12cbe050be3e0d5f2f091c15571cd3930"}, + {file = "grpcio-1.62.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2a0204532aa2f1afd467024b02b4069246320405bc18abec7babab03e2644e75"}, + {file = "grpcio-1.62.2-cp311-cp311-win32.whl", hash = "sha256:6e784f60e575a0de554ef9251cbc2ceb8790914fe324f11e28450047f264ee6f"}, + {file = "grpcio-1.62.2-cp311-cp311-win_amd64.whl", hash = "sha256:112eaa7865dd9e6d7c0556c8b04ae3c3a2dc35d62ad3373ab7f6a562d8199200"}, + {file = "grpcio-1.62.2-cp312-cp312-linux_armv7l.whl", hash = "sha256:65034473fc09628a02fb85f26e73885cf1ed39ebd9cf270247b38689ff5942c5"}, + {file = "grpcio-1.62.2-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:d2c1771d0ee3cf72d69bb5e82c6a82f27fbd504c8c782575eddb7839729fbaad"}, + {file = "grpcio-1.62.2-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:3abe6838196da518863b5d549938ce3159d809218936851b395b09cad9b5d64a"}, + {file = "grpcio-1.62.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5ffeb269f10cedb4f33142b89a061acda9f672fd1357331dbfd043422c94e9e"}, + {file = "grpcio-1.62.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404d3b4b6b142b99ba1cff0b2177d26b623101ea2ce51c25ef6e53d9d0d87bcc"}, + {file = "grpcio-1.62.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:262cda97efdabb20853d3b5a4c546a535347c14b64c017f628ca0cc7fa780cc6"}, + {file = "grpcio-1.62.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17708db5b11b966373e21519c4c73e5a750555f02fde82276ea2a267077c68ad"}, + {file = "grpcio-1.62.2-cp312-cp312-win32.whl", hash = "sha256:b7ec9e2f8ffc8436f6b642a10019fc513722858f295f7efc28de135d336ac189"}, + {file = "grpcio-1.62.2-cp312-cp312-win_amd64.whl", hash = "sha256:aa787b83a3cd5e482e5c79be030e2b4a122ecc6c5c6c4c42a023a2b581fdf17b"}, + {file = "grpcio-1.62.2-cp37-cp37m-linux_armv7l.whl", hash = "sha256:cfd23ad29bfa13fd4188433b0e250f84ec2c8ba66b14a9877e8bce05b524cf54"}, + {file = "grpcio-1.62.2-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:af15e9efa4d776dfcecd1d083f3ccfb04f876d613e90ef8432432efbeeac689d"}, + {file = "grpcio-1.62.2-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:f4aa94361bb5141a45ca9187464ae81a92a2a135ce2800b2203134f7a1a1d479"}, + {file = "grpcio-1.62.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82af3613a219512a28ee5c95578eb38d44dd03bca02fd918aa05603c41018051"}, + {file = "grpcio-1.62.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55ddaf53474e8caeb29eb03e3202f9d827ad3110475a21245f3c7712022882a9"}, + {file = "grpcio-1.62.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c79b518c56dddeec79e5500a53d8a4db90da995dfe1738c3ac57fe46348be049"}, + {file = "grpcio-1.62.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a5eb4844e5e60bf2c446ef38c5b40d7752c6effdee882f716eb57ae87255d20a"}, + {file = "grpcio-1.62.2-cp37-cp37m-win_amd64.whl", hash = "sha256:aaae70364a2d1fb238afd6cc9fcb10442b66e397fd559d3f0968d28cc3ac929c"}, + {file = "grpcio-1.62.2-cp38-cp38-linux_armv7l.whl", hash = "sha256:1bcfe5070e4406f489e39325b76caeadab28c32bf9252d3ae960c79935a4cc36"}, + {file = "grpcio-1.62.2-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:da6a7b6b938c15fa0f0568e482efaae9c3af31963eec2da4ff13a6d8ec2888e4"}, + {file = "grpcio-1.62.2-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:41955b641c34db7d84db8d306937b72bc4968eef1c401bea73081a8d6c3d8033"}, + {file = "grpcio-1.62.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c772f225483905f675cb36a025969eef9712f4698364ecd3a63093760deea1bc"}, + {file = "grpcio-1.62.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07ce1f775d37ca18c7a141300e5b71539690efa1f51fe17f812ca85b5e73262f"}, + {file = "grpcio-1.62.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:26f415f40f4a93579fd648f48dca1c13dfacdfd0290f4a30f9b9aeb745026811"}, + {file = "grpcio-1.62.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:db707e3685ff16fc1eccad68527d072ac8bdd2e390f6daa97bc394ea7de4acea"}, + {file = "grpcio-1.62.2-cp38-cp38-win32.whl", hash = "sha256:589ea8e75de5fd6df387de53af6c9189c5231e212b9aa306b6b0d4f07520fbb9"}, + {file = "grpcio-1.62.2-cp38-cp38-win_amd64.whl", hash = "sha256:3c3ed41f4d7a3aabf0f01ecc70d6b5d00ce1800d4af652a549de3f7cf35c4abd"}, + {file = "grpcio-1.62.2-cp39-cp39-linux_armv7l.whl", hash = "sha256:162ccf61499c893831b8437120600290a99c0bc1ce7b51f2c8d21ec87ff6af8b"}, + {file = "grpcio-1.62.2-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:f27246d7da7d7e3bd8612f63785a7b0c39a244cf14b8dd9dd2f2fab939f2d7f1"}, + {file = "grpcio-1.62.2-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:2507006c8a478f19e99b6fe36a2464696b89d40d88f34e4b709abe57e1337467"}, + {file = "grpcio-1.62.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a90ac47a8ce934e2c8d71e317d2f9e7e6aaceb2d199de940ce2c2eb611b8c0f4"}, + {file = "grpcio-1.62.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99701979bcaaa7de8d5f60476487c5df8f27483624f1f7e300ff4669ee44d1f2"}, + {file = "grpcio-1.62.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:af7dc3f7a44f10863b1b0ecab4078f0a00f561aae1edbd01fd03ad4dcf61c9e9"}, + {file = "grpcio-1.62.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fa63245271920786f4cb44dcada4983a3516be8f470924528cf658731864c14b"}, + {file = "grpcio-1.62.2-cp39-cp39-win32.whl", hash = "sha256:c6ad9c39704256ed91a1cffc1379d63f7d0278d6a0bad06b0330f5d30291e3a3"}, + {file = "grpcio-1.62.2-cp39-cp39-win_amd64.whl", hash = "sha256:16da954692fd61aa4941fbeda405a756cd96b97b5d95ca58a92547bba2c1624f"}, + {file = "grpcio-1.62.2.tar.gz", hash = "sha256:c77618071d96b7a8be2c10701a98537823b9c65ba256c0b9067e0594cdbd954d"}, +] + +[package.extras] +protobuf = ["grpcio-tools (>=1.62.2)"] + +[[package]] +name = "grpcio-tools" +version = "1.62.2" +description = "Protobuf code generator for gRPC" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpcio-tools-1.62.2.tar.gz", hash = "sha256:5fd5e1582b678e6b941ee5f5809340be5e0724691df5299aae8226640f94e18f"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-linux_armv7l.whl", hash = "sha256:1679b4903aed2dc5bd8cb22a452225b05dc8470a076f14fd703581efc0740cdb"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:9d41e0e47dd075c075bb8f103422968a65dd0d8dc8613288f573ae91eb1053ba"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:987e774f74296842bbffd55ea8826370f70c499e5b5f71a8cf3103838b6ee9c3"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40cd4eeea4b25bcb6903b82930d579027d034ba944393c4751cdefd9c49e6989"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6746bc823958499a3cf8963cc1de00072962fb5e629f26d658882d3f4c35095"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2ed775e844566ce9ce089be9a81a8b928623b8ee5820f5e4d58c1a9d33dfc5ae"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bdc5dd3f57b5368d5d661d5d3703bcaa38bceca59d25955dff66244dbc987271"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-win32.whl", hash = "sha256:3a8d6f07e64c0c7756f4e0c4781d9d5a2b9cc9cbd28f7032a6fb8d4f847d0445"}, + {file = "grpcio_tools-1.62.2-cp310-cp310-win_amd64.whl", hash = "sha256:e33b59fb3efdddeb97ded988a871710033e8638534c826567738d3edce528752"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-linux_armv7l.whl", hash = "sha256:472505d030135d73afe4143b0873efe0dcb385bd6d847553b4f3afe07679af00"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:ec674b4440ef4311ac1245a709e87b36aca493ddc6850eebe0b278d1f2b6e7d1"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:184b4174d4bd82089d706e8223e46c42390a6ebac191073b9772abc77308f9fa"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c195d74fe98541178ece7a50dad2197d43991e0f77372b9a88da438be2486f12"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a34d97c62e61bfe9e6cff0410fe144ac8cca2fc979ad0be46b7edf026339d161"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cbb8453ae83a1db2452b7fe0f4b78e4a8dd32be0f2b2b73591ae620d4d784d3d"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f989e5cebead3ae92c6abf6bf7b19949e1563a776aea896ac5933f143f0c45d"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-win32.whl", hash = "sha256:c48fabe40b9170f4e3d7dd2c252e4f1ff395dc24e49ac15fc724b1b6f11724da"}, + {file = "grpcio_tools-1.62.2-cp311-cp311-win_amd64.whl", hash = "sha256:8c616d0ad872e3780693fce6a3ac8ef00fc0963e6d7815ce9dcfae68ba0fc287"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-linux_armv7l.whl", hash = "sha256:10cc3321704ecd17c93cf68c99c35467a8a97ffaaed53207e9b2da6ae0308ee1"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:9be84ff6d47fd61462be7523b49d7ba01adf67ce4e1447eae37721ab32464dd8"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:d82f681c9a9d933a9d8068e8e382977768e7779ddb8870fa0cf918d8250d1532"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04c607029ae3660fb1624ed273811ffe09d57d84287d37e63b5b802a35897329"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72b61332f1b439c14cbd3815174a8f1d35067a02047c32decd406b3a09bb9890"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8214820990d01b52845f9fbcb92d2b7384a0c321b303e3ac614c219dc7d1d3af"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:462e0ab8dd7c7b70bfd6e3195eebc177549ede5cf3189814850c76f9a340d7ce"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-win32.whl", hash = "sha256:fa107460c842e4c1a6266150881694fefd4f33baa544ea9489601810c2210ef8"}, + {file = "grpcio_tools-1.62.2-cp312-cp312-win_amd64.whl", hash = "sha256:759c60f24c33a181bbbc1232a6752f9b49fbb1583312a4917e2b389fea0fb0f2"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-linux_armv7l.whl", hash = "sha256:45db5da2bcfa88f2b86b57ef35daaae85c60bd6754a051d35d9449c959925b57"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:ab84bae88597133f6ea7a2bdc57b2fda98a266fe8d8d4763652cbefd20e73ad7"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:7a49bccae1c7d154b78e991885c3111c9ad8c8fa98e91233de425718f47c6139"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e439476b29d6dac363b321781a113794397afceeb97dad85349db5f1cb5e9a"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ea369c4d1567d1acdf69c8ea74144f4ccad9e545df7f9a4fc64c94fa7684ba3"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f955702dc4b530696375251319d05223b729ed24e8673c2129f7a75d2caefbb"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3708a747aa4b6b505727282ca887041174e146ae030ebcadaf4c1d346858df62"}, + {file = "grpcio_tools-1.62.2-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce149ea55eadb486a7fb75a20f63ef3ac065ee6a0240ed25f3549ce7954c653"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-linux_armv7l.whl", hash = "sha256:58cbb24b3fa6ae35aa9c210fcea3a51aa5fef0cd25618eb4fd94f746d5a9b703"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:6413581e14a80e0b4532577766cf0586de4dd33766a31b3eb5374a746771c07d"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:47117c8a7e861382470d0e22d336e5a91fdc5f851d1db44fa784b9acea190d87"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f1ba79a253df9e553d20319c615fa2b429684580fa042dba618d7f6649ac7e4"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04a394cf5e51ba9be412eb9f6c482b6270bd81016e033e8eb7d21b8cc28fe8b5"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3c53b221378b035ae2f1881cbc3aca42a6075a8e90e1a342c2f205eb1d1aa6a1"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c384c838b34d1b67068e51b5bbe49caa6aa3633acd158f1ab16b5da8d226bc53"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-win32.whl", hash = "sha256:19ea69e41c3565932aa28a202d1875ec56786aea46a2eab54a3b28e8a27f9517"}, + {file = "grpcio_tools-1.62.2-cp38-cp38-win_amd64.whl", hash = "sha256:1d768a5c07279a4c461ebf52d0cec1c6ca85c6291c71ec2703fe3c3e7e28e8c4"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-linux_armv7l.whl", hash = "sha256:5b07b5874187e170edfbd7aa2ca3a54ebf3b2952487653e8c0b0d83601c33035"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:d58389fe8be206ddfb4fa703db1e24c956856fcb9a81da62b13577b3a8f7fda7"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:7d8b4e00c3d7237b92260fc18a561cd81f1da82e8be100db1b7d816250defc66"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe08d2038f2b7c53259b5c49e0ad08c8e0ce2b548d8185993e7ef67e8592cca"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19216e1fb26dbe23d12a810517e1b3fbb8d4f98b1a3fbebeec9d93a79f092de4"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b8574469ecc4ff41d6bb95f44e0297cdb0d95bade388552a9a444db9cd7485cd"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4f6f32d39283ea834a493fccf0ebe9cfddee7577bdcc27736ad4be1732a36399"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-win32.whl", hash = "sha256:76eb459bdf3fb666e01883270beee18f3f11ed44488486b61cd210b4e0e17cc1"}, + {file = "grpcio_tools-1.62.2-cp39-cp39-win_amd64.whl", hash = "sha256:217c2ee6a7ce519a55958b8622e21804f6fdb774db08c322f4c9536c35fdce7c"}, +] + +[package.dependencies] +grpcio = ">=1.62.2" +protobuf = ">=4.21.6,<5.0dev" +setuptools = "*" + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "h2" +version = "4.1.0" +description = "HTTP/2 State-Machine based protocol implementation" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, + {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, +] + +[package.dependencies] +hpack = ">=4.0,<5" +hyperframe = ">=6.0,<7" + +[[package]] +name = "hpack" +version = "4.0.0" +description = "Pure-Python HPACK header compression" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, + {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "huggingface-hub" +version = "0.22.2" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "huggingface_hub-0.22.2-py3-none-any.whl", hash = "sha256:3429e25f38ccb834d310804a3b711e7e4953db5a9e420cc147a5e194ca90fd17"}, + {file = "huggingface_hub-0.22.2.tar.gz", hash = "sha256:32e9a9a6843c92f253ff9ca16b9985def4d80a93fb357af5353f770ef74a81be"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +packaging = ">=20.9" +pyyaml = ">=5.1" +requests = "*" +tqdm = ">=4.42.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.3.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +cli = ["InquirerPy (==0.3.4)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.3.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf-transfer (>=0.1.4)"] +inference = ["aiohttp", "minijinja (>=1.0)"] +quality = ["mypy (==1.5.1)", "ruff (>=0.3.0)"] +tensorflow = ["graphviz", "pydot", "tensorflow"] +tensorflow-testing = ["keras (<3.0)", "tensorflow"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "minijinja (>=1.0)", "numpy", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors", "torch"] +typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + +[[package]] +name = "hypercorn" +version = "0.16.0" +description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" +optional = false +python-versions = ">=3.8" +files = [ + {file = "hypercorn-0.16.0-py3-none-any.whl", hash = "sha256:929e45c4acde3fbf7c58edf55336d30a009d2b4cb1f1eb96e6a515d61b663f58"}, + {file = "hypercorn-0.16.0.tar.gz", hash = "sha256:3b17d1dcf4992c1f262d9f9dd799c374125d0b9a8e40e1e2d11e2938b0adfe03"}, +] + +[package.dependencies] +h11 = "*" +h2 = ">=3.1.0" +priority = "*" +taskgroup = {version = "*", markers = "python_version < \"3.11\""} +tomli = {version = "*", markers = "python_version < \"3.11\""} +wsproto = ">=0.14.0" + +[package.extras] +docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"] +h3 = ["aioquic (>=0.9.0,<1.0)"] +trio = ["exceptiongroup (>=1.1.0)", "trio (>=0.22.0)"] +uvloop = ["uvloop"] + +[[package]] +name = "hyperframe" +version = "6.0.1" +description = "HTTP/2 framing layer for Python" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, + {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, +] + +[[package]] +name = "identify" +version = "2.5.36" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "6.11.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b"}, + {file = "importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "importlib-resources" +version = "6.4.0" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.4.0-py3-none-any.whl", hash = "sha256:50d10f043df931902d4194ea07ec57960f66a80449ff867bfe782b4c486ba78c"}, + {file = "importlib_resources-6.4.0.tar.gz", hash = "sha256:cdb2b453b8046ca4e3798eb1d84f3cce1446a0e8e7b5ef4efb600f19fc398145"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test (>=5.4)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "ipykernel" +version = "6.29.4" +description = "IPython Kernel for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ipykernel-6.29.4-py3-none-any.whl", hash = "sha256:1181e653d95c6808039c509ef8e67c4126b3b3af7781496c7cbfb5ed938a27da"}, + {file = "ipykernel-6.29.4.tar.gz", hash = "sha256:3d44070060f9475ac2092b760123fadf105d2e2493c24848b6691a7c4f42af5c"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "platform_system == \"Darwin\""} +comm = ">=0.1.1" +debugpy = ">=1.6.5" +ipython = ">=7.23.1" +jupyter-client = ">=6.1.12" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +matplotlib-inline = ">=0.1" +nest-asyncio = "*" +packaging = "*" +psutil = "*" +pyzmq = ">=24" +tornado = ">=6.1" +traitlets = ">=5.4.0" + +[package.extras] +cov = ["coverage[toml]", "curio", "matplotlib", "pytest-cov", "trio"] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] +pyqt5 = ["pyqt5"] +pyside6 = ["pyside6"] +test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "ipython" +version = "8.24.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.24.0-py3-none-any.whl", hash = "sha256:d7bf2f6c4314984e3e02393213bab8703cf163ede39672ce5918c51fe253a2a3"}, + {file = "ipython-8.24.0.tar.gz", hash = "sha256:010db3f8a728a578bb641fdd06c063b9fb8e96a9464c63aec6310fbcb5e80501"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5.13.0" +typing-extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "stack-data", "typing-extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + +[[package]] +name = "ipywidgets" +version = "8.1.2" +description = "Jupyter interactive widgets" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ipywidgets-8.1.2-py3-none-any.whl", hash = "sha256:bbe43850d79fb5e906b14801d6c01402857996864d1e5b6fa62dd2ee35559f60"}, + {file = "ipywidgets-8.1.2.tar.gz", hash = "sha256:d0b9b41e49bae926a866e613a39b0f0097745d2b9f1f3dd406641b4a57ec42c9"}, +] + +[package.dependencies] +comm = ">=0.1.3" +ipython = ">=6.1.0" +jupyterlab-widgets = ">=3.0.10,<3.1.0" +traitlets = ">=4.3.1" +widgetsnbextension = ">=4.0.10,<4.1.0" + +[package.extras] +test = ["ipykernel", "jsonschema", "pytest (>=3.6.0)", "pytest-cov", "pytz"] + +[[package]] +name = "isoduration" +version = "20.11.0" +description = "Operations with ISO 8601 durations" +optional = false +python-versions = ">=3.7" +files = [ + {file = "isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042"}, + {file = "isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9"}, +] + +[package.dependencies] +arrow = ">=0.15.0" + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "joblib" +version = "1.4.0" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "joblib-1.4.0-py3-none-any.whl", hash = "sha256:42942470d4062537be4d54c83511186da1fc14ba354961a2114da91efa9a4ed7"}, + {file = "joblib-1.4.0.tar.gz", hash = "sha256:1eb0dc091919cd384490de890cb5dfd538410a6d4b3b54eef09fb8c50b409b1c"}, +] + +[[package]] +name = "json5" +version = "0.9.25" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8" +files = [ + {file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"}, + {file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"}, +] + +[[package]] +name = "jsonpointer" +version = "2.4" +description = "Identify specific nodes in a JSON document (RFC 6901)" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +files = [ + {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, + {file = "jsonpointer-2.4.tar.gz", hash = "sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"}, +] + +[[package]] +name = "jsonschema" +version = "4.21.1" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.21.1-py3-none-any.whl", hash = "sha256:7996507afae316306f9e2290407761157c6f78002dcf7419acb99822143d1c6f"}, + {file = "jsonschema-4.21.1.tar.gz", hash = "sha256:85727c00279f5fa6bedbe6238d2aa6403bedd8b4864ab11207d07df3cc1b2ee5"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} +idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} +isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} +jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} +rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} +rpds-py = ">=0.7.1" +uri-template = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} +webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format-nongpl\""} + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] + +[[package]] +name = "jsonschema-specifications" +version = "2023.12.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, + {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "jupyter" +version = "1.0.0" +description = "Jupyter metapackage. Install all the Jupyter components in one go." +optional = false +python-versions = "*" +files = [ + {file = "jupyter-1.0.0-py2.py3-none-any.whl", hash = "sha256:5b290f93b98ffbc21c0c7e749f054b3267782166d72fa5e3ed1ed4eaf34a2b78"}, + {file = "jupyter-1.0.0.tar.gz", hash = "sha256:d9dc4b3318f310e34c82951ea5d6683f67bed7def4b259fafbfe4f1beb1d8e5f"}, + {file = "jupyter-1.0.0.zip", hash = "sha256:3e1f86076bbb7c8c207829390305a2b1fe836d471ed54be66a3b8c41e7f46cc7"}, +] + +[package.dependencies] +ipykernel = "*" +ipywidgets = "*" +jupyter-console = "*" +nbconvert = "*" +notebook = "*" +qtconsole = "*" + +[[package]] +name = "jupyter-client" +version = "8.6.1" +description = "Jupyter protocol implementation and client libraries" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_client-8.6.1-py3-none-any.whl", hash = "sha256:3b7bd22f058434e3b9a7ea4b1500ed47de2713872288c0d511d19926f99b459f"}, + {file = "jupyter_client-8.6.1.tar.gz", hash = "sha256:e842515e2bab8e19186d89fdfea7abd15e39dd581f94e399f00e2af5a1652d3f"}, +] + +[package.dependencies] +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +python-dateutil = ">=2.8.2" +pyzmq = ">=23.0" +tornado = ">=6.2" +traitlets = ">=5.3" + +[package.extras] +docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +description = "Jupyter terminal console" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485"}, + {file = "jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539"}, +] + +[package.dependencies] +ipykernel = ">=6.14" +ipython = "*" +jupyter-client = ">=7.0.0" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +prompt-toolkit = ">=3.0.30" +pygments = "*" +pyzmq = ">=17" +traitlets = ">=5.4" + +[package.extras] +test = ["flaky", "pexpect", "pytest"] + +[[package]] +name = "jupyter-core" +version = "5.7.2" +description = "Jupyter core package. A base package on which Jupyter projects rely." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409"}, + {file = "jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9"}, +] + +[package.dependencies] +platformdirs = ">=2.5" +pywin32 = {version = ">=300", markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\""} +traitlets = ">=5.3" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "traitlets"] +test = ["ipykernel", "pre-commit", "pytest (<8)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "jupyter-events" +version = "0.10.0" +description = "Jupyter Event System library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_events-0.10.0-py3-none-any.whl", hash = "sha256:4b72130875e59d57716d327ea70d3ebc3af1944d3717e5a498b8a06c6c159960"}, + {file = "jupyter_events-0.10.0.tar.gz", hash = "sha256:670b8229d3cc882ec782144ed22e0d29e1c2d639263f92ca8383e66682845e22"}, +] + +[package.dependencies] +jsonschema = {version = ">=4.18.0", extras = ["format-nongpl"]} +python-json-logger = ">=2.0.4" +pyyaml = ">=5.3" +referencing = "*" +rfc3339-validator = "*" +rfc3986-validator = ">=0.1.1" +traitlets = ">=5.3" + +[package.extras] +cli = ["click", "rich"] +docs = ["jupyterlite-sphinx", "myst-parser", "pydata-sphinx-theme", "sphinxcontrib-spelling"] +test = ["click", "pre-commit", "pytest (>=7.0)", "pytest-asyncio (>=0.19.0)", "pytest-console-scripts", "rich"] + +[[package]] +name = "jupyter-lsp" +version = "2.2.5" +description = "Multi-Language Server WebSocket proxy for Jupyter Notebook/Lab server" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter-lsp-2.2.5.tar.gz", hash = "sha256:793147a05ad446f809fd53ef1cd19a9f5256fd0a2d6b7ce943a982cb4f545001"}, + {file = "jupyter_lsp-2.2.5-py3-none-any.whl", hash = "sha256:45fbddbd505f3fbfb0b6cb2f1bc5e15e83ab7c79cd6e89416b248cb3c00c11da"}, +] + +[package.dependencies] +jupyter-server = ">=1.1.2" + +[[package]] +name = "jupyter-server" +version = "2.14.0" +description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_server-2.14.0-py3-none-any.whl", hash = "sha256:fb6be52c713e80e004fac34b35a0990d6d36ba06fd0a2b2ed82b899143a64210"}, + {file = "jupyter_server-2.14.0.tar.gz", hash = "sha256:659154cea512083434fd7c93b7fe0897af7a2fd0b9dd4749282b42eaac4ae677"}, +] + +[package.dependencies] +anyio = ">=3.1.0" +argon2-cffi = ">=21.1" +jinja2 = ">=3.0.3" +jupyter-client = ">=7.4.4" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-events = ">=0.9.0" +jupyter-server-terminals = ">=0.4.4" +nbconvert = ">=6.4.4" +nbformat = ">=5.3.0" +overrides = ">=5.0" +packaging = ">=22.0" +prometheus-client = ">=0.9" +pywinpty = {version = ">=2.0.1", markers = "os_name == \"nt\""} +pyzmq = ">=24" +send2trash = ">=1.8.2" +terminado = ">=0.8.3" +tornado = ">=6.2.0" +traitlets = ">=5.6.0" +websocket-client = ">=1.7" + +[package.extras] +docs = ["ipykernel", "jinja2", "jupyter-client", "jupyter-server", "myst-parser", "nbformat", "prometheus-client", "pydata-sphinx-theme", "send2trash", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-openapi (>=0.8.0)", "sphinxcontrib-spelling", "sphinxemoji", "tornado", "typing-extensions"] +test = ["flaky", "ipykernel", "pre-commit", "pytest (>=7.0,<9)", "pytest-console-scripts", "pytest-jupyter[server] (>=0.7)", "pytest-timeout", "requests"] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.3" +description = "A Jupyter Server Extension Providing Terminals." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa"}, + {file = "jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269"}, +] + +[package.dependencies] +pywinpty = {version = ">=2.0.3", markers = "os_name == \"nt\""} +terminado = ">=0.8.3" + +[package.extras] +docs = ["jinja2", "jupyter-server", "mistune (<4.0)", "myst-parser", "nbformat", "packaging", "pydata-sphinx-theme", "sphinxcontrib-github-alt", "sphinxcontrib-openapi", "sphinxcontrib-spelling", "sphinxemoji", "tornado"] +test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>=0.5.3)", "pytest-timeout"] + +[[package]] +name = "jupyterlab" +version = "4.1.7" +description = "JupyterLab computational environment" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyterlab-4.1.7-py3-none-any.whl", hash = "sha256:43ccd32a3afa641912e4e2d2875b8cebbebcead57a35e2987c43bf496ac49d58"}, + {file = "jupyterlab-4.1.7.tar.gz", hash = "sha256:32532a43d35d4aaab328722e738ee527915fd572a5c84ae5eeba6e409d0cdc55"}, +] + +[package.dependencies] +async-lru = ">=1.0.0" +httpx = ">=0.25.0" +ipykernel = ">=6.5.0" +jinja2 = ">=3.0.3" +jupyter-core = "*" +jupyter-lsp = ">=2.0.0" +jupyter-server = ">=2.4.0,<3" +jupyterlab-server = ">=2.27.1,<3" +notebook-shim = ">=0.2" +packaging = "*" +tomli = {version = ">=1.2.2", markers = "python_version < \"3.11\""} +tornado = ">=6.2.0" +traitlets = "*" + +[package.extras] +dev = ["build", "bump2version", "coverage", "hatch", "pre-commit", "pytest-cov", "ruff (==0.2.0)"] +docs = ["jsx-lexer", "myst-parser", "pydata-sphinx-theme (>=0.13.0)", "pytest", "pytest-check-links", "pytest-jupyter", "sphinx (>=1.8,<7.3.0)", "sphinx-copybutton"] +docs-screenshots = ["altair (==5.2.0)", "ipython (==8.16.1)", "ipywidgets (==8.1.1)", "jupyterlab-geojson (==3.4.0)", "jupyterlab-language-pack-zh-cn (==4.0.post6)", "matplotlib (==3.8.2)", "nbconvert (>=7.0.0)", "pandas (==2.2.0)", "scipy (==1.12.0)", "vega-datasets (==0.9.0)"] +test = ["coverage", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter (>=0.5.3)", "pytest-timeout", "pytest-tornasync", "requests", "requests-cache", "virtualenv"] +upgrade-extension = ["copier (>=8.0,<9.0)", "jinja2-time (<0.3)", "pydantic (<2.0)", "pyyaml-include (<2.0)", "tomli-w (<2.0)"] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +description = "Pygments theme using JupyterLab CSS variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780"}, + {file = "jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d"}, +] + +[[package]] +name = "jupyterlab-server" +version = "2.27.1" +description = "A set of server components for JupyterLab and JupyterLab like applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyterlab_server-2.27.1-py3-none-any.whl", hash = "sha256:f5e26156e5258b24d532c84e7c74cc212e203bff93eb856f81c24c16daeecc75"}, + {file = "jupyterlab_server-2.27.1.tar.gz", hash = "sha256:097b5ac709b676c7284ac9c5e373f11930a561f52cd5a86e4fc7e5a9c8a8631d"}, +] + +[package.dependencies] +babel = ">=2.10" +jinja2 = ">=3.0.3" +json5 = ">=0.9.0" +jsonschema = ">=4.18.0" +jupyter-server = ">=1.21,<3" +packaging = ">=21.3" +requests = ">=2.31" + +[package.extras] +docs = ["autodoc-traits", "jinja2 (<3.2.0)", "mistune (<4)", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinxcontrib-openapi (>0.8)"] +openapi = ["openapi-core (>=0.18.0,<0.19.0)", "ruamel-yaml"] +test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-validator (>=0.6.0,<0.8.0)", "pytest (>=7.0,<8)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter[server] (>=0.6.2)", "pytest-timeout", "requests-mock", "ruamel-yaml", "sphinxcontrib-spelling", "strict-rfc3339", "werkzeug"] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.10" +description = "Jupyter interactive widgets for JupyterLab" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jupyterlab_widgets-3.0.10-py3-none-any.whl", hash = "sha256:dd61f3ae7a5a7f80299e14585ce6cf3d6925a96c9103c978eda293197730cb64"}, + {file = "jupyterlab_widgets-3.0.10.tar.gz", hash = "sha256:04f2ac04976727e4f9d0fa91cdc2f1ab860f965e504c29dbd6a65c882c9d04c0"}, +] + +[[package]] +name = "kiwisolver" +version = "1.4.5" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.7" +files = [ + {file = "kiwisolver-1.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:05703cf211d585109fcd72207a31bb170a0f22144d68298dc5e61b3c946518af"}, + {file = "kiwisolver-1.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:146d14bebb7f1dc4d5fbf74f8a6cb15ac42baadee8912eb84ac0b3b2a3dc6ac3"}, + {file = "kiwisolver-1.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ef7afcd2d281494c0a9101d5c571970708ad911d028137cd558f02b851c08b4"}, + {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9eaa8b117dc8337728e834b9c6e2611f10c79e38f65157c4c38e9400286f5cb1"}, + {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec20916e7b4cbfb1f12380e46486ec4bcbaa91a9c448b97023fde0d5bbf9e4ff"}, + {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b42c68602539407884cf70d6a480a469b93b81b7701378ba5e2328660c847a"}, + {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa12042de0171fad672b6c59df69106d20d5596e4f87b5e8f76df757a7c399aa"}, + {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a40773c71d7ccdd3798f6489aaac9eee213d566850a9533f8d26332d626b82c"}, + {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:19df6e621f6d8b4b9c4d45f40a66839294ff2bb235e64d2178f7522d9170ac5b"}, + {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:83d78376d0d4fd884e2c114d0621624b73d2aba4e2788182d286309ebdeed770"}, + {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e391b1f0a8a5a10ab3b9bb6afcfd74f2175f24f8975fb87ecae700d1503cdee0"}, + {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:852542f9481f4a62dbb5dd99e8ab7aedfeb8fb6342349a181d4036877410f525"}, + {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59edc41b24031bc25108e210c0def6f6c2191210492a972d585a06ff246bb79b"}, + {file = "kiwisolver-1.4.5-cp310-cp310-win32.whl", hash = "sha256:a6aa6315319a052b4ee378aa171959c898a6183f15c1e541821c5c59beaa0238"}, + {file = "kiwisolver-1.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:d0ef46024e6a3d79c01ff13801cb19d0cad7fd859b15037aec74315540acc276"}, + {file = "kiwisolver-1.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:11863aa14a51fd6ec28688d76f1735f8f69ab1fabf388851a595d0721af042f5"}, + {file = "kiwisolver-1.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ab3919a9997ab7ef2fbbed0cc99bb28d3c13e6d4b1ad36e97e482558a91be90"}, + {file = "kiwisolver-1.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fcc700eadbbccbf6bc1bcb9dbe0786b4b1cb91ca0dcda336eef5c2beed37b797"}, + {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfdd7c0b105af050eb3d64997809dc21da247cf44e63dc73ff0fd20b96be55a9"}, + {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c6a5964640638cdeaa0c359382e5703e9293030fe730018ca06bc2010c4437"}, + {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbea0db94288e29afcc4c28afbf3a7ccaf2d7e027489c449cf7e8f83c6346eb9"}, + {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ceec1a6bc6cab1d6ff5d06592a91a692f90ec7505d6463a88a52cc0eb58545da"}, + {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:040c1aebeda72197ef477a906782b5ab0d387642e93bda547336b8957c61022e"}, + {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f91de7223d4c7b793867797bacd1ee53bfe7359bd70d27b7b58a04efbb9436c8"}, + {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:faae4860798c31530dd184046a900e652c95513796ef51a12bc086710c2eec4d"}, + {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b0157420efcb803e71d1b28e2c287518b8808b7cf1ab8af36718fd0a2c453eb0"}, + {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:06f54715b7737c2fecdbf140d1afb11a33d59508a47bf11bb38ecf21dc9ab79f"}, + {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fdb7adb641a0d13bdcd4ef48e062363d8a9ad4a182ac7647ec88f695e719ae9f"}, + {file = "kiwisolver-1.4.5-cp311-cp311-win32.whl", hash = "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac"}, + {file = "kiwisolver-1.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355"}, + {file = "kiwisolver-1.4.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:32d5cf40c4f7c7b3ca500f8985eb3fb3a7dfc023215e876f207956b5ea26632a"}, + {file = "kiwisolver-1.4.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f846c260f483d1fd217fe5ed7c173fb109efa6b1fc8381c8b7552c5781756192"}, + {file = "kiwisolver-1.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ff5cf3571589b6d13bfbfd6bcd7a3f659e42f96b5fd1c4830c4cf21d4f5ef45"}, + {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7269d9e5f1084a653d575c7ec012ff57f0c042258bf5db0954bf551c158466e7"}, + {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da802a19d6e15dffe4b0c24b38b3af68e6c1a68e6e1d8f30148c83864f3881db"}, + {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3aba7311af82e335dd1e36ffff68aaca609ca6290c2cb6d821a39aa075d8e3ff"}, + {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763773d53f07244148ccac5b084da5adb90bfaee39c197554f01b286cf869228"}, + {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2270953c0d8cdab5d422bee7d2007f043473f9d2999631c86a223c9db56cbd16"}, + {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d099e745a512f7e3bbe7249ca835f4d357c586d78d79ae8f1dcd4d8adeb9bda9"}, + {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:74db36e14a7d1ce0986fa104f7d5637aea5c82ca6326ed0ec5694280942d1162"}, + {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e5bab140c309cb3a6ce373a9e71eb7e4873c70c2dda01df6820474f9889d6d4"}, + {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0f114aa76dc1b8f636d077979c0ac22e7cd8f3493abbab152f20eb8d3cda71f3"}, + {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:88a2df29d4724b9237fc0c6eaf2a1adae0cdc0b3e9f4d8e7dc54b16812d2d81a"}, + {file = "kiwisolver-1.4.5-cp312-cp312-win32.whl", hash = "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20"}, + {file = "kiwisolver-1.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a2b053a0ab7a3960c98725cfb0bf5b48ba82f64ec95fe06f1d06c99b552e130"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd32d6c13807e5c66a7cbb79f90b553642f296ae4518a60d8d76243b0ad2898"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59ec7b7c7e1a61061850d53aaf8e93db63dce0c936db1fda2658b70e4a1be709"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da4cfb373035def307905d05041c1d06d8936452fe89d464743ae7fb8371078b"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2400873bccc260b6ae184b2b8a4fec0e4082d30648eadb7c3d9a13405d861e89"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1b04139c4236a0f3aff534479b58f6f849a8b351e1314826c2d230849ed48985"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:4e66e81a5779b65ac21764c295087de82235597a2293d18d943f8e9e32746265"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7931d8f1f67c4be9ba1dd9c451fb0eeca1a25b89e4d3f89e828fe12a519b782a"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b3f7e75f3015df442238cca659f8baa5f42ce2a8582727981cbfa15fee0ee205"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:bbf1d63eef84b2e8c89011b7f2235b1e0bf7dacc11cac9431fc6468e99ac77fb"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4c380469bd3f970ef677bf2bcba2b6b0b4d5c75e7a020fb863ef75084efad66f"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-win32.whl", hash = "sha256:9408acf3270c4b6baad483865191e3e582b638b1654a007c62e3efe96f09a9a3"}, + {file = "kiwisolver-1.4.5-cp37-cp37m-win_amd64.whl", hash = "sha256:5b94529f9b2591b7af5f3e0e730a4e0a41ea174af35a4fd067775f9bdfeee01a"}, + {file = "kiwisolver-1.4.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:11c7de8f692fc99816e8ac50d1d1aef4f75126eefc33ac79aac02c099fd3db71"}, + {file = "kiwisolver-1.4.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:53abb58632235cd154176ced1ae8f0d29a6657aa1aa9decf50b899b755bc2b93"}, + {file = "kiwisolver-1.4.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:88b9f257ca61b838b6f8094a62418421f87ac2a1069f7e896c36a7d86b5d4c29"}, + {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3195782b26fc03aa9c6913d5bad5aeb864bdc372924c093b0f1cebad603dd712"}, + {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc579bf0f502e54926519451b920e875f433aceb4624a3646b3252b5caa9e0b6"}, + {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a580c91d686376f0f7c295357595c5a026e6cbc3d77b7c36e290201e7c11ecb"}, + {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cfe6ab8da05c01ba6fbea630377b5da2cd9bcbc6338510116b01c1bc939a2c18"}, + {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d2e5a98f0ec99beb3c10e13b387f8db39106d53993f498b295f0c914328b1333"}, + {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a51a263952b1429e429ff236d2f5a21c5125437861baeed77f5e1cc2d2c7c6da"}, + {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3edd2fa14e68c9be82c5b16689e8d63d89fe927e56debd6e1dbce7a26a17f81b"}, + {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:74d1b44c6cfc897df648cc9fdaa09bc3e7679926e6f96df05775d4fb3946571c"}, + {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:76d9289ed3f7501012e05abb8358bbb129149dbd173f1f57a1bf1c22d19ab7cc"}, + {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:92dea1ffe3714fa8eb6a314d2b3c773208d865a0e0d35e713ec54eea08a66250"}, + {file = "kiwisolver-1.4.5-cp38-cp38-win32.whl", hash = "sha256:5c90ae8c8d32e472be041e76f9d2f2dbff4d0b0be8bd4041770eddb18cf49a4e"}, + {file = "kiwisolver-1.4.5-cp38-cp38-win_amd64.whl", hash = "sha256:c7940c1dc63eb37a67721b10d703247552416f719c4188c54e04334321351ced"}, + {file = "kiwisolver-1.4.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9407b6a5f0d675e8a827ad8742e1d6b49d9c1a1da5d952a67d50ef5f4170b18d"}, + {file = "kiwisolver-1.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15568384086b6df3c65353820a4473575dbad192e35010f622c6ce3eebd57af9"}, + {file = "kiwisolver-1.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0dc9db8e79f0036e8173c466d21ef18e1befc02de8bf8aa8dc0813a6dc8a7046"}, + {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cdc8a402aaee9a798b50d8b827d7ecf75edc5fb35ea0f91f213ff927c15f4ff0"}, + {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6c3bd3cde54cafb87d74d8db50b909705c62b17c2099b8f2e25b461882e544ff"}, + {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:955e8513d07a283056b1396e9a57ceddbd272d9252c14f154d450d227606eb54"}, + {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:346f5343b9e3f00b8db8ba359350eb124b98c99efd0b408728ac6ebf38173958"}, + {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9098e0049e88c6a24ff64545cdfc50807818ba6c1b739cae221bbbcbc58aad3"}, + {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:00bd361b903dc4bbf4eb165f24d1acbee754fce22ded24c3d56eec268658a5cf"}, + {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7b8b454bac16428b22560d0a1cf0a09875339cab69df61d7805bf48919415901"}, + {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f1d072c2eb0ad60d4c183f3fb44ac6f73fb7a8f16a2694a91f988275cbf352f9"}, + {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:31a82d498054cac9f6d0b53d02bb85811185bcb477d4b60144f915f3b3126342"}, + {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6512cb89e334e4700febbffaaa52761b65b4f5a3cf33f960213d5656cea36a77"}, + {file = "kiwisolver-1.4.5-cp39-cp39-win32.whl", hash = "sha256:9db8ea4c388fdb0f780fe91346fd438657ea602d58348753d9fb265ce1bca67f"}, + {file = "kiwisolver-1.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:59415f46a37f7f2efeec758353dd2eae1b07640d8ca0f0c42548ec4125492635"}, + {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5c7b3b3a728dc6faf3fc372ef24f21d1e3cee2ac3e9596691d746e5a536de920"}, + {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:620ced262a86244e2be10a676b646f29c34537d0d9cc8eb26c08f53d98013390"}, + {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:378a214a1e3bbf5ac4a8708304318b4f890da88c9e6a07699c4ae7174c09a68d"}, + {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf7be1207676ac608a50cd08f102f6742dbfc70e8d60c4db1c6897f62f71523"}, + {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ba55dce0a9b8ff59495ddd050a0225d58bd0983d09f87cfe2b6aec4f2c1234e4"}, + {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd32ea360bcbb92d28933fc05ed09bffcb1704ba3fc7942e81db0fd4f81a7892"}, + {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5e7139af55d1688f8b960ee9ad5adafc4ac17c1c473fe07133ac092310d76544"}, + {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dced8146011d2bc2e883f9bd68618b8247387f4bbec46d7392b3c3b032640126"}, + {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9bf3325c47b11b2e51bca0824ea217c7cd84491d8ac4eefd1e409705ef092bd"}, + {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5794cf59533bc3f1b1c821f7206a3617999db9fbefc345360aafe2e067514929"}, + {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e368f200bbc2e4f905b8e71eb38b3c04333bddaa6a2464a6355487b02bb7fb09"}, + {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5d706eba36b4c4d5bc6c6377bb6568098765e990cfc21ee16d13963fab7b3e7"}, + {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85267bd1aa8880a9c88a8cb71e18d3d64d2751a790e6ca6c27b8ccc724bcd5ad"}, + {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210ef2c3a1f03272649aff1ef992df2e724748918c4bc2d5a90352849eb40bea"}, + {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:11d011a7574eb3b82bcc9c1a1d35c1d7075677fdd15de527d91b46bd35e935ee"}, + {file = "kiwisolver-1.4.5.tar.gz", hash = "sha256:e57e563a57fb22a142da34f38acc2fc1a5c864bc29ca1517a88abc963e60d6ec"}, +] + +[[package]] +name = "langdetect" +version = "1.0.9" +description = "Language detection library ported from Google's language-detection." +optional = false +python-versions = "*" +files = [ + {file = "langdetect-1.0.9-py2-none-any.whl", hash = "sha256:7cbc0746252f19e76f77c0b1690aadf01963be835ef0cd4b56dddf2a8f1dfc2a"}, + {file = "langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "mando" +version = "0.7.1" +description = "Create Python CLI apps with little to no effort at all!" +optional = false +python-versions = "*" +files = [ + {file = "mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a"}, + {file = "mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500"}, +] + +[package.dependencies] +six = "*" + +[package.extras] +restructuredtext = ["rst2ansi"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "matplotlib" +version = "3.8.4" +description = "Python plotting package" +optional = false +python-versions = ">=3.9" +files = [ + {file = "matplotlib-3.8.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:abc9d838f93583650c35eca41cfcec65b2e7cb50fd486da6f0c49b5e1ed23014"}, + {file = "matplotlib-3.8.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f65c9f002d281a6e904976007b2d46a1ee2bcea3a68a8c12dda24709ddc9106"}, + {file = "matplotlib-3.8.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce1edd9f5383b504dbc26eeea404ed0a00656c526638129028b758fd43fc5f10"}, + {file = "matplotlib-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecd79298550cba13a43c340581a3ec9c707bd895a6a061a78fa2524660482fc0"}, + {file = "matplotlib-3.8.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:90df07db7b599fe7035d2f74ab7e438b656528c68ba6bb59b7dc46af39ee48ef"}, + {file = "matplotlib-3.8.4-cp310-cp310-win_amd64.whl", hash = "sha256:ac24233e8f2939ac4fd2919eed1e9c0871eac8057666070e94cbf0b33dd9c338"}, + {file = "matplotlib-3.8.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:72f9322712e4562e792b2961971891b9fbbb0e525011e09ea0d1f416c4645661"}, + {file = "matplotlib-3.8.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:232ce322bfd020a434caaffbd9a95333f7c2491e59cfc014041d95e38ab90d1c"}, + {file = "matplotlib-3.8.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6addbd5b488aedb7f9bc19f91cd87ea476206f45d7116fcfe3d31416702a82fa"}, + {file = "matplotlib-3.8.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc4ccdc64e3039fc303defd119658148f2349239871db72cd74e2eeaa9b80b71"}, + {file = "matplotlib-3.8.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b7a2a253d3b36d90c8993b4620183b55665a429da8357a4f621e78cd48b2b30b"}, + {file = "matplotlib-3.8.4-cp311-cp311-win_amd64.whl", hash = "sha256:8080d5081a86e690d7688ffa542532e87f224c38a6ed71f8fbed34dd1d9fedae"}, + {file = "matplotlib-3.8.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6485ac1f2e84676cff22e693eaa4fbed50ef5dc37173ce1f023daef4687df616"}, + {file = "matplotlib-3.8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c89ee9314ef48c72fe92ce55c4e95f2f39d70208f9f1d9db4e64079420d8d732"}, + {file = "matplotlib-3.8.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50bac6e4d77e4262c4340d7a985c30912054745ec99756ce213bfbc3cb3808eb"}, + {file = "matplotlib-3.8.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f51c4c869d4b60d769f7b4406eec39596648d9d70246428745a681c327a8ad30"}, + {file = "matplotlib-3.8.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b12ba985837e4899b762b81f5b2845bd1a28f4fdd1a126d9ace64e9c4eb2fb25"}, + {file = "matplotlib-3.8.4-cp312-cp312-win_amd64.whl", hash = "sha256:7a6769f58ce51791b4cb8b4d7642489df347697cd3e23d88266aaaee93b41d9a"}, + {file = "matplotlib-3.8.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:843cbde2f0946dadd8c5c11c6d91847abd18ec76859dc319362a0964493f0ba6"}, + {file = "matplotlib-3.8.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c13f041a7178f9780fb61cc3a2b10423d5e125480e4be51beaf62b172413b67"}, + {file = "matplotlib-3.8.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb44f53af0a62dc80bba4443d9b27f2fde6acfdac281d95bc872dc148a6509cc"}, + {file = "matplotlib-3.8.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:606e3b90897554c989b1e38a258c626d46c873523de432b1462f295db13de6f9"}, + {file = "matplotlib-3.8.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9bb0189011785ea794ee827b68777db3ca3f93f3e339ea4d920315a0e5a78d54"}, + {file = "matplotlib-3.8.4-cp39-cp39-win_amd64.whl", hash = "sha256:6209e5c9aaccc056e63b547a8152661324404dd92340a6e479b3a7f24b42a5d0"}, + {file = "matplotlib-3.8.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c7064120a59ce6f64103c9cefba8ffe6fba87f2c61d67c401186423c9a20fd35"}, + {file = "matplotlib-3.8.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0e47eda4eb2614300fc7bb4657fced3e83d6334d03da2173b09e447418d499f"}, + {file = "matplotlib-3.8.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:493e9f6aa5819156b58fce42b296ea31969f2aab71c5b680b4ea7a3cb5c07d94"}, + {file = "matplotlib-3.8.4.tar.gz", hash = "sha256:8aac397d5e9ec158960e31c381c5ffc52ddd52bd9a47717e2a694038167dffea"}, +] + +[package.dependencies] +contourpy = ">=1.0.1" +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.3.1" +numpy = ">=1.21" +packaging = ">=20.0" +pillow = ">=8" +pyparsing = ">=2.3.1" +python-dateutil = ">=2.7" + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mistune" +version = "3.0.2" +description = "A sane and fast Markdown parser with useful plugins and renderers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205"}, + {file = "mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8"}, +] + +[[package]] +name = "multidict" +version = "6.0.5" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, +] + +[[package]] +name = "multiprocess" +version = "0.70.16" +description = "better multiprocessing and multithreading in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "multiprocess-0.70.16-pp310-pypy310_pp73-macosx_10_13_x86_64.whl", hash = "sha256:476887be10e2f59ff183c006af746cb6f1fd0eadcfd4ef49e605cbe2659920ee"}, + {file = "multiprocess-0.70.16-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d951bed82c8f73929ac82c61f01a7b5ce8f3e5ef40f5b52553b4f547ce2b08ec"}, + {file = "multiprocess-0.70.16-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37b55f71c07e2d741374998c043b9520b626a8dddc8b3129222ca4f1a06ef67a"}, + {file = "multiprocess-0.70.16-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba8c31889abf4511c7308a8c52bb4a30b9d590e7f58523302ba00237702ca054"}, + {file = "multiprocess-0.70.16-pp39-pypy39_pp73-macosx_10_13_x86_64.whl", hash = "sha256:0dfd078c306e08d46d7a8d06fb120313d87aa43af60d66da43ffff40b44d2f41"}, + {file = "multiprocess-0.70.16-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e7b9d0f307cd9bd50851afaac0dba2cb6c44449efff697df7c7645f7d3f2be3a"}, + {file = "multiprocess-0.70.16-py310-none-any.whl", hash = "sha256:c4a9944c67bd49f823687463660a2d6daae94c289adff97e0f9d696ba6371d02"}, + {file = "multiprocess-0.70.16-py311-none-any.whl", hash = "sha256:af4cabb0dac72abfb1e794fa7855c325fd2b55a10a44628a3c1ad3311c04127a"}, + {file = "multiprocess-0.70.16-py312-none-any.whl", hash = "sha256:fc0544c531920dde3b00c29863377f87e1632601092ea2daca74e4beb40faa2e"}, + {file = "multiprocess-0.70.16-py38-none-any.whl", hash = "sha256:a71d82033454891091a226dfc319d0cfa8019a4e888ef9ca910372a446de4435"}, + {file = "multiprocess-0.70.16-py39-none-any.whl", hash = "sha256:a0bafd3ae1b732eac64be2e72038231c1ba97724b60b09400d68f229fcc2fbf3"}, + {file = "multiprocess-0.70.16.tar.gz", hash = "sha256:161af703d4652a0e1410be6abccecde4a7ddffd19341be0a7011b94aeb171ac1"}, +] + +[package.dependencies] +dill = ">=0.3.8" + +[[package]] +name = "mypy" +version = "1.10.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, + {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, + {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, + {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, + {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, + {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, + {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, + {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, + {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, + {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, + {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, + {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, + {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, + {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, + {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, + {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, + {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, + {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nbclient" +version = "0.10.0" +description = "A client library for executing notebooks. Formerly nbconvert's ExecutePreprocessor." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "nbclient-0.10.0-py3-none-any.whl", hash = "sha256:f13e3529332a1f1f81d82a53210322476a168bb7090a0289c795fe9cc11c9d3f"}, + {file = "nbclient-0.10.0.tar.gz", hash = "sha256:4b3f1b7dba531e498449c4db4f53da339c91d449dc11e9af3a43b4eb5c5abb09"}, +] + +[package.dependencies] +jupyter-client = ">=6.1.12" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +nbformat = ">=5.1" +traitlets = ">=5.4" + +[package.extras] +dev = ["pre-commit"] +docs = ["autodoc-traits", "mock", "moto", "myst-parser", "nbclient[test]", "sphinx (>=1.7)", "sphinx-book-theme", "sphinxcontrib-spelling"] +test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=7.0.0)", "pytest (>=7.0,<8)", "pytest-asyncio", "pytest-cov (>=4.0)", "testpath", "xmltodict"] + +[[package]] +name = "nbconvert" +version = "7.16.3" +description = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)." +optional = false +python-versions = ">=3.8" +files = [ + {file = "nbconvert-7.16.3-py3-none-any.whl", hash = "sha256:ddeff14beeeedf3dd0bc506623e41e4507e551736de59df69a91f86700292b3b"}, + {file = "nbconvert-7.16.3.tar.gz", hash = "sha256:a6733b78ce3d47c3f85e504998495b07e6ea9cf9bf6ec1c98dda63ec6ad19142"}, +] + +[package.dependencies] +beautifulsoup4 = "*" +bleach = "!=5.0.0" +defusedxml = "*" +jinja2 = ">=3.0" +jupyter-core = ">=4.7" +jupyterlab-pygments = "*" +markupsafe = ">=2.0" +mistune = ">=2.0.3,<4" +nbclient = ">=0.5.0" +nbformat = ">=5.7" +packaging = "*" +pandocfilters = ">=1.4.1" +pygments = ">=2.4.1" +tinycss2 = "*" +traitlets = ">=5.1" + +[package.extras] +all = ["nbconvert[docs,qtpdf,serve,test,webpdf]"] +docs = ["ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (==5.0.2)", "sphinxcontrib-spelling"] +qtpdf = ["nbconvert[qtpng]"] +qtpng = ["pyqtwebengine (>=5.15)"] +serve = ["tornado (>=6.1)"] +test = ["flaky", "ipykernel", "ipywidgets (>=7.5)", "pytest (>=7)"] +webpdf = ["playwright"] + +[[package]] +name = "nbformat" +version = "5.10.4" +description = "The Jupyter Notebook format" +optional = false +python-versions = ">=3.8" +files = [ + {file = "nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b"}, + {file = "nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a"}, +] + +[package.dependencies] +fastjsonschema = ">=2.15" +jsonschema = ">=2.6" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +traitlets = ">=5.1" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +test = ["pep440", "pre-commit", "pytest", "testpath"] + +[[package]] +name = "nbqa" +version = "1.8.5" +description = "Run any standard Python code quality tool on a Jupyter Notebook" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "nbqa-1.8.5-py3-none-any.whl", hash = "sha256:fe59ccb66f29bda2912c75cacf9cdbd34504923effb58ae1c88211d075213eff"}, + {file = "nbqa-1.8.5.tar.gz", hash = "sha256:91624e9c747bbe38ff14ebf75d17cfb838b5c0432b039bcb7e8ad0bb423ef7ef"}, +] + +[package.dependencies] +autopep8 = ">=1.5" +ipython = ">=7.8.0" +tokenize-rt = ">=3.2.0" +tomli = "*" + +[package.extras] +toolchain = ["black", "blacken-docs", "flake8", "isort", "jupytext", "mypy", "pylint", "pyupgrade", "ruff"] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + +[[package]] +name = "nltk" +version = "3.8.1" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "nltk-3.8.1-py3-none-any.whl", hash = "sha256:fd5c9109f976fa86bcadba8f91e47f5e9293bd034474752e92a520f81c93dda5"}, + {file = "nltk-3.8.1.zip", hash = "sha256:1834da3d0682cba4f2cede2f9aad6b0fafb6461ba451db0efb6f9c39798d64d3"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "notebook" +version = "7.1.3" +description = "Jupyter Notebook - A web-based notebook environment for interactive computing" +optional = false +python-versions = ">=3.8" +files = [ + {file = "notebook-7.1.3-py3-none-any.whl", hash = "sha256:919b911e59f41f6e3857ce93c9d93535ba66bb090059712770e5968c07e1004d"}, + {file = "notebook-7.1.3.tar.gz", hash = "sha256:41fcebff44cf7bb9377180808bcbae066629b55d8c7722f1ebbe75ca44f9cfc1"}, +] + +[package.dependencies] +jupyter-server = ">=2.4.0,<3" +jupyterlab = ">=4.1.1,<4.2" +jupyterlab-server = ">=2.22.1,<3" +notebook-shim = ">=0.2,<0.3" +tornado = ">=6.2.0" + +[package.extras] +dev = ["hatch", "pre-commit"] +docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.22.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +description = "A shim layer for notebook traits and config" +optional = false +python-versions = ">=3.7" +files = [ + {file = "notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef"}, + {file = "notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb"}, +] + +[package.dependencies] +jupyter-server = ">=1.8,<3" + +[package.extras] +test = ["pytest", "pytest-console-scripts", "pytest-jupyter", "pytest-tornasync"] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "opentelemetry-api" +version = "1.23.0" +description = "OpenTelemetry Python API" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_api-1.23.0-py3-none-any.whl", hash = "sha256:cc03ea4025353048aadb9c64919099663664672ea1c6be6ddd8fee8e4cd5e774"}, + {file = "opentelemetry_api-1.23.0.tar.gz", hash = "sha256:14a766548c8dd2eb4dfc349739eb4c3893712a0daa996e5dbf945f9da665da9d"}, +] + +[package.dependencies] +deprecated = ">=1.2.6" +importlib-metadata = ">=6.0,<7.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.23.0" +description = "OpenTelemetry Protobuf encoding" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_exporter_otlp_proto_common-1.23.0-py3-none-any.whl", hash = "sha256:2a9e7e9d5a8b026b572684b6b24dcdefcaa58613d5ce3d644130b0c373c056c1"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.23.0.tar.gz", hash = "sha256:35e4ea909e7a0b24235bd0aaf17fba49676527feb1823b46565ff246d5a1ab18"}, +] + +[package.dependencies] +opentelemetry-proto = "1.23.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.23.0" +description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_exporter_otlp_proto_http-1.23.0-py3-none-any.whl", hash = "sha256:ad853b58681df8efcb2cfc93be2b5fd86351c99ff4ab47dc917da384b8650d91"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.23.0.tar.gz", hash = "sha256:088eac2320f4a604e2d9ff71aced71fdae601ac6457005fb0303d6bbbf44e6ca"}, +] + +[package.dependencies] +deprecated = ">=1.2.6" +googleapis-common-protos = ">=1.52,<2.0" +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-exporter-otlp-proto-common = "1.23.0" +opentelemetry-proto = "1.23.0" +opentelemetry-sdk = ">=1.23.0,<1.24.0" +requests = ">=2.7,<3.0" + +[package.extras] +test = ["responses (>=0.22.0,<0.25)"] + +[[package]] +name = "opentelemetry-proto" +version = "1.23.0" +description = "OpenTelemetry Python Proto" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_proto-1.23.0-py3-none-any.whl", hash = "sha256:4c017deca052cb287a6003b7c989ed8b47af65baeb5d57ebf93dde0793f78509"}, + {file = "opentelemetry_proto-1.23.0.tar.gz", hash = "sha256:e6aaf8b7ace8d021942d546161401b83eed90f9f2cc6f13275008cea730e4651"}, +] + +[package.dependencies] +protobuf = ">=3.19,<5.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.23.0" +description = "OpenTelemetry Python SDK" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_sdk-1.23.0-py3-none-any.whl", hash = "sha256:a93c96990ac0f07c6d679e2f1015864ff7a4f5587122dd5af968034436efb1fd"}, + {file = "opentelemetry_sdk-1.23.0.tar.gz", hash = "sha256:9ddf60195837b59e72fd2033d6a47e2b59a0f74f0ec37d89387d89e3da8cab7f"}, +] + +[package.dependencies] +opentelemetry-api = "1.23.0" +opentelemetry-semantic-conventions = "0.44b0" +typing-extensions = ">=3.7.4" + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.44b0" +description = "OpenTelemetry Semantic Conventions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_semantic_conventions-0.44b0-py3-none-any.whl", hash = "sha256:7c434546c9cbd797ab980cc88bf9ff3f4a5a28f941117cad21694e43d5d92019"}, + {file = "opentelemetry_semantic_conventions-0.44b0.tar.gz", hash = "sha256:2e997cb28cd4ca81a25a9a43365f593d0c2b76be0685015349a89abdf1aa4ffa"}, +] + +[[package]] +name = "overrides" +version = "7.7.0" +description = "A decorator to automatically detect mismatch when overriding a method." +optional = false +python-versions = ">=3.6" +files = [ + {file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"}, + {file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"}, +] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pandas" +version = "2.2.2" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce"}, + {file = "pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99"}, + {file = "pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1"}, + {file = "pandas-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9dfde2a0ddef507a631dc9dc4af6a9489d5e2e740e226ad426a05cabfbd7c8ef"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b79011ff7a0f4b1d6da6a61aa1aa604fb312d6647de5bad20013682d1429ce"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3e374f59e440d4ab45ca2fffde54b81ac3834cf5ae2cdfa69c90bc03bde04d76"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:43498c0bdb43d55cb162cdc8c06fac328ccb5d2eabe3cadeb3529ae6f0517c32"}, + {file = "pandas-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:d187d355ecec3629624fccb01d104da7d7f391db0311145817525281e2804d23"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9057e6aa78a584bc93a13f0a9bf7e753a5e9770a30b4d758b8d5f2a62a9433cd"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57"}, + {file = "pandas-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4"}, + {file = "pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pandas-stubs" +version = "2.2.1.240316" +description = "Type annotations for pandas" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas_stubs-2.2.1.240316-py3-none-any.whl", hash = "sha256:0126a26451a37cb893ea62357ca87ba3d181bd999ec8ba2ca5602e20207d6682"}, + {file = "pandas_stubs-2.2.1.240316.tar.gz", hash = "sha256:236a4f812fb6b1922e9607ff09e427f6d8540c421c9e5a40e3e4ddf7adac7f05"}, +] + +[package.dependencies] +numpy = {version = ">=1.26.0", markers = "python_version < \"3.13\""} +types-pytz = ">=2022.1.1" + +[[package]] +name = "pandocfilters" +version = "1.5.1" +description = "Utilities for writing pandoc filters in python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc"}, + {file = "pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e"}, +] + +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pillow" +version = "10.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, + {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, + {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, + {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, + {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, + {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, + {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, + {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, + {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, + {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, + {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, + {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, + {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, + {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, + {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, + {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, + {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.2.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, + {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "portalocker" +version = "2.8.2" +description = "Wraps the portalocker recipe for easy usage" +optional = false +python-versions = ">=3.8" +files = [ + {file = "portalocker-2.8.2-py3-none-any.whl", hash = "sha256:cfb86acc09b9aa7c3b43594e19be1345b9d16af3feb08bf92f23d4dce513a28e"}, + {file = "portalocker-2.8.2.tar.gz", hash = "sha256:2b035aa7828e46c58e9b31390ee1f169b98e1066ab10b9a6a861fe7e25ee4f33"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["sphinx (>=1.7.1)"] +redis = ["redis"] +tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"] + +[[package]] +name = "pre-commit" +version = "3.7.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.0-py2.py3-none-any.whl", hash = "sha256:5eae9e10c2b5ac51577c3452ec0a490455c45a0533f7960f993a0d01e59decab"}, + {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "priority" +version = "2.0.0" +description = "A pure-Python implementation of the HTTP/2 priority tree" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, + {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, +] + +[[package]] +name = "prometheus-client" +version = "0.20.0" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.8" +files = [ + {file = "prometheus_client-0.20.0-py3-none-any.whl", hash = "sha256:cde524a85bce83ca359cc837f28b8c0db5cac7aa653a588fd7e84ba061c329e7"}, + {file = "prometheus_client-0.20.0.tar.gz", hash = "sha256:287629d00b147a32dcb2be0b9df905da599b2d82f80377083ec8463309a4bb89"}, +] + +[package.extras] +twisted = ["twisted"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.43" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, + {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "protobuf" +version = "4.25.3" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-4.25.3-cp310-abi3-win32.whl", hash = "sha256:d4198877797a83cbfe9bffa3803602bbe1625dc30d8a097365dbc762e5790faa"}, + {file = "protobuf-4.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:209ba4cc916bab46f64e56b85b090607a676f66b473e6b762e6f1d9d591eb2e8"}, + {file = "protobuf-4.25.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f1279ab38ecbfae7e456a108c5c0681e4956d5b1090027c1de0f934dfdb4b35c"}, + {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:e7cb0ae90dd83727f0c0718634ed56837bfeeee29a5f82a7514c03ee1364c019"}, + {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:7c8daa26095f82482307bc717364e7c13f4f1c99659be82890dcfc215194554d"}, + {file = "protobuf-4.25.3-cp38-cp38-win32.whl", hash = "sha256:f4f118245c4a087776e0a8408be33cf09f6c547442c00395fbfb116fac2f8ac2"}, + {file = "protobuf-4.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:c053062984e61144385022e53678fbded7aea14ebb3e0305ae3592fb219ccfa4"}, + {file = "protobuf-4.25.3-cp39-cp39-win32.whl", hash = "sha256:19b270aeaa0099f16d3ca02628546b8baefe2955bbe23224aaf856134eccf1e4"}, + {file = "protobuf-4.25.3-cp39-cp39-win_amd64.whl", hash = "sha256:e3c97a1555fd6388f857770ff8b9703083de6bf1f9274a002a332d65fbb56c8c"}, + {file = "protobuf-4.25.3-py3-none-any.whl", hash = "sha256:f0700d54bcf45424477e46a9f0944155b46fb0639d69728739c0e47bab83f2b9"}, + {file = "protobuf-4.25.3.tar.gz", hash = "sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c"}, +] + +[[package]] +name = "psutil" +version = "5.9.8" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "psutil-5.9.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:26bd09967ae00920df88e0352a91cff1a78f8d69b3ecabbfe733610c0af486c8"}, + {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:05806de88103b25903dff19bb6692bd2e714ccf9e668d050d144012055cbca73"}, + {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:611052c4bc70432ec770d5d54f64206aa7203a101ec273a0cd82418c86503bb7"}, + {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:50187900d73c1381ba1454cf40308c2bf6f34268518b3f36a9b663ca87e65e36"}, + {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:02615ed8c5ea222323408ceba16c60e99c3f91639b07da6373fb7e6539abc56d"}, + {file = "psutil-5.9.8-cp27-none-win32.whl", hash = "sha256:36f435891adb138ed3c9e58c6af3e2e6ca9ac2f365efe1f9cfef2794e6c93b4e"}, + {file = "psutil-5.9.8-cp27-none-win_amd64.whl", hash = "sha256:bd1184ceb3f87651a67b2708d4c3338e9b10c5df903f2e3776b62303b26cb631"}, + {file = "psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81"}, + {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421"}, + {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4"}, + {file = "psutil-5.9.8-cp36-cp36m-win32.whl", hash = "sha256:7d79560ad97af658a0f6adfef8b834b53f64746d45b403f225b85c5c2c140eee"}, + {file = "psutil-5.9.8-cp36-cp36m-win_amd64.whl", hash = "sha256:27cc40c3493bb10de1be4b3f07cae4c010ce715290a5be22b98493509c6299e2"}, + {file = "psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0"}, + {file = "psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf"}, + {file = "psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8"}, + {file = "psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pyarrow" +version = "16.0.0" +description = "Python library for Apache Arrow" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyarrow-16.0.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:22a1fdb1254e5095d629e29cd1ea98ed04b4bbfd8e42cc670a6b639ccc208b60"}, + {file = "pyarrow-16.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:574a00260a4ed9d118a14770edbd440b848fcae5a3024128be9d0274dbcaf858"}, + {file = "pyarrow-16.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0815d0ddb733b8c1b53a05827a91f1b8bde6240f3b20bf9ba5d650eb9b89cdf"}, + {file = "pyarrow-16.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df0080339387b5d30de31e0a149c0c11a827a10c82f0c67d9afae3981d1aabb7"}, + {file = "pyarrow-16.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:edf38cce0bf0dcf726e074159c60516447e4474904c0033f018c1f33d7dac6c5"}, + {file = "pyarrow-16.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91d28f9a40f1264eab2af7905a4d95320ac2f287891e9c8b0035f264fe3c3a4b"}, + {file = "pyarrow-16.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:99af421ee451a78884d7faea23816c429e263bd3618b22d38e7992c9ce2a7ad9"}, + {file = "pyarrow-16.0.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:d22d0941e6c7bafddf5f4c0662e46f2075850f1c044bf1a03150dd9e189427ce"}, + {file = "pyarrow-16.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:266ddb7e823f03733c15adc8b5078db2df6980f9aa93d6bb57ece615df4e0ba7"}, + {file = "pyarrow-16.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cc23090224b6594f5a92d26ad47465af47c1d9c079dd4a0061ae39551889efe"}, + {file = "pyarrow-16.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56850a0afe9ef37249d5387355449c0f94d12ff7994af88f16803a26d38f2016"}, + {file = "pyarrow-16.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:705db70d3e2293c2f6f8e84874b5b775f690465798f66e94bb2c07bab0a6bb55"}, + {file = "pyarrow-16.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5448564754c154997bc09e95a44b81b9e31ae918a86c0fcb35c4aa4922756f55"}, + {file = "pyarrow-16.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:729f7b262aa620c9df8b9967db96c1575e4cfc8c25d078a06968e527b8d6ec05"}, + {file = "pyarrow-16.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:fb8065dbc0d051bf2ae2453af0484d99a43135cadabacf0af588a3be81fbbb9b"}, + {file = "pyarrow-16.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:20ce707d9aa390593ea93218b19d0eadab56390311cb87aad32c9a869b0e958c"}, + {file = "pyarrow-16.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5823275c8addbbb50cd4e6a6839952682a33255b447277e37a6f518d6972f4e1"}, + {file = "pyarrow-16.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab8b9050752b16a8b53fcd9853bf07d8daf19093533e990085168f40c64d978"}, + {file = "pyarrow-16.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:42e56557bc7c5c10d3e42c3b32f6cff649a29d637e8f4e8b311d334cc4326730"}, + {file = "pyarrow-16.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2a7abdee4a4a7cfa239e2e8d721224c4b34ffe69a0ca7981354fe03c1328789b"}, + {file = "pyarrow-16.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ef2f309b68396bcc5a354106741d333494d6a0d3e1951271849787109f0229a6"}, + {file = "pyarrow-16.0.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:ed66e5217b4526fa3585b5e39b0b82f501b88a10d36bd0d2a4d8aa7b5a48e2df"}, + {file = "pyarrow-16.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc8814310486f2a73c661ba8354540f17eef51e1b6dd090b93e3419d3a097b3a"}, + {file = "pyarrow-16.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c2f5e239db7ed43e0ad2baf46a6465f89c824cc703f38ef0fde927d8e0955f7"}, + {file = "pyarrow-16.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f293e92d1db251447cb028ae12f7bc47526e4649c3a9924c8376cab4ad6b98bd"}, + {file = "pyarrow-16.0.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:dd9334a07b6dc21afe0857aa31842365a62eca664e415a3f9536e3a8bb832c07"}, + {file = "pyarrow-16.0.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:d91073d1e2fef2c121154680e2ba7e35ecf8d4969cc0af1fa6f14a8675858159"}, + {file = "pyarrow-16.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:71d52561cd7aefd22cf52538f262850b0cc9e4ec50af2aaa601da3a16ef48877"}, + {file = "pyarrow-16.0.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:b93c9a50b965ee0bf4fef65e53b758a7e8dcc0c2d86cebcc037aaaf1b306ecc0"}, + {file = "pyarrow-16.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d831690844706e374c455fba2fb8cfcb7b797bfe53ceda4b54334316e1ac4fa4"}, + {file = "pyarrow-16.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35692ce8ad0b8c666aa60f83950957096d92f2a9d8d7deda93fb835e6053307e"}, + {file = "pyarrow-16.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dd3151d098e56f16a8389c1247137f9e4c22720b01c6f3aa6dec29a99b74d80"}, + {file = "pyarrow-16.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:bd40467bdb3cbaf2044ed7a6f7f251c8f941c8b31275aaaf88e746c4f3ca4a7a"}, + {file = "pyarrow-16.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:00a1dcb22ad4ceb8af87f7bd30cc3354788776c417f493089e0a0af981bc8d80"}, + {file = "pyarrow-16.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:fda9a7cebd1b1d46c97b511f60f73a5b766a6de4c5236f144f41a5d5afec1f35"}, + {file = "pyarrow-16.0.0.tar.gz", hash = "sha256:59bb1f1edbbf4114c72415f039f1359f1a57d166a331c3229788ccbfbb31689a"}, +] + +[package.dependencies] +numpy = ">=1.16.6" + +[[package]] +name = "pyarrow-hotfix" +version = "0.6" +description = "" +optional = false +python-versions = ">=3.5" +files = [ + {file = "pyarrow_hotfix-0.6-py3-none-any.whl", hash = "sha256:dcc9ae2d220dff0083be6a9aa8e0cdee5182ad358d4931fce825c545e5c89178"}, + {file = "pyarrow_hotfix-0.6.tar.gz", hash = "sha256:79d3e030f7ff890d408a100ac16d6f00b14d44a502d7897cd9fc3e3a534e9945"}, +] + +[[package]] +name = "pycares" +version = "4.4.0" +description = "Python interface for c-ares" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycares-4.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:24da119850841d16996713d9c3374ca28a21deee056d609fbbed29065d17e1f6"}, + {file = "pycares-4.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8f64cb58729689d4d0e78f0bfb4c25ce2f851d0274c0273ac751795c04b8798a"}, + {file = "pycares-4.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33e2a1120887e89075f7f814ec144f66a6ce06a54f5722ccefc62fbeda83cff"}, + {file = "pycares-4.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c680fef1b502ee680f8f0b95a41af4ec2c234e50e16c0af5bbda31999d3584bd"}, + {file = "pycares-4.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fff16b09042ba077f7b8aa5868d1d22456f0002574d0ba43462b10a009331677"}, + {file = "pycares-4.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:229a1675eb33bc9afb1fc463e73ee334950ccc485bc83a43f6ae5839fb4d5fa3"}, + {file = "pycares-4.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3aebc73e5ad70464f998f77f2da2063aa617cbd8d3e8174dd7c5b4518f967153"}, + {file = "pycares-4.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6ef64649eba56448f65e26546d85c860709844d2fc22ef14d324fe0b27f761a9"}, + {file = "pycares-4.4.0-cp310-cp310-win32.whl", hash = "sha256:4afc2644423f4eef97857a9fd61be9758ce5e336b4b0bd3d591238bb4b8b03e0"}, + {file = "pycares-4.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5ed4e04af4012f875b78219d34434a6d08a67175150ac1b79eb70ab585d4ba8c"}, + {file = "pycares-4.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bce8db2fc6f3174bd39b81405210b9b88d7b607d33e56a970c34a0c190da0490"}, + {file = "pycares-4.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a0303428d013ccf5c51de59c83f9127aba6200adb7fd4be57eddb432a1edd2a"}, + {file = "pycares-4.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afb91792f1556f97be7f7acb57dc7756d89c5a87bd8b90363a77dbf9ea653817"}, + {file = "pycares-4.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b61579cecf1f4d616e5ea31a6e423a16680ab0d3a24a2ffe7bb1d4ee162477ff"}, + {file = "pycares-4.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7af06968cbf6851566e806bf3e72825b0e6671832a2cbe840be1d2d65350710"}, + {file = "pycares-4.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ceb12974367b0a68a05d52f4162b29f575d241bd53de155efe632bf2c943c7f6"}, + {file = "pycares-4.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2eeec144bcf6a7b6f2d74d6e70cbba7886a84dd373c886f06cb137a07de4954c"}, + {file = "pycares-4.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e3a6f7cfdfd11eb5493d6d632e582408c8f3b429f295f8799c584c108b28db6f"}, + {file = "pycares-4.4.0-cp311-cp311-win32.whl", hash = "sha256:34736a2ffaa9c08ca9c707011a2d7b69074bbf82d645d8138bba771479b2362f"}, + {file = "pycares-4.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:eb66c30eb11e877976b7ead13632082a8621df648c408b8e15cdb91a452dd502"}, + {file = "pycares-4.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fd644505a8cfd7f6584d33a9066d4e3d47700f050ef1490230c962de5dfb28c6"}, + {file = "pycares-4.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52084961262232ec04bd75f5043aed7e5d8d9695e542ff691dfef0110209f2d4"}, + {file = "pycares-4.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0c5368206057884cde18602580083aeaad9b860e2eac14fd253543158ce1e93"}, + {file = "pycares-4.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:112a4979c695b1c86f6782163d7dec58d57a3b9510536dcf4826550f9053dd9a"}, + {file = "pycares-4.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d186dafccdaa3409194c0f94db93c1a5d191145a275f19da6591f9499b8e7b8"}, + {file = "pycares-4.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:64965dc19c578a683ea73487a215a8897276224e004d50eeb21f0bc7a0b63c88"}, + {file = "pycares-4.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ed2a38e34bec6f2586435f6ff0bc5fe11d14bebd7ed492cf739a424e81681540"}, + {file = "pycares-4.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:94d6962db81541eb0396d2f0dfcbb18cdb8c8b251d165efc2d974ae652c547d4"}, + {file = "pycares-4.4.0-cp312-cp312-win32.whl", hash = "sha256:1168a48a834813aa80f412be2df4abaf630528a58d15c704857448b20b1675c0"}, + {file = "pycares-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:db24c4e7fea4a052c6e869cbf387dd85d53b9736cfe1ef5d8d568d1ca925e977"}, + {file = "pycares-4.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:21a5a0468861ec7df7befa69050f952da13db5427ae41ffe4713bc96291d1d95"}, + {file = "pycares-4.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:22c00bf659a9fa44d7b405cf1cd69b68b9d37537899898d8cbe5dffa4016b273"}, + {file = "pycares-4.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23aa3993a352491a47fcf17867f61472f32f874df4adcbb486294bd9fbe8abee"}, + {file = "pycares-4.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:813d661cbe2e37d87da2d16b7110a6860e93ddb11735c6919c8a3545c7b9c8d8"}, + {file = "pycares-4.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77cf5a2fd5583c670de41a7f4a7b46e5cbabe7180d8029f728571f4d2e864084"}, + {file = "pycares-4.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3eaa6681c0a3e3f3868c77aca14b7760fed35fdfda2fe587e15c701950e7bc69"}, + {file = "pycares-4.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ad58e284a658a8a6a84af2e0b62f2f961f303cedfe551854d7bd40c3cbb61912"}, + {file = "pycares-4.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bfb89ca9e3d0a9b5332deeb666b2ede9d3469107742158f4aeda5ce032d003f4"}, + {file = "pycares-4.4.0-cp38-cp38-win32.whl", hash = "sha256:f36bdc1562142e3695555d2f4ac0cb69af165eddcefa98efc1c79495b533481f"}, + {file = "pycares-4.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:902461a92b6a80fd5041a2ec5235680c7cc35e43615639ec2a40e63fca2dfb51"}, + {file = "pycares-4.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7bddc6adba8f699728f7fc1c9ce8cef359817ad78e2ed52b9502cb5f8dc7f741"}, + {file = "pycares-4.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cb49d5805cd347c404f928c5ae7c35e86ba0c58ffa701dbe905365e77ce7d641"}, + {file = "pycares-4.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56cf3349fa3a2e67ed387a7974c11d233734636fe19facfcda261b411af14d80"}, + {file = "pycares-4.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf2eaa83a5987e48fa63302f0fe7ce3275cfda87b34d40fef9ce703fb3ac002"}, + {file = "pycares-4.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82bba2ab77eb5addbf9758d514d9bdef3c1bfe7d1649a47bd9a0d55a23ef478b"}, + {file = "pycares-4.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c6a8bde63106f162fca736e842a916853cad3c8d9d137e11c9ffa37efa818b02"}, + {file = "pycares-4.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5f646eec041db6ffdbcaf3e0756fb92018f7af3266138c756bb09d2b5baadec"}, + {file = "pycares-4.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9dc04c54c6ea615210c1b9e803d0e2d2255f87a3d5d119b6482c8f0dfa15b26b"}, + {file = "pycares-4.4.0-cp39-cp39-win32.whl", hash = "sha256:97892cced5794d721fb4ff8765764aa4ea48fe8b2c3820677505b96b83d4ef47"}, + {file = "pycares-4.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:917f08f0b5d9324e9a34211e68d27447c552b50ab967044776bbab7e42a553a2"}, + {file = "pycares-4.4.0.tar.gz", hash = "sha256:f47579d508f2f56eddd16ce72045782ad3b1b3b678098699e2b6a1b30733e1c2"}, +] + +[package.dependencies] +cffi = ">=1.5.0" + +[package.extras] +idna = ["idna (>=2.1)"] + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pycountry" +version = "23.12.11" +description = "ISO country, subdivision, language, currency and script definitions and their translations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycountry-23.12.11-py3-none-any.whl", hash = "sha256:2ff91cff4f40ff61086e773d61e72005fe95de4a57bfc765509db05695dc50ab"}, + {file = "pycountry-23.12.11.tar.gz", hash = "sha256:00569d82eaefbc6a490a311bfa84a9c571cff9ddbf8b0a4f4e7b4f868b4ad925"}, +] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pydantic" +version = "2.7.1" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydocstyle" +version = "6.3.0" +description = "Python docstring style checker" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, +] + +[package.dependencies] +snowballstemmer = ">=2.2.0" + +[package.extras] +toml = ["tomli (>=1.2.3)"] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pygments" +version = "2.17.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[package.extras] +plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pylama" +version = "8.4.1" +description = "Code audit tool for python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pylama-8.4.1-py3-none-any.whl", hash = "sha256:5bbdbf5b620aba7206d688ed9fc917ecd3d73e15ec1a89647037a09fa3a86e60"}, + {file = "pylama-8.4.1.tar.gz", hash = "sha256:2d4f7aecfb5b7466216d48610c7d6bad1c3990c29cdd392ad08259b161e486f6"}, +] + +[package.dependencies] +mccabe = ">=0.7.0" +pycodestyle = ">=2.9.1" +pydocstyle = ">=6.1.1" +pyflakes = ">=2.5.0" +radon = {version = "*", optional = true, markers = "extra == \"radon\""} +toml = {version = ">=0.10.2", optional = true, markers = "extra == \"toml\""} +vulture = {version = "*", optional = true, markers = "extra == \"vulture\""} + +[package.extras] +all = ["eradicate", "mypy", "pylint", "radon", "vulture"] +eradicate = ["eradicate"] +mypy = ["mypy"] +pylint = ["pylint"] +radon = ["radon"] +tests = ["eradicate (>=2.0.0)", "mypy", "pylama-quotes", "pylint (>=2.11.1)", "pytest (>=7.1.2)", "pytest-mypy", "radon (>=5.1.0)", "toml", "types-setuptools", "types-toml", "vulture"] +toml = ["toml (>=0.10.2)"] +vulture = ["vulture"] + +[[package]] +name = "pyparsing" +version = "3.1.2" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"}, + {file = "pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "8.1.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.1.2-py3-none-any.whl", hash = "sha256:6c06dc309ff46a05721e6fd48e492a775ed8165d2ecdf57f156a80c7e95bb142"}, + {file = "pytest-8.1.2.tar.gz", hash = "sha256:f3c45d1d5eed96b01a2aea70dee6a4a366d51d38f9957768083e4fecfc77f3ef"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-xdist" +version = "3.5.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-xdist-3.5.0.tar.gz", hash = "sha256:cbb36f3d67e0c478baa57fa4edc8843887e0f6cfc42d677530a36d7472b32d8a"}, + {file = "pytest_xdist-3.5.0-py3-none-any.whl", hash = "sha256:d075629c7e00b611df89f490a5063944bee7a4362a5ff11c7cc7824a03dfce24"}, +] + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-json-logger" +version = "2.0.7" +description = "A python library adding a json log formatter" +optional = false +python-versions = ">=3.6" +files = [ + {file = "python-json-logger-2.0.7.tar.gz", hash = "sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c"}, + {file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"}, +] + +[[package]] +name = "python-liquid" +version = "1.12.1" +description = "A Python engine for the Liquid template language." +optional = false +python-versions = ">=3.7" +files = [ + {file = "python_liquid-1.12.1-py3-none-any.whl", hash = "sha256:2224312944be16c1a44406398eb8a07c7e57398d5c0ef15ff950946dbefe7c33"}, + {file = "python_liquid-1.12.1.tar.gz", hash = "sha256:7367e979125859fb4116f360f224a89a52ecb455fb26843c43e4d800b389d325"}, +] + +[package.dependencies] +importlib-resources = ">=5.10.0" +python-dateutil = ">=2.8.1" +typing-extensions = ">=4.2.0" + +[package.extras] +autoescape = ["markupsafe (>=2.0.0)"] + +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pywinpty" +version = "2.0.13" +description = "Pseudo terminal support for Windows from Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pywinpty-2.0.13-cp310-none-win_amd64.whl", hash = "sha256:697bff211fb5a6508fee2dc6ff174ce03f34a9a233df9d8b5fe9c8ce4d5eaf56"}, + {file = "pywinpty-2.0.13-cp311-none-win_amd64.whl", hash = "sha256:b96fb14698db1284db84ca38c79f15b4cfdc3172065b5137383910567591fa99"}, + {file = "pywinpty-2.0.13-cp312-none-win_amd64.whl", hash = "sha256:2fd876b82ca750bb1333236ce98488c1be96b08f4f7647cfdf4129dfad83c2d4"}, + {file = "pywinpty-2.0.13-cp38-none-win_amd64.whl", hash = "sha256:61d420c2116c0212808d31625611b51caf621fe67f8a6377e2e8b617ea1c1f7d"}, + {file = "pywinpty-2.0.13-cp39-none-win_amd64.whl", hash = "sha256:71cb613a9ee24174730ac7ae439fd179ca34ccb8c5349e8d7b72ab5dea2c6f4b"}, + {file = "pywinpty-2.0.13.tar.gz", hash = "sha256:c34e32351a3313ddd0d7da23d27f835c860d32fe4ac814d372a3ea9594f41dde"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "pyzmq" +version = "26.0.2" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyzmq-26.0.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:1a60a03b01e8c9c58932ec0cca15b1712d911c2800eb82d4281bc1ae5b6dad50"}, + {file = "pyzmq-26.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:949067079e14ea1973bd740255e0840118c163d4bce8837f539d749f145cf5c3"}, + {file = "pyzmq-26.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e7edfa6cf96d036a403775c96afa25058d1bb940a79786a9a2fc94a783abe3"}, + {file = "pyzmq-26.0.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:903cc7a84a7d4326b43755c368780800e035aa3d711deae84a533fdffa8755b0"}, + {file = "pyzmq-26.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cb2e41af165e5f327d06fbdd79a42a4e930267fade4e9f92d17f3ccce03f3a7"}, + {file = "pyzmq-26.0.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:55353b8189adcfc4c125fc4ce59d477744118e9c0ec379dd0999c5fa120ac4f5"}, + {file = "pyzmq-26.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f961423ff6236a752ced80057a20e623044df95924ed1009f844cde8b3a595f9"}, + {file = "pyzmq-26.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ba77fe84fe4f5f3dc0ef681a6d366685c8ffe1c8439c1d7530997b05ac06a04b"}, + {file = "pyzmq-26.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:52589f0a745ef61b9c75c872cf91f8c1f7c0668eb3dd99d7abd639d8c0fb9ca7"}, + {file = "pyzmq-26.0.2-cp310-cp310-win32.whl", hash = "sha256:b7b6d2a46c7afe2ad03ec8faf9967090c8ceae85c4d8934d17d7cae6f9062b64"}, + {file = "pyzmq-26.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:86531e20de249d9204cc6d8b13d5a30537748c78820215161d8a3b9ea58ca111"}, + {file = "pyzmq-26.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:f26a05029ecd2bd306b941ff8cb80f7620b7901421052bc429d238305b1cbf2f"}, + {file = "pyzmq-26.0.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:70770e296a9cb03d955540c99360aab861cbb3cba29516abbd106a15dbd91268"}, + {file = "pyzmq-26.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2740fd7161b39e178554ebf21aa5667a1c9ef0cd2cb74298fd4ef017dae7aec4"}, + {file = "pyzmq-26.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5e3706c32dea077faa42b1c92d825b7f86c866f72532d342e0be5e64d14d858"}, + {file = "pyzmq-26.0.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fa1416876194927f7723d6b7171b95e1115602967fc6bfccbc0d2d51d8ebae1"}, + {file = "pyzmq-26.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ef9a79a48794099c57dc2df00340b5d47c5caa1792f9ddb8c7a26b1280bd575"}, + {file = "pyzmq-26.0.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1c60fcdfa3229aeee4291c5d60faed3a813b18bdadb86299c4bf49e8e51e8605"}, + {file = "pyzmq-26.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e943c39c206b04df2eb5d71305761d7c3ca75fd49452115ea92db1b5b98dbdef"}, + {file = "pyzmq-26.0.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8da0ed8a598693731c76659880a668f4748b59158f26ed283a93f7f04d47447e"}, + {file = "pyzmq-26.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7bf51970b11d67096bede97cdbad0f4333f7664f4708b9b2acb352bf4faa3140"}, + {file = "pyzmq-26.0.2-cp311-cp311-win32.whl", hash = "sha256:6f8e6bd5d066be605faa9fe5ec10aa1a46ad9f18fc8646f2b9aaefc8fb575742"}, + {file = "pyzmq-26.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:6d03da3a0ae691b361edcb39530075461202f699ce05adbb15055a0e1c9bcaa4"}, + {file = "pyzmq-26.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f84e33321b68ff00b60e9dbd1a483e31ab6022c577c8de525b8e771bd274ce68"}, + {file = "pyzmq-26.0.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:44c33ebd1c62a01db7fbc24e18bdda569d6639217d13d5929e986a2b0f69070d"}, + {file = "pyzmq-26.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ac04f904b4fce4afea9cdccbb78e24d468cb610a839d5a698853e14e2a3f9ecf"}, + {file = "pyzmq-26.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2133de5ba9adc5f481884ccb699eac9ce789708292945c05746880f95b241c0"}, + {file = "pyzmq-26.0.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7753c67c570d7fc80c2dc59b90ca1196f1224e0e2e29a548980c95fe0fe27fc1"}, + {file = "pyzmq-26.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d4e51632e6b12e65e8d9d7612446ecda2eda637a868afa7bce16270194650dd"}, + {file = "pyzmq-26.0.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d6c38806f6ecd0acf3104b8d7e76a206bcf56dadd6ce03720d2fa9d9157d5718"}, + {file = "pyzmq-26.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:48f496bbe14686b51cec15406323ae6942851e14022efd7fc0e2ecd092c5982c"}, + {file = "pyzmq-26.0.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e84a3161149c75bb7a7dc8646384186c34033e286a67fec1ad1bdedea165e7f4"}, + {file = "pyzmq-26.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:dabf796c67aa9f5a4fcc956d47f0d48b5c1ed288d628cf53aa1cf08e88654343"}, + {file = "pyzmq-26.0.2-cp312-cp312-win32.whl", hash = "sha256:3eee4c676af1b109f708d80ef0cf57ecb8aaa5900d1edaf90406aea7e0e20e37"}, + {file = "pyzmq-26.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:26721fec65846b3e4450dad050d67d31b017f97e67f7e0647b5f98aa47f828cf"}, + {file = "pyzmq-26.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:653955c6c233e90de128a1b8e882abc7216f41f44218056bd519969c8c413a15"}, + {file = "pyzmq-26.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:becd8d8fb068fbb5a52096efd83a2d8e54354383f691781f53a4c26aee944542"}, + {file = "pyzmq-26.0.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7a15e5465e7083c12517209c9dd24722b25e9b63c49a563922922fc03554eb35"}, + {file = "pyzmq-26.0.2-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e8158ac8616941f874841f9fa0f6d2f1466178c2ff91ea08353fdc19de0d40c2"}, + {file = "pyzmq-26.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea2c6a53e28c7066ea7db86fcc0b71d78d01b818bb11d4a4341ec35059885295"}, + {file = "pyzmq-26.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:bdbc7dab0b0e9c62c97b732899c4242e3282ba803bad668e03650b59b165466e"}, + {file = "pyzmq-26.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e74b6d5ef57bb65bf1b4a37453d8d86d88550dde3fb0f23b1f1a24e60c70af5b"}, + {file = "pyzmq-26.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ed4c6ee624ecbc77b18aeeb07bf0700d26571ab95b8f723f0d02e056b5bce438"}, + {file = "pyzmq-26.0.2-cp37-cp37m-win32.whl", hash = "sha256:8a98b3cb0484b83c19d8fb5524c8a469cd9f10e743f5904ac285d92678ee761f"}, + {file = "pyzmq-26.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:aa5f95d71b6eca9cec28aa0a2f8310ea53dea313b63db74932879ff860c1fb8d"}, + {file = "pyzmq-26.0.2-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:5ff56c76ce77b9805378a7a73032c17cbdb1a5b84faa1df03c5d3e306e5616df"}, + {file = "pyzmq-26.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bab697fc1574fee4b81da955678708567c43c813c84c91074e452bda5346c921"}, + {file = "pyzmq-26.0.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0c0fed8aa9ba0488ee1cbdaa304deea92d52fab43d373297002cfcc69c0a20c5"}, + {file = "pyzmq-26.0.2-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:606b922699fcec472ed814dda4dc3ff7c748254e0b26762a0ba21a726eb1c107"}, + {file = "pyzmq-26.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f0fd82bad4d199fa993fbf0ac586a7ac5879addbe436a35a389df7e0eb4c91"}, + {file = "pyzmq-26.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:166c5e41045939a52c01e6f374e493d9a6a45dfe677360d3e7026e38c42e8906"}, + {file = "pyzmq-26.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d566e859e8b8d5bca08467c093061774924b3d78a5ba290e82735b2569edc84b"}, + {file = "pyzmq-26.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:264ee0e72b72ca59279dc320deab5ae0fac0d97881aed1875ce4bde2e56ffde0"}, + {file = "pyzmq-26.0.2-cp38-cp38-win32.whl", hash = "sha256:3152bbd3a4744cbdd83dfb210ed701838b8b0c9065cef14671d6d91df12197d0"}, + {file = "pyzmq-26.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:bf77601d75ca692c179154b7e5943c286a4aaffec02c491afe05e60493ce95f2"}, + {file = "pyzmq-26.0.2-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:c770a7545b3deca2db185b59175e710a820dd4ed43619f4c02e90b0e227c6252"}, + {file = "pyzmq-26.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d47175f0a380bfd051726bc5c0054036ae4a5d8caf922c62c8a172ccd95c1a2a"}, + {file = "pyzmq-26.0.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bce298c1ce077837e110367c321285dc4246b531cde1abfc27e4a5bbe2bed4d"}, + {file = "pyzmq-26.0.2-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c40b09b7e184d6e3e1be1c8af2cc320c0f9f610d8a5df3dd866e6e6e4e32b235"}, + {file = "pyzmq-26.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d420d856bf728713874cefb911398efe69e1577835851dd297a308a78c14c249"}, + {file = "pyzmq-26.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d792d3cab987058451e55c70c5926e93e2ceb68ca5a2334863bb903eb860c9cb"}, + {file = "pyzmq-26.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:83ec17729cf6d3464dab98a11e98294fcd50e6b17eaabd3d841515c23f6dbd3a"}, + {file = "pyzmq-26.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47c17d5ebfa88ae90f08960c97b49917098665b8cd8be31f2c24e177bcf37a0f"}, + {file = "pyzmq-26.0.2-cp39-cp39-win32.whl", hash = "sha256:d509685d1cd1d018705a811c5f9d5bc237790936ead6d06f6558b77e16cc7235"}, + {file = "pyzmq-26.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:c7cc8cc009e8f6989a6d86c96f87dae5f5fb07d6c96916cdc7719d546152c7db"}, + {file = "pyzmq-26.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:3ada31cb879cd7532f4a85b501f4255c747d4813ab76b35c49ed510ce4865b45"}, + {file = "pyzmq-26.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0a6ceaddc830dd3ca86cb8451cf373d1f05215368e11834538c2902ed5205139"}, + {file = "pyzmq-26.0.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a967681463aa7a99eb9a62bb18229b653b45c10ff0947b31cc0837a83dfb86f"}, + {file = "pyzmq-26.0.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6472a73bc115bc40a2076609a90894775abe6faf19a78375675a2f889a613071"}, + {file = "pyzmq-26.0.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d6aea92bcccfe5e5524d3c70a6f16ffdae548390ddad26f4207d55c55a40593"}, + {file = "pyzmq-26.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e025f6351e49d48a5aa2f5a09293aa769b0ee7369c25bed551647234b7fa0c75"}, + {file = "pyzmq-26.0.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:40bd7ebe4dbb37d27f0c56e2a844f360239343a99be422085e13e97da13f73f9"}, + {file = "pyzmq-26.0.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1dd40d586ad6f53764104df6e01810fe1b4e88fd353774629a5e6fe253813f79"}, + {file = "pyzmq-26.0.2-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f2aca15e9ad8c8657b5b3d7ae3d1724dc8c1c1059c06b4b674c3aa36305f4930"}, + {file = "pyzmq-26.0.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:450ec234736732eb0ebeffdb95a352450d4592f12c3e087e2a9183386d22c8bf"}, + {file = "pyzmq-26.0.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f43be2bebbd09360a2f23af83b243dc25ffe7b583ea8c722e6df03e03a55f02f"}, + {file = "pyzmq-26.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:867f55e54aff254940bcec5eec068e7c0ac1e6bf360ab91479394a8bf356b0e6"}, + {file = "pyzmq-26.0.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b4dbc033c5ad46f8c429bf238c25a889b8c1d86bfe23a74e1031a991cb3f0000"}, + {file = "pyzmq-26.0.2-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6e8dd2961462e337e21092ec2da0c69d814dcb1b6e892955a37444a425e9cfb8"}, + {file = "pyzmq-26.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35391e72df6c14a09b697c7b94384947c1dd326aca883ff98ff137acdf586c33"}, + {file = "pyzmq-26.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:1c3d3c92fa54eda94ab369ca5b8d35059987c326ba5e55326eb068862f64b1fc"}, + {file = "pyzmq-26.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e7aa61a9cc4f0523373e31fc9255bf4567185a099f85ca3598e64de484da3ab2"}, + {file = "pyzmq-26.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee53a8191271f144cc20b12c19daa9f1546adc84a2f33839e3338039b55c373c"}, + {file = "pyzmq-26.0.2-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac60a980f07fa988983f7bfe6404ef3f1e4303f5288a01713bc1266df6d18783"}, + {file = "pyzmq-26.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88896b1b4817d7b2fe1ec7205c4bbe07bf5d92fb249bf2d226ddea8761996068"}, + {file = "pyzmq-26.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:18dfffe23751edee917764ffa133d5d3fef28dfd1cf3adebef8c90bc854c74c4"}, + {file = "pyzmq-26.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6926dd14cfe6967d3322640b6d5c3c3039db71716a5e43cca6e3b474e73e0b36"}, + {file = "pyzmq-26.0.2.tar.gz", hash = "sha256:f0f9bb370449158359bb72a3e12c658327670c0ffe6fbcd1af083152b64f9df0"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + +[[package]] +name = "qdrant-client" +version = "1.9.0" +description = "Client library for the Qdrant vector search engine" +optional = false +python-versions = ">=3.8" +files = [ + {file = "qdrant_client-1.9.0-py3-none-any.whl", hash = "sha256:ee02893eab1f642481b1ac1e38eb68ec30bab0f673bef7cc05c19fa5d2cbf43e"}, + {file = "qdrant_client-1.9.0.tar.gz", hash = "sha256:7b1792f616651a6f0a76312f945c13d088e9451726795b82ce0350f7df3b7981"}, +] + +[package.dependencies] +grpcio = ">=1.41.0" +grpcio-tools = ">=1.41.0" +httpx = {version = ">=0.20.0", extras = ["http2"]} +numpy = {version = ">=1.21", markers = "python_version >= \"3.8\" and python_version < \"3.12\""} +portalocker = ">=2.7.0,<3.0.0" +pydantic = ">=1.10.8" +urllib3 = ">=1.26.14,<3" + +[package.extras] +fastembed = ["fastembed (==0.2.6)"] + +[[package]] +name = "qtconsole" +version = "5.5.1" +description = "Jupyter Qt console" +optional = false +python-versions = ">= 3.8" +files = [ + {file = "qtconsole-5.5.1-py3-none-any.whl", hash = "sha256:8c75fa3e9b4ed884880ff7cea90a1b67451219279ec33deaee1d59e3df1a5d2b"}, + {file = "qtconsole-5.5.1.tar.gz", hash = "sha256:a0e806c6951db9490628e4df80caec9669b65149c7ba40f9bf033c025a5b56bc"}, +] + +[package.dependencies] +ipykernel = ">=4.1" +jupyter-client = ">=4.1" +jupyter-core = "*" +packaging = "*" +pygments = "*" +pyzmq = ">=17.1" +qtpy = ">=2.4.0" +traitlets = "<5.2.1 || >5.2.1,<5.2.2 || >5.2.2" + +[package.extras] +doc = ["Sphinx (>=1.3)"] +test = ["flaky", "pytest", "pytest-qt"] + +[[package]] +name = "qtpy" +version = "2.4.1" +description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)." +optional = false +python-versions = ">=3.7" +files = [ + {file = "QtPy-2.4.1-py3-none-any.whl", hash = "sha256:1c1d8c4fa2c884ae742b069151b0abe15b3f70491f3972698c683b8e38de839b"}, + {file = "QtPy-2.4.1.tar.gz", hash = "sha256:a5a15ffd519550a1361bdc56ffc07fda56a6af7292f17c7b395d4083af632987"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"] + +[[package]] +name = "radon" +version = "6.0.1" +description = "Code Metrics in Python" +optional = false +python-versions = "*" +files = [ + {file = "radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859"}, + {file = "radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5"}, +] + +[package.dependencies] +colorama = {version = ">=0.4.1", markers = "python_version > \"3.4\""} +mando = ">=0.6,<0.8" + +[package.extras] +toml = ["tomli (>=2.0.1)"] + +[[package]] +name = "referencing" +version = "0.35.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.0-py3-none-any.whl", hash = "sha256:8080727b30e364e5783152903672df9b6b091c926a146a759080b62ca3126cd6"}, + {file = "referencing-0.35.0.tar.gz", hash = "sha256:191e936b0c696d0af17ad7430a3dc68e88bc11be6514f4757dc890f04ab05889"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "regex" +version = "2024.4.16" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.7" +files = [ + {file = "regex-2024.4.16-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb83cc090eac63c006871fd24db5e30a1f282faa46328572661c0a24a2323a08"}, + {file = "regex-2024.4.16-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c91e1763696c0eb66340c4df98623c2d4e77d0746b8f8f2bee2c6883fd1fe18"}, + {file = "regex-2024.4.16-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:10188fe732dec829c7acca7422cdd1bf57d853c7199d5a9e96bb4d40db239c73"}, + {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:956b58d692f235cfbf5b4f3abd6d99bf102f161ccfe20d2fd0904f51c72c4c66"}, + {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a70b51f55fd954d1f194271695821dd62054d949efd6368d8be64edd37f55c86"}, + {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c02fcd2bf45162280613d2e4a1ca3ac558ff921ae4e308ecb307650d3a6ee51"}, + {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4ed75ea6892a56896d78f11006161eea52c45a14994794bcfa1654430984b22"}, + {file = "regex-2024.4.16-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd727ad276bb91928879f3aa6396c9a1d34e5e180dce40578421a691eeb77f47"}, + {file = "regex-2024.4.16-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7cbc5d9e8a1781e7be17da67b92580d6ce4dcef5819c1b1b89f49d9678cc278c"}, + {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:78fddb22b9ef810b63ef341c9fcf6455232d97cfe03938cbc29e2672c436670e"}, + {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:445ca8d3c5a01309633a0c9db57150312a181146315693273e35d936472df912"}, + {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:95399831a206211d6bc40224af1c635cb8790ddd5c7493e0bd03b85711076a53"}, + {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7731728b6568fc286d86745f27f07266de49603a6fdc4d19c87e8c247be452af"}, + {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4facc913e10bdba42ec0aee76d029aedda628161a7ce4116b16680a0413f658a"}, + {file = "regex-2024.4.16-cp310-cp310-win32.whl", hash = "sha256:911742856ce98d879acbea33fcc03c1d8dc1106234c5e7d068932c945db209c0"}, + {file = "regex-2024.4.16-cp310-cp310-win_amd64.whl", hash = "sha256:e0a2df336d1135a0b3a67f3bbf78a75f69562c1199ed9935372b82215cddd6e2"}, + {file = "regex-2024.4.16-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1210365faba7c2150451eb78ec5687871c796b0f1fa701bfd2a4a25420482d26"}, + {file = "regex-2024.4.16-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ab40412f8cd6f615bfedea40c8bf0407d41bf83b96f6fc9ff34976d6b7037fd"}, + {file = "regex-2024.4.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd80d1280d473500d8086d104962a82d77bfbf2b118053824b7be28cd5a79ea5"}, + {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bb966fdd9217e53abf824f437a5a2d643a38d4fd5fd0ca711b9da683d452969"}, + {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20b7a68444f536365af42a75ccecb7ab41a896a04acf58432db9e206f4e525d6"}, + {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b74586dd0b039c62416034f811d7ee62810174bb70dffcca6439f5236249eb09"}, + {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c8290b44d8b0af4e77048646c10c6e3aa583c1ca67f3b5ffb6e06cf0c6f0f89"}, + {file = "regex-2024.4.16-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2d80a6749724b37853ece57988b39c4e79d2b5fe2869a86e8aeae3bbeef9eb0"}, + {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3a1018e97aeb24e4f939afcd88211ace472ba566efc5bdf53fd8fd7f41fa7170"}, + {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8d015604ee6204e76569d2f44e5a210728fa917115bef0d102f4107e622b08d5"}, + {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:3d5ac5234fb5053850d79dd8eb1015cb0d7d9ed951fa37aa9e6249a19aa4f336"}, + {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:0a38d151e2cdd66d16dab550c22f9521ba79761423b87c01dae0a6e9add79c0d"}, + {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:159dc4e59a159cb8e4e8f8961eb1fa5d58f93cb1acd1701d8aff38d45e1a84a6"}, + {file = "regex-2024.4.16-cp311-cp311-win32.whl", hash = "sha256:ba2336d6548dee3117520545cfe44dc28a250aa091f8281d28804aa8d707d93d"}, + {file = "regex-2024.4.16-cp311-cp311-win_amd64.whl", hash = "sha256:8f83b6fd3dc3ba94d2b22717f9c8b8512354fd95221ac661784df2769ea9bba9"}, + {file = "regex-2024.4.16-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:80b696e8972b81edf0af2a259e1b2a4a661f818fae22e5fa4fa1a995fb4a40fd"}, + {file = "regex-2024.4.16-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d61ae114d2a2311f61d90c2ef1358518e8f05eafda76eaf9c772a077e0b465ec"}, + {file = "regex-2024.4.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ba6745440b9a27336443b0c285d705ce73adb9ec90e2f2004c64d95ab5a7598"}, + {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295004b2dd37b0835ea5c14a33e00e8cfa3c4add4d587b77287825f3418d310"}, + {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4aba818dcc7263852aabb172ec27b71d2abca02a593b95fa79351b2774eb1d2b"}, + {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0800631e565c47520aaa04ae38b96abc5196fe8b4aa9bd864445bd2b5848a7a"}, + {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08dea89f859c3df48a440dbdcd7b7155bc675f2fa2ec8c521d02dc69e877db70"}, + {file = "regex-2024.4.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eeaa0b5328b785abc344acc6241cffde50dc394a0644a968add75fcefe15b9d4"}, + {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4e819a806420bc010489f4e741b3036071aba209f2e0989d4750b08b12a9343f"}, + {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c2d0e7cbb6341e830adcbfa2479fdeebbfbb328f11edd6b5675674e7a1e37730"}, + {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:91797b98f5e34b6a49f54be33f72e2fb658018ae532be2f79f7c63b4ae225145"}, + {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:d2da13568eff02b30fd54fccd1e042a70fe920d816616fda4bf54ec705668d81"}, + {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:370c68dc5570b394cbaadff50e64d705f64debed30573e5c313c360689b6aadc"}, + {file = "regex-2024.4.16-cp312-cp312-win32.whl", hash = "sha256:904c883cf10a975b02ab3478bce652f0f5346a2c28d0a8521d97bb23c323cc8b"}, + {file = "regex-2024.4.16-cp312-cp312-win_amd64.whl", hash = "sha256:785c071c982dce54d44ea0b79cd6dfafddeccdd98cfa5f7b86ef69b381b457d9"}, + {file = "regex-2024.4.16-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e2f142b45c6fed48166faeb4303b4b58c9fcd827da63f4cf0a123c3480ae11fb"}, + {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87ab229332ceb127a165612d839ab87795972102cb9830e5f12b8c9a5c1b508"}, + {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81500ed5af2090b4a9157a59dbc89873a25c33db1bb9a8cf123837dcc9765047"}, + {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b340cccad138ecb363324aa26893963dcabb02bb25e440ebdf42e30963f1a4e0"}, + {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c72608e70f053643437bd2be0608f7f1c46d4022e4104d76826f0839199347a"}, + {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a01fe2305e6232ef3e8f40bfc0f0f3a04def9aab514910fa4203bafbc0bb4682"}, + {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:03576e3a423d19dda13e55598f0fd507b5d660d42c51b02df4e0d97824fdcae3"}, + {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:549c3584993772e25f02d0656ac48abdda73169fe347263948cf2b1cead622f3"}, + {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:34422d5a69a60b7e9a07a690094e824b66f5ddc662a5fc600d65b7c174a05f04"}, + {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:5f580c651a72b75c39e311343fe6875d6f58cf51c471a97f15a938d9fe4e0d37"}, + {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3399dd8a7495bbb2bacd59b84840eef9057826c664472e86c91d675d007137f5"}, + {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8d1f86f3f4e2388aa3310b50694ac44daefbd1681def26b4519bd050a398dc5a"}, + {file = "regex-2024.4.16-cp37-cp37m-win32.whl", hash = "sha256:dd5acc0a7d38fdc7a3a6fd3ad14c880819008ecb3379626e56b163165162cc46"}, + {file = "regex-2024.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:ba8122e3bb94ecda29a8de4cf889f600171424ea586847aa92c334772d200331"}, + {file = "regex-2024.4.16-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:743deffdf3b3481da32e8a96887e2aa945ec6685af1cfe2bcc292638c9ba2f48"}, + {file = "regex-2024.4.16-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7571f19f4a3fd00af9341c7801d1ad1967fc9c3f5e62402683047e7166b9f2b4"}, + {file = "regex-2024.4.16-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:df79012ebf6f4efb8d307b1328226aef24ca446b3ff8d0e30202d7ebcb977a8c"}, + {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e757d475953269fbf4b441207bb7dbdd1c43180711b6208e129b637792ac0b93"}, + {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4313ab9bf6a81206c8ac28fdfcddc0435299dc88cad12cc6305fd0e78b81f9e4"}, + {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d83c2bc678453646f1a18f8db1e927a2d3f4935031b9ad8a76e56760461105dd"}, + {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9df1bfef97db938469ef0a7354b2d591a2d438bc497b2c489471bec0e6baf7c4"}, + {file = "regex-2024.4.16-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62120ed0de69b3649cc68e2965376048793f466c5a6c4370fb27c16c1beac22d"}, + {file = "regex-2024.4.16-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c2ef6f7990b6e8758fe48ad08f7e2f66c8f11dc66e24093304b87cae9037bb4a"}, + {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8fc6976a3395fe4d1fbeb984adaa8ec652a1e12f36b56ec8c236e5117b585427"}, + {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:03e68f44340528111067cecf12721c3df4811c67268b897fbe695c95f860ac42"}, + {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ec7e0043b91115f427998febaa2beb82c82df708168b35ece3accb610b91fac1"}, + {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c21fc21a4c7480479d12fd8e679b699f744f76bb05f53a1d14182b31f55aac76"}, + {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:12f6a3f2f58bb7344751919a1876ee1b976fe08b9ffccb4bbea66f26af6017b9"}, + {file = "regex-2024.4.16-cp38-cp38-win32.whl", hash = "sha256:479595a4fbe9ed8f8f72c59717e8cf222da2e4c07b6ae5b65411e6302af9708e"}, + {file = "regex-2024.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:0534b034fba6101611968fae8e856c1698da97ce2efb5c2b895fc8b9e23a5834"}, + {file = "regex-2024.4.16-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7ccdd1c4a3472a7533b0a7aa9ee34c9a2bef859ba86deec07aff2ad7e0c3b94"}, + {file = "regex-2024.4.16-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f2f017c5be19984fbbf55f8af6caba25e62c71293213f044da3ada7091a4455"}, + {file = "regex-2024.4.16-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:803b8905b52de78b173d3c1e83df0efb929621e7b7c5766c0843704d5332682f"}, + {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:684008ec44ad275832a5a152f6e764bbe1914bea10968017b6feaecdad5736e0"}, + {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65436dce9fdc0aeeb0a0effe0839cb3d6a05f45aa45a4d9f9c60989beca78b9c"}, + {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea355eb43b11764cf799dda62c658c4d2fdb16af41f59bb1ccfec517b60bcb07"}, + {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c1165f3809ce7774f05cb74e5408cd3aa93ee8573ae959a97a53db3ca3180d"}, + {file = "regex-2024.4.16-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cccc79a9be9b64c881f18305a7c715ba199e471a3973faeb7ba84172abb3f317"}, + {file = "regex-2024.4.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00169caa125f35d1bca6045d65a662af0202704489fada95346cfa092ec23f39"}, + {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6cc38067209354e16c5609b66285af17a2863a47585bcf75285cab33d4c3b8df"}, + {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:23cff1b267038501b179ccbbd74a821ac4a7192a1852d1d558e562b507d46013"}, + {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d320b3bf82a39f248769fc7f188e00f93526cc0fe739cfa197868633d44701"}, + {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:89ec7f2c08937421bbbb8b48c54096fa4f88347946d4747021ad85f1b3021b3c"}, + {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4918fd5f8b43aa7ec031e0fef1ee02deb80b6afd49c85f0790be1dc4ce34cb50"}, + {file = "regex-2024.4.16-cp39-cp39-win32.whl", hash = "sha256:684e52023aec43bdf0250e843e1fdd6febbe831bd9d52da72333fa201aaa2335"}, + {file = "regex-2024.4.16-cp39-cp39-win_amd64.whl", hash = "sha256:e697e1c0238133589e00c244a8b676bc2cfc3ab4961318d902040d099fec7483"}, + {file = "regex-2024.4.16.tar.gz", hash = "sha256:fa454d26f2e87ad661c4f0c5a5fe4cf6aab1e307d1b94f16ffdfcb089ba685c0"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +description = "Pure python rfc3986 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9"}, + {file = "rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055"}, +] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rouge" +version = "1.0.1" +description = "Full Python ROUGE Score Implementation (not a wrapper)" +optional = false +python-versions = "*" +files = [ + {file = "rouge-1.0.1-py3-none-any.whl", hash = "sha256:28d118536e8c774dc47d1d15ec266479b4dd0914c4672ce117d4002789bdc644"}, + {file = "rouge-1.0.1.tar.gz", hash = "sha256:12b48346ca47d6bcf3c45061f315452b9ccec0620ee895ec85b7efc3d54aae34"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "rpds-py" +version = "0.18.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.18.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5b4e7d8d6c9b2e8ee2d55c90b59c707ca59bc30058269b3db7b1f8df5763557e"}, + {file = "rpds_py-0.18.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c463ed05f9dfb9baebef68048aed8dcdc94411e4bf3d33a39ba97e271624f8f7"}, + {file = "rpds_py-0.18.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01e36a39af54a30f28b73096dd39b6802eddd04c90dbe161c1b8dbe22353189f"}, + {file = "rpds_py-0.18.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d62dec4976954a23d7f91f2f4530852b0c7608116c257833922a896101336c51"}, + {file = "rpds_py-0.18.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd18772815d5f008fa03d2b9a681ae38d5ae9f0e599f7dda233c439fcaa00d40"}, + {file = "rpds_py-0.18.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:923d39efa3cfb7279a0327e337a7958bff00cc447fd07a25cddb0a1cc9a6d2da"}, + {file = "rpds_py-0.18.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39514da80f971362f9267c600b6d459bfbbc549cffc2cef8e47474fddc9b45b1"}, + {file = "rpds_py-0.18.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a34d557a42aa28bd5c48a023c570219ba2593bcbbb8dc1b98d8cf5d529ab1434"}, + {file = "rpds_py-0.18.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93df1de2f7f7239dc9cc5a4a12408ee1598725036bd2dedadc14d94525192fc3"}, + {file = "rpds_py-0.18.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:34b18ba135c687f4dac449aa5157d36e2cbb7c03cbea4ddbd88604e076aa836e"}, + {file = "rpds_py-0.18.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0b5dcf9193625afd8ecc92312d6ed78781c46ecbf39af9ad4681fc9f464af88"}, + {file = "rpds_py-0.18.0-cp310-none-win32.whl", hash = "sha256:c4325ff0442a12113a6379af66978c3fe562f846763287ef66bdc1d57925d337"}, + {file = "rpds_py-0.18.0-cp310-none-win_amd64.whl", hash = "sha256:7223a2a5fe0d217e60a60cdae28d6949140dde9c3bcc714063c5b463065e3d66"}, + {file = "rpds_py-0.18.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3a96e0c6a41dcdba3a0a581bbf6c44bb863f27c541547fb4b9711fd8cf0ffad4"}, + {file = "rpds_py-0.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30f43887bbae0d49113cbaab729a112251a940e9b274536613097ab8b4899cf6"}, + {file = "rpds_py-0.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcb25daa9219b4cf3a0ab24b0eb9a5cc8949ed4dc72acb8fa16b7e1681aa3c58"}, + {file = "rpds_py-0.18.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d68c93e381010662ab873fea609bf6c0f428b6d0bb00f2c6939782e0818d37bf"}, + {file = "rpds_py-0.18.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b34b7aa8b261c1dbf7720b5d6f01f38243e9b9daf7e6b8bc1fd4657000062f2c"}, + {file = "rpds_py-0.18.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e6d75ab12b0bbab7215e5d40f1e5b738aa539598db27ef83b2ec46747df90e1"}, + {file = "rpds_py-0.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b8612cd233543a3781bc659c731b9d607de65890085098986dfd573fc2befe5"}, + {file = "rpds_py-0.18.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aec493917dd45e3c69d00a8874e7cbed844efd935595ef78a0f25f14312e33c6"}, + {file = "rpds_py-0.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:661d25cbffaf8cc42e971dd570d87cb29a665f49f4abe1f9e76be9a5182c4688"}, + {file = "rpds_py-0.18.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1df3659d26f539ac74fb3b0c481cdf9d725386e3552c6fa2974f4d33d78e544b"}, + {file = "rpds_py-0.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1ce3ba137ed54f83e56fb983a5859a27d43a40188ba798993812fed73c70836"}, + {file = "rpds_py-0.18.0-cp311-none-win32.whl", hash = "sha256:69e64831e22a6b377772e7fb337533c365085b31619005802a79242fee620bc1"}, + {file = "rpds_py-0.18.0-cp311-none-win_amd64.whl", hash = "sha256:998e33ad22dc7ec7e030b3df701c43630b5bc0d8fbc2267653577e3fec279afa"}, + {file = "rpds_py-0.18.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f2facbd386dd60cbbf1a794181e6aa0bd429bd78bfdf775436020172e2a23f0"}, + {file = "rpds_py-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d9a5be316c15ffb2b3c405c4ff14448c36b4435be062a7f578ccd8b01f0c4d8"}, + {file = "rpds_py-0.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd5bf1af8efe569654bbef5a3e0a56eca45f87cfcffab31dd8dde70da5982475"}, + {file = "rpds_py-0.18.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5417558f6887e9b6b65b4527232553c139b57ec42c64570569b155262ac0754f"}, + {file = "rpds_py-0.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:56a737287efecafc16f6d067c2ea0117abadcd078d58721f967952db329a3e5c"}, + {file = "rpds_py-0.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f03bccbd8586e9dd37219bce4d4e0d3ab492e6b3b533e973fa08a112cb2ffc9"}, + {file = "rpds_py-0.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4457a94da0d5c53dc4b3e4de1158bdab077db23c53232f37a3cb7afdb053a4e3"}, + {file = "rpds_py-0.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ab39c1ba9023914297dd88ec3b3b3c3f33671baeb6acf82ad7ce883f6e8e157"}, + {file = "rpds_py-0.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9d54553c1136b50fd12cc17e5b11ad07374c316df307e4cfd6441bea5fb68496"}, + {file = "rpds_py-0.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0af039631b6de0397ab2ba16eaf2872e9f8fca391b44d3d8cac317860a700a3f"}, + {file = "rpds_py-0.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84ffab12db93b5f6bad84c712c92060a2d321b35c3c9960b43d08d0f639d60d7"}, + {file = "rpds_py-0.18.0-cp312-none-win32.whl", hash = "sha256:685537e07897f173abcf67258bee3c05c374fa6fff89d4c7e42fb391b0605e98"}, + {file = "rpds_py-0.18.0-cp312-none-win_amd64.whl", hash = "sha256:e003b002ec72c8d5a3e3da2989c7d6065b47d9eaa70cd8808b5384fbb970f4ec"}, + {file = "rpds_py-0.18.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:08f9ad53c3f31dfb4baa00da22f1e862900f45908383c062c27628754af2e88e"}, + {file = "rpds_py-0.18.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0013fe6b46aa496a6749c77e00a3eb07952832ad6166bd481c74bda0dcb6d58"}, + {file = "rpds_py-0.18.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32a92116d4f2a80b629778280103d2a510a5b3f6314ceccd6e38006b5e92dcb"}, + {file = "rpds_py-0.18.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e541ec6f2ec456934fd279a3120f856cd0aedd209fc3852eca563f81738f6861"}, + {file = "rpds_py-0.18.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bed88b9a458e354014d662d47e7a5baafd7ff81c780fd91584a10d6ec842cb73"}, + {file = "rpds_py-0.18.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2644e47de560eb7bd55c20fc59f6daa04682655c58d08185a9b95c1970fa1e07"}, + {file = "rpds_py-0.18.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e8916ae4c720529e18afa0b879473049e95949bf97042e938530e072fde061d"}, + {file = "rpds_py-0.18.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:465a3eb5659338cf2a9243e50ad9b2296fa15061736d6e26240e713522b6235c"}, + {file = "rpds_py-0.18.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ea7d4a99f3b38c37eac212dbd6ec42b7a5ec51e2c74b5d3223e43c811609e65f"}, + {file = "rpds_py-0.18.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:67071a6171e92b6da534b8ae326505f7c18022c6f19072a81dcf40db2638767c"}, + {file = "rpds_py-0.18.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:41ef53e7c58aa4ef281da975f62c258950f54b76ec8e45941e93a3d1d8580594"}, + {file = "rpds_py-0.18.0-cp38-none-win32.whl", hash = "sha256:fdea4952db2793c4ad0bdccd27c1d8fdd1423a92f04598bc39425bcc2b8ee46e"}, + {file = "rpds_py-0.18.0-cp38-none-win_amd64.whl", hash = "sha256:7cd863afe7336c62ec78d7d1349a2f34c007a3cc6c2369d667c65aeec412a5b1"}, + {file = "rpds_py-0.18.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5307def11a35f5ae4581a0b658b0af8178c65c530e94893345bebf41cc139d33"}, + {file = "rpds_py-0.18.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77f195baa60a54ef9d2de16fbbfd3ff8b04edc0c0140a761b56c267ac11aa467"}, + {file = "rpds_py-0.18.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39f5441553f1c2aed4de4377178ad8ff8f9d733723d6c66d983d75341de265ab"}, + {file = "rpds_py-0.18.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a00312dea9310d4cb7dbd7787e722d2e86a95c2db92fbd7d0155f97127bcb40"}, + {file = "rpds_py-0.18.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f2fc11e8fe034ee3c34d316d0ad8808f45bc3b9ce5857ff29d513f3ff2923a1"}, + {file = "rpds_py-0.18.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:586f8204935b9ec884500498ccc91aa869fc652c40c093bd9e1471fbcc25c022"}, + {file = "rpds_py-0.18.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddc2f4dfd396c7bfa18e6ce371cba60e4cf9d2e5cdb71376aa2da264605b60b9"}, + {file = "rpds_py-0.18.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ddcba87675b6d509139d1b521e0c8250e967e63b5909a7e8f8944d0f90ff36f"}, + {file = "rpds_py-0.18.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7bd339195d84439cbe5771546fe8a4e8a7a045417d8f9de9a368c434e42a721e"}, + {file = "rpds_py-0.18.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d7c36232a90d4755b720fbd76739d8891732b18cf240a9c645d75f00639a9024"}, + {file = "rpds_py-0.18.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6b0817e34942b2ca527b0e9298373e7cc75f429e8da2055607f4931fded23e20"}, + {file = "rpds_py-0.18.0-cp39-none-win32.whl", hash = "sha256:99f70b740dc04d09e6b2699b675874367885217a2e9f782bdf5395632ac663b7"}, + {file = "rpds_py-0.18.0-cp39-none-win_amd64.whl", hash = "sha256:6ef687afab047554a2d366e112dd187b62d261d49eb79b77e386f94644363294"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ad36cfb355e24f1bd37cac88c112cd7730873f20fb0bdaf8ba59eedf8216079f"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:36b3ee798c58ace201289024b52788161e1ea133e4ac93fba7d49da5fec0ef9e"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8a2f084546cc59ea99fda8e070be2fd140c3092dc11524a71aa8f0f3d5a55ca"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e4461d0f003a0aa9be2bdd1b798a041f177189c1a0f7619fe8c95ad08d9a45d7"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8db715ebe3bb7d86d77ac1826f7d67ec11a70dbd2376b7cc214199360517b641"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793968759cd0d96cac1e367afd70c235867831983f876a53389ad869b043c948"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66e6a3af5a75363d2c9a48b07cb27c4ea542938b1a2e93b15a503cdfa8490795"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ef0befbb5d79cf32d0266f5cff01545602344eda89480e1dd88aca964260b18"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:1d4acf42190d449d5e89654d5c1ed3a4f17925eec71f05e2a41414689cda02d1"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:a5f446dd5055667aabaee78487f2b5ab72e244f9bc0b2ffebfeec79051679984"}, + {file = "rpds_py-0.18.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9dbbeb27f4e70bfd9eec1be5477517365afe05a9b2c441a0b21929ee61048124"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:22806714311a69fd0af9b35b7be97c18a0fc2826e6827dbb3a8c94eac6cf7eeb"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b34ae4636dfc4e76a438ab826a0d1eed2589ca7d9a1b2d5bb546978ac6485461"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c8370641f1a7f0e0669ddccca22f1da893cef7628396431eb445d46d893e5cd"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c8362467a0fdeccd47935f22c256bec5e6abe543bf0d66e3d3d57a8fb5731863"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11a8c85ef4a07a7638180bf04fe189d12757c696eb41f310d2426895356dcf05"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b316144e85316da2723f9d8dc75bada12fa58489a527091fa1d5a612643d1a0e"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf1ea2e34868f6fbf070e1af291c8180480310173de0b0c43fc38a02929fc0e3"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e546e768d08ad55b20b11dbb78a745151acbd938f8f00d0cfbabe8b0199b9880"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4901165d170a5fde6f589acb90a6b33629ad1ec976d4529e769c6f3d885e3e80"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:618a3d6cae6ef8ec88bb76dd80b83cfe415ad4f1d942ca2a903bf6b6ff97a2da"}, + {file = "rpds_py-0.18.0-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ed4eb745efbff0a8e9587d22a84be94a5eb7d2d99c02dacf7bd0911713ed14dd"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6c81e5f372cd0dc5dc4809553d34f832f60a46034a5f187756d9b90586c2c307"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:43fbac5f22e25bee1d482c97474f930a353542855f05c1161fd804c9dc74a09d"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d7faa6f14017c0b1e69f5e2c357b998731ea75a442ab3841c0dbbbfe902d2c4"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08231ac30a842bd04daabc4d71fddd7e6d26189406d5a69535638e4dcb88fe76"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:044a3e61a7c2dafacae99d1e722cc2d4c05280790ec5a05031b3876809d89a5c"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f26b5bd1079acdb0c7a5645e350fe54d16b17bfc5e71f371c449383d3342e17"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:482103aed1dfe2f3b71a58eff35ba105289b8d862551ea576bd15479aba01f66"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1374f4129f9bcca53a1bba0bb86bf78325a0374577cf7e9e4cd046b1e6f20e24"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:635dc434ff724b178cb192c70016cc0ad25a275228f749ee0daf0eddbc8183b1"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:bc362ee4e314870a70f4ae88772d72d877246537d9f8cb8f7eacf10884862432"}, + {file = "rpds_py-0.18.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4832d7d380477521a8c1644bbab6588dfedea5e30a7d967b5fb75977c45fd77f"}, + {file = "rpds_py-0.18.0.tar.gz", hash = "sha256:42821446ee7a76f5d9f71f9e33a4fb2ffd724bb3e7f93386150b61a43115788d"}, +] + +[[package]] +name = "ruff" +version = "0.4.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8d14dc8953f8af7e003a485ef560bbefa5f8cc1ad994eebb5b12136049bbccc5"}, + {file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:24016ed18db3dc9786af103ff49c03bdf408ea253f3cb9e3638f39ac9cf2d483"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2e06459042ac841ed510196c350ba35a9b24a643e23db60d79b2db92af0c2b"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3afabaf7ba8e9c485a14ad8f4122feff6b2b93cc53cd4dad2fd24ae35112d5c5"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799eb468ea6bc54b95527143a4ceaf970d5aa3613050c6cff54c85fda3fde480"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec4ba9436a51527fb6931a8839af4c36a5481f8c19e8f5e42c2f7ad3a49f5069"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a2243f8f434e487c2a010c7252150b1fdf019035130f41b77626f5655c9ca22"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8772130a063f3eebdf7095da00c0b9898bd1774c43b336272c3e98667d4fb8fa"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ab165ef5d72392b4ebb85a8b0fbd321f69832a632e07a74794c0e598e7a8376"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f32cadf44c2020e75e0c56c3408ed1d32c024766bd41aedef92aa3ca28eef68"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:22e306bf15e09af45ca812bc42fa59b628646fa7c26072555f278994890bc7ac"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82986bb77ad83a1719c90b9528a9dd663c9206f7c0ab69282af8223566a0c34e"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:652e4ba553e421a6dc2a6d4868bc3b3881311702633eb3672f9f244ded8908cd"}, + {file = "ruff-0.4.2-py3-none-win32.whl", hash = "sha256:7891ee376770ac094da3ad40c116258a381b86c7352552788377c6eb16d784fe"}, + {file = "ruff-0.4.2-py3-none-win_amd64.whl", hash = "sha256:5ec481661fb2fd88a5d6cf1f83403d388ec90f9daaa36e40e2c003de66751798"}, + {file = "ruff-0.4.2-py3-none-win_arm64.whl", hash = "sha256:cbd1e87c71bca14792948c4ccb51ee61c3296e164019d2d484f3eaa2d360dfaf"}, + {file = "ruff-0.4.2.tar.gz", hash = "sha256:33bcc160aee2520664bc0859cfeaebc84bb7323becff3f303b8f1f2d81cb4edc"}, +] + +[[package]] +name = "semantic-text-splitter" +version = "0.12.0" +description = "Split text into semantic chunks, up to a desired chunk size. Supports calculating length by characters and tokens, and is callable from Rust and Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "semantic_text_splitter-0.12.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e6ec3807026e0637c04a5490cf26795eb10d22b629b651b3eaef37acce11fcc9"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:55c91133783e6d323d87defbd25704ada2bdcec4ea79bab1cd0f8563cc457d02"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b22bcc90269454b9107fb8f4b074977791f8f370942ff9cbd048f87862adab60"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4283948ad34b36807017f3b663908869a371fafe32922990b4989376bd7467c3"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26208a262dbf0d4350bba80badb27807982af3a289c5ed08f7edd5ffaebfbf16"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb62cdf886596dd6c411812092dc6132555546157079423b1af887d672e90300"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68b92b217c79317aaca6353c8057eb880d35e01b8b4de942382161ece990d472"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a26db68d4e6da5f47c71e797f837dfaf7c2d3d1a1ac733efa8983adf35dd5"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-win32.whl", hash = "sha256:e191be257b9e061211f4bb7c17d00fb7888c54f00dd5015f7d66136ca6b10549"}, + {file = "semantic_text_splitter-0.12.0-cp38-abi3-win_amd64.whl", hash = "sha256:4c3c9855596776da5d5a3f216385f6362f1db46cc541cfe7e67aeb975c65f37a"}, + {file = "semantic_text_splitter-0.12.0.tar.gz", hash = "sha256:d4f44fce36b42265cfccfcf6d7b6acfaa60e58c4f8dc027fa623d09473cab868"}, +] + +[package.extras] +docs = ["pdoc"] +test = ["black", "pytest", "tokenizers"] + +[[package]] +name = "send2trash" +version = "1.8.3" +description = "Send file to trash natively under Mac OS X, Windows and Linux" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9"}, + {file = "Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf"}, +] + +[package.extras] +nativelib = ["pyobjc-framework-Cocoa", "pywin32"] +objc = ["pyobjc-framework-Cocoa"] +win32 = ["pywin32"] + +[[package]] +name = "setuptools" +version = "69.5.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, + {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + +[[package]] +name = "sphinx" +version = "7.3.7" +description = "Python documentation generator" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinx-7.3.7-py3-none-any.whl", hash = "sha256:413f75440be4cacf328f580b4274ada4565fb2187d696a84970c23f77b64d8c3"}, + {file = "sphinx-7.3.7.tar.gz", hash = "sha256:a4a7db75ed37531c05002d56ed6948d4c42f473a36f46e1382b0bd76ca9627bc"}, +] + +[package.dependencies] +alabaster = ">=0.7.14,<0.8.0" +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.18.1,<0.22" +imagesize = ">=1.3" +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.14" +requests = ">=2.25.0" +snowballstemmer = ">=2.0" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.9" +tomli = {version = ">=2", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "importlib_metadata", "mypy (==1.9.0)", "pytest (>=6.0)", "ruff (==0.3.7)", "sphinx-lint", "tomli", "types-docutils", "types-requests"] +test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=6.0)", "setuptools (>=67.0)"] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +description = "Read the Docs theme for Sphinx" +optional = false +python-versions = ">=3.6" +files = [ + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, +] + +[package.dependencies] +docutils = "<0.21" +sphinx = ">=5,<8" +sphinxcontrib-jquery = ">=4,<5" + +[package.extras] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.8" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, + {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.6" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, + {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.5" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, + {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = false +python-versions = ">=2.7" +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.7" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, + {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.10" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, + {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "starlette" +version = "0.37.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, + {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "taskgroup" +version = "0.0.0a4" +description = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout" +optional = false +python-versions = "*" +files = [ + {file = "taskgroup-0.0.0a4-py2.py3-none-any.whl", hash = "sha256:5c1bd0e4c06114e7a4128583ab75c987597d5378a33948a3b74c662b90f61277"}, + {file = "taskgroup-0.0.0a4.tar.gz", hash = "sha256:eb08902d221e27661950f2a0320ddf3f939f579279996f81fe30779bca3a159c"}, +] + +[package.dependencies] +exceptiongroup = "*" + +[[package]] +name = "terminado" +version = "0.18.1" +description = "Tornado websocket backend for the Xterm.js Javascript terminal emulator library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0"}, + {file = "terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e"}, +] + +[package.dependencies] +ptyprocess = {version = "*", markers = "os_name != \"nt\""} +pywinpty = {version = ">=1.1.0", markers = "os_name == \"nt\""} +tornado = ">=6.1.0" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["pre-commit", "pytest (>=7.0)", "pytest-timeout"] +typing = ["mypy (>=1.6,<2.0)", "traitlets (>=5.11.1)"] + +[[package]] +name = "tinycss2" +version = "1.3.0" +description = "A tiny CSS parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tinycss2-1.3.0-py3-none-any.whl", hash = "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7"}, + {file = "tinycss2-1.3.0.tar.gz", hash = "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d"}, +] + +[package.dependencies] +webencodings = ">=0.4" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["pytest", "ruff"] + +[[package]] +name = "tokenize-rt" +version = "5.2.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"}, + {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"}, +] + +[[package]] +name = "tokenizers" +version = "0.19.1" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tokenizers-0.19.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:952078130b3d101e05ecfc7fc3640282d74ed26bcf691400f872563fca15ac97"}, + {file = "tokenizers-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82c8b8063de6c0468f08e82c4e198763e7b97aabfe573fd4cf7b33930ca4df77"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f03727225feaf340ceeb7e00604825addef622d551cbd46b7b775ac834c1e1c4"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:453e4422efdfc9c6b6bf2eae00d5e323f263fff62b29a8c9cd526c5003f3f642"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02e81bf089ebf0e7f4df34fa0207519f07e66d8491d963618252f2e0729e0b46"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b07c538ba956843833fee1190cf769c60dc62e1cf934ed50d77d5502194d63b1"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28cab1582e0eec38b1f38c1c1fb2e56bce5dc180acb1724574fc5f47da2a4fe"}, + {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b01afb7193d47439f091cd8f070a1ced347ad0f9144952a30a41836902fe09e"}, + {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7fb297edec6c6841ab2e4e8f357209519188e4a59b557ea4fafcf4691d1b4c98"}, + {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2e8a3dd055e515df7054378dc9d6fa8c8c34e1f32777fb9a01fea81496b3f9d3"}, + {file = "tokenizers-0.19.1-cp310-none-win32.whl", hash = "sha256:7ff898780a155ea053f5d934925f3902be2ed1f4d916461e1a93019cc7250837"}, + {file = "tokenizers-0.19.1-cp310-none-win_amd64.whl", hash = "sha256:bea6f9947e9419c2fda21ae6c32871e3d398cba549b93f4a65a2d369662d9403"}, + {file = "tokenizers-0.19.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5c88d1481f1882c2e53e6bb06491e474e420d9ac7bdff172610c4f9ad3898059"}, + {file = "tokenizers-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddf672ed719b4ed82b51499100f5417d7d9f6fb05a65e232249268f35de5ed14"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:dadc509cc8a9fe460bd274c0e16ac4184d0958117cf026e0ea8b32b438171594"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfedf31824ca4915b511b03441784ff640378191918264268e6923da48104acc"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac11016d0a04aa6487b1513a3a36e7bee7eec0e5d30057c9c0408067345c48d2"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76951121890fea8330d3a0df9a954b3f2a37e3ec20e5b0530e9a0044ca2e11fe"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b342d2ce8fc8d00f376af068e3274e2e8649562e3bc6ae4a67784ded6b99428d"}, + {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d16ff18907f4909dca9b076b9c2d899114dd6abceeb074eca0c93e2353f943aa"}, + {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:706a37cc5332f85f26efbe2bdc9ef8a9b372b77e4645331a405073e4b3a8c1c6"}, + {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16baac68651701364b0289979ecec728546133e8e8fe38f66fe48ad07996b88b"}, + {file = "tokenizers-0.19.1-cp311-none-win32.whl", hash = "sha256:9ed240c56b4403e22b9584ee37d87b8bfa14865134e3e1c3fb4b2c42fafd3256"}, + {file = "tokenizers-0.19.1-cp311-none-win_amd64.whl", hash = "sha256:ad57d59341710b94a7d9dbea13f5c1e7d76fd8d9bcd944a7a6ab0b0da6e0cc66"}, + {file = "tokenizers-0.19.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:621d670e1b1c281a1c9698ed89451395d318802ff88d1fc1accff0867a06f153"}, + {file = "tokenizers-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d924204a3dbe50b75630bd16f821ebda6a5f729928df30f582fb5aade90c818a"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f3fefdc0446b1a1e6d81cd4c07088ac015665d2e812f6dbba4a06267d1a2c95"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9620b78e0b2d52ef07b0d428323fb34e8ea1219c5eac98c2596311f20f1f9266"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04ce49e82d100594715ac1b2ce87d1a36e61891a91de774755f743babcd0dd52"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5c2ff13d157afe413bf7e25789879dd463e5a4abfb529a2d8f8473d8042e28f"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3174c76efd9d08f836bfccaca7cfec3f4d1c0a4cf3acbc7236ad577cc423c840"}, + {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9d5b6c0e7a1e979bec10ff960fae925e947aab95619a6fdb4c1d8ff3708ce3"}, + {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a179856d1caee06577220ebcfa332af046d576fb73454b8f4d4b0ba8324423ea"}, + {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:952b80dac1a6492170f8c2429bd11fcaa14377e097d12a1dbe0ef2fb2241e16c"}, + {file = "tokenizers-0.19.1-cp312-none-win32.whl", hash = "sha256:01d62812454c188306755c94755465505836fd616f75067abcae529c35edeb57"}, + {file = "tokenizers-0.19.1-cp312-none-win_amd64.whl", hash = "sha256:b70bfbe3a82d3e3fb2a5e9b22a39f8d1740c96c68b6ace0086b39074f08ab89a"}, + {file = "tokenizers-0.19.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:bb9dfe7dae85bc6119d705a76dc068c062b8b575abe3595e3c6276480e67e3f1"}, + {file = "tokenizers-0.19.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:1f0360cbea28ea99944ac089c00de7b2e3e1c58f479fb8613b6d8d511ce98267"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:71e3ec71f0e78780851fef28c2a9babe20270404c921b756d7c532d280349214"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b82931fa619dbad979c0ee8e54dd5278acc418209cc897e42fac041f5366d626"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ff5b90eabdcdaa19af697885f70fe0b714ce16709cf43d4952f1f85299e73a"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e742d76ad84acbdb1a8e4694f915fe59ff6edc381c97d6dfdd054954e3478ad4"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8c5d59d7b59885eab559d5bc082b2985555a54cda04dda4c65528d90ad252ad"}, + {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2da5c32ed869bebd990c9420df49813709e953674c0722ff471a116d97b22d"}, + {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:638e43936cc8b2cbb9f9d8dde0fe5e7e30766a3318d2342999ae27f68fdc9bd6"}, + {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:78e769eb3b2c79687d9cb0f89ef77223e8e279b75c0a968e637ca7043a84463f"}, + {file = "tokenizers-0.19.1-cp37-none-win32.whl", hash = "sha256:72791f9bb1ca78e3ae525d4782e85272c63faaef9940d92142aa3eb79f3407a3"}, + {file = "tokenizers-0.19.1-cp37-none-win_amd64.whl", hash = "sha256:f3bbb7a0c5fcb692950b041ae11067ac54826204318922da754f908d95619fbc"}, + {file = "tokenizers-0.19.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:07f9295349bbbcedae8cefdbcfa7f686aa420be8aca5d4f7d1ae6016c128c0c5"}, + {file = "tokenizers-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10a707cc6c4b6b183ec5dbfc5c34f3064e18cf62b4a938cb41699e33a99e03c1"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6309271f57b397aa0aff0cbbe632ca9d70430839ca3178bf0f06f825924eca22"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ad23d37d68cf00d54af184586d79b84075ada495e7c5c0f601f051b162112dc"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:427c4f0f3df9109314d4f75b8d1f65d9477033e67ffaec4bca53293d3aca286d"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e83a31c9cf181a0a3ef0abad2b5f6b43399faf5da7e696196ddd110d332519ee"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c27b99889bd58b7e301468c0838c5ed75e60c66df0d4db80c08f43462f82e0d3"}, + {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bac0b0eb952412b0b196ca7a40e7dce4ed6f6926489313414010f2e6b9ec2adf"}, + {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8a6298bde623725ca31c9035a04bf2ef63208d266acd2bed8c2cb7d2b7d53ce6"}, + {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:08a44864e42fa6d7d76d7be4bec62c9982f6f6248b4aa42f7302aa01e0abfd26"}, + {file = "tokenizers-0.19.1-cp38-none-win32.whl", hash = "sha256:1de5bc8652252d9357a666e609cb1453d4f8e160eb1fb2830ee369dd658e8975"}, + {file = "tokenizers-0.19.1-cp38-none-win_amd64.whl", hash = "sha256:0bcce02bf1ad9882345b34d5bd25ed4949a480cf0e656bbd468f4d8986f7a3f1"}, + {file = "tokenizers-0.19.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0b9394bd204842a2a1fd37fe29935353742be4a3460b6ccbaefa93f58a8df43d"}, + {file = "tokenizers-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4692ab92f91b87769d950ca14dbb61f8a9ef36a62f94bad6c82cc84a51f76f6a"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6258c2ef6f06259f70a682491c78561d492e885adeaf9f64f5389f78aa49a051"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85cf76561fbd01e0d9ea2d1cbe711a65400092bc52b5242b16cfd22e51f0c58"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:670b802d4d82bbbb832ddb0d41df7015b3e549714c0e77f9bed3e74d42400fbe"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85aa3ab4b03d5e99fdd31660872249df5e855334b6c333e0bc13032ff4469c4a"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbf001afbbed111a79ca47d75941e9e5361297a87d186cbfc11ed45e30b5daba"}, + {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c89aa46c269e4e70c4d4f9d6bc644fcc39bb409cb2a81227923404dd6f5227"}, + {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:39c1ec76ea1027438fafe16ecb0fb84795e62e9d643444c1090179e63808c69d"}, + {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c2a0d47a89b48d7daa241e004e71fb5a50533718897a4cd6235cb846d511a478"}, + {file = "tokenizers-0.19.1-cp39-none-win32.whl", hash = "sha256:61b7fe8886f2e104d4caf9218b157b106207e0f2a4905c9c7ac98890688aabeb"}, + {file = "tokenizers-0.19.1-cp39-none-win_amd64.whl", hash = "sha256:f97660f6c43efd3e0bfd3f2e3e5615bf215680bad6ee3d469df6454b8c6e8256"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3b11853f17b54c2fe47742c56d8a33bf49ce31caf531e87ac0d7d13d327c9334"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d26194ef6c13302f446d39972aaa36a1dda6450bc8949f5eb4c27f51191375bd"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e8d1ed93beda54bbd6131a2cb363a576eac746d5c26ba5b7556bc6f964425594"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca407133536f19bdec44b3da117ef0d12e43f6d4b56ac4c765f37eca501c7bda"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce05fde79d2bc2e46ac08aacbc142bead21614d937aac950be88dc79f9db9022"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:35583cd46d16f07c054efd18b5d46af4a2f070a2dd0a47914e66f3ff5efb2b1e"}, + {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:43350270bfc16b06ad3f6f07eab21f089adb835544417afda0f83256a8bf8b75"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b4399b59d1af5645bcee2072a463318114c39b8547437a7c2d6a186a1b5a0e2d"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6852c5b2a853b8b0ddc5993cd4f33bfffdca4fcc5d52f89dd4b8eada99379285"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd266ae85c3d39df2f7e7d0e07f6c41a55e9a3123bb11f854412952deacd828"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecb2651956eea2aa0a2d099434134b1b68f1c31f9a5084d6d53f08ed43d45ff2"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b279ab506ec4445166ac476fb4d3cc383accde1ea152998509a94d82547c8e2a"}, + {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:89183e55fb86e61d848ff83753f64cded119f5d6e1f553d14ffee3700d0a4a49"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2edbc75744235eea94d595a8b70fe279dd42f3296f76d5a86dde1d46e35f574"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0e64bfde9a723274e9a71630c3e9494ed7b4c0f76a1faacf7fe294cd26f7ae7c"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0b5ca92bfa717759c052e345770792d02d1f43b06f9e790ca0a1db62838816f3"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f8a20266e695ec9d7a946a019c1d5ca4eddb6613d4f466888eee04f16eedb85"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63c38f45d8f2a2ec0f3a20073cccb335b9f99f73b3c69483cd52ebc75369d8a1"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dd26e3afe8a7b61422df3176e06664503d3f5973b94f45d5c45987e1cb711876"}, + {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:eddd5783a4a6309ce23432353cdb36220e25cbb779bfa9122320666508b44b88"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:56ae39d4036b753994476a1b935584071093b55c7a72e3b8288e68c313ca26e7"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f9939ca7e58c2758c01b40324a59c034ce0cebad18e0d4563a9b1beab3018243"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c330c0eb815d212893c67a032e9dc1b38a803eccb32f3e8172c19cc69fbb439"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec11802450a2487cdf0e634b750a04cbdc1c4d066b97d94ce7dd2cb51ebb325b"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b718f316b596f36e1dae097a7d5b91fc5b85e90bf08b01ff139bd8953b25af"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ed69af290c2b65169f0ba9034d1dc39a5db9459b32f1dd8b5f3f32a3fcf06eab"}, + {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f8a9c828277133af13f3859d1b6bf1c3cb6e9e1637df0e45312e6b7c2e622b1f"}, + {file = "tokenizers-0.19.1.tar.gz", hash = "sha256:ee59e6680ed0fdbe6b724cf38bd70400a0c1dd623b07ac729087270caeac88e3"}, +] + +[package.dependencies] +huggingface-hub = ">=0.16.4,<1.0" + +[package.extras] +dev = ["tokenizers[testing]"] +docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] +testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tornado" +version = "6.4" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">= 3.8" +files = [ + {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, + {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, + {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, + {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"}, + {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"}, + {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, + {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, + {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, + {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, + {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, + {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, +] + +[[package]] +name = "tqdm" +version = "4.66.2" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9"}, + {file = "tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "types-colorama" +version = "0.4.15.20240311" +description = "Typing stubs for colorama" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-colorama-0.4.15.20240311.tar.gz", hash = "sha256:a28e7f98d17d2b14fb9565d32388e419f4108f557a7d939a66319969b2b99c7a"}, + {file = "types_colorama-0.4.15.20240311-py3-none-any.whl", hash = "sha256:6391de60ddc0db3f147e31ecb230006a6823e81e380862ffca1e4695c13a0b8e"}, +] + +[[package]] +name = "types-decorator" +version = "5.1.8.20240310" +description = "Typing stubs for decorator" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-decorator-5.1.8.20240310.tar.gz", hash = "sha256:52e316b03783886a8a2abdc228f7071680ba65894545cd2085ebe3cf88684a0e"}, + {file = "types_decorator-5.1.8.20240310-py3-none-any.whl", hash = "sha256:3af75dc38f5baf65b9b53ea6661ce2056c5ca7d70d620d0b1f620285c1242757"}, +] + +[[package]] +name = "types-docutils" +version = "0.21.0.20240423" +description = "Typing stubs for docutils" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-docutils-0.21.0.20240423.tar.gz", hash = "sha256:7716ec6c68b5179b7ba1738cace2f1326e64df9f44b7ab08d9904d32c23fc15f"}, + {file = "types_docutils-0.21.0.20240423-py3-none-any.whl", hash = "sha256:7f6e84ba8fcd2454c5b8bb8d77384d091a901929cc2b31079316e10eb346580a"}, +] + +[[package]] +name = "types-pygments" +version = "2.17.0.20240310" +description = "Typing stubs for Pygments" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-Pygments-2.17.0.20240310.tar.gz", hash = "sha256:b1d97e905ce36343c7283b0319182ae6d4f967188f361f45502a18ae43e03e1f"}, + {file = "types_Pygments-2.17.0.20240310-py3-none-any.whl", hash = "sha256:b101ca9448aaff52af6966506f1fdd73b1e60a79b8a79a8bace3366cbf1f7ed9"}, +] + +[package.dependencies] +types-docutils = "*" +types-setuptools = "*" + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20240316" +description = "Typing stubs for python-dateutil" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-python-dateutil-2.9.0.20240316.tar.gz", hash = "sha256:5d2f2e240b86905e40944dd787db6da9263f0deabef1076ddaed797351ec0202"}, + {file = "types_python_dateutil-2.9.0.20240316-py3-none-any.whl", hash = "sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b"}, +] + +[[package]] +name = "types-pytz" +version = "2024.1.0.20240417" +description = "Typing stubs for pytz" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-pytz-2024.1.0.20240417.tar.gz", hash = "sha256:6810c8a1f68f21fdf0f4f374a432487c77645a0ac0b31de4bf4690cf21ad3981"}, + {file = "types_pytz-2024.1.0.20240417-py3-none-any.whl", hash = "sha256:8335d443310e2db7b74e007414e74c4f53b67452c0cb0d228ca359ccfba59659"}, +] + +[[package]] +name = "types-requests" +version = "2.31.0.20240406" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.31.0.20240406.tar.gz", hash = "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1"}, + {file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"}, +] + +[package.dependencies] +urllib3 = ">=2" + +[[package]] +name = "types-setuptools" +version = "69.5.0.20240423" +description = "Typing stubs for setuptools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-setuptools-69.5.0.20240423.tar.gz", hash = "sha256:a7ba908f1746c4337d13f027fa0f4a5bcad6d1d92048219ba792b3295c58586d"}, + {file = "types_setuptools-69.5.0.20240423-py3-none-any.whl", hash = "sha256:a4381e041510755a6c9210e26ad55b1629bc10237aeb9cb8b6bd24996b73db48"}, +] + +[[package]] +name = "types-tqdm" +version = "4.66.0.20240417" +description = "Typing stubs for tqdm" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-tqdm-4.66.0.20240417.tar.gz", hash = "sha256:16dce9ef522ea8d40e4f5b8d84dd8a1166eefc13ceee7a7e158bf0f1a1421a31"}, + {file = "types_tqdm-4.66.0.20240417-py3-none-any.whl", hash = "sha256:248aef1f9986b7b8c2c12b3cb4399fc17dba0a29e7e3f3f9cd704babb879383d"}, +] + +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +description = "RFC 6570 URI Template Processor" +optional = false +python-versions = ">=3.7" +files = [ + {file = "uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7"}, + {file = "uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363"}, +] + +[package.extras] +dev = ["flake8", "flake8-annotations", "flake8-bandit", "flake8-bugbear", "flake8-commas", "flake8-comprehensions", "flake8-continuation", "flake8-datetimez", "flake8-docstrings", "flake8-import-order", "flake8-literal", "flake8-modern-annotations", "flake8-noqa", "flake8-pyproject", "flake8-requirements", "flake8-typechecking-import", "flake8-use-fstring", "mypy", "pep8-naming", "types-PyYAML"] + +[[package]] +name = "urllib3" +version = "2.2.1" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uvicorn" +version = "0.29.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, + {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "virtualenv" +version = "20.26.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.0-py3-none-any.whl", hash = "sha256:0846377ea76e818daaa3e00a4365c018bc3ac9760cbb3544de542885aad61fb3"}, + {file = "virtualenv-20.26.0.tar.gz", hash = "sha256:ec25a9671a5102c8d2657f62792a27b48f016664c6873f6beed3800008577210"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "vulture" +version = "2.11" +description = "Find dead code" +optional = false +python-versions = ">=3.8" +files = [ + {file = "vulture-2.11-py2.py3-none-any.whl", hash = "sha256:12d745f7710ffbf6aeb8279ba9068a24d4e52e8ed333b8b044035c9d6b823aba"}, + {file = "vulture-2.11.tar.gz", hash = "sha256:f0fbb60bce6511aad87ee0736c502456737490a82d919a44e6d92262cb35f1c2"}, +] + +[package.dependencies] +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[[package]] +name = "webcolors" +version = "1.13" +description = "A library for working with the color formats defined by HTML and CSS." +optional = false +python-versions = ">=3.7" +files = [ + {file = "webcolors-1.13-py3-none-any.whl", hash = "sha256:29bc7e8752c0a1bd4a1f03c14d6e6a72e93d82193738fa860cbff59d0fcc11bf"}, + {file = "webcolors-1.13.tar.gz", hash = "sha256:c225b674c83fa923be93d235330ce0300373d02885cef23238813b0d5668304a"}, +] + +[package.extras] +docs = ["furo", "sphinx", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-notfound-page", "sphinxext-opengraph"] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + +[[package]] +name = "widgetsnbextension" +version = "4.0.10" +description = "Jupyter interactive widgets for Jupyter Notebook" +optional = false +python-versions = ">=3.7" +files = [ + {file = "widgetsnbextension-4.0.10-py3-none-any.whl", hash = "sha256:d37c3724ec32d8c48400a435ecfa7d3e259995201fbefa37163124a9fcb393cc"}, + {file = "widgetsnbextension-4.0.10.tar.gz", hash = "sha256:64196c5ff3b9a9183a8e699a4227fb0b7002f252c814098e66c4d1cd0644688f"}, +] + +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + +[[package]] +name = "xxhash" +version = "3.4.1" +description = "Python binding for xxHash" +optional = false +python-versions = ">=3.7" +files = [ + {file = "xxhash-3.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91dbfa55346ad3e18e738742236554531a621042e419b70ad8f3c1d9c7a16e7f"}, + {file = "xxhash-3.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:665a65c2a48a72068fcc4d21721510df5f51f1142541c890491afc80451636d2"}, + {file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb11628470a6004dc71a09fe90c2f459ff03d611376c1debeec2d648f44cb693"}, + {file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bef2a7dc7b4f4beb45a1edbba9b9194c60a43a89598a87f1a0226d183764189"}, + {file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c0f7b2d547d72c7eda7aa817acf8791f0146b12b9eba1d4432c531fb0352228"}, + {file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00f2fdef6b41c9db3d2fc0e7f94cb3db86693e5c45d6de09625caad9a469635b"}, + {file = "xxhash-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23cfd9ca09acaf07a43e5a695143d9a21bf00f5b49b15c07d5388cadf1f9ce11"}, + {file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6a9ff50a3cf88355ca4731682c168049af1ca222d1d2925ef7119c1a78e95b3b"}, + {file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f1d7c69a1e9ca5faa75546fdd267f214f63f52f12692f9b3a2f6467c9e67d5e7"}, + {file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:672b273040d5d5a6864a36287f3514efcd1d4b1b6a7480f294c4b1d1ee1b8de0"}, + {file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4178f78d70e88f1c4a89ff1ffe9f43147185930bb962ee3979dba15f2b1cc799"}, + {file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9804b9eb254d4b8cc83ab5a2002128f7d631dd427aa873c8727dba7f1f0d1c2b"}, + {file = "xxhash-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c09c49473212d9c87261d22c74370457cfff5db2ddfc7fd1e35c80c31a8c14ce"}, + {file = "xxhash-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ebbb1616435b4a194ce3466d7247df23499475c7ed4eb2681a1fa42ff766aff6"}, + {file = "xxhash-3.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:25dc66be3db54f8a2d136f695b00cfe88018e59ccff0f3b8f545869f376a8a46"}, + {file = "xxhash-3.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58c49083801885273e262c0f5bbeac23e520564b8357fbb18fb94ff09d3d3ea5"}, + {file = "xxhash-3.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b526015a973bfbe81e804a586b703f163861da36d186627e27524f5427b0d520"}, + {file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36ad4457644c91a966f6fe137d7467636bdc51a6ce10a1d04f365c70d6a16d7e"}, + {file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:248d3e83d119770f96003271fe41e049dd4ae52da2feb8f832b7a20e791d2920"}, + {file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2070b6d5bbef5ee031666cf21d4953c16e92c2f8a24a94b5c240f8995ba3b1d0"}, + {file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2746035f518f0410915e247877f7df43ef3372bf36cfa52cc4bc33e85242641"}, + {file = "xxhash-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a8ba6181514681c2591840d5632fcf7356ab287d4aff1c8dea20f3c78097088"}, + {file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0aac5010869240e95f740de43cd6a05eae180c59edd182ad93bf12ee289484fa"}, + {file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4cb11d8debab1626181633d184b2372aaa09825bde709bf927704ed72765bed1"}, + {file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b29728cff2c12f3d9f1d940528ee83918d803c0567866e062683f300d1d2eff3"}, + {file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:a15cbf3a9c40672523bdb6ea97ff74b443406ba0ab9bca10ceccd9546414bd84"}, + {file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6e66df260fed01ed8ea790c2913271641c58481e807790d9fca8bfd5a3c13844"}, + {file = "xxhash-3.4.1-cp311-cp311-win32.whl", hash = "sha256:e867f68a8f381ea12858e6d67378c05359d3a53a888913b5f7d35fbf68939d5f"}, + {file = "xxhash-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:200a5a3ad9c7c0c02ed1484a1d838b63edcf92ff538770ea07456a3732c577f4"}, + {file = "xxhash-3.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:1d03f1c0d16d24ea032e99f61c552cb2b77d502e545187338bea461fde253583"}, + {file = "xxhash-3.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c4bbba9b182697a52bc0c9f8ec0ba1acb914b4937cd4a877ad78a3b3eeabefb3"}, + {file = "xxhash-3.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9fd28a9da300e64e434cfc96567a8387d9a96e824a9be1452a1e7248b7763b78"}, + {file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6066d88c9329ab230e18998daec53d819daeee99d003955c8db6fc4971b45ca3"}, + {file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93805bc3233ad89abf51772f2ed3355097a5dc74e6080de19706fc447da99cd3"}, + {file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64da57d5ed586ebb2ecdde1e997fa37c27fe32fe61a656b77fabbc58e6fbff6e"}, + {file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97322e9a7440bf3c9805cbaac090358b43f650516486746f7fa482672593df"}, + {file = "xxhash-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbe750d512982ee7d831838a5dee9e9848f3fb440e4734cca3f298228cc957a6"}, + {file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fd79d4087727daf4d5b8afe594b37d611ab95dc8e29fe1a7517320794837eb7d"}, + {file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:743612da4071ff9aa4d055f3f111ae5247342931dedb955268954ef7201a71ff"}, + {file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:b41edaf05734092f24f48c0958b3c6cbaaa5b7e024880692078c6b1f8247e2fc"}, + {file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:a90356ead70d715fe64c30cd0969072de1860e56b78adf7c69d954b43e29d9fa"}, + {file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ac56eebb364e44c85e1d9e9cc5f6031d78a34f0092fea7fc80478139369a8b4a"}, + {file = "xxhash-3.4.1-cp312-cp312-win32.whl", hash = "sha256:911035345932a153c427107397c1518f8ce456f93c618dd1c5b54ebb22e73747"}, + {file = "xxhash-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:f31ce76489f8601cc7b8713201ce94b4bd7b7ce90ba3353dccce7e9e1fee71fa"}, + {file = "xxhash-3.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:b5beb1c6a72fdc7584102f42c4d9df232ee018ddf806e8c90906547dfb43b2da"}, + {file = "xxhash-3.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6d42b24d1496deb05dee5a24ed510b16de1d6c866c626c2beb11aebf3be278b9"}, + {file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b685fab18876b14a8f94813fa2ca80cfb5ab6a85d31d5539b7cd749ce9e3624"}, + {file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419ffe34c17ae2df019a4685e8d3934d46b2e0bbe46221ab40b7e04ed9f11137"}, + {file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e041ce5714f95251a88670c114b748bca3bf80cc72400e9f23e6d0d59cf2681"}, + {file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc860d887c5cb2f524899fb8338e1bb3d5789f75fac179101920d9afddef284b"}, + {file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:312eba88ffe0a05e332e3a6f9788b73883752be63f8588a6dc1261a3eaaaf2b2"}, + {file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e01226b6b6a1ffe4e6bd6d08cfcb3ca708b16f02eb06dd44f3c6e53285f03e4f"}, + {file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9f3025a0d5d8cf406a9313cd0d5789c77433ba2004b1c75439b67678e5136537"}, + {file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:6d3472fd4afef2a567d5f14411d94060099901cd8ce9788b22b8c6f13c606a93"}, + {file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:43984c0a92f06cac434ad181f329a1445017c33807b7ae4f033878d860a4b0f2"}, + {file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a55e0506fdb09640a82ec4f44171273eeabf6f371a4ec605633adb2837b5d9d5"}, + {file = "xxhash-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:faec30437919555b039a8bdbaba49c013043e8f76c999670aef146d33e05b3a0"}, + {file = "xxhash-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c9e1b646af61f1fc7083bb7b40536be944f1ac67ef5e360bca2d73430186971a"}, + {file = "xxhash-3.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:961d948b7b1c1b6c08484bbce3d489cdf153e4122c3dfb07c2039621243d8795"}, + {file = "xxhash-3.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:719a378930504ab159f7b8e20fa2aa1896cde050011af838af7e7e3518dd82de"}, + {file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74fb5cb9406ccd7c4dd917f16630d2e5e8cbbb02fc2fca4e559b2a47a64f4940"}, + {file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5dab508ac39e0ab988039bc7f962c6ad021acd81fd29145962b068df4148c476"}, + {file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c59f3e46e7daf4c589e8e853d700ef6607afa037bfad32c390175da28127e8c"}, + {file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc07256eff0795e0f642df74ad096f8c5d23fe66bc138b83970b50fc7f7f6c5"}, + {file = "xxhash-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9f749999ed80f3955a4af0eb18bb43993f04939350b07b8dd2f44edc98ffee9"}, + {file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7688d7c02149a90a3d46d55b341ab7ad1b4a3f767be2357e211b4e893efbaaf6"}, + {file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a8b4977963926f60b0d4f830941c864bed16aa151206c01ad5c531636da5708e"}, + {file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:8106d88da330f6535a58a8195aa463ef5281a9aa23b04af1848ff715c4398fb4"}, + {file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4c76a77dbd169450b61c06fd2d5d436189fc8ab7c1571d39265d4822da16df22"}, + {file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:11f11357c86d83e53719c592021fd524efa9cf024dc7cb1dfb57bbbd0d8713f2"}, + {file = "xxhash-3.4.1-cp38-cp38-win32.whl", hash = "sha256:0c786a6cd74e8765c6809892a0d45886e7c3dc54de4985b4a5eb8b630f3b8e3b"}, + {file = "xxhash-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:aabf37fb8fa27430d50507deeab2ee7b1bcce89910dd10657c38e71fee835594"}, + {file = "xxhash-3.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6127813abc1477f3a83529b6bbcfeddc23162cece76fa69aee8f6a8a97720562"}, + {file = "xxhash-3.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef2e194262f5db16075caea7b3f7f49392242c688412f386d3c7b07c7733a70a"}, + {file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71be94265b6c6590f0018bbf73759d21a41c6bda20409782d8117e76cd0dfa8b"}, + {file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10e0a619cdd1c0980e25eb04e30fe96cf8f4324758fa497080af9c21a6de573f"}, + {file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa122124d2e3bd36581dd78c0efa5f429f5220313479fb1072858188bc2d5ff1"}, + {file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17032f5a4fea0a074717fe33477cb5ee723a5f428de7563e75af64bfc1b1e10"}, + {file = "xxhash-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca7783b20e3e4f3f52f093538895863f21d18598f9a48211ad757680c3bd006f"}, + {file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d77d09a1113899fad5f354a1eb4f0a9afcf58cefff51082c8ad643ff890e30cf"}, + {file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:21287bcdd299fdc3328cc0fbbdeaa46838a1c05391264e51ddb38a3f5b09611f"}, + {file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:dfd7a6cc483e20b4ad90224aeb589e64ec0f31e5610ab9957ff4314270b2bf31"}, + {file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:543c7fcbc02bbb4840ea9915134e14dc3dc15cbd5a30873a7a5bf66039db97ec"}, + {file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fe0a98d990e433013f41827b62be9ab43e3cf18e08b1483fcc343bda0d691182"}, + {file = "xxhash-3.4.1-cp39-cp39-win32.whl", hash = "sha256:b9097af00ebf429cc7c0e7d2fdf28384e4e2e91008130ccda8d5ae653db71e54"}, + {file = "xxhash-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:d699b921af0dcde50ab18be76c0d832f803034d80470703700cb7df0fbec2832"}, + {file = "xxhash-3.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:2be491723405e15cc099ade1280133ccfbf6322d2ef568494fb7d07d280e7eee"}, + {file = "xxhash-3.4.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:431625fad7ab5649368c4849d2b49a83dc711b1f20e1f7f04955aab86cd307bc"}, + {file = "xxhash-3.4.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc6dbd5fc3c9886a9e041848508b7fb65fd82f94cc793253990f81617b61fe49"}, + {file = "xxhash-3.4.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ff8dbd0ec97aec842476cb8ccc3e17dd288cd6ce3c8ef38bff83d6eb927817"}, + {file = "xxhash-3.4.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef73a53fe90558a4096e3256752268a8bdc0322f4692ed928b6cd7ce06ad4fe3"}, + {file = "xxhash-3.4.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:450401f42bbd274b519d3d8dcf3c57166913381a3d2664d6609004685039f9d3"}, + {file = "xxhash-3.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a162840cf4de8a7cd8720ff3b4417fbc10001eefdd2d21541a8226bb5556e3bb"}, + {file = "xxhash-3.4.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b736a2a2728ba45017cb67785e03125a79d246462dfa892d023b827007412c52"}, + {file = "xxhash-3.4.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d0ae4c2e7698adef58710d6e7a32ff518b66b98854b1c68e70eee504ad061d8"}, + {file = "xxhash-3.4.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6322c4291c3ff174dcd104fae41500e75dad12be6f3085d119c2c8a80956c51"}, + {file = "xxhash-3.4.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:dd59ed668801c3fae282f8f4edadf6dc7784db6d18139b584b6d9677ddde1b6b"}, + {file = "xxhash-3.4.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92693c487e39523a80474b0394645b393f0ae781d8db3474ccdcead0559ccf45"}, + {file = "xxhash-3.4.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4603a0f642a1e8d7f3ba5c4c25509aca6a9c1cc16f85091004a7028607ead663"}, + {file = "xxhash-3.4.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fa45e8cbfbadb40a920fe9ca40c34b393e0b067082d94006f7f64e70c7490a6"}, + {file = "xxhash-3.4.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:595b252943b3552de491ff51e5bb79660f84f033977f88f6ca1605846637b7c6"}, + {file = "xxhash-3.4.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:562d8b8f783c6af969806aaacf95b6c7b776929ae26c0cd941d54644ea7ef51e"}, + {file = "xxhash-3.4.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:41ddeae47cf2828335d8d991f2d2b03b0bdc89289dc64349d712ff8ce59d0647"}, + {file = "xxhash-3.4.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c44d584afdf3c4dbb3277e32321d1a7b01d6071c1992524b6543025fb8f4206f"}, + {file = "xxhash-3.4.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7bddb3a5b86213cc3f2c61500c16945a1b80ecd572f3078ddbbe68f9dabdfb"}, + {file = "xxhash-3.4.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ecb6c987b62437c2f99c01e97caf8d25660bf541fe79a481d05732e5236719c"}, + {file = "xxhash-3.4.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:696b4e18b7023527d5c50ed0626ac0520edac45a50ec7cf3fc265cd08b1f4c03"}, + {file = "xxhash-3.4.1.tar.gz", hash = "sha256:0379d6cf1ff987cd421609a264ce025e74f346e3e145dd106c0cc2e3ec3f99a9"}, +] + +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[[package]] +name = "zipp" +version = "3.18.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, + {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.10,<3.12" +content-hash = "16effe1ca63608828a41caab5434ecbcdcaa08947c0140bdd87f6e26b29cd6be" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..c15a56ba8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,95 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "intelligence-layer" +version = "0.11.0" +description = "" +authors = ["Aleph Alpha Engineering "] +readme = "README.md" +license = "Aleph Alpha Licensed" +packages = [{ include = "intelligence_layer", from = "src" }] + +[tool.poetry.dependencies] +python = ">=3.10,<3.12" +pydantic = "2.7.*" +fastapi = "*" +uvicorn = "*" +aleph-alpha-client = ">=7.1.0" +python-dotenv = "*" +semantic-text-splitter = "^0.12.0" +qdrant-client = "^1.9.0" +rich = "^13.7.1" +nbconvert = "^7.16.3" +datasets = "^2.19.0" +jupyter = "^1.0.0" +requests = "^2.31.0" +langdetect = "^1.0.9" +nltk = "^3.8.1" +pycountry = "23.12.11" +rouge = "^1.0.1" +opentelemetry-api = "^1.22.0" +opentelemetry-sdk = "^1.22.0" +huggingface-hub = "^0.22.2" +opentelemetry-exporter-otlp-proto-http = "1.23.0" + +[tool.poetry.group.dev.dependencies] +# lint & format +mypy = "^1.10.0" +nbqa = "^1.8.5" +ruff = "^0.4.2" +pre-commit = "*" +pylama = { extras = ["radon", "vulture", "toml"], version = "^8.4.1" } +faker = "^24.14.0" + +# tests +pytest = "*" +pytest-xdist = "^3.5.0" +hypercorn = "0.16.0" + +# typing +httpx = "*" +types-Pygments = "*" +types-colorama = "*" +types-docutils = "*" +types-setuptools = "*" +types-decorator = "*" +types-requests = "*" +types-tqdm = "*" +pandas-stubs = "^2.2.1.240316" +# docs +sphinx-rtd-theme = "^2.0.0" +sphinx = "^7.3.7" +matplotlib = "^3.8.4" + +[tool.mypy] +files = "src,tests" +strict = "True" + +[tool.pytest.ini_options] +markers = [ + "internal: marks tests as internal (deselect with '-k \"not internal\"')", + "docker: the test depends on having a docker container running." +] +addopts = "--capture=tee-sys" +filterwarnings = [ + 'ignore:.*\`general_plain_validator_function\` is deprecated.*', +] + +[tool.pylama] +skip = "*/__init__.py,.venv/*,*/node_modules/*" +ignore = "E501,E203" + +[tool.ruff] +extend-include = ["*.ipynb"] + +[tool.ruff.lint.mccabe] +max-complexity = 11 + +# we double check this, maybe pylama isn't necessary anymore +[tool.pylama.linter.mccabe] +max-complexity = "11" + +[tool.isort] +profile = "black" diff --git a/run.py b/run.py new file mode 100644 index 000000000..c85dfa0bf --- /dev/null +++ b/run.py @@ -0,0 +1,31 @@ +"""Fastapi server to run predictions.""" + +from dotenv import load_dotenv +from fastapi import Depends, FastAPI + +from intelligence_layer.core import ControlModel, LuminousControlModel, NoOpTracer +from intelligence_layer.examples.classify.classify import ( + ClassifyInput, + SingleLabelClassifyOutput, +) +from intelligence_layer.examples.classify.prompt_based_classify import ( + PromptBasedClassify, +) + +app = FastAPI() + +load_dotenv() + + +def model() -> ControlModel: + return LuminousControlModel("luminous-base-control") + + +@app.post("/classify") +async def classify( + classify_input: ClassifyInput, + luminous_control_model: LuminousControlModel = Depends(model), +) -> SingleLabelClassifyOutput: + classify = PromptBasedClassify(luminous_control_model) + classify_output = classify.run(classify_input, NoOpTracer()) + return classify_output diff --git a/scripts/all.sh b/scripts/all.sh new file mode 100644 index 000000000..50028c751 --- /dev/null +++ b/scripts/all.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env -S bash -eu -o pipefail + +ProjectRoot="$(cd $(dirname "$0")/.. && pwd -P)" + +cd "$ProjectRoot" + +# see https://stackoverflow.com/questions/43267413/how-to-set-environment-variables-from-env-file +set -a # automatically export all variables +source .env +set +a + +./scripts/lint.sh +./scripts/doctest.sh +./scripts/notebook_runner.sh +./scripts/test.sh diff --git a/scripts/doctest.sh b/scripts/doctest.sh new file mode 100644 index 000000000..288e013e8 --- /dev/null +++ b/scripts/doctest.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env -S bash -eu -o pipefail + +ProjectRoot="$(cd $(dirname "$0")/.. && pwd -P)" + +cd "$ProjectRoot" + +if [ -f .env ]; then + # Export environment variables from .env file + set -a # automatically export all variables + source .env + set +a +fi +(cd docs && poetry run make doctest) diff --git a/scripts/fastapi_example_test.sh b/scripts/fastapi_example_test.sh new file mode 100644 index 000000000..b554ef47c --- /dev/null +++ b/scripts/fastapi_example_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env -S bash -eu -o pipefail + +# start the server in the background +hypercorn src/documentation/fastapi_example:app --bind localhost:8000 & +server_pid=$! + +attempt_counter=0 +max_attempts=10 + +trap 'kill $server_pid' EXIT SIGINT +# waiting for server startup +until $(curl -X GET http://localhost:8000 --fail-with-body --output /dev/null --silent --head); do + if [ ${attempt_counter} -eq ${max_attempts} ];then + echo "Max attempts reached" + exit 1 + fi + + printf '.' + attempt_counter=$(($attempt_counter+1)) + sleep 1 +done + +curl -X GET http://localhost:8000 --fail-with-body +curl -X POST http://localhost:8000/summary --fail-with-body -H "Content-Type: application/json" -d '{"chunk": "", "language": {"iso_639_1": "en"}}' + +# kill happens at the end with the trap command +exit 0 diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100644 index 000000000..97899bbfd --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env -S bash -eu -o pipefail + +poetry run pre-commit run --all-files +poetry run mypy diff --git a/scripts/notebook_runner.sh b/scripts/notebook_runner.sh new file mode 100644 index 000000000..6bfd344db --- /dev/null +++ b/scripts/notebook_runner.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env -S bash -eu -o pipefail +# next line loads AA_TOKEN from .env file when running bash script locally. In CI this is not necessary since AA_TOKEN is environment variable. +[ -f .env ] && source .env +export AA_TOKEN +# Find all .ipynb files in the directory and pass them to xargs for parallel execution +rm -rf src/documentation/.ipynb_checkpoints +rm -rf src/documentation/how_tos/.ipynb_checkpoints + +find src/documentation -name "*.nbconvert.ipynb" -type f -delete +find src/documentation -name "*.ipynb" ! -name "performance_tips.ipynb" | xargs --max-args 1 --max-procs 6 poetry run jupyter nbconvert --to notebook --execute +find src/documentation -name "*.nbconvert.ipynb" -type f -delete + +poetry run ./scripts/fastapi_example_test.sh diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100644 index 000000000..82abaac1e --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env -S bash -eu -o pipefail + +poetry run python3 -c "import nltk; nltk.download('punkt')" +poetry run pytest -n 10 diff --git a/src/documentation/classification.ipynb b/src/documentation/classification.ipynb new file mode 100644 index 000000000..b64750213 --- /dev/null +++ b/src/documentation/classification.ipynb @@ -0,0 +1,395 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dotenv import load_dotenv\n", + "\n", + "from intelligence_layer.core import InMemoryTracer, LogEntry, TextChunk\n", + "from intelligence_layer.examples import (\n", + " ClassifyInput,\n", + " EmbeddingBasedClassify,\n", + " LabelWithExamples,\n", + " PromptBasedClassify,\n", + " TreeNode,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Classification\n", + "\n", + "Language models offer unprecedented capabilities in understanding and generating human-like text.\n", + "One of the pressing issues in their application is the classification of vast amounts of data.\n", + "Traditional methods often require manual labeling and can be time-consuming and prone to errors.\n", + "LLMs, on the other hand, can swiftly process and categorize enormous datasets with minimal human intervention.\n", + "By leveraging LLMs for classification tasks, organizations can unlock insights from their data more efficiently, streamline their workflows, and harness the full potential of their information assets.\n", + "\n", + "\n", + "
\n", + "\n", + "This notebook is designed to showcase two different approaches and ways of classifying text using Aleph Alpha's Luminous models.\n", + "To make proper use of the classification task, it is necessary to evaluate the results in an iterative way, to ensure it satisfies your requirements.\n", + "For an example of how such an evaluation can look like, refer to [evaluation.ipynb](./evaluation.ipynb).\n", + "
\n", + "\n", + "First, let's have a look at single-label classification using prompting.\n", + "\n", + "### Prompt-based single-label classification\n", + "\n", + "Single-label classification refers to the task of categorizing data points into one of n distinct categories or classes.\n", + "In this type of classification, each input is assigned to only one class, ensuring that no overlap exists between categories.\n", + "Common applications of single-label classification include email spam detection, where emails are classified as either \"spam\" or \"not spam\", or sentiment classification, where a text can be \"positive\", \"negative\" or \"neutral\".\n", + "When trying to solve this issue in a prompt-based manner, our primary goal is to construct a prompt that instructs the model to accurately predict the correct class for any given input.\n", + "\n", + "### When should you use prompt-based classification?\n", + "\n", + "We recommend using this type of classification when...\n", + "- ...the labels are easily understood (they don't require explanation or examples).\n", + "- ...the labels cannot be recognized purely by their semantic meaning.\n", + "- ...many examples for each label aren't readily available.\n", + "\n", + "### Example snippet\n", + "\n", + "Running the following code will instantiate a `PromptBasedClassify`-task that leverages a prompt for classification.\n", + "We can pass any `ClassifyInput` to the task and it returns each label along with its probability.\n", + "In addition, note the `tracer`, which will give a comprehensive overview of the result." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text_to_classify = TextChunk(\n", + " \"In the distant future, a space exploration party embarked on a thrilling journey to the uncharted regions of the galaxy. \\n\\\n", + "With excitement in their hearts and the cosmos as their canvas, they ventured into the unknown, discovering breathtaking celestial wonders. \\n\\\n", + "As they gazed upon distant stars and nebulas, they forged unforgettable memories that would forever bind them as pioneers of the cosmos.\"\n", + ")\n", + "labels = [\"happy\", \"angry\", \"sad\"]\n", + "input = ClassifyInput(chunk=text_to_classify, labels=labels)\n", + "\n", + "task = PromptBasedClassify()\n", + "tracer = InMemoryTracer()\n", + "output = task.run(input, tracer)\n", + "\n", + "for label, score in output.scores.items():\n", + " print(f\"{label}: {round(score, 4)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### How does this implementation work?\n", + "\n", + "We prompt the model multiple times, each time supplying the text, or chunk, and one label at a time.\n", + "Note that we also supply each label, rather than letting the model generate it.\n", + "\n", + "To further explain this, let's start with a more familiar case.\n", + "Intuitively, one would probably prompt a model like so:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "prompt = PromptBasedClassify.INSTRUCTION\n", + "print(prompt)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The model would then complete our instruction, thus generating a matching label.\n", + "\n", + "In case of single-label classification, however, we already know all possible classes beforehand.\n", + "Thus, all we are interested in is the probability that the model would have generated our specific class for the given input.\n", + "To get this probability, we modify the model such that it does not generate any token but returns the logarithmic probabilities (logprops) of the completion instead. From this we then extract the probability with which our class would have been selected. This process is called an `EchoTask`.\n", + "\n", + "Let's have a look at just one of these tasks triggered by our classification run.\n", + "\n", + "Feel free to ignore the big `Complete` task dump in the middle.\n", + "Instead, focus on the `expected_completion` in the `Input` and the `prob` for the token \" angry\" in the `Output`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In particular, note the `expected_completion` in the `Input` and the `prob` for the token \" angry\" in the `Output`.\n", + "Feel free to ignore the big `Complete` task dump in the middle." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tracer.entries[-1].entries[0].entries[0]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we have the logprobs, we just need to do some calculations to turn them into a final score.\n", + "\n", + "To turn the logprobs into our end scores, we first normalize our probabilities.\n", + "For this, we utilize a probability tree." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "task_log = tracer.entries[-1]\n", + "normalized_probs_logs = [\n", + " log_entry.value\n", + " for log_entry in task_log.entries\n", + " if isinstance(log_entry, LogEntry) and log_entry.message == \"Normalized Probs\"\n", + "]\n", + "log = normalized_probs_logs[-1]\n", + "\n", + "root = TreeNode()\n", + "for probs in log.values():\n", + " root.insert_without_calculation(probs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, we take the product of all the paths to get the following results:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for label, score in output.scores.items():\n", + " print(f\"{label}: {round(score, 5)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Embedding-based multi-label classification\n", + "\n", + "Large language model embeddings offer a powerful approach to text classification.\n", + "In particular, such embeddings can be seen as a numerical representation of the meaning of a text.\n", + "Utilizing this, we can provide textual examples for each label and embed them to create a representations for each label in vector space.\n", + "\n", + "**Or, in more detail**:\n", + "In this method, each example from various classes is transformed into a vector representation using the embeddings from the language model.\n", + "These embedded vectors capture the semantic essence of the text.\n", + "Once this is done, clusters of embeddings are formed for each class, representing the centroid or the average meaning of the examples within that class.\n", + "When a new piece of text needs to be classified, it is first embedded using the same language model.\n", + "This new embedded vector is then compared to the pre-defined clusters for each class using a cosine similarity.\n", + "The class whose cluster is closest to the new text's embedding is then assigned to the text, thereby achieving classification.\n", + "This method leverages the deep semantic understanding of large language models to classify texts with high accuracy and nuance.\n", + "\n", + "### When should you use embedding-based classification?\n", + "\n", + "We recommend using this type of classification when...\n", + "- ...proper classification requires fine-grained control over the classes' definitions.\n", + "- ...the labels can be defined mostly or purely by the semantic meaning of the examples.\n", + "- ...examples for each label are readily available.\n", + "\n", + "### Example snippet\n", + "\n", + "Let's start by instantiating a classifier for sentiment classification." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "labels_with_examples = [\n", + " LabelWithExamples(\n", + " name=\"positive\",\n", + " examples=[\n", + " \"I really like this.\",\n", + " \"Wow, your hair looks great!\",\n", + " \"We're so in love.\",\n", + " \"That truly was the best day of my life!\",\n", + " \"What a great movie.\",\n", + " ],\n", + " ),\n", + " LabelWithExamples(\n", + " name=\"negative\",\n", + " examples=[\n", + " \"I really dislike this.\",\n", + " \"Ugh, Your hair looks horrible!\",\n", + " \"We're not in love anymore.\",\n", + " \"My day was very bad, I did not have a good time.\",\n", + " \"They make terrible food.\",\n", + " ],\n", + " ),\n", + "]\n", + "\n", + "classify = EmbeddingBasedClassify(labels_with_examples)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are several things to note here, in particular:\n", + "- This time, we instantiated our classification task with a number of `LabelWithExamples`.\n", + "- The examples provided should reflect the spectrum of texts expected in the intended usage domain of this classifier.\n", + "- This cell took some time to run.\n", + "This is because we instantiate a retriever in the background, which also requires us to embed the provided examples.\n", + "\n", + "With that being said, let's run an unknown example!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "classify_input = ClassifyInput(\n", + " chunk=\"It was very awkward with him, I did not enjoy it.\",\n", + " labels=frozenset(label.name for label in labels_with_examples),\n", + ")\n", + "tracer = InMemoryTracer()\n", + "result = classify.run(classify_input, tracer)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Nice, we correctly identified the new example.\n", + "\n", + "Again, let's appreciate the difference of this result compared to `PromptBasedClassify`'s result.\n", + "- The probabilities do not add up to 1.\n", + "In fact, we have no way of predicting what the sum of all scores will be.\n", + "We only know they will be in the range from 0 to 1.\n", + "All we know is that the highest score is likely to correspond to the best fitting label, provided we delivered good examples.\n", + "- We were much quicker to obtain a result.\n", + "\n", + "Because all examples are pre-embedded, this classifier is much cheaper to operate as it only requires a single embedding-task to be sent to the Aleph Alpha API.\n", + "\n", + "Let's try another example. This time, we expect the outcome to be positive.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "classify_input = ClassifyInput(\n", + " chunk=\"We used to be not like each other, but this changed a lot.\",\n", + " labels=frozenset(label.name for label in labels_with_examples),\n", + ")\n", + "tracer = InMemoryTracer()\n", + "result = classify.run(classify_input, tracer)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Unfortunately, we wrongly classify this text as negative.\n", + "To be fair, it is a difficult example.\n", + "But no worries, let's simply include this failing example in our list of label examples and try again!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "labels_with_examples = [\n", + " LabelWithExamples(\n", + " name=\"positive\",\n", + " examples=[\n", + " \"I really like this.\",\n", + " \"Wow, your hair looks great!\",\n", + " \"We're so in love.\",\n", + " \"That truly was the best day of my life!\",\n", + " \"What a great movie.\",\n", + " \"We used to be not like each other, but this changed a lot.\", # failing example\n", + " ],\n", + " ),\n", + " LabelWithExamples(\n", + " name=\"negative\",\n", + " examples=[\n", + " \"I really dislike this.\",\n", + " \"Ugh, Your hair looks horrible!\",\n", + " \"We're not in love anymore.\",\n", + " \"My day was very bad, I did not have a good time.\",\n", + " \"They make terrible food.\",\n", + " ],\n", + " ),\n", + "]\n", + "classify = EmbeddingBasedClassify(labels_with_examples)\n", + "\n", + "tracer = InMemoryTracer()\n", + "result = classify.run(classify_input, tracer)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Nice, we now correctly classify this example!\n", + "\n", + "One advantage of using the `EmbeddingBasedClassify`-approach is that we can easily tweak our labels by adding new examples.\n", + "In essence, this guarantees that we never make the same mistake twice.\n", + "As we increase the number of examples, this makes the method evermore precise.\n", + "\n", + "You now have an overview of these two main methods of classification!\n", + "Feel free to tweak these method and play around with their parameters to finetune them to our specific use-case." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/data/classify_examples.json b/src/documentation/data/classify_examples.json new file mode 100644 index 000000000..cd2be7297 --- /dev/null +++ b/src/documentation/data/classify_examples.json @@ -0,0 +1,98 @@ +[ + { + "label": "Finance", + "message": "I just traveled to Paris for a conference, where can I get the train ride refunded?" + }, + { + "label": "Sales", + "message": "Hello, we would like to get in contact with your sales team, because we are interested in your solution." + }, + { + "label": "Communications", + "message": "We are working on a documentation on AI and would like to film a piece about you. Would you be interested?" + }, + { + "label": "Research", + "message": "I am working with Stanford and was hoping to win you over for a research collaboration." + }, + { + "label": "IT Support", + "message": "My laptop is broken" + }, + { + "label": "Communications", + "message": "Can you send your models via email?" + }, + { + "label": "Research", + "message": "We should do a research collaboration." + }, + { + "label": "Research", + "message": "My company has been working on time series and signal processing for a long time. It would make sense to define a joint go to market and research strategy." + }, + { + "label": "Human Resources", + "message": "Full stack developer in your area available now." + }, + { + "label": "Product", + "message": "Hi,\n\nI recently bought your offering. I am having trouble running your docker container in my environment. It fails to start. Can you help?" + }, + { + "label": "Product", + "message": "Hello,\n\nI am getting strange errors from your API. It is saying the queue is full, but I am only sending one task at a time. Why is this happening?" + }, + { + "label": "Product", + "message": "Can you show me a demo of different use cases your offering can solve?" + }, + { + "label": "Human Resources", + "message": "Hey, I did not get a t-shirt in the onboarding. Could I still get one?" + }, + { + "label": "Customer", + "message": "Hi, can you name me a couple of timeslots for a first call? Would be really interested in learning more about the product?" + }, + { + "label": "Product", + "message": "Hi Jan, is your product ISO 37301 compliant?" + }, + { + "label": "IT Support", + "message": "I can\u2019t login to Mattermost or Sharepoint, how can I gain access?" + }, + { + "label": "Finance", + "message": "I did not get paid last month, when do I get paid? What is going on?" + }, + { + "label": "Security", + "message": "Hi, I want to get a new badge, the photo of me looks ugly and I just got new glasses so it does not look like me. " + }, + { + "label": "Marketing", + "message": "I have a question concerning your marketing strategy, would you have time to hop on a call?" + }, + { + "label": "CEO Office", + "message": "Dear Jonas Andrulis,\n\nWe have met each other at the event in N\u00fcrnberg, can we meet for a follow up in your Office in Heidelberg?" + }, + { + "label": "Security", + "message": "Your hTTPs Certificate is not valid on your www.aleph-alpha.de" + }, + { + "label": "Human Resources", + "message": "I want to take a week off immediately" + }, + { + "label": "Human Resources", + "message": "I want to take a sabbatical" + }, + { + "label": "Human Resources", + "message": "How can I work more, I want to work weekends, can I get paid overtime?" + } +] diff --git a/src/documentation/document_index.ipynb b/src/documentation/document_index.ipynb new file mode 100644 index 000000000..e3ea71c9b --- /dev/null +++ b/src/documentation/document_index.ipynb @@ -0,0 +1,331 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "from os import getenv\n", + "\n", + "from dotenv import load_dotenv\n", + "\n", + "from intelligence_layer.connectors import (\n", + " CollectionPath,\n", + " DocumentContents,\n", + " DocumentIndexClient,\n", + " DocumentIndexRetriever,\n", + " DocumentPath,\n", + " LimitedConcurrencyClient,\n", + ")\n", + "from intelligence_layer.core import InMemoryTracer\n", + "from intelligence_layer.examples import RetrieverBasedQa, RetrieverBasedQaInput\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Document Index\n", + "\n", + "Answering questions given a known text may not be sufficient for your use case.\n", + "At some point, you will probably want to search through, or answer questions about, your own knowledge base.\n", + "You can leverage Aleph Alpha's DocumentIndex (DI) – a robust semantic search tool – to pinpoint sections in documents that align closely with your query.\n", + "Simply upload your knowledge base, internal documents, manuals, SharePoint, and more to the DI for efficient searching.\n", + "\n", + "The DI splits documents into smaller parts called 'chunks', generates semantic embedding vectors for each, and stores them alongside each other.\n", + "To find segments that closely match your query, the system identifies chunks with embedding vectors that best align semantically with your question.\n", + "The DI seamlessly manages document updates (using document names), determines the ideal chunk size, and optimizes the vector space search process.\n", + "\n", + "\n", + "
\n", + "\n", + "In this notebook, we will show you how to upload your own documents to the DI, how to search through your documents, and how to build a question-answering system based on your DI-knowledge base.\n", + "To make proper use of the search and question-answering task, it is necessary to evaluate the results in an iterative way, to ensure it satisfies your requirements.\n", + "For an example of how such an evaluation can look like, refer to [evaluation.ipynb](./evaluation.ipynb).\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Upload documents to the Document Index\n", + "\n", + "To search through the DI, you'll first need to upload the documents to it.\n", + "For now, we'll use the DI instance stored in the Aleph Alpha cloud.\n", + "We assume you have an assigned namespace and possess a token to access it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# specify this for your own namespace\n", + "NAMESPACE = \"aleph-alpha\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "document_index = DocumentIndexClient(\n", + " token=getenv(\"AA_TOKEN\"),\n", + " base_document_index_url=\"https://document-index.aleph-alpha.com\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, to start working with the DI, you need to execute four simple steps:\n", + "\n", + "1. Create a collection.\n", + "2. Define a few documents we will put in our collection.\n", + "3. Upload the documents to the collection.\n", + "4. Verify whether the documents are successfully uploaded." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# change this value if you want to use a collection of a different name\n", + "COLLECTION = \"demo\"\n", + "\n", + "collection_path = CollectionPath(namespace=NAMESPACE, collection=COLLECTION)\n", + "\n", + "document_index.create_collection(collection_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's define three documents based on Wikipedia articles:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# \"https://en.wikipedia.org/wiki/Robert_Moses\"\n", + "document_1 = {\n", + " \"name\": \"robert_moses\",\n", + " \"content\": \"\"\"Robert Moses''' (December 18, 1888 – July 29, 1981) was an American [[urban planner]] and public official who worked in the [[New York metropolitan area]] during the early to mid 20th century. Despite never being elected to any office, Moses is regarded as one of the most powerful and influential individuals in the history of New York City and New York State. The grand scale of his infrastructural projects and his philosophy of urban development influenced a generation of engineers, architects, and urban planners across the United States.\n", + "\n", + "Moses held various positions throughout his more than forty-year long career. He at times held up to 12 titles simultaneously, including [[New York City Parks Commissioner]] and chairman of the [[Long Island State Park Commission]].{{Cite web|url=https://www.pbs.org/wnet/need-to-know/environment/the-legacy-of-robert-moses/16018/|title=The legacy of Robert Moses|last=Sarachan|first=Sydney|date=January 17, 2013|website=Need to Know {{!}} PBS|language=en-US|access-date=December 3, 2019}} Having worked closely with New York governor [[Al Smith]] early in his career, Moses became expert in writing laws and navigating and manipulating the inner workings of state government. He created and led numerous semi-autonomous [[Public authority|public authorities]], through which he controlled millions of dollars in revenue and directly issued [[Bond (finance)|bonds]] to fund new ventures with little outside input or oversight.\n", + "\n", + "Moses's projects transformed the New York area and revolutionized the way cities in the U.S. were designed and built. As Long Island State Park Commissioner, Moses oversaw the construction of [[Jones Beach State Park]], the most visited public beach in the United States,{{cite news |url=http://www.longislandexchange.com/jones-beach.html |website=Long Island Exchange |title=Jones Beach |access-date=November 21, 2012 |archive-url=https://web.archive.org/web/20130121130008/http://www.longislandexchange.com/jones-beach.html |archive-date=January 21, 2013 |url-status=dead }} and was the primary architect of the [[Parkways in New York|New York State Parkway System]]. As head of the [[MTA Bridges and Tunnels|Triborough Bridge Authority]], Moses had near-complete control over bridges and tunnels in New York City as well as the tolls collected from them, and built, among others, the [[Robert F. Kennedy Bridge|Triborough Bridge]], the [[Brooklyn–Battery Tunnel]], and the [[Throgs Neck Bridge]], as well as several major highways. These roadways and bridges, alongside [[urban renewal]] efforts that saw the destruction of huge swaths of tenement housing and their replacement with large [[New York City Housing Authority|public housing projects]], transformed the physical fabric of New York and inspired other cities to undertake similar development endeavors.\n", + "\n", + "Moses's reputation declined following the publication of [[Robert Caro]]'s [[Pulitzer Prize]]-winning biography ''[[The Power Broker]]'' (1974), which cast doubt on the purported benefits of many of Moses's projects and further cast Moses as racist. In large part because of ''The Power Broker'', Moses is today considered a controversial figure in the history of New York City.\n", + "\n", + "==Early life and career==\n", + "Moses was born in [[New Haven, Connecticut]], on December 18, 1888, to [[German Jewish]] parents, Bella (Silverman) and Emanuel Moses.{{cite news | url=https://www.nytimes.com/learning/general/onthisday/bday/1218.html | title=Robert Moses, Master Builder, is Dead at 92| newspaper=The New York Times |archive-url=https://web.archive.org/web/20160305003155/https://www.nytimes.com/learning/general/onthisday/bday/1218.html |archive-date=March 5, 2016 |url-status=dead}}{{sfn|Caro|1974|p=25}} He spent the first nine years of his life living at 83 Dwight Street in New Haven, two blocks from [[Yale University]]. In 1897, the Moses family moved to New York City,{{sfn|Caro|1974|pp=29}} where they lived on East 46th Street off Fifth Avenue.{{cite web |url=http://www.newsday.com/community/guide/lihistory/ny-history-hs722a,0,7092161.story |title=The Master Builder |access-date=April 4, 2007 |last=DeWan |first=George |year=2007 |website=Long Island History |publisher=Newsday |archive-url=https://web.archive.org/web/20061211045554/http://www.newsday.com/community/guide/lihistory/ny-history-hs722a%2C0%2C7092161.story |archive-date=December 11, 2006 |url-status=dead }} Moses's father was a successful department store owner and [[real estate]] speculator in New Haven. In order for the family to move to New York City, he sold his real estate holdings and store, then retired.{{sfn|Caro|1974|pp=29}} Moses's mother was active in the [[settlement movement]], with her own love of building. Robert Moses and his brother Paul attended several schools for their elementary and [[secondary education]], including the [[Ethical Culture Fieldston School|Ethical Culture School]], the [[Dwight School]] and the [[Mohegan Lake, New York#Historic places|Mohegan Lake School]], a military academy near [[Peekskill, New York|Peekskill]].{{sfn|Caro|1974|pp=35}}\n", + "\n", + "After graduating from [[Yale College]] (B.A., 1909) and [[Wadham College]], [[Oxford University|Oxford]] (B.A., Jurisprudence, 1911; M.A., 1913), and earning a Ph.D. in [[political science]] from [[Columbia University]] in 1914, Moses became attracted to New York City reform politics.{{Cite web|url=http://c250.columbia.edu/c250_celebrates/remarkable_columbians/robert_moses.html|title = Robert Moses}} A committed [[idealism|idealist]], he developed several plans to rid New York of [[Patronage#Politics|patronage hiring]] practices, including being the lead author of a 1919 proposal to reorganize the New York state government. None went very far, but Moses, due to his intelligence, caught the notice of [[Belle Moskowitz]], a friend and trusted advisor to Governor [[Al Smith]].{{sfn|Caro|1974}} When the state [[Secretary of State of New York|Secretary of State's]] position became appointive rather than elective, Smith named Moses. He served from 1927 to 1929.{{cite news |date=December 19, 1928 |title=Moses Resigns State Position |url=http://cdsun.library.cornell.edu/cgi-bin/cornell?a=d&d=CDS19281219.2.63.7# |newspaper=Cornell Daily Sun |location=Ithaca, NY |page=8}}\n", + "\n", + "Moses rose to power with Smith, who was elected as governor in 1918, and then again in 1922. With Smith's support, Moses set in motion a sweeping consolidation of the New York State government. During that period Moses began his first foray into large-scale public work initiatives, while drawing on Smith's political power to enact legislation. This helped create the new [[Long Island State Park Commission]] and the State Council of Parks.{{cite web|last=Gutfreund|first=Owen|title=Moses, Robert|url=http://www.anb.org/articles/07/07-00375.html|publisher=Anb.org|access-date=December 24, 2014}} In 1924, Governor Smith appointed Moses chairman of the State Council of Parks and president of the Long Island State Park Commission.{{Cite book|title=Encyclopedia of the City|url=https://archive.org/details/encyclopediacity00cave|url-access=limited|last=Caves|first=R. W.|publisher=Routledge|year=2004|isbn=978-0-415-25225-6|pages=[https://archive.org/details/encyclopediacity00cave/page/n512 472]}} This centralization allowed Smith to run a government later used as a model for Franklin D. Roosevelt's [[New Deal]] federal government.{{or|date=October 2022}} Moses also received numerous commissions that he carried out efficiently, such as the development of [[Jones Beach State Park]].{{cn|date=October 2022}} Displaying a strong command of [[law]] as well as matters of [[engineering]], Moses became known for his skill in drafting legislation, and was called \"the best bill drafter in [[Albany, New York|Albany]]\".{{cite news |title=Annals of Power |first=Robert A. |last=Caro |author-link=Robert Caro |url=http://archives.newyorker.com/?i=1974-07-22#folio=032 |magazine=[[The New Yorker]] |date=July 22, 1974 |access-date=September 1, 2011}} At a time when the public was accustomed to [[Tammany Hall]] corruption and incompetence, Moses was seen as a savior of government.{{sfn|Caro|1974}}\n", + "\n", + "Shortly after [[President of the United States|President]] [[Franklin Delano Roosevelt|Franklin D. Roosevelt's]] [[First inauguration of Franklin D. Roosevelt|inauguration]] in 1933, the [[United States federal government|federal government]] found itself with millions of [[New Deal]] dollars to spend, yet states and cities had few projects ready. Moses was one of the few local officials who had projects [[shovel ready]]. For that reason, New York City was able to obtain significant [[Works Progress Administration]] (WPA), [[Civilian Conservation Corps]] (CCC), and other Depression-era funding. One of his most influential and longest-lasting positions was that of Parks Commissioner of New York City, a role he served from January 18, 1934, to May 23, 1960.{{Cite web|url=https://www.nycgovparks.org/about/history/commissioners|title=New York City Parks Commissioners : NYC Parks|website=www.nycgovparks.org|language=en|access-date=March 29, 2018}}\n", + "\n", + "==Offices held==\n", + "The many offices and professional titles that Moses held gave him unusually broad power to shape urban development in the New York metropolitan region. These include, according to the New York Preservation Archive Project:{{Cite web|url=http://www.nypap.org/preservation-history/robert-moses/|title=Robert Moses {{!}}|website=www.nypap.org|language=en-US|access-date=March 29, 2018}}\n", + "*[[Long Island State Park Commission]] (President, 1924–1963)\n", + "* New York State Council of Parks (Chairman, 1924–1963)\n", + "*[[Secretary of State of New York|New York Secretary of State]] (1927–1928)\n", + "* Bethpage State Park Authority (President, 1933–1963)\n", + "* Emergency Public Works Commission (Chairman, 1933–1934)\n", + "* Jones Beach Parkway Authority (President, 1933–1963)\n", + "*[[New York City Department of Parks and Recreation|New York City Department of Parks]] (Commissioner, 1934–1960)\n", + "* [[Triborough Bridge]] and Tunnel Authority (Chairman, 1934–1968)\n", + "* New York City Planning Commission (Commissioner, 1942–1960)\n", + "* New York State Power Authority (Chairman, 1954–1962)\n", + "* [[1964 New York World's Fair|New York's World Fair]] (President, 1960–1966)\n", + "* Office of the Governor of New York (Special Advisor on Housing, 1974–1975)\n", + "\n", + "==Influence==\n", + "During the 1920s, Moses sparred with [[Franklin D. Roosevelt]], then head of the Taconic State Park Commission, who favored the prompt construction of a [[parkway]] through the [[Hudson Valley]]. Moses succeeded in diverting funds to his Long Island parkway projects (the [[Northern State Parkway]], the [[Southern State Parkway]] and the [[Wantagh State Parkway]]), although the [[Taconic State Parkway]] was later completed as well.{{cite web|url=http://www.nycroads.com/roads/taconic/ |title=Taconic State Parkway |website=NYCRoads.com |access-date=May 25, 2006}} Moses helped build Long Island's [[Meadowbrook State Parkway]]. It was the first fully divided limited access highway in the world.{{cite book|last=Leonard|first=Wallock|title=The Myth of The Master Builder|year=1991|publisher=Journal of Urban History|page=339}}\n", + "\n", + "Moses was a highly influential figure in the initiation of many of the reforms that restructured New York state's government during the 1920s. A 'Reconstruction Commission' headed by Moses produced a highly influential report that provided recommendations that would largely be adopted, including the consolidation of 187 existing agencies under 18 departments, a new executive budget system, and the four-year term limit for the governorship.{{sfn|Caro|1974|pp=106, 260}}\"\"\",\n", + "}\n", + "\n", + "# \"https://en.wikipedia.org/wiki/Jane_Jacobs\"\n", + "document_2 = {\n", + " \"name\": \"jane_jacobs\",\n", + " \"content\": \"\"\"Jane Jacobs OC OOnt (née Butzner; 4 May 1916 – 25 April 2006) was an American-Canadian journalist, author, theorist, and activist who influenced urban studies, sociology, and economics. Her book The Death and Life of Great American Cities (1961) argued that \"urban renewal\" and \"slum clearance\" did not respect the needs of city-dwellers.[1][2]\n", + "\n", + "Jacobs organized grassroots efforts to protect neighborhoods from urban renewal and slum clearance – in particular plans by Robert Moses to overhaul her own Greenwich Village neighborhood. She was instrumental in the eventual cancellation of the Lower Manhattan Expressway,[3] which would have passed directly through the area of Manhattan that would later become known as SoHo, as well as part of Little Italy and Chinatown.[4] She was arrested in 1968 for inciting a crowd at a public hearing on that project.[5] After moving to Toronto in 1968, she joined the opposition to the Spadina Expressway and the associated network of expressways in Toronto that were planned and under construction.[6][7]\n", + "\n", + "As a woman and a writer who criticized experts in the male-dominated field of urban planning,[8][9] Jacobs endured scorn from established figures.[who?] Routinely, she was described first as a housewife,[10] as she did not have a college degree or any formal training in urban planning; as a result, her lack of credentials was seized upon as grounds for criticism.[11][12] However, the influence of her concepts eventually was acknowledged by highly respected professionals such as Richard Florida and Robert Lucas.[13] \"\"\",\n", + "}\n", + "\n", + "# \"https://en.wikipedia.org/wiki/Nelson_Rockefeller\"\n", + "document_3 = {\n", + " \"name\": \"nelson_rockefeller\",\n", + " \"content\": \"\"\"Nelson Aldrich Rockefeller (July 8, 1908 – January 26, 1979), sometimes referred to by his nickname Rocky,[1] was an American businessman and politician who served as the 41st vice president of the United States from 1974 to 1977 under President Gerald Ford. A member of the Republican Party and the wealthy Rockefeller family, he previously served as the 49th governor of New York from 1959 to 1973. Rockefeller also served as assistant secretary of State for American Republic Affairs for Presidents Franklin D. Roosevelt and Harry S. Truman (1944–1945) as well as under secretary of Health, Education and Welfare (HEW) under Dwight D. Eisenhower from 1953 to 1954. A son of John D. Rockefeller Jr. and Abby Aldrich Rockefeller as well as a grandson of Standard Oil co-founder John D. Rockefeller, he was a noted art collector and served as administrator of Rockefeller Center in Manhattan, New York City.\n", + "\n", + "Rockefeller was often considered to be liberal, progressive,[2] or moderate. In an agreement that was termed the Treaty of Fifth Avenue, he persuaded Richard Nixon to alter the Republican Party platform just before the 1960 Republican Convention. In his time, liberals in the Republican Party were called \"Rockefeller Republicans\". As Governor of New York from 1959 to 1973, Rockefeller's achievements included the expansion of the State University of New York (SUNY), efforts to protect the environment, the construction of the Empire State Plaza in Albany, increased facilities and personnel for medical care, and the creation of the New York State Council on the Arts.\n", + "\n", + "After unsuccessfully seeking the Republican presidential nomination in 1960, 1964, and 1968, he was appointed vice president of the United States under President Gerald Ford, who was appointed Vice President by President Richard Nixon after the resignation of Spiro Agnew, and who ascended to the presidency following Nixon's August 1974 resignation. Rockefeller was the second vice president appointed to the position under the 25th Amendment, following Ford himself. Rockefeller did not seek a full term on the 1976 Republican ticket with Ford. He retired from politics in 1977 and died two years later.\n", + "\n", + "As a businessman, Rockefeller was president and later chair of Rockefeller Center, Inc., and he formed the International Basic Economy Corporation in 1947. Rockefeller assembled a significant art collection and promoted public access to the arts. He served as trustee, treasurer, and president of the Museum of Modern Art and founded the Museum of Primitive Art in 1954. In the area of philanthropy, he founded the Rockefeller Brothers Fund in 1940 with his four brothers and established the American International Association for Economic and Social Development in 1946. \"\"\",\n", + "}\n", + "\n", + "documents = [document_1, document_2, document_3]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's upload the documents:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for doc in documents:\n", + " document_path = DocumentPath(\n", + " collection_path=collection_path, document_name=doc[\"name\"]\n", + " )\n", + " document_index.add_document(\n", + " document_path, contents=DocumentContents.from_text(doc[\"content\"])\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And let's check if the documents are in the collection:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "document_index.documents(collection_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Search\n", + "\n", + "Now that we have uploaded our documents, we can search through them using the semantic similarities between a given query and each chunk.\n", + "\n", + "To do so, let's use the `DocumentIndexRetriever`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "document_index_retriever = DocumentIndexRetriever(\n", + " document_index=document_index,\n", + " index_name=\"asymmetric\",\n", + " namespace=NAMESPACE,\n", + " collection=COLLECTION,\n", + " k=5,\n", + " threshold=0.5,\n", + ")\n", + "\n", + "document_index_retriever.get_relevant_documents_with_scores(\n", + " query=\"The influence of Robert Moses\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Question Answering\n", + "\n", + "Finally, since we have a ready-to use 'Retriever', we can employ it to do something more complicated than just search; it can serve as the basis for a question-answering system.\n", + "\n", + "To do so, let's run a `RetrieverBasedQa` task:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client = LimitedConcurrencyClient.from_env()\n", + "retriever_qa = RetrieverBasedQa(document_index_retriever)\n", + "\n", + "\n", + "input = RetrieverBasedQaInput(\n", + " question=\"What is the name of the book about Robert Moses?\"\n", + ")\n", + "tracer = InMemoryTracer()\n", + "\n", + "output = retriever_qa.run(input, tracer)\n", + "\n", + "output.answer" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you want to learn how the answer was produced, you can see it step-by-step in the `tracer`.\n", + "Here, we record the input and output of every individual step up to the final answer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tracer" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/evaluation.ipynb b/src/documentation/evaluation.ipynb new file mode 100644 index 000000000..75eda7620 --- /dev/null +++ b/src/documentation/evaluation.ipynb @@ -0,0 +1,410 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from collections import defaultdict\n", + "from typing import Any, Mapping, Sequence\n", + "\n", + "from datasets import load_dataset\n", + "from dotenv import load_dotenv\n", + "\n", + "from intelligence_layer.connectors import LimitedConcurrencyClient\n", + "from intelligence_layer.core import NoOpTracer, TextChunk\n", + "from intelligence_layer.evaluation import (\n", + " Aggregator,\n", + " Evaluator,\n", + " Example,\n", + " InMemoryAggregationRepository,\n", + " InMemoryDatasetRepository,\n", + " InMemoryEvaluationRepository,\n", + " InMemoryRunRepository,\n", + " RepositoryNavigator,\n", + " Runner,\n", + " evaluation_lineages_to_pandas,\n", + ")\n", + "from intelligence_layer.examples import (\n", + " ClassifyInput,\n", + " EmbeddingBasedClassify,\n", + " LabelWithExamples,\n", + " MultiLabelClassifyAggregationLogic,\n", + " MultiLabelClassifyEvaluationLogic,\n", + " PromptBasedClassify,\n", + " SingleLabelClassifyAggregationLogic,\n", + " SingleLabelClassifyEvaluation,\n", + " SingleLabelClassifyEvaluationLogic,\n", + " SingleLabelClassifyOutput,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Evaluating LLM-based tasks\n", + "\n", + "Evaluating LLM-based use cases is pivotal for several reasons.\n", + "First, with the myriad of methods available, comparability becomes essential.\n", + "By systematically evaluating different approaches, we can discern which techniques are more effective or suited for specific tasks, fostering a deeper understanding of their strengths and weaknesses.\n", + "Secondly, optimization plays a significant role. Without proper evaluation metrics and rigorous testing, it becomes challenging to fine-tune methods and/or models to achieve their maximum potential.\n", + "Moreover, drawing comparisons with state-of-the-art (SOTA) and open-source methods is crucial.\n", + "Such comparisons not only provide benchmarks but also enable users to determine the value-added by proprietary or newer models over freely available counterparts.\n", + "\n", + "However, evaluating LLMs, especially in the domain of text generation, presents unique challenges.\n", + "Text generation is inherently subjective, and what one evaluator deems coherent and relevant, another might find disjointed or off-topic. This subjectivity complicates the establishment of universal evaluation standards, making it imperative to approach LLM evaluation with a multifaceted and comprehensive strategy.\n", + "\n", + "### Evaluating classification use-cases\n", + "\n", + "To (at least for now) evade the elusive issue described in the last paragraph, let's have a look at an easier to evaluate methodology: classification.\n", + "Why is this easier?\n", + "Well, unlike other tasks such as QA, the result of a classification task is more or less binary (true/false).\n", + "There are very few grey areas, as it is unlikely that a classification result is somewhat or \"half\" correct.\n", + "\n", + "Make sure that you have familiarized yourself with the [PromptBasedClassify](classification.ipynb#prompt-based-single-label-classification) prior to starting this notebook.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, we need to instantiate our task, as well as, a runner, an evaluator and an aggregator for it. Furthermore, we need the corresponding repositories that store the results of each step along with tracing information.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "task = PromptBasedClassify()\n", + "dataset_repository = InMemoryDatasetRepository()\n", + "run_repository = InMemoryRunRepository()\n", + "evaluation_repository = InMemoryEvaluationRepository()\n", + "evaluation_logic = SingleLabelClassifyEvaluationLogic()\n", + "aggregation_repository = InMemoryAggregationRepository()\n", + "aggregation_logic = SingleLabelClassifyAggregationLogic()\n", + "\n", + "\n", + "runner = Runner(task, dataset_repository, run_repository, \"prompt-based-classify\")\n", + "evaluator = Evaluator(\n", + " dataset_repository,\n", + " run_repository,\n", + " evaluation_repository,\n", + " \"single-label-classify\",\n", + " evaluation_logic,\n", + ")\n", + "aggregator = Aggregator(\n", + " evaluation_repository,\n", + " aggregation_repository,\n", + " \"single-label-classify\",\n", + " aggregation_logic,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, let's run a single example and see what comes of it!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "classify_input = ClassifyInput(\n", + " chunk=TextChunk(\"This is good\"),\n", + " labels=frozenset({\"positive\", \"negative\"}),\n", + ")\n", + "\n", + "single_example_dataset = dataset_repository.create_dataset(\n", + " examples=[Example(input=classify_input, expected_output=\"positive\")],\n", + " dataset_name=\"ClassifyDataset\",\n", + ")\n", + "\n", + "run_overview = runner.run_dataset(single_example_dataset.id, NoOpTracer())\n", + "evaluation_overview = evaluator.evaluate_runs(run_overview.id)\n", + "aggregation_overview = aggregator.aggregate_evaluation(evaluation_overview.id)\n", + "\n", + "print(\"Statistics: \", aggregation_overview.statistics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Perfect! The example was classified correctly.\n", + "\n", + "Next, we will have a look at this pre-defined [dataset of tweets](https://huggingface.co/cardiffnlp/tweet-topic-21-multi) for more elaborate evaluation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = load_dataset(\"cardiffnlp/tweet_topic_multi\")\n", + "test_set_name = \"validation_random\"\n", + "all_data = list(dataset[test_set_name])\n", + "data = all_data[:25] # this has 573 datapoints, let's take a look at 25 for now" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We need to transform our dataset into the required format. \n", + "Therefore, let's check out what it looks like." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data[1]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Accordingly, this must be translated into the interface of our `Evaluator`.\n", + "\n", + "This is the target structure:\n", + "\n", + "``` python\n", + "class Example(BaseModel, Generic[Input, ExpectedOutput]):\n", + " input: Input\n", + " expected_output: ExpectedOutput\n", + " id: Optional[str] = Field(default_factory=lambda: str(uuid4()))\n", + "\n", + "```\n", + "\n", + "We want the `input` in each `Example` to mimic the input of an actual task. Therefore, we have to always include the text (chunk) and all possible labels.\n", + "The `expected_output` shall correspond to anything we wish to compare our generated output to.\n", + "In this case, that means the correct class(es), i.e., the label name(s)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "all_labels = list(set(label_name for item in data for label_name in item[\"label_name\"]))\n", + "dataset = dataset_repository.create_dataset(\n", + " examples=[\n", + " Example(\n", + " input=ClassifyInput(chunk=TextChunk(item[\"text\"]), labels=all_labels),\n", + " expected_output=item[\"label_name\"][0],\n", + " )\n", + " for item in data\n", + " ],\n", + " dataset_name=\"tweet_topic_multi\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ok, let's run this!\n", + "\n", + "Note that this may take a while as we parallelise the tasks in a way that accommodates the inference API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run_overview = runner.run_dataset(dataset.id)\n", + "evaluation_overview = evaluator.evaluate_runs(run_overview.id)\n", + "aggregation_overview = aggregator.aggregate_evaluation(evaluation_overview.id)\n", + "aggregation_overview.raise_on_evaluation_failure()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Checking out the results..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "evaluation_overview" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Percentage correct:\", aggregation_overview.statistics.percentage_correct)\n", + "\n", + "# You can also use evaluator.evaluation_lineages for an easier use, but that only works if the evaluator is still in memory.\n", + "navigator = RepositoryNavigator(\n", + " dataset_repository=dataset_repository,\n", + " run_repository=run_repository,\n", + " evaluation_repository=evaluation_repository,\n", + ")\n", + "lineages = navigator.evaluation_lineages(\n", + " next(iter(aggregation_overview.evaluation_overviews)).id,\n", + " input_type=ClassifyInput,\n", + " expected_output_type=SingleLabelClassifyOutput,\n", + " output_type=Sequence[str],\n", + " evaluation_type=SingleLabelClassifyEvaluation,\n", + ")\n", + "evaluation_lineages_to_pandas(lineages).head(2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As an alternative to the `PromptBasedClassify` we now gonne use the `EmbeddingBasedClassify` for multi label classifications.\n", + "In this case, we have to provide some example for each class.\n", + "\n", + "We can even reuse our data repositories:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def build_labels_and_examples(hf_data: Any) -> Mapping[str, Sequence[str]]:\n", + " examples = defaultdict(list)\n", + " for item in hf_data:\n", + " labels = item[\"label_name\"]\n", + " for label in labels:\n", + " if len(examples[label]) < 20:\n", + " examples[label].append(item[\"text\"])\n", + " return examples\n", + "\n", + "\n", + "client = LimitedConcurrencyClient.from_env()\n", + "embedding_based_classify = EmbeddingBasedClassify(\n", + " client=client,\n", + " labels_with_examples=[\n", + " LabelWithExamples(name=name, examples=examples)\n", + " for name, examples in build_labels_and_examples(all_data[25:]).items()\n", + " ],\n", + ")\n", + "eval_logic = MultiLabelClassifyEvaluationLogic(threshold=0.60)\n", + "aggregation_logic = MultiLabelClassifyAggregationLogic()\n", + "\n", + "embedding_based_classify_runner = Runner(\n", + " embedding_based_classify,\n", + " dataset_repository,\n", + " run_repository,\n", + " \"embedding-based-classify\",\n", + ")\n", + "embedding_based_classify_evaluator = Evaluator(\n", + " dataset_repository,\n", + " run_repository,\n", + " evaluation_repository,\n", + " \"multi-label-classify\",\n", + " eval_logic,\n", + ")\n", + "embedding_based_classify_aggregator = Aggregator(\n", + " evaluation_repository,\n", + " aggregation_repository,\n", + " \"multi-label-classify\",\n", + " aggregation_logic,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "embedding_based_classify_run_result = embedding_based_classify_runner.run_dataset(\n", + " dataset.id\n", + ")\n", + "embedding_based_classify_evaluation_result = (\n", + " embedding_based_classify_evaluator.evaluate_runs(\n", + " embedding_based_classify_run_result.id\n", + " )\n", + ")\n", + "embedding_based_classify_aggregation_result = (\n", + " embedding_based_classify_aggregator.aggregate_evaluation(\n", + " embedding_based_classify_evaluation_result.id\n", + " )\n", + ")\n", + "embedding_based_classify_aggregation_result.raise_on_evaluation_failure()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "embedding_based_classify_aggregation_result.statistics.macro_avg" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Apparently, our method has a great recall value, i.e. all relevant labels are retrieved. However, the low precision value indicates that we tend to falsely predict labels at times.\n", + "\n", + "Note, that the evaluation criteria for the multiple label approach are a lot harsher; we evaluate whether we correctly predict all labels & not just one of the correct ones!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Wrap up\n", + "\n", + "There you go, this is how to evaluate any task using the 'Intelligence Layer'-framework.\n", + "Simply define an `Evaluator` that takes the target `Task` as input and customize the `do_evaluate` as well as `aggregate` methods." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/fastapi_example.py b/src/documentation/fastapi_example.py new file mode 100644 index 000000000..16563dafe --- /dev/null +++ b/src/documentation/fastapi_example.py @@ -0,0 +1,94 @@ +import http +import os +from http import HTTPStatus +from typing import Annotated, Sequence + +from aleph_alpha_client import Client +from dotenv import load_dotenv +from fastapi import Depends, FastAPI, HTTPException, Request, Response +from fastapi.datastructures import URL + +from intelligence_layer.connectors import AlephAlphaClientProtocol +from intelligence_layer.core import LuminousControlModel, NoOpTracer, Task +from intelligence_layer.examples import ( + SingleChunkSummarizeInput, + SteerableSingleChunkSummarize, + SummarizeOutput, +) + +# Minimal FastAPI app ########################################################## + +app = FastAPI() + + +@app.get("/") +def root() -> Response: + return Response(content="Hello World", status_code=HTTPStatus.OK) + + +# Authentication ############################################################### + + +class AuthService: + def is_valid_token(self, token: str, permissions: Sequence[str], url: URL) -> bool: + # Add your authentication logic here + print(f"Checking permission for route: {url.path}") + return True + + +class PermissionChecker: + def __init__(self, permissions: Sequence[str] = []): + self.permissions = permissions + + def __call__( + self, + request: Request, + auth_service: Annotated[AuthService, Depends(AuthService)], + ) -> None: + token = request.headers.get("Authorization") or "" + try: + if not auth_service.is_valid_token(token, self.permissions, request.url): + raise HTTPException(HTTPStatus.UNAUTHORIZED) + except RuntimeError: + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR) + + +permission_checker_for_user = PermissionChecker(["User"]) + + +# Intelligence Layer Task ###################################################### + +load_dotenv() + + +def client() -> Client: + return Client( + token=os.environ["AA_TOKEN"], + host=os.getenv("AA_CLIENT_BASE_URL", "https://api.aleph-alpha.com"), + ) + + +def default_model( + app_client: Annotated[AlephAlphaClientProtocol, Depends(client)], +) -> LuminousControlModel: + return LuminousControlModel(client=app_client) + + +def summary_task( + model: Annotated[LuminousControlModel, Depends(default_model)], +) -> SteerableSingleChunkSummarize: + return SteerableSingleChunkSummarize(model) + + +@app.post( + "/summary", + dependencies=[Depends(PermissionChecker(["User"]))], + status_code=http.HTTPStatus.OK, +) +def summary_task_route( + input: SingleChunkSummarizeInput, + task: Annotated[ + Task[SingleChunkSummarizeInput, SummarizeOutput], Depends(summary_task) + ], +) -> SummarizeOutput: + return task.run(input, NoOpTracer()) diff --git a/src/documentation/fastapi_tutorial.ipynb b/src/documentation/fastapi_tutorial.ipynb new file mode 100644 index 000000000..0f5078f24 --- /dev/null +++ b/src/documentation/fastapi_tutorial.ipynb @@ -0,0 +1,323 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tutorial: Extending a FastAPI App with the Aleph-Alpha Intelligence Layer\n", + "\n", + "In this tutorial, a basic [FastAPI](https://fastapi.tiangolo.com) app is extended with a new route at which a summary for a given text can be retrieved, using the _Aleph-Alpha Intelligence Layer_, and it's _Luminous_ control models.\n", + "\n", + "The full source code for this example app can be found at the end of this tutorial and in [src/examples/fastapi_example.py](./fastapi_example.py).\n", + "\n", + "## Basic FastAPI App\n", + "\n", + "The foundation for this tutorial is a minimal [FastAPI](https://fastapi.tiangolo.com) application with a root endpoint:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from http import HTTPStatus\n", + "\n", + "from fastapi import FastAPI, Response\n", + "\n", + "app = FastAPI()\n", + "\n", + "\n", + "@app.get(\"/\")\n", + "def root() -> Response:\n", + " return Response(content=\"Hello World\", status_code=HTTPStatus.OK)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This application can be started from the command line with the [Hypercorn](https://github.com/pgjones/hypercorn/) server as follows:\n", + "\n", + "```bash\n", + "hypercorn fastapi_example:app --bind localhost:8000\n", + "```\n", + "\n", + "If the start-up was successful, you should see a message similar to\n", + "```cmd\n", + "[2024-03-07 14:00:55 +0100] [6468] [INFO] Running on http://:8000 (CTRL + C to quit)\n", + "```\n", + "\n", + "Now that the server is running, we can perform a `GET` request via `cURL`:\n", + "```bash\n", + "curl -X GET http://localhost:8000\n", + "```\n", + "You should get\n", + "```\n", + "Hello World\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After successfully starting the basic FastAPI app, the next step is to add a route that makes use of the Intelligence Layer.\n", + "\n", + "## Adding the Intelligence Layer to the application\n", + "\n", + "The building blocks of the Intelligence Layer for applications are `Tasks`. In general, a task implements the `Task`\n", + "interface and defines an `Input` and an `Output`. Multiple tasks can be chained to create more complex applications.\n", + "Here, we will make use of the pre-built task `SteerableSingleChunkSummarize`. This task defines `SingleChunkSummarizeInput`\n", + "as it's input, and `SummarizeOutput` as it's output.\n", + "Like many other tasks, the `SteerableSingleChunkSummarize` task makes use of a `ControlModel`. The\n", + "`ControlModel` itself needs access to the Aleph-Alpha backend via a `AlephAlphaClientProtocol` client.\n", + "In short, the hierarchy is as follows:\n", + "\n", + "![task_dependencies.drawio.svg](task_dependencies.drawio.svg)\n", + "\n", + "We make use of the built-in [Dependency Injection](https://fastapi.tiangolo.com/reference/dependencies/) of FastAPI to\n", + "resolve this hierarchy automatically. In this framework, the defaults for the parameters are dynamically created with\n", + "the `Depends(func)` annotation, where `func` is a function that returns the default value.\n", + "\n", + "So, first, we define our client-generating function. For that, we provide the host URL and a valid Aleph-Alpha token,\n", + "which are stored in an `.env`-file.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from aleph_alpha_client import Client\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv()\n", + "\n", + "\n", + "def client() -> Client:\n", + " return Client(\n", + " token=os.environ[\"AA_TOKEN\"],\n", + " host=os.getenv(\"AA_CLIENT_BASE_URL\", \"https://api.aleph-alpha.com\"),\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we create a `ControlModel`. In this case, we make use of the `LuminousControlModel`, which takes\n", + "an `AlephAlphaClientProtocol` that we let default to the previously defined `client`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Annotated\n", + "\n", + "from fastapi import Depends\n", + "\n", + "from intelligence_layer.connectors import AlephAlphaClientProtocol\n", + "from intelligence_layer.core import LuminousControlModel\n", + "\n", + "\n", + "def default_model(app_client: Annotated[AlephAlphaClientProtocol, Depends(client)]):\n", + " return LuminousControlModel(client=app_client)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, we create the actual `Task`. For our example, we choose the `SteerableSingleChunkSummarize`.\n", + "The `Input` of this task is a `SingleChunkSummarizeInput`, consisting of the text to summarize as the `chunk` field,\n", + "and the desired `Language` as the `language` field.\n", + "The `Output` of this task is a `SummarizeOutput` and contains the `summary` as text,\n", + "and number of generated tokens for the `summary` as the `generated_tokens` field." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from intelligence_layer.examples import SteerableSingleChunkSummarize\n", + "\n", + "\n", + "def summary_task(\n", + " model: Annotated[LuminousControlModel, Depends(default_model)],\n", + ") -> SteerableSingleChunkSummarize:\n", + " return SteerableSingleChunkSummarize(model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then provide a `POST` endpoint on `/summary` to run the task.\n", + "The default for `task` will be set by `summary_task`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from intelligence_layer.core import NoOpTracer, Task\n", + "from intelligence_layer.examples import SingleChunkSummarizeInput, SummarizeOutput\n", + "\n", + "\n", + "@app.post(\"/summary\")\n", + "def summary_task_route_without_permissions(\n", + " input: SingleChunkSummarizeInput,\n", + " task: Annotated[\n", + " Task[SingleChunkSummarizeInput, SummarizeOutput], Depends(summary_task)\n", + " ],\n", + ") -> SummarizeOutput:\n", + " return task.run(input, NoOpTracer())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This concludes the addition of an Intelligence-Layer task to the FastAPI app. After restarting the server, we can call\n", + "our newly created endpoint via a command such as the following:\n", + "```bash\n", + "\n", + "curl -X POST http://localhost:8000/summary -H \"Content-Type: application/json\" -d '{\"chunk\": \"\", \"language\": {\"iso_639_1\": \"en\"}}'\n", + "```\n", + "\n", + "## Add Authorization to the Routes\n", + "\n", + "Typically, authorization is needed to control access to endpoints.\n", + "Here, we will give a minimal example of how a per-route authorization system could be implemented in the minimal example app.\n", + "\n", + "The authorization system makes use of two parts: An `AuthService` that checks whether the user is allowed to access a\n", + "given site, and a `PermissionsChecker` that is called on each route access and in turn calls the `AuthService`.\n", + "\n", + "For this minimal example, the `AuthService` is simply a stub. You will want to implement a concrete authorization service\n", + "tailored to your needs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Sequence\n", + "\n", + "from fastapi.datastructures import URL\n", + "\n", + "\n", + "class AuthService:\n", + " def is_valid_token(self, token: str, permissions: Sequence[str], url: URL):\n", + " # Add your authentication logic here\n", + " print(f\"Checking permission for route: {url.path}\")\n", + " return True" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With this `PermissionsChecker`, `permissions` can be passed in to define which roles, e.g. \"user\" or \"admin\",\n", + "are allowed to access which endpoints. The `PermissionsChecker` implements the `__call__` function, so that it can be\n", + "used as a function in the `dependencies` argument of each route via `Depends`. For more details see the extended\n", + "definition of the `summary_task_route` further below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fastapi import HTTPException, Request\n", + "\n", + "\n", + "class PermissionChecker:\n", + " def __init__(self, permissions: Sequence[str] = []):\n", + " self.permissions = permissions\n", + "\n", + " def __call__(\n", + " self,\n", + " request: Request,\n", + " auth_service: Annotated[AuthService, Depends(AuthService)],\n", + " ) -> None:\n", + " token = request.headers.get(\"Authorization\")\n", + " try:\n", + " if not auth_service.is_valid_token(token, self.permissions, request.url):\n", + " raise HTTPException(HTTPStatus.UNAUTHORIZED)\n", + " except RuntimeError:\n", + " raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A specific `PermissionChecker` with `\"User\"` permissions is created which will be called for the `/summary` route to check, whether a \"User\" is allowed to access it.\n", + "\n", + "The permission checker can then be added to any route via the `dependencies` argument in the decorator. Here, we add it to the `summary_task_route`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "permission_checker_for_user = PermissionChecker([\"User\"])\n", + "\n", + "\n", + "@app.post(\"/summary\", dependencies=[Depends(permission_checker_for_user)])\n", + "def summary_task_route(\n", + " input: SingleChunkSummarizeInput,\n", + " task: Annotated[\n", + " Task[SingleChunkSummarizeInput, SummarizeOutput], Depends(summary_task)\n", + " ],\n", + ") -> SummarizeOutput:\n", + " return task.run(input, NoOpTracer())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Complete Source\n", + "The final source can be found in the [accompanying python file](./fastapi_example.py)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-rp3__H-P-py3.11", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/__init__.py b/src/documentation/how_tos/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/documentation/how_tos/example_data.py b/src/documentation/how_tos/example_data.py new file mode 100644 index 000000000..c9d60dd33 --- /dev/null +++ b/src/documentation/how_tos/example_data.py @@ -0,0 +1,112 @@ +from typing import Iterable, Sequence + +from pydantic import BaseModel + +from intelligence_layer.core import Task, TaskSpan +from intelligence_layer.evaluation import ( + Dataset, + EvaluationLogic, + EvaluationOverview, + Evaluator, + Example, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, + Runner, + RunOverview, + SuccessfulExampleOutput, +) +from intelligence_layer.evaluation.aggregation.aggregator import AggregationLogic + + +class DummyExample(Example[str, str]): + data: str + + +class DummyTask(Task[str, str]): + def do_run(self, input: str, task_span: TaskSpan) -> str: + return f"{input} -> output" + + +class DummyEvaluation(BaseModel): + eval: str + + +class DummyEvaluationLogic(EvaluationLogic[str, str, str, DummyEvaluation]): + def do_evaluate( + self, example: Example[str, str], *output: SuccessfulExampleOutput[str] + ) -> DummyEvaluation: + output_str = "(" + (", ".join(o.output for o in output)) + ")" + return DummyEvaluation( + eval=f"{example.input}, {example.expected_output}, {output_str} -> evaluation" + ) + + +class DummyAggregation(BaseModel): + num_evaluations: int + + +class DummyAggregationLogic(AggregationLogic[DummyEvaluation, DummyAggregation]): + def aggregate(self, evaluations: Iterable[DummyEvaluation]) -> DummyAggregation: + return DummyAggregation(num_evaluations=len(list(evaluations))) + + +class ExampleData: + examples: Sequence[DummyExample] + dataset_repository: InMemoryDatasetRepository + run_repository: InMemoryRunRepository + evaluation_repository: InMemoryEvaluationRepository + runner: Runner[str, str] + evaluator: Evaluator[str, str, str, DummyEvaluation] + dataset: Dataset + run_overview_1: RunOverview + run_overview_2: RunOverview + evaluation_overview_1: EvaluationOverview + evaluation_overview_2: EvaluationOverview + + +def example_data() -> ExampleData: + examples = [ + DummyExample(input="input0", expected_output="expected_output0", data="data0"), + DummyExample(input="input1", expected_output="expected_output1", data="data1"), + ] + + dataset_repository = InMemoryDatasetRepository() + dataset = dataset_repository.create_dataset( + examples=examples, dataset_name="my-dataset" + ) + + run_repository = InMemoryRunRepository() + runner = Runner(DummyTask(), dataset_repository, run_repository, "my-runner") + run_overview_1 = runner.run_dataset(dataset.id) + run_overview_2 = runner.run_dataset(dataset.id) + + evaluation_repository = InMemoryEvaluationRepository() + evaluator = Evaluator( + dataset_repository, + run_repository, + evaluation_repository, + "my-evaluator", + DummyEvaluationLogic(), + ) + evaluation_overview_1 = evaluator.evaluate_runs( + run_overview_1.id, run_overview_2.id + ) + evaluation_overview_2 = evaluator.evaluate_runs( + run_overview_1.id, run_overview_2.id + ) + + example_data = ExampleData() + example_data.examples = examples + example_data.dataset_repository = dataset_repository + example_data.run_repository = run_repository + example_data.evaluation_repository = evaluation_repository + example_data.runner = runner + example_data.evaluator = evaluator + example_data.dataset = dataset + example_data.run_overview_1 = run_overview_1 + example_data.run_overview_2 = run_overview_2 + example_data.evaluation_overview_1 = evaluation_overview_1 + example_data.evaluation_overview_2 = evaluation_overview_2 + + return example_data diff --git a/src/documentation/how_tos/how_to_aggregate_evaluations.ipynb b/src/documentation/how_tos/how_to_aggregate_evaluations.ipynb new file mode 100644 index 000000000..2dadcce47 --- /dev/null +++ b/src/documentation/how_tos/how_to_aggregate_evaluations.ipynb @@ -0,0 +1,88 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from example_data import DummyAggregationLogic, example_data\n", + "\n", + "from intelligence_layer.evaluation.aggregation.aggregator import Aggregator\n", + "from intelligence_layer.evaluation.aggregation.in_memory_aggregation_repository import (\n", + " InMemoryAggregationRepository,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to aggregate evaluations\n", + "0. Run the evaluations of all your tasks and datasets (see [here](./how_to_evaluate_runs.ipynb)).\n", + " - When aggregating multiple evaluations, all of them need the same data types \n", + "1. Initialize all necessary repositories for the `Aggregator`, and an `AggregationLogic`\n", + "2. Run the `Aggregator` to aggregate all examples and create a single `AggregationOverview`\n", + "3. (Optional) Save the `AggregationOverview.id` for later retrieval\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 0\n", + "\n", + "\n", + "my_example_data = example_data()\n", + "print()\n", + "\n", + "evaluation_ids = [\n", + " my_example_data.evaluation_overview_1.id,\n", + " my_example_data.evaluation_overview_2.id,\n", + "]\n", + "\n", + "# Step 1\n", + "evaluation_repository = my_example_data.evaluation_repository\n", + "aggregation_repository = InMemoryAggregationRepository()\n", + "aggregation_logic = DummyAggregationLogic()\n", + "\n", + "# Step 2\n", + "aggregator = Aggregator(\n", + " evaluation_repository,\n", + " aggregation_repository,\n", + " \"MyAggregationDescription\",\n", + " aggregation_logic,\n", + ")\n", + "aggregation_overview = aggregator.aggregate_evaluation(*evaluation_ids)\n", + "\n", + "# Step 3\n", + "print(aggregation_overview.id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_create_a_dataset.ipynb b/src/documentation/how_tos/how_to_create_a_dataset.ipynb new file mode 100644 index 000000000..498c21bce --- /dev/null +++ b/src/documentation/how_tos/how_to_create_a_dataset.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Sequence\n", + "\n", + "from pydantic import BaseModel\n", + "\n", + "from intelligence_layer.evaluation import Example, InMemoryDatasetRepository" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to create a dataset\n", + "\n", + "0. Collect data for examples.\n", + "1. Convert data to `Example`s.\n", + "1. Create a `DatasetRepository`.\n", + "2. Store `Example`s to `DatasetRepository`.\n", + "3. Remember the dataset id." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class StoryTaskInput(BaseModel): # Should already be implemented in your task\n", + " topic: str\n", + " targeted_word_count: int\n", + "\n", + "\n", + "class StoryTaskExpectedOutput(BaseModel): # Should already be implemented in your task\n", + " keywords: Sequence[str]\n", + "\n", + "\n", + "# Step 1\n", + "examples = [\n", + " Example(\n", + " input=StoryTaskInput(topic=\"rain\", targeted_word_count=42),\n", + " expected_output=StoryTaskExpectedOutput(keywords=[\"wet\"]),\n", + " ),\n", + " # ...\n", + "]\n", + "\n", + "# Step 2 - Use FileDatasetRepository or HuggingFaceDatasetRepository for persistence\n", + "dataset_repository = InMemoryDatasetRepository()\n", + "\n", + "# Step 3\n", + "dataset = dataset_repository.create_dataset(\n", + " examples=examples,\n", + " dataset_name=\"StoryDataset\",\n", + ")\n", + "\n", + "# Step 4\n", + "print(dataset.id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-dgcJwC7l-py3.11", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_define_a_task.ipynb b/src/documentation/how_tos/how_to_define_a_task.ipynb new file mode 100644 index 000000000..f3d803091 --- /dev/null +++ b/src/documentation/how_tos/how_to_define_a_task.ipynb @@ -0,0 +1,70 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to define a task\n", + "\n", + "1. Think about what you want to do and define the requirements for your task\n", + "2. Define the corresponding input and output in the form of Python classes\n", + "3. Check if any existing task can be used to fulfill these requirements (see the [Use-case index](../../../README.md#use-case-index))\n", + "4. Implement the task with the defined input and output types, see [How to implement a task](how_to_implement_a_task.ipynb) \n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "**Step 1**\n", + "- I want the LLM to tell a joke about a specific topic\n", + "- It should work for any topic\n", + "- It should fail if there is no topic given by the user\n", + "\n", + "\n", + "**Step 2**\n", + "\n", + "```python\n", + "class TellAJokeTaskInput(BaseModel):\n", + " topic: str\n", + "\n", + "class TellAJokeTaskOutput(BaseModel):\n", + " joke: str\n", + "```\n", + "\n", + "**Step 3**\n", + "On first glance any of the QA tasks seem to fulfill the requirements. However, here only the topic for the joke should be specified by the user and the request to tell a joke should be handled by the task itself. \n", + "\n", + "\n", + "**Step 4**\n", + "```python\n", + "class TellAJokeTask(Task[TellAJokeTaskInput, TellAJokeTaskOutput]):\n", + " ...\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_evaluate_runs.ipynb b/src/documentation/how_tos/how_to_evaluate_runs.ipynb new file mode 100644 index 000000000..403ebcb1a --- /dev/null +++ b/src/documentation/how_tos/how_to_evaluate_runs.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from example_data import DummyEvaluationLogic, example_data\n", + "\n", + "from intelligence_layer.evaluation.evaluation.evaluator import Evaluator\n", + "from intelligence_layer.evaluation.evaluation.in_memory_evaluation_repository import (\n", + " InMemoryEvaluationRepository,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to evaluate runs\n", + "0. Run your tasks on the datasets where you want to evaluate them on (see [here](./how_to_run_a_task_on_a_dataset.ipynb))\n", + " - When evaluating multiple runs, all of them need the same data types \n", + "2. Initialize all necessary repositories for the `Evaluator`, and an `EvaluationLogic`.\n", + "3. Run the evaluator to evaluate all examples and create a single `EvaluationOverview`\n", + "4. (Optional) Save the evaluation id for later use" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 0\n", + "my_example_data = example_data()\n", + "print()\n", + "run_ids = [my_example_data.run_overview_1.id, my_example_data.run_overview_2.id]\n", + "\n", + "# Step 1\n", + "dataset_repository = my_example_data.dataset_repository\n", + "run_repository = my_example_data.run_repository\n", + "evaluation_repository = InMemoryEvaluationRepository()\n", + "evaluation_logic = DummyEvaluationLogic()\n", + "\n", + "# Step 3\n", + "evaluator = Evaluator(\n", + " dataset_repository,\n", + " run_repository,\n", + " evaluation_repository,\n", + " \"My dummy evaluation\",\n", + " evaluation_logic,\n", + ")\n", + "\n", + "evaluation_overview = evaluator.evaluate_runs(*run_ids)\n", + "\n", + "# Step 4\n", + "print(evaluation_overview.id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_human_evaluation_via_argilla.ipynb b/src/documentation/how_tos/how_to_human_evaluation_via_argilla.ipynb new file mode 100644 index 000000000..b7de9e085 --- /dev/null +++ b/src/documentation/how_tos/how_to_human_evaluation_via_argilla.ipynb @@ -0,0 +1,268 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Iterable\n", + "\n", + "from dotenv import load_dotenv\n", + "from pydantic import BaseModel\n", + "\n", + "from intelligence_layer.connectors import (\n", + " ArgillaEvaluation,\n", + " DefaultArgillaClient,\n", + " Field,\n", + " Question,\n", + " RecordData,\n", + ")\n", + "from intelligence_layer.evaluation import (\n", + " AggregationLogic,\n", + " ArgillaAggregator,\n", + " ArgillaEvaluationLogic,\n", + " ArgillaEvaluationRepository,\n", + " Example,\n", + " InMemoryAggregationRepository,\n", + " InMemoryEvaluationRepository,\n", + " RecordDataSequence,\n", + " SuccessfulExampleOutput,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to evaluate with human evaluation via Argilla\n", + "1. Initialize an Argilla client with the correct settings for your setup\n", + " - By default, the url and api key are read from the environment variables `ARGILLA_API_URL` and `ARGILLA_API_KEY`\n", + "2. Create `Question`s and `Field`s to structure the data that will be displayed in Argilla\n", + "3. Choose an Argilla workspace and get its ID\n", + "4. Create an `ArgillaEvaluationRepository`\n", + "5. Implement an `ArgillaEvaluationLogic`\n", + "6. Submit tasks to the Argilla instance by running the `ArgillaEvaluator`\n", + " - Make sure to save the `EvaluationOverview.id`, as it is needed to retrieve the results later\n", + "7. **Use the Argilla web platform to evaluate** " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1\n", + "client = DefaultArgillaClient(\n", + " # api_url=\"your url here\", # not necessary if ARGILLA_API_URL is set in environment\n", + " # api_key=\"your api key here\", # not necessary if ARGILLA_API_KEY is set in environment\n", + ")\n", + "\n", + "# Step 2\n", + "questions = [\n", + " Question(\n", + " name=\"rating\",\n", + " title=\"Funniness\",\n", + " description=\"How funny do you think is the joke? Rate it from 1-5.\",\n", + " options=range(1, 6),\n", + " )\n", + "]\n", + "fields = [\n", + " Field(name=\"input\", title=\"Topic\"),\n", + " Field(name=\"output\", title=\"Joke\"),\n", + "]\n", + "\n", + "# Step 3\n", + "workspace_id = client.ensure_workspace_exists(\"my-workspace-name\")\n", + "\n", + "# Step 4\n", + "data_storage = (\n", + " InMemoryEvaluationRepository()\n", + ") # Use FileEvaluationRepository for persistent results\n", + "evaluation_repository = ArgillaEvaluationRepository(\n", + " data_storage, client, workspace_id, fields, questions\n", + ")\n", + "\n", + "\n", + "# Step 5\n", + "class StoryTaskInput(BaseModel): # Should already be implemented in your task\n", + " topic: str\n", + " targeted_word_count: int\n", + "\n", + "\n", + "class StoryTaskOutput(BaseModel): # Should already be implemented in your task\n", + " story: str\n", + "\n", + "\n", + "class CustomArgillaEvaluationLogic(\n", + " ArgillaEvaluationLogic[\n", + " StoryTaskInput, StoryTaskOutput, None\n", + " ] # No expected output, therefore \"None\"\n", + "):\n", + " def _to_record(\n", + " self,\n", + " example: Example[StoryTaskInput, None],\n", + " *output: SuccessfulExampleOutput[StoryTaskOutput],\n", + " ) -> RecordDataSequence:\n", + " return RecordDataSequence(\n", + " records=[\n", + " RecordData(\n", + " content={\n", + " # labels as defined in Field.name\n", + " \"input\": example.input.topic,\n", + " \"output\": run_output.output.story,\n", + " },\n", + " example_id=example.id,\n", + " )\n", + " for run_output in output\n", + " ]\n", + " )\n", + "\n", + "\n", + "evaluation_logic = CustomArgillaEvaluationLogic()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%script false --no-raise-error\n", + "# we skip this as we do not have a dataset or run in this example\n", + "\n", + "# Step 6\n", + "runs_to_evaluate = [\"your_run_id_of_interest\", \"other_run_id_of_interest\"]\n", + "\n", + "evaluator = ArgillaEvaluator(\n", + " ..., evaluation_repository, description=\"My evaluation description\", evaluation_logic=evaluation_logic\n", + ")\n", + "evaluation_overview = evaluator.evaluate_runs(*runs_to_evaluate)\n", + "print(\"ID to retrieve results later: \", evaluation_overview.id)\n", + "\n", + "# Step 7\n", + "\n", + "####################################\n", + "# Evaluate via the Argilla UI here #\n", + "####################################" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "```python\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to aggregate an Argilla evaluation\n", + "0. Submit tasks to Argilla and perform an evaluation (see [here](#how-to-evaluate-with-human-evaluation-via-argilla)).\n", + "1. Implement an `AggregationLogic` that takes `ArgillaEvaluation`s as input.\n", + "2. Remember the ID of the evaluation and the name of the Argilla workspace that you want to aggregate.\n", + "3. Initialize the `ArgillaEvaluationRepository` and an aggregation repository.\n", + "4. Aggregate the results with an `ArgillaAggregator`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1\n", + "\n", + "\n", + "class CustomArgillaAggregation(BaseModel):\n", + " avg_funniness: float\n", + "\n", + "\n", + "class CustomArgillaAggregationLogic(\n", + " AggregationLogic[ArgillaEvaluation, CustomArgillaAggregation]\n", + "):\n", + " def aggregate(\n", + " self, evaluations: Iterable[ArgillaEvaluation]\n", + " ) -> CustomArgillaAggregation:\n", + " evaluation_list = list(evaluations)\n", + " total_score = sum(\n", + " evaluation.metadata[\n", + " \"rating\"\n", + " ] # This name is defined by the `Question`s given to the Argilla repository during submission\n", + " for evaluation in evaluation_list\n", + " )\n", + " return CustomArgillaAggregation(\n", + " avg_funniness=total_score / len(evaluation_list)\n", + " )\n", + "\n", + "\n", + "aggregation_logic = CustomArgillaAggregationLogic()\n", + "\n", + "# Step 2 - See the first example for more info\n", + "eval_id = \"my-previous-eval-id\"\n", + "client = DefaultArgillaClient()\n", + "workspace_id = client.ensure_workspace_exists(\"my-workspace-name\")\n", + "\n", + "# Step 3\n", + "evaluation_repository = ArgillaEvaluationRepository(\n", + " InMemoryEvaluationRepository(), client, workspace_id\n", + ")\n", + "aggregation_repository = InMemoryAggregationRepository()\n", + "\n", + "# Step 4\n", + "aggregator = ArgillaAggregator(\n", + " evaluation_repository,\n", + " aggregation_repository,\n", + " \"My aggregation description\",\n", + " aggregation_logic,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%script false --no-raise-error\n", + "# we skip this as we do not have a dataset or run in this example\n", + "\n", + "aggregation = aggregator.aggregate_evaluation(eval_id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_implement_a_simple_evaluation_and_aggregation_logic.ipynb b/src/documentation/how_tos/how_to_implement_a_simple_evaluation_and_aggregation_logic.ipynb new file mode 100644 index 000000000..2d90e7781 --- /dev/null +++ b/src/documentation/how_tos/how_to_implement_a_simple_evaluation_and_aggregation_logic.ipynb @@ -0,0 +1,170 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Iterable\n", + "\n", + "import numpy as np\n", + "from dotenv import load_dotenv\n", + "from pydantic import BaseModel\n", + "\n", + "from intelligence_layer.evaluation.aggregation.aggregator import AggregationLogic\n", + "from intelligence_layer.evaluation.dataset.domain import Example\n", + "from intelligence_layer.evaluation.evaluation.evaluator import (\n", + " SingleOutputEvaluationLogic,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to implement logic for the evaluation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "1. Determine the data types you need for the evaluation:\n", + " - An `Example` of the dataset you are using defines \"`Input`\" and \"`ExpectedOutput`\" data types\n", + " - The task you are using defines the `Output` data type.\n", + "2. Create an `Evaluation` type that will contain the domain-specific evaluation result for a single `Example`.\n", + "3. Decide if you want to use a single `Output` per `Example`, or multiple outputs per example, during your evaluation to generate your evaluation results.\n", + " - For a single output, we recommend to implement a `SingleOutputEvaluationLogic`.\n", + " - For multiple outputs, implement an `EvaluationLogic`.\n", + "4. Implement the evaluation logic in the `do_evaluate_single_output` method for `SingleOutputEvaluationLogic` or in the `do_evaluate` method for `EvaluationLogic`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "In the following example we want to evaluate a story-generating task that generates a story of a topic with a targeted word count" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1 - This is only redefined here for completeness. Normally these would be imported.\n", + "# Note that we do not have an ExpectedOutput here.\n", + "\n", + "\n", + "class StoryTaskInput(BaseModel):\n", + " topic: str\n", + " targeted_word_count: int\n", + "\n", + "\n", + "class StoryTaskOutput(BaseModel):\n", + " story: str\n", + "\n", + "\n", + "# Step 2 - We want to analyze if the word count is accurate\n", + "class StoryEvaluation(BaseModel):\n", + " word_count_off_by: int\n", + "\n", + "\n", + "class StoryEvaluationLogic(\n", + " # Step 3 - We only need a single output to analyze the word count\n", + " SingleOutputEvaluationLogic[\n", + " StoryTaskInput, StoryTaskOutput, None, StoryEvaluation\n", + " ] # We pass None here as we do not have an ExpectedOutput\n", + "):\n", + " def do_evaluate_single_output(\n", + " self, example: Example[StoryTaskInput, None], output: StoryTaskOutput\n", + " ) -> StoryEvaluation:\n", + " # Step 4 - Implement the domain specific logic\n", + " output_word_count = len(output.story.split())\n", + " word_count_off_by = output_word_count - example.input.targeted_word_count\n", + " return StoryEvaluation(word_count_off_by=word_count_off_by)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to implement a logic for an aggregation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "0. Implement the evaluation logic for your use-case. (see [above](#how-to-implement-logic-for-the-evaluation))\n", + "1. Create an `AggregatedEvaluation` type that will contain the domain specific data aggregated from evaluations.\n", + "2. Implement an `AggregationLogic` for your data types\n", + " 1. Implement the domain-specific logic in the `aggregate` method" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example\n", + "In the following example, we calculate basic statistics on the word count differences of the previous evaluation example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 0 - See the example above\n", + "\n", + "\n", + "# Step 1\n", + "class StoryAggregation(BaseModel):\n", + " wc_off_mean: float\n", + " wc_off_median: int\n", + " wc_off_std: float\n", + "\n", + "\n", + "# Step 2\n", + "class StoryAggregationLogic(AggregationLogic[StoryEvaluation, StoryAggregation]):\n", + " def aggregate(self, evaluations: Iterable[StoryEvaluation]) -> StoryAggregation:\n", + " # Step 2.1\n", + " word_counts = np.array(\n", + " [evaluation.word_count_off_by for evaluation in evaluations]\n", + " )\n", + " wc_off_mean = np.mean(word_counts)\n", + " wc_off_median = np.median(word_counts)\n", + " wc_off_std = np.std(word_counts)\n", + " return StoryAggregation(\n", + " wc_off_mean=wc_off_mean, wc_off_median=wc_off_median, wc_off_std=wc_off_std\n", + " )" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_implement_a_task.ipynb b/src/documentation/how_tos/how_to_implement_a_task.ipynb new file mode 100644 index 000000000..4c17ac6cc --- /dev/null +++ b/src/documentation/how_tos/how_to_implement_a_task.ipynb @@ -0,0 +1,176 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dotenv import load_dotenv\n", + "from pydantic import BaseModel\n", + "\n", + "from intelligence_layer.core import (\n", + " CompleteInput,\n", + " LuminousControlModel,\n", + " NoOpTracer,\n", + " Task,\n", + " TaskSpan,\n", + ")\n", + "from intelligence_layer.examples import SingleChunkQa, SingleChunkQaInput\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to implement a task using an Aleph Alpha model\n", + "0. Define the task (see [here](./how_to_define_a_task.ipynb))\n", + "1. Decide which model best suits your use case (for a list of Aleph Alpha control models see [here](https://aleph-alpha-intelligence-layer.readthedocs-hosted.com/en/latest/intelligence_layer.core.html#intelligence_layer.core.LuminousControlModel))\n", + "2. Create a `Task` subclass\n", + " 1. Pass the Model to the constructor \n", + " 2. Implement your domain logic in `do_run()`\n", + " 1. Generate a `Prompt`. Examples for generating prompts are `ControlModel.to_instruct_prompt()`, `PromptTemplate.to_rich_prompt()` or `Prompt.from_text()`\n", + " 2. Run the model with the prompt\n", + " 3. Map the prompt output to the task output class\n", + "3. Run and test it" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 0 - Define the input and output types for your task\n", + "\n", + "\n", + "class TellAJokeTaskInput(BaseModel):\n", + " topic: str\n", + "\n", + "\n", + "class TellAJokeTaskOutput(BaseModel):\n", + " joke: str\n", + "\n", + "\n", + "# Step 1 - we want a control model but do not care otherwise. Therefore we use the default.\n", + "\n", + "\n", + "# Step 2\n", + "class TellAJokeTask(Task[TellAJokeTaskInput, TellAJokeTaskOutput]):\n", + " PROMPT_TEMPLATE: str = \"\"\"Tell me a joke about the following topic:\"\"\"\n", + "\n", + " # Step 2.1\n", + " def __init__(self, model: LuminousControlModel = LuminousControlModel()) -> None:\n", + " self._model = model\n", + "\n", + " # Step 2.2\n", + " def do_run(\n", + " self, input: TellAJokeTaskInput, task_span: TaskSpan\n", + " ) -> TellAJokeTaskOutput:\n", + " # Step 2.2.1\n", + " prompt = self._model.to_instruct_prompt(self.PROMPT_TEMPLATE, input.topic)\n", + " completion_input = CompleteInput(prompt=prompt)\n", + " # Step 2.2.2\n", + " completion = self._model.complete(completion_input, task_span)\n", + " return TellAJokeTaskOutput(joke=completion.completions[0].completion)\n", + "\n", + "\n", + "TellAJokeTask().run(TellAJokeTaskInput(topic=\"Software Engineers\"), NoOpTracer())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to use subtasks in your task\n", + " - Follow [How to implement a task using an Aleph Alpha model](#how-to-implement-a-task-using-an-aleph-alpha-model) to create a task and replace the `Model` class with a subtask of your choosing." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class PeopleExtractorInput(BaseModel):\n", + " text_passage: str\n", + "\n", + "\n", + "class PeopleExtractorOutput(BaseModel):\n", + " answer: str | None\n", + "\n", + "\n", + "class PeopleExtractor(Task[PeopleExtractorInput, PeopleExtractorOutput]):\n", + " QUESTION: str = \"\"\"Who are the people involved in the text?\"\"\"\n", + "\n", + " # Step 2.1 - pass the task into the init function\n", + " def __init__(self, task: SingleChunkQa = SingleChunkQa()) -> None:\n", + " self._task = task\n", + "\n", + " def do_run(\n", + " self, input: PeopleExtractorInput, task_span: TaskSpan\n", + " ) -> PeopleExtractorOutput:\n", + " # Step 2.2.1 - create the required input for the task\n", + " question_input = SingleChunkQaInput(\n", + " chunk=input.text_passage, question=self.QUESTION\n", + " )\n", + " # Step 2.2.2 - use the task in the run\n", + " completion = self._task.run(\n", + " question_input,\n", + " task_span,\n", + " )\n", + " return PeopleExtractorOutput(answer=completion.answer)\n", + "\n", + "\n", + "task_input = PeopleExtractorInput(\n", + " text_passage=\"Peter ate Sarahs Lunch, their teacher Mr. Meyers was very angry with him.'\"\n", + ")\n", + "PeopleExtractor().run(task_input, NoOpTracer()).answer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_log_and_debug_a_task.ipynb b/src/documentation/how_tos/how_to_log_and_debug_a_task.ipynb new file mode 100644 index 000000000..9072bfdbe --- /dev/null +++ b/src/documentation/how_tos/how_to_log_and_debug_a_task.ipynb @@ -0,0 +1,116 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "from aleph_alpha_client import Prompt\n", + "from dotenv import load_dotenv\n", + "\n", + "from intelligence_layer.core import (\n", + " CompleteInput,\n", + " InMemoryTracer,\n", + " LuminousControlModel,\n", + " Task,\n", + " TaskSpan,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to log and debug a task\n", + "The Intelligence Layer offers logging and debugging via a `Tracer`. \n", + "Here are several steps you can use to debug tasks with the trace feature:\n", + "\n", + "-----\n", + "Most logging of a task (input, output, time) is done simply by inheriting from `Task`. This logs to a trace.\n", + "\n", + " - If you don't care about logging and tracing, use the `NoOpTracer`.\n", + " - To create custom logging messages in a trace use `task_span.log()`.\n", + " - To map a complex execution flow of a task into a single trace, pass the `task_span` of the `do_run` to other execution methods (e.g. `Task.run()` or `model.complete()`). \n", + " - If the execution method is not provided by the intelligence layer, the tracing of input and output has to happen manually. See the implementation of `Task.run()` for an example.\n", + " - Use the [trace viewer](./how_to_run_the_trace_viewer.ipynb) to view and inspect a trace\n", + " - Use and display an `InMemoryTracer` in a notebook to automatically send the trace data to the trace viewer.\n", + " - Note: This also works for traces of the `Runner` and the `Evaluator`.\n", + " - To create persistent traces, use the `FileTracer` instead. This creates files which can manually be uploaded in the trace viewer UI." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class DummyTask(Task[str, str]):\n", + " def __init__(self, model: LuminousControlModel = LuminousControlModel()) -> None:\n", + " self._model = model\n", + "\n", + " def do_run(self, input: str, task_span: TaskSpan) -> str:\n", + " should_output = random.random()\n", + " # log a custom message and value\n", + " task_span.log(\n", + " \"My very important log message that logs a random value\", should_output\n", + " )\n", + " if should_output > 0.5:\n", + " model_input = CompleteInput(prompt=Prompt.from_text(input), temperature=0.2)\n", + " # Create a trace tree by passing task_span to .run or .complete methods.\n", + " completion = self._model.complete(model_input, task_span)\n", + " return completion.completions[0].completion\n", + " else:\n", + " return \"Nope!\"\n", + "\n", + "\n", + "tracer = InMemoryTracer()\n", + "DummyTask().run(\"\", tracer)\n", + "# ! make sure to run the trace viewer docker container to get the improved display !\n", + "# display an InMemoryTracer in a notebook and send the data to the trace viewer\n", + "display(tracer)\n", + "\n", + "pass" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_retrieve_data_for_analysis.ipynb b/src/documentation/how_tos/how_to_retrieve_data_for_analysis.ipynb new file mode 100644 index 000000000..57875aa8a --- /dev/null +++ b/src/documentation/how_tos/how_to_retrieve_data_for_analysis.ipynb @@ -0,0 +1,143 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from example_data import DummyEvaluation, example_data\n", + "\n", + "from intelligence_layer.evaluation import (\n", + " RepositoryNavigator,\n", + " evaluation_lineages_to_pandas,\n", + ")\n", + "\n", + "example_data = example_data()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to retrieve data for analysis\n", + "0. Initialize the relevant repositories for the data of interest\n", + "1. Retrieve the data\n", + " - from the repositories directly for a quick overview of specific data of interest\n", + " - via the `RepositoryNavigator` to join data from different repositories together.\n", + " - via the `Runner`/`Evaluator`\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 0\n", + "dataset_repository = example_data.dataset_repository\n", + "run_repository = example_data.run_repository\n", + "evaluation_repository = example_data.evaluation_repository\n", + "\n", + "# Step 1 - direct access of data\n", + "my_example_id = example_data.examples[0].id\n", + "my_dataset_id = example_data.dataset.id\n", + "\n", + "# retrieve a dataset with all examples, and a single example\n", + "my_dataset = dataset_repository.dataset(my_dataset_id)\n", + "my_example = dataset_repository.example(\n", + " my_dataset_id, my_example_id, input_type=str, expected_output_type=str\n", + ")\n", + "\n", + "# retrieve all outputs, and an output for an example\n", + "my_run_id = example_data.run_overview_1.id\n", + "my_outputs = run_repository.example_outputs(my_run_id, output_type=str)\n", + "my_example_output = run_repository.example_output(\n", + " my_run_id, my_example_id, output_type=str\n", + ")\n", + "\n", + "# retrieve all evaluations, and an evaluation for an example\n", + "my_evaluation_id = example_data.evaluation_overview_1.id\n", + "my_evaluations = evaluation_repository.example_evaluations(\n", + " my_evaluation_id, evaluation_type=DummyEvaluation\n", + ")\n", + "my_example_evaluation = evaluation_repository.example_evaluation(\n", + " my_evaluation_id, my_example_id, evaluation_type=DummyEvaluation\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1 - retrieve linked data across all repositories\n", + "\n", + "navigator = RepositoryNavigator(\n", + " dataset_repository=dataset_repository,\n", + " run_repository=run_repository,\n", + " evaluation_repository=evaluation_repository,\n", + ")\n", + "\n", + "my_lineages = navigator.evaluation_lineages(\n", + " my_evaluation_id,\n", + " input_type=str,\n", + " expected_output_type=str,\n", + " output_type=str,\n", + " evaluation_type=DummyEvaluation,\n", + ")\n", + "display(evaluation_lineages_to_pandas(my_lineages))\n", + "\n", + "my_lineage = navigator.evaluation_lineage(\n", + " my_evaluation_id,\n", + " my_example_id,\n", + " input_type=str,\n", + " expected_output_type=str,\n", + " output_type=str,\n", + " evaluation_type=DummyEvaluation,\n", + ")\n", + "display(my_lineage)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1 - retrieve linked data via the `Runner`/`Evaluator`\n", + "my_evaluator = example_data.evaluator\n", + "\n", + "my_lineages = my_evaluator.evaluation_lineages(my_evaluation_id)\n", + "display(evaluation_lineages_to_pandas(my_lineages))\n", + "\n", + "my_lineage = my_evaluator.evaluation_lineage(my_evaluation_id, my_example_id)\n", + "display(my_lineage)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_run_a_task_on_a_dataset.ipynb b/src/documentation/how_tos/how_to_run_a_task_on_a_dataset.ipynb new file mode 100644 index 000000000..8b9358268 --- /dev/null +++ b/src/documentation/how_tos/how_to_run_a_task_on_a_dataset.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from example_data import DummyTask, example_data\n", + "\n", + "from intelligence_layer.evaluation.run.in_memory_run_repository import (\n", + " InMemoryRunRepository,\n", + ")\n", + "from intelligence_layer.evaluation.run.runner import Runner" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to run a task on a dataset\n", + "0. Create a suitable dataset (see [here](./how_to_create_a_dataset.ipynb)) and a task (see [here](./how_to_implement_a_task.ipynb)).\n", + "1. Initialize the task and a `RunRepository`, and open the correct `DatasetRepository`\n", + " - The `DatasetRepository` needs to contain the dataset.\n", + " - The `RunRepository` stores results.\n", + "2. Use the `Runner` to run the task on the given dataset via `run_dataset`\n", + "3. Save the id of the resulting `RunOverview`\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Step 0\n", + "my_example_data = example_data()\n", + "print()\n", + "\n", + "# Step 1\n", + "dataset_repository = my_example_data.dataset_repository\n", + "run_repository = InMemoryRunRepository()\n", + "task = DummyTask()\n", + "\n", + "# Step 2\n", + "runner = Runner(task, dataset_repository, run_repository, \"MyRunDescription\")\n", + "run_overview = runner.run_dataset(my_example_data.dataset.id)\n", + "\n", + "# Step 3\n", + "print(run_overview.id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "intelligence-layer-d3iSWYpm-py3.10", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/how_tos/how_to_run_the_trace_viewer.ipynb b/src/documentation/how_tos/how_to_run_the_trace_viewer.ipynb new file mode 100644 index 000000000..4daf1fed6 --- /dev/null +++ b/src/documentation/how_tos/how_to_run_the_trace_viewer.ipynb @@ -0,0 +1,35 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Running the Trace Viewer\n", + "\n", + "Make sure you have your access to the Jfrog instance at https://alephalpha.jfrog.io. \n", + "Then login to the container registry with docker with your JFrog user name and a JFrog token as the password with the following command:\n", + "\n", + "```bash\n", + "docker login alephalpha.jfrog.io\n", + "```\n", + "\n", + "Note: If you do not already have a JFrog token, you can find it on the website under the \"Set me up\" option, either in the resource of interest or under your profile name.\n", + "\n", + "Afterwards, run the container locally to start the trace viewer:\n", + "\n", + "```bash\n", + "docker run -p 3000:3000 alephalpha.jfrog.io/container-images/trace-viewer:latest\n", + "```\n", + "\n", + "Finally, visit `http://localhost:3000`, where you can upload a trace to interact with the data." + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/documentation/human_evaluation.ipynb b/src/documentation/human_evaluation.ipynb new file mode 100644 index 000000000..486a1c56b --- /dev/null +++ b/src/documentation/human_evaluation.ipynb @@ -0,0 +1,570 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import shutil\n", + "from pathlib import Path\n", + "from typing import Iterable, cast\n", + "\n", + "from datasets import load_dataset\n", + "from dotenv import load_dotenv\n", + "from pydantic import BaseModel\n", + "\n", + "from intelligence_layer.connectors import (\n", + " ArgillaEvaluation,\n", + " DefaultArgillaClient,\n", + " Field,\n", + " LimitedConcurrencyClient,\n", + " Question,\n", + " RecordData,\n", + ")\n", + "from intelligence_layer.core import (\n", + " CompleteOutput,\n", + " Instruct,\n", + " InstructInput,\n", + " LuminousControlModel,\n", + ")\n", + "from intelligence_layer.evaluation import (\n", + " AggregationLogic,\n", + " ArgillaAggregator,\n", + " ArgillaEvaluationLogic,\n", + " ArgillaEvaluationRepository,\n", + " ArgillaEvaluator,\n", + " Example,\n", + " FileAggregationRepository,\n", + " FileDatasetRepository,\n", + " FileEvaluationRepository,\n", + " FileRunRepository,\n", + " RecordDataSequence,\n", + " Runner,\n", + " SuccessfulExampleOutput,\n", + ")\n", + "\n", + "load_dotenv()\n", + "\n", + "client = LimitedConcurrencyClient.from_env()\n", + "\n", + "REPOSITORY_ROOT_PATH = Path(\"human-eval-data\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Human Evaluation using the Intelligence Layer\n", + "\n", + "Although there are a variety of ways to automate the evaluation of LLM-based tasks, sometimes it is still necessary to get a human opinion.\n", + "To make this as painless as possible, we have integrated an [Argilla](https://argilla.io/)-Evaluator into the intelligence layer.\n", + "This notebook serves as a quick start guide." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Environment setup\n", + "This notebook expects that you have added your Aleph Alpha token to your .env file.\n", + "Additionally you need to add the `ARGILLA_API_URL` and `ARGILLA_API_KEY` from env.sample to your .env file. \n", + "Next, run\n", + "\n", + "```bash\n", + "docker-compose up -d\n", + "``` \n", + "\n", + "from the intelligence layer base directory." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once you go to `localhost:6900` and you are prompted to enter a username and password, use:\n", + "- username: `argilla`\n", + "- password: `1234`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + "This notebook is designed such that the creation of the dataset, the submission to Argilla and the aggregation of the Argilla evaluations do not have to be done in a single session.\n", + "\n", + "As a result, the data repositories are redefined for each step and we use file-based repositories that persist the data. If you run all steps in a single session, you can use InMemory-based repositories and reuse the same repository object for multiple steps.\n", + "\n", + "Running this notebook creates a `human-eval-data` folder, which will be deleted if you run the whole notebook to completion. It also creates the `test-human-eval` Argilla workspace, which will also be deleted afterwards.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dataset Repository definition\n", + "First we need to define our dataset. Here we use an [Instruction Dataset](https://huggingface.co/datasets/HuggingFaceH4/instruction-dataset?row=0) from Huggingface. Before we can use it for human eval, we need to make an intelligence layer dataset repository." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = load_dataset(\"HuggingfaceH4/instruction-dataset\")[\"test\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let us explore the dataset a bit. It consists of prompts, example completions and metadata for 327 examples. Since we are doing human eval, for now we only need the prompt and corresponding id." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(dataset)\n", + "print(dataset[\"meta\"][0].keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We could now build a single `Example` like this:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "example = Example(\n", + " input=InstructInput(instruction=dataset[\"prompt\"][0], input=None),\n", + " expected_output=None,\n", + " id=str(dataset[\"meta\"][0][\"id\"]),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For our dataset repository, we can either use a `FileDatasetRepository` or an `InMemoryDatasetRepository`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "num_examples = 5\n", + "assert num_examples <= len(dataset)\n", + "dataset_repository = FileDatasetRepository(REPOSITORY_ROOT_PATH)\n", + "dataset_id = dataset_repository.create_dataset(\n", + " examples=[\n", + " Example(\n", + " input=InstructInput(instruction=dataset[\"prompt\"][i], input=None),\n", + " expected_output=None,\n", + " id=str(dataset[\"meta\"][i][\"id\"]),\n", + " )\n", + " for i in range(num_examples)\n", + " ],\n", + " dataset_name=\"human-evaluation-dataset\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_id.name" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Task Setup\n", + "\n", + "We use an `Instruction` task to run the examples in our dataset.\n", + "In addition, we define a `Runner` to generate the completions from the model for our dataset\n", + "and a `RunRepository` to save the results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model = LuminousControlModel(name=\"luminous-base-control\", client=client)\n", + "task = Instruct(model=model)\n", + "\n", + "dataset_repository = FileDatasetRepository(REPOSITORY_ROOT_PATH)\n", + "# either remember the id from before (dataset.id) or retrieve as below\n", + "dataset_id = [\n", + " dataset.id\n", + " for dataset in dataset_repository.datasets()\n", + " if dataset.name == \"human-evaluation-dataset\"\n", + "][0]\n", + "dataset_repository.datasets()\n", + "run_repository = FileRunRepository(REPOSITORY_ROOT_PATH)\n", + "runner = Runner(task, dataset_repository, run_repository, \"instruct-run\")\n", + "\n", + "run_overview = runner.run_dataset(dataset_id)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Evaluator Definition\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "At the end of our evaluation we want a float score $s \\in [1,5]$ describing the model performance.\n", + "We define this as an `InstructAggregatedEvaluation`, which will be used in our aggregation later." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class InstructAggregatedEvaluation(BaseModel):\n", + " general_rating: float | None\n", + " fluency: float | None\n", + " evaluated_examples: int" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now start to define our human evaluation. This is done with `Questions` and `Fields`. \n", + "`Fields` define what a user has to evaluate. In our example, this will be the model input (Instruction) and output (Model Completion). Note that the field names have to match the content keys from the `RecordData` which we will define later in our `InstructArgillaEvaluationLogic`. \n", + "`Questions` are what a user has to answer in order to evaluate the `Fields`. The `name` property will later be used to access the human ratings in the aggregation step. In our case we ask how complete and how fluent the completions are." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "questions = [\n", + " Question(\n", + " name=\"general_rating\", # name of the field in program, used for retrieval later\n", + " title=\"Rating\", # name shown to the user\n", + " description=\"Rate this completion on a scale from 1 to 5\",\n", + " options=range(1, 6),\n", + " ),\n", + " Question(\n", + " name=\"fluency\",\n", + " title=\"Fluency\",\n", + " description=\"How fluent is the completion?\",\n", + " options=range(1, 6),\n", + " ),\n", + "]\n", + "\n", + "fields = [\n", + " Field(name=\"input\", title=\"Instruction\"),\n", + " Field(name=\"output\", title=\"Model Completion\"),\n", + "]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our defined fields and questions will look like this:\n", + "![Argilla Interface](../../assets/argilla_interface.png)\n", + "\n", + "We can now define our `InstructArgillaEvaluationLogic` and `InstructArgillaAggregationLogic`.\n", + "They have to implement the two abstract methods `_to_record` and `aggregate` respectively.\n", + "Lets look at the documentation:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "help(ArgillaEvaluationLogic._to_record)\n", + "print(\"-\" * 100)\n", + "help(AggregationLogic.aggregate)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Instead of performing the evaluation, the `ArgillaEvaluationLogic` is responsible for converting the evaluation data to a format that is accepted by Argilla. During the evaluation, these records will simply be submitted to Argilla. \n", + "We will now create everything we need to submit these evaluations to our Argilla instance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class InstructArgillaEvaluationLogic(\n", + " ArgillaEvaluationLogic[\n", + " InstructInput,\n", + " CompleteOutput,\n", + " None,\n", + " ]\n", + "):\n", + " def _to_record(\n", + " self,\n", + " example: Example[InstructInput, None],\n", + " *example_outputs: SuccessfulExampleOutput[CompleteOutput],\n", + " ) -> RecordDataSequence:\n", + " return RecordDataSequence(\n", + " records=[\n", + " RecordData(\n", + " content={\n", + " \"input\": example.input.instruction,\n", + " \"output\": example_outputs[0].output.completion,\n", + " },\n", + " example_id=example.id,\n", + " )\n", + " ]\n", + " )\n", + "\n", + "\n", + "argilla_client = DefaultArgillaClient()\n", + "workspace_id = argilla_client.ensure_workspace_exists(\"test-human-eval\")\n", + "\n", + "dataset_repository = FileDatasetRepository(REPOSITORY_ROOT_PATH)\n", + "run_repository = FileRunRepository(REPOSITORY_ROOT_PATH)\n", + "evaluation_repository = FileEvaluationRepository(\n", + " REPOSITORY_ROOT_PATH\n", + ") # this is only used to store failed evaluations and the evaluation overview\n", + "argilla_evaluation_repository = ArgillaEvaluationRepository(\n", + " evaluation_repository, argilla_client, workspace_id, fields, questions\n", + ")\n", + "\n", + "eval_logic = InstructArgillaEvaluationLogic()\n", + "evaluator = ArgillaEvaluator(\n", + " dataset_repository,\n", + " run_repository,\n", + " argilla_evaluation_repository,\n", + " \"instruct-evaluation\",\n", + " eval_logic,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After setting up the `ArgillaEvaluator`, the `evaluate_runs` methods posts the records to the Argilla instance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# either remember the id from before (run_overview.id) or retrieve as below\n", + "run_id = [\n", + " overview.id\n", + " for overview in run_repository.run_overviews()\n", + " if overview.description == \"instruct-run\"\n", + "][0]\n", + "\n", + "try:\n", + " eval_overview = evaluator.evaluate_runs(run_id)\n", + " print(eval_overview)\n", + "\n", + "except Exception as e:\n", + " eval_overview = None\n", + " print(str(e))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "While the evaluation says that 5 examples were successfully evaluated, no real evaluation has happened yet. \n", + "If we try to perform an aggregation right now, it will have no evaluations, as none of the submitted records were evaluated by humans through Argilla yet. \n", + "The aggregation fetches only the results that were already evaluated.\n", + "\n", + "---\n", + "\n", + "**Note:** Sometimes it is best to split up the human evaluation effort into multiple people. To best facilitate this, it is possible to split up the dataset by giving them labels.\n", + "Our Argilla client offers an easy way to do this:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "eval_id = eval_overview.id\n", + "argilla_client.split_dataset(eval_id, n_splits=3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These splits can then be filered by, as shown below. \n", + "\"drawing\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "---\n", + "\n", + "For the Aggregation, we first need to define our `AggregationLogic` that has to take an `ArgillaEvaluation` as an input. As output, we use the `InstructAggregatedEvaluation` we defined earlier." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class InstructArgillaAggregationLogic(\n", + " AggregationLogic[ArgillaEvaluation, InstructAggregatedEvaluation]\n", + "):\n", + " def aggregate(\n", + " self,\n", + " evaluations: Iterable[ArgillaEvaluation],\n", + " ) -> InstructAggregatedEvaluation:\n", + " evaluations = list(evaluations)\n", + "\n", + " if len(evaluations) == 0: # if no evaluations were submitted, return\n", + " return InstructAggregatedEvaluation(\n", + " general_rating=None,\n", + " fluency=None,\n", + " evaluated_examples=0,\n", + " )\n", + "\n", + " general_rating = sum(\n", + " cast(float, evaluation.responses[\"general_rating\"])\n", + " for evaluation in evaluations\n", + " ) / len(evaluations)\n", + "\n", + " fluency = sum(\n", + " cast(float, evaluation.responses[\"fluency\"]) for evaluation in evaluations\n", + " ) / len(evaluations)\n", + "\n", + " return InstructAggregatedEvaluation(\n", + " general_rating=general_rating,\n", + " fluency=fluency,\n", + " evaluated_examples=len(evaluations),\n", + " )\n", + "\n", + "\n", + "aggregation_logic = InstructArgillaAggregationLogic()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With this, we can define our `ArgillaAggregator` and retrieve the aggregation of all records that have been evaluated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "evaluation_repository = FileEvaluationRepository(REPOSITORY_ROOT_PATH)\n", + "argilla_evaluation_repository = ArgillaEvaluationRepository(\n", + " evaluation_repository,\n", + " argilla_client,\n", + " workspace_id, # we do not need to set questions and fields here\n", + ")\n", + "aggregation_repository = FileAggregationRepository(REPOSITORY_ROOT_PATH)\n", + "# either remember the id from before (eval_overview.id) or retrieve as below\n", + "eval_id = [\n", + " overview.id\n", + " for overview in argilla_evaluation_repository.evaluation_overviews()\n", + " if overview.description == \"instruct-evaluation\"\n", + "][0]\n", + "\n", + "\n", + "aggregator = ArgillaAggregator(\n", + " argilla_evaluation_repository,\n", + " aggregation_repository,\n", + " \"instruct-aggregation\",\n", + " aggregation_logic,\n", + ")\n", + "\n", + "if eval_overview:\n", + " output = aggregator.aggregate_evaluation(eval_id)\n", + " print(output.statistics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ! WARNING ! This deletes the \"test-human-eval\" argilla workspace and the \"human-eval-data\" folder.\n", + "argilla_client.delete_workspace(workspace_id)\n", + "\n", + "shutil.rmtree(REPOSITORY_ROOT_PATH)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/performance_tips.ipynb b/src/documentation/performance_tips.ipynb new file mode 100644 index 000000000..ca065a9ce --- /dev/null +++ b/src/documentation/performance_tips.ipynb @@ -0,0 +1,227 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "from concurrent.futures import ThreadPoolExecutor\n", + "from itertools import repeat\n", + "from typing import Any\n", + "\n", + "from intelligence_layer.core import NoOpTracer, Task, TaskSpan" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# How to get more done in less time\n", + "The following notebook contains tips for the following problems:\n", + " - A single task that takes very long to complete\n", + " - Running one task multiple times\n", + " - Running several different tasks at the same time\n", + " " + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## A single long running task\n", + "With a single long running task, consider the following:\n", + " - If there are other calculations to do, consider using `ThreadPool.submit`, together with `result`\n", + " - See [here](#submit_example) for an example\n", + " - If this is not the case consider:\n", + " - Choosing a faster model. The `base` model is faster than `extended`, `extended` is faster than `supreme`\n", + " - Choosing tasks that perform fewer LLM operations. E.g.: `MultiChunkQa` usually takes longer than `SingleChunkQa`" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Running one task multiple times\n", + "When a single task should process multiple inputs, one can use `task.run_concurrently` to easily process the inputs at the same time \n", + "\n", + "**Example:**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "class DummyTask(Task):\n", + " def do_run(self, input: Any, task_span: TaskSpan) -> Any:\n", + " time.sleep(2)\n", + " print(\"Task1 complete\")\n", + " return input\n", + "\n", + "\n", + "tracer = NoOpTracer()\n", + "\n", + "task_input = [\"A\", \"B\", \"C\", \"D\"]\n", + "task = DummyTask()\n", + "\n", + "\n", + "result = task.run_concurrently(\n", + " task_input, tracer\n", + ") # this finishes in 2 seconds instead of 8\n", + "result" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## Running several tasks at the same time\n", + "When having to run multiple distinct tasks at the same time, one can leverage the existing `concurrent.futures` python library.\n", + "The following shows some examples on how this can be done" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# Second long running task\n", + "\n", + "\n", + "class DummyTask2(Task):\n", + " def do_run(self, input: Any, task_span: TaskSpan) -> Any:\n", + " time.sleep(2)\n", + " print(\"Task2 complete\")\n", + " return input\n", + "\n", + "\n", + "# initialize all tasks and inputs\n", + "task_1 = DummyTask()\n", + "task_2 = DummyTask2()\n", + "\n", + "task_input_1 = list(range(10))\n", + "task_input_2 = list(range(10, 20))" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "\n", + "The individual tasks can then be submitted to a ThreadPool. \n", + "This is especially useful when there are other things to do while running tasks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "with ThreadPoolExecutor(max_workers=2) as executor:\n", + " task_1_result = executor.submit(task_1.run_concurrently, task_input_1, tracer)\n", + " task_2_result = executor.submit(task_2.run_concurrently, task_input_2, tracer)\n", + " # ...other important code here\n", + " print(\"Task 1 result:\", task_1_result.result())\n", + " print(\"Task 2 result:\", task_2_result.result())" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "`ThreadPool` can easily be used via the function `.map`. This processes a list of jobs in order and outputs the results once all jobs are done. \n", + "This is especially useful if there are many diverse jobs that take a varying amount of time. \n", + "However, since `map` only takes a single parameter, the input has to be bundled into a list of tuples beforehand." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "jobs = list(zip(repeat(task_1), task_input_1)) + list(zip(repeat(task_2), task_input_2))\n", + "\n", + "with ThreadPoolExecutor(max_workers=20) as executor:\n", + " result = list(executor.map(lambda job: job[0].run(job[1], tracer), jobs))\n", + " print(\"Task 1 result:\", result[: len(task_input_1)])\n", + " print(\"Task 2 result:\", result[len(task_input_1) :])" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "`ThreadPool.map` can also be used with `Task.run_concurrently()` in which case the creation of the jobs becomes slightly easier." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "with ThreadPoolExecutor(max_workers=2) as executor:\n", + " results = list(\n", + " executor.map(\n", + " lambda job: job[0].run_concurrently(job[1], tracer),\n", + " [(task_1, task_input_1), (task_2, task_input_2)],\n", + " )\n", + " )\n", + " print(\"Task 1 result:\", result[: len(task_input_1)])\n", + " print(\"Task 2 result:\", result[len(task_input_1) :])" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "
\n", + "Note\n", + "
\n", + "\n", + "If tasks are CPU bound, the abovementioned code will not help. In that case, replace the `ThreadPoolExecutor` with a `ProcessPoolExecutor`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/documentation/qa.ipynb b/src/documentation/qa.ipynb new file mode 100644 index 000000000..37955bc52 --- /dev/null +++ b/src/documentation/qa.ipynb @@ -0,0 +1,428 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dotenv import load_dotenv\n", + "from IPython.display import Pretty\n", + "\n", + "from intelligence_layer.core import (\n", + " DetectLanguage,\n", + " DetectLanguageInput,\n", + " InMemoryTracer,\n", + " Language,\n", + " LuminousControlModel,\n", + " NoOpTracer,\n", + ")\n", + "from intelligence_layer.examples import (\n", + " LongContextQa,\n", + " LongContextQaInput,\n", + " MultipleChunkQa,\n", + " MultipleChunkQaInput,\n", + " SingleChunkQa,\n", + " SingleChunkQaInput,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Question and Answer\n", + "\n", + "A common use case for using large language models is to generate answers to questions based on a given piece of text.\n", + "\n", + "
\n", + "\n", + "This notebook we will be focusing on the open-book Q&A use case, where we provide the model with a piece of text we think is relevant to the question and ask the model to answer the question based on the given text.\n", + "To make proper use of the classification task, it is necessary to evaluate the results in an iterative way, to ensure it satisfies your requirements.\n", + "For an example of how such an evaluation can look like, refer to [evaluation.ipynb](./evaluation.ipynb).\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's grab a piece of text we want to ask a question about. We can start with a random Wikipedia article about [\"Surface micromachining\"](https://en.wikipedia.org/wiki/Surface_micromachining)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text = \"\"\"Surface micromachining\n", + "\n", + "Surface micromachining builds microstructures by deposition and etching structural layers over a substrate.[1] This is different from Bulk micromachining, in which a silicon substrate wafer is selectively etched to produce structures.\n", + "\n", + "Layers\n", + "\n", + "Generally, polysilicon is used as one of the substrate layers while silicon dioxide is used as a sacrificial layer. The sacrificial layer is removed or etched out to create any necessary void in the thickness direction. Added layers tend to vary in size from 2-5 micrometres. The main advantage of this machining process is the ability to build electronic and mechanical components (functions) on the same substrate. Surface micro-machined components are smaller compared to their bulk micro-machined counterparts.\n", + "\n", + "As the structures are built on top of the substrate and not inside it, the substrate's properties are not as important as in bulk micro-machining. Expensive silicon wafers can be replaced by cheaper substrates, such as glass or plastic. The size of the substrates may be larger than a silicon wafer, and surface micro-machining is used to produce thin-film transistors on large area glass substrates for flat panel displays. This technology can also be used for the manufacture of thin film solar cells, which can be deposited on glass, polyethylene terepthalate substrates or other non-rigid materials.\n", + "\n", + "Fabrication process\n", + "\n", + "Micro-machining starts with a silicon wafer or other substrate upon which new layers are grown. These layers are selectively etched by photo-lithography; either a wet etch involving an acid, or a dry etch involving an ionized gas (or plasma). Dry etching can combine chemical etching with physical etching or ion bombardment. Surface micro-machining involves as many layers as are needed with a different mask (producing a different pattern) on each layer. Modern integrated circuit fabrication uses this technique and can use as many as 100 layers. Micro-machining is a younger technology and usually uses no more than 5 or 6 layers. Surface micro-machining uses developed technology (although sometimes not enough for demanding applications) which is easily repeatable for volume production.\"\"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can use the `SingleChunkQa`-task to answer questions about this text!\n", + "This particular task is optimized for shorter texts that fit into the model's context window.\n", + "The main things of interest are that you can provide a `QaInput`, which consists of a `question` you want to ask and a `text` to base that answer upon.\n", + "\n", + "The output will be a `QaOutput`, which will include an `answer` (if it can find one in the text) and `highlights` which mark the most relevant sections of the input text for the generated answer.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Define some question you want to ask about the input text\n", + "question = \"What are some benefits of surface micro-machining?\"\n", + "\n", + "# Pass both the input text and the question to the SingleChunkQaInput-task\n", + "input = SingleChunkQaInput(chunk=text, question=question)\n", + "\n", + "# Define a LuminousControlModel and instantiate a SingleChunkQa task\n", + "model = LuminousControlModel(name=\"luminous-supreme-control\")\n", + "single_chunk_qa = SingleChunkQa(model=model)\n", + "\n", + "output = single_chunk_qa.run(input, NoOpTracer())\n", + "\n", + "Pretty(output.answer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Nice, we extracted some advantages!\n", + "\n", + "If you want to investigate based on which part of the input text the answer was produced, you can use the `highlights` property of the `SingleChunkQaOutput`.\n", + "Under the hood, is uses the explainability feature of the Aleph Alpha inference stack. Each highlight in the `highlights` list contains the start and end courser position of the relevant text section and a score indicating its degree of relevance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "output.highlights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " f\"Highlight 1 (Score {round(output.highlights[0].score,2)}): {text[output.highlights[0].start:output.highlights[0].end]}\"\n", + ")\n", + "print(\n", + " f\"Highlight 2 (Score {round(output.highlights[1].score,2)}): {text[output.highlights[1].start:output.highlights[1].end]}\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "What if we ask a question that cannot be answered on the basis of the text?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "question = \"Who plays Gandalf in LOTR?\"\n", + "input = SingleChunkQaInput(chunk=text, question=question)\n", + "output = single_chunk_qa.run(input, NoOpTracer())\n", + "\n", + "output" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can see, our QA-task returns *None*, because there is no answer to our question within the source document.\n", + "\n", + "Thus, we prevented a potential hallucination." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Language detection\n", + "\n", + "You can also ask questions about documents in languages other than English.\n", + "Our models support 5 European languages:\n", + "- English - 'en'\n", + "- German - 'de'\n", + "- Spanish - 'es'\n", + "- French - 'fr'\n", + "- Italian - 'it'\n", + " \n", + "We provide you with some tools making it easier to detect the language in the document." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "document_with_unknown_language = \"\"\"Rom begann ab dem 5. Jahrhundert v. Chr. mit einer immer rascheren Expansion in Mittelitalien (Eroberung von Veji 396 v. Chr.), musste dabei aber auch schwere Rückschläge verkraften. Der „Galliersturm“ unter Brennus hinterließ psychologisch tiefe Spuren, wobei die Schlacht an der Allia am 18. Juli (wahrscheinlich) 387 v. Chr. als „dies ater“ („schwarzer Tag“) in die Geschichte Roms einging. Es folgten die Samnitenkriege (343–341 v. Chr.; 326–304 v. Chr.; 298–290 v. Chr.) und der Latinerkrieg (um 340–338 v. Chr.). Rom schuf schließlich ein weitverzweigtes Bündnisgeflecht. So wurden an strategisch wichtigen Orten Kolonien angelegt und Bündnisse mit mehreren italischen Stämmen geschlossen, die jedoch nicht das römische Bürgerrecht erhielten.\n", + "\n", + "Aus dieser Zeit seiner Geschichte ging Rom als straffes Staatswesen mit schlagkräftiger Armee und starkem Drang zur Ausdehnung hervor. Damit waren die Grundlagen für seinen weiteren Aufstieg geschaffen. Konkurrierende Mächte stellten auf der Italischen Halbinsel die Stadtstaaten der Etrusker nördlich von Rom, die Kelten in der Po-Ebene und die griechischen Kolonien in Süditalien dar.\n", + "\n", + "Im 3. Jahrhundert v. Chr. setzte sich Rom gegen die Samniten und andere italische Stämme durch. Nach und nach fiel die gesamte Halbinsel an Rom (außer Oberitalien, welches erst später annektiert wurde). Im Süden verleibte sich die Republik um 275 v. Chr. die dortigen griechischen Stadtstaaten ein, nachdem es während des Pyrrhischen Krieges gelungen war, den hellenistischen Hegemon Pyrrhos I. von Epiros abzuwehren. Mit dieser Expansion kam Rom allerdings in Konflikt mit der bisher Rom freundlich gesinnten Handelsrepublik Karthago (im heutigen Tunesien), was zu den Punischen Kriegen führte.\"\"\"\n", + "\n", + "lang_detection_input = DetectLanguageInput(\n", + " text=document_with_unknown_language,\n", + " possible_languages=[\n", + " Language(language) for language in [\"en\", \"de\", \"es\", \"fr\", \"it\"]\n", + " ],\n", + ")\n", + "language = DetectLanguage().run(lang_detection_input, NoOpTracer())\n", + "language" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ah, it seems that this document is written in German!\n", + "Let's generate a German answer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "question = \"Wie viele Samnitenkriege gab es & wann fanden sie statt?\"\n", + "\n", + "input = SingleChunkQaInput(\n", + " chunk=document_with_unknown_language,\n", + " question=question,\n", + " language=language.best_fit,\n", + ")\n", + "output = single_chunk_qa.run(input, NoOpTracer())\n", + "\n", + "print(output.answer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multi-chunk QA\n", + "\n", + "Some times you might have multiple texts you want to provide as context for your question. In this case the `MultipleChunkQa`-task might be the better option. The workflow of this task consists of the following steps:\n", + "1. The tasks takes multiple text chunks and a question as input.\n", + "2. It runs the model for each chunk generating an individual answer per chunk.\n", + "3. It generates a final answer based on the combination of the intermediate answers.\n", + "\n", + "Note, that for the `MultipleChunkQa` the combined length of all input chunks is **not** limited by the context window of the model. Thus, `MultipleChunkQa` provides one option to deal with long input texts by splitting them into multiple chunks. However, below in the section [Long context QA](#long-context-qa) we will present a more sophisticated approache on how to handle QA-tasks for long input texts. \n", + "\n", + "Now let's have a look at an example where two chunks lead to different parts of the final answer.\n", + "\n", + "This time, let's also use a proper debug log, so that we can see what happens under the hood!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chunks = [\n", + " 'Around 1997, Goldenvoice was struggling to book concerts against larger companies, and they were unable to offer guarantees as high as their competitors, such as SFX Entertainment. Tollett said, \"We were getting our ass kicked financially. We were losing a lot of bands. And we couldn\\'t compete with the money.\" As a result, the idea of a music festival was conceived, and Tollett began to brainstorm ideas for one with multiple venues. His intent was to book trendy artists who were not necessarily chart successes: \"Maybe if you put a bunch of them together, that might be a magnet for a lot of people.\" While attending the 1997 Glastonbury Festival, Tollett handed out pamphlets to artists and talent managers that featured pictures of the Empire Polo Club and pitched a possible festival there. In contrast to the frequently muddy conditions at Glastonbury caused by rain, he recalled, \"We had this pamphlet... showing sunny Coachella. Everyone was laughing.\"',\n", + " \"Rock am Ring wurde erstmals 1985 veranstaltet und war ursprünglich als ein einmaliges Ereignis geplant. Aufgrund des großen Erfolges mit 75.000 Zuschauern entschloss man sich jedoch, diese Veranstaltung jedes Jahr stattfinden zu lassen. Der Einbruch der Zuschauerzahlen 1988 hatte eine zweijährige Pause zur Folge. 1991 startete das größte deutsche Rockfestival mit einem überarbeiteten Konzept erneut. Ein neues Hauptaugenmerk wurde darauf gelegt, dem Publikum mehr Newcomer vorzustellen. So traten unter anderem die zu diesem Zeitpunkt eher unbekannten INXS oder Alanis Morissette bei Rock am Ring vor großem Publikum auf.\",\n", + "]\n", + "\n", + "question = \"What festival is the text about?\"\n", + "input = MultipleChunkQaInput(chunks=chunks, question=question)\n", + "\n", + "multi_chunk_qa = MultipleChunkQa(merge_answers_model=model)\n", + "tracer = InMemoryTracer()\n", + "output = multi_chunk_qa.run(input, tracer)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(output.answer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how information from each chunk made it into the final combined answer; despite the texts being written in different languages.\n", + "\n", + "Furthermore, it is also possible to examine the inner workings of the QA mechanism by looking at the `tracer`. There, we track the inputs, outputs, and internal states used to calculate the mentioned outputs. You can also take a look at the parameters of the request sent to the API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tracer" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the trace we can see that the `MultipleChunkQa`-task runs the the `SingleChunkQa` twice, once for each chunk and then combines both answeres in a final `Complete`. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Long context QA\n", + "\n", + "Sometimes you want to execute a QA request against a document that is really, really long.\n", + "In such cases, the document will not fit into the context window of the model (e.g., 2048 tokens for Luminous) and not all parts of it will be relevant for the question at hand.\n", + "We will first need to split the document into chunks and use semantic search to find the most relevant chunks (i.e., those most similar to the question).\n", + "Then, we use these chunks to answer the question using the `MultipleChunkQa` functionality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# https://en.wikipedia.org/wiki/Robert_Moses\n", + "long_text = \"\"\"Robert Moses''' (December 18, 1888 – July 29, 1981) was an American [[urban planner]] and public official who worked in the [[New York metropolitan area]] during the early to mid 20th century. Despite never being elected to any office, Moses is regarded as one of the most powerful and influential individuals in the history of New York City and New York State. The grand scale of his infrastructural projects and his philosophy of urban development influenced a generation of engineers, architects, and urban planners across the United States.\n", + "\n", + "Moses held various positions throughout his more than forty-year long career. He at times held up to 12 titles simultaneously, including [[New York City Parks Commissioner]] and chairman of the [[Long Island State Park Commission]].{{Cite web|url=https://www.pbs.org/wnet/need-to-know/environment/the-legacy-of-robert-moses/16018/|title=The legacy of Robert Moses|last=Sarachan|first=Sydney|date=January 17, 2013|website=Need to Know {{!}} PBS|language=en-US|access-date=December 3, 2019}} Having worked closely with New York governor [[Al Smith]] early in his career, Moses became expert in writing laws and navigating and manipulating the inner workings of state government. He created and led numerous semi-autonomous [[Public authority|public authorities]], through which he controlled millions of dollars in revenue and directly issued [[Bond (finance)|bonds]] to fund new ventures with little outside input or oversight.\n", + "\n", + "Moses's projects transformed the New York area and revolutionized the way cities in the U.S. were designed and built. As Long Island State Park Commissioner, Moses oversaw the construction of [[Jones Beach State Park]], the most visited public beach in the United States,{{cite news |url=http://www.longislandexchange.com/jones-beach.html |website=Long Island Exchange |title=Jones Beach |access-date=November 21, 2012 |archive-url=https://web.archive.org/web/20130121130008/http://www.longislandexchange.com/jones-beach.html |archive-date=January 21, 2013 |url-status=dead }} and was the primary architect of the [[Parkways in New York|New York State Parkway System]]. As head of the [[MTA Bridges and Tunnels|Triborough Bridge Authority]], Moses had near-complete control over bridges and tunnels in New York City as well as the tolls collected from them, and built, among others, the [[Robert F. Kennedy Bridge|Triborough Bridge]], the [[Brooklyn–Battery Tunnel]], and the [[Throgs Neck Bridge]], as well as several major highways. These roadways and bridges, alongside [[urban renewal]] efforts that saw the destruction of huge swaths of tenement housing and their replacement with large [[New York City Housing Authority|public housing projects]], transformed the physical fabric of New York and inspired other cities to undertake similar development endeavors.\n", + "\n", + "Moses's reputation declined following the publication of [[Robert Caro]]'s [[Pulitzer Prize]]-winning biography ''[[The Power Broker]]'' (1974), which cast doubt on the purported benefits of many of Moses's projects and further cast Moses as racist. In large part because of ''The Power Broker'', Moses is today considered a controversial figure in the history of New York City.\n", + "\n", + "==Early life and career==\n", + "Moses was born in [[New Haven, Connecticut]], on December 18, 1888, to [[German Jewish]] parents, Bella (Silverman) and Emanuel Moses.{{cite news | url=https://www.nytimes.com/learning/general/onthisday/bday/1218.html | title=Robert Moses, Master Builder, is Dead at 92| newspaper=The New York Times |archive-url=https://web.archive.org/web/20160305003155/https://www.nytimes.com/learning/general/onthisday/bday/1218.html |archive-date=March 5, 2016 |url-status=dead}}{{sfn|Caro|1974|p=25}} He spent the first nine years of his life living at 83 Dwight Street in New Haven, two blocks from [[Yale University]]. In 1897, the Moses family moved to New York City,{{sfn|Caro|1974|pp=29}} where they lived on East 46th Street off Fifth Avenue.{{cite web |url=http://www.newsday.com/community/guide/lihistory/ny-history-hs722a,0,7092161.story |title=The Master Builder |access-date=April 4, 2007 |last=DeWan |first=George |year=2007 |website=Long Island History |publisher=Newsday |archive-url=https://web.archive.org/web/20061211045554/http://www.newsday.com/community/guide/lihistory/ny-history-hs722a%2C0%2C7092161.story |archive-date=December 11, 2006 |url-status=dead }} Moses's father was a successful department store owner and [[real estate]] speculator in New Haven. In order for the family to move to New York City, he sold his real estate holdings and store, then retired.{{sfn|Caro|1974|pp=29}} Moses's mother was active in the [[settlement movement]], with her own love of building. Robert Moses and his brother Paul attended several schools for their elementary and [[secondary education]], including the [[Ethical Culture Fieldston School|Ethical Culture School]], the [[Dwight School]] and the [[Mohegan Lake, New York#Historic places|Mohegan Lake School]], a military academy near [[Peekskill, New York|Peekskill]].{{sfn|Caro|1974|pp=35}}\n", + "\n", + "After graduating from [[Yale College]] (B.A., 1909) and [[Wadham College]], [[Oxford University|Oxford]] (B.A., Jurisprudence, 1911; M.A., 1913), and earning a Ph.D. in [[political science]] from [[Columbia University]] in 1914, Moses became attracted to New York City reform politics.{{Cite web|url=http://c250.columbia.edu/c250_celebrates/remarkable_columbians/robert_moses.html|title = Robert Moses}} A committed [[idealism|idealist]], he developed several plans to rid New York of [[Patronage#Politics|patronage hiring]] practices, including being the lead author of a 1919 proposal to reorganize the New York state government. None went very far, but Moses, due to his intelligence, caught the notice of [[Belle Moskowitz]], a friend and trusted advisor to Governor [[Al Smith]].{{sfn|Caro|1974}} When the state [[Secretary of State of New York|Secretary of State's]] position became appointive rather than elective, Smith named Moses. He served from 1927 to 1929.{{cite news |date=December 19, 1928 |title=Moses Resigns State Position |url=http://cdsun.library.cornell.edu/cgi-bin/cornell?a=d&d=CDS19281219.2.63.7# |newspaper=Cornell Daily Sun |location=Ithaca, NY |page=8}}\n", + "\n", + "Moses rose to power with Smith, who was elected as governor in 1918, and then again in 1922. With Smith's support, Moses set in motion a sweeping consolidation of the New York State government. During that period Moses began his first foray into large-scale public work initiatives, while drawing on Smith's political power to enact legislation. This helped create the new [[Long Island State Park Commission]] and the State Council of Parks.{{cite web|last=Gutfreund|first=Owen|title=Moses, Robert|url=http://www.anb.org/articles/07/07-00375.html|publisher=Anb.org|access-date=December 24, 2014}} In 1924, Governor Smith appointed Moses chairman of the State Council of Parks and president of the Long Island State Park Commission.{{Cite book|title=Encyclopedia of the City|url=https://archive.org/details/encyclopediacity00cave|url-access=limited|last=Caves|first=R. W.|publisher=Routledge|year=2004|isbn=978-0-415-25225-6|pages=[https://archive.org/details/encyclopediacity00cave/page/n512 472]}} This centralization allowed Smith to run a government later used as a model for Franklin D. Roosevelt's [[New Deal]] federal government.{{or|date=October 2022}} Moses also received numerous commissions that he carried out efficiently, such as the development of [[Jones Beach State Park]].{{cn|date=October 2022}} Displaying a strong command of [[law]] as well as matters of [[engineering]], Moses became known for his skill in drafting legislation, and was called \"the best bill drafter in [[Albany, New York|Albany]]\".{{cite news |title=Annals of Power |first=Robert A. |last=Caro |author-link=Robert Caro |url=http://archives.newyorker.com/?i=1974-07-22#folio=032 |magazine=[[The New Yorker]] |date=July 22, 1974 |access-date=September 1, 2011}} At a time when the public was accustomed to [[Tammany Hall]] corruption and incompetence, Moses was seen as a savior of government.{{sfn|Caro|1974}}\n", + "\n", + "Shortly after [[President of the United States|President]] [[Franklin Delano Roosevelt|Franklin D. Roosevelt's]] [[First inauguration of Franklin D. Roosevelt|inauguration]] in 1933, the [[United States federal government|federal government]] found itself with millions of [[New Deal]] dollars to spend, yet states and cities had few projects ready. Moses was one of the few local officials who had projects [[shovel ready]]. For that reason, New York City was able to obtain significant [[Works Progress Administration]] (WPA), [[Civilian Conservation Corps]] (CCC), and other Depression-era funding. One of his most influential and longest-lasting positions was that of Parks Commissioner of New York City, a role he served from January 18, 1934, to May 23, 1960.{{Cite web|url=https://www.nycgovparks.org/about/history/commissioners|title=New York City Parks Commissioners : NYC Parks|website=www.nycgovparks.org|language=en|access-date=March 29, 2018}}\n", + "\n", + "==Offices held==\n", + "The many offices and professional titles that Moses held gave him unusually broad power to shape urban development in the New York metropolitan region. These include, according to the New York Preservation Archive Project:{{Cite web|url=http://www.nypap.org/preservation-history/robert-moses/|title=Robert Moses {{!}}|website=www.nypap.org|language=en-US|access-date=March 29, 2018}}\n", + "*[[Long Island State Park Commission]] (President, 1924–1963)\n", + "* New York State Council of Parks (Chairman, 1924–1963)\n", + "*[[Secretary of State of New York|New York Secretary of State]] (1927–1928)\n", + "* Bethpage State Park Authority (President, 1933–1963)\n", + "* Emergency Public Works Commission (Chairman, 1933–1934)\n", + "* Jones Beach Parkway Authority (President, 1933–1963)\n", + "*[[New York City Department of Parks and Recreation|New York City Department of Parks]] (Commissioner, 1934–1960)\n", + "* [[Triborough Bridge]] and Tunnel Authority (Chairman, 1934–1968)\n", + "* New York City Planning Commission (Commissioner, 1942–1960)\n", + "* New York State Power Authority (Chairman, 1954–1962)\n", + "* [[1964 New York World's Fair|New York's World Fair]] (President, 1960–1966)\n", + "* Office of the Governor of New York (Special Advisor on Housing, 1974–1975)\n", + "\n", + "==Influence==\n", + "During the 1920s, Moses sparred with [[Franklin D. Roosevelt]], then head of the Taconic State Park Commission, who favored the prompt construction of a [[parkway]] through the [[Hudson Valley]]. Moses succeeded in diverting funds to his Long Island parkway projects (the [[Northern State Parkway]], the [[Southern State Parkway]] and the [[Wantagh State Parkway]]), although the [[Taconic State Parkway]] was later completed as well.{{cite web|url=http://www.nycroads.com/roads/taconic/ |title=Taconic State Parkway |website=NYCRoads.com |access-date=May 25, 2006}} Moses helped build Long Island's [[Meadowbrook State Parkway]]. It was the first fully divided limited access highway in the world.{{cite book|last=Leonard|first=Wallock|title=The Myth of The Master Builder|year=1991|publisher=Journal of Urban History|page=339}}\n", + "\n", + "Moses was a highly influential figure in the initiation of many of the reforms that restructured New York state's government during the 1920s. A 'Reconstruction Commission' headed by Moses produced a highly influential report that provided recommendations that would largely be adopted, including the consolidation of 187 existing agencies under 18 departments, a new executive budget system, and the four-year term limit for the governorship.{{sfn|Caro|1974|pp=106, 260}}\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "question = \"What is the name of the book about Robert Moses?\"\n", + "input = LongContextQaInput(text=long_text, question=question)\n", + "\n", + "long_context_qa = LongContextQa()\n", + "tracer = InMemoryTracer()\n", + "output = long_context_qa.run(input, tracer=tracer)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(output.answer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's have a look at the source chunk for this answer!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " \"Number of subanswers that contributed to the final answer:\", len(output.subanswers)\n", + ")\n", + "print(\"Subanswer:\", output.subanswers[0].answer)\n", + "print(\"Chunk:\", output.subanswers[0].chunk)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/quickstart_task.ipynb b/src/documentation/quickstart_task.ipynb new file mode 100644 index 000000000..c8f75aacf --- /dev/null +++ b/src/documentation/quickstart_task.ipynb @@ -0,0 +1,603 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from statistics import mean\n", + "from typing import Iterable\n", + "\n", + "from aleph_alpha_client import Prompt\n", + "from dotenv import load_dotenv\n", + "from pydantic import BaseModel\n", + "\n", + "from intelligence_layer.core import (\n", + " AlephAlphaModel,\n", + " CompleteInput,\n", + " InMemoryTracer,\n", + " NoOpTracer,\n", + " Task,\n", + " TaskSpan,\n", + ")\n", + "from intelligence_layer.evaluation import (\n", + " AggregationLogic,\n", + " Aggregator,\n", + " Evaluator,\n", + " Example,\n", + " InMemoryAggregationRepository,\n", + " InMemoryDatasetRepository,\n", + " InMemoryEvaluationRepository,\n", + " InMemoryRunRepository,\n", + " Runner,\n", + " SingleOutputEvaluationLogic,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Setting up your own custom task\n", + "\n", + "If the available task methodologies are not suitable for your use case, this guide explains how to set up your own task from scratch.\n", + "Using the task interface gives us the added benefit of getting built-in input and output logging and gives us the possibility of using the evaluation framework.\n", + "\n", + "For the purpose of this tutorial, we will set up a simple keyword extraction task.\n", + "To do so, we will leverage `luminous-base` and a few-shot prompt to generate matching keywords for variable input texts.\n", + "Next, we will build an evaluator to check how well our extractor performs.\n", + "\n", + "## Initial task setup\n", + "\n", + "Let's start with the interface of any generic task. The full `Task` interface can be found here: [../intelligence_layer/task.py](../intelligence_layer/task.py).\n", + "However, to initially set up a `Task`, there are only a few parts relevant to us. For now, we shall only care about the following part of the interface:\n", + "\n", + "```python\n", + "Input = TypeVar(\"Input\", bound=PydanticSerializable)\n", + "Output = TypeVar(\"Output\", bound=PydanticSerializable)\n", + "\n", + "class Task(ABC, Generic[Input, Output]):\n", + " @abstractmethod\n", + " def do_run(self, input: Input, task_span: TaskSpan) -> Output:\n", + " \"\"\"Executes the process for this use-case.\"\"\"\n", + " ...\n", + "```\n", + "\n", + "For every task, we have to define an `Input`, an `Output` and how we would like to run it. Since these can vary so much, we make no assumptions about a `Task`'s implementation. \n", + "We only require both input and output to be `PydanticSerializable`. The best way to guarantee this is to make them pydantic `BaseModel`s. For our keyword extraction task, we will define `Input` and `Output` as follows:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class KeywordExtractionInput(BaseModel):\n", + " \"\"\"This is the text we will extract keywords from\"\"\"\n", + "\n", + " text: str\n", + "\n", + "\n", + "class KeywordExtractionOutput(BaseModel):\n", + " \"\"\"The matching set of keywords we aim to extract\"\"\"\n", + "\n", + " keywords: frozenset[str]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we have our input and output defined, we will implement the actual task.\n", + "\n", + "The steps that the task consists of are:\n", + "- Create a `Prompt` using the input text.\n", + "- Have `luminous-base` complete the prompt.\n", + "- Extract keywords from said completion.\n", + "\n", + "When a task is executed, we offer the possibility to log all intermediate steps and outputs.\n", + "This is crucial because large language models are inherently probabilistic.\n", + "Therefore, we might get unexpected answers.\n", + "This logging allows us to check the results afterwards and find out what went wrong.\n", + "\n", + "For this, we shall inject an `InMemoryTracer` into the task. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class KeywordExtractionTask(Task[KeywordExtractionInput, KeywordExtractionOutput]):\n", + " PROMPT_TEMPLATE: str = \"\"\"Identify matching keywords for each text.\n", + "###\n", + "Text: The \"Whiskey War\" is an ongoing conflict between Denmark and Canada over ownership of Hans Island. The dispute began in 1973, when Denmark and Canada reached an agreement on Greenland's borders. However, no settlement regarding Hans Island could be reached by the time the treaty was signed. Since then both countries have used peaceful means - such as planting their national flag or burying liquor - to draw attention to the disagreement.\n", + "Keywords: Conflict, Whiskey War, Denmark, Canada, Treaty, Flag, Liquor\n", + "###\n", + "Text: I really like pizza and sushi.\n", + "Keywords: Pizza, Sushi\n", + "###\n", + "Text: NASA launched the Discovery program to explore the solar system. It comprises a series of expeditions that have continued from the program's launch in the 1990s to the present day. In the course of the 16 expeditions launched so far, the Moon, Mars, Mercury and Venus, among others, have been explored. Unlike other space programs, the Discovery program places particular emphasis on cost efficiency, true to the motto: \"faster, better, cheaper\".\n", + "Keywords: Space program, NASA, Expedition, Cost efficiency, Moon, Mars, Mercury, Venus\n", + "###\n", + "Text: {text}\n", + "Keywords:\"\"\"\n", + " MODEL: str = \"luminous-base\"\n", + "\n", + " def __init__(\n", + " self, model: AlephAlphaModel = AlephAlphaModel(name=\"luminous-base\")\n", + " ) -> None:\n", + " super().__init__()\n", + " self._model = model\n", + "\n", + " def _create_complete_input(self, text: str) -> Prompt:\n", + " prompt = Prompt.from_text(self.PROMPT_TEMPLATE.format(text=text))\n", + " # 'stop_sequences' is a list of strings which will stop generation if they're generated.\n", + " model_input = CompleteInput(\n", + " prompt=prompt,\n", + " stop_sequences=[\"\\n\", \"###\"],\n", + " frequency_penalty=0.25,\n", + " model=self._model.name,\n", + " )\n", + " return model_input\n", + "\n", + " def do_run(\n", + " self, input: KeywordExtractionInput, task_span: TaskSpan\n", + " ) -> KeywordExtractionOutput:\n", + " completion_input = self._create_complete_input(input.text)\n", + " completion = self._model.complete(completion_input, task_span)\n", + " return KeywordExtractionOutput(\n", + " keywords=set(\n", + " k.strip().lower() for k in completion.completion.split(\",\") if k.strip()\n", + " )\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, we can run this `KeywordExtractionTask` like so:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "task = KeywordExtractionTask()\n", + "text = \"Computer vision describes the processing of an image by a machine using external devices (e.g., a scanner) into a digital description of that image for further processing. An example of this is optical character recognition (OCR), the recognition and processing of images containing text. Further processing and final classification of the image is often done using artificial intelligence methods. The goal of this field is to enable computers to process visual tasks that were previously reserved for humans.\"\n", + "\n", + "tracer = InMemoryTracer()\n", + "output = task.run(KeywordExtractionInput(text=text), tracer)\n", + "\n", + "print(output)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looks great!\n", + "\n", + "## Evaluation\n", + "\n", + "Now that our task is set up, we can start evaluating its performance.\n", + "\n", + "For this, we will have to set up an evaluator. The evaluator requires an `EvaluationLogic` and an `AggregationLogic` object. \n", + "The logic objects are responsible for how single examples are evaluated and how a list of examples are aggregated. \n", + "How these single examples are put together is the job of the `Evaluator`. This typically does not need to be changed and can just be used.\n", + "\n", + "```python\n", + "class EvaluationLogic(ABC, Generic[Input, Output, ExpectedOutput, Evaluation]):\n", + " @abstractmethod\n", + " def do_evaluate(\n", + " self,\n", + " example: Example[Input, ExpectedOutput],\n", + " *output: SuccessfulExampleOutput[Output],\n", + " ) -> Evaluation:\n", + " ...\n", + "\n", + "class AggregationLogic(ABC, Generic[Evaluation, AggregatedEvaluation]):\n", + " @abstractmethod\n", + " def aggregate(self, evaluations: Iterable[Evaluation]) -> AggregatedEvaluation:\n", + " ...\n", + "```\n", + "\n", + "Notice that, just like our `Task`, the `EvaluationLogic` takes an `Input`. This input is the same as our task input.\n", + "However, we don't just want to run a task; we also want to evaluate the result. \n", + "Therefore, our evaluation logic also depends on some `ExpectedOutput`, as well as `Evaluation`.\n", + "We will come back to the `AggregatedEvaluation` of the `AggregationLogic` at a later stage.\n", + "\n", + "Let's build an evaluation that can check the performance of our keyword extraction methodology. For this, we need four things:\n", + "- An implementation of the task to be run (we suggest supplying this in the `Evaluator`'s `__init__`)\n", + "- An interface for our `ExpectedOutput`\n", + "- Some `Evaluation`, i.e., the output of the `do_evaluate` method\n", + "- An implementation of the `do_evaluate` function in form of an `EvaluationLogic`.\n", + "\n", + "In our case, we will measure the performance of our keyword extraction by calculating the proportion of correctly generated keywords compared to all expected keywords. \n", + "This is also known as the \"true positive rate\". \n", + "To calculate this, our evaluate function will need a set of the expected keywords.\n", + "Also, we will add the missing keywords and keywords that are generated that we don't expect. \n", + "This way, we can see how our task performs for a specific example, and we can check for unexpected results.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class KeywordExtractionExpectedOutput(BaseModel):\n", + " \"\"\"This is the expected output for an example run. This is used to compare the output of the task with.\n", + "\n", + " We will be evaluating our keyword extraction based on the expected keywords.\"\"\"\n", + "\n", + " keywords: frozenset[str]\n", + "\n", + "\n", + "class KeywordExtractionEvaluation(BaseModel):\n", + " \"\"\"This is the interface for the metrics that are generated for each evaluation case\"\"\"\n", + "\n", + " true_positive_rate: float\n", + " true_positives: frozenset[str]\n", + " false_positives: frozenset[str]\n", + " false_negatives: frozenset[str]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Accordingly, our evaluate function will take a `KeywordExtractionInput`, and run the task with this.\n", + "Next, we shall compare the generated output with the `KeywordExtractionExpectedOutput` to create the `KeywordExtractionEvaluation`.\n", + "\n", + "```python\n", + "def do_evaluate(\n", + " self,\n", + " input: KeywordExtractionInput,\n", + " output: KeywordExtractionOutput,\n", + " expected_output: KeywordExtractionExpectedOutput,\n", + ") -> KeywordExtractionEvaluation:\n", + " true_positives = output.keywords & expected_output.keywords\n", + " false_positives = output.keywords - expected_output.keywords\n", + " false_negatives = expected_output.keywords - output.keywords\n", + " return KeywordExtractionEvaluation(\n", + " true_positive_rate=len(true_positives) / len(expected_output.keywords),\n", + " true_positives=true_positives,\n", + " false_positives=false_positives,\n", + " false_negatives=false_negatives,\n", + " )\n", + "```\n", + "\n", + "However, to quantitatively evaluate the performance of a task, we will need to run many different examples and calculate the metrics for each. \n", + "To do this, we can use the `eval_and_aggregate_runs` function provided by the `Evaluator` base class. This takes a dataset, runs all the examples, and aggregates the metrics generated from the evaluation.\n", + "\n", + "To set this up, we will first need to create an interface for the `AggregatedEvaluation` and implement the `aggregate` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"This is the interface for the aggregated metrics that are generated from running a number of examples\"\"\"\n", + "\n", + "\n", + "class KeywordExtractionAggregatedEvaluation(BaseModel):\n", + " average_true_positive_rate: float" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we have all parts in place, let's run our task which will produce the results for evaluation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_repository = InMemoryDatasetRepository()\n", + "run_repository = InMemoryRunRepository()\n", + "\n", + "runner = Runner(task, dataset_repository, run_repository, \"keyword-extraction\")\n", + "model_input = KeywordExtractionInput(text=\"This is a text about dolphins and sharks.\")\n", + "expected_output = KeywordExtractionExpectedOutput(keywords=[\"dolphins\", \"sharks\"])\n", + "\n", + "single_example_dataset = dataset_repository.create_dataset(\n", + " examples=[Example(input=model_input, expected_output=expected_output)],\n", + " dataset_name=\"quickstart-task-single-example-dataset\",\n", + ").id\n", + "\n", + "run_overview = runner.run_dataset(single_example_dataset, NoOpTracer())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, let's build an evaluator.\n", + "For this, we need to implement a method doing the actual evaluation in a `EvaluationLogic` class." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class KeywordExtractionEvaluationLogic(\n", + " SingleOutputEvaluationLogic[\n", + " KeywordExtractionInput,\n", + " KeywordExtractionOutput,\n", + " KeywordExtractionExpectedOutput,\n", + " KeywordExtractionEvaluation,\n", + " ]\n", + "):\n", + " def do_evaluate_single_output(\n", + " self,\n", + " example: Example[KeywordExtractionInput, KeywordExtractionOutput],\n", + " output: KeywordExtractionExpectedOutput,\n", + " ) -> KeywordExtractionEvaluation:\n", + " true_positives = output.keywords & example.expected_output.keywords\n", + " false_positives = output.keywords - example.expected_output.keywords\n", + " false_negatives = example.expected_output.keywords - output.keywords\n", + " return KeywordExtractionEvaluation(\n", + " true_positive_rate=len(true_positives) / len(output.keywords),\n", + " true_positives=true_positives,\n", + " false_positives=false_positives,\n", + " false_negatives=false_negatives,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And now, we can create an evaluator and run it on our data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "evaluation_repository = InMemoryEvaluationRepository()\n", + "evaluation_logic = KeywordExtractionEvaluationLogic()\n", + "evaluator = Evaluator(\n", + " dataset_repository,\n", + " run_repository,\n", + " evaluation_repository,\n", + " \"keyword-extraction\",\n", + " evaluation_logic,\n", + ")\n", + "\n", + "evaluation_overview = evaluator.evaluate_runs(run_overview.id)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To aggregate the evaluation results, we have to implement a method doing this in an `AggregationLogic` class." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class KeywordExtractionAggregationLogic(\n", + " AggregationLogic[\n", + " KeywordExtractionEvaluation,\n", + " KeywordExtractionAggregatedEvaluation,\n", + " ]\n", + "):\n", + " def aggregate(\n", + " self, evaluations: Iterable[KeywordExtractionEvaluation]\n", + " ) -> KeywordExtractionAggregatedEvaluation:\n", + " evaluation_list = list(evaluations)\n", + " true_positive_rate = (\n", + " mean(evaluation.true_positive_rate for evaluation in evaluation_list)\n", + " if evaluation_list\n", + " else 0\n", + " )\n", + " return KeywordExtractionAggregatedEvaluation(\n", + " average_true_positive_rate=true_positive_rate\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's now create an aggregator and generate evaluation statistics from the previously generated evaluation results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "aggregation_repository = InMemoryAggregationRepository()\n", + "aggregation_logic = KeywordExtractionAggregationLogic()\n", + "aggregator = Aggregator(\n", + " evaluation_repository,\n", + " aggregation_repository,\n", + " \"keyword-extraction\",\n", + " aggregation_logic,\n", + ")\n", + "\n", + "aggregation_overview = aggregator.aggregate_evaluation(evaluation_overview.id)\n", + "\n", + "print(\"Statistics: \", aggregation_overview.statistics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we have implemented all required methods, let's run a dataset with some more examples." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_id = dataset_repository.create_dataset(\n", + " examples=[\n", + " Example(input=model_input, expected_output=expected_output),\n", + " Example(\n", + " input=KeywordExtractionInput(\n", + " text=\"Clinical psychology is an integration of human science, behavioral science, theory, and clinical knowledge for the purpose of understanding, preventing, and relieving psychologically-based distress or dysfunction and to promote subjective well-being and personal development.\"\n", + " ),\n", + " expected_output=KeywordExtractionExpectedOutput(\n", + " keywords={\"clinical psychology\", \"well-being\", \"personal development\"}\n", + " ),\n", + " ),\n", + " Example(\n", + " input=KeywordExtractionInput(\n", + " text=\"Prospect theory is a theory of behavioral economics, judgment and decision making that was developed by Daniel Kahneman and Amos Tversky in 1979.[1] The theory was cited in the decision to award Kahneman the 2002 Nobel Memorial Prize in Economics.[2]Based on results from controlled studies, it describes how individuals assess their loss and gain perspectives in an asymmetric manner (see loss aversion).\"\n", + " ),\n", + " expected_output=KeywordExtractionExpectedOutput(\n", + " keywords={\n", + " \"prospect theory\",\n", + " \"behavioural economics\",\n", + " \"decision making\",\n", + " \"losses and gains\",\n", + " }\n", + " ),\n", + " ),\n", + " ],\n", + " dataset_name=\"human-evaluation-multiple-examples-dataset\",\n", + ").id\n", + "\n", + "run = runner.run_dataset(dataset_id)\n", + "evaluation_overview = evaluator.evaluate_runs(run.id)\n", + "aggregation_overview = aggregator.aggregate_evaluation(evaluation_overview.id)\n", + "\n", + "print(aggregation_overview)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have now run our first evaluation on this tiny dataset.\n", + "Let's take a more detailed look at the debug log of one of the example runs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "examples = list(\n", + " dataset_repository.examples(\n", + " dataset_id, evaluator.input_type(), evaluator.expected_output_type()\n", + " )\n", + ")\n", + "print(examples[1].input.text)\n", + "examples.sort(key=lambda x: x.input.text)\n", + "last_example_result = run_repository.example_trace(\n", + " next(iter(aggregation_overview.run_overviews())).id, examples[1].id\n", + ")\n", + "last_example_result.trace" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's inspect this debug log from top to bottom to try and figure out what happened here.\n", + "\n", + "1. **Input**: This corresponds to the `Input` we supplied for our task. In this case, it's just the text of the provided example.\n", + "\n", + "2. **Completion request**: The request sent to the Aleph Alpha API. Here you can see the formatted prompt.\n", + "\n", + "3. **The output of the `CompletionTask`**: This is the original completion created by the API.\n", + "\n", + "4. **The output of our `KeywordExtractionTask`**: The output of our task. Here this is just a list of stripped, lowercase keywords.\n", + "\n", + "5. **Metrics**: Several metrics generated by our `KeywordExtractionTaskEvaluationLogic`.\n", + "\n", + "Let's have a look at the evaluation results.\n", + "Here, we can see that the model returned \"behavi*o*ral economics\" as a keyword.\n", + "However, in the `false_negatives`, we can see that we did indeed expect this phrase, but with a different spelling: \"behavi*ou*ral economics\".\n", + "Thus, the debug log helped us easily identify this misalignment between our dataset and the model's generation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "last_example_result = evaluation_repository.example_evaluation(\n", + " next(iter(aggregation_overview.evaluation_overviews)).id,\n", + " examples[1].id,\n", + " KeywordExtractionEvaluation,\n", + ")\n", + "print(last_example_result.result)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can see, we predicted \"behavioural economics\" but expected \"behavioral economics\"...\n", + "\n", + "**What does this tell us?**\n", + "\n", + "Why did the British \"ou\" and the American \"o\" go to therapy?\n", + "\n", + "They had behavioural differences!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/summarization.ipynb b/src/documentation/summarization.ipynb new file mode 100644 index 000000000..96f6d59f0 --- /dev/null +++ b/src/documentation/summarization.ipynb @@ -0,0 +1,361 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dotenv import load_dotenv\n", + "\n", + "from intelligence_layer.core import (\n", + " CompleteInput,\n", + " InMemoryTracer,\n", + " Language,\n", + " LuminousControlModel,\n", + " NoOpTracer,\n", + " TextChunk,\n", + ")\n", + "from intelligence_layer.examples import (\n", + " RecursiveSummarize,\n", + " RecursiveSummarizeInput,\n", + " SingleChunkSummarizeInput,\n", + " SteerableLongContextSummarize,\n", + " SteerableSingleChunkSummarize,\n", + ")\n", + "\n", + "load_dotenv()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Summarization\n", + "\n", + "Summarizing and compressing information, whether from a text, a book or freely from previous experience, is something that is inherently useful for many different types of knowledge work.\n", + "Large language models are adept at summarizing due to their sophisticated understanding of language structure, semantics, and context derived from the vast amounts of text they have been trained on.\n", + "\n", + "
\n", + "\n", + "This notebook is designed to showcase a summarization task.\n", + "To make proper use of such a summarization example, it is necessary to evaluate the results in an iterative way, to ensure it satisfies your requirements.\n", + "For an example of how such an evaluation can look like, refer to [evaluation.ipynb](./evaluation.ipynb).\n", + "
\n", + "\n", + "Let's take a Luminous model and try this out!\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# first, we define a `LuminousControlModel`; this model holds much more information than a simple string...\n", + "# it knows how to call the API, how to complete, how to tokenize and more...\n", + "model = LuminousControlModel(\"luminous-base-control\")\n", + "\n", + "summarization_instruction = (\n", + " \"Summarize the main events leading up to the 1969 moon landing.\"\n", + ")\n", + "# different models require different prompting formats\n", + "# our model \"knows\" its prompt format and can thus transform the above instruction into the correct format\n", + "summarization_prompt = model.to_instruct_prompt(instruction=summarization_instruction)\n", + "\n", + "# we want to run a completion on our model, let's build the correct input object\n", + "completion_input = CompleteInput(prompt=summarization_prompt)\n", + "\n", + "# we allow the tracing of subresults, but let's ignore this for now -> NoOpTracer()\n", + "completion_output = model.complete(completion_input, NoOpTracer())\n", + "\n", + "print(completion_output.completion)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In practive, however, we likely want to summarize some text or document.\n", + "Let's try this out by simply including some document in our prompt." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "document = \"\"\"Title: The Historic Journey to the 1969 Moon Landing: A Chronicle\n", + "\n", + "Introduction:\n", + "The Apollo 11 mission, culminating in the first human steps on the moon on July 20, 1969, stands as a pinnacle achievement in human history and a landmark in the annals of space exploration. This document presents a detailed overview of the key events and milestones leading up to this historic moon landing.\n", + "\n", + "Background:\n", + "The roots of the 1969 moon landing trace back to the geopolitical and scientific rivalries of the Cold War era, most notably between the United States of America and the Soviet Union. The launch of Sputnik by the Soviet Union in 1957 marked the beginning of the space race, a competition that would define much of the late 20th century's scientific endeavors.\n", + "\n", + "Formation of NASA and the Space Race:\n", + "In response to Sputnik, the United States created the National Aeronautics and Space Administration (NASA) in 1958. NASA's primary goal was to surpass Soviet space achievements, a challenge underscored by President John F. Kennedy's ambitious declaration in 1961 that America would send a man to the moon and return him safely to Earth before the decade's end.\n", + "\n", + "Project Apollo:\n", + "The Apollo program, initiated in 1961, was the United States' answer to the space race. It followed the earlier Mercury and Gemini programs, which developed the necessary technology and skills for space travel. Apollo's objective was lunar landing, and it underwent a series of unmanned and manned flights to test and perfect the required technology.\n", + "\n", + "Apollo 1 Tragedy:\n", + "The journey to the moon was not without its setbacks. In 1967, a tragic fire during a test for Apollo 1 claimed the lives of astronauts Virgil \"Gus\" Grissom, Edward H. White, and Roger B. Chaffee. This incident led to major design and engineering overhauls in the Apollo program.\n", + "\n", + "Technological and Engineering Feats:\n", + "The success of the Apollo 11 mission relied on the Saturn V rocket, the most powerful rocket ever built. The Lunar Module, known as the Eagle, was another critical component, designed to land on the moon's surface and return to the orbiting Command Module.\n", + "\n", + "The Apollo 11 Mission:\n", + "On July 16, 1969, Apollo 11 was launched from Kennedy Space Center. Astronauts Neil Armstrong, Buzz Aldrin, and Michael Collins embarked on a historic journey. Four days later, on July 20, Armstrong and Aldrin descended to the lunar surface in the Eagle, while Collins remained in lunar orbit.\n", + "\n", + "\"One Small Step for Man\":\n", + "The world watched in awe as Neil Armstrong became the first human to set foot on the moon, uttering the iconic words, \"That's one small step for man, one giant leap for mankind.\" Aldrin joined him shortly after, and together they spent approximately two hours walking on the moon, conducting experiments, and collecting samples.\n", + "\n", + "Conclusion:\n", + "The 1969 moon landing was a moment of unparalleled human achievement. It was not just a victory for the United States, but a triumph for mankind, symbolizing the boundless possibilities of human ingenuity and perseverance. The Apollo 11 mission set a precedent for all future space exploration, leaving an indelible mark on history.\"\"\"\n", + "\n", + "# the instruction from before should still work fine, but let's now include the document\n", + "summarization_prompt = model.to_instruct_prompt(\n", + " instruction=summarization_instruction, input=document\n", + ")\n", + "# we set maximum tokens to make sure that we generate the full, unabridged, summary\n", + "completion_input = CompleteInput(prompt=summarization_prompt, maximum_tokens=256)\n", + "completion_output = model.complete(completion_input, NoOpTracer())\n", + "\n", + "print(completion_output.completion)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Cool, our summary now uses information from the document!\n", + "\n", + "At the core of the Intelligence Layer is the concept of a `Task`; a task could be any process involving an LLM, for example our summarization use-case here. \n", + "By using a summarization-task we can isolate the responsibility for summarizing in said task.\n", + "We can then simply export this task, evaluate it or deploy it into productive settings.\n", + "\n", + "The IL has pre-buiilt `Task`s for summarizing texts. Let's try this out.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# instantiating a `SteerableSingleChunkSummarize` with our model from before\n", + "single_chunk_summarize = SteerableSingleChunkSummarize(model)\n", + "\n", + "# this task needs a matching input;\n", + "single_chunk_summarize_input = SingleChunkSummarizeInput(\n", + " chunk=TextChunk(\n", + " document\n", + " ), # a `TextChunk` is any text that fits into the model's context window\n", + ")\n", + "single_chunk_summarize_output = single_chunk_summarize.run(\n", + " single_chunk_summarize_input, NoOpTracer()\n", + ")\n", + "\n", + "print(single_chunk_summarize_output.summary)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Awesome!\n", + "\n", + "Notice how the output is quite similar but we did not have to provide the instruction. It is in fact embedded in our task.\n", + "\n", + "Let's adjust this instruction to steer the format of our summary. The task is called `SteerableSingleChunkSummarize` after all.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# to do this, we can instantiate a new task instance, this time overwriting the `instruction_configs`\n", + "bullet_point_summarize = SteerableSingleChunkSummarize(\n", + " model, instruction_configs={Language(\"en\"): \"Summarize the text in bullet points.\"}\n", + ")\n", + "# we can run the same input as before, because the interface (input/output) of our `Task` did not change\n", + "bullet_point_summarize_output = bullet_point_summarize.run(\n", + " single_chunk_summarize_input, NoOpTracer()\n", + ")\n", + "\n", + "print(bullet_point_summarize_output.summary)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The beauty of prompting!\n", + "\n", + "We successfully summarized a short document.\n", + "Most often, however, we will encounter longer texts; they may not fit into the model's context window in one go.\n", + "\n", + "Luckily, we provide means to handle this. You could, for example, instantiate a `SteerableLongContextSummarize`-task.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# again, we can simply insert our model from before\n", + "long_context_summarize = SteerableLongContextSummarize(model=model)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ok, let's find a longer text to summarize.\n", + "\n", + "Lately, I've been into wooden scyscrapers, so let's hit up Wikipedia." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from intelligence_layer.examples import LongContextSummarizeInput\n", + "\n", + "long_document = \"\"\"Plyscraper\n", + "\n", + "A plyscraper, or timber tower is a skyscraper made (at least partly) of wood. They may alternatively be known as mass timber buildings.\n", + "\n", + "Materials\n", + "There are four main types of engineered wood used for mass timber including cross-laminated timber (CLT), glued laminated timber (glulam), laminated strand lumber (LSL), and laminated veneer lumber (LVL). Of these three wood systems, CLT is the most commonly used.[1]\n", + "\n", + "When other materials, such as concrete or steel, are used in conjunction with engineered wood, these plyscrapers are called “hybrids”. For hybrid buildings, there are some approaches to how different materials can be used including the “Cree’s System” which was developed by Cree Buildings, and the “Finding the Forest Through the Trees\" (FFTT) construction model” developed by Michael Green. Cree's System combines the use of concrete and wood mainly in its hybrid flooring systems. In some instances, concrete can also be used as a core or for the foundation of a building because wood is too light. The FFTT construction model incorporates a wooden core and wooden floor slabs mixed with steel beams to provide ductility to the building.[1][2]\n", + "\n", + "Advantages and disadvantages\n", + "When considering which engineered wood system to use for a plyscraper the individual benefits of each must be compared. CLT has a high fire resistance due to the fire-resistant adhesive used and the surface char layer that forms when it is exposed to fire. The surface char layer protects the interior of the wood from further damage. Glulam is typically used for columns and beams as an alternative to commonly used steel and concrete.[1][3] This is because it has a greater tensile strength-to-weight ratio than steel and can resist compression better than concrete. LVL also has the same strength as concrete.[4] As plyscrapers are made from wood, they sequester carbon during construction and are renewable if the forests that they are sourced from are sustainably managed.[1][3]\n", + "\n", + "Despite these benefits, there are bound to be some drawbacks when using the various engineered woods. Steel overall has a greater strength and durability for the same sized profile when compared to its wood counterpart.[5] Thus, a building made with steel beams would require smaller beams than the same building constructed with wooden beams. Walls and columns in the interior spaces of these plyscrapers can get so thick that the size of said interior space gets heavily reduced. This issue however, does not occur within shorter buildings.\n", + "\n", + "Tallest plyscrapers\n", + "\n", + "Mjøstårnet in Brumunddal, Norway\n", + "Over the years, many plyscrapers have been constructed, each becoming taller than the last. In 2017, the tallest habitable plyscraper was Brock Commons Tallwood House, on the campus of the University of British Columbia near Vancouver, Canada and measured 53 meters tall (174 feet).[6] It was overtaken in 2019, when the Mjøstårnet was built in Brumunddal, Norway. The Mjøstårnet measured 85.4 meters (280 ft).[7] However, in 2022, the title of the tallest habitable plyscraper shifted once again when the Ascent MKE Building, located in Milwaukee, Wisconsin was built measuring 86.56 meters (284 ft).[8]\n", + "\n", + "Future\n", + "The use of mass timber has been popular in Europe for a few years but has started to gain traction in the United States as knowledge of engineered woods has developed and experience with them as construction materials has increased.[9] Plyscrapers are still in their infancy stage, but as we learn more about mass timber and become more proficient using it, many mass-timber buildings are currently being proposed. In fact, they are becoming increasingly popular among contractors and builders due to the ease of construction, as putting together a plyscraper is faster and quieter compared to its steel and concrete counterparts.[8]\n", + "\n", + "Several proposals to create more plyscrapers have been made all over the world. For example, in Tokyo, a plyscraper that is 350 meters (1,150 ft) tall called the W350 Project has been proposed with plans to be complete in 2041.[10] The W350 Project plans to be a hybrid plyscraper using only 10% steel and the remainder engineered wood. In London, research and planning are underway for the Oakwood Tower which is estimated to be 300 meters (980 ft) tall.[11] This would be an 80-story building integrated into the London skyline. In Chicago, there is a concept proposal for a 228-meter-tall (748 ft) building called the River Beech Tower.[2] The team designing this building plans to implement a system called the exterior diagrid system which would take advantage of the natural axial strength of timber allowing for efficient load distribution throughout the whole building.\"\"\"\n", + "\n", + "long_context_summarize_input = LongContextSummarizeInput(\n", + " text=long_document,\n", + ")\n", + "long_context_summarize_output = long_context_summarize.run(\n", + " long_context_summarize_input, NoOpTracer()\n", + ")\n", + "\n", + "# to be able to handle longer text, our `LongContextSummarize`-task chunks the text into smaller pieces under the hood\n", + "# by default, it returns multiple summaries, one per chunk\n", + "print(\n", + " \"\\n\".join(\n", + " partial_summary.summary\n", + " for partial_summary in long_context_summarize_output.partial_summaries\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's try to write a single summary!\n", + "\n", + "We may do so by using yet another task, the `RecursiveSummarize`-task. It will compress a text into smaller summaries until only a single, coherent, summary is left. This approach is not bound by the model's context window.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# notice how tasks are composable\n", + "# we instantiate our recursive summarization strategy with our previous long context strategy\n", + "recursive_summarize = RecursiveSummarize(long_context_summarize)\n", + "\n", + "# running the same input as before\n", + "recursive_summarize_input = RecursiveSummarizeInput(text=long_document, max_tokens=128)\n", + "recursive_summarize_output = recursive_summarize.run(\n", + " recursive_summarize_input, NoOpTracer()\n", + ")\n", + "\n", + "print(recursive_summarize_output.summary)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Cool, this new summary is a combination of the two subsummaries, combining information from different parts of the input text.\n", + "\n", + "Finally, let's have a look at the Intelligence Layer tracing. Any task may be given a tracer when run. This will automatically record what is happening under the hood.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# for our experiment, we use an `InMemoryTracer`\n", + "# you may also use other tracers, for example the `FileTracer` if you want to persist the traces\n", + "tracer = InMemoryTracer()\n", + "traced_recursive_summarize_output = recursive_summarize.run(\n", + " recursive_summarize_input, tracer\n", + ")\n", + "\n", + "# printing the traces\n", + "tracer" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Don't be afraid, I promise this trace makes sense!\n", + "\n", + "Notice how each block is labeled with the task that was run as well as the respective inputs and outputs.\n", + "We can now obtain a better insight in which task is doing what. If we find a mistake, we can double down to figure out what went wrong.\n", + "\n", + "Great! You are now familiar with the basics concepts of a `Model`, a `Task` and summarization using the Intelligence Layer.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/documentation/task_dependencies.drawio.svg b/src/documentation/task_dependencies.drawio.svg new file mode 100644 index 000000000..f153c9b29 --- /dev/null +++ b/src/documentation/task_dependencies.drawio.svg @@ -0,0 +1,4 @@ + + + +
Task
ControlModel
AlephAlphaClientProtocol
diff --git a/src/intelligence_layer/__init__.py b/src/intelligence_layer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/connectors/__init__.py b/src/intelligence_layer/connectors/__init__.py new file mode 100644 index 000000000..ec76d6171 --- /dev/null +++ b/src/intelligence_layer/connectors/__init__.py @@ -0,0 +1,44 @@ +from .argilla.argilla_client import ArgillaClient as ArgillaClient +from .argilla.argilla_client import ArgillaEvaluation as ArgillaEvaluation +from .argilla.argilla_client import DefaultArgillaClient as DefaultArgillaClient +from .argilla.argilla_client import Field as Field +from .argilla.argilla_client import Question as Question +from .argilla.argilla_client import Record as Record +from .argilla.argilla_client import RecordData as RecordData +from .base.json_serializable import JsonSerializable as JsonSerializable +from .document_index.document_index import CollectionPath as CollectionPath +from .document_index.document_index import ConstraintViolation as ConstraintViolation +from .document_index.document_index import DocumentContents as DocumentContents +from .document_index.document_index import DocumentIndexClient as DocumentIndexClient +from .document_index.document_index import DocumentIndexError as DocumentIndexError +from .document_index.document_index import DocumentInfo as DocumentInfo +from .document_index.document_index import DocumentPath as DocumentPath +from .document_index.document_index import DocumentSearchResult as DocumentSearchResult +from .document_index.document_index import ( + ExternalServiceUnavailable as ExternalServiceUnavailable, +) +from .document_index.document_index import IndexConfiguration as IndexConfiguration +from .document_index.document_index import IndexPath as IndexPath +from .document_index.document_index import InternalError as InternalError +from .document_index.document_index import InvalidInput as InvalidInput +from .document_index.document_index import ResourceNotFound as ResourceNotFound +from .document_index.document_index import SearchQuery as SearchQuery +from .limited_concurrency_client import ( + AlephAlphaClientProtocol as AlephAlphaClientProtocol, +) +from .limited_concurrency_client import ( + LimitedConcurrencyClient as LimitedConcurrencyClient, +) +from .retrievers.base_retriever import BaseRetriever as BaseRetriever +from .retrievers.base_retriever import Document as Document +from .retrievers.base_retriever import DocumentChunk as DocumentChunk +from .retrievers.base_retriever import SearchResult as SearchResult +from .retrievers.document_index_retriever import ( + DocumentIndexRetriever as DocumentIndexRetriever, +) +from .retrievers.qdrant_in_memory_retriever import ( + QdrantInMemoryRetriever as QdrantInMemoryRetriever, +) +from .retrievers.qdrant_in_memory_retriever import RetrieverType as RetrieverType + +__all__ = [symbol for symbol in dir() if symbol and symbol[0].isupper()] diff --git a/src/intelligence_layer/connectors/argilla/argilla_client.py b/src/intelligence_layer/connectors/argilla/argilla_client.py new file mode 100644 index 000000000..7ff167d22 --- /dev/null +++ b/src/intelligence_layer/connectors/argilla/argilla_client.py @@ -0,0 +1,516 @@ +import itertools +import os +from abc import ABC, abstractmethod +from http import HTTPStatus +from itertools import chain, count, islice +from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, Union, cast +from uuid import uuid4 + +from pydantic import BaseModel +from pydantic import Field as PydanticField +from requests import HTTPError, Session +from requests.adapters import HTTPAdapter +from requests.structures import CaseInsensitiveDict +from urllib3 import Retry + + +class Field(BaseModel): + """Definition of an Argilla feedback-dataset field. + + Attributes: + name: The name of the field. This is used to reference the field in json-documents + title: The title of the field. This is displayed in the Argilla UI to users that perform the manual evaluations. + + """ + + name: str + title: str + + +class Question(BaseModel): + """ "Definition of an evaluation-question for an Argilla feedback dataset. + + Attributes: + name: The name of the question. This is used to reference the questions in json-documents + title: The title of the field. This is displayed in the Argilla UI to users that perform the manual evaluations. + description: A more verbose description of the question. + This is displayed in the Argilla UI to users that perform the manual evaluations. + options: All integer options to answer this question + """ + + name: str + title: str + description: str + options: Sequence[int] # range: 1-10 + + +class ArgillaEvaluation(BaseModel): + """The evaluation result for a single records in an Argilla feedback-dataset. + + Attributes: + example_id: the id of the example that was evaluated. + record_id: the id of the record that is evaluated. + responses: Maps question-names (:attr:`Question.name` ) to response values. + metadata: Metadata belonging to the evaluation, for example ids of completions. + """ + + example_id: str + record_id: str + responses: Mapping[str, Union[str, int, float, bool]] + metadata: Mapping[str, str] + + +class RecordData(BaseModel): + """Input-data for a Argilla evaluation record. + + This can be used to add a new record to an existing Argilla feedback-dataset. + Once it is added it gets an Argilla provided id and can be retrieved as :class:`Record` + + Attributes: + content: Maps field-names (:attr:`Field.name` ) to string values that can be displayed to the user. + example_id: the id of the corresponding :class:`Example` from a :class:`Dataset`. + metadata: Arbitrary metadata in form of key/value strings that can be attached to a record. + """ + + content: Mapping[str, str] + example_id: str + metadata: Mapping[str, str] = PydanticField(default_factory=dict) + + +class Record(RecordData): + """Represents an Argilla record of an feedback-dataset. + + Just adds the id to a :class:`RecordData` + + Attributes: + id: the Argilla generated id of the record. + """ + + id: str + + +class ArgillaClient(ABC): + """Client interface for accessing an Argilla server. + + Argilla supports human in the loop evaluation. This class defines the API used by + the intelligence layer to create feedback datasets or retrieve evaluation results. + """ + + @abstractmethod + def ensure_dataset_exists( + self, + workspace_id: str, + dataset_name: str, + fields: Sequence[Field], + questions: Sequence[Question], + ) -> str: + """Retrieves an existing dataset or creates and publishes a new feedback dataset in Argilla. + + Args: + workspace_id: the id of the workspace the feedback-dataset should be created in. + The user executing this request must have corresponding permissions for this workspace. + dataset_name: the name of the feedback-dataset to be created. + fields: all fields of this dataset. + questions: all questions for this dataset. + Returns: + The id of the dataset to be retrieved . + """ + ... + + @abstractmethod + def add_record(self, dataset_id: str, record: RecordData) -> None: + """Adds a new record to be evalated to the given dataset. + + Args: + dataset_id: id of the dataset the record is added to + record: contains the actual record data (i.e. content for the dataset's fields) + """ + ... + + @abstractmethod + def evaluations(self, dataset_id: str) -> Iterable[ArgillaEvaluation]: + """Returns all submitted evaluations for the given dataset. + + Args: + dataset_id: the id of the dataset. + Returns: + An `Iterable` over all submitted evaluations for the given dataset. + """ + ... + + @abstractmethod + def split_dataset(self, dataset_id: str, n_splits: int) -> None: + """Adds a new metadata property to the dataset and assigns a split to each record. + + Args: + dataset_id: the id of the dataset + n_splits: the number of splits to create + """ + ... + + +class DefaultArgillaClient(ArgillaClient): + def __init__( + self, + api_url: Optional[str] = None, + api_key: Optional[str] = None, + total_retries: int = 5, + ) -> None: + url = api_url or os.environ.get("ARGILLA_API_URL") + key = api_key or os.environ.get("ARGILLA_API_KEY") + if not (key and url): + raise RuntimeError( + "Environment variables ARGILLA_API_URL and ARGILLA_API_KEY must be defined to connect to an argilla instance" + ) + self.api_url = url + self.api_key = key + retry_strategy = Retry( + total=total_retries, + backoff_factor=0.25, + allowed_methods=["POST", "GET", "PUT"], + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session = Session() + self.session.headers = CaseInsensitiveDict({"X-Argilla-Api-Key": self.api_key}) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + def ensure_workspace_exists(self, workspace_name: str) -> str: + """Retrieves the id of an argilla workspace with specified name or creates a new workspace if necessary + + Args: + workspace_name: the name of the workspace to be retrieved or created. + Returns: + The id of an argilla workspace with the given `workspace_name`. + """ + try: + return cast(str, self._create_workspace(workspace_name)["id"]) + except HTTPError as e: + if e.response.status_code == HTTPStatus.CONFLICT: + workspaces = self._list_workspaces() + return next( + cast(str, item["id"]) + for item in workspaces + if item["name"] == workspace_name + ) + raise e + + def ensure_dataset_exists( + self, + workspace_id: str, + dataset_name: str, + fields: Sequence[Field], + questions: Sequence[Question], + ) -> str: + try: + dataset_id: str = self._create_dataset(dataset_name, workspace_id)["id"] + except HTTPError as e: + if e.response.status_code == HTTPStatus.CONFLICT: + datasets = self._list_datasets(workspace_id) + dataset_id = next( + cast(str, item["id"]) + for item in datasets["items"] + if item["name"] == dataset_name + ) + else: + raise e + + for field in fields: + self._ignore_failure_status( + frozenset([HTTPStatus.CONFLICT]), + lambda: self._create_field(field.name, field.title, dataset_id), + ) + + for question in questions: + self._ignore_failure_status( + frozenset([HTTPStatus.CONFLICT]), + lambda: self._create_question( + question.name, + question.title, + question.description, + question.options, + dataset_id, + ), + ) + self._ignore_failure_status( + frozenset([HTTPStatus.UNPROCESSABLE_ENTITY]), + lambda: self._publish_dataset(dataset_id), + ) + return dataset_id + + def _ignore_failure_status( + self, expected_failure: frozenset[HTTPStatus], f: Callable[[], None] + ) -> None: + try: + f() + except HTTPError as e: + if e.response.status_code not in expected_failure: + raise e + + def add_record(self, dataset_id: str, record: RecordData) -> None: + self._create_record( + record.content, record.metadata, record.example_id, dataset_id + ) + + def evaluations(self, dataset_id: str) -> Iterable[ArgillaEvaluation]: + def to_responses( + json_responses: Sequence[Mapping[str, Any]], + ) -> Mapping[str, int | float | bool | str]: + return { + question_name: json_response["value"] + for json_response in json_responses + for question_name, json_response in json_response["values"].items() + } + + return ( + ArgillaEvaluation( + example_id=json_evaluation["example_id"], + record_id=json_evaluation["id"], + responses=to_responses(json_evaluation["responses"]), + metadata=json_evaluation["metadata"], + ) + for json_evaluation in self._list_records( + dataset_id, + optional_params={ + "response_status": "submitted", + "include": "responses", + }, + ) + ) + + def split_dataset(self, dataset_id: str, n_splits: int) -> None: + self._create_metadata_property(dataset_id, n_splits) + self._add_split_to_records(dataset_id, n_splits) + + def _create_metadata_property(self, dataset_id: str, n_splits: int) -> None: + response = self.session.get( + f"{self.api_url}api/v1/me/datasets/{dataset_id}/metadata-properties" + ) + response.raise_for_status() + existing_split_id = [ + property["id"] + for property in response.json()["items"] + if property["name"] == "split" + ] + if len(existing_split_id) > 0: + self.session.delete( + f"{self.api_url}api/v1/metadata-properties/{existing_split_id[0]}" + ) + + data = { + "id": str(uuid4()), + "name": "split", + "title": "split", + "settings": {"type": "terms", "values": [str(i) for i in range(n_splits)]}, + "visible_for_annotators": True, + } + + response = self.session.post( + f"{self.api_url}api/v1/datasets/{dataset_id}/metadata-properties", + json=data, + ) + response.raise_for_status() + + def _add_split_to_records(self, dataset_id: str, n_splits: int) -> None: + records = self._list_records(dataset_id) + splits = itertools.cycle(range(n_splits)) + records_and_splits = zip(records, splits) + + def chunks( + iterator: Iterable[tuple[Mapping[str, Any], int]], size: int + ) -> Iterable[Iterable[tuple[Mapping[str, Any], int]]]: + for first in iterator: + yield chain([first], islice(iterator, size - 1)) + + for chunk in chunks( + records_and_splits, size=1000 + ): # argilla has a limit of 1000 records per request + data = { + "items": [ + { + "id": record["id"], + "metadata": { + **record["metadata"], + "example_id": record["example_id"], + "split": str(split), + }, + } + for record, split in chunk + ] + } + response = self.session.patch( + f"{self.api_url}api/v1/datasets/{dataset_id}/records", + json=data, + ) + response.raise_for_status() + + def _evaluations(self, dataset_id: str) -> Mapping[str, Any]: + response = self.session.get( + self.api_url + f"api/v1/datasets/{dataset_id}/records", + params={"response_status": "submitted", "include": "responses"}, + ) + response.raise_for_status() + return cast(Mapping[str, Any], response.json()) + + def records(self, dataset_id: str) -> Iterable[Record]: + return ( + Record( + id=json_record["id"], + content=json_record["fields"], + example_id=json_record["example_id"], + metadata=json_record["metadata"], + ) + for json_record in self._list_records(dataset_id) + ) + + def create_evaluation(self, evaluation: ArgillaEvaluation) -> None: + response = self.session.post( + self.api_url + f"api/v1/records/{evaluation.record_id}/responses", + json={ + "status": "submitted", + "values": { + question_name: {"value": response_value} + for question_name, response_value in evaluation.responses.items() + }, + }, + ) + response.raise_for_status() + + def _list_workspaces(self) -> Sequence[Any]: + url = self.api_url + "api/workspaces" + response = self.session.get(url) + response.raise_for_status() + return cast(Sequence[Any], response.json()) + + def _create_workspace(self, workspace_name: str) -> Mapping[str, Any]: + url = self.api_url + "api/workspaces" + data = { + "name": workspace_name, + } + response = self.session.post(url, json=data) + response.raise_for_status() + return cast(Mapping[str, Any], response.json()) + + def _list_datasets(self, workspace_id: str) -> Mapping[str, Any]: + url = self.api_url + f"api/v1/me/datasets?workspace_id={workspace_id}" + response = self.session.get(url) + response.raise_for_status() + return cast(Mapping[str, Any], response.json()) + + def _publish_dataset(self, dataset_id: str) -> None: + url = self.api_url + f"api/v1/datasets/{dataset_id}/publish" + response = self.session.put(url) + response.raise_for_status() + + def _create_dataset( + self, name: str, workspace_id: str, guidelines: str = "No guidelines." + ) -> Mapping[str, Any]: + url = self.api_url + "api/v1/datasets" + data = { + "name": name, + "guidelines": guidelines, + "workspace_id": workspace_id, + "allow_extra_metadata": True, + } + response = self.session.post(url, json=data) + response.raise_for_status() + return cast(Mapping[str, Any], response.json()) + + def _list_fields(self, dataset_id: str) -> Sequence[Any]: + url = self.api_url + f"api/v1/datasets/{dataset_id}/fields" + response = self.session.get(url) + response.raise_for_status() + return cast(Sequence[Any], response.json()) + + def _create_field(self, name: str, title: str, dataset_id: str) -> None: + url = self.api_url + f"api/v1/datasets/{dataset_id}/fields" + data = { + "name": name, + "title": title, + "required": True, + "settings": {"type": "text", "use_markdown": False}, + } + response = self.session.post(url, json=data) + response.raise_for_status() + + def _create_question( + self, + name: str, + title: str, + description: str, + options: Sequence[int], + dataset_id: str, + ) -> None: + url = self.api_url + f"api/v1/datasets/{dataset_id}/questions" + data = { + "name": name, + "title": title, + "description": description, + "required": True, + "settings": { + "type": "rating", + "options": [{"value": option} for option in options], + }, + } + response = self.session.post(url, json=data) + response.raise_for_status() + + def _list_records( + self, + dataset_id: str, + optional_params: Optional[Mapping[str, str]] = None, + page_size: int = 50, + ) -> Iterable[Mapping[str, Any]]: + url = self.api_url + f"api/v1/datasets/{dataset_id}/records" + for offset in count(0, page_size): + params: Mapping[str, str | int] = { + **(optional_params or {}), + "offset": offset, + "limit": page_size, + } + response = self.session.get(url, params=params) + response.raise_for_status() + records = response.json()["items"] + if not records: + break + for record in records: + metadata = record["metadata"] + example_id = record["metadata"]["example_id"] + del metadata["example_id"] + record["example_id"] = example_id + yield from cast(Sequence[Mapping[str, Any]], records) + + def _create_record( + self, + content: Mapping[str, str], + metadata: Mapping[str, str], + example_id: str, + dataset_id: str, + ) -> None: + url = self.api_url + f"api/v1/datasets/{dataset_id}/records" + data = { + "items": [ + { + "fields": content, + "metadata": {**metadata, "example_id": example_id}, + } + ] + } + response = self.session.post(url, json=data) + response.raise_for_status() + + def delete_workspace(self, workspace_id: str) -> None: + for dataset in self._list_datasets(workspace_id)["items"]: + self._delete_dataset(dataset["id"]) + self._delete_workspace(workspace_id) + + def _delete_workspace(self, workspace_id: str) -> None: + url = self.api_url + f"api/v1/workspaces/{workspace_id}" + response = self.session.delete(url) + response.raise_for_status() + + def _delete_dataset(self, dataset_id: str) -> None: + url = self.api_url + f"api/v1/datasets/{dataset_id}" + response = self.session.delete(url) + response.raise_for_status() diff --git a/src/intelligence_layer/connectors/base/json_serializable.py b/src/intelligence_layer/connectors/base/json_serializable.py new file mode 100644 index 000000000..ef25c7422 --- /dev/null +++ b/src/intelligence_layer/connectors/base/json_serializable.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING, Mapping, Sequence + +from typing_extensions import TypeAliasType + +if TYPE_CHECKING: + JsonSerializable = ( + int + | float + | str + | None + | bool + | Sequence["JsonSerializable"] + | Mapping[str, "JsonSerializable"] + ) +else: + JsonSerializable = TypeAliasType( + "JsonSerializable", + int + | float + | str + | None + | bool + | Sequence["JsonSerializable"] + | Mapping[str, "JsonSerializable"], + ) diff --git a/src/intelligence_layer/connectors/document_index/document_index.py b/src/intelligence_layer/connectors/document_index/document_index.py new file mode 100644 index 000000000..a641dc45b --- /dev/null +++ b/src/intelligence_layer/connectors/document_index/document_index.py @@ -0,0 +1,593 @@ +from datetime import datetime +from http import HTTPStatus +from json import dumps +from typing import Annotated, Any, Literal, Mapping, Optional, Sequence + +import requests +from pydantic import BaseModel, Field +from requests import HTTPError + +from intelligence_layer.connectors.base.json_serializable import JsonSerializable + + +class IndexPath(BaseModel, frozen=True): + """Path to an index. + + Args: + namespace: Holds collections. + index: The name of the index, holds a config. + """ + + namespace: str + index: str + + +class IndexConfiguration(BaseModel): + """Configuration of an index. + + Args: + embedding_type: "symmetric" or "asymmetric" embedding type. + chunk_size: The maximum size of the chunks in tokens to be used for the index. + """ + + embedding_type: Literal["symmetric", "asymmetric"] + chunk_size: int + + +class DocumentContents(BaseModel): + """Actual content of a document. + + Note: + Currently only supports text-only documents. + + Args: + contents: List of text items. + metadata: Any metadata that is kept along with the document. This could contain things like + author, creation-data, references to external systems. The content must be serializable using + `json.dumps`. The document-index leaves it unchanged. + """ + + contents: Sequence[str] + metadata: JsonSerializable = None + + @classmethod + def from_text(cls, text: str) -> "DocumentContents": + return cls(contents=[text]) + + @classmethod + def _from_modalities_json( + cls, modalities_json: Mapping[str, Any] + ) -> "DocumentContents": + return cls( + contents=[ + modality["text"] + for modality in modalities_json.get("contents", []) + if modality["modality"] == "text" + ], + metadata=modalities_json.get("metadata"), + ) + + def _to_modalities_json(self) -> Mapping[str, Any]: + return { + "schema_version": "V1", + "contents": [{"modality": "text", "text": c} for c in self.contents], + "metadata": self.metadata, + } + + +class CollectionPath(BaseModel, frozen=True): + """Path to a collection. + + Args: + namespace: Holds collections. + collection: Holds documents. + Unique within a namespace. + """ + + namespace: str + collection: str + + +class DocumentPath(BaseModel, frozen=True): + """Path to a document. + + Args: + collection_path: Path to a collection. + document_name: Points to a document. + Unique within a collection. + """ + + collection_path: CollectionPath + document_name: str + + @classmethod + def from_json(cls, document_path_json: Mapping[str, str]) -> "DocumentPath": + return cls( + collection_path=CollectionPath( + namespace=document_path_json["namespace"], + collection=document_path_json["collection"], + ), + document_name=document_path_json["name"], + ) + + def to_slash_separated_str(self) -> str: + return f"{self.collection_path.namespace}/{self.collection_path.collection}/{self.document_name}" + + @classmethod + def from_slash_separated_str(cls, path: str) -> "DocumentPath": + split = path.split("/") + assert len(split) == 3 + return cls( + collection_path=CollectionPath( + namespace=split[0], + collection=split[1], + ), + document_name=split[2], + ) + + +class DocumentInfo(BaseModel): + """Presents an overview of a document. + + Args: + document_path: Path to a document. + created: When this version of the document was created. + Equivalent to when it was last updated. + version: How many times the document was updated. + """ + + document_path: DocumentPath + created: datetime + version: int + + @classmethod + def from_list_documents_response( + cls, list_documents_response: Mapping[str, Any] + ) -> "DocumentInfo": + return cls( + document_path=DocumentPath.from_json(list_documents_response["path"]), + created=datetime.strptime( + list_documents_response["created_timestamp"], "%Y-%m-%dT%H:%M:%S.%fZ" + ), + version=list_documents_response["version"], + ) + + +class SearchQuery(BaseModel): + """Query to search through a collection with. + + Args: + query: Actual text to be searched with. + max_results: Max number of search results to be retrieved by the query. + Must be larger than 0. + min_score: Min score needed for a search result to be returned. + Must be between 0 and 1. + """ + + query: str + max_results: int = Field(..., ge=0) + min_score: float = Field(..., ge=0.0, le=1.0) + + +class DocumentFilterQueryParams(BaseModel): + """Query to filter documents by + + Args: + max_documents: Maximum number of documents to display. + starts_with: Document title prefix/substring to search by. + """ + + max_documents: Optional[Annotated[int, Field(default=25, ge=0)]] + starts_with: Optional[str] + + +class DocumentTextPosition(BaseModel): + """Position of a document chunk within a document item. + + Args: + item: Which item in the document the chunk belongs to. + start_position: Start index of the chunk within the item. + end_position: End index of the chunk within the item. + """ + + item: int + start_position: int + end_position: int + + +class DocumentSearchResult(BaseModel): + """Result of a search query for one individual section. + + Args: + document_path: Path to the document that the section originates from. + section: Actual section of the document that was found as a match to the query. + score: Actual search score of the section found. + Generally, higher scores correspond to better matches. + Will be between 0 and 1. + """ + + document_path: DocumentPath + section: str + score: float + chunk_position: DocumentTextPosition + + @classmethod + def _from_search_response( + cls, search_response: Mapping[str, Any] + ) -> "DocumentSearchResult": + assert search_response["start"]["item"] == search_response["end"]["item"] + return cls( + document_path=DocumentPath.from_json(search_response["document_path"]), + section=search_response["section"][0]["text"], + score=search_response["score"], + chunk_position=DocumentTextPosition( + item=search_response["start"]["item"], + start_position=search_response["start"]["position"], + end_position=search_response["end"]["position"], + ), + ) + + +class DocumentIndexError(RuntimeError): + """Raised in case of any `DocumentIndexClient`-related errors. + + Attributes: + message: The error message as returned by the Document Index. + status_code: The http error code. + """ + + def __init__(self, message: str, status_code: HTTPStatus) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + + +class ExternalServiceUnavailable(DocumentIndexError): + """Raised in case external service is unavailable when the request is executed.""" + + pass + + +class ResourceNotFound(DocumentIndexError): + """Raised when a resource like a namespace or a document cannot be found. + + Note that this can also mean that the user executing the request does not have + permission to access the resource. + """ + + pass + + +class InvalidInput(DocumentIndexError): + """Raised when the user-input could not be processed as it violates pre-conditions.""" + + pass + + +class ConstraintViolation(DocumentIndexError): + """Raised when the request cannot be processed as it would lead to an inconsistent state.""" + + pass + + +_status_code_to_exception = { + HTTPStatus.SERVICE_UNAVAILABLE: ExternalServiceUnavailable, + HTTPStatus.NOT_FOUND: ResourceNotFound, + HTTPStatus.UNPROCESSABLE_ENTITY: InvalidInput, + HTTPStatus.CONFLICT: ConstraintViolation, +} + + +class InternalError(DocumentIndexError): + """Raised in case of unexpected errors.""" + + pass + + +class DocumentIndexClient: + """Client for the Document Index allowing handling documents and search. + + Document Index is a tool for managing collections of documents, enabling operations such as creation, deletion, listing, and searching. + Documents can be stored either in the cloud or in a local deployment. + + Args: + token: A valid token for the document index API. + base_document_index_url: The url of the document index' API. + + Example: + >>> import os + + >>> from intelligence_layer.connectors import ( + ... CollectionPath, + ... DocumentContents, + ... DocumentIndexClient, + ... DocumentPath, + ... SearchQuery, + ... ) + + >>> document_index = DocumentIndexClient(os.getenv("AA_TOKEN")) + >>> collection_path = CollectionPath( + ... namespace="aleph-alpha", collection="wikipedia-de" + ... ) + >>> document_index.create_collection(collection_path) + >>> document_index.add_document( + ... document_path=DocumentPath( + ... collection_path=collection_path, document_name="Fun facts about Germany" + ... ), + ... contents=DocumentContents.from_text("Germany is a country located in ..."), + ... ) + >>> search_result = document_index.search( + ... collection_path=collection_path, + ... index_name="asymmetric", + ... search_query=SearchQuery( + ... query="What is the capital of Germany", max_results=4, min_score=0.5 + ... ), + ... ) + """ + + def __init__( + self, + token: str | None, + base_document_index_url: str = "https://document-index.aleph-alpha.com", + ) -> None: + self._base_document_index_url = base_document_index_url + self.headers = { + "Content-Type": "application/json", + "Accept": "application/json", + **({"Authorization": f"Bearer {token}"} if token is not None else {}), + } + + def list_namespaces(self) -> Sequence[str]: + """Lists all available namespaces. + + Returns: + List of all available namespaces. + """ + + url = f"{self._base_document_index_url}/namespaces" + response = requests.get(url, headers=self.headers) + self._raise_for_status(response) + return [str(namespace) for namespace in response.json()] + + def create_collection(self, collection_path: CollectionPath) -> None: + """Creates a collection at the path. + + Note: + Collection's name must be unique within a namespace. + + Args: + collection_path: Path to the collection of interest. + """ + + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}" + response = requests.put(url, headers=self.headers) + self._raise_for_status(response) + + def delete_collection(self, collection_path: CollectionPath) -> None: + """Deletes the collection at the path. + + Args: + collection_path: Path to the collection of interest. + """ + + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}" + response = requests.delete(url, headers=self.headers) + self._raise_for_status(response) + + def list_collections(self, namespace: str) -> Sequence[CollectionPath]: + """Lists all collections within a namespace. + + Args: + namespace: For a collection of documents. + Typically corresponds to an organization. + + Returns: + List of all `CollectionPath` instances in the given namespace. + """ + + url = f"{self._base_document_index_url}/collections/{namespace}" + response = requests.get(url, headers=self.headers) + self._raise_for_status(response) + return [ + CollectionPath(namespace=namespace, collection=collection) + for collection in response.json() + ] + + def create_index( + self, index_path: IndexPath, index_configuration: IndexConfiguration + ) -> None: + """Creates an index in a namespace. + + Args: + index_path: Path to the index. + index_configuration: Configuration of the index to be created. + """ + + url = f"{self._base_document_index_url}/indexes/{index_path.namespace}/{index_path.index}" + + data = { + "chunk_size": index_configuration.chunk_size, + "embedding_type": index_configuration.embedding_type, + } + response = requests.put(url, data=dumps(data), headers=self.headers) + self._raise_for_status(response) + + def index_configuration(self, index_path: IndexPath) -> IndexConfiguration: + """Retrieve the configuration of an index in a namespace given its name. + + Args: + index_path: Path to the index. + + Returns: + Configuration of the index. + """ + + url = f"{self._base_document_index_url}/indexes/{index_path.namespace}/{index_path.index}" + response = requests.get(url, headers=self.headers) + self._raise_for_status(response) + response_json: Mapping[str, Any] = response.json() + return IndexConfiguration( + embedding_type=response_json["embedding_type"], + chunk_size=response_json["chunk_size"], + ) + + def assign_index_to_collection( + self, collection_path: CollectionPath, index_name: str + ) -> None: + """Assign an index to a collection. + + Args: + collection_path: Path to the collection of interest. + index_name: Name of the index. + """ + + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}/indexes/{index_name}" + response = requests.put(url, headers=self.headers) + self._raise_for_status(response) + + def delete_index_from_collection( + self, collection_path: CollectionPath, index_name: str + ) -> None: + """Delete an index from a collection. + + Args: + index_name: Name of the index. + collection_path: Path to the collection of interest. + """ + + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}/indexes/{index_name}" + response = requests.delete(url, headers=self.headers) + self._raise_for_status(response) + + def list_assigned_index_names( + self, collection_path: CollectionPath + ) -> Sequence[str]: + """List all indexes assigned to a collection. + + Args: + collection_path: Path to the collection of interest. + + Returns: + List of all indexes that are assigned to the collection. + """ + + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}/indexes" + response = requests.get(url, headers=self.headers) + self._raise_for_status(response) + return [str(index_name) for index_name in response.json()] + + def add_document( + self, + document_path: DocumentPath, + contents: DocumentContents, + ) -> None: + """Add a document to a collection. + + Note: + If a document with the same `document_path` exists, it will be updated with the new `contents`. + + Args: + document_path: Consists of `collection_path` and name of document to be created. + contents: Actual content of the document. + Currently only supports text. + """ + + url = f"{self._base_document_index_url}/collections/{document_path.collection_path.namespace}/{document_path.collection_path.collection}/docs/{document_path.document_name}" + response = requests.put( + url, data=dumps(contents._to_modalities_json()), headers=self.headers + ) + self._raise_for_status(response) + + def delete_document(self, document_path: DocumentPath) -> None: + """Delete a document from a collection. + + Args: + document_path: Consists of `collection_path` and name of document to be deleted. + """ + + url = f"{self._base_document_index_url}/collections/{document_path.collection_path.namespace}/{document_path.collection_path.collection}/docs/{document_path.document_name}" + response = requests.delete(url, headers=self.headers) + self._raise_for_status(response) + + def document(self, document_path: DocumentPath) -> DocumentContents: + """Retrieve a document from a collection. + + Args: + document_path: Consists of `collection_path` and name of document to be retrieved. + + Returns: + Content of the retrieved document. + """ + + url = f"{self._base_document_index_url}/collections/{document_path.collection_path.namespace}/{document_path.collection_path.collection}/docs/{document_path.document_name}" + response = requests.get(url, headers=self.headers) + self._raise_for_status(response) + return DocumentContents._from_modalities_json(response.json()) + + def documents( + self, + collection_path: CollectionPath, + filter_query_params: DocumentFilterQueryParams = DocumentFilterQueryParams( + max_documents=None, starts_with=None + ), + ) -> Sequence[DocumentInfo]: + """List all documents within a collection. + + Note: + Does not return each document's content. + + Args: + collection_path: Path to the collection of interest. + filter_query_params: Query parameters to filter the results. + + Returns: + Overview of all documents within the collection. + """ + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}/docs" + + query_params = {} + if filter_query_params.max_documents: + query_params["max_documents"] = str(filter_query_params.max_documents) + if filter_query_params.starts_with: + query_params["starts_with"] = filter_query_params.starts_with + + response = requests.get(url=url, params=query_params, headers=self.headers) + self._raise_for_status(response) + return [DocumentInfo.from_list_documents_response(r) for r in response.json()] + + def search( + self, + collection_path: CollectionPath, + index_name: str, + search_query: SearchQuery, + ) -> Sequence[DocumentSearchResult]: + """Search through a collection with a `search_query`. + + Args: + collection_path: Path to the collection of interest. + index_name: Name of the index to search with. + search_query: The query to search with. + + Returns: + Result of the search operation. Will be empty if nothing was retrieved. + """ + + url = f"{self._base_document_index_url}/collections/{collection_path.namespace}/{collection_path.collection}/indexes/{index_name}/search" + data = { + "query": [{"modality": "text", "text": search_query.query}], + "max_results": search_query.max_results, + "min_score": search_query.min_score, + "filter": [{"with": [{"modality": "text"}]}], + } + response = requests.post(url, data=dumps(data), headers=self.headers) + self._raise_for_status(response) + return [DocumentSearchResult._from_search_response(r) for r in response.json()] + + def _raise_for_status(self, response: requests.Response) -> None: + try: + response.raise_for_status() + except HTTPError: + exception_factory = _status_code_to_exception.get( + HTTPStatus(response.status_code), InternalError + ) + raise exception_factory(response.text, HTTPStatus(response.status_code)) diff --git a/src/intelligence_layer/connectors/limited_concurrency_client.py b/src/intelligence_layer/connectors/limited_concurrency_client.py new file mode 100644 index 000000000..189269dfa --- /dev/null +++ b/src/intelligence_layer/connectors/limited_concurrency_client.py @@ -0,0 +1,259 @@ +import time +from functools import lru_cache +from os import getenv +from threading import Semaphore +from typing import Any, Callable, Mapping, Optional, Protocol, Sequence, TypeVar + +from aleph_alpha_client import ( + BatchSemanticEmbeddingRequest, + BatchSemanticEmbeddingResponse, + BusyError, + Client, + CompletionRequest, + CompletionResponse, + DetokenizationRequest, + DetokenizationResponse, + EmbeddingRequest, + EmbeddingResponse, + EvaluationRequest, + EvaluationResponse, + ExplanationRequest, + ExplanationResponse, + SemanticEmbeddingRequest, + SemanticEmbeddingResponse, + TokenizationRequest, + TokenizationResponse, +) +from tokenizers import Tokenizer # type: ignore + + +class AlephAlphaClientProtocol(Protocol): + def complete( + self, + request: CompletionRequest, + model: str, + ) -> CompletionResponse: + pass + + def get_version(self) -> str: + pass + + def models(self) -> Sequence[Mapping[str, Any]]: + pass + + def tokenize( + self, + request: TokenizationRequest, + model: str, + ) -> TokenizationResponse: + pass + + def detokenize( + self, + request: DetokenizationRequest, + model: str, + ) -> DetokenizationResponse: + pass + + def embed( + self, + request: EmbeddingRequest, + model: str, + ) -> EmbeddingResponse: + pass + + def semantic_embed( + self, + request: SemanticEmbeddingRequest, + model: str, + ) -> SemanticEmbeddingResponse: + pass + + def batch_semantic_embed( + self, + request: BatchSemanticEmbeddingRequest, + model: Optional[str] = None, + ) -> BatchSemanticEmbeddingResponse: + pass + + def evaluate( + self, + request: EvaluationRequest, + model: str, + ) -> EvaluationResponse: + pass + + def explain( + self, + request: ExplanationRequest, + model: str, + ) -> ExplanationResponse: + pass + + def tokenizer(self, model: str) -> Tokenizer: + pass + + +class LimitedConcurrencyClient: + """An Aleph Alpha Client wrapper that limits the number of concurrent requests. + + This just delegates each call to the wrapped Aleph Alpha Client and ensures that + never more then a given number of concurrent calls are executed against the API. + + Args: + client: The wrapped `Client`. + max_concurrency: the maximal number of requests that may run concurrently + against the API. + max_retry_time: the maximal time in seconds a complete is retried in case a `BusyError` is raised. + + """ + + def __init__( + self, + client: AlephAlphaClientProtocol, + max_concurrency: int = 20, + max_retry_time: int = 24 * 60 * 60, # one day in seconds + ) -> None: + self._client = client + self._concurrency_limit_semaphore = Semaphore(max_concurrency) + self._max_retry_time = max_retry_time + + @classmethod + @lru_cache(maxsize=1) + def from_env( + cls, token: Optional[str] = None, host: Optional[str] = None + ) -> "LimitedConcurrencyClient": + """This is a helper method to construct your client with default settings from a token and host + + Args: + token: An Aleph Alpha token to instantiate the client. If no token is provided, + this method tries to fetch it from the environment under the name of "AA_TOKEN". + host: The host that is used for requests. If no token is provided, + this method tries to fetch it from the environment under the naem of "CLIENT_URL". + If this is not present, it defaults to the Aleph Alpha Api. + If you have an on premise setup, change this to your host URL. + """ + if token is None: + token = getenv("AA_TOKEN") + assert token, "Define environment variable AA_TOKEN with a valid token for the Aleph Alpha API" + if host is None: + host = getenv("CLIENT_URL") + if not host: + host = "https://api.aleph-alpha.com" + print(f"No CLIENT_URL specified in environment, using default: {host}.") + + return cls(Client(token, host=host)) + + T = TypeVar("T") + + def _retry_on_busy_error(self, func: Callable[[], T]) -> T: + retries = 0 + start_time = time.time() + latest_exception = None + while ( + time.time() - start_time < self._max_retry_time or self._max_retry_time < 0 + ): + try: + return func() + except BusyError as e: + latest_exception = e + time.sleep( + min( + 2**retries, + self._max_retry_time - (time.time() - start_time), + ) + ) + retries += 1 + continue + assert latest_exception is not None + raise latest_exception + + def complete( + self, + request: CompletionRequest, + model: str, + ) -> CompletionResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.complete(request, model) + ) + + def get_version(self) -> str: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error(lambda: self._client.get_version()) + + def models(self) -> Sequence[Mapping[str, Any]]: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error(lambda: self._client.models()) + + def tokenize( + self, + request: TokenizationRequest, + model: str, + ) -> TokenizationResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.tokenize(request, model) + ) + + def detokenize( + self, + request: DetokenizationRequest, + model: str, + ) -> DetokenizationResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.detokenize(request, model) + ) + + def embed( + self, + request: EmbeddingRequest, + model: str, + ) -> EmbeddingResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error(lambda: self._client.embed(request, model)) + + def semantic_embed( + self, + request: SemanticEmbeddingRequest, + model: str, + ) -> SemanticEmbeddingResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.semantic_embed(request, model) + ) + + def batch_semantic_embed( + self, + request: BatchSemanticEmbeddingRequest, + model: Optional[str] = None, + ) -> BatchSemanticEmbeddingResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.batch_semantic_embed(request, model) + ) + + def evaluate( + self, + request: EvaluationRequest, + model: str, + ) -> EvaluationResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.evaluate(request, model) + ) + + def explain( + self, + request: ExplanationRequest, + model: str, + ) -> ExplanationResponse: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error( + lambda: self._client.explain(request, model) + ) + + def tokenizer(self, model: str) -> Tokenizer: + with self._concurrency_limit_semaphore: + return self._retry_on_busy_error(lambda: self._client.tokenizer(model)) diff --git a/src/intelligence_layer/connectors/retrievers/__init__.py b/src/intelligence_layer/connectors/retrievers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/connectors/retrievers/base_retriever.py b/src/intelligence_layer/connectors/retrievers/base_retriever.py new file mode 100644 index 000000000..a3db1c02f --- /dev/null +++ b/src/intelligence_layer/connectors/retrievers/base_retriever.py @@ -0,0 +1,69 @@ +from abc import ABC, abstractmethod +from typing import Any, Generic, Optional, Sequence, TypeVar + +from pydantic import BaseModel + + +class Document(BaseModel): + """A document. + + Attributes: + text: The document's text. + metadata: Any metadata added to the document. + """ + + text: str + metadata: Any = None + + +class DocumentChunk(BaseModel): + """Part of a :class:`Document`, specifically for retrieval use cases. + + Attributes: + text: Chunk of the document that matched the search query. + metadata: Any metadata added to the document. + start: Start index of the chunk within the document + end: End index of the chunk within the document + """ + + text: str + start: int + end: int + metadata: Any = None + + +ID = TypeVar("ID") + + +class SearchResult(BaseModel, Generic[ID]): + """Contains a text alongside its search score. + + Attributes: + id: Unique identifier of the document + score: The similarity score between the text and the query that was searched with. + Will be between 0 and 1, where 0 means no similarity and 1 perfect similarity. + document_chunk: The document chunk found by search. + """ + + id: ID + score: float + document_chunk: DocumentChunk + + +class BaseRetriever(ABC, Generic[ID]): + """General interface for any retriever. + + Retrievers are used to find texts given a user query. + Each Retriever implementation owns its own logic for retrieval. + For comparison purposes, we assume scores in the `SearchResult` instances to be between 0 and 1. + """ + + @abstractmethod + def get_relevant_documents_with_scores( + self, query: str + ) -> Sequence[SearchResult[ID]]: + pass + + @abstractmethod + def get_full_document(self, id: ID) -> Optional[Document]: + pass diff --git a/src/intelligence_layer/connectors/retrievers/document_index_retriever.py b/src/intelligence_layer/connectors/retrievers/document_index_retriever.py new file mode 100644 index 000000000..842966863 --- /dev/null +++ b/src/intelligence_layer/connectors/retrievers/document_index_retriever.py @@ -0,0 +1,98 @@ +from typing import Sequence + +from intelligence_layer.connectors.document_index.document_index import ( + CollectionPath, + DocumentIndexClient, + DocumentPath, + DocumentTextPosition, + SearchQuery, +) +from intelligence_layer.connectors.retrievers.base_retriever import ( + BaseRetriever, + Document, + DocumentChunk, + SearchResult, +) + + +class DocumentIndexRetriever(BaseRetriever[DocumentPath]): + """Search through documents within collections in the `DocumentIndexClient`. + + We initialize this Retriever with a collection & namespace names, and we can find the documents in the collection + most semanticly similar to our query. + + Args: + document_index: Client offering functionality for search. + index_name: The name of the index to be used. + namespace: The namespace within the `DocumentIndexClient` where all collections are stored. + collection: The collection within the namespace that holds the desired documents. + k: The (top) number of documents to be returned by search. + threshold: The mimumum value of cosine similarity between the query vector and the document vector. + + Example: + >>> import os + >>> from intelligence_layer.connectors import DocumentIndexClient, DocumentIndexRetriever + >>> document_index = DocumentIndexClient(os.getenv("AA_TOKEN")) + >>> retriever = DocumentIndexRetriever(document_index, "asymmetric", "aleph-alpha", "wikipedia-de", 3) + >>> documents = retriever.get_relevant_documents_with_scores("Who invented the airplane?") + """ + + def __init__( + self, + document_index: DocumentIndexClient, + index_name: str, + namespace: str, + collection: str, + k: int, + threshold: float = 0.5, + ) -> None: + self._document_index = document_index + self._index_name = index_name + self._collection_path = CollectionPath( + namespace=namespace, collection=collection + ) + self._k = k + self._threshold = threshold + + def _get_absolute_position( + self, id: DocumentPath, document_text_position: DocumentTextPosition + ) -> dict[str, int]: + doc = self._document_index.document(id) + + previous_item_length = sum( + len(text) for text in doc.contents[0 : document_text_position.item] + ) + + start = previous_item_length + document_text_position.start_position + end = previous_item_length + document_text_position.end_position + + return {"start": start, "end": end} + + def get_relevant_documents_with_scores( + self, query: str + ) -> Sequence[SearchResult[DocumentPath]]: + search_query = SearchQuery( + query=query, max_results=self._k, min_score=self._threshold + ) + response = self._document_index.search( + self._collection_path, self._index_name, search_query + ) + relevant_chunks = [ + SearchResult( + id=result.document_path, + score=result.score, + document_chunk=DocumentChunk( + text=result.section, + **self._get_absolute_position( + id=result.document_path, + document_text_position=result.chunk_position, + ), + ), + ) + for result in response + ] + return relevant_chunks + + def get_full_document(self, id: DocumentPath) -> Document: + contents = self._document_index.document(id) + return Document(text="\n".join(contents.contents), metadata=contents.metadata) diff --git a/src/intelligence_layer/connectors/retrievers/qdrant_in_memory_retriever.py b/src/intelligence_layer/connectors/retrievers/qdrant_in_memory_retriever.py new file mode 100644 index 000000000..007ed8331 --- /dev/null +++ b/src/intelligence_layer/connectors/retrievers/qdrant_in_memory_retriever.py @@ -0,0 +1,169 @@ +from concurrent.futures import ThreadPoolExecutor +from enum import Enum +from typing import Optional, Sequence + +from aleph_alpha_client import Prompt, SemanticEmbeddingRequest, SemanticRepresentation +from qdrant_client import QdrantClient +from qdrant_client.conversions.common_types import ScoredPoint +from qdrant_client.http.models import Distance, PointStruct, VectorParams, models + +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, + LimitedConcurrencyClient, +) +from intelligence_layer.connectors.retrievers.base_retriever import ( + BaseRetriever, + Document, + DocumentChunk, + SearchResult, +) + + +class RetrieverType(Enum): + """Specify the type of retriever to instantiate. + + Attributes: + ASYMMETRIC: Query is embedded as `Query` and each document as `Document`. + SYMMETRIC: Both query and documents will be embedded as `Symmetric`. + """ + + ASYMMETRIC = (SemanticRepresentation.Query, SemanticRepresentation.Document) + SYMMETRIC = (SemanticRepresentation.Symmetric, SemanticRepresentation.Symmetric) + + +class QdrantInMemoryRetriever(BaseRetriever[int]): + """Search through documents stored in memory using semantic search. + + This retriever uses a [Qdrant](https://github.com/qdrant/qdrant)-in-Memory vector store instance to store documents and their asymmetric embeddings. + When run, the given query is embedded and scored against the document embeddings to retrieve the k-most similar matches by cosine similarity. + + Args: + client: Aleph Alpha client instance for running model related API calls. + texts: The sequence of texts to be made searchable. + k: The (top) number of documents to be returned by search. + threshold: The mimumum value of cosine similarity between the query vector and the document vector. + retriever_type: The type of retriever to be instantiated. + Should be `ASYMMETRIC` for most query-document retrieveal use cases, `SYMMETRIC` is optimized + for similar document retrieval. + distance_metric: The distance metric to be used for vector comparison. + + Example: + >>> from intelligence_layer.connectors import LimitedConcurrencyClient, Document, QdrantInMemoryRetriever + >>> client = LimitedConcurrencyClient.from_env() + >>> documents = [Document(text=t) for t in ["I do not like rain.", "Summer is warm.", "We are so back."]] + >>> retriever = QdrantInMemoryRetriever(documents, 5, client=client) + >>> query = "Do you like summer?" + >>> documents = retriever.get_relevant_documents_with_scores(query) + """ + + MAX_WORKERS = 10 + + def __init__( + self, + documents: Sequence[Document], + k: int, + client: AlephAlphaClientProtocol | None = None, + threshold: float = 0.5, + retriever_type: RetrieverType = RetrieverType.ASYMMETRIC, + distance_metric: Distance = Distance.COSINE, + ) -> None: + self._client = client or LimitedConcurrencyClient.from_env() + self._search_client = QdrantClient(":memory:") + self._collection_name = "in_memory_collection" + self._k = k + self._threshold = threshold + self._query_representation, self._document_representation = retriever_type.value + self._distance_metric = distance_metric + + self._search_client.recreate_collection( + collection_name=self._collection_name, + vectors_config=VectorParams(size=128, distance=self._distance_metric), + ) + self._add_texts_to_memory(documents) + + def get_relevant_documents_with_scores( + self, query: str + ) -> Sequence[SearchResult[int]]: + query_embedding = self._embed(query, self._query_representation) + search_result = self._search_client.search( + collection_name=self._collection_name, + query_vector=query_embedding, + score_threshold=self._threshold, + limit=self._k, + ) + return [self._point_to_search_result(point) for point in search_result] + + def _embed(self, text: str, representation: SemanticRepresentation) -> list[float]: + embedding_request = SemanticEmbeddingRequest( + prompt=Prompt.from_text(text), + representation=representation, + compress_to_size=128, + normalize=True, + ) + return self._client.semantic_embed( + request=embedding_request, model="luminous-base" + ).embedding + + @staticmethod + def _point_to_search_result(point: ScoredPoint) -> SearchResult[int]: + assert point.payload + assert isinstance(point.id, int) + return SearchResult( + id=point.id, + score=point.score, + document_chunk=DocumentChunk(**point.payload), + ) + + def _add_texts_to_memory(self, documents: Sequence[Document]) -> None: + with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor: + embeddings = list( + executor.map( + lambda c: self._embed(c.text, self._document_representation), + documents, + ) + ) + self._search_client.upsert( + collection_name=self._collection_name, + wait=True, + points=[ + PointStruct( + id=idx, + vector=text_embedding, + payload=DocumentChunk( + text=document.text, + start=0, + end=len(document.text) - 1, + metadata=document.metadata, + ).model_dump(), + ) + for idx, (text_embedding, document) in enumerate( + zip(embeddings, documents) + ) + ], + ) + + def get_filtered_documents_with_scores( + self, query: str, filter: models.Filter + ) -> Sequence[SearchResult[int]]: + """Specific method for `InMemoryRetriever` to support filtering search results. + + Args: + query: The text to be searched with. + filter: Conditions to filter by. + """ + query_embedding = self._embed(query, self._query_representation) + search_result = self._search_client.search( + collection_name=self._collection_name, + query_vector=query_embedding, + limit=self._k, + query_filter=filter, + ) + return [self._point_to_search_result(point) for point in search_result] + + def get_full_document(self, id: int) -> Optional[Document]: + records = self._search_client.retrieve(self._collection_name, [id], True, False) + if not records: + return None + record = records.pop() + assert record.payload + return Document(**record.payload) diff --git a/src/intelligence_layer/core/__init__.py b/src/intelligence_layer/core/__init__.py new file mode 100644 index 000000000..02d25369a --- /dev/null +++ b/src/intelligence_layer/core/__init__.py @@ -0,0 +1,67 @@ +from .chunk import Chunk as Chunk +from .chunk import ChunkInput as ChunkInput +from .chunk import ChunkOutput as ChunkOutput +from .chunk import ChunkWithIndices as ChunkWithIndices +from .chunk import ChunkWithIndicesOutput as ChunkWithIndicesOutput +from .chunk import ChunkWithStartEndIndices as ChunkWithStartEndIndices +from .chunk import TextChunk as TextChunk +from .detect_language import DetectLanguage as DetectLanguage +from .detect_language import DetectLanguageInput as DetectLanguageInput +from .detect_language import DetectLanguageOutput as DetectLanguageOutput +from .detect_language import Language as Language +from .echo import Echo as Echo +from .echo import EchoInput as EchoInput +from .echo import EchoOutput as EchoOutput +from .echo import TokenWithLogProb as TokenWithLogProb +from .instruct import Instruct as Instruct +from .instruct import InstructInput as InstructInput +from .model import AlephAlphaModel as AlephAlphaModel +from .model import CompleteInput as CompleteInput +from .model import CompleteOutput as CompleteOutput +from .model import ControlModel as ControlModel +from .model import Llama2InstructModel as Llama2InstructModel +from .model import Llama3InstructModel as Llama3InstructModel +from .model import LuminousControlModel as LuminousControlModel +from .prompt_template import Cursor as Cursor +from .prompt_template import PromptItemCursor as PromptItemCursor +from .prompt_template import PromptRange as PromptRange +from .prompt_template import PromptTemplate as PromptTemplate +from .prompt_template import RichPrompt as RichPrompt +from .prompt_template import TextCursor as TextCursor +from .task import MAX_CONCURRENCY as MAX_CONCURRENCY +from .task import Input as Input +from .task import Output as Output +from .task import Task as Task +from .task import Token as Token +from .text_highlight import ScoredTextHighlight as ScoredTextHighlight +from .text_highlight import TextHighlight as TextHighlight +from .text_highlight import TextHighlightInput as TextHighlightInput +from .text_highlight import TextHighlightOutput as TextHighlightOutput +from .tracer.composite_tracer import CompositeTracer as CompositeTracer +from .tracer.file_tracer import FileSpan as FileSpan +from .tracer.file_tracer import FileTaskSpan as FileTaskSpan +from .tracer.file_tracer import FileTracer as FileTracer +from .tracer.in_memory_tracer import InMemorySpan as InMemorySpan +from .tracer.in_memory_tracer import InMemoryTaskSpan as InMemoryTaskSpan +from .tracer.in_memory_tracer import InMemoryTracer as InMemoryTracer +from .tracer.open_telemetry_tracer import OpenTelemetryTracer as OpenTelemetryTracer +from .tracer.persistent_tracer import PersistentSpan as PersistentSpan +from .tracer.persistent_tracer import PersistentTaskSpan as PersistentTaskSpan +from .tracer.persistent_tracer import PersistentTracer as PersistentTracer +from .tracer.persistent_tracer import TracerLogEntryFailed as TracerLogEntryFailed +from .tracer.tracer import EndSpan as EndSpan +from .tracer.tracer import EndTask as EndTask +from .tracer.tracer import JsonSerializer as JsonSerializer +from .tracer.tracer import LogEntry as LogEntry +from .tracer.tracer import LogLine as LogLine +from .tracer.tracer import NoOpTracer as NoOpTracer +from .tracer.tracer import PlainEntry as PlainEntry +from .tracer.tracer import PydanticSerializable as PydanticSerializable +from .tracer.tracer import Span as Span +from .tracer.tracer import StartSpan as StartSpan +from .tracer.tracer import StartTask as StartTask +from .tracer.tracer import TaskSpan as TaskSpan +from .tracer.tracer import Tracer as Tracer +from .tracer.tracer import utc_now as utc_now + +__all__ = [symbol for symbol in dir()] diff --git a/src/intelligence_layer/core/chunk.py b/src/intelligence_layer/core/chunk.py new file mode 100644 index 000000000..f1f3b5763 --- /dev/null +++ b/src/intelligence_layer/core/chunk.py @@ -0,0 +1,114 @@ +from typing import NewType, Sequence + +from pydantic import BaseModel +from semantic_text_splitter import TextSplitter + +from intelligence_layer.core.model import AlephAlphaModel +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer.tracer import TaskSpan + +TextChunk = NewType("TextChunk", str) +"""Segment of a larger text. + +This type infers that the string is smaller than the context size of the model where it is used. + +LLMs can't process documents larger than their context size. +To handle this, documents have to be split up into smaller segments that fit within their context size. +These smaller segments are referred to as chunks. +""" + + +class ChunkInput(BaseModel): + """The input for a `Chunk`-task. + + Attributes: + text: A text of arbitrary length. + """ + + text: str + + +class ChunkOutput(BaseModel): + """The output of a `ChunkTask`. + + Attributes: + chunks: A list of smaller sections of the input text. + """ + + chunks: Sequence[TextChunk] + + +class Chunk(Task[ChunkInput, ChunkOutput]): + """Splits a longer text into smaller text chunks. + + Provide a text of any length and chunk it into smaller pieces using a + tokenizer that is available within the Aleph Alpha client. + + Args: + model: A valid Aleph Alpha model. + max_tokens_per_chunk: The maximum number of tokens to fit into one chunk. + """ + + def __init__(self, model: AlephAlphaModel, max_tokens_per_chunk: int = 512): + super().__init__() + self._splitter = TextSplitter.from_huggingface_tokenizer( + model.get_tokenizer(), capacity=max_tokens_per_chunk + ) + + def do_run(self, input: ChunkInput, task_span: TaskSpan) -> ChunkOutput: + chunks = [TextChunk(t) for t in self._splitter.chunks(input.text)] + return ChunkOutput(chunks=chunks) + + +class ChunkWithStartEndIndices(BaseModel): + """A `TextChunk` and its `start_index` and `end_index` within the given text. + + Attributes: + chunk: The actual text. + start_index: The character start index of the chunk within the given text. + end_index: The character end index of the chunk within the given text. + """ + + chunk: TextChunk + start_index: int + end_index: int + + +class ChunkWithIndicesOutput(BaseModel): + """The output of a `ChunkWithIndices`-task. + + Attributes: + chunks_with_indices: A list of smaller sections of the input text with the respective start_index. + """ + + chunks_with_indices: Sequence[ChunkWithStartEndIndices] + + +class ChunkWithIndices(Task[ChunkInput, ChunkWithIndicesOutput]): + """Splits a longer text into smaller text chunks and returns the chunks' start indices. + + Provide a text of any length and chunk it into smaller pieces using a + tokenizer that is available within the Aleph Alpha client. For each chunk, the respective + start index relative to the document is also returned. + + Args: + model: A valid Aleph Alpha model. + max_tokens_per_chunk: The maximum number of tokens to fit into one chunk. + """ + + def __init__(self, model: AlephAlphaModel, max_tokens_per_chunk: int = 512): + super().__init__() + self._splitter = TextSplitter.from_huggingface_tokenizer( + model.get_tokenizer(), capacity=max_tokens_per_chunk, trim=False + ) + + def do_run(self, input: ChunkInput, task_span: TaskSpan) -> ChunkWithIndicesOutput: + chunks_with_indices = [ + ChunkWithStartEndIndices( + chunk=TextChunk(chunk), + start_index=start_index, + end_index=start_index + len(chunk), + ) + for (start_index, chunk) in self._splitter.chunk_indices(input.text) + ] + return ChunkWithIndicesOutput(chunks_with_indices=chunks_with_indices) diff --git a/src/intelligence_layer/core/detect_language.py b/src/intelligence_layer/core/detect_language.py new file mode 100644 index 000000000..c30aa7d4f --- /dev/null +++ b/src/intelligence_layer/core/detect_language.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from typing import Mapping, Optional, Sequence, TypeVar + +from langdetect import detect_langs # type: ignore +from pycountry import languages +from pydantic import BaseModel + +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer.tracer import TaskSpan + + +class LanguageNotSupportedError(ValueError): + """Raised in case language in the input is not compatible with the languages supported in the task""" + + +Config = TypeVar("Config") + + +@dataclass(frozen=True) +class Language: + """A language identified by its `ISO 639-1 code `_.""" + + iso_639_1: str + + def get_name(self) -> Optional[str]: + language = languages.get(alpha_2=self.iso_639_1) + return language.name if language else None + + def language_config(self, configs: Mapping["Language", Config]) -> Config: + config = configs.get(self) + if config is None: + raise LanguageNotSupportedError( + f"{self.iso_639_1} not in ({', '.join(lang.iso_639_1 for lang in configs.keys())})" + ) + return config + + +class DetectLanguageInput(BaseModel): + """The input for a `DetectLanguage` task. + + Attributes: + text: The text to identify the language for. + possible_languages: All languages that should be considered during detection. + Languages should be provided with their ISO 639-1 codes. + """ + + text: str + possible_languages: Sequence[Language] + + +class DetectLanguageOutput(BaseModel): + """The output of a `DetectLanguage` task. + + Attributes: + best_fit: The prediction for the best matching language. + Will be `None` if no language has a probability above the threshold. + """ + + best_fit: Optional[Language] + + +class AnnotatedLanguage(BaseModel): + lang: Language + prob: float + + +class DetectLanguage(Task[DetectLanguageInput, DetectLanguageOutput]): + """Task that detects the language of a text. + + Analyzes the likelihood that a given text is written in one of the + `possible_languages`. Returns the best match or `None`. + + Args: + threshold: Minimum probability value for a language to be considered + the `best_fit`. + + Example: + >>> from intelligence_layer.core import ( + ... DetectLanguage, + ... DetectLanguageInput, + ... InMemoryTracer, + ... Language, + ... ) + + >>> task = DetectLanguage() + >>> input = DetectLanguageInput( + ... text="This is an English text.", + ... possible_languages=[Language(l) for l in ("en", "fr")], + ... ) + >>> output = task.run(input, InMemoryTracer()) + """ + + def __init__(self, threshold: float = 0.5): + super().__init__() + self._threshold = threshold + + def do_run( + self, input: DetectLanguageInput, task_span: TaskSpan + ) -> DetectLanguageOutput: + annotated_languages = self._detect_languages(input, task_span) + best_fit = self._get_best_fit(annotated_languages, input.possible_languages) + return DetectLanguageOutput(best_fit=best_fit) + + def _detect_languages( + self, input: DetectLanguageInput, task_span: TaskSpan + ) -> Sequence[AnnotatedLanguage]: + languages = detect_langs(input.text) + annotated_languages = [ + AnnotatedLanguage(lang=Language(lang.lang), prob=lang.prob) + for lang in languages + ] + task_span.log("Raw language probabilities", annotated_languages) + return annotated_languages + + def _get_best_fit( + self, + languages_result: Sequence[AnnotatedLanguage], + possible_languages: Sequence[Language], + ) -> Optional[Language]: + return ( + languages_result[0].lang + if ( + languages_result[0].prob >= self._threshold + and languages_result[0].lang in possible_languages + ) + else None + ) diff --git a/src/intelligence_layer/core/echo.py b/src/intelligence_layer/core/echo.py new file mode 100644 index 000000000..c96729794 --- /dev/null +++ b/src/intelligence_layer/core/echo.py @@ -0,0 +1,127 @@ +from typing import NewType, Sequence + +from aleph_alpha_client import Prompt, Tokens +from pydantic import BaseModel +from tokenizers import Encoding # type: ignore + +from intelligence_layer.core.model import AlephAlphaModel, CompleteInput +from intelligence_layer.core.prompt_template import PromptTemplate +from intelligence_layer.core.task import Task, Token +from intelligence_layer.core.tracer.tracer import TaskSpan + +LogProb = NewType("LogProb", float) + + +class TokenWithLogProb(BaseModel): + token: Token + prob: LogProb + + +class EchoInput(BaseModel): + """The input for an `Echo` task. + + Attributes: + prompt: The input text that serves as the starting point for the LLM. + expected_completion: The desired completion based on the prompt. + The likelihood of the tokens in this will be examined. + """ + + prompt: Prompt + expected_completion: str + + +class EchoOutput(BaseModel): + """The output of an `Echo` task. + + Attributes: + tokens_with_log_probs: Every token of the `expected_completion` of the + `EchoInput` accompanied by its probability of having been generated + in a completion scenario. + """ + + tokens_with_log_probs: Sequence[TokenWithLogProb] + + +class Echo(Task[EchoInput, EchoOutput]): + """Task that returns probabilities of a completion given a prompt. + + Analyzes the likelihood of generating tokens in the expected completion based on + a given prompt and model. Does not generate any tokens. + + Args: + model: A model to use in the task. + + Example: + >>> from aleph_alpha_client import Prompt + >>> from intelligence_layer.core import Echo, EchoInput, InMemoryTracer, LuminousControlModel + + >>> model = LuminousControlModel(name="luminous-base-control") + >>> task = Echo(model) + >>> input = EchoInput( + ... prompt=Prompt.from_text("This is a "), + ... expected_completion="happy text", + ... ) + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + PROMPT_TEMPLATE_STR: str = "{{prompt}}{{expected_completion}}" + + def __init__(self, model: AlephAlphaModel) -> None: + super().__init__() + self._model = model + + def do_run(self, input: EchoInput, task_span: TaskSpan) -> EchoOutput: + # We tokenize the prompt separately so we don't have an overlap in the tokens. + # If we don't do this, the end of the prompt and expected completion can be merged into unexpected tokens. + expected_completion_tokens = self._tokenize(input.expected_completion) + prompt_template = PromptTemplate(self.PROMPT_TEMPLATE_STR) + prompt = prompt_template.to_rich_prompt( + prompt=prompt_template.embed_prompt(input.prompt), + expected_completion=prompt_template.placeholder( + Tokens.from_token_ids( + [token.token_id for token in expected_completion_tokens] + ) + ), + ) + output = self._model.complete( + CompleteInput( + prompt=prompt, + maximum_tokens=0, + log_probs=0, + tokens=True, + echo=True, + ), + task_span, + ) + assert output.completions[0].log_probs + log_prob_dicts = output.completions[0].log_probs[ + -len(expected_completion_tokens) : + ] + tokens_with_prob = [] + for token, log_prob in zip( + expected_completion_tokens, log_prob_dicts, strict=True + ): + assert token.token in log_prob + tokens_with_prob.append( + TokenWithLogProb( + token=token, + prob=LogProb(log_prob.get(token.token, 0.0) or 0.0), + ) + ) + return EchoOutput(tokens_with_log_probs=tokens_with_prob) + + def _tokenize(self, text: str) -> Sequence[Token]: + # Turns the expected output into list of token ids. Important so that we know how many tokens + # the label is and can retrieve the last N log probs for the label + tokenizer = self._model.get_tokenizer() + if tokenizer.pre_tokenizer: + tokenizer.pre_tokenizer.add_prefix_space = False + encoding: Encoding = tokenizer.encode(text) + return [ + Token( + token=tokenizer.decode([token_id], skip_special_tokens=False), + token_id=token_id, + ) + for token_id in encoding.ids + ] diff --git a/src/intelligence_layer/core/instruct.py b/src/intelligence_layer/core/instruct.py new file mode 100644 index 000000000..25fdd0cd8 --- /dev/null +++ b/src/intelligence_layer/core/instruct.py @@ -0,0 +1,30 @@ +from typing import Optional + +from pydantic import BaseModel + +from intelligence_layer.core.model import CompleteInput, CompleteOutput, ControlModel +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer.tracer import TaskSpan + + +class InstructInput(BaseModel): + instruction: str + input: Optional[str] = None + response_prefix: Optional[str] = None + maximum_tokens: int = 128 + + +class Instruct(Task[InstructInput, CompleteOutput]): + def __init__(self, model: ControlModel) -> None: + super().__init__() + self._model = model + + def do_run(self, input: InstructInput, task_span: TaskSpan) -> CompleteOutput: + prompt = self._model.to_instruct_prompt( + instruction=input.instruction, + input=input.input, + response_prefix=input.response_prefix, + ) + return self._model.complete( + CompleteInput(prompt=prompt, maximum_tokens=input.maximum_tokens), task_span + ) diff --git a/src/intelligence_layer/core/model.py b/src/intelligence_layer/core/model.py new file mode 100644 index 000000000..f1b404b29 --- /dev/null +++ b/src/intelligence_layer/core/model.py @@ -0,0 +1,329 @@ +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import Literal, Optional + +from aleph_alpha_client import ( + CompletionRequest, + CompletionResponse, + ExplanationRequest, + ExplanationResponse, +) +from pydantic import BaseModel, ConfigDict +from tokenizers import Encoding, Tokenizer # type: ignore + +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, + LimitedConcurrencyClient, +) +from intelligence_layer.core.prompt_template import PromptTemplate, RichPrompt +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer.tracer import TaskSpan, Tracer + + +class CompleteInput(BaseModel, CompletionRequest, frozen=True): + """The input for a `Complete` task.""" + + def to_completion_request(self) -> CompletionRequest: + return CompletionRequest(**self.__dict__) + + +class CompleteOutput(BaseModel, CompletionResponse, frozen=True): + """The output of a `Complete` task.""" + + # BaseModel protects namespace "model_". + # "model_version" is a field in CompletionResponse and clashes with the namespace. + model_config = ConfigDict(protected_namespaces=()) + + @staticmethod + def from_completion_response( + completion_response: CompletionResponse, + ) -> "CompleteOutput": + return CompleteOutput(**completion_response.__dict__) + + @property + def completion(self) -> str: + return self.completions[0].completion or "" + + @property + def generated_tokens(self) -> int: + return self.num_tokens_generated + + +class _Complete(Task[CompleteInput, CompleteOutput]): + """Performs a completion request with access to all possible request parameters. + + Only use this task for testing. Is wrapped by the AlephAlphaModel for sending + completion requests to the API. + + Args: + client: Aleph Alpha client instance for running model related API calls. + model: The name of a valid model that can access an API using an implementation + of the AlephAlphaClientProtocol. + """ + + def __init__(self, client: AlephAlphaClientProtocol, model: str) -> None: + super().__init__() + self._client = client + self._model = model + + def do_run(self, input: CompleteInput, task_span: TaskSpan) -> CompleteOutput: + task_span.log("Model", self._model) + return CompleteOutput.from_completion_response( + self._client.complete( + request=input.to_completion_request(), + model=self._model, + ) + ) + + +class ExplainInput(BaseModel, ExplanationRequest, frozen=True): + """The input for a `Explain` task.""" + + def to_explanation_request(self) -> ExplanationRequest: + return ExplanationRequest(**self.__dict__) + + +class ExplainOutput(BaseModel, ExplanationResponse, frozen=True): + """The output of a `Explain` task.""" + + # BaseModel protects namespace "model_". + # "model_version" is a field in ExplanationResponse and clashes with the namespace. + model_config = ConfigDict(protected_namespaces=()) + + @staticmethod + def from_explanation_response( + explanation_response: ExplanationResponse, + ) -> "ExplainOutput": + return ExplainOutput(**explanation_response.__dict__) + + +class _Explain(Task[ExplainInput, ExplainOutput]): + """Performs an explanation request with access to all possible request parameters. + + Only use this task for testing. Is wrapped by the AlephAlphaModel for sending + explanation requests to the API. + + Args: + client: Aleph Alpha client instance for running model related API calls. + model: The name of a valid model that can access an API using an implementation + of the AlephAlphaClientProtocol. + """ + + def __init__(self, client: AlephAlphaClientProtocol, model: str) -> None: + super().__init__() + self._client = client + self._model = model + + def do_run(self, input: ExplainInput, task_span: TaskSpan) -> ExplainOutput: + task_span.log("Model", self._model) + return ExplainOutput.from_explanation_response( + self._client.explain( + request=input.to_explanation_request(), model=self._model + ) + ) + + +@lru_cache(maxsize=1) +def limited_concurrency_client_from_env() -> LimitedConcurrencyClient: + return LimitedConcurrencyClient.from_env() + + +class AlephAlphaModel: + """Abstract base class for the implementation of any model that uses the Aleph Alpha client. + + Any class of Aleph Alpha model is implemented on top of this base class. Exposes methods that + are available to all models, such as `complete` and `tokenize`. It is the central place for + all things that are physically interconnected with a model, such as its tokenizer or prompt + format used during training. + + Args: + name: The name of a valid model that can access an API using an implementation + of the AlephAlphaClientProtocol. + client: Aleph Alpha client instance for running model related API calls. + Defaults to :class:`LimitedConcurrencyClient` + """ + + def __init__( + self, + name: str, + client: Optional[AlephAlphaClientProtocol] = None, + ) -> None: + self.name = name + self._client = ( + limited_concurrency_client_from_env() if client is None else client + ) + self._complete: Task[CompleteInput, CompleteOutput] = _Complete( + self._client, name + ) + self._explain = _Explain(self._client, name) + + def complete_task(self) -> Task[CompleteInput, CompleteOutput]: + return self._complete + + def complete(self, input: CompleteInput, tracer: Tracer) -> CompleteOutput: + return self._complete.run(input, tracer) + + def explain(self, input: ExplainInput, tracer: Tracer) -> ExplainOutput: + return self._explain.run(input, tracer) + + @lru_cache(maxsize=1) + def get_tokenizer(self) -> Tokenizer: + return self._client.tokenizer(self.name) + + def tokenize(self, text: str) -> Encoding: + return self.get_tokenizer().encode(text) + + +class ControlModel(ABC, AlephAlphaModel): + @abstractmethod + def to_instruct_prompt( + self, + instruction: str, + input: Optional[str] = None, + response_prefix: Optional[str] = None, + ) -> RichPrompt: + pass + + +class LuminousControlModel(ControlModel): + """An Aleph Alpha control model of the second generation. + + Args: + name: The name of a valid model second generation control model. + Defaults to `luminous-base-control` + client: Aleph Alpha client instance for running model related API calls. + Defaults to :class:`LimitedConcurrencyClient` + """ + + INSTRUCTION_PROMPT_TEMPLATE = PromptTemplate( + """{% promptrange instruction %}{{instruction}}{% endpromptrange %} +{% if input %} +{% promptrange input %}{{input}}{% endpromptrange %} +{% endif %} +### Response:{{response_prefix}}""" + ) + + def __init__( + self, + name: Literal[ + "luminous-base-control-20230501", + "luminous-extended-control-20230501", + "luminous-supreme-control-20230501", + "luminous-base-control", + "luminous-extended-control", + "luminous-supreme-control", + "luminous-base-control-20240215", + "luminous-extended-control-20240215", + "luminous-supreme-control-20240215", + ] = "luminous-base-control", + client: Optional[AlephAlphaClientProtocol] = None, + ) -> None: + super().__init__(name, client) + + def to_instruct_prompt( + self, + instruction: str, + input: Optional[str] = None, + response_prefix: Optional[str] = None, + ) -> RichPrompt: + return self.INSTRUCTION_PROMPT_TEMPLATE.to_rich_prompt( + instruction=instruction, input=input, response_prefix=response_prefix + ) + + +class Llama2InstructModel(ControlModel): + """A llama-2-*-chat model, prompt-optimized for single-turn instructions. + + If possible, we recommend using `Llama3InstructModel` instead. + + Args: + name: The name of a valid llama-2 model. + Defaults to `llama-2-13b-chat` + client: Aleph Alpha client instance for running model related API calls. + Defaults to :class:`LimitedConcurrencyClient` + """ + + INSTRUCTION_PROMPT_TEMPLATE = PromptTemplate("""[INST] <> +{% promptrange instruction %}{{instruction}}{% endpromptrange %} +<>{% if input %} + +{% promptrange input %}{{input}}{% endpromptrange %}{% endif %} [/INST]{% if response_prefix %} + +{{response_prefix}}{% endif %}""") + + def __init__( + self, + name: Literal[ + "llama-2-7b-chat", + "llama-2-13b-chat", + "llama-2-70b-chat", + ] = "llama-2-13b-chat", + client: Optional[AlephAlphaClientProtocol] = None, + ) -> None: + super().__init__(name, client) + + def to_instruct_prompt( + self, + instruction: str, + input: Optional[str] = None, + response_prefix: Optional[str] = None, + ) -> RichPrompt: + return self.INSTRUCTION_PROMPT_TEMPLATE.to_rich_prompt( + instruction=instruction, input=input, response_prefix=response_prefix + ) + + +class Llama3InstructModel(ControlModel): + """A llama-3-*-instruct model. + + Args: + name: The name of a valid llama-3 model. + Defaults to `llama-3-8b-instruct` + client: Aleph Alpha client instance for running model related API calls. + Defaults to :class:`LimitedConcurrencyClient` + """ + + INSTRUCTION_PROMPT_TEMPLATE = PromptTemplate( + """<|begin_of_text|><|start_header_id|>user<|end_header_id|> + +{% promptrange instruction %}{{instruction}}{% endpromptrange %}{% if input %} + +{% promptrange input %}{{input}}{% endpromptrange %}{% endif %}<|eot_id|><|start_header_id|>assistant<|end_header_id|>{% if response_prefix %} + +{{response_prefix}}{% endif %}""" + ) + EOT_TOKEN = "<|eot_id|>" + + def __init__( + self, + name: Literal[ + "llama-3-8b-instruct", + "llama-3-70b-instruct", + ] = "llama-3-8b-instruct", + client: Optional[AlephAlphaClientProtocol] = None, + ) -> None: + super().__init__(name, client) + + def _add_eot_token_to_stop_sequences(self, input: CompleteInput) -> CompleteInput: + # remove this once the API supports the llama-3 EOT_TOKEN + params = input.__dict__ + if isinstance(params["stop_sequences"], list): + if self.EOT_TOKEN not in params["stop_sequences"]: + params["stop_sequences"].append(self.EOT_TOKEN) + else: + params["stop_sequences"] = [self.EOT_TOKEN] + return CompleteInput(**params) + + def complete(self, input: CompleteInput, tracer: Tracer) -> CompleteOutput: + input_with_eot = self._add_eot_token_to_stop_sequences(input) + return super().complete(input_with_eot, tracer) + + def to_instruct_prompt( + self, + instruction: str, + input: Optional[str] = None, + response_prefix: Optional[str] = None, + ) -> RichPrompt: + return self.INSTRUCTION_PROMPT_TEMPLATE.to_rich_prompt( + instruction=instruction, input=input, response_prefix=response_prefix + ) diff --git a/src/intelligence_layer/core/prompt_template.py b/src/intelligence_layer/core/prompt_template.py new file mode 100644 index 000000000..30ac9640f --- /dev/null +++ b/src/intelligence_layer/core/prompt_template.py @@ -0,0 +1,409 @@ +from collections import defaultdict +from dataclasses import dataclass, field, replace +from itertools import chain +from re import finditer +from sys import intern +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + NewType, + Optional, + Sequence, + TextIO, + Tuple, + Union, +) +from uuid import UUID, uuid4 + +from aleph_alpha_client.prompt import Image, Prompt, PromptItem, Text, Tokens +from liquid import BoundTemplate, Context, Environment +from liquid.ast import BlockNode, Node +from liquid.context import Namespace +from liquid.exceptions import LiquidTypeError +from liquid.expressions.common import parse_unchained_identifier +from liquid.expressions.filtered.lex import tokenize +from liquid.expressions.stream import TokenStream as AstTokenStream +from liquid.parse import expect, get_parser +from liquid.stream import TokenStream +from liquid.tag import Tag +from liquid.token import TOKEN_EOF, TOKEN_EXPRESSION, TOKEN_TAG + +Placeholder = NewType("Placeholder", UUID) + + +@dataclass(frozen=True) +class TextCursor: + """Defines a position with a `Text` prompt item. + + Args: + item: the index of the prompt item within the `Prompt` + position: the character position in the text of the item. + + Example: + >>> from aleph_alpha_client import Prompt + >>> from intelligence_layer.core import TextCursor + >>> prompt = Prompt.from_text("This is a text") + >>> # This denotes the "i" in "is" in the text-item of the `Prompt` above + >>> cursor = TextCursor(item=0, position=5) + """ + + item: int + position: int + + +@dataclass(frozen=True) +class PromptItemCursor: + """Defines a position with a non-`Text` prompt item. + + Args: + item: the index of the prompt item within the `Prompt` + """ + + item: int + + +Cursor = Union[TextCursor, PromptItemCursor] + + +@dataclass +class PromptRange: + """Defines a range within a `Prompt`.""" + + start: Cursor + end: Cursor + + +@dataclass +class RichPrompt(Prompt): + """The `Prompt` along with some metadata generated when a `PromptTemplate` is turned into a `Prompt`. + + Args: + ranges: A mapping of range name to a `Sequence` of corresponding `PromptRange` instances. + """ + + ranges: Mapping[str, Sequence[PromptRange]] = field(default_factory=dict) + + +PROMPT_RANGE_TAG = intern("promptrange") +PROMPT_RANGE_END_TAG = intern("endpromptrange") + + +class PromptRangeTag(Tag): + """Defines the liquid tag for the promptrange.""" + + name = PROMPT_RANGE_TAG + end = PROMPT_RANGE_END_TAG + + def __init__(self, env: Environment): + super().__init__(env) + self.parser = get_parser(env) + + def parse(self, stream: TokenStream) -> Node: + expect(stream, TOKEN_TAG, PROMPT_RANGE_TAG) + stream.next_token() + expect(stream, TOKEN_EXPRESSION) + + name = str( + parse_unchained_identifier(AstTokenStream(tokenize(stream.current.value))) + ) + stream.next_token() + block = self.parser.parse_block(stream, (PROMPT_RANGE_END_TAG, TOKEN_EOF)) + expect(stream, TOKEN_TAG, value=PROMPT_RANGE_END_TAG) + return PromptRangeNode(block, name) + + +class PromptRangeContext(Context): + """A liquid `Context` with some additional state used by the `PromptRangeNode`.""" + + def __init__( + self, + env: Environment, + globals: Optional[Namespace] = None, + disabled_tags: Optional[List[str]] = None, + copy_depth: int = 0, + parent_context: Optional[Context] = None, + loop_iteration_carry: int = 1, + local_namespace_size_carry: int = 0, + template: Optional[BoundTemplate] = None, + ): + super().__init__( + env, + globals, + disabled_tags, + copy_depth, + parent_context, + loop_iteration_carry, + local_namespace_size_carry, + template, + ) + self._placeholder_range_names: dict[Placeholder, str] = {} + + def add_placeholder_range(self, placeholder: Placeholder, name: str) -> None: + self._placeholder_range_names[placeholder] = name + + def placeholder_range_names(self) -> Mapping[Placeholder, str]: + return self._placeholder_range_names + + +class PromptRangeNode(Node): + """A liquid `Node` representing a promptrange.""" + + def __init__(self, inner: BlockNode, name: str) -> None: + super().__init__() + self.inner = inner + self.name = name + self.placeholder = Placeholder(uuid4()) + + def render_to_output(self, context: Context, buffer: TextIO) -> Optional[bool]: + if not isinstance(context, PromptRangeContext): + raise LiquidTypeError( + f"Context not of expected type: {PromptRangeContext} (is: {type(context)})" + ) + context.add_placeholder_range(self.placeholder, self.name) + buffer.write(str(self.placeholder)) + self.inner.render(context, buffer) + buffer.write(str(self.placeholder)) + return True + + +class PromptTemplate: + """Allows to build a `Prompt` using the `liquid template language `_. + + To add non-text prompt items first you have to save it to the template with the `template.placeholder()` function. + To embed the items in the template, pass the placeholder in the place(s) where you would like the items. + + Example: + >>> from aleph_alpha_client import CompletionRequest, Tokens + + >>> from intelligence_layer.core import PromptTemplate + + >>> tokens = Tokens.from_token_ids([1, 2, 3]) + >>> template = PromptTemplate( + ... '''{%- for name in names -%} + ... Hello {{name}}! + ... {% endfor -%} + ... {{ image }} + ... ''') + >>> placeholder = template.placeholder(tokens) + >>> names = ["World", "Rutger"] + >>> prompt = template.to_rich_prompt(names=names, image=placeholder) + >>> request = CompletionRequest(prompt=prompt) + """ + + def __init__(self, template_str: str) -> None: + """Initialize with the liquid template string. + + The template supports the custom liquid tag `promptrange`. This can be used to determine ranges + within the `Prompt` primarily for downstream explainability tasks. + + Args: + template_str: the liquid template string + + Example: + >>> from intelligence_layer.core import PromptTemplate + + + >>> template = PromptTemplate( + ... '''Answer the following question given the input. + ... + ... Input: {% promptrange input %}{{text}}{% endpromptrange %} + ... Question: {% promptrange question %}{{question}}{% endpromptrange %} + ... Answer:''') + >>> prompt_data = template.to_rich_prompt(text="Some text...", question="A question ...") + >>> input_range = prompt_data.ranges.get("input") + """ + env = Environment() + env.add_tag(PromptRangeTag) + self._template = env.from_string(template_str) + self._prompt_item_placeholders: dict[Placeholder, Union[Image, Tokens]] = {} + + def placeholder(self, value: Union[Image, Tokens]) -> Placeholder: + """Saves a non-text prompt item to the template and returns a placeholder. + + Args: + value: Tokens to store + The placeholder is used to embed the prompt item in the template + """ + id = Placeholder(uuid4()) + self._prompt_item_placeholders[id] = value + return id + + def _join_character( + self, first_item: Union[Text, Image, Tokens, None], second_item: Text + ) -> str: + if ( + isinstance(first_item, Text) + and not first_item.text[-1].isspace() + and not second_item.text[0].isspace() + ): + return " " + else: + return "" + + def embed_prompt(self, prompt: Prompt) -> str: + """Embeds a prompt in a prompt template + + Adds whitespace between text items if there is no whitespace between them. + In case of non-text prompt items, this embeds them into the end result. + + Args: + prompt: prompt to embed in the template + + Example: + >>> from aleph_alpha_client import Prompt, Text, Tokens + + >>> from intelligence_layer.core import PromptTemplate + + >>> user_prompt = Prompt([ + ... Tokens.from_token_ids([1, 2, 3]), + ... Text.from_text("cool"), + ... ]) + >>> template = PromptTemplate("Question: {{user_prompt}}\\n Answer: ") + >>> prompt = template.to_rich_prompt(user_prompt=template.embed_prompt(user_prompt)) + """ + prompt_text = "" + last_item = None + for item in prompt.items: + if isinstance(item, Text): + if len(item.text) == 0: + continue + prompt_text = str.join( + self._join_character(last_item, item), [prompt_text, item.text] + ) + else: + prompt_text = str.join("", [prompt_text, str(self.placeholder(item))]) + last_item = item + return prompt_text + + def to_rich_prompt(self, **kwargs: Any) -> RichPrompt: + """Creates a `Prompt` along with metadata from the template string and the given parameters. + + Args: + **kwargs: Parameters to enrich prompt with + Currently, the only metadata returned is information about ranges that are marked in the template. + Provided parameters are passed to `liquid.Template.render`. + """ + context = PromptRangeContext( + self._template.env, + globals=self._template.make_globals(kwargs), + template=self._template, + ) + buffer = self._template._get_buffer() + self._template.render_with_context(context, buffer, **kwargs) + liquid_prompt = buffer.getvalue() + placeholder_indices = self._compute_indices( + chain( + self._prompt_item_placeholders.keys(), + context.placeholder_range_names().keys(), + ), + liquid_prompt, + ) + modalities, placeholder_ranges = self._compute_modalities_and_ranges( + placeholder_indices, context.placeholder_range_names(), liquid_prompt + ) + + result = RichPrompt(modalities, placeholder_ranges) + self._reset_placeholder_state() + return result + + def _reset_placeholder_state(self) -> None: + self._prompt_item_placeholders = {} + + def _compute_indices( + self, placeholders: Iterable[Placeholder], template: str + ) -> Iterable[Tuple[int, int]]: + pattern = "|".join(str(placeholder) for placeholder in placeholders) + return ( + ( + (match.start(), match.end()) + for match in finditer(f"({pattern})", template) + ) + if pattern + else [] + ) + + def _compute_modalities_and_ranges( + self, + placeholder_indices: Iterable[Tuple[int, int]], + placeholder_range_names: Mapping[Placeholder, str], + template: str, + ) -> Tuple[Sequence[PromptItem], Mapping[str, Sequence[PromptRange]]]: + placeholder_ranges: Dict[Placeholder, List[PromptRange]] = defaultdict(list) + modalities = list( + self._modalities_from(placeholder_indices, placeholder_ranges, template) + ) + self._replace_start_cursors_of_non_text_items(modalities, placeholder_ranges) + return modalities, { + placeholder_range_names[placeholder]: ranges + for placeholder, ranges in placeholder_ranges.items() + if placeholder_range_names.get(placeholder) + } + + @staticmethod + def _replace_start_cursors_of_non_text_items( + modalities: Sequence[PromptItem], + placeholder_ranges: Dict[Placeholder, List[PromptRange]], + ) -> None: + for prompt_ranges in placeholder_ranges.values(): + for index, range in enumerate(prompt_ranges): + if not isinstance(modalities[range.start.item], Text): + prompt_ranges[index] = replace( + range, start=PromptItemCursor(range.start.item) + ) + + def _modalities_from( + self, + placeholder_indices: Iterable[Tuple[int, int]], + placeholder_ranges: dict[Placeholder, List[PromptRange]], + template: str, + ) -> Iterable[PromptItem]: + last_to = 0 + accumulated_text = "" + item_cnt = 0 + range_starts: Dict[Placeholder, TextCursor] = {} + + def new_prompt_item(item: PromptItem) -> PromptItem: + nonlocal item_cnt, accumulated_text + item_cnt += 1 + accumulated_text = "" + return item + + def initial_start_text_cursor() -> TextCursor: + return TextCursor(item=item_cnt, position=len(accumulated_text)) + + def end_cursor() -> Cursor: + return ( + TextCursor(item=item_cnt, position=len(accumulated_text)) + if accumulated_text + else PromptItemCursor(item_cnt - 1) + ) + + def valid_range_for( + placeholder: Placeholder, end: Cursor + ) -> Iterable[PromptRange]: + if end.item >= range_starts[placeholder].item: + yield PromptRange(start=range_starts[placeholder], end=end) + del range_starts[placeholder] + + for placeholder_from, placeholder_to in placeholder_indices: + placeholder = Placeholder(UUID(template[placeholder_from:placeholder_to])) + accumulated_text += template[last_to:placeholder_from] + placeholder_prompt_item = self._prompt_item_placeholders.get(placeholder) + if placeholder_prompt_item: + if accumulated_text: + yield new_prompt_item(Text.from_text(accumulated_text)) + + yield new_prompt_item(placeholder_prompt_item) + else: + if range_starts.get(placeholder): + placeholder_ranges[placeholder].extend( + valid_range_for(placeholder, end_cursor()) + ) + else: + range_starts[placeholder] = initial_start_text_cursor() + last_to = placeholder_to + if last_to < len(template) or accumulated_text: + yield Text.from_text(accumulated_text + template[last_to:]) diff --git a/src/intelligence_layer/core/task.py b/src/intelligence_layer/core/task.py new file mode 100644 index 000000000..885079187 --- /dev/null +++ b/src/intelligence_layer/core/task.py @@ -0,0 +1,116 @@ +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor +from typing import Generic, Iterable, Optional, Sequence, TypeVar, final + +from pydantic import BaseModel + +from intelligence_layer.core.tracer.tracer import PydanticSerializable, TaskSpan, Tracer + + +class Token(BaseModel): + """A token class containing it's id and the raw token. + + This is used instead of the Aleph Alpha client Token class since this one is serializable, + while the one from the client is not. + """ + + token: str + token_id: int + + +Input = TypeVar("Input", bound=PydanticSerializable) +"""Interface to be passed to the task with all data needed to run the process. +Ideally, these are specified in terms related to the use-case, rather than lower-level +configuration options.""" +Output = TypeVar("Output", bound=PydanticSerializable) +"""Interface of the output returned by the task.""" + + +MAX_CONCURRENCY = 20 +global_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) + + +class Task(ABC, Generic[Input, Output]): + """Base task interface. This may consist of several sub-tasks to accomplish the given task. + + Generics: + Input: Interface to be passed to the task with all data needed to run the process. + Ideally, these are specified in terms related to the use-case, rather than lower-level + configuration options. + + Output: Interface of the output returned by the task. + """ + + @abstractmethod + def do_run(self, input: Input, task_span: TaskSpan) -> Output: + """The implementation for this use case. + + This takes an input and runs the implementation to generate an output. + It takes a `Span` for tracing of the process. + The Input and Output are logged by default. + + Args: + input: Generic input defined by the task implementation + task_span: The `Span` used for tracing. + Returns: + Generic output defined by the task implementation. + """ + ... + + @final + def run( + self, input: Input, tracer: Tracer, trace_id: Optional[str] = None + ) -> Output: + """Executes the implementation of `do_run` for this use case. + + This takes an input and runs the implementation to generate an output. + It takes a `Tracer` for tracing of the process. + The Input and Output are logged by default. + + Args: + input: Generic input defined by the task implementation + tracer: The `Tracer` used for tracing. + trace_id: An optional id of the run, used to track its trace. + Returns: + Generic output defined by the task implementation. + """ + with tracer.task_span( + type(self).__name__, input, trace_id=trace_id + ) as task_span: + output = self.do_run(input, task_span) + task_span.record_output(output) + return output + + @final + def run_concurrently( + self, + inputs: Iterable[Input], + tracer: Tracer, + concurrency_limit: int = MAX_CONCURRENCY, + trace_id: Optional[str] = None, + ) -> Sequence[Output]: + """Executes multiple processes of this task concurrently. + + Each provided input is potentially executed concurrently to the others. There is a global limit + on the number of concurrently executed tasks that is shared by all tasks of all types. + + Args: + inputs: The inputs that are potentially processed concurrently. + tracer: The tracer passed on the `run` method when executing a task. + concurrency_limit: An optional additional limit for the number of concurrently executed task for + this method call. This can be used to prevent queue-full or similar error of downstream APIs + when the global concurrency limit is too high for a certain task. + trace_id: An optional id of the run, used to track its trace. + + Returns: + The Outputs generated by calling `run` for each given Input. + The order of Outputs corresponds to the order of the Inputs. + """ + + with tracer.span( + f"Concurrent {type(self).__name__} tasks", trace_id=trace_id + ) as span: + with ThreadPoolExecutor( + max_workers=min(concurrency_limit, MAX_CONCURRENCY) + ) as executor: + return list(executor.map(lambda input: self.run(input, span), inputs)) diff --git a/src/intelligence_layer/core/text_highlight.py b/src/intelligence_layer/core/text_highlight.py new file mode 100644 index 000000000..ccdedaebd --- /dev/null +++ b/src/intelligence_layer/core/text_highlight.py @@ -0,0 +1,413 @@ +import itertools +from typing import Iterable, Mapping, Sequence, cast + +from aleph_alpha_client import ( + Prompt, + PromptGranularity, + Text, + TextPromptItemExplanation, + TextScore, +) +from pydantic import BaseModel + +from intelligence_layer.core.model import AlephAlphaModel, ExplainInput, ExplainOutput +from intelligence_layer.core.prompt_template import ( + Cursor, + PromptRange, + RichPrompt, + TextCursor, +) +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer.tracer import TaskSpan + + +class TextHighlightInput(BaseModel): + """The input for a text highlighting task. + + Attributes: + rich_prompt: From client's PromptTemplate. Includes both the actual 'Prompt' as well as text range information. + Supports liquid-template-language-style {% promptrange range_name %}/{% endpromptrange %} for range. + target: The target that should be explained. Expected to follow the prompt. + focus_ranges: The ranges contained in `rich_prompt` the returned highlights stem from. That means that each returned + highlight overlaps with at least one character with one of the ranges listed here. + If this set is empty highlights of the entire prompt are returned. + """ + + rich_prompt: RichPrompt + target: str + focus_ranges: frozenset[str] = frozenset() + + +class ScoredTextHighlight(BaseModel): + """A substring of the input prompt scored for relevance with regard to the output. + + Attributes: + start: The start index of the highlight. + end: The end index of the highlight. + score: The score of the highlight. Normalized to be between zero and one, with higher being more important. + """ + + start: int + end: int + score: float + + +class TextHighlightOutput(BaseModel): + """The output of a text highlighting task. + + Attributes: + highlights: A sequence of 'ScoredTextHighlight's. + """ + + highlights: Sequence[ScoredTextHighlight] + + +class TextPromptRange(PromptRange): + start: TextCursor + end: TextCursor + + +class TextHighlight(Task[TextHighlightInput, TextHighlightOutput]): + """Generates text highlights given a prompt and completion. + + For a given prompt and target (completion), extracts the parts of the prompt responsible for generation. + The prompt can only contain text. A range can be provided via use of the liquid language (see the example). + In this case, the highlights will only refer to text within this range. + + Args: + model: The model used throughout the task for model related API calls. + granularity: At which granularity should the target be explained in terms of the prompt. + threshold: After normalization, everything highlight below this value will be dropped. + clamp: Control whether highlights should be clamped to a focus range if they intersect it. + + Example: + >>> import os + + >>> from intelligence_layer.core import ( + ... InMemoryTracer, + ... PromptTemplate, + ... TextHighlight, + ... TextHighlightInput, + ... AlephAlphaModel + ... ) + + >>> model = AlephAlphaModel(name="luminous-base") + >>> text_highlight = TextHighlight(model=model) + >>> prompt_template_str = ( + ... "{% promptrange r1 %}Question: What is 2 + 2?{% endpromptrange %}\\nAnswer:" + ... ) + >>> template = PromptTemplate(prompt_template_str) + >>> rich_prompt = template.to_rich_prompt() + >>> completion = " 4." + >>> model = "luminous-base" + >>> input = TextHighlightInput( + ... rich_prompt=rich_prompt, target=completion, focus_ranges=frozenset({"r1"}) + ... ) + >>> output = text_highlight.run(input, InMemoryTracer()) + """ + + def __init__( + self, + model: AlephAlphaModel, + granularity: PromptGranularity | None = None, + threshold: float = 0.1, + clamp: bool = False, + ) -> None: + super().__init__() + self._threshold = threshold + self._model = model + self._granularity = granularity + self._clamp_to_focus = clamp + + def do_run( + self, input: TextHighlightInput, task_span: TaskSpan + ) -> TextHighlightOutput: + self._raise_on_invalid_focus_range(input) + self._raise_on_incompatible_prompt(input) + explanation = self._explain( + prompt=input.rich_prompt, + target=input.target, + task_span=task_span, + ) + focus_ranges = self._filter_and_flatten_prompt_ranges( + input.focus_ranges, input.rich_prompt.ranges + ) + + text_prompt_item_explanations_and_indices = ( + self._extract_text_prompt_item_explanations_and_item_index(explanation) + ) + + highlights = self._to_highlights( + focus_ranges, + text_prompt_item_explanations_and_indices, + task_span, + ) + return TextHighlightOutput(highlights=highlights) + + def _raise_on_incompatible_prompt(self, input: TextHighlightInput) -> None: + """Currently, the text highlight logic does not correctly deal with + multi item texts. This is a result of returning indices instead of text. + Therefore, we disable running text highlighting on prompts with more than one index + for the moment. This also means we only deal with text items. + + Args: + input: The input for a text highlighting task. + """ + n_items = len(input.rich_prompt.items) + # the last item is always the question + if n_items > 2: + raise ValueError( + f"Text highlighting currently only works correctly with a single Text item. Found {n_items-1}." + ) + if any(not isinstance(item, Text) for item in input.rich_prompt.items): + raise ValueError("Text highlighting only supports text prompts.") + + def _raise_on_invalid_focus_range(self, input: TextHighlightInput) -> None: + unknown_focus_ranges = input.focus_ranges - set(input.rich_prompt.ranges.keys()) + if unknown_focus_ranges: + raise ValueError(f"Unknown focus ranges: {', '.join(unknown_focus_ranges)}") + + def _explain( + self, prompt: Prompt, target: str, task_span: TaskSpan + ) -> ExplainOutput: + input = ExplainInput( + prompt=prompt, + target=target, + prompt_granularity=self._granularity, + ) + output = self._model.explain(input, task_span) + return output + + def _filter_and_flatten_prompt_ranges( + self, + focus_ranges: frozenset[str], + input_ranges: Mapping[str, Sequence[PromptRange]], + ) -> Sequence[PromptRange]: + relevant_ranges = ( + range for name, range in input_ranges.items() if name in focus_ranges + ) + return list(itertools.chain.from_iterable(relevant_ranges)) + + def _extract_text_prompt_item_explanations_and_item_index( + self, + explain_output: ExplainOutput, + ) -> Iterable[tuple[TextPromptItemExplanation, int]]: + return ( + (explanation, index) + # we explain the complete target at once, therefore we have 1 explanation + for index, explanation in enumerate(explain_output.explanations[0].items) + if isinstance(explanation, TextPromptItemExplanation) + ) + + def _to_highlights( + self, + focus_ranges: Sequence[PromptRange], + text_prompt_item_explanations_and_indices: Iterable[ + tuple[TextPromptItemExplanation, int] + ], + task_span: TaskSpan, + ) -> Sequence[ScoredTextHighlight]: + relevant_text_scores: list[TextScore] = [] + for ( + text_prompt_item_explanation, + explanation_idx, + ) in text_prompt_item_explanations_and_indices: + for text_score in text_prompt_item_explanation.scores: + assert isinstance(text_score, TextScore) # for typing + + if self._is_relevant_explanation( + explanation_idx, text_score, focus_ranges + ): + relevant_text_scores.append(text_score) + + task_span.log( + "Raw explanation scores", + [ + { + "start": text_score.start, + "end": text_score.start + text_score.length, + "score": text_score.score, + } + for text_score in relevant_text_scores + ], + ) + if self._clamp_to_focus: + relevant_text_scores = self._clamp_ranges_to_focus( + focus_ranges, relevant_text_scores + ) + + text_highlights = [ + ScoredTextHighlight( + start=text_score.start, + end=text_score.start + text_score.length, + score=text_score.score, + ) + for text_score in relevant_text_scores + ] + + return self._normalize_and_filter(text_highlights) + + def _clamp_ranges_to_focus( + self, + prompt_ranges: Sequence[PromptRange], + relevant_highlights: list[TextScore], + ) -> list[TextScore]: + text_prompt_ranges = [ + cast(TextPromptRange, prompt_range) for prompt_range in prompt_ranges + ] + if not self._should_clamp(text_prompt_ranges): + return relevant_highlights + + new_relevant_text_scores: list[TextScore] = [] + for highlight in relevant_highlights: + + def _get_overlap(range: TextPromptRange) -> int: + return min( + highlight.start + highlight.length, range.end.position + ) - max(highlight.start, range.start.position) + + most_overlapping_range = sorted( + text_prompt_ranges, + key=_get_overlap, + )[-1] + new_relevant_text_scores.append( + self._clamp_to_range(highlight, most_overlapping_range) + ) + return new_relevant_text_scores + + def _should_clamp(self, prompt_ranges: Sequence[TextPromptRange]) -> bool: + def are_overlapping(p1: TextPromptRange, p2: TextPromptRange) -> bool: + if p1.start.position > p2.start.position: + p1, p2 = p2, p1 + return ( + p1.start.position <= p2.end.position + and p1.end.position >= p2.start.position + ) + + if len(prompt_ranges) == 0: + return False + + # this check is relatively expensive if no ranges are overlapping + has_overlapping_ranges = any( + are_overlapping(p1, p2) + for p1, p2 in itertools.permutations(prompt_ranges, 2) + ) + if has_overlapping_ranges: + print( + "TextHighlighting with clamping is on, but focus ranges are overlapping. Disabling clamping." + ) + return not has_overlapping_ranges + + def _clamp_to_range( + self, highlight: TextScore, focus_range: TextPromptRange + ) -> TextScore: + new_start = highlight.start + new_length = highlight.length + new_score = highlight.score + cut_characters = 0 + # clamp start + if highlight.start < focus_range.start.position: + cut_characters = focus_range.start.position - highlight.start + new_start = focus_range.start.position + new_length -= cut_characters + # clamp end + if highlight.start + highlight.length > focus_range.end.position: + n_cut_at_end = ( + highlight.start + highlight.length + ) - focus_range.end.position + cut_characters += n_cut_at_end + new_length -= n_cut_at_end + + if cut_characters: + new_score = highlight.score * (new_length / highlight.length) + return TextScore(new_start, new_length, new_score) + + def _normalize_and_filter( + self, text_highlights: Sequence[ScoredTextHighlight] + ) -> Sequence[ScoredTextHighlight]: + max_score = max(highlight.score for highlight in text_highlights) + divider = max( + 1, max_score + ) # We only normalize if the max score is above a threshold to avoid noisy attribution in case where + + for highlight in text_highlights: + highlight.score = max(highlight.score / divider, 0) + + return [ + highlight + for highlight in text_highlights + if highlight.score >= self._threshold + ] + + def _is_relevant_explanation( + self, + explanation_idx: int, + text_score: TextScore, + prompt_ranges: Iterable[PromptRange], + ) -> bool: + return ( + any( + self._prompt_range_overlaps_with_text_score( + prompt_range, text_score, explanation_idx + ) + for prompt_range in prompt_ranges + ) + or not prompt_ranges + ) + + @classmethod + def _prompt_range_overlaps_with_text_score( + cls, + prompt_range: PromptRange, + text_score: TextScore, + explanation_item_idx: int, + ) -> bool: + return ( + cls._is_within_prompt_range( + prompt_range, + explanation_item_idx, + text_score.start, + ) + or cls._is_within_prompt_range( + prompt_range, + explanation_item_idx, + text_score.start + text_score.length - 1, + ) + or cls._is_within_text_score( + text_score, explanation_item_idx, prompt_range.start + ) + ) + + @staticmethod + def _is_within_text_score( + text_score: TextScore, + text_score_item: int, + prompt_range_cursor: Cursor, + ) -> bool: + if text_score_item != prompt_range_cursor.item: + return False + assert isinstance(prompt_range_cursor, TextCursor) + return ( + text_score.start + <= prompt_range_cursor.position + <= text_score.start + text_score.length - 1 + ) + + @staticmethod + def _is_within_prompt_range( + prompt_range: PromptRange, + item_check: int, + pos_check: int, + ) -> bool: + if item_check < prompt_range.start.item or item_check > prompt_range.end.item: + return False + if item_check == prompt_range.start.item: + # must be a text cursor, because has same index as TextScore + assert isinstance(prompt_range.start, TextCursor) + if pos_check < prompt_range.start.position: + return False + if item_check == prompt_range.end.item: + assert isinstance(prompt_range.end, TextCursor) # see above + if pos_check > prompt_range.end.position: + return False + return True diff --git a/src/intelligence_layer/core/tracer/__init__.py b/src/intelligence_layer/core/tracer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/core/tracer/composite_tracer.py b/src/intelligence_layer/core/tracer/composite_tracer.py new file mode 100644 index 000000000..02face451 --- /dev/null +++ b/src/intelligence_layer/core/tracer/composite_tracer.py @@ -0,0 +1,106 @@ +from datetime import datetime +from typing import Generic, Optional, Sequence + +from intelligence_layer.core.tracer.tracer import ( + PydanticSerializable, + Span, + SpanVar, + TaskSpan, + Tracer, + TracerVar, + utc_now, +) + + +class CompositeTracer(Tracer, Generic[TracerVar]): + """A :class:`Tracer` that allows for recording to multiple tracers simultaneously. + + Each log-entry and span will be forwarded to all subtracers. + + Args: + tracers: tracers that will be forwarded all subsequent log and span calls. + + Example: + >>> from intelligence_layer.core import InMemoryTracer, FileTracer, CompositeTracer, TextChunk + >>> from intelligence_layer.examples import PromptBasedClassify, ClassifyInput + + >>> tracer_1 = InMemoryTracer() + >>> tracer_2 = InMemoryTracer() + >>> tracer = CompositeTracer([tracer_1, tracer_2]) + >>> task = PromptBasedClassify() + >>> response = task.run(ClassifyInput(chunk=TextChunk("Cool"), labels=frozenset({"label", "other label"})), tracer) + """ + + def __init__(self, tracers: Sequence[TracerVar]) -> None: + assert len(tracers) > 0 + self.tracers = tracers + + def span( + self, + name: str, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "CompositeSpan[Span]": + timestamp = timestamp or utc_now() + trace_id = self.ensure_id(trace_id) + return CompositeSpan( + [tracer.span(name, timestamp, trace_id) for tracer in self.tracers] + ) + + def task_span( + self, + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "CompositeTaskSpan": + timestamp = timestamp or utc_now() + trace_id = self.ensure_id(trace_id) + return CompositeTaskSpan( + [ + tracer.task_span(task_name, input, timestamp, trace_id) + for tracer in self.tracers + ] + ) + + +class CompositeSpan(Generic[SpanVar], CompositeTracer[SpanVar], Span): + """A :class:`Span` that allows for recording to multiple spans simultaneously. + + Each log-entry and span will be forwarded to all subspans. + + Args: + tracers: spans that will be forwarded all subsequent log and span calls. + """ + + def id(self) -> str: + return self.tracers[0].id() + + def log( + self, + message: str, + value: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + timestamp = timestamp or utc_now() + for tracer in self.tracers: + tracer.log(message, value, timestamp) + + def end(self, timestamp: Optional[datetime] = None) -> None: + timestamp = timestamp or utc_now() + for tracer in self.tracers: + tracer.end(timestamp) + + +class CompositeTaskSpan(CompositeSpan[TaskSpan], TaskSpan): + """A :class:`TaskSpan` that allows for recording to multiple TaskSpans simultaneously. + + Each log-entry and span will be forwarded to all subspans. + + Args: + tracers: task spans that will be forwarded all subsequent log and span calls. + """ + + def record_output(self, output: PydanticSerializable) -> None: + for tracer in self.tracers: + tracer.record_output(output) diff --git a/src/intelligence_layer/core/tracer/file_tracer.py b/src/intelligence_layer/core/tracer/file_tracer.py new file mode 100644 index 000000000..d320134e0 --- /dev/null +++ b/src/intelligence_layer/core/tracer/file_tracer.py @@ -0,0 +1,101 @@ +from datetime import datetime +from json import loads +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel + +from intelligence_layer.core.tracer.in_memory_tracer import InMemoryTracer +from intelligence_layer.core.tracer.persistent_tracer import ( + PersistentSpan, + PersistentTaskSpan, + PersistentTracer, +) +from intelligence_layer.core.tracer.tracer import LogLine, PydanticSerializable + + +class FileTracer(PersistentTracer): + """A `Tracer` that logs to a file. + + Each log-entry is represented by a JSON object. The information logged allows + to reconstruct the hierarchical nature of the logs, i.e. all entries have a + _pointer_ to its parent element in form of a parent attribute containing + the uuid of the parent. + + Args: + log_file_path: Denotes the file to log to. + + Attributes: + uuid: a uuid for the tracer. If multiple :class:`FileTracer` instances log to the same file + the child-elements for a tracer can be identified by referring to this id as parent. + """ + + def __init__(self, log_file_path: Path | str) -> None: + super().__init__() + self._log_file_path = Path(log_file_path) + + def _log_entry(self, id: str, entry: BaseModel) -> None: + self._log_file_path.parent.mkdir(parents=True, exist_ok=True) + with self._log_file_path.open(mode="a", encoding="utf-8") as f: + f.write( + LogLine( + trace_id=id, entry_type=type(entry).__name__, entry=entry + ).model_dump_json() + + "\n" + ) + + def span( + self, + name: str, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "FileSpan": + span = FileSpan(self._log_file_path, trace_id=self.ensure_id(trace_id)) + self._log_span(span, name, timestamp) + return span + + def task_span( + self, + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "FileTaskSpan": + task = FileTaskSpan( + self._log_file_path, + trace_id=self.ensure_id(trace_id), + ) + self._log_task(task, task_name, input, timestamp) + return task + + def trace(self, trace_id: Optional[str] = None) -> InMemoryTracer: + with self._log_file_path.open("r") as f: + traces = (LogLine.model_validate(loads(line)) for line in f) + filtered_traces = ( + (line for line in traces if line.trace_id == trace_id) + if trace_id is not None + else traces + ) + return self._parse_log(filtered_traces) + + +class FileSpan(PersistentSpan, FileTracer): + """A `Span` created by `FileTracer.span`.""" + + def id(self) -> str: + return self.trace_id + + def __init__(self, log_file_path: Path, trace_id: str) -> None: + super().__init__(log_file_path) + self.trace_id = trace_id + + +class FileTaskSpan(PersistentTaskSpan, FileSpan): + """A `TaskSpan` created by `FileTracer.task_span`.""" + + def __init__( + self, + log_file_path: Path, + trace_id: str, + ) -> None: + super().__init__(log_file_path, trace_id) diff --git a/src/intelligence_layer/core/tracer/in_memory_tracer.py b/src/intelligence_layer/core/tracer/in_memory_tracer.py new file mode 100644 index 000000000..fbfda4b23 --- /dev/null +++ b/src/intelligence_layer/core/tracer/in_memory_tracer.py @@ -0,0 +1,222 @@ +import json +import os +from datetime import datetime +from typing import Optional, Union +from uuid import UUID + +import requests +import rich +from pydantic import BaseModel, Field, SerializeAsAny +from requests import HTTPError +from rich.tree import Tree + +from intelligence_layer.core.tracer.tracer import ( + EndSpan, + EndTask, + LogEntry, + LogLine, + PlainEntry, + PydanticSerializable, + Span, + StartSpan, + StartTask, + TaskSpan, + Tracer, + _render_log_value, + utc_now, +) + + +class InMemoryTracer(BaseModel, Tracer): + """Collects log entries in a nested structure, and keeps them in memory. + + If desired, the structure is serializable with Pydantic, so you can write out the JSON + representation to a file, or return via an API, or something similar. + + Attributes: + name: A descriptive name of what the tracer contains log entries about. + entries: A sequential list of log entries and/or nested InMemoryTracers with their own + log entries. + """ + + entries: list[Union[LogEntry, "InMemoryTaskSpan", "InMemorySpan"]] = [] + + def span( + self, + name: str, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "InMemorySpan": + child = InMemorySpan( + name=name, + start_timestamp=timestamp or utc_now(), + trace_id=self.ensure_id(trace_id), + ) + self.entries.append(child) + return child + + def task_span( + self, + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "InMemoryTaskSpan": + child = InMemoryTaskSpan( + name=task_name, + input=input, + start_timestamp=timestamp or utc_now(), + trace_id=self.ensure_id(trace_id), + ) + self.entries.append(child) + return child + + def _rich_render_(self) -> Tree: + """Renders the trace via classes in the `rich` package""" + tree = Tree(label="Trace") + + for log in self.entries: + tree.add(log._rich_render_()) + + return tree + + def _ipython_display_(self) -> None: + """Default rendering for Jupyter notebooks""" + + if not self.submit_to_trace_viewer(): + rich.print(self._rich_render_()) + + def submit_to_trace_viewer(self) -> bool: + """Submits the trace to the UI for visualization""" + trace_viewer_url = os.getenv("TRACE_VIEWER_URL", "http://localhost:3000") + trace_viewer_trace_upload = f"{trace_viewer_url}/trace" + try: + res = requests.post( + trace_viewer_trace_upload, json=json.loads(self.model_dump_json()) + ) + if res.status_code != 200: + raise HTTPError(res.status_code) + rich.print( + f"Open the [link={trace_viewer_url}]Trace Viewer[/link] to view the trace." + ) + return True + except requests.ConnectionError: + print( + f"Trace viewer not found under {trace_viewer_url}.\nConsider running it for a better viewing experience.\nIf it is, set `TRACE_VIEWER_URL` in the environment." + ) + return False + + +class InMemorySpan(InMemoryTracer, Span): + name: str + start_timestamp: datetime = Field(default_factory=datetime.utcnow) + end_timestamp: Optional[datetime] = None + trace_id: str + + def id(self) -> str: + return self.trace_id + + def log( + self, + message: str, + value: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + self.entries.append( + LogEntry( + message=message, + value=value, + timestamp=timestamp or utc_now(), + trace_id=self.id(), + ) + ) + + def end(self, timestamp: Optional[datetime] = None) -> None: + if not self.end_timestamp: + self.end_timestamp = timestamp or utc_now() + + def _rich_render_(self) -> Tree: + """Renders the trace via classes in the `rich` package""" + tree = Tree(label=self.name) + + for log in self.entries: + tree.add(log._rich_render_()) + + return tree + + +class InMemoryTaskSpan(InMemorySpan, TaskSpan): + input: SerializeAsAny[PydanticSerializable] + output: Optional[SerializeAsAny[PydanticSerializable]] = None + + def record_output(self, output: PydanticSerializable) -> None: + self.output = output + + def _rich_render_(self) -> Tree: + """Renders the trace via classes in the `rich` package""" + tree = Tree(label=self.name) + + tree.add(_render_log_value(self.input, "Input")) + + for log in self.entries: + tree.add(log._rich_render_()) + + tree.add(_render_log_value(self.output, "Output")) + + return tree + + +class TreeBuilder(BaseModel): + root: InMemoryTracer = InMemoryTracer() + tracers: dict[UUID, InMemoryTracer] = Field(default_factory=dict) + tasks: dict[UUID, InMemoryTaskSpan] = Field(default_factory=dict) + spans: dict[UUID, InMemorySpan] = Field(default_factory=dict) + + def start_task(self, log_line: LogLine) -> None: + start_task = StartTask.model_validate(log_line.entry) + child = InMemoryTaskSpan( + name=start_task.name, + input=start_task.input, + start_timestamp=start_task.start, + trace_id=start_task.trace_id, + ) + self.tracers[start_task.uuid] = child + self.tasks[start_task.uuid] = child + self.tracers.get(start_task.parent, self.root).entries.append(child) + + def end_task(self, log_line: LogLine) -> None: + end_task = EndTask.model_validate(log_line.entry) + task_span = self.tasks[end_task.uuid] + task_span.end_timestamp = end_task.end + task_span.record_output(end_task.output) + + def start_span(self, log_line: LogLine) -> None: + start_span = StartSpan.model_validate(log_line.entry) + child = InMemorySpan( + name=start_span.name, + start_timestamp=start_span.start, + trace_id=start_span.trace_id, + ) + self.tracers[start_span.uuid] = child + self.spans[start_span.uuid] = child + self.tracers.get(start_span.parent, self.root).entries.append(child) + + def end_span(self, log_line: LogLine) -> None: + end_span = EndSpan.model_validate(log_line.entry) + span = self.spans[end_span.uuid] + span.end_timestamp = end_span.end + + def plain_entry(self, log_line: LogLine) -> None: + plain_entry = PlainEntry.model_validate(log_line.entry) + entry = LogEntry( + message=plain_entry.message, + value=plain_entry.value, + timestamp=plain_entry.timestamp, + trace_id=plain_entry.trace_id, + ) + self.tracers[plain_entry.parent].entries.append(entry) + + +# Required for sphinx, see also: https://docs.pydantic.dev/2.4/errors/usage_errors/#class-not-fully-defined +InMemorySpan.model_rebuild() +InMemoryTracer.model_rebuild() diff --git a/src/intelligence_layer/core/tracer/open_telemetry_tracer.py b/src/intelligence_layer/core/tracer/open_telemetry_tracer.py new file mode 100644 index 000000000..82da828c5 --- /dev/null +++ b/src/intelligence_layer/core/tracer/open_telemetry_tracer.py @@ -0,0 +1,109 @@ +from datetime import datetime +from typing import Optional + +from opentelemetry.context import attach, detach +from opentelemetry.trace import Span as OpenTSpan +from opentelemetry.trace import Tracer as OpenTTracer +from opentelemetry.trace import set_span_in_context + +from intelligence_layer.core.tracer.tracer import ( + PydanticSerializable, + Span, + TaskSpan, + Tracer, + _serialize, +) + + +class OpenTelemetryTracer(Tracer): + """A `Tracer` that uses open telemetry.""" + + def __init__(self, tracer: OpenTTracer) -> None: + self._tracer = tracer + + def span( + self, + name: str, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "OpenTelemetrySpan": + trace_id = self.ensure_id(trace_id) + tracer_span = self._tracer.start_span( + name, + attributes={"trace_id": trace_id}, + start_time=None if not timestamp else _open_telemetry_timestamp(timestamp), + ) + token = attach(set_span_in_context(tracer_span)) + return OpenTelemetrySpan(tracer_span, self._tracer, token, trace_id) + + def task_span( + self, + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "OpenTelemetryTaskSpan": + trace_id = self.ensure_id(trace_id) + + tracer_span = self._tracer.start_span( + task_name, + attributes={"input": _serialize(input), "trace_id": trace_id}, + start_time=None if not timestamp else _open_telemetry_timestamp(timestamp), + ) + token = attach(set_span_in_context(tracer_span)) + return OpenTelemetryTaskSpan(tracer_span, self._tracer, token, trace_id) + + +class OpenTelemetrySpan(Span, OpenTelemetryTracer): + """A `Span` created by `OpenTelemetryTracer.span`.""" + + end_timestamp: Optional[datetime] = None + + def id(self) -> str: + return self._trace_id + + def __init__( + self, span: OpenTSpan, tracer: OpenTTracer, token: object, trace_id: str + ) -> None: + super().__init__(tracer) + self.open_ts_span = span + self._token = token + self._trace_id = trace_id + + def log( + self, + message: str, + value: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + self.open_ts_span.add_event( + message, + {"value": _serialize(value), "trace_id": self.id()}, + None if not timestamp else _open_telemetry_timestamp(timestamp), + ) + + def end(self, timestamp: Optional[datetime] = None) -> None: + detach(self._token) + self.open_ts_span.end( + _open_telemetry_timestamp(timestamp) if timestamp is not None else None + ) + + +class OpenTelemetryTaskSpan(TaskSpan, OpenTelemetrySpan): + """A `TaskSpan` created by `OpenTelemetryTracer.task_span`.""" + + output: Optional[PydanticSerializable] = None + + def __init__( + self, span: OpenTSpan, tracer: OpenTTracer, token: object, trace_id: str + ) -> None: + super().__init__(span, tracer, token, trace_id) + + def record_output(self, output: PydanticSerializable) -> None: + self.open_ts_span.set_attribute("output", _serialize(output)) + + +def _open_telemetry_timestamp(t: datetime) -> int: + # Open telemetry expects *nanoseconds* since epoch + t_float = t.timestamp() * 1e9 + return int(t_float) diff --git a/src/intelligence_layer/core/tracer/persistent_tracer.py b/src/intelligence_layer/core/tracer/persistent_tracer.py new file mode 100644 index 000000000..f836bc24a --- /dev/null +++ b/src/intelligence_layer/core/tracer/persistent_tracer.py @@ -0,0 +1,159 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Iterable, Optional +from uuid import uuid4 + +from pydantic import BaseModel + +from intelligence_layer.core.tracer.in_memory_tracer import InMemoryTracer, TreeBuilder +from intelligence_layer.core.tracer.tracer import ( + EndSpan, + EndTask, + LogLine, + PlainEntry, + PydanticSerializable, + Span, + StartSpan, + StartTask, + TaskSpan, + Tracer, + utc_now, +) + + +class PersistentTracer(Tracer, ABC): + def __init__(self) -> None: + self.uuid = uuid4() + + @abstractmethod + def _log_entry(self, id: str, entry: BaseModel) -> None: + pass + + @abstractmethod + def trace(self, trace_id: str) -> InMemoryTracer: + pass + + def _log_span( + self, span: "PersistentSpan", name: str, timestamp: Optional[datetime] = None + ) -> None: + self._log_entry( + span.id(), + StartSpan( + uuid=span.uuid, + parent=self.uuid, + name=name, + start=timestamp or utc_now(), + trace_id=span.id(), + ), + ) + + def _log_task( + self, + task_span: "PersistentTaskSpan", + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + try: + self._log_entry( + task_span.id(), + StartTask( + uuid=task_span.uuid, + parent=self.uuid, + name=task_name, + start=timestamp or utc_now(), + input=input, + trace_id=task_span.id(), + ), + ) + except TracerLogEntryFailed as error: + self._log_entry( + task_span.id(), + StartTask( + uuid=task_span.uuid, + parent=self.uuid, + name=task_name, + start=timestamp or utc_now(), + input=error.description, + trace_id=task_span.id(), + ), + ) + + def _parse_log(self, log_entries: Iterable[LogLine]) -> InMemoryTracer: + tree_builder = TreeBuilder() + for log_line in log_entries: + if log_line.entry_type == StartTask.__name__: + tree_builder.start_task(log_line) + elif log_line.entry_type == EndTask.__name__: + tree_builder.end_task(log_line) + elif log_line.entry_type == StartSpan.__name__: + tree_builder.start_span(log_line) + elif log_line.entry_type == EndSpan.__name__: + tree_builder.end_span(log_line) + elif log_line.entry_type == PlainEntry.__name__: + tree_builder.plain_entry(log_line) + else: + raise RuntimeError(f"Unexpected entry_type in {log_line}") + assert tree_builder.root + return tree_builder.root + + +class PersistentSpan(Span, PersistentTracer, ABC): + end_timestamp: Optional[datetime] = None + + def log( + self, + message: str, + value: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + try: + self._log_entry( + self.id(), + PlainEntry( + message=message, + value=value, + timestamp=timestamp or utc_now(), + parent=self.uuid, + trace_id=self.id(), + ), + ) + except TracerLogEntryFailed as error: + self._log_entry( + self.id(), + PlainEntry( + message="log entry failed", + value=error.description, + timestamp=timestamp or utc_now(), + parent=self.uuid, + trace_id=self.id(), + ), + ) + + def end(self, timestamp: Optional[datetime] = None) -> None: + if not self.end_timestamp: + self.end_timestamp = timestamp or utc_now() + self._log_entry(self.id(), EndSpan(uuid=self.uuid, end=self.end_timestamp)) + + +class PersistentTaskSpan(TaskSpan, PersistentSpan, ABC): + output: Optional[PydanticSerializable] = None + + def record_output(self, output: PydanticSerializable) -> None: + self.output = output + + def end(self, timestamp: Optional[datetime] = None) -> None: + if not self.end_timestamp: + self.end_timestamp = timestamp or utc_now() + self._log_entry( + self.id(), + EndTask(uuid=self.uuid, end=self.end_timestamp, output=self.output), + ) + + +class TracerLogEntryFailed(Exception): + def __init__(self, error_message: str, id: str) -> None: + super().__init__( + f"Log entry with id {id} failed with error message {error_message}." + ) + self.description = error_message diff --git a/src/intelligence_layer/core/tracer/tracer.py b/src/intelligence_layer/core/tracer/tracer.py new file mode 100644 index 000000000..b4654d21c --- /dev/null +++ b/src/intelligence_layer/core/tracer/tracer.py @@ -0,0 +1,447 @@ +import traceback +from abc import ABC, abstractmethod +from contextlib import AbstractContextManager +from datetime import datetime, timezone +from types import TracebackType +from typing import TYPE_CHECKING, Mapping, Optional, Sequence, TypeVar +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field, RootModel, SerializeAsAny +from rich.panel import Panel +from rich.syntax import Syntax +from typing_extensions import Self, TypeAliasType + +if TYPE_CHECKING: + PydanticSerializable = ( + int + | float + | str + | None + | bool + | BaseModel + | UUID + | Sequence["PydanticSerializable"] + | set["PydanticSerializable"] + | frozenset["PydanticSerializable"] + | Mapping[str, "PydanticSerializable"] + ) +else: + PydanticSerializable = TypeAliasType( + "PydanticSerializable", + int + | float + | str + | None + | bool + | BaseModel + | UUID + | Sequence["PydanticSerializable"] + | set["PydanticSerializable"] + | frozenset["PydanticSerializable"] + | Mapping[str, "PydanticSerializable"], + ) + + +def utc_now() -> datetime: + """return datetime object with utc timezone. + + datetime.utcnow() returns a datetime object without timezone, so this function is preferred. + """ + return datetime.now(timezone.utc) + + +class Tracer(ABC): + """Provides a consistent way to instrument a :class:`Task` with logging for each step of the + workflow. + + A tracer needs to provide a way to collect an individual log, which should be serializable, and + a way to generate nested spans, so that sub-tasks can emit logs that are grouped together. + + Implementations of how logs are collected and stored may differ. Refer to the individual + documentation of each implementation to see how to use the resulting tracer. + """ + + @abstractmethod + def span( + self, + name: str, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "Span": + """Generate a span from the current span or logging instance. + + Allows for grouping multiple logs and duration together as a single, logical step in the + process. + + Each tracer implementation can decide on how it wants to represent this, but they should + all capture the hierarchical nature of nested spans, as well as the idea of the duration of + the span. + + Args: + name: A descriptive name of what this span will contain logs about. + timestamp: optional override of the starting timestamp. Otherwise should default to now. + trace_id: optional override of a trace id. Otherwise it creates a new default id. + + Returns: + An instance of a Span. + """ + ... + + @abstractmethod + def task_span( + self, + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "TaskSpan": + """Generate a task-specific span from the current span or logging instance. + + Allows for grouping multiple logs together, as well as the task's specific input, output, + and duration. + + Each tracer implementation can decide on how it wants to represent this, but they should + all allow for representing logs of a span within the context of a parent span. + + Args: + task_name: The name of the task that is being logged + input: The input for the task that is being logged. + timestamp: optional override of the starting timestamp. Otherwise should default to now. + trace_id: optional override of a trace id. Otherwise it creates a new default id. + + + Returns: + An instance of a TaskSpan. + """ + ... + + def ensure_id(self, id: Optional[str]) -> str: + """Returns a valid id for tracing. + + Args: + id: current id to use if present. + Returns: + `id` if present, otherwise a new unique ID. + """ + + return id if id is not None else str(uuid4()) + + +class ErrorValue(BaseModel): + error_type: str + message: str + stack_trace: str + + +class Span(Tracer, AbstractContextManager["Span"]): + """Captures a logical step within the overall workflow + + Logs and other spans can be nested underneath. + + Can also be used as a Context Manager to easily capture the start and end time, and keep the + span only in scope while it is active. + """ + + @abstractmethod + def id(self) -> str: + pass + + def __enter__(self) -> Self: + return self + + @abstractmethod + def log( + self, + message: str, + value: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + """Record a log of relevant information as part of a step within a task. + + By default, the `Input` and `Output` of each :class:`Task` are logged automatically, but + you can log anything else that seems relevant to understanding the process of a given task. + + Args: + message: A description of the value you are logging, such as the step in the task this + is related to. + value: The relevant data you want to log. Can be anything that is serializable by + Pydantic, which gives the tracers flexibility in how they store and emit the logs. + timestamp: optional override of the timestamp. Otherwise should default to now + """ + ... + + @abstractmethod + def end(self, timestamp: Optional[datetime] = None) -> None: + """Marks the Span as done, with the end time of the span. The Span should be regarded + as complete, and no further logging should happen with it. + + Args: + timestamp: Optional override of the timestamp, otherwise should be set to now. + """ + ... + + def ensure_id(self, id: str | None) -> str: + """Returns a valid id for tracing. + + Args: + id: current id to use if present. + Returns: + `id` if present, otherwise id of this `Span` + """ + return id if id is not None else self.id() + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + _traceback: Optional[TracebackType], + ) -> None: + if exc_type is not None and exc_value is not None and _traceback is not None: + error_value = ErrorValue( + error_type=str(exc_type.__qualname__), + message=str(exc_value), + stack_trace=str(traceback.format_exc()), + ) + self.log(error_value.message, error_value) + self.end() + + +class TaskSpan(Span): + """Specialized span for instrumenting :class:`Task` input, output, and nested spans and logs. + + Generating this TaskSpan should capture the :class:`Task` input, as well as the task name. + + Can also be used as a Context Manager to easily capture the start and end time of the task, + and keep the span only in scope while it is active + """ + + @abstractmethod + def record_output(self, output: PydanticSerializable) -> None: + """Record :class:`Task` output. Since a Context Manager can't provide this in the `__exit__` + method, output should be captured once it is generated. + + This should be handled automatically within the execution of the task, and it is + unlikely this would be called directly by you. + + Args: + output: The output of the task that is being logged. + """ + ... + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + _traceback: Optional[TracebackType], + ) -> None: + if exc_type is not None and exc_value is not None and _traceback is not None: + error_value = ErrorValue( + error_type=str(exc_type.__qualname__), + message=str(exc_value), + stack_trace=str(traceback.format_exc()), + ) + self.record_output(error_value) + self.end() + + +TracerVar = TypeVar("TracerVar", bound=Tracer) + +SpanVar = TypeVar("SpanVar", bound=Span) + + +class NoOpTracer(TaskSpan): + """A no-op tracer. + + Useful for cases, like testing, where a tracer is needed for a task, but you + don't have a need to collect or inspect the actual logs. + + All calls to `log` won't actually do anything. + """ + + def id(self) -> str: + return "NoOp" + + def log( + self, + message: str, + value: PydanticSerializable, + timestamp: Optional[datetime] = None, + ) -> None: + pass + + def span( + self, + name: str, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "NoOpTracer": + return self + + def task_span( + self, + task_name: str, + input: PydanticSerializable, + timestamp: Optional[datetime] = None, + trace_id: Optional[str] = None, + ) -> "NoOpTracer": + return self + + def record_output(self, output: PydanticSerializable) -> None: + pass + + def end(self, timestamp: Optional[datetime] = None) -> None: + pass + + +class JsonSerializer(RootModel[PydanticSerializable]): + root: SerializeAsAny[PydanticSerializable] + + +def _render_log_value(value: PydanticSerializable, title: str) -> Panel: + value = value if isinstance(value, BaseModel) else JsonSerializer(root=value) + return Panel( + Syntax( + value.model_dump_json(indent=2, exclude_defaults=True), + "json", + word_wrap=True, + ), + title=title, + ) + + +class LogEntry(BaseModel): + """An individual log entry, currently used to represent individual logs by the + `InMemoryTracer`. + + Attributes: + message: A description of the value you are logging, such as the step in the task this + is related to. + value: The relevant data you want to log. Can be anything that is serializable by + Pydantic, which gives the tracers flexibility in how they store and emit the logs. + timestamp: The time that the log was emitted. + id: The ID of the trace to which this log entry belongs. + """ + + message: str + value: SerializeAsAny[PydanticSerializable] + timestamp: datetime = Field(default_factory=datetime.utcnow) + trace_id: str + + def id(self) -> str: + return self.trace_id + + def _rich_render_(self) -> Panel: + """Renders the trace via classes in the `rich` package""" + return _render_log_value(self.value, self.message) + + def _ipython_display_(self) -> None: + """Default rendering for Jupyter notebooks""" + from rich import print + + print(self._rich_render_()) + + +class StartTask(BaseModel): + """Represents the payload/entry of a log-line indicating that a `TaskSpan` was opened through `Tracer.task_span`. + + Attributes: + uuid: A unique id for the opened `TaskSpan`. + parent: The unique id of the parent element of opened `TaskSpan`. + This could refer to either a surrounding `TaskSpan`, `Span` or the top-level `Tracer`. + name: The name of the task. + start: The timestamp when this `Task` was started (i.e. `run` was called). + input: The `Input` (i.e. parameter for `run`) the `Task` was started with. + trace_id: The trace id of the opened `TaskSpan`. + + """ + + uuid: UUID + parent: UUID + name: str + start: datetime + input: SerializeAsAny[PydanticSerializable] + trace_id: str + + +class EndTask(BaseModel): + """Represents the payload/entry of a log-line that indicates that a `TaskSpan` ended (i.e. the context-manager exited). + + Attributes: + uuid: The uuid of the corresponding `StartTask`. + end: the timestamp when this `Task` completed (i.e. `run` returned). + output: the `Output` (i.e. return value of `run`) the `Task` returned. + """ + + uuid: UUID + end: datetime + output: SerializeAsAny[PydanticSerializable] + + +class StartSpan(BaseModel): + """Represents the payload/entry of a log-line indicating that a `Span` was opened through `Tracer.span`. + + Attributes: + uuid: A unique id for the opened `Span`. + parent: The unique id of the parent element of opened `TaskSpan`. + This could refer to either a surrounding `TaskSpan`, `Span` or the top-level `Tracer`. + name: The name of the task. + start: The timestamp when this `Span` was started. + trace_id: The ID of the trace this span belongs to. + """ + + uuid: UUID + parent: UUID + name: str + start: datetime + trace_id: str + + +class EndSpan(BaseModel): + """Represents the payload/entry of a log-line that indicates that a `Span` ended. + + Attributes: + uuid: The uuid of the corresponding `StartSpan`. + end: the timestamp when this `Span` completed. + """ + + uuid: UUID + end: datetime + + +class PlainEntry(BaseModel): + """Represents a plain log-entry created through `Tracer.log`. + + Attributes: + message: the message-parameter of `Tracer.log` + value: the value-parameter of `Tracer.log` + timestamp: the timestamp when `Tracer.log` was called. + parent: The unique id of the parent element of the log. + This could refer to either a surrounding `TaskSpan`, `Span` or the top-level `Tracer`. + trace_id: The ID of the trace this entry belongs to. + """ + + message: str + value: SerializeAsAny[PydanticSerializable] + timestamp: datetime + parent: UUID + trace_id: str + + +class LogLine(BaseModel): + """Represents a complete log-line. + + Attributes: + entry_type: The type of the entry. This is the class-name of one of the classes + representing a log-entry (e.g. "StartTask"). + entry: The actual entry. + + """ + + trace_id: str + entry_type: str + entry: SerializeAsAny[PydanticSerializable] + + +def _serialize(s: SerializeAsAny[PydanticSerializable]) -> str: + value = s if isinstance(s, BaseModel) else JsonSerializer(root=s) + return value.model_dump_json() diff --git a/src/intelligence_layer/evaluation/__init__.py b/src/intelligence_layer/evaluation/__init__.py new file mode 100644 index 000000000..2b978abba --- /dev/null +++ b/src/intelligence_layer/evaluation/__init__.py @@ -0,0 +1,109 @@ +from .aggregation.accumulator import MeanAccumulator as MeanAccumulator +from .aggregation.aggregation_repository import ( + AggregationRepository as AggregationRepository, +) +from .aggregation.aggregator import AggregationLogic as AggregationLogic +from .aggregation.aggregator import Aggregator as Aggregator +from .aggregation.argilla_aggregator import ( + AggregatedInstructComparison as AggregatedInstructComparison, +) +from .aggregation.argilla_aggregator import ArgillaAggregator as ArgillaAggregator +from .aggregation.argilla_aggregator import ( + InstructComparisonArgillaAggregationLogic as InstructComparisonArgillaAggregationLogic, +) +from .aggregation.argilla_aggregator import PlayerScore as PlayerScore +from .aggregation.domain import AggregatedEvaluation as AggregatedEvaluation +from .aggregation.domain import AggregationOverview as AggregationOverview +from .aggregation.elo import EloCalculator as EloCalculator +from .aggregation.elo import MatchOutcome as MatchOutcome +from .aggregation.elo import WinRateCalculator as WinRateCalculator +from .aggregation.file_aggregation_repository import ( + FileAggregationRepository as FileAggregationRepository, +) +from .aggregation.hugging_face_aggregation_repository import ( + HuggingFaceAggregationRepository as HuggingFaceAggregationRepository, +) +from .aggregation.in_memory_aggregation_repository import ( + InMemoryAggregationRepository as InMemoryAggregationRepository, +) +from .dataset.dataset_repository import DatasetRepository as DatasetRepository +from .dataset.domain import Dataset as Dataset +from .dataset.domain import Example as Example +from .dataset.domain import ExpectedOutput as ExpectedOutput +from .dataset.file_dataset_repository import ( + FileDatasetRepository as FileDatasetRepository, +) +from .dataset.hugging_face_dataset_repository import ( + HuggingFaceDatasetRepository as HuggingFaceDatasetRepository, +) +from .dataset.in_memory_dataset_repository import ( + InMemoryDatasetRepository as InMemoryDatasetRepository, +) +from .dataset.single_huggingface_dataset_repository import ( + MultipleChoiceInput as MultipleChoiceInput, +) +from .dataset.single_huggingface_dataset_repository import ( + SingleHuggingfaceDatasetRepository as SingleHuggingfaceDatasetRepository, +) +from .evaluation.argilla_evaluation_repository import ( + ArgillaEvaluationRepository as ArgillaEvaluationRepository, +) +from .evaluation.argilla_evaluation_repository import ( + RecordDataSequence as RecordDataSequence, +) +from .evaluation.argilla_evaluator import ( + ArgillaEvaluationLogic as ArgillaEvaluationLogic, +) +from .evaluation.argilla_evaluator import ArgillaEvaluator as ArgillaEvaluator +from .evaluation.argilla_evaluator import ( + InstructComparisonArgillaEvaluationLogic as InstructComparisonArgillaEvaluationLogic, +) +from .evaluation.domain import Evaluation as Evaluation +from .evaluation.domain import EvaluationFailed as EvaluationFailed +from .evaluation.domain import EvaluationOverview as EvaluationOverview +from .evaluation.domain import ExampleEvaluation as ExampleEvaluation +from .evaluation.domain import FailedExampleEvaluation as FailedExampleEvaluation +from .evaluation.evaluation_repository import ( + EvaluationRepository as EvaluationRepository, +) +from .evaluation.evaluator import EvaluationLogic as EvaluationLogic +from .evaluation.evaluator import Evaluator as Evaluator +from .evaluation.evaluator import ( + SingleOutputEvaluationLogic as SingleOutputEvaluationLogic, +) +from .evaluation.file_evaluation_repository import ( + FileEvaluationRepository as FileEvaluationRepository, +) +from .evaluation.graders import BleuGrader as BleuGrader +from .evaluation.graders import FScores as FScores +from .evaluation.graders import HighlightCoverageGrader as HighlightCoverageGrader +from .evaluation.graders import LanguageMatchesGrader as LanguageMatchesGrader +from .evaluation.graders import RougeGrader as RougeGrader +from .evaluation.in_memory_evaluation_repository import ( + InMemoryEvaluationRepository as InMemoryEvaluationRepository, +) +from .infrastructure.hugging_face_repository import ( + HuggingFaceRepository as HuggingFaceRepository, +) +from .infrastructure.repository_navigator import ( + RepositoryNavigator as RepositoryNavigator, +) +from .infrastructure.repository_navigator import ( + evaluation_lineages_to_pandas as evaluation_lineages_to_pandas, +) +from .infrastructure.repository_navigator import ( + run_lineages_to_pandas as run_lineages_to_pandas, +) +from .run.domain import ExampleOutput as ExampleOutput +from .run.domain import RunOverview as RunOverview +from .run.domain import SuccessfulExampleOutput as SuccessfulExampleOutput +from .run.file_run_repository import FileRunRepository as FileRunRepository +from .run.in_memory_run_repository import InMemoryRunRepository as InMemoryRunRepository +from .run.run_repository import RunRepository as RunRepository +from .run.runner import Runner as Runner +from .run.trace import ExampleTrace as ExampleTrace +from .run.trace import LogTrace as LogTrace +from .run.trace import SpanTrace as SpanTrace +from .run.trace import TaskSpanTrace as TaskSpanTrace + +__all__ = [symbol for symbol in dir()] diff --git a/src/intelligence_layer/evaluation/aggregation/accumulator.py b/src/intelligence_layer/evaluation/aggregation/accumulator.py new file mode 100644 index 000000000..f5ce0cf30 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/accumulator.py @@ -0,0 +1,66 @@ +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +T = TypeVar("T") +Output = TypeVar("Output") + + +class Accumulator(ABC, Generic[T, Output]): + """Used for incremental computation. + + For use cases with large amount of data where you don't want to have every value in memory at once, e.g. evaluation. + """ + + @abstractmethod + def add(self, value: T) -> None: + """Responsible for accumulating values + + Args: + value: the value to add + Returns: + nothing + """ + ... + + @abstractmethod + def extract(self) -> Output: + """Accumulates the final result + + Returns: + float: 0.0 if no values were added before, else the mean + """ + ... + + +class MeanAccumulator(Accumulator[float, float]): + def __init__(self) -> None: + self._n = 0 + self._acc = 0.0 + self._squares_acc = 0.0 # Sum of squares of the values + + def add(self, value: float) -> None: + self._n += 1 + self._acc += value + self._squares_acc += value**2 + + def extract(self) -> float: + """Accumulates the mean + + :return: 0.0 if no values were added before, else the mean""" + return 0.0 if self._n == 0 else self._acc / self._n + + def standard_deviation(self) -> float: + """Calculates the standard deviation""" + if self._n == 0: + return 0.0 + mean = self.extract() + variance = (self._squares_acc / self._n) - (mean**2) + # not recognized as float by VSCode or mypy + return variance**0.5 # type: ignore + + def standard_error(self) -> float: + """Calculates the standard error of the mean""" + if self._n <= 1: + return 0.0 + # not recognized as float by VSCode or mypy + return self.standard_deviation() / (self._n**0.5) # type: ignore diff --git a/src/intelligence_layer/evaluation/aggregation/aggregation_repository.py b/src/intelligence_layer/evaluation/aggregation/aggregation_repository.py new file mode 100644 index 000000000..b89180213 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/aggregation_repository.py @@ -0,0 +1,67 @@ +from abc import ABC, abstractmethod +from typing import Iterable, Optional, Sequence + +from intelligence_layer.evaluation.aggregation.domain import ( + AggregatedEvaluation, + AggregationOverview, +) + + +class AggregationRepository(ABC): + """Base aggregation repository interface. + + Provides methods to store and load aggregated evaluation results: :class:`AggregationOverview`. + """ + + @abstractmethod + def store_aggregation_overview( + self, aggregation_overview: AggregationOverview[AggregatedEvaluation] + ) -> None: + """Stores an :class:`AggregationOverview`. + + Args: + aggregation_overview: The aggregated results to be persisted. + """ + ... + + @abstractmethod + def aggregation_overview( + self, aggregation_id: str, aggregation_type: type[AggregatedEvaluation] + ) -> Optional[AggregationOverview[AggregatedEvaluation]]: + """Returns an :class:`AggregationOverview` for the given ID. + + Args: + aggregation_id: ID of the aggregation overview to retrieve. + aggregation_type: Type of the aggregation. + + Returns: + :class:`EvaluationOverview` if it was found, `None` otherwise. + """ + ... + + def aggregation_overviews( + self, aggregation_type: type[AggregatedEvaluation] + ) -> Iterable[AggregationOverview[AggregatedEvaluation]]: + """Returns all :class:`AggregationOverview`s sorted by their ID. + + Args: + aggregation_type: Type of the aggregation. + + Returns: + An :class:`Iterable` of :class:`AggregationOverview`s. + """ + for aggregation_id in self.aggregation_overview_ids(): + aggregation_overview = self.aggregation_overview( + aggregation_id, aggregation_type + ) + if aggregation_overview is not None: + yield aggregation_overview + + @abstractmethod + def aggregation_overview_ids(self) -> Sequence[str]: + """Returns sorted IDs of all stored :class:`AggregationOverview`s. + + Returns: + A :class:`Sequence` of the :class:`AggregationOverview` IDs. + """ + pass diff --git a/src/intelligence_layer/evaluation/aggregation/aggregator.py b/src/intelligence_layer/evaluation/aggregation/aggregator.py new file mode 100644 index 000000000..a03cf4210 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/aggregator.py @@ -0,0 +1,243 @@ +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import ( + Callable, + Generic, + Iterable, + Iterator, + Mapping, + TypeVar, + cast, + final, + get_args, + get_origin, +) +from uuid import uuid4 + +from intelligence_layer.core import utc_now +from intelligence_layer.evaluation.aggregation.aggregation_repository import ( + AggregationRepository, +) +from intelligence_layer.evaluation.aggregation.domain import ( + AggregatedEvaluation, + AggregationOverview, +) +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + EvaluationOverview, + FailedExampleEvaluation, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, +) + +T = TypeVar("T") + + +class CountingFilterIterable(Iterable[T]): + def __init__( + self, wrapped_iterable: Iterable[T], filter: Callable[[T], bool] + ) -> None: + self._wrapped_iterator = iter(wrapped_iterable) + self._filter = filter + self._included_count = 0 + self._excluded_count = 0 + + def __next__(self) -> T: + e = next(self._wrapped_iterator) + while not self._filter(e): + self._excluded_count += 1 + e = next(self._wrapped_iterator) + self._included_count += 1 + return e + + def __iter__(self) -> Iterator[T]: + return self + + def included_count(self) -> int: + return self._included_count + + def excluded_count(self) -> int: + return self._excluded_count + + +class AggregationLogic(ABC, Generic[Evaluation, AggregatedEvaluation]): + @abstractmethod + def aggregate(self, evaluations: Iterable[Evaluation]) -> AggregatedEvaluation: + """`Evaluator`-specific method for aggregating individual `Evaluations` into report-like `Aggregated Evaluation`. + + This method is responsible for taking the results of an evaluation run and aggregating all the results. + It should create an `AggregatedEvaluation` class and return it at the end. + + Args: + evaluations: The results from running `eval_and_aggregate_runs` with a :class:`Task`. + + Returns: + The aggregated results of an evaluation run with a :class:`Dataset`. + """ + ... + + +class Aggregator(Generic[Evaluation, AggregatedEvaluation]): + """Aggregator that can handle automatic aggregation of evaluation scenarios. + + This aggregator should be used for automatic eval. A user still has to implement + :class: `AggregationLogic`. + + + Arguments: + evaluation_repository: The repository that will be used to store evaluation results. + aggregation_repository: The repository that will be used to store aggregation results. + description: Human-readable description for the evaluator. + aggregation_logic: The logic to aggregate the evaluations. + + Generics: + Evaluation: Interface of the metrics that come from the evaluated :class:`Task`. + AggregatedEvaluation: The aggregated results of an evaluation run with a :class:`Dataset`. + """ + + def __init__( + self, + evaluation_repository: EvaluationRepository, + aggregation_repository: AggregationRepository, + description: str, + aggregation_logic: AggregationLogic[Evaluation, AggregatedEvaluation], + ) -> None: + self._evaluation_repository = evaluation_repository + self._aggregation_repository = aggregation_repository + self._aggregation_logic = aggregation_logic + self.description = description + + @lru_cache(maxsize=1) + def _get_types(self) -> Mapping[str, type]: + """Type magic function that gets the actual types of the generic parameters. + + Traverses the inheritance history of `BaseEvaluator`-subclass to find an actual type every time a TypeVar is replaced. + + Returns: + Name of generic parameter to the type found. + """ + + def is_eligible_subclass(parent: type) -> bool: + return hasattr(parent, "__orig_bases__") and issubclass( + parent, AggregationLogic + ) + + def update_types() -> None: + num_types_set = 0 + for current_index, current_type in enumerate(current_types): + if type(current_type) is not TypeVar: + type_var_count = num_types_set - 1 + for element_index, element in enumerate(type_list): + if type(element) is TypeVar: + type_var_count += 1 + if type_var_count == current_index: + break + assert type_var_count == current_index + type_list[element_index] = current_type + num_types_set += 1 + + # mypy does not know __orig_bases__ + base_types = AggregationLogic.__orig_bases__[1] # type: ignore + type_list: list[type | TypeVar] = list(get_args(base_types)) + + possible_parent_classes = [ + p + for p in reversed(type(self._aggregation_logic).__mro__) + if is_eligible_subclass(p) + ] + for parent in possible_parent_classes: + # mypy does not know __orig_bases__ + for base in parent.__orig_bases__: # type: ignore + origin = get_origin(base) + if origin is None or not issubclass(origin, AggregationLogic): + continue + current_types = list(get_args(base)) + update_types() + + return { + name: param_type + for name, param_type in zip( + (a.__name__ for a in get_args(base_types)), type_list + ) + if type(param_type) is not TypeVar + } + + def evaluation_type(self) -> type[Evaluation]: + """Returns the type of the evaluation result of an example. + + This can be used to retrieve properly typed evaluations of an evaluation run + from a :class:`EvaluationRepository` + + Returns: + Returns the type of the evaluation result of an example. + """ + try: + evaluation_type = self._get_types()["Evaluation"] + except KeyError: + raise TypeError( + f"Alternatively overwrite evaluation_type() in {type(self)}" + ) + return cast(type[Evaluation], evaluation_type) + + @final + def aggregate_evaluation( + self, *eval_ids: str + ) -> AggregationOverview[AggregatedEvaluation]: + """Aggregates all evaluations into an overview that includes high-level statistics. + + Aggregates :class:`Evaluation`s according to the implementation of :func:`BaseEvaluator.aggregate`. + + Args: + eval_ids: An overview of the evaluation to be aggregated. Does not include + actual evaluations as these will be retrieved from the repository. + + Returns: + An overview of the aggregated evaluation. + """ + + def load_eval_overview(evaluation_id: str) -> EvaluationOverview: + evaluation_overview = self._evaluation_repository.evaluation_overview( + evaluation_id + ) + if not evaluation_overview: + raise ValueError( + f"No PartialEvaluationOverview found for eval-id: {evaluation_id}" + ) + return evaluation_overview + + evaluation_overviews = frozenset( + load_eval_overview(evaluation_id) for evaluation_id in set(eval_ids) + ) + + nested_evaluations = [ + self._evaluation_repository.example_evaluations( + overview.id, self.evaluation_type() + ) + for overview in evaluation_overviews + ] + example_evaluations = [ + evaluation for sublist in nested_evaluations for evaluation in sublist + ] + + successful_evaluations = CountingFilterIterable( + (example_eval.result for example_eval in example_evaluations), + lambda evaluation: not isinstance(evaluation, FailedExampleEvaluation), + ) + start = utc_now() + statistics = self._aggregation_logic.aggregate( + cast(Iterable[Evaluation], successful_evaluations) + ) + + aggregation_overview = AggregationOverview( + evaluation_overviews=frozenset(evaluation_overviews), + id=str(uuid4()), + start=start, + end=utc_now(), + successful_evaluation_count=successful_evaluations.included_count(), + crashed_during_evaluation_count=successful_evaluations.excluded_count(), + description=self.description, + statistics=statistics, + ) + self._aggregation_repository.store_aggregation_overview(aggregation_overview) + return aggregation_overview diff --git a/src/intelligence_layer/evaluation/aggregation/argilla_aggregator.py b/src/intelligence_layer/evaluation/aggregation/argilla_aggregator.py new file mode 100644 index 000000000..3785cec19 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/argilla_aggregator.py @@ -0,0 +1,117 @@ +import random +from abc import ABC +from collections import Counter +from typing import Iterable, Mapping + +from pydantic import BaseModel + +from intelligence_layer.connectors.argilla.argilla_client import ArgillaEvaluation +from intelligence_layer.evaluation.aggregation.accumulator import MeanAccumulator +from intelligence_layer.evaluation.aggregation.aggregation_repository import ( + AggregationRepository, +) +from intelligence_layer.evaluation.aggregation.aggregator import ( + AggregationLogic, + Aggregator, +) +from intelligence_layer.evaluation.aggregation.domain import AggregatedEvaluation +from intelligence_layer.evaluation.aggregation.elo import ( + EloCalculator, + MatchOutcome, + WinRateCalculator, +) +from intelligence_layer.evaluation.evaluation.argilla_evaluation_repository import ( + ArgillaEvaluationRepository, +) + + +class ArgillaAggregator( + Aggregator[ArgillaEvaluation, AggregatedEvaluation], + ABC, +): + """Aggregator used to aggregate Argilla (https://github.com/argilla-io/argilla) evaluations. + + You can fetch the results by using the `aggregate_evaluation` method. + + Arguments: + evaluation_repository: The repository that will be used to store evaluation results. + aggregation_repository: The repository that will be used to store aggregation results. + description: Human-readable description for the evaluator. + aggregation_logic: The logic to aggregate the evaluations. + + Generics: + ArgillaEvaluation: Interface of the metrics that come from the Argilla task`. + AggregatedEvaluation: The aggregated results of an evaluation run with a :class:`Dataset`. + """ + + def evaluation_type(self) -> type[ArgillaEvaluation]: # type: ignore + return ArgillaEvaluation + + def __init__( + self, + evaluation_repository: ArgillaEvaluationRepository, + aggregation_repository: AggregationRepository, + description: str, + aggregation_logic: AggregationLogic[ArgillaEvaluation, AggregatedEvaluation], + ) -> None: + super().__init__( + evaluation_repository, + aggregation_repository, + description, + aggregation_logic, + ) + + +class PlayerScore(BaseModel): + elo: float + elo_standard_error: float + win_rate: float + num_matches: int + + +class AggregatedInstructComparison(BaseModel): + scores: Mapping[str, PlayerScore] + + +class InstructComparisonArgillaAggregationLogic( + AggregationLogic[ArgillaEvaluation, AggregatedInstructComparison] +): + def aggregate( + self, evaluations: Iterable[ArgillaEvaluation] + ) -> AggregatedInstructComparison: + flattened_evaluations = [ + ( + evaluation.metadata["first"], + evaluation.metadata["second"], + MatchOutcome.from_rank_literal(int(evaluation.responses["winner"])), + ) + for evaluation in evaluations + ] + player_counter = Counter( + player for match in flattened_evaluations for player in [match[0], match[1]] + ) + player_counts = dict(player_counter) + players = player_counts.keys() + + accumulators = {p: MeanAccumulator() for p in players} + for _ in range(100): + elo_calc = EloCalculator(players) + random.shuffle(flattened_evaluations) + elo_calc.calculate(flattened_evaluations) + for p in players: + accumulators[p].add(elo_calc.ratings[p]) + + win_rate_calc = WinRateCalculator(players) + win_rate = win_rate_calc.calculate(flattened_evaluations) + + return AggregatedInstructComparison( + scores={ + p: PlayerScore( + elo=acc.extract(), + elo_standard_error=acc.standard_error(), + win_rate=win_rate[p], + num_matches=player_counts[p], + ) + for p, acc in accumulators.items() + }, + ) diff --git a/src/intelligence_layer/evaluation/aggregation/domain.py b/src/intelligence_layer/evaluation/aggregation/domain.py new file mode 100644 index 000000000..eec08d7d1 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/domain.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import Generic, Iterable, Sequence, TypeVar + +from pydantic import BaseModel, SerializeAsAny + +from intelligence_layer.evaluation.evaluation.domain import ( + EvaluationFailed, + EvaluationOverview, +) +from intelligence_layer.evaluation.run.domain import RunOverview + +AggregatedEvaluation = TypeVar("AggregatedEvaluation", bound=BaseModel, covariant=True) + + +class AggregationOverview(BaseModel, Generic[AggregatedEvaluation], frozen=True): + """Complete overview of the results of evaluating a :class:`Task` on a dataset. + + Created when running :meth:`Evaluator.eval_and_aggregate_runs`. Contains high-level information and statistics. + + Attributes: + evaluation_overviews: :class:`EvaluationOverview`s used for aggregation. + id: Aggregation overview ID. + start: Start timestamp of the aggregation. + end: End timestamp of the aggregation. + end: The time when the evaluation run ended + successful_evaluation_count: The number of examples that where successfully evaluated. + crashed_during_evaluation_count: The number of examples that crashed during evaluation. + failed_evaluation_count: The number of examples that crashed during evaluation + plus the number of examples that failed to produce an output for evaluation. + run_ids: IDs of all :class:`RunOverview`s from all linked :class:`EvaluationOverview`s. + description: A short description. + statistics: Aggregated statistics of the run. Whatever is returned by :meth:`Evaluator.aggregate` + """ + + evaluation_overviews: frozenset[EvaluationOverview] + id: str + start: datetime + end: datetime + successful_evaluation_count: int + crashed_during_evaluation_count: int + description: str + statistics: SerializeAsAny[AggregatedEvaluation] + + @property + def run_ids(self) -> Sequence[str]: + return [overview.id for overview in self.run_overviews()] + + def run_overviews(self) -> Iterable[RunOverview]: + return set( + run_overview + for evaluation_overview in self.evaluation_overviews + for run_overview in evaluation_overview.run_overviews + ) + + @property + def failed_evaluation_count(self) -> int: + return self.crashed_during_evaluation_count + sum( + run_overview.failed_example_count for run_overview in self.run_overviews() + ) + + def raise_on_evaluation_failure(self) -> None: + if self.crashed_during_evaluation_count > 0: + raise EvaluationFailed(self.id, self.crashed_during_evaluation_count) + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + res = ( + f"Aggregation Overview ID = {self.id}\n" + f"Start time = {self.start}\n" + f"End time = {self.end}\n" + f"Successful example count = {self.successful_evaluation_count}\n" + f"Count of examples crashed during evaluation = {self.failed_evaluation_count}\n" + f'Description = "{self.description}"\n' + ) + + res += f"IDs of aggregated Evaluation Overviews = {[evaluation_overview.id for evaluation_overview in self.evaluation_overviews]}\n" + res += f"IDs of aggregated Run Overviews = {self.run_ids}\n" + + res += "Statistics = {\n" + res += f"{self.statistics}\n" + res += "}\n" + + return res diff --git a/src/intelligence_layer/evaluation/aggregation/elo.py b/src/intelligence_layer/evaluation/aggregation/elo.py new file mode 100644 index 000000000..0fbdac2a1 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/elo.py @@ -0,0 +1,98 @@ +from collections import defaultdict +from enum import Enum +from typing import Iterable, Mapping, Sequence + +import numpy as np + + +class MatchOutcome(str, Enum): + A_WINS = "a_wins" + DRAW = "draw" + B_WINS = "b_wins" + + @property + def payoff(self) -> tuple[float, float]: + if self == self.A_WINS: + return (1, 0) + if self == self.DRAW: + return (0.5, 0.5) + return (0, 1) + + @staticmethod + def from_rank_literal(rank: int) -> "MatchOutcome": + match rank: + case 1: + return MatchOutcome.A_WINS + case 2: + return MatchOutcome.B_WINS + case 3: + return MatchOutcome.DRAW + case _: + raise ValueError(f"Got unexpected rank {rank}") + + +class EloCalculator: + def __init__( + self, + players: Iterable[str], + k_start: float = 20.0, + k_floor: float = 10.0, + decay_factor: float = 0.0005, + ) -> None: + self.ratings: dict[str, float] = {player: 1500.0 for player in players} + self._match_counts: dict[str, int] = defaultdict(int) + self._k_ceiling = k_start - k_floor + self._k_floor = k_floor + self._decay_factor = decay_factor + + def _calc_k_factor(self, player: str) -> float: + n = self._match_counts.get(player) or 0 + # Mypy thinks this is Any + return self._k_ceiling * np.exp(-self._decay_factor * n) + self._k_floor # type: ignore + + def _calc_expected_win_rates( + self, player_a: str, player_b: str + ) -> tuple[float, float]: + rating_a, rating_b = self.ratings[player_a], self.ratings[player_b] + exp_a = 1 / (1 + 10 ** ((rating_b - rating_a) / 400)) + return exp_a, 1 - exp_a + + def _calc_difs( + self, match_outcome: MatchOutcome, player_a: str, player_b: str + ) -> tuple[float, float]: + expected_win_rate_a, expected_win_rate_b = self._calc_expected_win_rates( + player_a, player_b + ) + actual_a, actual_b = match_outcome.payoff + k_a, k_b = self._calc_k_factor(player_a), self._calc_k_factor(player_b) + return k_a * (actual_a - expected_win_rate_a), k_b * ( + actual_b - expected_win_rate_b + ) + + def calculate(self, matches: Sequence[tuple[str, str, MatchOutcome]]) -> None: + for a, b, o in matches: + dif_a, dif_b = self._calc_difs(o, a, b) + self.ratings[a] += dif_a + self.ratings[b] += dif_b + self._match_counts[a] += 1 + self._match_counts[b] += 1 + + +class WinRateCalculator: + def __init__(self, players: Iterable[str]) -> None: + self.match_count: dict[str, int] = {p: 0 for p in players} + self.win_count: dict[str, float] = {p: 0 for p in players} + + def calculate( + self, matches: Sequence[tuple[str, str, MatchOutcome]] + ) -> Mapping[str, float]: + for a, b, o in matches: + self.match_count[a] += 1 + self.match_count[b] += 1 + self.win_count[a] += o.payoff[0] + self.win_count[b] += o.payoff[1] + + return { + player: self.win_count[player] / match_count + for player, match_count in self.match_count.items() + } diff --git a/src/intelligence_layer/evaluation/aggregation/file_aggregation_repository.py b/src/intelligence_layer/evaluation/aggregation/file_aggregation_repository.py new file mode 100644 index 000000000..c5367b44a --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/file_aggregation_repository.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Optional, Sequence + +from fsspec.implementations.local import LocalFileSystem # type: ignore + +from intelligence_layer.evaluation.aggregation.aggregation_repository import ( + AggregationRepository, +) +from intelligence_layer.evaluation.aggregation.domain import ( + AggregatedEvaluation, + AggregationOverview, +) +from intelligence_layer.evaluation.infrastructure.file_system_based_repository import ( + FileSystemBasedRepository, +) + + +class FileSystemAggregationRepository(AggregationRepository, FileSystemBasedRepository): + _SUB_DIRECTORY = "aggregations" + + def store_aggregation_overview( + self, aggregation_overview: AggregationOverview[AggregatedEvaluation] + ) -> None: + self.write_utf8( + self._aggregation_overview_path(aggregation_overview.id), + aggregation_overview.model_dump_json(indent=2), + create_parents=True, + ) + + def aggregation_overview( + self, aggregation_id: str, aggregation_type: type[AggregatedEvaluation] + ) -> Optional[AggregationOverview[AggregatedEvaluation]]: + file_path = self._aggregation_overview_path(aggregation_id) + + if not self.exists(file_path): + return None + + content = self.read_utf8(file_path) + return AggregationOverview[aggregation_type].model_validate_json( # type:ignore + content + ) + + def aggregation_overview_ids(self) -> Sequence[str]: + return sorted(self.file_names(self._aggregation_root_directory())) + + def _aggregation_root_directory(self) -> Path: + return self._root_directory / self._SUB_DIRECTORY + + def _aggregation_directory(self, evaluation_id: str) -> Path: + return self._aggregation_root_directory() / evaluation_id + + def _aggregation_overview_path(self, aggregation_id: str) -> Path: + return self._aggregation_directory(aggregation_id).with_suffix(".json") + + +class FileAggregationRepository(FileSystemAggregationRepository): + def __init__(self, root_directory: Path) -> None: + super().__init__(LocalFileSystem(), root_directory) + + @staticmethod + def path_to_str(path: Path) -> str: + return str(path) diff --git a/src/intelligence_layer/evaluation/aggregation/hugging_face_aggregation_repository.py b/src/intelligence_layer/evaluation/aggregation/hugging_face_aggregation_repository.py new file mode 100644 index 000000000..c2eb2439a --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/hugging_face_aggregation_repository.py @@ -0,0 +1,13 @@ +from intelligence_layer.evaluation.aggregation.file_aggregation_repository import ( + FileSystemAggregationRepository, +) +from intelligence_layer.evaluation.infrastructure.hugging_face_repository import ( + HuggingFaceRepository, +) + + +class HuggingFaceAggregationRepository( + FileSystemAggregationRepository, HuggingFaceRepository +): + # this class inherits all its behavior from its parents + pass diff --git a/src/intelligence_layer/evaluation/aggregation/in_memory_aggregation_repository.py b/src/intelligence_layer/evaluation/aggregation/in_memory_aggregation_repository.py new file mode 100644 index 000000000..cb328c6e9 --- /dev/null +++ b/src/intelligence_layer/evaluation/aggregation/in_memory_aggregation_repository.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Sequence + +from intelligence_layer.evaluation.aggregation.aggregation_repository import ( + AggregationRepository, +) +from intelligence_layer.evaluation.aggregation.domain import ( + AggregatedEvaluation, + AggregationOverview, +) + + +class InMemoryAggregationRepository(AggregationRepository): + def __init__(self) -> None: + super().__init__() + self._aggregation_overviews: dict[str, AggregationOverview[Any]] = dict() + + def store_aggregation_overview( + self, aggregation_overview: AggregationOverview[AggregatedEvaluation] + ) -> None: + self._aggregation_overviews[aggregation_overview.id] = aggregation_overview + + def aggregation_overview( + self, aggregation_id: str, aggregation_type: type[AggregatedEvaluation] + ) -> Optional[AggregationOverview[AggregatedEvaluation]]: + return self._aggregation_overviews.get(aggregation_id, None) + + def aggregation_overview_ids(self) -> Sequence[str]: + return sorted(list(self._aggregation_overviews.keys())) diff --git a/src/intelligence_layer/evaluation/dataset/dataset_repository.py b/src/intelligence_layer/evaluation/dataset/dataset_repository.py new file mode 100644 index 000000000..aecf89b24 --- /dev/null +++ b/src/intelligence_layer/evaluation/dataset/dataset_repository.py @@ -0,0 +1,116 @@ +from abc import ABC, abstractmethod +from typing import Iterable, Optional + +from intelligence_layer.core import Input +from intelligence_layer.evaluation.dataset.domain import ( + Dataset, + Example, + ExpectedOutput, +) + + +class DatasetRepository(ABC): + """Base dataset repository interface. + + Provides methods to store and load datasets and their linked examples (:class:`Example`s). + """ + + @abstractmethod + def create_dataset( + self, + examples: Iterable[Example[Input, ExpectedOutput]], + dataset_name: str, + id: str | None = None, + ) -> Dataset: + """Creates a dataset from given :class:`Example`s and returns the ID of that dataset. + + Args: + examples: An :class:`Iterable` of :class:`Example`s to be saved in the same dataset. + dataset_name: A name for the dataset. + id: The dataset ID. If `None`, an ID will be generated. + + Returns: + The created :class:`Dataset`. + """ + pass + + @abstractmethod + def delete_dataset(self, dataset_id: str) -> None: + """Deletes a dataset identified by the given dataset ID. + + Args: + dataset_id: Dataset ID of the dataset to delete. + """ + pass + + @abstractmethod + def dataset(self, dataset_id: str) -> Optional[Dataset]: + """Returns a dataset identified by the given dataset ID. + + Args: + dataset_id: Dataset ID of the dataset to delete. + + Returns: + :class:`Dataset` if it was not, `None` otherwise. + """ + pass + + def datasets(self) -> Iterable[Dataset]: + """Returns all :class:`Dataset`s sorted by their ID. + + Returns: + :class:`Sequence` of :class:`Dataset`s. + """ + for dataset_id in self.dataset_ids(): + dataset = self.dataset(dataset_id) + if dataset is not None: + yield dataset + + @abstractmethod + def dataset_ids(self) -> Iterable[str]: + """Returns all sorted dataset IDs. + + Returns: + :class:`Iterable` of dataset IDs. + """ + pass + + @abstractmethod + def example( + self, + dataset_id: str, + example_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Optional[Example[Input, ExpectedOutput]]: + """Returns an :class:`Example` for the given dataset ID and example ID. + + Args: + dataset_id: Dataset ID of the linked dataset. + example_id: ID of the example to retrieve. + input_type: Input type of the example. + expected_output_type: Expected output type of the example. + + Returns: + :class:`Example` if it was found, `None` otherwise. + """ + pass + + @abstractmethod + def examples( + self, + dataset_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Iterable[Example[Input, ExpectedOutput]]: + """Returns all :class:`Example`s for the given dataset ID sorted by their ID. + + Args: + dataset_id: Dataset ID whose examples should be retrieved. + input_type: Input type of the example. + expected_output_type: Expected output type of the example. + + Returns: + :class:`Iterable` of :class`Example`s. + """ + pass diff --git a/src/intelligence_layer/evaluation/dataset/domain.py b/src/intelligence_layer/evaluation/dataset/domain.py new file mode 100644 index 000000000..a90a68b12 --- /dev/null +++ b/src/intelligence_layer/evaluation/dataset/domain.py @@ -0,0 +1,66 @@ +from typing import Generic, TypeVar +from uuid import uuid4 + +from pydantic import BaseModel, Field +from rich.tree import Tree + +from intelligence_layer.core.task import Input +from intelligence_layer.core.tracer.tracer import PydanticSerializable + +ExpectedOutput = TypeVar("ExpectedOutput", bound=PydanticSerializable) +"""Dataset-specific type that defines characteristics that an :class:`Output` can be checked against. + +Traditional names for this are `label` or `y` in classification.""" + + +class Example(BaseModel, Generic[Input, ExpectedOutput]): + """Example case used for evaluations. + + Attributes: + input: Input for the :class:`Task`. Has to be same type as the input for the task used. + expected_output: The expected output from a given example run. + This will be used by the evaluator to compare the received output with. + id: Identifier for the example, defaults to uuid. + + Generics: + Input: Interface to be passed to the :class:`Task` that shall be evaluated. + ExpectedOutput: Output that is expected from the run with the supplied input. + """ + + input: Input + expected_output: ExpectedOutput + id: str = Field(default_factory=lambda: str(uuid4())) + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + return ( + f"Example ID = {self.id}\n" + f"Input = {self.input}\n" + f'Expected output = "{self.expected_output}"\n' + ) + + def _rich_render(self) -> Tree: + example_tree = Tree(f"Example: {self.id}") + example_tree.add("Input").add(str(self.input)) + example_tree.add("Expected Output").add(str(self.expected_output)) + return example_tree + + +class Dataset(BaseModel): + """Represents a dataset linked to multiple examples + + Attributes: + id: Dataset ID. + name: A short name of the dataset. + """ + + id: str = Field(default_factory=lambda: str(uuid4())) + name: str + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + return f"Dataset ID = {self.id}\nName = {self.name}\n" diff --git a/src/intelligence_layer/evaluation/dataset/file_dataset_repository.py b/src/intelligence_layer/evaluation/dataset/file_dataset_repository.py new file mode 100644 index 000000000..f5d110068 --- /dev/null +++ b/src/intelligence_layer/evaluation/dataset/file_dataset_repository.py @@ -0,0 +1,148 @@ +from pathlib import Path +from typing import Iterable, Optional + +from fsspec.implementations.local import LocalFileSystem # type: ignore + +from intelligence_layer.core import Input, JsonSerializer, PydanticSerializable +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import ( + Dataset, + Example, + ExpectedOutput, +) +from intelligence_layer.evaluation.infrastructure.file_system_based_repository import ( + FileSystemBasedRepository, +) + + +class FileSystemDatasetRepository(DatasetRepository, FileSystemBasedRepository): + """A dataset repository that stores :class:`Dataset`s in files. + + It creates a single file per dataset and stores the :class:`Example`s as lines in this file. + The format of the file is `.jsonl`. + """ + + _REPO_TYPE = "dataset" + + def create_dataset( + self, + examples: Iterable[Example[Input, ExpectedOutput]], + dataset_name: str, + id: str | None = None, + ) -> Dataset: + dataset = Dataset(name=dataset_name) + if id is not None: + dataset.id = id + + self.mkdir(self._dataset_directory(dataset.id)) + + dataset_path = self._dataset_path(dataset.id) + examples_path = self._dataset_examples_path(dataset.id) + if self.exists(dataset_path) or self.exists(examples_path): + raise ValueError( + f"One of the dataset files already exist for dataset {dataset}. This should not happen. Files: {dataset_path}, {examples_path}." + ) + + self._write_data(dataset_path, [dataset]) + self._write_data(examples_path, examples) + + return dataset + + def delete_dataset(self, dataset_id: str) -> None: + try: + self._file_system.rm( + self.path_to_str(self._dataset_directory(dataset_id)), recursive=True + ) + except FileNotFoundError: + pass + + def dataset(self, dataset_id: str) -> Optional[Dataset]: + file_path = self.path_to_str(self._dataset_path(dataset_id)) + if not self._file_system.exists(file_path): + return None + + with self._file_system.open(file_path, "r", encoding="utf-8") as file_content: + # we save only one dataset per file + return [ + Dataset.model_validate_json(dataset_string) + for dataset_string in file_content + ][0] + + def dataset_ids(self) -> Iterable[str]: + dataset_files = self._file_system.glob( + path=self.path_to_str(self._dataset_root_directory()) + "/**/*.jsonl", + maxdepth=2, + detail=False, + ) + return sorted([Path(f).stem for f in dataset_files]) + + def example( + self, + dataset_id: str, + example_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Optional[Example[Input, ExpectedOutput]]: + example_path = self._dataset_examples_path(dataset_id) + if not self.exists(example_path.parent): + raise ValueError( + f"Repository does not contain a dataset with id: {dataset_id}" + ) + if not self.exists(example_path): + return None + for example in self.examples(dataset_id, input_type, expected_output_type): + if example.id == example_id: + return example + return None + + def examples( + self, + dataset_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Iterable[Example[Input, ExpectedOutput]]: + example_path = self.path_to_str(self._dataset_examples_path(dataset_id)) + if not self._file_system.exists(example_path): + raise ValueError( + f"Repository does not contain a dataset with id: {dataset_id}" + ) + + with self._file_system.open( + example_path, "r", encoding="utf-8" + ) as examples_file: + # Mypy does not accept dynamic types + examples = [ + Example[input_type, expected_output_type].model_validate_json( # type: ignore + json_data=example + ) + for example in examples_file + ] + + return sorted(examples, key=lambda example: example.id) + + def _dataset_root_directory(self) -> Path: + return self._root_directory / "datasets" + + def _dataset_directory(self, dataset_id: str) -> Path: + return self._dataset_root_directory() / f"{dataset_id}" + + def _dataset_path(self, dataset_id: str) -> Path: + return self._dataset_directory(dataset_id) / f"{dataset_id}.json" + + def _dataset_examples_path(self, dataset_id: str) -> Path: + return self._dataset_directory(dataset_id) / f"{dataset_id}.jsonl" + + def _write_data( + self, + file_path: Path, + data_to_write: Iterable[PydanticSerializable], + ) -> None: + data = "\n".join( + JsonSerializer(root=chunk).model_dump_json() for chunk in data_to_write + ) + self.write_utf8(file_path, data, create_parents=True) + + +class FileDatasetRepository(FileSystemDatasetRepository): + def __init__(self, root_directory: Path) -> None: + super().__init__(LocalFileSystem(), root_directory) diff --git a/src/intelligence_layer/evaluation/dataset/hugging_face_dataset_repository.py b/src/intelligence_layer/evaluation/dataset/hugging_face_dataset_repository.py new file mode 100644 index 000000000..b821845e9 --- /dev/null +++ b/src/intelligence_layer/evaluation/dataset/hugging_face_dataset_repository.py @@ -0,0 +1,74 @@ +from functools import _lru_cache_wrapper, lru_cache +from typing import Optional + +from intelligence_layer.evaluation.dataset.domain import Dataset +from intelligence_layer.evaluation.dataset.file_dataset_repository import ( + FileSystemDatasetRepository, +) +from intelligence_layer.evaluation.infrastructure.hugging_face_repository import ( + HuggingFaceRepository, +) + + +class HuggingFaceDatasetRepository(HuggingFaceRepository, FileSystemDatasetRepository): + def __init__( + self, repository_id: str, token: str, private: bool, caching: bool = True + ) -> None: + """Initializes a :class:`HuggingFaceDatasetRepository` to be ready for dataset storage and access. + + Args: + repository_id: The HuggingFace namespace and repository name, separated by a "/". + token: The HuggingFace authentication token. + private: Whether the dataset repository should be private on HuggingFace. + caching: If set, datasets are cached in memory once retrieved. + This means external updates to datasets will be missed. Defaults to `True`. + """ + super().__init__(repository_id, token, private) + if caching: + self.examples = lru_cache(maxsize=2)(self.examples) # type: ignore + + def delete_dataset(self, dataset_id: str) -> None: + """Deletes a dataset identified by the given dataset ID. + + This implementation should be backwards compatible to datasets + created without a dataset object (i.e., there is no dataset file + with dataset metadata). + + Note, that HuggingFace API does not seem to support deleting not-existing files. + + Args: + dataset_id: Dataset ID of the dataset to delete. + """ + if self.exists(self._dataset_examples_path(dataset_id)): + self._file_system.rm( + self.path_to_str(self._dataset_examples_path(dataset_id)) + ) + + if self.exists(self._dataset_path(dataset_id)): + self._file_system.rm(self.path_to_str(self._dataset_path(dataset_id))) + # this resets the complete cache if a dataset gets deleted. + if isinstance(self.examples, _lru_cache_wrapper): + self.examples.cache_clear() + + def dataset(self, dataset_id: str) -> Optional[Dataset]: + """Returns a dataset identified by the given dataset ID. + + This implementation should be backwards compatible to datasets + created without a dataset object (i.e., there is no dataset file + with dataset metadata). + + Args: + dataset_id: Dataset ID of the dataset to delete. + + Returns: + :class:`Dataset` if it was not, `None` otherwise. + """ + dataset_file_path = self._dataset_path(dataset_id) + examples_file_path = self._dataset_examples_path(dataset_id) + if not self.exists(dataset_file_path): + if not self.exists(examples_file_path): + return None + else: + return Dataset(id=dataset_id, name=f"HuggingFace dataset {dataset_id}") + + return super().dataset(dataset_id) diff --git a/src/intelligence_layer/evaluation/dataset/in_memory_dataset_repository.py b/src/intelligence_layer/evaluation/dataset/in_memory_dataset_repository.py new file mode 100644 index 000000000..4504b98a5 --- /dev/null +++ b/src/intelligence_layer/evaluation/dataset/in_memory_dataset_repository.py @@ -0,0 +1,81 @@ +from typing import Iterable, Optional, Sequence, Tuple, cast + +from intelligence_layer.core import Input, PydanticSerializable +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import ( + Dataset, + Example, + ExpectedOutput, +) + + +class InMemoryDatasetRepository(DatasetRepository): + def __init__(self) -> None: + self._datasets_and_examples: dict[ + str, + Tuple[ + Dataset, Sequence[Example[PydanticSerializable, PydanticSerializable]] + ], + ] = {} + + def create_dataset( + self, + examples: Iterable[Example[Input, ExpectedOutput]], + dataset_name: str, + id: str | None = None, + ) -> Dataset: + dataset = Dataset(name=dataset_name) + if id is not None: + dataset.id = id + if dataset.id in self._datasets_and_examples: + raise ValueError( + f"Created random dataset ID already exists for dataset {dataset}. This should not happen." + ) + + examples_casted = cast( + Sequence[Example[PydanticSerializable, PydanticSerializable]], + list(examples), + ) + self._datasets_and_examples[dataset.id] = (dataset, examples_casted) + + return dataset + + def delete_dataset(self, dataset_id: str) -> None: + self._datasets_and_examples.pop(dataset_id, None) + + def dataset(self, dataset_id: str) -> Optional[Dataset]: + if dataset_id in self._datasets_and_examples: + return self._datasets_and_examples[dataset_id][0] + return None + + def dataset_ids(self) -> Iterable[str]: + return sorted(list(self._datasets_and_examples.keys())) + + def example( + self, + dataset_id: str, + example_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Optional[Example[Input, ExpectedOutput]]: + examples = self.examples(dataset_id, input_type, expected_output_type) + filtered = (e for e in examples if e.id == example_id) + return next(filtered, None) + + def examples( + self, + dataset_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Iterable[Example[Input, ExpectedOutput]]: + if dataset_id not in self._datasets_and_examples.keys(): + raise ValueError( + f"Repository does not contain a dataset with id: {dataset_id}" + ) + return cast( + Iterable[Example[Input, ExpectedOutput]], + sorted( + self._datasets_and_examples[dataset_id][1], + key=lambda example: example.id, + ), + ) diff --git a/src/intelligence_layer/evaluation/dataset/single_huggingface_dataset_repository.py b/src/intelligence_layer/evaluation/dataset/single_huggingface_dataset_repository.py new file mode 100644 index 000000000..7cf025e28 --- /dev/null +++ b/src/intelligence_layer/evaluation/dataset/single_huggingface_dataset_repository.py @@ -0,0 +1,84 @@ +from typing import Iterable, Sequence, cast + +from datasets import Dataset as HFDataset # type: ignore +from datasets import DatasetDict, IterableDataset, IterableDatasetDict +from pydantic import BaseModel + +from intelligence_layer.core.task import Input +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import ( + Dataset, + Example, + ExpectedOutput, +) + + +class MultipleChoiceInput(BaseModel): + question: str + choices: Sequence[str] + + +class SingleHuggingfaceDatasetRepository(DatasetRepository): + def __init__( + self, + huggingface_dataset: ( + DatasetDict | HFDataset | IterableDatasetDict | IterableDataset + ), + ) -> None: + self._huggingface_dataset = huggingface_dataset + + def create_dataset( + self, + examples: Iterable[Example[Input, ExpectedOutput]], + dataset_name: str, + id: str | None = None, + ) -> Dataset: + raise NotImplementedError + + def dataset(self, dataset_id: str) -> Dataset | None: + raise NotImplementedError + + def dataset_ids(self) -> Iterable[str]: + raise NotImplementedError + + def delete_dataset(self, dataset_id: str) -> None: + raise NotImplementedError + + def example( + self, + dataset_id: str, + example_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Example[Input, ExpectedOutput] | None: + examples = self.examples( + dataset_id=dataset_id, + input_type=input_type, + expected_output_type=expected_output_type, + ) + + for example in examples: + if example.id == example_id: + return example + return None + + def examples( + self, + dataset_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Iterable[Example[Input, ExpectedOutput]]: + answers = "ABCD" + assert input_type == MultipleChoiceInput + assert expected_output_type == str + for index, sample in enumerate(self._huggingface_dataset["test"]): + yield Example( + input=cast( + Input, + MultipleChoiceInput( + question=sample["question"], choices=sample["choices"] + ), + ), + expected_output=cast(ExpectedOutput, answers[sample["answer"]]), + id=str(index), + ) diff --git a/src/intelligence_layer/evaluation/evaluation/argilla_evaluation_repository.py b/src/intelligence_layer/evaluation/evaluation/argilla_evaluation_repository.py new file mode 100644 index 000000000..ec643578a --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/argilla_evaluation_repository.py @@ -0,0 +1,166 @@ +from itertools import chain +from typing import Optional, Sequence, cast +from uuid import uuid4 + +from pydantic import BaseModel + +from intelligence_layer.connectors.argilla.argilla_client import ( + ArgillaClient, + ArgillaEvaluation, + Field, + Question, + RecordData, +) +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + EvaluationOverview, + ExampleEvaluation, + FailedExampleEvaluation, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, +) + + +class RecordDataSequence(BaseModel): + records: Sequence[RecordData] + + +class ArgillaEvaluationRepository(EvaluationRepository): + """Evaluation repository used in the :class:`ArgillaEvaluator` and :class:`ArgillaAggregator`. + + Only an `EvaluationOverview` is stored in the `evaluation_repository`, while the `ExampleEvaluation`s themselves are stored in Argilla. + These `ExampleEvaluation`s are submitted to Argilla in the `store_example_evaluation` method. + + Args: + evaluation_repository: The evaluation repository to use internally. + argilla_client: Client to be used to connect to Argilla. + workspace_id: The workspace ID to save the results in. + Has to be created in Argilla beforehand. + fields: The Argilla fields of the dataset. Has to be set for use in the :class:`ArgillaEvaluator`. + questions: The questions that will be presented to the human evaluators in Argilla. Has to be set for use in the :class:`ArgillaEvaluator`. + """ + + def __init__( + self, + evaluation_repository: EvaluationRepository, + argilla_client: ArgillaClient, + workspace_id: str, + fields: Optional[Sequence[Field]] = None, + questions: Optional[Sequence[Question]] = None, + ) -> None: + super().__init__() + self._evaluation_repository = evaluation_repository + self._client = argilla_client + self._workspace_id = workspace_id + self._fields = fields + self._questions = questions + + def initialize_evaluation(self) -> str: + if self._fields is None or self._questions is None: + raise ValueError( + "Fields and questions have to be set to initialize the evaluation but are `None`." + ) + + return self._client.ensure_dataset_exists( + self._workspace_id, + str(uuid4()), + self._fields, + self._questions, + ) + + def store_evaluation_overview(self, overview: EvaluationOverview) -> None: + return self._evaluation_repository.store_evaluation_overview(overview) + + def evaluation_overview(self, evaluation_id: str) -> Optional[EvaluationOverview]: + return self._evaluation_repository.evaluation_overview(evaluation_id) + + def evaluation_overview_ids(self) -> Sequence[str]: + return sorted(self._evaluation_repository.evaluation_overview_ids()) + + def store_example_evaluation( + self, evaluation: ExampleEvaluation[Evaluation] + ) -> None: + if isinstance(evaluation.result, RecordDataSequence): + for record in evaluation.result.records: + self._client.add_record(evaluation.evaluation_id, record) + elif isinstance(evaluation.result, FailedExampleEvaluation): + self._evaluation_repository.store_example_evaluation(evaluation) + else: + raise TypeError( + "ArgillaEvaluationRepository does not support storing non-RecordDataSequence evaluations." + ) + + def example_evaluation( + self, evaluation_id: str, example_id: str, evaluation_type: type[Evaluation] + ) -> Optional[ExampleEvaluation[Evaluation]]: + return self._evaluation_repository.example_evaluation( + evaluation_id, example_id, evaluation_type + ) + + def example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + assert evaluation_type == ArgillaEvaluation + successful_example_evaluations = self.successful_example_evaluations( + evaluation_id, evaluation_type + ) + failed_example_evaluations = self.failed_example_evaluations( + evaluation_id, evaluation_type + ) + + return sorted( + chain(successful_example_evaluations, failed_example_evaluations), + key=lambda evaluation: evaluation.example_id, + ) + + def successful_example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + """Returns all successfully stored :class:`ExampleEvaluation`s for the given evaluation overview ID sorted by their example ID. + + Args: + evaluation_id: ID of the corresponding evaluation overview. + evaluation_type: Type of evaluations that the :class:`Evaluator` returned + in :func:`Evaluator.do_evaluate`. + + Returns: + A :class:`Sequence` of successful :class:`ExampleEvaluation`s. + """ + assert evaluation_type == ArgillaEvaluation + example_evaluations = [ + ExampleEvaluation( + evaluation_id=evaluation_id, + example_id=e.example_id, + # cast to Evaluation because mypy thinks ArgillaEvaluation cannot be Evaluation + result=cast(Evaluation, e), + ) + for e in self._client.evaluations(evaluation_id) + ] + return sorted(example_evaluations, key=lambda i: i.example_id) + + def failed_example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + """Returns all failed :class:`ExampleEvaluation`s sorted by their example ID. + + A failed example evaluation is an :class:`ExampleEvaluation` for which the storage process failed, e.g., because + the Argilla service was unresponsive. + + The failed examples are stored in the given evaluation repository and not in Argilla. + + Args: + evaluation_id: ID of the corresponding evaluation overview. + evaluation_type: Type of evaluations that the :class:`Evaluator` returned + in :func:`Evaluator.do_evaluate` + + Returns: + A `Sequence` of failed example evaluations. + """ + # If there are no failed examples, the dataset with the id was never created and fails on retrieval. + try: + return self._evaluation_repository.failed_example_evaluations( + evaluation_id, evaluation_type + ) + except ValueError: + return [] diff --git a/src/intelligence_layer/evaluation/evaluation/argilla_evaluator.py b/src/intelligence_layer/evaluation/evaluation/argilla_evaluator.py new file mode 100644 index 000000000..137847a2d --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/argilla_evaluator.py @@ -0,0 +1,190 @@ +import random +from abc import ABC, abstractmethod +from itertools import combinations +from typing import Mapping, Optional + +from intelligence_layer.connectors.argilla.argilla_client import ( + ArgillaClient, + ArgillaEvaluation, + Field, + Question, + RecordData, +) +from intelligence_layer.core import CompleteOutput, Input, InstructInput, Output +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import Example, ExpectedOutput +from intelligence_layer.evaluation.evaluation.argilla_evaluation_repository import ( + ArgillaEvaluationRepository, + RecordDataSequence, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, +) +from intelligence_layer.evaluation.evaluation.evaluator import ( + EvaluationLogic, + Evaluator, +) +from intelligence_layer.evaluation.run.domain import SuccessfulExampleOutput +from intelligence_layer.evaluation.run.run_repository import RunRepository + + +class ArgillaEvaluationLogic( + EvaluationLogic[Input, Output, ExpectedOutput, RecordDataSequence], ABC +): + def do_evaluate( + self, + example: Example[Input, ExpectedOutput], + *output: SuccessfulExampleOutput[Output], + ) -> RecordDataSequence: + return self._to_record(example, *output) + + @abstractmethod + def _to_record( + self, + example: Example[Input, ExpectedOutput], + *output: SuccessfulExampleOutput[Output], + ) -> RecordDataSequence: + """This method is responsible for translating the `Example` and `Output` of the task to :class:`RecordData` + + + Args: + example: The example to be translated. + output: The output of the example that was run. + """ + ... + + +class ArgillaEvaluator( + Evaluator[Input, Output, ExpectedOutput, ArgillaEvaluation], + ABC, +): + """Evaluator used to integrate with Argilla (https://github.com/argilla-io/argilla). + + Use this evaluator if you would like to easily do human eval. + This evaluator runs a dataset and sends the input and output to Argilla to be evaluated. + + Arguments: + dataset_repository: The repository with the examples that will be taken for the evaluation. + run_repository: The repository of the runs to evaluate. + evaluation_repository: The repository that will be used to store evaluation results. + description: Human-readable description for the evaluator. + evaluation_logic: The logic to use for evaluation. + + Generics: + Input: Interface to be passed to the :class:`Task` that shall be evaluated. + Output: Type of the output of the :class:`Task` to be evaluated. + ExpectedOutput: Output that is expected from the run with the supplied input. + ArgillaEvaluation: Interface of the metrics that come from the Argilla task`. + """ + + def __init__( + self, + dataset_repository: DatasetRepository, + run_repository: RunRepository, + evaluation_repository: ArgillaEvaluationRepository, + description: str, + evaluation_logic: ArgillaEvaluationLogic[Input, Output, ExpectedOutput], + ) -> None: + super().__init__( + dataset_repository, + run_repository, + evaluation_repository, + description, + evaluation_logic, # type: ignore + ) + + def evaluation_type(self) -> type[ArgillaEvaluation]: # type: ignore + return ArgillaEvaluation + + +class InstructComparisonArgillaEvaluationLogic( + ArgillaEvaluationLogic[InstructInput, CompleteOutput, None] +): + def __init__( + self, + workspace_id: str, + fields: Mapping[str, Field], + high_priority_runs: Optional[frozenset[str]] = None, + ) -> None: + self._workspace_id = workspace_id + self._fields = fields + self._high_priority_runs = high_priority_runs + + def _to_record( + self, + example: Example[InstructInput, None], + *outputs: SuccessfulExampleOutput[CompleteOutput], + ) -> RecordDataSequence: + pairs = combinations(outputs, 2) + return RecordDataSequence( + records=[ + self._create_record_data(example, first, second) + for [first, second] in pairs + if self._high_priority_runs is None + or any( + run_id in self._high_priority_runs + for run_id in [first.run_id, second.run_id] + ) + ] + ) + + def _create_record_data( + self, + example: Example[InstructInput, None], + first: SuccessfulExampleOutput[CompleteOutput], + second: SuccessfulExampleOutput[CompleteOutput], + ) -> RecordData: + if random.choice([True, False]): + first, second = second, first + return RecordData( + content={ + self._fields["KEY_INSTRUCTION"].name: example.input.instruction, + self._fields["KEY_INPUT"].name: example.input.input or "", + self._fields["KEY_RESPONSE_1"].name: first.output.completion, + self._fields["KEY_RESPONSE_2"].name: second.output.completion, + }, + example_id=example.id, + metadata={ + self._fields["KEY_RESPONSE_1"].name: first.run_id, + self._fields["KEY_RESPONSE_2"].name: second.run_id, + }, + ) + + +def create_instruct_comparison_argilla_evaluation_classes( + workspace_id: str, + evaluation_repository: EvaluationRepository, + argilla_client: ArgillaClient, + high_priority_runs: Optional[frozenset[str]] = None, +) -> tuple[InstructComparisonArgillaEvaluationLogic, ArgillaEvaluationRepository]: + KEY_INSTRUCTION = "instruction" + KEY_INPUT = "input" + KEY_RESPONSE_1 = "first" + KEY_RESPONSE_2 = "second" + KEY_QUESTION = "winner" + OPTIONS = [1, 2, 3] + + fields = { + "KEY_INSTRUCTION": Field(name=KEY_INSTRUCTION, title="Instruction"), + "KEY_INPUT": Field(name=KEY_INPUT, title="Input"), + "KEY_RESPONSE_1": Field(name=KEY_RESPONSE_1, title="Response 1"), + "KEY_RESPONSE_2": Field(name=KEY_RESPONSE_2, title="Response 2"), + } + questions = [ + Question( + name=KEY_QUESTION, + title="Which response is better?", + description="1: The first completion is better.\n2: The second completion is better.\n3: They are both equally good.", + options=OPTIONS, + ) + ] + + return InstructComparisonArgillaEvaluationLogic( + workspace_id, fields, high_priority_runs + ), ArgillaEvaluationRepository( + evaluation_repository, + argilla_client, + workspace_id, + list(fields.values()), + questions, + ) diff --git a/src/intelligence_layer/evaluation/evaluation/domain.py b/src/intelligence_layer/evaluation/evaluation/domain.py new file mode 100644 index 000000000..e4cd7d254 --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/domain.py @@ -0,0 +1,113 @@ +import traceback +from datetime import datetime +from typing import Generic, Optional, TypeVar + +from pydantic import BaseModel, SerializeAsAny +from rich.tree import Tree + +from intelligence_layer.evaluation.run.domain import RunOverview + +Evaluation = TypeVar("Evaluation", bound=BaseModel, covariant=True) +"""Custom type that holds the domain-specific data of a single :class:`Example` evaluation""" + + +class FailedExampleEvaluation(BaseModel): + """Captures an exception raised when evaluating an :class:`ExampleOutput`. + + Attributes: + error_message: String-representation of the exception. + """ + + error_message: str + + @staticmethod + def from_exception(exception: Exception) -> "FailedExampleEvaluation": + return FailedExampleEvaluation( + error_message=f"{type(exception)}: {str(exception)}\n{traceback.format_exc()}" + ) + + +class ExampleEvaluation(BaseModel, Generic[Evaluation]): + """Evaluation of a single evaluated :class:`Example` + + Created to persist the evaluation result in the repository. + + Attributes: + evaluation_id: Identifier of the run the evaluated example belongs to. + example_id: Identifier of the :class:`Example` evaluated. + result: If the evaluation was successful, evaluation's result, + otherwise the exception raised during running or evaluating + the :class:`Task`. + + Generics: + Evaluation: Interface of the metrics that come from the evaluated :class:`Task`. + """ + + evaluation_id: str + example_id: str + result: SerializeAsAny[Evaluation | FailedExampleEvaluation] + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + return ( + f"Evaluation ID = {self.evaluation_id}\n" + f"Example ID = {self.example_id}\n" + f"Result = {self.result}\n" + ) + + def _rich_render(self, skip_example_id: bool = False) -> Tree: + tree = Tree(f"Evaluation: {self.evaluation_id}") + if not skip_example_id: + tree.add(f"Example ID: {self.example_id}") + tree.add(str(self.result)) + return tree + + +class EvaluationOverview(BaseModel, frozen=True): + """Overview of the un-aggregated results of evaluating a :class:`Task` on a dataset. + + Attributes: + run_overviews: Overviews of the runs that were evaluated. + id: The unique identifier of this evaluation. + start: The time when the evaluation run was started + description: human-readable for the evaluator that created the evaluation + """ + + run_overviews: frozenset[RunOverview] + id: str + start: Optional[datetime] + description: str + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + run_overview_str: str = "Run Overviews={\n" + comma_counter = 0 + for overview in self.run_overviews: + run_overview_str += f"{overview}" + if comma_counter < len(self.run_overviews) - 1: + run_overview_str += ", " + comma_counter += 1 + run_overview_str += "}\n" + + return ( + f"Evaluation Overview ID = {self.id}\n" + f"Start time = {self.start}\n" + f'Description = "{self.description}"\n' + f"{run_overview_str}" + ) + + +class EvaluationFailed(Exception): + def __init__(self, evaluation_id: str, failed_count: int) -> None: + super().__init__( + f"Evaluation {evaluation_id} failed with {failed_count} failed examples." + ) + + end: datetime + failed_example_count: int + successful_example_count: int + description: str diff --git a/src/intelligence_layer/evaluation/evaluation/evaluation_repository.py b/src/intelligence_layer/evaluation/evaluation/evaluation_repository.py new file mode 100644 index 000000000..15b0bc721 --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/evaluation_repository.py @@ -0,0 +1,196 @@ +from abc import ABC, abstractmethod +from typing import Iterable, Optional, Sequence +from uuid import uuid4 + +from pydantic import BaseModel + +from intelligence_layer.core import JsonSerializer +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + EvaluationOverview, + ExampleEvaluation, + FailedExampleEvaluation, +) + + +class SerializedExampleEvaluation(BaseModel): + """A json-serialized evaluation of a single example in a dataset. + + Attributes: + evaluation_id: ID of the linked :class:`EvaluationOverview`. + example_id: ID of the :class:`ExampleEvaluation` this evaluation was created for. + is_exception: Will be `True` if an exception occurred during the evaluation. + json_result: The actual serialized evaluation result. + """ + + evaluation_id: str + example_id: str + is_exception: bool + json_result: str + + @classmethod + def from_example_result( + cls, result: ExampleEvaluation[Evaluation] + ) -> "SerializedExampleEvaluation": + return cls( + evaluation_id=result.evaluation_id, + json_result=JsonSerializer(root=result.result).model_dump_json(), + is_exception=isinstance(result.result, FailedExampleEvaluation), + example_id=result.example_id, + ) + + def to_example_result( + self, evaluation_type: type[Evaluation] + ) -> ExampleEvaluation[Evaluation]: + if self.is_exception: + return ExampleEvaluation( + evaluation_id=self.evaluation_id, + example_id=self.example_id, + result=FailedExampleEvaluation.model_validate_json(self.json_result), + ) + else: + return ExampleEvaluation( + evaluation_id=self.evaluation_id, + example_id=self.example_id, + result=evaluation_type.model_validate_json(self.json_result), + ) + + +class EvaluationRepository(ABC): + """Base evaluation repository interface. + + Provides methods to store and load evaluation results: + :class:`EvaluationOverview`s and :class:`ExampleEvaluation`. + An :class:`EvaluationOverview` is created from and is linked (by its ID) + to multiple :class:`ExampleEvaluation`s. + """ + + def initialize_evaluation(self) -> str: + """Initializes an :class:`EvaluationOverview` and returns its ID. + + If no extra logic is required for the initialization, this function just returns a UUID as string. + In other cases (e.g., when a dataset has to be created in an external repository), + this method is responsible for implementing this logic and returning the created ID. + + Returns: + The created ID. + """ + return str(uuid4()) + + @abstractmethod + def store_evaluation_overview( + self, evaluation_overview: EvaluationOverview + ) -> None: + """Stores an :class:`EvaluationOverview`. + + Args: + evaluation_overview: The overview to be persisted. + """ + ... + + @abstractmethod + def evaluation_overview(self, evaluation_id: str) -> Optional[EvaluationOverview]: + """Returns an :class:`EvaluationOverview` for the given ID. + + Args: + evaluation_id: ID of the evaluation overview to retrieve. + + Returns: + :class:`EvaluationOverview` if it was found, `None` otherwise. + """ + ... + + def evaluation_overviews(self) -> Iterable[EvaluationOverview]: + """Returns all :class:`EvaluationOverview`s sorted by their ID. + + Returns: + :class:`Iterable` of :class:`EvaluationOverview`s. + """ + for eval_id in self.evaluation_overview_ids(): + evaluation_overview = self.evaluation_overview(eval_id) + if evaluation_overview is not None: + yield evaluation_overview + + @abstractmethod + def evaluation_overview_ids(self) -> Sequence[str]: + """Returns sorted IDs of all stored :class:`EvaluationOverview`s. + + Returns: + A :class:`Sequence` of the :class:`EvaluationOverview` IDs. + """ + ... + + @abstractmethod + def store_example_evaluation( + self, example_evaluation: ExampleEvaluation[Evaluation] + ) -> None: + """Stores an :class:`ExampleEvaluation`. + + Args: + example_evaluation: The example evaluation to be persisted. + """ + + @abstractmethod + def example_evaluation( + self, evaluation_id: str, example_id: str, evaluation_type: type[Evaluation] + ) -> Optional[ExampleEvaluation[Evaluation]]: + """Returns an :class:`ExampleEvaluation` for the given evaluation overview ID and example ID. + + Args: + evaluation_id: ID of the linked evaluation overview. + example_id: ID of the example evaluation to retrieve. + evaluation_type: Type of example evaluations that the `Evaluator` returned + in :func:`Evaluator.do_evaluate` + + Returns: + :class:`ExampleEvaluation` if it was found, `None` otherwise. + """ + ... + + @abstractmethod + def example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + """Returns all :class:`ExampleEvaluation`s for the given evaluation overview ID sorted by their example ID. + + Args: + evaluation_id: ID of the corresponding evaluation overview. + evaluation_type: Type of evaluations that the :class:`Evaluator` returned + in :func:`Evaluator.do_evaluate`. + + Returns: + A :class:`Sequence` of :class:`ExampleEvaluation`s. + """ + ... + + def successful_example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + """Returns all successful :class:`ExampleEvaluation`s for the given evaluation overview ID sorted by their example ID. + + Args: + evaluation_id: ID of the corresponding evaluation overview. + evaluation_type: Type of evaluations that the :class:`Evaluator` returned + in :func:`Evaluator.do_evaluate`. + + Returns: + A :class:`Sequence` of successful :class:`ExampleEvaluation`s. + """ + results = self.example_evaluations(evaluation_id, evaluation_type) + return [r for r in results if not isinstance(r.result, FailedExampleEvaluation)] + + def failed_example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + """Returns all failed :class:`ExampleEvaluation`s for the given evaluation overview ID sorted by their example ID. + + Args: + evaluation_id: ID of the corresponding evaluation overview. + evaluation_type: Type of evaluations that the :class:`Evaluator` returned + in :func:`Evaluator.do_evaluate`. + + Returns: + A :class:`Sequence` of failed :class:`ExampleEvaluation`s. + """ + results = self.example_evaluations(evaluation_id, evaluation_type) + return [r for r in results if isinstance(r.result, FailedExampleEvaluation)] diff --git a/src/intelligence_layer/evaluation/evaluation/evaluator.py b/src/intelligence_layer/evaluation/evaluation/evaluator.py new file mode 100644 index 000000000..dbedcf55d --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/evaluator.py @@ -0,0 +1,451 @@ +import typing +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +from typing import ( + Generic, + Iterable, + Mapping, + Optional, + Sequence, + Tuple, + TypeVar, + cast, + final, + get_args, + get_origin, +) + +from tqdm import tqdm + +from intelligence_layer.core import Input, Output, utc_now +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import Example, ExpectedOutput +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + EvaluationOverview, + ExampleEvaluation, + FailedExampleEvaluation, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, +) +from intelligence_layer.evaluation.infrastructure.repository_navigator import ( + EvaluationLineage, + RepositoryNavigator, +) +from intelligence_layer.evaluation.run.domain import ( + ExampleOutput, + FailedExampleRun, + RunOverview, + SuccessfulExampleOutput, +) +from intelligence_layer.evaluation.run.run_repository import RunRepository + + +class EvaluationLogic(ABC, Generic[Input, Output, ExpectedOutput, Evaluation]): + @abstractmethod + def do_evaluate( + self, + example: Example[Input, ExpectedOutput], + *output: SuccessfulExampleOutput[Output], + ) -> Evaluation: + """Executes the evaluation for this specific example. + + Responsible for comparing the input & expected output of a task to the + actually generated output. + + Args: + example: Input data of :class:`Task` to produce the output. + output: Output of the :class:`Task`. + + Returns: + The metrics that come from the evaluated :class:`Task`. + """ + pass + + +class SingleOutputEvaluationLogic( + EvaluationLogic[Input, Output, ExpectedOutput, Evaluation] +): + @final + def do_evaluate( + self, + example: Example[Input, ExpectedOutput], + *output: SuccessfulExampleOutput[Output], + ) -> Evaluation: + assert len(output) == 1 + return self.do_evaluate_single_output(example, output[0].output) + + @abstractmethod + def do_evaluate_single_output( + self, example: Example[Input, ExpectedOutput], output: Output + ) -> Evaluation: + pass + + +class Evaluator(Generic[Input, Output, ExpectedOutput, Evaluation]): + """Evaluator that can handle automatic evaluation scenarios. + + This evaluator should be used for automatic eval. A user still has to implement + :class:`EvaluationLogic`. + + + Arguments: + dataset_repository: The repository with the examples that will be taken for the evaluation. + run_repository: The repository of the runs to evaluate. + evaluation_repository: The repository that will be used to store evaluation results. + description: Human-readable description for the evaluator. + evaluation_logic: The logic to use for evaluation. + + Generics: + Input: Interface to be passed to the :class:`Task` that shall be evaluated. + Output: Type of the output of the :class:`Task` to be evaluated. + ExpectedOutput: Output that is expected from the run with the supplied input. + Evaluation: Interface of the metrics that come from the evaluated :class:`Task`. + """ + + def __init__( + self, + dataset_repository: DatasetRepository, + run_repository: RunRepository, + evaluation_repository: EvaluationRepository, + description: str, + evaluation_logic: EvaluationLogic[Input, Output, ExpectedOutput, Evaluation], + ) -> None: + self._dataset_repository = dataset_repository + self._run_repository = run_repository + self._evaluation_repository = evaluation_repository + self._evaluation_logic = evaluation_logic + self.description = description + + @lru_cache(maxsize=1) + def _get_types(self) -> Mapping[str, type]: + """Type magic function that gets the actual types of the generic parameters. + + Traverses the inheritance history of `BaseEvaluator`-subclass to find an actual type every time a TypeVar is replaced. + + Returns: + Name of generic parameter to the type found. + """ + + def is_eligible_subclass(parent: type) -> bool: + return hasattr(parent, "__orig_bases__") and issubclass( + parent, EvaluationLogic + ) + + def update_types() -> None: + num_types_set = 0 + for current_index, current_type in enumerate(current_types): + if type(current_type) is not TypeVar: + type_var_count = num_types_set - 1 + for element_index, element in enumerate(type_list): + if type(element) is TypeVar: + type_var_count += 1 + if type_var_count == current_index: + break + assert type_var_count == current_index + type_list[element_index] = current_type + num_types_set += 1 + + # mypy does not know __orig_bases__ + base_types = EvaluationLogic.__orig_bases__[1] # type: ignore + type_list: list[type | TypeVar] = list(get_args(base_types)) + possible_parent_classes = [ + p + for p in reversed(type(self._evaluation_logic).__mro__) + if is_eligible_subclass(p) + ] + for parent in possible_parent_classes: + # mypy does not know __orig_bases__ + for base in parent.__orig_bases__: # type: ignore + origin = get_origin(base) + if origin is None or not issubclass(origin, EvaluationLogic): + continue + current_types = list(get_args(base)) + update_types() + + return { + name: param_type + for name, param_type in zip( + (a.__name__ for a in get_args(base_types)), type_list + ) + if type(param_type) is not TypeVar + } + + def input_type(self) -> type[Input]: + try: + input_type = self._get_types()["Input"] + except KeyError: + raise TypeError(f"Alternatively overwrite input_type() in {type(self)}") + return cast(type[Input], input_type) + + def output_type(self) -> type[Output]: + """Returns the type of the evaluated task's output. + + This can be used to retrieve properly typed outputs of an evaluation run + from a :class:`EvaluationRepository` + + Returns: + the type of the evaluated task's output. + """ + try: + output_type = self._get_types()["Output"] + except KeyError: + raise TypeError(f"Alternatively overwrite output_type() in {type(self)}") + return cast(type[Output], output_type) + + def expected_output_type(self) -> type[ExpectedOutput]: + try: + expected_output_type = self._get_types()["ExpectedOutput"] + except KeyError: + raise TypeError( + f"Alternatively overwrite expected_output_type() in {type(self)}" + ) + return cast(type[ExpectedOutput], expected_output_type) + + def evaluation_type(self) -> type[Evaluation]: + """Returns the type of the evaluation result of an example. + + This can be used to retrieve properly typed evaluations of an evaluation run + from a :class:`EvaluationRepository` + + Returns: + Returns the type of the evaluation result of an example. + """ + try: + evaluation_type = self._get_types()["Evaluation"] + except KeyError: + raise TypeError( + f"Alternatively overwrite evaluation_type() in {type(self)}" + ) + return cast(type[Evaluation], evaluation_type) + + @final + def evaluate_runs( + self, + *run_ids: str, + num_examples: Optional[int] = None, + abort_on_error: bool = False, + ) -> EvaluationOverview: + """Evaluates all generated outputs in the run. + + For each set of successful outputs in the referenced runs, + :func:`EvaluationLogic.do_evaluate` is called and eval metrics are produced & + stored in the provided :class:`EvaluationRepository`. + + Args: + run_ids: The runs to be evaluated. Each run is expected to have the same + dataset as input (which implies their tasks have the same input-type) + and their tasks have the same output-type. For each example in the + dataset referenced by the runs the outputs of all runs are collected + and if all of them were successful they are passed on to the implementation + specific evaluation. The method compares all run of the provided ids to each other. + num_examples: The number of examples which should be evaluated from the given runs. + Always the first n runs stored in the evaluation repository + abort_on_error: Flag to abort all evaluations when an error occurs. Defaults to False. + + Returns: + EvaluationOverview: An overview of the evaluation. Individual :class:`Evaluation`s will not be + returned but instead stored in the :class:`EvaluationRepository` provided in the + __init__. + """ + + def load_run_overview(run_id: str) -> RunOverview: + run_overview = self._run_repository.run_overview(run_id) + if not run_overview: + raise ValueError(f"No RunOverview found for run-id: {run_id}") + return run_overview + + if not run_ids: + raise ValueError("At least one run-id needs to be provided") + run_overviews = frozenset(load_run_overview(run_id) for run_id in run_ids) + if not all( + next(iter(run_overviews)).dataset_id == run_overview.dataset_id + for run_overview in run_overviews + ): + raise ValueError( + f"All run-overviews must reference the same dataset: {run_overviews}" + ) + eval_id = self._evaluation_repository.initialize_evaluation() + dataset_id = next(iter(run_overviews)).dataset_id + examples = self._dataset_repository.examples( + dataset_id, + self.input_type(), + self.expected_output_type(), + ) + if examples is None: + raise ValueError(f"Dataset: {dataset_id} not found") + start = utc_now() + + examples_zipped: Iterable[tuple[ExampleOutput[Output], ...]] = zip( + *( + self._run_repository.example_outputs( + run_overview.id, self.output_type() + ) + for run_overview in run_overviews + ), + strict=True, + ) + + def generate_evaluation_inputs() -> ( + Iterable[ + Tuple[ + Example[Input, ExpectedOutput], + str, + Sequence[SuccessfulExampleOutput[Output]], + ] + ] + ): + current_example = 0 + for example_outputs in examples_zipped: + successful_example_outputs = [ + typing.cast(SuccessfulExampleOutput[Output], output) + for output in example_outputs + if not isinstance(output.output, FailedExampleRun) + ] + if not successful_example_outputs: + continue + example_id = successful_example_outputs[0].example_id + assert all( + example_output.example_id == example_id + for example_output in successful_example_outputs + ) + + example = self._dataset_repository.example( + dataset_id, + example_id, + self.input_type(), + self.expected_output_type(), + ) + assert example is not None + + if num_examples and current_example >= num_examples: + break + current_example += 1 + + yield ( + example, + eval_id, + successful_example_outputs, + ) + + with ThreadPoolExecutor(max_workers=10) as executor: + list( # the list is needed to consume the iterator returned from the executor.map + tqdm( + executor.map( + lambda args: self.evaluate( + args[0], args[1], abort_on_error, *args[2] + ), + generate_evaluation_inputs(), + ), + desc="Evaluating", + ) + ) + + partial_overview = EvaluationOverview( + run_overviews=run_overviews, + id=eval_id, + start=start, + description=self.description, + ) + self._evaluation_repository.store_evaluation_overview(partial_overview) + + return partial_overview + + @final + def evaluate( + self, + example: Example[Input, ExpectedOutput], + evaluation_id: str, + abort_on_error: bool, + *example_outputs: SuccessfulExampleOutput[Output], + ) -> None: + try: + result: Evaluation | FailedExampleEvaluation = ( + self._evaluation_logic.do_evaluate( + example, + *example_outputs, + ) + ) + except Exception as e: + if abort_on_error: + raise e + print( + f'FAILED EVALUATION: example "{example.id}", {type(e).__qualname__}: "{e}"' + ) + result = FailedExampleEvaluation.from_exception(e) + self._evaluation_repository.store_example_evaluation( + ExampleEvaluation( + evaluation_id=evaluation_id, example_id=example.id, result=result + ) + ) + + def failed_evaluations( + self, evaluation_id: str + ) -> Iterable[EvaluationLineage[Input, ExpectedOutput, Output, Evaluation]]: + """Returns the `EvaluationLineage` objects for all failed example evalations that belong to the given evaluation ID. + + Args: + evaluation_id: The ID of the evaluation overview + + Returns: + :class:`Iterable` of :class:`EvaluationLineage`s. + """ + failed_example_evaluations = ( + self._evaluation_repository.failed_example_evaluations( + evaluation_id, evaluation_type=self.evaluation_type() + ) + ) + lineages = ( + self.evaluation_lineage(evaluation_id, output.example_id) + for output in failed_example_evaluations + ) + return (lineage for lineage in lineages if lineage is not None) + + def evaluation_lineages( + self, evaluation_id: str + ) -> Iterable[EvaluationLineage[Input, ExpectedOutput, Output, Evaluation]]: + """Wrapper for `RepositoryNagivator.evaluation_lineages`. + + Args: + evaluation_id: The id of the evaluation + + Returns: + An iterator over all :class:`EvaluationLineage`s for the given evaluation id. + """ + navigator = RepositoryNavigator( + self._dataset_repository, self._run_repository, self._evaluation_repository + ) + return navigator.evaluation_lineages( + evaluation_id=evaluation_id, + input_type=self.input_type(), + expected_output_type=self.expected_output_type(), + output_type=self.output_type(), + evaluation_type=self.evaluation_type(), + ) + + def evaluation_lineage( + self, evaluation_id: str, example_id: str + ) -> EvaluationLineage[Input, ExpectedOutput, Output, Evaluation] | None: + """Wrapper for `RepositoryNagivator.evaluation_lineage`. + + Args: + evaluation_id: The id of the evaluation + example_id: The id of the example of interest + + Returns: + The :class:`EvaluationLineage` for the given evaluation id and example id. + Returns `None` if the lineage is not complete because either an example, a run, or an evaluation does not exist. + """ + navigator = RepositoryNavigator( + self._dataset_repository, self._run_repository, self._evaluation_repository + ) + return navigator.evaluation_lineage( + evaluation_id=evaluation_id, + example_id=example_id, + input_type=self.input_type(), + expected_output_type=self.expected_output_type(), + output_type=self.output_type(), + evaluation_type=self.evaluation_type(), + ) diff --git a/src/intelligence_layer/evaluation/evaluation/file_evaluation_repository.py b/src/intelligence_layer/evaluation/evaluation/file_evaluation_repository.py new file mode 100644 index 000000000..a701d7efe --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/file_evaluation_repository.py @@ -0,0 +1,114 @@ +from pathlib import Path +from typing import Optional, Sequence + +from fsspec.implementations.local import LocalFileSystem # type: ignore + +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + EvaluationOverview, + ExampleEvaluation, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, + SerializedExampleEvaluation, +) +from intelligence_layer.evaluation.infrastructure.file_system_based_repository import ( + FileSystemBasedRepository, +) + + +class FileSystemEvaluationRepository(EvaluationRepository, FileSystemBasedRepository): + """An :class:`EvaluationRepository` that stores evaluation results in JSON files.""" + + def store_evaluation_overview(self, overview: EvaluationOverview) -> None: + self.write_utf8( + self._evaluation_overview_path(overview.id), + overview.model_dump_json(indent=2), + create_parents=True, + ) + # initialize the correct folders + self.mkdir(self._eval_directory(overview.id)) + + def evaluation_overview(self, evaluation_id: str) -> Optional[EvaluationOverview]: + file_path = self._evaluation_overview_path(evaluation_id) + if not self.exists(file_path): + return None + + content = self.read_utf8(file_path) + return EvaluationOverview.model_validate_json(content) + + def evaluation_overview_ids(self) -> Sequence[str]: + return sorted(self.file_names(self._eval_root_directory())) + + def store_example_evaluation( + self, example_evaluation: ExampleEvaluation[Evaluation] + ) -> None: + serialized_result = SerializedExampleEvaluation.from_example_result( + example_evaluation + ) + self.write_utf8( + self._example_result_path( + example_evaluation.evaluation_id, example_evaluation.example_id + ), + serialized_result.model_dump_json(indent=2), + create_parents=True, + ) + + def example_evaluation( + self, evaluation_id: str, example_id: str, evaluation_type: type[Evaluation] + ) -> Optional[ExampleEvaluation[Evaluation]]: + file_path = self._example_result_path(evaluation_id, example_id) + if not self.exists(file_path.parent): + raise ValueError( + f"Repository does not contain an evaluation with id: {evaluation_id}" + ) + if not self.exists(file_path): + return None + + content = self.read_utf8(file_path) + serialized_example = SerializedExampleEvaluation.model_validate_json(content) + return serialized_example.to_example_result(evaluation_type) + + def example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + path = self._eval_directory(evaluation_id) + if not self.exists(path): + raise ValueError( + f"Repository does not contain an evaluation with id: {evaluation_id}" + ) + + example_evaluations: list[ExampleEvaluation[Evaluation]] = [] + for file_name in self.file_names(path): + evaluation = self.example_evaluation( + evaluation_id, file_name, evaluation_type + ) + if evaluation is not None: + example_evaluations.append(evaluation) + + return sorted(example_evaluations, key=lambda i: i.example_id) + + def _eval_root_directory(self) -> Path: + path = self._root_directory / "evaluations" + return path + + def _eval_directory(self, evaluation_id: str) -> Path: + path = self._eval_root_directory() / evaluation_id + return path + + def _example_result_path(self, evaluation_id: str, example_id: str) -> Path: + path = (self._eval_directory(evaluation_id) / example_id).with_suffix(".json") + return path + + def _evaluation_overview_path(self, evaluation_id: str) -> Path: + path = self._eval_directory(evaluation_id).with_suffix(".json") + return path + + +class FileEvaluationRepository(FileSystemEvaluationRepository): + def __init__(self, root_directory: Path) -> None: + super().__init__(LocalFileSystem(), root_directory) + + @staticmethod + def path_to_str(path: Path) -> str: + return str(path) diff --git a/src/intelligence_layer/evaluation/evaluation/graders.py b/src/intelligence_layer/evaluation/evaluation/graders.py new file mode 100644 index 000000000..5bb22a3c3 --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/graders.py @@ -0,0 +1,337 @@ +import math +from dataclasses import dataclass +from threading import Lock +from typing import List, Mapping, Sequence, Tuple + +import nltk # type: ignore +from langdetect import LangDetectException, detect_langs # type: ignore +from langdetect.language import Language as LangdetectLanguage # type: ignore +from nltk.tokenize import RegexpTokenizer # type: ignore +from nltk.translate.bleu_score import sentence_bleu # type: ignore +from rouge import Rouge # type: ignore +from semantic_text_splitter import TextSplitter + +_nltk_lock = Lock() + + +def _download_nltk() -> None: + with _nltk_lock: + nltk.download("punkt", quiet=True) + + +def _split_into_words(input: str) -> Sequence[str]: + """Splits a string into a list of words. + + Removes non-alphanumeric characters and lowercases the given text. + + Args: + input: String to split. + Returns: + List of words. + """ + tokenizer = RegexpTokenizer(r"\w+") + tokens = tokenizer.tokenize(input.lower()) + assert isinstance(tokens, list) + return tokens + + +class BleuGrader: + def __init__(self) -> None: + _download_nltk() + + def calculate_bleu(self, hypothesis: str, reference: str) -> float: + """Calculates the BLEU-score for the given hypothesis and reference. + + In the summarization use-case the `BLEU-score `_ roughly corresponds to the precision of the generated summary with regard to the expected summary. + + Args: + hypothesis: The generation to be evaluated. + reference: The baseline for the evaluation. + + Returns: + BLEU-score, float between 0 and 1. Where 1 means perfect match and 0 no overlap. + """ + hypothesis_tokens = _split_into_words(hypothesis) + reference_tokens = _split_into_words(reference) + bleu_score = sentence_bleu( + references=[reference_tokens], hypothesis=hypothesis_tokens + ) + return bleu_score if isinstance(bleu_score, float) else 0.0 + + +@dataclass +class FScores: + precision: float + recall: float + f_score: float + + @classmethod + def from_rouge_results(cls, rouge_results: Mapping[str, float]) -> "FScores": + return cls( + precision=rouge_results["p"], + recall=rouge_results["r"], + f_score=rouge_results["f"], + ) + + +class RougeGrader: + def __init__(self) -> None: + _download_nltk() + + def calculate_rouge(self, hypothesis: str, reference: str) -> FScores: + """Calculates the ROUGE-score for the hypothesis and reference. + + In the summarization use-case the `ROUGE-score `_ roughly corresponds to the recall of the generated summary with regard to the expected summary. + + Args: + hypothesis: The generation to be evaluated. + reference: The baseline for the evaluation. + + Returns: + ROUGE-score, which contains precision, recall and f1 metrics, all will be floats between 0 and 1. Where 1 means perfect match and 0 no overlap. + """ + hypothesis = " ".join(_split_into_words(hypothesis)) + reference = " ".join(_split_into_words(reference)) + rouge = Rouge() + rouge_scores = rouge.get_scores(hypothesis, reference)[0]["rouge-2"] + return FScores.from_rouge_results(rouge_scores) + + +class LanguageMatchesGrader: + """Provides a method to evaluate whether two texts are of the same language. + + Args: + acceptance_threshold: probability a language must surpass to be accepted + """ + + _acceptance_threshold: float + + def __init__(self, acceptance_threshold: float = 0.75) -> None: + self._acceptance_threshold = acceptance_threshold + _download_nltk() + + def languages_match(self, input: str, output: str) -> bool: + """Calculates if the input and output text are of the same language. + The length of the texts and its sentences should be reasonably long in order for good performance. + + Args: + input: text for which languages is compared to + output: text + + Returns: + bool: whether input and output language match + returns true if clear input language is not determinable + """ + + dominant_input_language = self._get_dominant_language(input) + + if dominant_input_language is None: + return True + + dominant_output_language = self._get_dominant_language(output) + + return dominant_input_language == dominant_output_language + + def _get_dominant_language(self, text: str) -> str | None: + test_chunks: Sequence[str] = self._tokenize_text(text) + probs_per_language = self._get_scores_per_language(test_chunks) + dominant_language = next( + ( + langs + for langs, probs in probs_per_language.items() + if probs >= self._acceptance_threshold + ), + None, + ) + return dominant_language + + @staticmethod + def _tokenize_text( + text: str, lower_char_bound: int = 30, upper_char_bound: int = 200 + ) -> Sequence[str]: + return TextSplitter((lower_char_bound, upper_char_bound)).chunks(text) + + @classmethod + def _get_scores_per_language(cls, text_chunks: Sequence[str]) -> dict[str, float]: + scores_per_language: dict[str, float] = {} + for text_chunk in text_chunks: + try: + languages_with_probs: Sequence[LangdetectLanguage] = detect_langs( + text_chunk + ) + for language in languages_with_probs: + scores_per_language[language.lang] = scores_per_language.get( + language.lang, 0 + ) + language.prob * len(text_chunk) + except LangDetectException: + continue # skip text_chunk in case language cannot be determined + return cls._normalize_dict(scores_per_language) + + @staticmethod + def _normalize_dict(dictionary: dict[str, float]) -> dict[str, float]: + total = sum(dictionary.values()) + if total == 0: + return {key: 0 for key in dictionary} + return {key: value / total for key, value in dictionary.items()} + + +@dataclass +class IndexRange: + start: int + stop: int + + +_HighlightRange = List[IndexRange] + + +class HighlightCoverageGrader: + """Evaluates how well the generated highlights match the expected highlights (via precision, recall and f1-score) + + Args: + beta_factor: factor to control weight of precision (0 <= beta < 1) vs. recall (beta > 1) when computing the f-score + """ + + beta_factor: float + + def __init__(self, beta_factor: float = 1.0) -> None: + self.beta_factor = beta_factor + + def compute_fscores( + self, + generated_highlight_indices: Sequence[Tuple[int, int]], + expected_highlight_indices: Sequence[Tuple[int, int]], + ) -> FScores: + """Calculates how well the generated highlight ranges match the expected ones + + Args: + generated_highlight_indices: list of tuples(start, end) of the generated highlights + expected_highlight_indices: list of tuples(start, end) of the generated highlights + + Returns: + FScores, which contains precision, recall and f-score metrics, all will be floats between 0 and 1, + where 1 means perfect match and 0 no overlap + """ + + generated_highlight_ranges: _HighlightRange = [ + IndexRange(el[0], el[1]) for el in generated_highlight_indices + ] + expected_highlight_ranges: _HighlightRange = [ + IndexRange(el[0], el[1]) for el in expected_highlight_indices + ] + + ( + correctly_identified_indices, + false_positive_indices, + false_negative_indices, + ) = self._identify_overlap_ranges( + generated_highlight_ranges, expected_highlight_ranges + ) + + true_positive_length = sum( + [ + index_range.stop - index_range.start + for index_range in correctly_identified_indices + ] + ) + false_positive_length = sum( + [ + index_range.stop - index_range.start + for index_range in false_positive_indices + ] + ) + false_negative_length = sum( + [ + index_range.stop - index_range.start + for index_range in false_negative_indices + ] + ) + + precision = true_positive_length / ( + true_positive_length + false_positive_length + ) + recall = true_positive_length / (true_positive_length + false_negative_length) + + denominator = math.pow(self.beta_factor, 2) * precision + recall + if denominator == 0.0: + f1_score = 0.0 + else: + f1_score = ( + (1 + math.pow(self.beta_factor, 2)) * precision * recall + ) / denominator + + return FScores(precision=precision, recall=recall, f_score=f1_score) + + @staticmethod + def _identify_overlap_ranges( + generated_highlights: _HighlightRange, expected_highlights: _HighlightRange + ) -> Tuple[_HighlightRange, _HighlightRange, _HighlightRange]: + max_index: int = max( + index_range.stop + for index_range in generated_highlights + expected_highlights + ) + + def get_highlight_present_array(highlights: _HighlightRange) -> Sequence[bool]: + highlight_map = [False] * max_index + for index_range in highlights: + for index in range(index_range.start, index_range.stop): + highlight_map[index] = True + return highlight_map + + gen_highlight_present_array = get_highlight_present_array(generated_highlights) + exp_highlight_present_array = get_highlight_present_array(expected_highlights) + + overlapping_indices: _HighlightRange = [] + leftover_gen_highlights: _HighlightRange = [] + leftover_exp_highlights: _HighlightRange = [] + current_range: IndexRange | None = None + + for index, ( + generated_highlight_present, + expected_highlight_present, + ) in enumerate(zip(gen_highlight_present_array, exp_highlight_present_array)): + if generated_highlight_present and expected_highlight_present: + current_range = HighlightCoverageGrader._increase_current_range_by_one( + current_range, index + ) + else: + if current_range: + overlapping_indices.append(current_range) + current_range = None + + if generated_highlight_present != expected_highlight_present: + if generated_highlight_present: + leftover_highlights = leftover_gen_highlights + else: + leftover_highlights = leftover_exp_highlights + + HighlightCoverageGrader._increase_last_leftover_range_by_one( + index, leftover_highlights + ) + + if current_range: + overlapping_indices.append(current_range) + + return overlapping_indices, leftover_gen_highlights, leftover_exp_highlights + + @staticmethod + def _increase_current_range_by_one( + current_range: IndexRange | None, index: int + ) -> IndexRange: + if current_range is None: + return IndexRange(index, index + 1) + return IndexRange(current_range.start, index + 1) + + @staticmethod + def _increase_last_leftover_range_by_one( + index: int, leftover_highlights: _HighlightRange + ) -> _HighlightRange: + if leftover_highlights and leftover_highlights[-1].stop == index: + leftover_highlights[-1] = ( + HighlightCoverageGrader._increase_current_range_by_one( + leftover_highlights[-1], index + ) + ) + else: + leftover_highlights.append(IndexRange(index, index + 1)) + + return leftover_highlights diff --git a/src/intelligence_layer/evaluation/evaluation/in_memory_evaluation_repository.py b/src/intelligence_layer/evaluation/evaluation/in_memory_evaluation_repository.py new file mode 100644 index 000000000..2cb6f567a --- /dev/null +++ b/src/intelligence_layer/evaluation/evaluation/in_memory_evaluation_repository.py @@ -0,0 +1,63 @@ +from collections import defaultdict +from typing import Optional, Sequence, cast + +from pydantic import BaseModel + +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + EvaluationOverview, + ExampleEvaluation, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, +) + + +class InMemoryEvaluationRepository(EvaluationRepository): + """An :class:`EvaluationRepository` that stores evaluation results in memory. + + Preferred for quick testing or to be used in Jupyter Notebooks. + """ + + def __init__(self) -> None: + self._example_evaluations: dict[str, list[ExampleEvaluation[BaseModel]]] = ( + defaultdict(list) + ) + self._evaluation_overviews: dict[str, EvaluationOverview] = dict() + + def store_evaluation_overview(self, overview: EvaluationOverview) -> None: + self._evaluation_overviews[overview.id] = overview + if overview.id not in self._example_evaluations.keys(): + self._example_evaluations[overview.id] = [] + + def evaluation_overview(self, evaluation_id: str) -> Optional[EvaluationOverview]: + return self._evaluation_overviews.get(evaluation_id, None) + + def evaluation_overview_ids(self) -> Sequence[str]: + return sorted(list(self._evaluation_overviews.keys())) + + def store_example_evaluation( + self, evaluation: ExampleEvaluation[Evaluation] + ) -> None: + self._example_evaluations[evaluation.evaluation_id].append(evaluation) + + def example_evaluation( + self, evaluation_id: str, example_id: str, evaluation_type: type[Evaluation] + ) -> Optional[ExampleEvaluation[Evaluation]]: + results = self.example_evaluations(evaluation_id, evaluation_type) + filtered = (result for result in results if result.example_id == example_id) + return next(filtered, None) + + def example_evaluations( + self, evaluation_id: str, evaluation_type: type[Evaluation] + ) -> Sequence[ExampleEvaluation[Evaluation]]: + if evaluation_id not in self._example_evaluations.keys(): + raise ValueError( + f"Repository does not contain an evaluation with id: {evaluation_id}" + ) + + example_evaluations = [ + cast(ExampleEvaluation[Evaluation], example_evaluation) + for example_evaluation in self._example_evaluations[evaluation_id] + ] + return sorted(example_evaluations, key=lambda i: i.example_id) diff --git a/src/intelligence_layer/evaluation/infrastructure/file_system_based_repository.py b/src/intelligence_layer/evaluation/infrastructure/file_system_based_repository.py new file mode 100644 index 000000000..063cc281a --- /dev/null +++ b/src/intelligence_layer/evaluation/infrastructure/file_system_based_repository.py @@ -0,0 +1,57 @@ +from abc import ABC +from pathlib import Path +from typing import Sequence, cast + +from fsspec import AbstractFileSystem # type: ignore + + +class FileSystemBasedRepository(ABC): + """An :class:`FileBasedRepository` that stores evaluation results in files. + + Args: + root_directory: The folder where the files are stored. The folder + (along with its parents) will be created if it does not exist yet. + """ + + def __init__(self, file_system: AbstractFileSystem, root_directory: Path) -> None: + self._root_directory = root_directory + self._file_system = file_system + self.mkdir(root_directory) + + def write_utf8( + self, path: Path, content: str, create_parents: bool = False + ) -> None: + if create_parents: + self.mkdir(path.parent) + self._file_system.write_text(self.path_to_str(path), content, encoding="utf-8") + + def read_utf8(self, path: Path) -> str: + return cast( + str, self._file_system.read_text(self.path_to_str(path), encoding="utf-8") + ) + + def exists(self, path: Path) -> bool: + return cast(bool, self._file_system.exists(self.path_to_str(path))) + + def mkdir(self, path: Path) -> None: + if self.exists(path): + return + self._file_system.makedir(self.path_to_str(path), create_parents=True) + + def file_names(self, path: Path, file_type: str = "json") -> Sequence[str]: + files = [ + Path(file) + for file in self._file_system.ls(self.path_to_str(path), detail=False) + ] + return [file.stem for file in files if file.suffix == "." + file_type] + + @staticmethod + def path_to_str(path: Path) -> str: + """Returns a string for the given Path so that it's readable for the respective file system. + + Args: + path: Given Path that should be converted. + Returns: + String representation of the given Path. + """ + return str(path) diff --git a/src/intelligence_layer/evaluation/infrastructure/hugging_face_repository.py b/src/intelligence_layer/evaluation/infrastructure/hugging_face_repository.py new file mode 100644 index 000000000..e921339b2 --- /dev/null +++ b/src/intelligence_layer/evaluation/infrastructure/hugging_face_repository.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import huggingface_hub # type: ignore + +from intelligence_layer.evaluation.infrastructure.file_system_based_repository import ( + FileSystemBasedRepository, +) + + +class HuggingFaceRepository(FileSystemBasedRepository): + """HuggingFace base repository""" + + _REPO_TYPE = "dataset" + _ROOT_DIRECTORY_PREFIX_ = "datasets" # HuggingFace API root directory + + @staticmethod + def path_to_str(path: Path) -> str: + return path.as_posix() + + def __init__(self, repository_id: str, token: str, private: bool) -> None: + """Create a HuggingFace repository. + + Creates a corresponding repository and initializes the file system. + + Args: + repository_id: The HuggingFace namespace and repository name, separated by a "/". + token: The HuggingFace authentication token. + private: Whether the dataset repository should be private. + """ + assert repository_id[-1] != "/" + self.create_repository(repository_id, token, private) + + file_system = huggingface_hub.HfFileSystem(token=token) + root_directory = Path(f"{self._ROOT_DIRECTORY_PREFIX_}/{repository_id}") + + super().__init__(file_system, root_directory) + self._repository_id = repository_id + # the file system is assigned in super init but this fixes the typing + self._file_system: huggingface_hub.HfFileSystem + + def create_repository(self, repository_id: str, token: str, private: bool) -> None: + huggingface_hub.create_repo( + repo_id=repository_id, + token=token, + repo_type=self._REPO_TYPE, + private=private, + exist_ok=True, + ) + + def delete_repository(self) -> None: + huggingface_hub.delete_repo( + repo_id=self._repository_id, + token=self._file_system.token, + repo_type=self._REPO_TYPE, + missing_ok=True, + ) diff --git a/src/intelligence_layer/evaluation/infrastructure/repository_navigator.py b/src/intelligence_layer/evaluation/infrastructure/repository_navigator.py new file mode 100644 index 000000000..be9cbbc40 --- /dev/null +++ b/src/intelligence_layer/evaluation/infrastructure/repository_navigator.py @@ -0,0 +1,315 @@ +import itertools +from typing import Generic, Iterable, Sequence + +import pandas as pd +import rich +from pydantic import BaseModel +from rich.tree import Tree + +from intelligence_layer.core.task import Input, Output +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import Example, ExpectedOutput +from intelligence_layer.evaluation.evaluation.domain import ( + Evaluation, + ExampleEvaluation, +) +from intelligence_layer.evaluation.evaluation.evaluation_repository import ( + EvaluationRepository, +) +from intelligence_layer.evaluation.run.domain import ExampleOutput +from intelligence_layer.evaluation.run.run_repository import RunRepository + + +class RunLineage(BaseModel, Generic[Input, ExpectedOutput, Output]): + example: Example[Input, ExpectedOutput] + output: ExampleOutput[Output] + + def _rich_render(self) -> Tree: + tree = Tree("Run Lineage") + tree.add(self.example._rich_render()) + tree.add(self.output._rich_render(skip_example_id=True)) + return tree + + def _ipython_display_(self) -> None: + rich.print(self._rich_render()) + + +def run_lineages_to_pandas( + run_lineages: Sequence[RunLineage[Input, ExpectedOutput, Output]], +) -> pd.DataFrame: + """Converts a sequence of `RunLineage` objects to a pandas `DataFrame`. + The `RunLineage` objects are stored in the column `"lineage"`. + The `DataFrame` is indexed by `(example_id, run_id)`. + + Args: + run_lineages: The lineages to convert. + + Returns: + A pandas `DataFrame` with the data contained in the `run_lineages`. + """ + df = pd.DataFrame( + [ + vars(lineage.example) | vars(lineage.output) | {"lineage": lineage} + for lineage in run_lineages + ] + ) + df = df.drop(columns="id") + df = df.set_index(["example_id", "run_id"]) + return df + + +class EvaluationLineage(BaseModel, Generic[Input, ExpectedOutput, Output, Evaluation]): + example: Example[Input, ExpectedOutput] + outputs: Sequence[ExampleOutput[Output]] + evaluation: ExampleEvaluation[Evaluation] + + def _rich_render(self) -> Tree: + tree = Tree("Run Lineage") + tree.add(self.example._rich_render()) + output_tree = Tree("Outputs") + for output in self.outputs: + output_tree.add(output._rich_render(skip_example_id=True)) + tree.add(output_tree) + tree.add(self.evaluation._rich_render(skip_example_id=True)) + return tree + + def _ipython_display_(self) -> None: + rich.print(self._rich_render()) + + +def evaluation_lineages_to_pandas( + evaluation_lineages: Sequence[ + EvaluationLineage[Input, ExpectedOutput, Output, Evaluation] + ], +) -> pd.DataFrame: + """Converts a sequence of `EvaluationLineage` objects to a pandas `DataFrame`. + The `EvaluationLineage` objects are stored in the column `"lineage"`. + The `DataFrame` is indexed by `(example_id, evaluation_id, run_id)`. + Each `output` of every lineage will contribute one row in the `DataFrame`. + + Args: + evaluation_lineages: The lineages to convert. + + Returns: + A pandas `DataFrame` with the data contained in the `evaluation_lineages`. + """ + df = pd.DataFrame( + [ + vars(lineage.example) + | vars(output) + | vars(lineage.evaluation) + | {"lineage": lineage} + for lineage in evaluation_lineages + for output in lineage.outputs + ] + ) + df = df.drop(columns="id") + df = df.set_index(["example_id", "evaluation_id", "run_id"]) + return df + + +class RepositoryNavigator: + """The `RepositoryNavigator` is used to retrieve coupled data from multiple repositories.""" + + def __init__( + self, + dataset_repository: DatasetRepository, + run_repository: RunRepository, + evaluation_repository: EvaluationRepository | None = None, + ) -> None: + self._dataset_repository = dataset_repository + self._run_repository = run_repository + self._eval_repository = evaluation_repository + + def run_lineages( + self, + run_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + output_type: type[Output], + ) -> Iterable[RunLineage[Input, ExpectedOutput, Output]]: + """Retrieves all :class:`RunLineage`s for the run with id `run_id`. + + Args: + run_id: The id of the run + input_type: The type of the input as defined by the :class:`Example` + expected_output_type: The type of the expected output as defined by the :class:`Example` + output_type: The type of the run output as defined by the :class:`Output` + + Returns: + An iterator over all :class:`RunLineage`s for the given run id. + """ + run_overview = self._run_repository.run_overview(run_id) + if run_overview is None: + raise ValueError(f"Run repository does not contain a run with id {run_id}.") + + examples = list( + self._dataset_repository.examples( + run_overview.dataset_id, + input_type, + expected_output_type, + ) + ) + + example_outputs = list( + self._run_repository.example_outputs(run_id, output_type) + ) + + # join + for example, example_output in itertools.product(examples, example_outputs): + if example.id == example_output.example_id: + yield RunLineage(example=example, output=example_output) + + def evaluation_lineages( + self, + evaluation_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + output_type: type[Output], + evaluation_type: type[Evaluation], + ) -> Iterable[EvaluationLineage[Input, ExpectedOutput, Output, Evaluation]]: + """Retrieves all :class:`EvaluationLineage`s for the evaluation with id `evaluation_id`. + + Args: + evaluation_id: The id of the evaluation + input_type: The type of the input as defined by the :class:`Example` + expected_output_type: The type of the expected output as defined by the :class:`Example` + output_type: The type of the run output as defined by the :class:`Output` + evaluation_type: The type of the evaluation as defined by the :class:`Evaluation` + + Returns: + An iterator over all :class:`EvaluationLineage`s for the given evaluation id. + """ + if self._eval_repository is None: + raise ValueError("Evaluation Repository is not set, but required.") + + eval_overview = self._eval_repository.evaluation_overview(evaluation_id) + if eval_overview is None: + raise ValueError( + f"Evaluation repository does not contain an evaluation with id {evaluation_id}." + ) + + evaluations = list( + self._eval_repository.example_evaluations(evaluation_id, evaluation_type) + ) + run_lineages = list( + itertools.chain.from_iterable( + self.run_lineages( + overview.id, input_type, expected_output_type, output_type + ) + for overview in eval_overview.run_overviews + ) + ) + + # join + for evaluation in evaluations: + example = None + outputs = [] + for run_lineage in run_lineages: + if run_lineage.example.id == evaluation.example_id: + if example is None: + # the evaluation has only one example + # and all relevant run lineages contain the same example + example = run_lineage.example + outputs.append(run_lineage.output) + + if example is not None: + yield EvaluationLineage( + example=example, outputs=outputs, evaluation=evaluation + ) + + def run_lineage( + self, + run_id: str, + example_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + output_type: type[Output], + ) -> RunLineage[Input, ExpectedOutput, Output] | None: + """Retrieves the :class:`RunLineage` for the run with id `run_id` and example with id `example_id`. + + Args: + run_id: The id of the run + example_id: The id of the example + input_type: The type of the input as defined by the :class:`Example` + expected_output_type: The type of the expected output as defined by the :class:`Example` + output_type: The type of the run output as defined by the :class:`Output` + + Returns: + The :class:`RunLineage` for the given run id and example id, `None` if the example or an output for the example does not exist. + """ + + run_overview = self._run_repository.run_overview(run_id) + if run_overview is None: + raise ValueError(f"Run repository does not contain a run with id {run_id}.") + + example = self._dataset_repository.example( + run_overview.dataset_id, example_id, input_type, expected_output_type + ) + if example is None: + return None + + example_output = self._run_repository.example_output( + run_id, example_id, output_type + ) + if example_output is None: + return None + + return RunLineage(example=example, output=example_output) + + def evaluation_lineage( + self, + evaluation_id: str, + example_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + output_type: type[Output], + evaluation_type: type[Evaluation], + ) -> EvaluationLineage[Input, ExpectedOutput, Output, Evaluation] | None: + """Retrieves the :class:`EvaluationLineage` for the evaluation with id `evaluation_id` and example with id `example_id`. + + Args: + evaluation_id: The id of the evaluation + example_id: The id of the example of interest + input_type: The type of the input as defined by the :class:`Example` + expected_output_type: The type of the expected output as defined by the :class:`Example` + output_type: The type of the run output as defined by the :class:`Output` + evaluation_type: The type of the evaluation as defined by the :class:`Evaluation` + + Returns: + The :class:`EvaluationLineage` for the given evaluation id and example id. + Returns `None` if the lineage is not complete because either an example, a run, or an evaluation does not exist. + """ + + if self._eval_repository is None: + raise ValueError("Evaluation Repository is not set, but required.") + + eval_overview = self._eval_repository.evaluation_overview(evaluation_id) + if eval_overview is None: + raise ValueError( + f"Evaluation repository does not contain an evaluation with id {evaluation_id}." + ) + + run_lineages = [ + self.run_lineage( + overview.id, example_id, input_type, expected_output_type, output_type + ) + for overview in eval_overview.run_overviews + ] + existing_run_lineages = [ + lineage for lineage in run_lineages if lineage is not None + ] + if len(existing_run_lineages) == 0: + return None + + example_evaluation = self._eval_repository.example_evaluation( + evaluation_id, example_id, evaluation_type + ) + if example_evaluation is None: + return None + + return EvaluationLineage( + example=existing_run_lineages[0].example, + outputs=[lineage.output for lineage in existing_run_lineages], + evaluation=example_evaluation, + ) diff --git a/src/intelligence_layer/evaluation/run/domain.py b/src/intelligence_layer/evaluation/run/domain.py new file mode 100644 index 000000000..fa67879dd --- /dev/null +++ b/src/intelligence_layer/evaluation/run/domain.py @@ -0,0 +1,125 @@ +import traceback +from datetime import datetime +from typing import Generic + +from pydantic import BaseModel +from rich.tree import Tree + +from intelligence_layer.core.task import Output + + +class FailedExampleRun(BaseModel): + """Captures an exception raised when running a single example with a :class:`Task`. + + Attributes: + error_message: String-representation of the exception. + """ + + error_message: str + + @staticmethod + def from_exception(exception: Exception) -> "FailedExampleRun": + return FailedExampleRun( + error_message=f"{type(exception).__qualname__}: {str(exception)}\n{traceback.format_exc()}" + ) + + +class ExampleOutput(BaseModel, Generic[Output]): + """Output of a single evaluated :class:`Example` + + Created to persist the output (including failures) of an individual example in the repository. + + Attributes: + run_id: Identifier of the run that created the output. + example_id: Identifier of the :class:`Example` that provided the input for generating the output. + output: Generated when running the :class:`Task`. When the running the task + failed this is an :class:`FailedExampleRun`. + + Generics: + Output: Interface of the output returned by the task. + """ + + run_id: str + example_id: str + output: Output | FailedExampleRun + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + return ( + f"Example ID={self.example_id}\n" + f"Related Run ID={self.run_id}\n" + f'Output="{self.output}"\n' + ) + + def _rich_render(self, skip_example_id: bool = False) -> Tree: + tree = Tree(f"Output: {self.run_id}") + if not skip_example_id: + tree.add(f"Example ID: {self.example_id}") + tree.add(str(self.output)) + return tree + + +class SuccessfulExampleOutput(BaseModel, Generic[Output]): + """Successful output of a single evaluated :class:`Example` + + Attributes: + run_id: Identifier of the run that created the output. + example_id: Identifier of the :class:`Example`. + output: Generated when running the :class:`Task`. This represent only + the output of an successful run. + + Generics: + Output: Interface of the output returned by the task. + """ + + run_id: str + example_id: str + output: Output + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + return ( + f"Run ID = {self.run_id}\n" + f"Example ID = {self.example_id}\n" + f'Output = "{self.output}"\n' + ) + + +class RunOverview(BaseModel, frozen=True): + """Overview of the run of a :class:`Task` on a dataset. + + Attributes: + dataset_id: Identifier of the dataset run. + id: The unique identifier of this run. + start: The time when the run was started + end: The time when the run ended + failed_example_count: The number of examples where an exception was raised when running the task. + successful_example_count: The number of examples that where successfully run. + description: Human-readable of the runner that run the task. + """ + + dataset_id: str + id: str + start: datetime + end: datetime + failed_example_count: int + successful_example_count: int + description: str + + def __repr__(self) -> str: + return self.__str__() + + def __str__(self) -> str: + return ( + f"Run Overview ID = {self.id}\n" + f"Dataset ID = {self.dataset_id}\n" + f"Start time = {self.start}\n" + f"End time = {self.end}\n" + f"Failed example count = {self.failed_example_count}\n" + f"Successful example count = {self.successful_example_count}\n" + f'Description = "{self.description}"\n' + ) diff --git a/src/intelligence_layer/evaluation/run/file_run_repository.py b/src/intelligence_layer/evaluation/run/file_run_repository.py new file mode 100644 index 000000000..454f42a04 --- /dev/null +++ b/src/intelligence_layer/evaluation/run/file_run_repository.py @@ -0,0 +1,136 @@ +from pathlib import Path +from typing import Iterable, Optional, Sequence, cast + +from fsspec.implementations.local import LocalFileSystem # type: ignore + +from intelligence_layer.core import ( + FileTracer, + InMemoryTaskSpan, + InMemoryTracer, + JsonSerializer, + Output, + Tracer, +) +from intelligence_layer.evaluation.infrastructure.file_system_based_repository import ( + FileSystemBasedRepository, +) +from intelligence_layer.evaluation.run.domain import ExampleOutput, RunOverview +from intelligence_layer.evaluation.run.run_repository import RunRepository +from intelligence_layer.evaluation.run.trace import ExampleTrace, TaskSpanTrace + + +class FileSystemRunRepository(RunRepository, FileSystemBasedRepository): + def store_run_overview(self, overview: RunOverview) -> None: + self.write_utf8( + self._run_overview_path(overview.id), + overview.model_dump_json(indent=2), + create_parents=True, + ) + # create empty folder just in case no examples are ever saved + self.mkdir(self._run_directory(overview.id)) + + def run_overview(self, run_id: str) -> Optional[RunOverview]: + file_path = self._run_overview_path(run_id) + if not self.exists(file_path): + return None + + content = self.read_utf8(file_path) + return RunOverview.model_validate_json(content) + + def run_overview_ids(self) -> Sequence[str]: + return sorted(self.file_names(self._run_root_directory())) + + def store_example_output(self, example_output: ExampleOutput[Output]) -> None: + serialized_result = JsonSerializer(root=example_output) + self.write_utf8( + self._example_output_path(example_output.run_id, example_output.example_id), + serialized_result.model_dump_json(indent=2), + create_parents=True, + ) + + def example_output( + self, run_id: str, example_id: str, output_type: type[Output] + ) -> Optional[ExampleOutput[Output]]: + file_path = self._example_output_path(run_id, example_id) + if not self.exists(file_path.parent): + raise ValueError(f"Repository does not contain a run with id: {run_id}") + if not self.exists(file_path): + return None + content = self.read_utf8(file_path) + # mypy does not accept dynamic types + return ExampleOutput[output_type].model_validate_json( # type: ignore + json_data=content + ) + + def example_trace(self, run_id: str, example_id: str) -> Optional[ExampleTrace]: + file_path = self._example_trace_path(run_id, example_id) + if not self.exists(file_path): + return None + in_memory_tracer = self._parse_log(file_path) + trace = TaskSpanTrace.from_task_span( + cast(InMemoryTaskSpan, in_memory_tracer.entries[0]) + ) + return ExampleTrace(run_id=run_id, example_id=example_id, trace=trace) + + def example_tracer(self, run_id: str, example_id: str) -> Tracer: + file_path = self._example_trace_path(run_id, example_id) + return FileTracer(file_path) + + def example_outputs( + self, run_id: str, output_type: type[Output] + ) -> Iterable[ExampleOutput[Output]]: + path = self._run_output_directory(run_id) + if not self.exists(path): + raise ValueError(f"Repository does not contain a run with id: {run_id}") + + example_outputs = [] + for file_name in self.file_names(path): + output = self.example_output(run_id, file_name, output_type) + if output is not None: + example_outputs.append(output) + + return sorted( + example_outputs, + key=lambda _example_output: _example_output.example_id, + ) + + def example_output_ids(self, run_id: str) -> Sequence[str]: + return sorted(self.file_names(self._run_output_directory(run_id))) + + def _run_root_directory(self) -> Path: + path = self._root_directory / "runs" + return path + + def _run_directory(self, run_id: str) -> Path: + path = self._run_root_directory() / run_id + return path + + def _run_output_directory(self, run_id: str) -> Path: + path = self._run_directory(run_id) / "output" + return path + + def _run_overview_path(self, run_id: str) -> Path: + return self._run_directory(run_id).with_suffix(".json") + + def _trace_directory(self, run_id: str) -> Path: + path = self._run_directory(run_id) / "trace" + return path + + def _example_trace_path(self, run_id: str, example_id: str) -> Path: + return (self._trace_directory(run_id) / example_id).with_suffix(".jsonl") + + @staticmethod + def _parse_log(log_path: Path) -> InMemoryTracer: + return FileTracer(log_path).trace() + + def _example_output_path(self, run_id: str, example_id: str) -> Path: + return (self._run_output_directory(run_id) / example_id).with_suffix(".json") + + +class FileRunRepository(FileSystemRunRepository): + def __init__(self, root_directory: Path) -> None: + super().__init__(LocalFileSystem(), root_directory) + + @staticmethod + def path_to_str(path: Path) -> str: + return str(path) diff --git a/src/intelligence_layer/evaluation/run/in_memory_run_repository.py b/src/intelligence_layer/evaluation/run/in_memory_run_repository.py new file mode 100644 index 000000000..943a7d466 --- /dev/null +++ b/src/intelligence_layer/evaluation/run/in_memory_run_repository.py @@ -0,0 +1,92 @@ +from collections import defaultdict +from typing import Iterable, Optional, Sequence, cast + +from intelligence_layer.core import ( + InMemoryTaskSpan, + InMemoryTracer, + Output, + PydanticSerializable, + Tracer, +) +from intelligence_layer.evaluation.run.domain import ExampleOutput, RunOverview +from intelligence_layer.evaluation.run.run_repository import RunRepository +from intelligence_layer.evaluation.run.trace import ExampleTrace, TaskSpanTrace + + +class InMemoryRunRepository(RunRepository): + def __init__(self) -> None: + self._example_outputs: dict[str, list[ExampleOutput[PydanticSerializable]]] = ( + defaultdict(list) + ) + self._example_traces: dict[str, InMemoryTracer] = dict() + self._run_overviews: dict[str, RunOverview] = dict() + + def store_run_overview(self, overview: RunOverview) -> None: + self._run_overviews[overview.id] = overview + if overview.id not in self._example_outputs.keys(): + self._example_outputs[overview.id] = [] + + def run_overview(self, run_id: str) -> Optional[RunOverview]: + return self._run_overviews.get(run_id, None) + + def run_overview_ids(self) -> Sequence[str]: + return sorted(self._run_overviews.keys()) + + def store_example_output(self, example_output: ExampleOutput[Output]) -> None: + self._example_outputs[example_output.run_id].append( + cast(ExampleOutput[PydanticSerializable], example_output) + ) + + def example_output( + self, run_id: str, example_id: str, output_type: type[Output] + ) -> Optional[ExampleOutput[Output]]: + if run_id not in self._example_outputs.keys(): + raise ValueError(f"Repository does not contain a run with id: {run_id}") + + if run_id not in self._example_outputs.keys(): + return None + + for example_output in self._example_outputs[run_id]: + if example_output.example_id == example_id: + return cast(ExampleOutput[Output], example_output) + return None + + def example_trace(self, run_id: str, example_id: str) -> Optional[ExampleTrace]: + tracer = self._example_traces.get(f"{run_id}/{example_id}") + if tracer is None: + return None + assert tracer + return ExampleTrace( + run_id=run_id, + example_id=example_id, + trace=TaskSpanTrace.from_task_span( + cast(InMemoryTaskSpan, tracer.entries[0]) + ), + ) + + def example_tracer(self, run_id: str, example_id: str) -> Tracer: + tracer = InMemoryTracer() + self._example_traces[f"{run_id}/{example_id}"] = tracer + return tracer + + def example_outputs( + self, run_id: str, output_type: type[Output] + ) -> Iterable[ExampleOutput[Output]]: + if run_id not in self._run_overviews.keys(): + raise ValueError(f"Repository does not contain a run with id: {run_id}") + + return ( + cast(ExampleOutput[Output], example_output) + for example_output in sorted( + self._example_outputs[run_id], + key=lambda example_output: example_output.example_id, + ) + ) + + def example_output_ids(self, run_id: str) -> Sequence[str]: + return sorted( + [ + example_output.example_id + for example_output in self._example_outputs[run_id] + ] + ) diff --git a/src/intelligence_layer/evaluation/run/run_repository.py b/src/intelligence_layer/evaluation/run/run_repository.py new file mode 100644 index 000000000..54292f832 --- /dev/null +++ b/src/intelligence_layer/evaluation/run/run_repository.py @@ -0,0 +1,168 @@ +from abc import ABC, abstractmethod +from typing import Iterable, Optional, Sequence + +from intelligence_layer.core import Output, Tracer +from intelligence_layer.evaluation.run.domain import ( + ExampleOutput, + FailedExampleRun, + RunOverview, +) +from intelligence_layer.evaluation.run.trace import ExampleTrace + + +class RunRepository(ABC): + """Base run repository interface. + + Provides methods to store and load run results: :class:`RunOverview` and :class:`ExampleOutput`. + A :class:`RunOverview` is created from and is linked (by its ID) to multiple :class:`ExampleOutput`s + representing results of a dataset. + """ + + @abstractmethod + def store_run_overview(self, overview: RunOverview) -> None: + """Stores a :class:`RunOverview`. + + Args: + overview: The overview to be persisted. + """ + ... + + @abstractmethod + def run_overview(self, run_id: str) -> Optional[RunOverview]: + """Returns a :class:`RunOverview` for the given ID. + + Args: + run_id: ID of the run overview to retrieve. + + Returns: + :class:`RunOverview` if it was found, `None` otherwise. + """ + ... + + def run_overviews(self) -> Iterable[RunOverview]: + """Returns all :class:`RunOverview`s sorted by their ID. + + Returns: + :class:`Iterable` of :class:`RunOverview`s. + """ + for run_id in self.run_overview_ids(): + run_overview = self.run_overview(run_id) + if run_overview is not None: + yield run_overview + + @abstractmethod + def run_overview_ids(self) -> Sequence[str]: + """Returns sorted IDs of all stored :class:`RunOverview`s. + + Returns: + A :class:`Sequence` of the :class:`RunOverview` IDs. + """ + ... + + @abstractmethod + def store_example_output(self, example_output: ExampleOutput[Output]) -> None: + """Stores an :class:`ExampleOutput`. + + Args: + example_output: The example output to be persisted. + """ + ... + + @abstractmethod + def example_output( + self, run_id: str, example_id: str, output_type: type[Output] + ) -> Optional[ExampleOutput[Output]]: + """Returns :class:`ExampleOutput` for the given run ID and example ID. + + Args: + run_id: The ID of the linked run overview. + example_id: ID of the example to retrieve. + output_type: Type of output that the `Task` returned in :func:`Task.do_run` + + Returns: + class:`ExampleOutput` if it was found, `None` otherwise. + """ + ... + + @abstractmethod + def example_trace(self, run_id: str, example_id: str) -> Optional[ExampleTrace]: + """Returns an :class:`ExampleTrace` for the given run ID and example ID. + + Args: + run_id: The ID of the linked run overview. + example_id: ID of the example whose :class:`ExampleTrace` should be retrieved. + + Returns: + An :class:`ExampleTrace` if it was found, `None` otherwise. + """ + ... + + @abstractmethod + def example_tracer(self, run_id: str, example_id: str) -> Tracer: + """Returns a :class:`Tracer` for the given run ID and example ID. + + Args: + run_id: The ID of the linked run overview. + example_id: ID of the example whose :class:`Tracer` should be retrieved. + + Returns: + A :.class:`Tracer`. + """ + ... + + @abstractmethod + def example_outputs( + self, run_id: str, output_type: type[Output] + ) -> Iterable[ExampleOutput[Output]]: + """Returns all :class:`ExampleOutput` for a given run ID sorted by their example ID. + + Args: + run_id: The ID of the run overview. + output_type: Type of output that the `Task` returned in :func:`Task.do_run` + + Returns: + :class:`Iterable` of :class:`ExampleOutput`s. + """ + ... + + @abstractmethod + def example_output_ids(self, run_id: str) -> Sequence[str]: + """Returns the sorted IDs of all :class:`ExampleOutput`s for a given run ID. + + Args: + run_id: The ID of the run overview. + + Returns: + A :class:`Sequence` of all :class:`ExampleOutput` IDs. + """ + ... + + def successful_example_outputs( + self, run_id: str, output_type: type[Output] + ) -> Iterable[ExampleOutput[Output]]: + """Returns all :class:`ExampleOutput` for successful example runs with a given run-overview ID sorted by their example ID. + + Args: + run_id: The ID of the run overview. + output_type: Type of output that the `Task` returned in :func:`Task.do_run` + + Returns: + :class:`Iterable` of :class:`ExampleOutput`s. + """ + results = self.example_outputs(run_id, output_type) + return (r for r in results if not isinstance(r.output, FailedExampleRun)) + + def failed_example_outputs( + self, run_id: str, output_type: type[Output] + ) -> Iterable[ExampleOutput[Output]]: + """Returns all :class:`ExampleOutput` for failed example runs with a given run-overview ID sorted by their example ID. + + Args: + run_id: The ID of the run overview. + output_type: Type of output that the `Task` returned in :func:`Task.do_run` + + Returns: + :class:`Iterable` of :class:`ExampleOutput`s. + """ + results = self.example_outputs(run_id, output_type) + return (r for r in results if isinstance(r.output, FailedExampleRun)) diff --git a/src/intelligence_layer/evaluation/run/runner.py b/src/intelligence_layer/evaluation/run/runner.py new file mode 100644 index 000000000..a45b670fd --- /dev/null +++ b/src/intelligence_layer/evaluation/run/runner.py @@ -0,0 +1,221 @@ +from concurrent.futures import ThreadPoolExecutor +from inspect import get_annotations +from itertools import islice +from typing import Generic, Iterable, Optional, cast +from uuid import uuid4 + +from pydantic import JsonValue +from tqdm import tqdm + +from intelligence_layer.core import ( + CompositeTracer, + Input, + Output, + Task, + Tracer, + utc_now, +) +from intelligence_layer.evaluation.dataset.dataset_repository import DatasetRepository +from intelligence_layer.evaluation.dataset.domain import Example, ExpectedOutput +from intelligence_layer.evaluation.infrastructure.repository_navigator import ( + RepositoryNavigator, + RunLineage, +) +from intelligence_layer.evaluation.run.domain import ( + ExampleOutput, + FailedExampleRun, + RunOverview, +) +from intelligence_layer.evaluation.run.run_repository import RunRepository + + +class Runner(Generic[Input, Output]): + def __init__( + self, + task: Task[Input, Output], + dataset_repository: DatasetRepository, + run_repository: RunRepository, + description: str, + ) -> None: + self._task = task + self._run_repository = run_repository + self._dataset_repository = dataset_repository + self.description = description + + def output_type(self) -> type[Output]: + """Returns the type of the evaluated task's output. + + This can be used to retrieve properly typed outputs of an evaluation run + from a :class:`RunRepository` + + Returns: + the type of the evaluated task's output. + """ + try: + output_type = get_annotations(self._task.do_run)["return"] + except KeyError: + raise TypeError( + f"Task of type {type(self._task)} must have a type-hint for the return value of do_run to detect the output_type. " + f"Alternatively overwrite output_type() in {type(self)}" + ) + return cast(type[Output], output_type) + + def input_type(self) -> type[Input]: + try: + input_type = get_annotations(self._task.do_run)["input"] + except KeyError: + raise TypeError( + f"Task of type {type(self._task)} must have a type-hint for the input value of do_run to detect the input_type. " + f"Alternatively overwrite input_type() in {type(self)}" + ) + return cast(type[Input], input_type) + + def run_dataset( + self, + dataset_id: str, + tracer: Optional[Tracer] = None, + num_examples: Optional[int] = None, + abort_on_error: bool = False, + max_workers: int = 10, + ) -> RunOverview: + """Generates all outputs for the provided dataset. + + Will run each :class:`Example` provided in the dataset through the :class:`Task`. + + Args: + dataset_id: The id of the dataset to generate output for. Consists of examples, each + with an :class:`Input` and an :class:`ExpectedOutput` (can be None). + tracer: An optional :class:`Tracer` to trace all the runs from each example + num_examples: An optional int to specify how many examples from the dataset should be run. + Always the first n examples will be taken. + abort_on_error: Flag to abort all run when an error occurs. Defaults to False. + max_workers: Number of examples that can be evaluated concurrently. Defaults to 10. + + Returns: + An overview of the run. Outputs will not be returned but instead stored in the + :class:`RunRepository` provided in the __init__. + """ + + def run( + example: Example[Input, ExpectedOutput], + ) -> tuple[str, Output | FailedExampleRun]: + evaluate_tracer = self._run_repository.example_tracer(run_id, example.id) + if tracer: + evaluate_tracer = CompositeTracer([evaluate_tracer, tracer]) + try: + return example.id, self._task.run(example.input, evaluate_tracer) + except Exception as e: + if abort_on_error: + raise e + print( + f'FAILED RUN: example "{example.id}", {type(e).__qualname__}: "{e}"' + ) + return example.id, FailedExampleRun.from_exception(e) + + # mypy does not like union types + + examples = self._dataset_repository.examples( + dataset_id, + self.input_type(), + JsonValue, # type: ignore + ) + if examples is None: + raise ValueError(f"Dataset with id {dataset_id} not found") + if num_examples: + examples = islice(examples, num_examples) + run_id = str(uuid4()) + start = utc_now() + with ThreadPoolExecutor(max_workers=max_workers) as executor: + ids_and_outputs = tqdm(executor.map(run, examples), desc="Running") + + failed_count = 0 + successful_count = 0 + for example_id, output in ids_and_outputs: + if isinstance(output, FailedExampleRun): + failed_count += 1 + else: + successful_count += 1 + self._run_repository.store_example_output( + ExampleOutput[Output]( + run_id=run_id, example_id=example_id, output=output + ), + ) + run_overview = RunOverview( + dataset_id=dataset_id, + id=run_id, + start=start, + end=utc_now(), + failed_example_count=failed_count, + successful_example_count=successful_count, + description=self.description, + ) + self._run_repository.store_run_overview(run_overview) + return run_overview + + def failed_runs( + self, run_id: str, expected_output_type: type[ExpectedOutput] + ) -> Iterable[RunLineage[Input, ExpectedOutput, Output]]: + """Returns the `RunLineage` objects for all failed example runs that belong to the given run ID. + + Args: + run_id: The ID of the run overview + expected_output_type: Type of output that the `Task` returned in :func:`Task.do_run` + + Returns: + :class:`Iterable` of :class:`RunLineage`s. + """ + failed_example_outputs = self._run_repository.failed_example_outputs( + run_id, output_type=self.output_type() + ) + lineages = ( + self.run_lineage(run_id, output.example_id, expected_output_type) + for output in failed_example_outputs + ) + return (lineage for lineage in lineages if lineage is not None) + + def run_lineages( + self, + run_id: str, + expected_output_type: type[ExpectedOutput], + ) -> Iterable[RunLineage[Input, ExpectedOutput, Output]]: + """Wrapper for `RepositoryNagivator.run_lineages`. + + Args: + run_id: The id of the run + expected_output_type: The type of the expected output as defined by the :class:`Example` + + Returns: + An iterator over all :class:`RunLineage`s for the given run id. + """ + navigator = RepositoryNavigator(self._dataset_repository, self._run_repository) + return navigator.run_lineages( + run_id=run_id, + input_type=self.input_type(), + expected_output_type=expected_output_type, + output_type=self.output_type(), + ) + + def run_lineage( + self, + run_id: str, + example_id: str, + expected_output_type: type[ExpectedOutput], + ) -> RunLineage[Input, ExpectedOutput, Output] | None: + """Wrapper for `RepositoryNagivator.run_lineage`. + + Args: + run_id: The id of the run + example_id: The id of the example of interest + expected_output_type: The type of the expected output as defined by the :class:`Example` + + Returns: + The :class:`RunLineage` for the given run id and example id, `None` if the example or an output for the example does not exist. + """ + navigator = RepositoryNavigator(self._dataset_repository, self._run_repository) + return navigator.run_lineage( + run_id=run_id, + example_id=example_id, + input_type=self.input_type(), + expected_output_type=expected_output_type, + output_type=self.output_type(), + ) diff --git a/src/intelligence_layer/evaluation/run/trace.py b/src/intelligence_layer/evaluation/run/trace.py new file mode 100644 index 000000000..8ae3c792f --- /dev/null +++ b/src/intelligence_layer/evaluation/run/trace.py @@ -0,0 +1,151 @@ +from datetime import datetime +from json import dumps +from typing import Optional, Sequence, Union + +from pydantic import BaseModel, ConfigDict, SerializeAsAny +from rich.panel import Panel +from rich.syntax import Syntax +from rich.tree import Tree + +from intelligence_layer.connectors import JsonSerializable +from intelligence_layer.core import ( + InMemorySpan, + InMemoryTaskSpan, + JsonSerializer, + LogEntry, +) + +Trace = Union["TaskSpanTrace", "SpanTrace", "LogTrace"] + + +class SpanTrace(BaseModel): + """Represents traces contained by :class:`Span` + + Attributes: + traces: The child traces. + start: Start time of the span. + end: End time of the span. + """ + + model_config = ConfigDict(frozen=True) + + name: str + traces: Sequence[Trace] + start: datetime + end: Optional[datetime] + + @staticmethod + def from_span(span: InMemorySpan) -> "SpanTrace": + return SpanTrace( + name=span.name, + traces=[_to_trace_entry(t) for t in span.entries], + start=span.start_timestamp, + end=span.end_timestamp, + ) + + def _rich_render_(self) -> Tree: + tree = Tree(label="span") + for log in self.traces: + tree.add(log._rich_render_()) + return tree + + +class TaskSpanTrace(SpanTrace): + """Represents traces contained by :class:`TaskSpan` + + Attributes: + input: Input from the traced :class:`Task`. + output: Output of the traced :class:`Task`. + """ + + model_config = ConfigDict(frozen=True) + + input: SerializeAsAny[JsonSerializable] + output: SerializeAsAny[JsonSerializable] + + @staticmethod + def from_task_span(task_span: InMemoryTaskSpan) -> "TaskSpanTrace": + return TaskSpanTrace( + name=task_span.name, + traces=[_to_trace_entry(t) for t in task_span.entries], + start=task_span.start_timestamp, + end=task_span.end_timestamp, + # RootModel.model_dump is declared to return the type of root, but actually returns + # a JSON-like structure that fits to the JsonSerializable type + input=JsonSerializer(root=task_span.input).model_dump(mode="json"), + output=JsonSerializer(root=task_span.output).model_dump(mode="json"), + ) + + def _rich_render_(self) -> Tree: + tree = Tree(label="task") + tree.add(_render_log_value(self.input, "Input")) + for log in self.traces: + tree.add(log._rich_render_()) + tree.add(_render_log_value(self.output, "Output")) + return tree + + def _ipython_display_(self) -> None: + """Default rendering for Jupyter notebooks""" + from rich import print + + print(self._rich_render_()) + + +class LogTrace(BaseModel): + """Represents a :class:`LogEntry`. + + Attributes: + message: A description of the value that is being logged, such as the step in the + :class:`Task` this is related to. + value: The logged data. Can be anything that is serializable by Pydantic, + which gives the tracers flexibility in how they store and emit the logs. + """ + + model_config = ConfigDict(frozen=True) + + message: str + value: SerializeAsAny[JsonSerializable] + + @staticmethod + def from_log_entry(entry: LogEntry) -> "LogTrace": + return LogTrace( + message=entry.message, + # RootModel.model_dump is declared to return the type of root, but actually returns + # a JSON-like structure that fits to the JsonSerializable type + value=JsonSerializer(root=entry.value).model_dump(mode="json"), + ) + + def _rich_render_(self) -> Panel: + return _render_log_value(self.value, self.message) + + +def _render_log_value(value: JsonSerializable, title: str) -> Panel: + return Panel( + Syntax(dumps(value, indent=2), "json", word_wrap=True), + title=title, + ) + + +def _to_trace_entry(entry: InMemoryTaskSpan | InMemorySpan | LogEntry) -> Trace: + if isinstance(entry, InMemoryTaskSpan): + return TaskSpanTrace.from_task_span(entry) + elif isinstance(entry, InMemorySpan): + return SpanTrace.from_span(entry) + else: + return LogTrace.from_log_entry(entry) + + +class ExampleTrace(BaseModel): + """Trace of a single evaluated :class:`Example` + + Created to persist the trace of an individual example in the repository. + + Attributes: + run_id: The unique identifier of the run. + example_id: Identifier of the :class:`Example`. + trace: Generated when running the :class:`Task`. + """ + + run_id: str + example_id: str + trace: TaskSpanTrace diff --git a/src/intelligence_layer/evaluation/run_evaluation.py b/src/intelligence_layer/evaluation/run_evaluation.py new file mode 100644 index 000000000..fa84b2e26 --- /dev/null +++ b/src/intelligence_layer/evaluation/run_evaluation.py @@ -0,0 +1,123 @@ +from argparse import ArgumentParser, Namespace +from importlib import import_module +from pathlib import Path +from sys import argv +from typing import Any, Sequence + +from dotenv import load_dotenv + +from intelligence_layer.connectors.limited_concurrency_client import ( + LimitedConcurrencyClient, +) +from intelligence_layer.evaluation.aggregation.aggregator import Aggregator +from intelligence_layer.evaluation.aggregation.file_aggregation_repository import ( + FileAggregationRepository, +) +from intelligence_layer.evaluation.dataset.file_dataset_repository import ( + FileDatasetRepository, +) +from intelligence_layer.evaluation.evaluation.evaluator import Evaluator +from intelligence_layer.evaluation.evaluation.file_evaluation_repository import ( + FileEvaluationRepository, +) +from intelligence_layer.evaluation.run.file_run_repository import FileRunRepository +from intelligence_layer.evaluation.run.runner import Runner + + +def function_from_string(fully_qualified_function_name: str) -> Any: + mod_name, func_name = fully_qualified_function_name.rsplit(".", 1) + mod = import_module(mod_name) + return getattr(mod, func_name) + + +def create_task(factory: Any) -> Any: + try: + return factory() + except TypeError: + return factory(LimitedConcurrencyClient.from_env()) + + +def parse_args(cli_args: Sequence[str]) -> Namespace: + parser = ArgumentParser(description="Runs the given evaluation") + parser.add_argument( + "--eval-logic", + required=True, + type=function_from_string, + help="A factory function for the evaluation logic. ", + ) + parser.add_argument( + "--aggregation-logic", + required=True, + type=function_from_string, + help="A factory function for the aggregation logic. ", + ) + parser.add_argument( + "--task", + required=True, + type=function_from_string, + help="A factory function for the task to be evaluated. " + "This function can either take no parameter or an Aleph Alpha client as only parameter. " + "If this corresponds to the init-parameters of the Evaluator " + "the class-type can actually be provided as argument.", + ) + parser.add_argument( + "--dataset-repository-path", + required=True, + type=Path, + help="Path to a file dataset repository.", + ) + parser.add_argument( + "--dataset-id", + required=True, + type=str, + help="ID of a dataset that exists in the file dataset repository provided.", + ) + parser.add_argument( + "--target-dir", + required=True, + type=Path, + help="Path to a directory where the evaluation results are stored. " + "The directory is created if it does not exist. " + "The process must have corresponding write permissions.", + ) + parser.add_argument( + "--description", + required=True, + type=str, + help="Description of the evaluator.", + ) + args = parser.parse_args(cli_args[1:]) + return args + + +def main(cli_args: Sequence[str]) -> None: + args = parse_args(cli_args) + dataset_repository = FileDatasetRepository(args.dataset_repository_path) + runner_repository = FileRunRepository(args.target_dir) + evaluation_repository = FileEvaluationRepository(args.target_dir) + aggregation_repository = FileAggregationRepository(args.target_dir) + description = args.description + task = create_task(args.task) + runner = Runner(task, dataset_repository, runner_repository, args.task.__name__) + dataset_id = args.dataset_id + run_overview_id = runner.run_dataset(dataset_id).id + eval_logic = args.eval_logic() + aggregation_logic = args.aggregation_logic() + + evaluator = Evaluator( + dataset_repository, + runner_repository, + evaluation_repository, + description, + eval_logic, + ) + aggregator = Aggregator( + evaluation_repository, aggregation_repository, description, aggregation_logic + ) + evaluation_overview = evaluator.evaluate_runs(run_overview_id) + aggregator.aggregate_evaluation(evaluation_overview.id) + + +if __name__ == "__main__": + load_dotenv() + main(argv) diff --git a/src/intelligence_layer/examples/__init__.py b/src/intelligence_layer/examples/__init__.py new file mode 100644 index 000000000..cc8c06709 --- /dev/null +++ b/src/intelligence_layer/examples/__init__.py @@ -0,0 +1,111 @@ +from .classify.classify import ( + AggregatedMultiLabelClassifyEvaluation as AggregatedMultiLabelClassifyEvaluation, +) +from .classify.classify import ( + AggregatedSingleLabelClassifyEvaluation as AggregatedSingleLabelClassifyEvaluation, +) +from .classify.classify import ClassifyInput as ClassifyInput +from .classify.classify import ( + MultiLabelClassifyAggregationLogic as MultiLabelClassifyAggregationLogic, +) +from .classify.classify import ( + MultiLabelClassifyEvaluation as MultiLabelClassifyEvaluation, +) +from .classify.classify import ( + MultiLabelClassifyEvaluationLogic as MultiLabelClassifyEvaluationLogic, +) +from .classify.classify import MultiLabelClassifyOutput as MultiLabelClassifyOutput +from .classify.classify import Probability as Probability +from .classify.classify import ( + SingleLabelClassifyAggregationLogic as SingleLabelClassifyAggregationLogic, +) +from .classify.classify import ( + SingleLabelClassifyEvaluation as SingleLabelClassifyEvaluation, +) +from .classify.classify import ( + SingleLabelClassifyEvaluationLogic as SingleLabelClassifyEvaluationLogic, +) +from .classify.classify import SingleLabelClassifyOutput as SingleLabelClassifyOutput +from .classify.embedding_based_classify import ( + EmbeddingBasedClassify as EmbeddingBasedClassify, +) +from .classify.embedding_based_classify import LabelWithExamples as LabelWithExamples +from .classify.embedding_based_classify import QdrantSearch as QdrantSearch +from .classify.embedding_based_classify import QdrantSearchInput as QdrantSearchInput +from .classify.keyword_extract import KeywordExtract as KeywordExtract +from .classify.keyword_extract import KeywordExtractInput as KeywordExtractInput +from .classify.keyword_extract import KeywordExtractOutput as KeywordExtractOutput +from .classify.prompt_based_classify import PromptBasedClassify as PromptBasedClassify +from .classify.prompt_based_classify import TreeNode as TreeNode +from .classify.prompt_based_classify_with_definitions import ( + LabelWithDefinition as LabelWithDefinition, +) +from .classify.prompt_based_classify_with_definitions import ( + PromptBasedClassifyWithDefinitions as PromptBasedClassifyWithDefinitions, +) +from .qa.long_context_qa import LongContextQa as LongContextQa +from .qa.long_context_qa import LongContextQaInput as LongContextQaInput +from .qa.multiple_chunk_qa import MultipleChunkQa as MultipleChunkQa +from .qa.multiple_chunk_qa import MultipleChunkQaInput as MultipleChunkQaInput +from .qa.multiple_chunk_qa import MultipleChunkQaOutput as MultipleChunkQaOutput +from .qa.multiple_chunk_qa import Subanswer as Subanswer +from .qa.multiple_chunk_retriever_qa import ( + MultipleChunkRetrieverQa as MultipleChunkRetrieverQa, +) +from .qa.multiple_chunk_retriever_qa import ( + MultipleChunkRetrieverQaOutput as MultipleChunkRetrieverQaOutput, +) +from .qa.retriever_based_qa import EnrichedSubanswer as EnrichedSubanswer +from .qa.retriever_based_qa import RetrieverBasedQa as RetrieverBasedQa +from .qa.retriever_based_qa import RetrieverBasedQaInput as RetrieverBasedQaInput +from .qa.retriever_based_qa import RetrieverBasedQaOutput as RetrieverBasedQaOutput +from .qa.single_chunk_qa import SingleChunkQa as SingleChunkQa +from .qa.single_chunk_qa import SingleChunkQaInput as SingleChunkQaInput +from .qa.single_chunk_qa import SingleChunkQaOutput as SingleChunkQaOutput +from .search.expand_chunks import ExpandChunks as ExpandChunks +from .search.expand_chunks import ExpandChunksInput as ExpandChunksInput +from .search.expand_chunks import ExpandChunksOutput as ExpandChunksOutput +from .search.search import AggregatedSearchEvaluation as AggregatedSearchEvaluation +from .search.search import ChunkFound as ChunkFound +from .search.search import ExpectedSearchOutput as ExpectedSearchOutput +from .search.search import Search as Search +from .search.search import SearchAggregationLogic as SearchAggregationLogic +from .search.search import SearchEvaluation as SearchEvaluation +from .search.search import SearchEvaluationLogic as SearchEvaluationLogic +from .search.search import SearchInput as SearchInput +from .search.search import SearchOutput as SearchOutput +from .summarize.recursive_summarize import RecursiveSummarize as RecursiveSummarize +from .summarize.recursive_summarize import ( + RecursiveSummarizeInput as RecursiveSummarizeInput, +) +from .summarize.steerable_long_context_summarize import ( + SteerableLongContextSummarize as SteerableLongContextSummarize, +) +from .summarize.steerable_single_chunk_summarize import ( + SteerableSingleChunkSummarize as SteerableSingleChunkSummarize, +) +from .summarize.summarize import ( + AggregatedSummarizeEvaluation as AggregatedSummarizeEvaluation, +) +from .summarize.summarize import ( + LongContextSummarizeAggregationLogic as LongContextSummarizeAggregationLogic, +) +from .summarize.summarize import ( + LongContextSummarizeEvaluationLogic as LongContextSummarizeEvaluationLogic, +) +from .summarize.summarize import LongContextSummarizeInput as LongContextSummarizeInput +from .summarize.summarize import ( + LongContextSummarizeOutput as LongContextSummarizeOutput, +) +from .summarize.summarize import PartialSummary as PartialSummary +from .summarize.summarize import ( + SingleChunkSummarizeAggregationLogic as SingleChunkSummarizeAggregationLogic, +) +from .summarize.summarize import ( + SingleChunkSummarizeEvaluationLogic as SingleChunkSummarizeEvaluationLogic, +) +from .summarize.summarize import SingleChunkSummarizeInput as SingleChunkSummarizeInput +from .summarize.summarize import SummarizeEvaluation as SummarizeEvaluation +from .summarize.summarize import SummarizeOutput as SummarizeOutput + +__all__ = [symbol for symbol in dir() if symbol and symbol[0].isupper()] diff --git a/src/intelligence_layer/examples/classify/__init__.py b/src/intelligence_layer/examples/classify/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/examples/classify/classify.py b/src/intelligence_layer/examples/classify/classify.py new file mode 100644 index 000000000..d3dcb89e6 --- /dev/null +++ b/src/intelligence_layer/examples/classify/classify.py @@ -0,0 +1,325 @@ +import warnings +from collections import defaultdict +from typing import Iterable, Mapping, NewType, Sequence + +from pydantic import BaseModel + +from intelligence_layer.core import TextChunk +from intelligence_layer.evaluation import ( + AggregationLogic, + Example, + MeanAccumulator, + SingleOutputEvaluationLogic, +) + +Probability = NewType("Probability", float) + + +class ClassifyInput(BaseModel): + """Input for a classification task. + + Attributes: + chunk: text to be classified. + labels: Possible labels the model will choose a label from + """ + + chunk: TextChunk + labels: frozenset[str] + + +class SingleLabelClassifyOutput(BaseModel): + """Output for a single label classification task. + + Attributes: + scores: Mapping of the provided label (key) to corresponding score (value). + The score represents how sure the model is that this is the correct label. + This will be a value between 0 and 1. + The sum of all probabilities will be 1. + """ + + scores: Mapping[str, Probability] + + @property + def sorted_scores(self) -> list[tuple[str, Probability]]: + return sorted(self.scores.items(), key=lambda item: item[1], reverse=True) + + +class MultiLabelClassifyOutput(BaseModel): + """Output for a multi label classification task. + + Attributes: + scores: Mapping of the provided label (key) to corresponding score (value). + The score represents how sure the model is that this is the correct label. + This will be a value between 0 and 1. + There is not constraint on the sum of the individual probabilities. + """ + + scores: Mapping[str, Probability] + + +class SingleLabelClassifyEvaluation(BaseModel): + """The evaluation of a single label classification run. + + Attributes: + correct: Was the highest scoring class from the output in the set of "correct classes". + predicted: The predicted label. + expected: The expected label. + expected_label_missing: Whether the expected label was missing from the possible set of + labels in the task's input. + """ + + correct: bool + predicted: str + expected: str + expected_label_missing: bool + + +class AggregatedLabelInfo(BaseModel): + expected_count: int + predicted_count: int + + +class AggregatedSingleLabelClassifyEvaluation(BaseModel): + """The aggregated evaluation of a single label classify implementation against a dataset. + + Attributes: + percentage_correct: Percentage of answers that were considered to be correct. + confusion_matrix: A matrix showing the predicted classifications vs the expected classifications. + by_label: Each label along side the counts how often it was expected or predicted. + missing_labels: Each expected label which is missing in the set of possible labels in the task input and the number of its occurrences. + """ + + percentage_correct: float + confusion_matrix: Mapping[tuple[str, str], int] + by_label: Mapping[str, AggregatedLabelInfo] + missing_labels: Mapping[str, int] + + +class SingleLabelClassifyAggregationLogic( + AggregationLogic[ + SingleLabelClassifyEvaluation, AggregatedSingleLabelClassifyEvaluation + ] +): + def aggregate( + self, evaluations: Iterable[SingleLabelClassifyEvaluation] + ) -> AggregatedSingleLabelClassifyEvaluation: + acc = MeanAccumulator() + missing_labels: dict[str, int] = defaultdict(int) + confusion_matrix: dict[tuple[str, str], int] = defaultdict(int) + by_label: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + for evaluation in evaluations: + acc.add(1.0 if evaluation.correct else 0.0) + if evaluation.expected_label_missing: + missing_labels[evaluation.expected] += 1 + else: + confusion_matrix[(evaluation.predicted, evaluation.expected)] += 1 + by_label[evaluation.predicted]["predicted"] += 1 + by_label[evaluation.expected]["expected"] += 1 + + if len(missing_labels) > 0: + warn_message = "[WARNING] There were examples with expected labels missing in the evaluation inputs. For a detailed list, see the 'statistics.missing_labels' field of the returned `AggregationOverview`." + warnings.warn(warn_message, RuntimeWarning) + + return AggregatedSingleLabelClassifyEvaluation( + percentage_correct=acc.extract(), + confusion_matrix=confusion_matrix, + by_label={ + label: AggregatedLabelInfo( + expected_count=counts["expected"], + predicted_count=counts["predicted"], + ) + for label, counts in by_label.items() + }, + missing_labels=missing_labels, + ) + + +class SingleLabelClassifyEvaluationLogic( + SingleOutputEvaluationLogic[ + ClassifyInput, + SingleLabelClassifyOutput, + str, + SingleLabelClassifyEvaluation, + ] +): + def do_evaluate_single_output( + self, + example: Example[ClassifyInput, str], + output: SingleLabelClassifyOutput, + ) -> SingleLabelClassifyEvaluation: + if example.expected_output not in example.input.labels: + warn_message = f"[WARNING] Example with ID '{example.id}' has expected label '{example.expected_output}', which is not part of the example's input labels." + warnings.warn(warn_message, RuntimeWarning) + + predicted = output.sorted_scores[0][0] + if predicted == example.expected_output: + correct = True + else: + correct = False + return SingleLabelClassifyEvaluation( + correct=correct, + predicted=predicted, + expected=example.expected_output, + expected_label_missing=example.expected_output not in example.input.labels, + ) + + +class MultiLabelClassifyEvaluation(BaseModel): + """The evaluation of a single multi-label classification example. + + Attributes: + tp: The classes that were expected and correctly predicted (true positives). + tn: The classes that were not expected and correctly not predicted (true negatives). + fp: The classes that were not expected and falsely predicted (false positives). + fn: The classes that were expected and falsely not predicted (false negatives). + """ + + tp: frozenset[str] + tn: frozenset[str] + fp: frozenset[str] + fn: frozenset[str] + + +class MultiLabelClassifyMetrics(BaseModel): + """The relevant metrics resulting from a confusion matrix in a classification run. + + Attributes: + precision: Proportion of correctly predicted classes to all predicted classes. + recall: Proportion of correctly predicted classes to all expected classes. + f1: Aggregated performance, formally the harmonic mean of precision and recall. + """ + + precision: float + recall: float + f1: float + + +class AggregatedMultiLabelClassifyEvaluation(BaseModel): + """The aggregated evaluation of a multi-label classify dataset. + + Attributes: + class_metrics: Mapping of all labels to their aggregated metrics. + micro_avg: Calculated by considering the tp, tn, fp and fn for each class, adding them up and dividing by them. + macro_avg: The metrics' mean across all classes. + + """ + + class_metrics: Mapping[str, MultiLabelClassifyMetrics] + micro_avg: MultiLabelClassifyMetrics + macro_avg: MultiLabelClassifyMetrics + + +class MultiLabelClassifyAggregationLogic( + AggregationLogic[ + MultiLabelClassifyEvaluation, AggregatedMultiLabelClassifyEvaluation + ] +): + def aggregate( + self, evaluations: Iterable[MultiLabelClassifyEvaluation] + ) -> AggregatedMultiLabelClassifyEvaluation: + label_confusion_matrix: dict[str, dict[str, int]] = defaultdict( + lambda: {"tp": 0, "tn": 0, "fp": 0, "fn": 0} + ) + + for evaluation in evaluations: + for tp in evaluation.tp: + label_confusion_matrix[tp]["tp"] += 1 + for tn in evaluation.tn: + label_confusion_matrix[tn]["tn"] += 1 + for fp in evaluation.fp: + label_confusion_matrix[fp]["fp"] += 1 + for fn in evaluation.fn: + label_confusion_matrix[fn]["fn"] += 1 + + class_metrics = {} + sum_tp, sum_fp, sum_fn = 0, 0, 0 + sum_precision, sum_recall, sum_f1 = 0.0, 0.0, 0.0 + + for label, confusion_matrix in label_confusion_matrix.items(): + precision = ( + confusion_matrix["tp"] + / (confusion_matrix["tp"] + confusion_matrix["fp"]) + if confusion_matrix["tp"] + confusion_matrix["fp"] + else 0 + ) + recall = ( + confusion_matrix["tp"] + / (confusion_matrix["tp"] + confusion_matrix["fn"]) + if confusion_matrix["tp"] + confusion_matrix["fn"] + else 0 + ) + f1 = ( + (2 * precision * recall) / (precision + recall) + if precision + recall + else 0 + ) + + class_metrics[label] = MultiLabelClassifyMetrics( + precision=precision, recall=recall, f1=f1 + ) + + sum_tp += confusion_matrix["tp"] + sum_fp += confusion_matrix["fp"] + sum_fn += confusion_matrix["fn"] + sum_precision += precision + sum_recall += recall + sum_f1 += f1 + + try: + micro_avg = MultiLabelClassifyMetrics( + precision=sum_tp / (sum_tp + sum_fp), + recall=sum_tp / (sum_tp + sum_fn), + f1=(2 * (sum_tp / (sum_tp + sum_fp)) * (sum_tp / (sum_tp + sum_fn))) + / ((sum_tp / (sum_tp + sum_fp)) + (sum_tp / (sum_tp + sum_fn))), + ) + except ZeroDivisionError: + micro_avg = MultiLabelClassifyMetrics( + precision=0, + recall=0, + f1=0, + ) + macro_avg = MultiLabelClassifyMetrics( + precision=sum_precision / len(class_metrics), + recall=sum_recall / len(class_metrics), + f1=sum_f1 / len(class_metrics), + ) + + return AggregatedMultiLabelClassifyEvaluation( + class_metrics=class_metrics, micro_avg=micro_avg, macro_avg=macro_avg + ) + + +class MultiLabelClassifyEvaluationLogic( + SingleOutputEvaluationLogic[ + ClassifyInput, + MultiLabelClassifyOutput, + Sequence[str], + MultiLabelClassifyEvaluation, + ] +): + def __init__( + self, + threshold: float = 0.55, + ): + super().__init__() + self.threshold = threshold + + def do_evaluate_single_output( + self, + example: Example[ClassifyInput, Sequence[str]], + output: MultiLabelClassifyOutput, + ) -> MultiLabelClassifyEvaluation: + predicted_classes = frozenset( + label for label, score in output.scores.items() if score > self.threshold + ) + expected_classes = frozenset(example.expected_output) + tp = predicted_classes & expected_classes + tn = (example.input.labels - predicted_classes) & ( + example.input.labels - expected_classes + ) + fp = (example.input.labels - expected_classes) - ( + example.input.labels - predicted_classes + ) + fn = expected_classes - predicted_classes + + return MultiLabelClassifyEvaluation(tp=tp, tn=tn, fp=fp, fn=fn) diff --git a/src/intelligence_layer/examples/classify/embedding_based_classify.py b/src/intelligence_layer/examples/classify/embedding_based_classify.py new file mode 100644 index 000000000..9c0530037 --- /dev/null +++ b/src/intelligence_layer/examples/classify/embedding_based_classify.py @@ -0,0 +1,237 @@ +import statistics +from typing import Sequence + +from pydantic import BaseModel +from qdrant_client.http.models import models + +from intelligence_layer.connectors import ( + Document, + QdrantInMemoryRetriever, + RetrieverType, +) +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, +) +from intelligence_layer.core import Task, TaskSpan, TextChunk +from intelligence_layer.examples.classify.classify import ( + ClassifyInput, + MultiLabelClassifyOutput, + Probability, +) +from intelligence_layer.examples.search.search import SearchOutput + + +class QdrantSearchInput(BaseModel): + """The input for a `QdrantSearch` task. + + Attributes: + query: The text to be searched with. + filter: Conditions to filter by as offered by Qdrant. + """ + + query: str + filter: models.Filter + + +class QdrantSearch(Task[QdrantSearchInput, SearchOutput[int]]): + """Performs search to find documents using QDrant filtering methods. + + Given a query, this task will utilize a retriever to fetch relevant text search results. + Contrary to `Search`, this `Task` offers the option to filter. + + Args: + in_memory_retriever: Implements logic to retrieve matching texts to the query. + + Example: + >>> import os + >>> from intelligence_layer.connectors import ( + ... LimitedConcurrencyClient, + ... ) + >>> from intelligence_layer.connectors import Document + >>> from intelligence_layer.connectors import ( + ... QdrantInMemoryRetriever, + ... ) + >>> from intelligence_layer.core import InMemoryTracer + >>> from intelligence_layer.examples import ( + ... QdrantSearch, + ... QdrantSearchInput, + ... ) + >>> from qdrant_client.http.models import models + + >>> client = LimitedConcurrencyClient.from_env() + >>> documents = [ + ... Document( + ... text="West and East Germany reunited in 1990.", metadata={"title": "Germany"} + ... ) + ... ] + >>> retriever = QdrantInMemoryRetriever(documents, 3, client=client) + >>> task = QdrantSearch(retriever) + >>> input = QdrantSearchInput( + ... query="When did East and West Germany reunite?", + ... filter=models.Filter( + ... must=[ + ... models.FieldCondition( + ... key="metadata.title", + ... match=models.MatchValue(value="Germany"), + ... ), + ... ] + ... ), + ... ) + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + def __init__(self, in_memory_retriever: QdrantInMemoryRetriever): + super().__init__() + self._in_memory_retriever = in_memory_retriever + + def do_run( + self, input: QdrantSearchInput, task_span: TaskSpan + ) -> SearchOutput[int]: + results = self._in_memory_retriever.get_filtered_documents_with_scores( + input.query, input.filter + ) + return SearchOutput(results=results) + + +class LabelWithExamples(BaseModel): + """Defines a label and the list of examples making it up. + + Attributes: + name: Name of the label. + examples: The texts defining the example. Should be similar in structure + and semantics to the texts to be classified on inference. + """ + + name: str + examples: Sequence[str] + + +class EmbeddingBasedClassify(Task[ClassifyInput, MultiLabelClassifyOutput]): + """Task that classifies a given input text based on examples. + + The input contains a complete set of all possible labels. The output will return a score + for each possible label. Scores will be between 0 and 1 but do not have to add up to one. + On initiation, provide a list of examples for each label. + + This methodology works best with a larger number of examples per label and with labels + that consist of easily definable semantic clusters. + + Args: + labels_with_examples: Examples to be used for classification. + client: Aleph Alpha client instance for running model related API calls. + :meth:`intelligence_layer.connectors.LimitedConcurrencyClient.from_env` + top_k_per_label: number of labels to assign to the input text. + + Attributes: + METADATA_LABEL_NAME: The metadata field name for 'label' in the retriever. + + Example: + >>> from intelligence_layer.connectors import ( + ... LimitedConcurrencyClient, + ... ) + >>> from intelligence_layer.core import TextChunk, InMemoryTracer + >>> from intelligence_layer.examples.classify.classify import ClassifyInput + >>> from intelligence_layer.examples.classify.embedding_based_classify import ( + ... EmbeddingBasedClassify, + ... LabelWithExamples, + ... ) + + + >>> labels_with_examples = [ + ... LabelWithExamples( + ... name="positive", + ... examples=[ + ... "I really like this.", + ... ], + ... ), + ... LabelWithExamples( + ... name="negative", + ... examples=[ + ... "I really dislike this.", + ... ], + ... ), + ... ] + >>> client = LimitedConcurrencyClient.from_env() + >>> task = EmbeddingBasedClassify(labels_with_examples, client=client) + >>> input = ClassifyInput(chunk=TextChunk("This is a happy text."), labels=frozenset({"positive", "negative"})) + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + METADATA_LABEL_NAME = "label" + + def __init__( + self, + labels_with_examples: Sequence[LabelWithExamples], + client: AlephAlphaClientProtocol | None = None, + top_k_per_label: int = 5, + ) -> None: + super().__init__() + self._labels_with_examples = labels_with_examples + documents = self._labels_with_examples_to_documents(labels_with_examples) + self._scoring = top_k_per_label + retriever = QdrantInMemoryRetriever( + client=client, + documents=documents, + k=top_k_per_label, + retriever_type=RetrieverType.SYMMETRIC, + ) + self._qdrant_search = QdrantSearch(retriever) + + def do_run( + self, input: ClassifyInput, task_span: TaskSpan + ) -> MultiLabelClassifyOutput: + self._validate_input_labels(input) + results_per_label = [ + self._label_search(input.chunk, label, task_span) for label in input.labels + ] + scores = self._calculate_scores(results_per_label) + return MultiLabelClassifyOutput( + scores={lang: Probability(s) for lang, s in zip(input.labels, scores)} + ) + + def _labels_with_examples_to_documents( + self, classes_with_examples: Sequence[LabelWithExamples] + ) -> Sequence[Document]: + return [ + Document( + text=e, + metadata={self.METADATA_LABEL_NAME: class_with_examples.name}, + ) + for class_with_examples in classes_with_examples + for e in class_with_examples.examples + ] + + def _validate_input_labels(self, input: ClassifyInput) -> None: + available_labels = set( + class_with_examples.name + for class_with_examples in self._labels_with_examples + ) + unknown_labels = input.labels - available_labels + if unknown_labels: + raise ValueError(f"Got unexpected labels: {', '.join(unknown_labels)}.") + + def _label_search( + self, chunk: TextChunk, label: str, task_span: TaskSpan + ) -> SearchOutput[int]: + search_input = QdrantSearchInput( + query=chunk, + filter=models.Filter( + must=[ + models.FieldCondition( + key=f"metadata.{self.METADATA_LABEL_NAME}", + match=models.MatchValue(value=label), + ), + ] + ), + ) + return self._qdrant_search.run(search_input, task_span) + + def _calculate_scores( + self, results_per_label: Sequence[SearchOutput[int]] + ) -> Sequence[float]: + return [ + (statistics.mean(r.score for r in r_per_l.results) + 1) / 2 + for r_per_l in results_per_label + ] diff --git a/src/intelligence_layer/examples/classify/keyword_extract.py b/src/intelligence_layer/examples/classify/keyword_extract.py new file mode 100644 index 000000000..3992cf51b --- /dev/null +++ b/src/intelligence_layer/examples/classify/keyword_extract.py @@ -0,0 +1,69 @@ +from typing import Mapping + +from pydantic import BaseModel + +from intelligence_layer.core import ( + CompleteInput, + ControlModel, + Language, + LuminousControlModel, + Task, + TaskSpan, + TextChunk, +) + +INSTRUCT_CONFIGS = { + Language( + "de" + ): "Worum geht es in dem Text? Extrahiere ein paar Stichwörter in Form einer Komma-separierten Liste.", + Language( + "en" + ): "What is the text about? Extract a few keywords in form of a comma-separated list.", + Language( + "es" + ): "¿De qué trata el texto? Extrae algunas palabras clave en forma de una lista separada por comas.", + Language( + "fr" + ): "De quoi parle le texte? Extraire quelques mots-clés sous forme d'une liste séparée par des virgules.", + Language( + "it" + ): "Di cosa tratta il testo? Estrai alcune parole chiave sotto forma di una lista separata da virgole.", +} + + +class KeywordExtractInput(BaseModel): + chunk: TextChunk + language: Language + + +class KeywordExtractOutput(BaseModel): + keywords: frozenset[str] + + +class KeywordExtract(Task[KeywordExtractInput, KeywordExtractOutput]): + def __init__( + self, + model: ControlModel | None = None, + instruct_configs: Mapping[Language, str] = INSTRUCT_CONFIGS, + maximum_tokens: int = 32, + ) -> None: + self._instruct_configs = instruct_configs + self._model = model or LuminousControlModel("luminous-base-control") + self._maximum_tokens = maximum_tokens + + def do_run( + self, input: KeywordExtractInput, task_span: TaskSpan + ) -> KeywordExtractOutput: + instruction = input.language.language_config(self._instruct_configs) + result = self._model.complete( + CompleteInput( + prompt=self._model.to_instruct_prompt( + instruction=instruction, input=str(input.chunk) + ), + maximum_tokens=self._maximum_tokens, + ), + task_span, + ) + return KeywordExtractOutput( + keywords=frozenset(s.strip() for s in result.completion.split(",")) + ) diff --git a/src/intelligence_layer/examples/classify/prompt_based_classify.py b/src/intelligence_layer/examples/classify/prompt_based_classify.py new file mode 100644 index 000000000..931b5f570 --- /dev/null +++ b/src/intelligence_layer/examples/classify/prompt_based_classify.py @@ -0,0 +1,219 @@ +import math +import re +from typing import Iterable, Mapping, Optional, Sequence + +from aleph_alpha_client import Tokens +from pydantic import BaseModel + +from intelligence_layer.core import ( + Echo, + EchoInput, + EchoOutput, + LuminousControlModel, + RichPrompt, + Task, + TaskSpan, + Token, + TokenWithLogProb, +) +from intelligence_layer.examples.classify.classify import ( + ClassifyInput, + Probability, + SingleLabelClassifyOutput, +) + + +class TokenWithProb(BaseModel): + token: Token + prob: Probability + + +def to_aa_tokens_prompt(tokens: Sequence[Token]) -> RichPrompt: + return RichPrompt([Tokens([token.token_id for token in tokens], [])]) + + +class PromptBasedClassify(Task[ClassifyInput, SingleLabelClassifyOutput]): + """Task that classifies a given input text with one of the given classes. + + The input contains a complete set of all possible labels. The output will return a score for + each possible label. All scores will add up to 1 and are relative to each other. The highest + score is given to the most likely class. + + This methodology works best for classes that are easily understood, and don't require an + explanation or examples. + + Args: + model: The model used throughout the task for model related API calls. Defaults + to luminous-base-control. + echo: echo-task used to compute the score for each label. Defaults to :class:`Echo`. + + Attributes: + PROMPT_TEMPLATE_STR: The prompt template used for answering the question. + 'text' and 'labels' will be inserted here. + MODEL: A valid Aleph Alpha model name. + + Example: + >>> from intelligence_layer.core import InMemoryTracer + >>> from intelligence_layer.core import TextChunk + >>> from intelligence_layer.examples import ClassifyInput + >>> from intelligence_layer.examples import PromptBasedClassify + + + >>> task = PromptBasedClassify() + >>> input = ClassifyInput( + ... chunk=TextChunk("This is a happy text."), labels=frozenset({"positive", "negative"}) + ... ) + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + INSTRUCTION: str = """Identify a class that describes the text adequately. +Reply with only the class label.""" + + def __init__( + self, + model: ( + LuminousControlModel | None + ) = None, # Must use LuminousControl model due to custom logic + echo: Task[EchoInput, EchoOutput] | None = None, + instruction: str = INSTRUCTION, + ) -> None: + super().__init__() + self._model = model or LuminousControlModel("luminous-base-control") + self._echo_task = echo or Echo(self._model) + self.instruction = instruction + + def do_run( + self, input: ClassifyInput, task_span: TaskSpan + ) -> SingleLabelClassifyOutput: + log_probs_per_label = self._log_probs_per_label( + text_to_classify=input.chunk, + labels=input.labels, + task_span=task_span, + ) + task_span.log("Log probs per label", log_probs_per_label) + normalized_probs_per_label = self._normalize(log_probs_per_label, task_span) + scores = self._compute_scores(normalized_probs_per_label) + return SingleLabelClassifyOutput( + scores=scores, + ) + + def _log_probs_per_label( + self, + text_to_classify: str, + labels: frozenset[str], + task_span: TaskSpan, + ) -> Mapping[str, Sequence[TokenWithLogProb]]: + prompt = self._model.to_instruct_prompt( + instruction=self.instruction, input=text_to_classify + ) + inputs = ( + EchoInput( + prompt=prompt, + expected_completion=self._prepare_label_for_echo_task(label), + ) + for label in labels + ) + outputs = self._echo_task.run_concurrently(inputs, task_span) + return { + label: output.tokens_with_log_probs + for label, output in zip(labels, outputs) + } + + def _prepare_label_for_echo_task(self, label: str) -> str: + label = label if re.match(r"^\s+", label) else f" {label}" + return label + "<|endoftext|>" + + def _compute_scores( + self, + normalized_probs_per_score: Mapping[str, Sequence[TokenWithProb]], + ) -> Mapping[str, Probability]: + return { + label: Probability( + math.prod(token_with_prob.prob for token_with_prob in tokens_with_probs) + ) + for label, tokens_with_probs in normalized_probs_per_score.items() + } + + def _normalize( + self, + log_probs_per_label: Mapping[str, Sequence[TokenWithLogProb]], + task_span: TaskSpan, + ) -> Mapping[str, Sequence[TokenWithProb]]: + node = TreeNode() + for log_probs in log_probs_per_label.values(): + node.insert_path(log_probs) + + node.normalize_probs() + normalized_probs = { + label: list( + node.path( + token_with_prob.token + for token_with_prob in log_probs_per_label[label] + ) + ) + for label in log_probs_per_label + } + task_span.log("Normalized Probs", normalized_probs) + return normalized_probs + + +class TreeNode: + def __init__( + self, token: Optional[Token] = None, prob: Optional[Probability] = None + ): + self.token = token + self.prob = prob + self.normalized_prob: Optional[Probability] = None + self.children: list[TreeNode] = [] + + def find_child(self, token: Token) -> Optional["TreeNode"]: + return next((child for child in self.children if child.token == token), None) + + def insert_without_calculation(self, path: Sequence[TokenWithProb]) -> None: + """Inserts a path into the tree without changing the original probability + + Args: + path: Path to insert + + Temporarily here until we change this data structure to be more versatile""" + if not path: + return + token_with_prob = path[0] + child = self.find_child(token_with_prob.token) + if child is None: + child = TreeNode(token_with_prob.token, Probability(token_with_prob.prob)) + self.children.append(child) + + child.insert_without_calculation(path[1:]) + + def insert_path(self, path: Sequence[TokenWithLogProb]) -> None: + if not path: + return + token_with_prob = path[0] + prob = Probability(math.exp(token_with_prob.prob)) + + child = self.find_child(token_with_prob.token) + if child is None: + child = TreeNode(token_with_prob.token, prob) + self.children.append(child) + + child.insert_path(path[1:]) + + def normalize_probs(self) -> None: + total_prob = sum( + child.prob for child in self.children if child.prob is not None + ) + for child in self.children: + if child.prob is not None: + child.normalized_prob = Probability(child.prob / total_prob) + child.normalize_probs() + + def path(self, tokens: Iterable[Token]) -> Iterable[TokenWithProb]: + node = self + for token in tokens: + child = node.find_child(token) + assert child + node = child + assert node.token and node.normalized_prob + yield TokenWithProb(token=node.token, prob=node.normalized_prob) diff --git a/src/intelligence_layer/examples/classify/prompt_based_classify_with_definitions.py b/src/intelligence_layer/examples/classify/prompt_based_classify_with_definitions.py new file mode 100644 index 000000000..7afd64dfd --- /dev/null +++ b/src/intelligence_layer/examples/classify/prompt_based_classify_with_definitions.py @@ -0,0 +1,102 @@ +from math import exp +from typing import Sequence + +from aleph_alpha_client import Prompt +from pydantic import BaseModel + +from intelligence_layer.core import ( + CompleteInput, + CompleteOutput, + ControlModel, + LuminousControlModel, + Task, + TaskSpan, + TextChunk, +) + +from .classify import ClassifyInput, Probability, SingleLabelClassifyOutput + + +class LabelWithDefinition(BaseModel): + """Defines a label with a definition. + + Attributes: + name: Name of the label. + definition: Ad efinition or description of the label. + """ + + name: str + definition: str + + def to_string(self) -> str: + return f"{self.name}: {self.definition}" + + +class PromptBasedClassifyWithDefinitions( + Task[ClassifyInput, SingleLabelClassifyOutput] +): + INSTRUCTION: str = """Identify a class that describes the text adequately. +Reply with only the class label.""" + + def __init__( + self, + labels_with_definitions: Sequence[LabelWithDefinition], + model: ControlModel | None = None, + instruction: str = INSTRUCTION, + ) -> None: + super().__init__() + self._labels_with_definitions = labels_with_definitions + self._model = model or LuminousControlModel("luminous-base-control") + self._instruction = instruction + + def do_run( + self, input: ClassifyInput, task_span: TaskSpan + ) -> SingleLabelClassifyOutput: + complete_output = self._model.complete( + CompleteInput( + prompt=self._get_prompt(input.chunk, input.labels), + completion_bias_inclusion=list(input.labels), + log_probs=len(input.labels) * 2, + ), + task_span, + ) + return SingleLabelClassifyOutput(scores=self._build_scores(complete_output)) + + def _get_prompt(self, chunk: TextChunk, labels: frozenset[str]) -> Prompt: + def format_input(text: str, labels: frozenset[str]) -> str: + definitions = "\n".join( + label.to_string() + for label in self._labels_with_definitions + if label.name in labels + ) + return f"""Labels: +{', '.join(label.name for label in self._labels_with_definitions if label.name in labels)} + +Definitions: +{definitions} + +Text: {text}""" + + unexpected_labels = labels - set( + label.name for label in self._labels_with_definitions + ) + if unexpected_labels: + raise ValueError(f"Got unexpected labels: {', '.join(unexpected_labels)}") + + return self._model.to_instruct_prompt( + instruction=self._instruction, + input=format_input(text=str(chunk), labels=labels), + ) + + def _build_scores(self, complete_output: CompleteOutput) -> dict[str, Probability]: + raw_probs: dict[str, float] = {} + for label in self._labels_with_definitions: + label_prob = 0.0 + assert complete_output.completions[0].log_probs + for token, prob in complete_output.completions[0].log_probs[0].items(): + if label.name.startswith(token.strip()) and prob: + label_prob += exp(prob) + raw_probs[label.name] = label_prob + + total = sum(raw_probs.values()) + return {key: Probability(value / total) for key, value in raw_probs.items()} diff --git a/src/intelligence_layer/examples/qa/__init__.py b/src/intelligence_layer/examples/qa/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/examples/qa/long_context_qa.py b/src/intelligence_layer/examples/qa/long_context_qa.py new file mode 100644 index 000000000..b7f401a95 --- /dev/null +++ b/src/intelligence_layer/examples/qa/long_context_qa.py @@ -0,0 +1,113 @@ +from pydantic import BaseModel + +from intelligence_layer.connectors.retrievers.base_retriever import Document +from intelligence_layer.connectors.retrievers.qdrant_in_memory_retriever import ( + QdrantInMemoryRetriever, +) +from intelligence_layer.core import ( + Chunk, + ChunkInput, + ChunkOutput, + ControlModel, + Language, + LuminousControlModel, + Task, + TaskSpan, + TextChunk, +) +from intelligence_layer.examples.qa.multiple_chunk_qa import ( + MultipleChunkQa, + MultipleChunkQaInput, + MultipleChunkQaOutput, +) +from intelligence_layer.examples.search.search import Search, SearchInput + + +class LongContextQaInput(BaseModel): + """The input for a `LongContextQa` task. + + Attributes: + text: Text of arbitrary length on the basis of which the question is to be answered. + question: The question for the text. + language: The desired language of the answer. ISO 619 str with language e.g. en, fr, etc. + """ + + text: str + question: str + language: Language = Language("en") + + +class LongContextQa(Task[LongContextQaInput, MultipleChunkQaOutput]): + """Answer a question on the basis of a (lengthy) document. + + Best for answering a question on the basis of a long document, where the length + of text exceeds the context length of a model (e.g. 2048 tokens for the luminous models). + + Note: + - Creates instance of `InMemoryRetriever` on the fly. + - `model` provided should be a control-type model. + + Args: + multi_chunk_qa: task used to produce answers for each relevant chunk generated + by the chunk-task for the given input. Defaults to :class:`MultipleChunkQa` . + chunk: task used to chunk the input. Defaults to :class:`Chunk` . + k: The number of top relevant chunks to retrieve. + model: The model used in the task. + + Example: + >>> from intelligence_layer.core import InMemoryTracer + >>> from intelligence_layer.examples import LongContextQa, LongContextQaInput + + + >>> task = LongContextQa() + >>> input = LongContextQaInput(text="Lengthy text goes here...", + ... question="Where does the text go?") + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + def __init__( + self, + multi_chunk_qa: Task[MultipleChunkQaInput, MultipleChunkQaOutput] | None = None, + chunk: Task[ChunkInput, ChunkOutput] | None = None, + k: int = 4, + model: ControlModel | None = None, + ): + super().__init__() + self._model = model or LuminousControlModel("luminous-supreme-control") + self._chunk_task = chunk or Chunk(self._model, 1024) + self._multi_chunk_qa = multi_chunk_qa or MultipleChunkQa( + merge_answers_model=self._model + ) + self._k = k + + def do_run( + self, input: LongContextQaInput, task_span: TaskSpan + ) -> MultipleChunkQaOutput: + chunk_output = self._chunk_task.run(ChunkInput(text=input.text), task_span) + retriever = QdrantInMemoryRetriever( + client=self._model._client, + documents=[ + Document( + text=c, + ) + for c in chunk_output.chunks + ], + k=self._k, + threshold=0.5, + ) + + search_output = Search(retriever).run( + SearchInput(query=input.question), task_span + ) + + multi_chunk_qa_input = MultipleChunkQaInput( + chunks=[ + TextChunk(result.document_chunk.text) + for result in search_output.results + ], + question=input.question, + language=input.language, + ) + qa_output = self._multi_chunk_qa.run(multi_chunk_qa_input, task_span) + return qa_output diff --git a/src/intelligence_layer/examples/qa/multiple_chunk_qa.py b/src/intelligence_layer/examples/qa/multiple_chunk_qa.py new file mode 100644 index 000000000..28d5da09d --- /dev/null +++ b/src/intelligence_layer/examples/qa/multiple_chunk_qa.py @@ -0,0 +1,248 @@ +from typing import Iterable, Mapping, Optional, Sequence + +from pydantic import BaseModel + +from intelligence_layer.core import ( + CompleteInput, + CompleteOutput, + ControlModel, + Language, + LuminousControlModel, + Task, + TaskSpan, + TextChunk, +) +from intelligence_layer.core.text_highlight import ScoredTextHighlight +from intelligence_layer.examples.qa.single_chunk_qa import ( + SingleChunkQa, + SingleChunkQaInput, + SingleChunkQaOutput, +) + + +class MultipleChunkQaInput(BaseModel): + """The input for a `MultipleChunkQa` task. + + Attributes: + chunks: The list of chunks that will be used to answer the question. + Can be arbitrarily long list of chunks. + question: The question that will be answered based on the chunks. + language: The desired language of the answer. ISO 619 str with language e.g. en, fr, etc. + """ + + chunks: Sequence[TextChunk] + question: str + language: Language = Language("en") + + +class Subanswer(BaseModel): + """Individual answer based on just one of the multiple chunks. + + Attributes: + answer: The answer generated by the task. Can be a string or None (if no answer was found). + chunk: Piece of the original text that answer is based on. + highlights: The specific sentences that explain the answer the most. + These are generated by the `TextHighlight` Task. + """ + + answer: Optional[str] + chunk: TextChunk + highlights: Sequence[ScoredTextHighlight] + + +class MultipleChunkQaOutput(BaseModel): + """The output of a `MultipleChunkQa` task. + + Attributes: + answer: The answer generated by the task. Can be a string or None (if no answer was found). + subanswers: All the subanswers used to generate the answer. + """ + + answer: Optional[str] + subanswers: Sequence[Subanswer] + + +class MergeAnswersInstructConfig(BaseModel): + instruction: str + question_label: str + answers_label: str + final_answer_label: str + maximum_tokens: int = 128 + + +MERGE_ANSWERS_INSTRUCT_CONFIGS = { + Language("en"): MergeAnswersInstructConfig( + instruction="You are tasked with combining multiple answers into a single answer. " + "If conflicting answers arise, acknowledge the discrepancies by presenting them collectively. " + "Your answer should not be lomnger than 5 sentences.", + question_label="Question", + answers_label="Answers", + final_answer_label="Final answer:", + ), + Language("it"): MergeAnswersInstructConfig( + instruction="Il compito è quello di combinare più risposte in un'unica risposta. " + "Se emergono risposte contrastanti, riconoscete le discrepanze presentandole collettivamente. " + "La risposta non deve essere più lunga di 5 frasi.", + question_label="Domanda", + answers_label="Risposte", + final_answer_label="Risposta finale:", + ), + Language("fr"): MergeAnswersInstructConfig( + instruction="Vous devez combiner plusieurs réponses en une seule. " + "Si des réponses contradictoires apparaissent, reconnaissez les divergences en les présentant collectivement. " + "Votre réponse ne doit pas dépasser 5 phrases.", + question_label="Question", + answers_label="Réponses", + final_answer_label="Réponse finale:", + ), + Language("de"): MergeAnswersInstructConfig( + instruction="Fasse alle Antworten zu einer einzigen Antwort zusammen. " + "Falls es Widersprüche gibt, präsentiere diese. " + "Deine Antwort sollte nicht länger als 5 Sätze sein.", + question_label="Frage", + answers_label="Antworten", + final_answer_label="Endgültige Antwort:", + ), + Language("es"): MergeAnswersInstructConfig( + instruction="Su tarea consiste en combinar varias respuestas en una sola. " + "Si surgen respuestas contradictorias, reconozca las discrepancias presentándolas colectivamente. " + "Su respuesta no debe superar las 5 frases.", + question_label="Pregunta", + answers_label="Respuestas", + final_answer_label="Respuesta final:", + ), +} + + +class MultipleChunkQa(Task[MultipleChunkQaInput, MultipleChunkQaOutput]): + """Answer a question on the basis of a list of text chunks. + + Uses Aleph Alpha models to generate a natural language answer based on multiple text chunks. + Best for longer texts that are already split into smaller units (chunks). + Relies on SingleChunkQa to generate answers for each chunk and then merges the answers into a single final answer. + Includes logic to return 'answer = None' if the language model determines that the question cannot be + reliably answered on the basis of the chunks. + + Note: + `model` provided should be a control-type model. + + Args: + single_chunk_qa: The task that is used to generate an answer based on a single chunk. + Defaults to :class:`SingleChunkQa` . + model: The model used throughout the task for model related API calls. + Defaults to luminous-supreme-control. + merge_answers_instruct_configs: Mapping language used to prompt parameters. + + Example: + >>> import os + >>> from intelligence_layer.connectors import ( + ... LimitedConcurrencyClient, + ... ) + >>> from intelligence_layer.core import Language, InMemoryTracer + >>> from intelligence_layer.core.chunk import TextChunk + >>> from intelligence_layer.examples import ( + ... MultipleChunkQa, + ... MultipleChunkQaInput, + ... ) + + + >>> task = MultipleChunkQa() + >>> input = MultipleChunkQaInput( + ... chunks=[TextChunk("Tina does not like pizza."), TextChunk("Mike is a big fan of pizza.")], + ... question="Who likes pizza?", + ... language=Language("en"), + ... ) + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + >>> print(output.answer) + Mike likes pizza. + """ + + def __init__( + self, + single_chunk_qa: Task[SingleChunkQaInput, SingleChunkQaOutput] | None = None, + merge_answers_model: ControlModel | None = None, + merge_answers_instruct_configs: Mapping[ + Language, MergeAnswersInstructConfig + ] = MERGE_ANSWERS_INSTRUCT_CONFIGS, + ): + super().__init__() + self._model = merge_answers_model or LuminousControlModel( + "luminous-supreme-control" + ) + self._single_chunk_qa = single_chunk_qa or SingleChunkQa(self._model) + self._merge_answers_instruct_configs = merge_answers_instruct_configs + + def do_run( + self, input: MultipleChunkQaInput, task_span: TaskSpan + ) -> MultipleChunkQaOutput: + instruct_config = input.language.language_config( + self._merge_answers_instruct_configs + ) + + qa_outputs = self._single_chunk_qa.run_concurrently( + ( + SingleChunkQaInput( + question=input.question, chunk=chunk, language=input.language + ) + for chunk in input.chunks + ), + task_span, + ) + final_answer = self._merge_answers( + input.question, qa_outputs, instruct_config, task_span + ) + + return MultipleChunkQaOutput( + answer=final_answer, + subanswers=[ + Subanswer( + answer=qa_output.answer, + chunk=chunk, + highlights=qa_output.highlights, + ) + for qa_output, chunk in zip(qa_outputs, input.chunks) + if qa_output.answer + ], + ) + + def _merge_answers( + self, + question: str, + qa_outputs: Iterable[SingleChunkQaOutput], + instruction_config: MergeAnswersInstructConfig, + task_span: TaskSpan, + ) -> Optional[str]: + answers = [output.answer for output in qa_outputs if output.answer] + if len(answers) == 0: + return None + elif len(answers) == 1: + return answers[0] + + joined_answers = "\n".join(answers) + return self._instruct( + f"""{instruction_config.question_label}: {question} + +{instruction_config.answers_label}: +{joined_answers}""", + instruction_config, + task_span, + ).completion + + def _instruct( + self, + input: str, + instruction_config: MergeAnswersInstructConfig, + task_span: TaskSpan, + ) -> CompleteOutput: + prompt = self._model.to_instruct_prompt( + instruction_config.instruction, + input=input, + response_prefix=f" {instruction_config.final_answer_label}", + ) + return self._model.complete( + CompleteInput( + prompt=prompt, maximum_tokens=instruction_config.maximum_tokens + ), + task_span, + ) diff --git a/src/intelligence_layer/examples/qa/multiple_chunk_retriever_qa.py b/src/intelligence_layer/examples/qa/multiple_chunk_retriever_qa.py new file mode 100644 index 000000000..5e82bf8b1 --- /dev/null +++ b/src/intelligence_layer/examples/qa/multiple_chunk_retriever_qa.py @@ -0,0 +1,217 @@ +from typing import Generic, Mapping, Optional, Sequence + +from pydantic import BaseModel + +from intelligence_layer.connectors.retrievers.base_retriever import ( + ID, + BaseRetriever, + SearchResult, +) +from intelligence_layer.core.chunk import TextChunk +from intelligence_layer.core.detect_language import Language +from intelligence_layer.core.model import ControlModel, LuminousControlModel +from intelligence_layer.core.task import Task +from intelligence_layer.core.text_highlight import ScoredTextHighlight +from intelligence_layer.core.tracer.tracer import TaskSpan +from intelligence_layer.examples.search.expand_chunks import ( + ExpandChunks, + ExpandChunksInput, + ExpandChunksOutput, +) +from intelligence_layer.examples.search.search import Search, SearchInput + +from .retriever_based_qa import RetrieverBasedQaInput +from .single_chunk_qa import SingleChunkQa, SingleChunkQaInput, SingleChunkQaOutput + + +class EnrichedChunk(BaseModel, Generic[ID]): + document_id: ID + chunk: TextChunk + indices: tuple[int, int] + + +class AnswerSource(BaseModel, Generic[ID]): + chunk: EnrichedChunk[ID] + highlights: Sequence[ScoredTextHighlight] + + +class MultipleChunkRetrieverQaOutput(BaseModel, Generic[ID]): + answer: Optional[str] + sources: Sequence[AnswerSource[ID]] + search_results: Sequence[SearchResult[ID]] + + +SOURCE_PREFIX_CONFIG = { + Language("en"): "Source {i}:\n", + Language("de"): "Quelle {i}:\n", + Language("fr"): "Source {i}:\n", + Language("es"): "Fuente {i}:\n", + Language("it"): "Fonte {i}:\n", +} + + +class MultipleChunkRetrieverQa( + Task[RetrieverBasedQaInput, MultipleChunkRetrieverQaOutput[ID]], Generic[ID] +): + """Answer a question based on documents found by a retriever. + + `MultipleChunkRetrieverBasedQa` is a task that answers a question based on a set of documents. + It relies on some retriever of type `BaseRetriever` that has the ability to access texts. + In contrast to the regular `RetrieverBasedQa`, this tasks injects multiple chunks into one + `SingleChunkQa` task run. + + We recommend using this task instead of `RetrieverBasedQa`. + + Note: + `model` provided should be a control-type model. + + Args: + retriever: Used to access and return a set of texts. + insert_chunk_number: number of top chunks to inject into :class:`SingleChunkQa`-task. + model: The model used throughout the task for model related API calls. + expand_chunks: The task used to fetch adjacent chunks to the search results. These + "expanded" chunks will be injected into the prompt. + single_chunk_qa: The task used to generate an answer for a single chunk (retrieved through + the retriever). Defaults to :class:`SingleChunkQa`. + """ + + def __init__( + self, + retriever: BaseRetriever[ID], + insert_chunk_number: int = 5, + model: ControlModel | None = None, + expand_chunks: Task[ExpandChunksInput[ID], ExpandChunksOutput] | None = None, + single_chunk_qa: Task[SingleChunkQaInput, SingleChunkQaOutput] | None = None, + source_prefix_config: Mapping[Language, str] = SOURCE_PREFIX_CONFIG, + ): + super().__init__() + self._search = Search(retriever) + self._insert_chunk_number = insert_chunk_number + self._model = model or LuminousControlModel("luminous-supreme-control") + self._expand_chunks = expand_chunks or ExpandChunks(retriever, self._model) + self._single_chunk_qa = single_chunk_qa or SingleChunkQa(self._model) + + if any("{i}" not in value for value in source_prefix_config.values()): + raise ValueError("All values in `source_prefix_config` must contain '{i}'.") + self._source_prefix_config = source_prefix_config + + def do_run( + self, input: RetrieverBasedQaInput, task_span: TaskSpan + ) -> MultipleChunkRetrieverQaOutput[ID]: + search_output = self._search.run( + SearchInput(query=input.question), task_span + ).results + sorted_search_results = sorted( + search_output, key=lambda output: output.score, reverse=True + ) + if not sorted_search_results: + return MultipleChunkRetrieverQaOutput( + answer=None, + sources=[], + search_results=[], + ) + + chunks_to_insert = self._expand_search_result_chunks( + sorted_search_results, task_span + ) + + source_prefix = input.language.language_config(self._source_prefix_config) + chunk_for_prompt, chunk_start_indices = self._combine_input_texts( + [c.chunk for c in chunks_to_insert], source_prefix + ) + + single_chunk_qa_input = SingleChunkQaInput( + chunk=chunk_for_prompt, + question=input.question, + language=input.language, + ) + + single_chunk_qa_output = self._single_chunk_qa.run( + single_chunk_qa_input, task_span + ) + + highlights_per_chunk = self._get_highlights_per_chunk( + chunk_start_indices, single_chunk_qa_output.highlights + ) + + return MultipleChunkRetrieverQaOutput( + answer=single_chunk_qa_output.answer, + sources=[ + AnswerSource( + chunk=enriched_chunk, + highlights=highlights, + ) + for enriched_chunk, highlights in zip( + chunks_to_insert, highlights_per_chunk + ) + ], + search_results=sorted_search_results, + ) + + @staticmethod + def _combine_input_texts( + chunks: Sequence[str], source_appendix: str + ) -> tuple[TextChunk, Sequence[int]]: + start_indices: list[int] = [] + combined_text = "" + for i, chunk in enumerate(chunks): + combined_text += source_appendix.format(i=i + 1) + start_indices.append(len(combined_text)) + combined_text += chunk + "\n\n" + return (TextChunk(combined_text.strip()), start_indices) + + @staticmethod + def _get_highlights_per_chunk( + chunk_start_indices: Sequence[int], highlights: Sequence[ScoredTextHighlight] + ) -> Sequence[Sequence[ScoredTextHighlight]]: + overlapping_ranges = [] + for i in range(len(chunk_start_indices)): + current_start = chunk_start_indices[i] + next_start = ( + chunk_start_indices[i + 1] + if i + 1 < len(chunk_start_indices) + else float("inf") + ) + + current_overlaps = [] + for highlight in highlights: + if highlight.start < next_start and highlight.end > current_start: + highlights_with_indices_fixed = ScoredTextHighlight( + start=max(0, highlight.start - current_start), + end=( + highlight.end - current_start + if isinstance(next_start, float) + else min(next_start, highlight.end - current_start) + ), + score=highlight.score, + ) + current_overlaps.append(highlights_with_indices_fixed) + + overlapping_ranges.append(current_overlaps) + return overlapping_ranges + + def _expand_search_result_chunks( + self, search_results: Sequence[SearchResult[ID]], task_span: TaskSpan + ) -> Sequence[EnrichedChunk[ID]]: + chunks_to_insert: list[EnrichedChunk[ID]] = [] + for result in search_results: + input = ExpandChunksInput( + document_id=result.id, chunks_found=[result.document_chunk] + ) + expand_chunks_output = self._expand_chunks.run(input, task_span) + for chunk in expand_chunks_output.chunks: + if len(chunks_to_insert) >= self._insert_chunk_number: + break + + enriched_chunk = EnrichedChunk( + document_id=result.id, + chunk=chunk.chunk, + indices=(chunk.start_index, chunk.end_index), + ) + + if enriched_chunk in chunks_to_insert: + continue + + chunks_to_insert.append(enriched_chunk) + + return chunks_to_insert diff --git a/src/intelligence_layer/examples/qa/retriever_based_qa.py b/src/intelligence_layer/examples/qa/retriever_based_qa.py new file mode 100644 index 000000000..c201f7262 --- /dev/null +++ b/src/intelligence_layer/examples/qa/retriever_based_qa.py @@ -0,0 +1,140 @@ +import warnings +from typing import Generic, Optional, Sequence + +from pydantic import BaseModel + +from intelligence_layer.connectors.retrievers.base_retriever import ID, BaseRetriever +from intelligence_layer.core import Language, Task, TaskSpan, TextChunk +from intelligence_layer.examples.qa.multiple_chunk_qa import ( + MultipleChunkQa, + MultipleChunkQaInput, + MultipleChunkQaOutput, + Subanswer, +) +from intelligence_layer.examples.search.search import Search, SearchInput + + +class RetrieverBasedQaInput(BaseModel): + """The input for a `RetrieverBasedQa` task. + + Attributes: + question: The question to be answered based on the documents accessed + by the retriever. + language: The desired language of the answer. ISO 619 str with language e.g. en, fr, etc. + """ + + question: str + language: Language = Language("en") + + +class EnrichedSubanswer(Subanswer, Generic[ID]): + """Individual answer for a chunk that also contains the origin of the chunk. + + Attributes: + answer: The answer generated by the task. Can be a string or None (if no answer was found). + chunk: Piece of the original text that answer is based on. + highlights: The specific sentences that explain the answer the most. + These are generated by the `TextHighlight` Task. + id: The id of the document where the chunk came from. + """ + + id: ID + + +class RetrieverBasedQaOutput(BaseModel, Generic[ID]): + """The output of a `RetrieverBasedQa` task. + + Attributes: + answer: The answer generated by the task. Can be a string or None (if no answer was found). + subanswers: All the subanswers used to generate the answer. + """ + + answer: Optional[str] + subanswers: Sequence[EnrichedSubanswer[ID]] + + +class RetrieverBasedQa( + Task[RetrieverBasedQaInput, RetrieverBasedQaOutput[ID]], Generic[ID] +): + """Answer a question based on documents found by a retriever. + + `RetrieverBasedQa` is a task that answers a question based on a set of documents. + Relies on some retriever of type `BaseRetriever` that has the ability to access texts. + + Note: + `model` provided should be a control-type model. + + Args: + retriever: Used to access and return a set of texts. + qa_task: The task that is used to generate an answer for a single chunk (retrieved through + the retriever). Defaults to :class:`MultipleChunkQa` . + + Example: + >>> import os + >>> from intelligence_layer.connectors import DocumentIndexClient + >>> from intelligence_layer.connectors import DocumentIndexRetriever + >>> from intelligence_layer.core import InMemoryTracer + >>> from intelligence_layer.examples import RetrieverBasedQa, RetrieverBasedQaInput + + + >>> token = os.getenv("AA_TOKEN") + >>> document_index = DocumentIndexClient(token) + >>> retriever = DocumentIndexRetriever(document_index, "asymmetric", "aleph-alpha", "wikipedia-de", 3) + >>> task = RetrieverBasedQa(retriever) + >>> input_data = RetrieverBasedQaInput(question="When was Rome founded?") + >>> tracer = InMemoryTracer() + >>> output = task.run(input_data, tracer) + """ + + def __init__( + self, + retriever: BaseRetriever[ID], + multi_chunk_qa: Task[MultipleChunkQaInput, MultipleChunkQaOutput] | None = None, + ): + super().__init__() + self._search = Search(retriever) + self._multi_chunk_qa = multi_chunk_qa or MultipleChunkQa() + + def do_run( + self, input: RetrieverBasedQaInput, task_span: TaskSpan + ) -> RetrieverBasedQaOutput[ID]: + warnings.warn( + "`RetrieverBasedQa` is deprecated and will be removed in future versions. We recommend using `MultipleChunkRetrieverQa` instead.", + DeprecationWarning, + ) + + search_output = self._search.run( + SearchInput(query=input.question), task_span + ).results + sorted_search_output = sorted( + search_output, key=lambda output: output.score, reverse=True + ) + + multi_chunk_qa_input = MultipleChunkQaInput( + chunks=[ + TextChunk(output.document_chunk.text) for output in sorted_search_output + ], + question=input.question, + language=input.language, + ) + + multi_chunk_qa_output = self._multi_chunk_qa.run( + multi_chunk_qa_input, task_span + ) + + enriched_answers = [ + EnrichedSubanswer( + answer=answer.answer, + chunk=TextChunk(input.document_chunk.text), + highlights=answer.highlights, + id=input.id, + ) + for answer, input in zip( + multi_chunk_qa_output.subanswers, sorted_search_output + ) + ] + correctly_formatted_output = RetrieverBasedQaOutput( + answer=multi_chunk_qa_output.answer, + subanswers=enriched_answers, + ) + return correctly_formatted_output diff --git a/src/intelligence_layer/examples/qa/single_chunk_qa.py b/src/intelligence_layer/examples/qa/single_chunk_qa.py new file mode 100644 index 000000000..d998ed4fb --- /dev/null +++ b/src/intelligence_layer/examples/qa/single_chunk_qa.py @@ -0,0 +1,231 @@ +from typing import Mapping, Optional, Sequence + +from liquid import Template +from pydantic import BaseModel + +from intelligence_layer.core import ( + CompleteInput, + CompleteOutput, + ControlModel, + Language, + LuminousControlModel, + RichPrompt, + Task, + TaskSpan, + TextChunk, + TextHighlight, + TextHighlightInput, + TextHighlightOutput, +) +from intelligence_layer.core.prompt_template import TextCursor +from intelligence_layer.core.text_highlight import ScoredTextHighlight + + +class QaSetup(BaseModel): + unformatted_instruction: str + no_answer_str: str + no_answer_logit_bias: Optional[float] = None + + +QA_INSTRUCTIONS = { + Language("en"): QaSetup( + unformatted_instruction='Question: {{question}}\nAnswer the question on the basis of the text. If there is no answer within the text, respond "{{no_answer_text}}".', + no_answer_str="no answer in text", + no_answer_logit_bias=1.0, + ), + Language("de"): QaSetup( + unformatted_instruction='Beantworte die Frage anhand des Textes. Wenn sich die Frage nicht mit dem Text beantworten lässt, antworte "{{no_answer_text}}".\nFrage: {{question}}', + no_answer_str="Unbeantwortbar", + no_answer_logit_bias=0.5, + ), + Language("fr"): QaSetup( + unformatted_instruction="{{question}}\nS'il n'y a pas de réponse, dites \"{{no_answer_text}}\". Ne répondez à la question qu'en vous basant sur le texte.", + no_answer_str="pas de réponse dans le texte", + ), + Language("es"): QaSetup( + unformatted_instruction='{{question}}\nSi no hay respuesta, di "{{no_answer_text}}". Responde sólo a la pregunta basándote en el texto.', + no_answer_str="no hay respuesta en el texto", + ), + Language("it"): QaSetup( + unformatted_instruction='{{question}}\nSe non c\'è risposta, dire "{{no_answer_text}}". Rispondere alla domanda solo in base al testo.', + no_answer_str="nessuna risposta nel testo", + ), +} + + +class SingleChunkQaInput(BaseModel): + """The input for a `SingleChunkQa` task. + + Attributes: + chunk: The (short) text to be asked about. Usually measures one or a few paragraph(s). + Can't be longer than the context length of the model used minus the size of the system prompt. + question: The question to be asked by about the chunk. + language: The desired language of the answer. ISO 619 str with language e.g. en, fr, etc. + """ + + chunk: TextChunk + question: str + language: Language = Language("en") + + +class SingleChunkQaOutput(BaseModel): + """The output of a `SingleChunkQa` task. + + Attributes: + answer: The answer generated by the task. Can be a string or None (if no answer was found). + highlights: Highlights indicating which parts of the chunk contributed to the answer. + Each highlight is a quote from the text. + """ + + answer: Optional[str] + highlights: Sequence[ScoredTextHighlight] + + +class SingleChunkQa(Task[SingleChunkQaInput, SingleChunkQaOutput]): + """Answer a question on the basis of one chunk. + + Uses Aleph Alpha models to generate a natural language answer for a text chunk given a question. + Will answer `None` if the language model determines that the question cannot be answered on the + basis of the text. + + Args: + model: The model used throughout the task for model related API calls. + text_highlight: The task that is used for highlighting that parts of the input that are + relevant for the answer. Defaults to :class:`TextHighlight` . + instruction_config: defines instructions for different langaugaes. + maximum_token: the maximal number of tokens to be generated for an answer. + + Attributes: + NO_ANSWER_STR: The string to be generated by the model in case no answer can be found. + + + Example: + >>> import os + >>> from intelligence_layer.core import Language, InMemoryTracer + >>> from intelligence_layer.core import TextChunk + >>> from intelligence_layer.examples import SingleChunkQa, SingleChunkQaInput + >>> + >>> task = SingleChunkQa() + >>> input = SingleChunkQaInput( + ... chunk=TextChunk("Tina does not like pizza. However, Mike does."), + ... question="Who likes pizza?", + ... language=Language("en"), + ... ) + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + def __init__( + self, + model: ControlModel | None = None, + text_highlight: Task[TextHighlightInput, TextHighlightOutput] | None = None, + instruction_config: Mapping[Language, QaSetup] = QA_INSTRUCTIONS, + maximum_tokens: int = 256, + ): + super().__init__() + self._model = model or LuminousControlModel("luminous-supreme-control") + self._text_highlight = text_highlight or TextHighlight(self._model, clamp=True) + self._instruction_config = instruction_config + self._maximum_tokens = maximum_tokens + + def do_run( + self, input: SingleChunkQaInput, task_span: TaskSpan + ) -> SingleChunkQaOutput: + qa_setup = input.language.language_config(self._instruction_config) + + instruction = Template(qa_setup.unformatted_instruction).render( + question=input.question, no_answer_text=qa_setup.no_answer_str + ) + + no_answer_logit_bias = ( + self._get_no_answer_logit_bias( + qa_setup.no_answer_str, qa_setup.no_answer_logit_bias + ) + if qa_setup.no_answer_logit_bias + else None + ) + output, prompt = self._generate_answer( + instruction, + input.chunk, + no_answer_logit_bias, + task_span, + ) + + answer = self._no_answer_to_none( + output.completion.strip(), qa_setup.no_answer_str + ) + + raw_highlights = ( + self._get_highlights( + prompt, + output.completion, + task_span, + ) + if answer + else [] + ) + highlights = self._shift_highlight_ranges_to_input(prompt, raw_highlights) + + return SingleChunkQaOutput( + answer=answer, + highlights=highlights, + ) + + def _shift_highlight_ranges_to_input( + self, prompt: RichPrompt, raw_highlights: Sequence[ScoredTextHighlight] + ) -> Sequence[ScoredTextHighlight]: + # This only works with models that have an 'input' range, e.g. control models. + input_cursor = prompt.ranges["input"][0].start + assert isinstance(input_cursor, TextCursor) + input_offset = input_cursor.position + return [ + ScoredTextHighlight( + start=raw.start - input_offset, + end=raw.end - input_offset, + score=raw.score, + ) + for raw in raw_highlights + ] + + def _get_no_answer_logit_bias( + self, no_answer_str: str, no_answer_logit_bias: float + ) -> dict[int, float]: + return {self._model.tokenize(no_answer_str).ids[0]: no_answer_logit_bias} + + def _generate_answer( + self, + instruction: str, + input: str, + no_answer_logit_bias: Optional[dict[int, float]], + task_span: TaskSpan, + ) -> tuple[CompleteOutput, RichPrompt]: + prompt = self._model.to_instruct_prompt(instruction, input) + + return ( + self._model.complete( + CompleteInput( + prompt=prompt, + maximum_tokens=self._maximum_tokens, + logit_bias=no_answer_logit_bias, + ), + task_span, + ), + prompt, + ) + + def _get_highlights( + self, + rich_prompt: RichPrompt, + completion: str, + task_span: TaskSpan, + ) -> Sequence[ScoredTextHighlight]: + highlight_input = TextHighlightInput( + rich_prompt=rich_prompt, + target=completion, + focus_ranges=frozenset({"input"}), + ) + highlight_output = self._text_highlight.run(highlight_input, task_span) + return [h for h in highlight_output.highlights if h.score > 0] + + def _no_answer_to_none(self, completion: str, no_answer_str: str) -> Optional[str]: + return completion if no_answer_str not in completion else None diff --git a/src/intelligence_layer/examples/search/__init__.py b/src/intelligence_layer/examples/search/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/examples/search/expand_chunks.py b/src/intelligence_layer/examples/search/expand_chunks.py new file mode 100644 index 000000000..1589501b6 --- /dev/null +++ b/src/intelligence_layer/examples/search/expand_chunks.py @@ -0,0 +1,98 @@ +from functools import lru_cache +from typing import Generic, Sequence + +from pydantic import BaseModel + +from intelligence_layer.connectors import BaseRetriever, DocumentChunk +from intelligence_layer.connectors.retrievers.base_retriever import ID +from intelligence_layer.core import ( + ChunkInput, + ChunkWithIndices, + ChunkWithStartEndIndices, + NoOpTracer, +) +from intelligence_layer.core.model import AlephAlphaModel +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer.tracer import TaskSpan + + +class ExpandChunksInput(BaseModel, Generic[ID]): + document_id: ID + chunks_found: Sequence[DocumentChunk] + + +class ExpandChunksOutput(BaseModel): + chunks: Sequence[ChunkWithStartEndIndices] + + +class ExpandChunks(Generic[ID], Task[ExpandChunksInput[ID], ExpandChunksOutput]): + """Expand chunks found during search. + + Args: + retriever: Used to access and return a set of texts. + model: The model's tokenizer is relevant to calculate the correct size of the returned chunks. + max_chunk_size: The maximum chunk size of each returned chunk. + """ + + def __init__( + self, + retriever: BaseRetriever[ID], + model: AlephAlphaModel, + max_chunk_size: int = 512, + ): + super().__init__() + self._retriever = retriever + self._chunk_with_indices = ChunkWithIndices(model, max_chunk_size) + self._no_op_tracer = NoOpTracer() + + def do_run( + self, input: ExpandChunksInput[ID], task_span: TaskSpan + ) -> ExpandChunksOutput: + chunked_text = self._retrieve_and_chunk(input.document_id) + + overlapping_chunk_indices = self._overlapping_chunk_indices( + [(c.start_index, c.end_index) for c in chunked_text], + [(chunk.start, chunk.end) for chunk in input.chunks_found], + ) + + return ExpandChunksOutput( + chunks=[chunked_text[index] for index in overlapping_chunk_indices], + ) + + @lru_cache(maxsize=100) + def _retrieve_and_chunk( + self, document_id: ID + ) -> Sequence[ChunkWithStartEndIndices]: + text = self._retrieve_text(document_id) + return self._chunk_text(text) + + def _retrieve_text(self, document_id: ID) -> str: + full_document = self._retriever.get_full_document(document_id) + if not full_document: + raise RuntimeError(f"No document for id '{document_id}' found") + return full_document.text + + def _chunk_text(self, text: str) -> Sequence[ChunkWithStartEndIndices]: + # NoOpTracer used to allow caching {ID: Sequence[ChunkWithStartEndIndices]} + return self._chunk_with_indices.run( + ChunkInput(text=text), self._no_op_tracer + ).chunks_with_indices + + def _overlapping_chunk_indices( + self, + chunk_indices: Sequence[tuple[int, int]], + target_ranges: Sequence[tuple[int, int]], + ) -> list[int]: + overlapping_indices: list[int] = [] + + for i in range(len(chunk_indices)): + if any( + ( + chunk_indices[i][0] <= target_range[1] + and chunk_indices[i][1] > target_range[0] + ) + for target_range in target_ranges + ): + overlapping_indices.append(i) + + return overlapping_indices diff --git a/src/intelligence_layer/examples/search/search.py b/src/intelligence_layer/examples/search/search.py new file mode 100644 index 000000000..bf43b5669 --- /dev/null +++ b/src/intelligence_layer/examples/search/search.py @@ -0,0 +1,183 @@ +from typing import Generic, Iterable, Mapping, Optional, Sequence + +from pydantic import BaseModel + +from intelligence_layer.connectors.retrievers.base_retriever import ( + ID, + BaseRetriever, + SearchResult, +) +from intelligence_layer.core import Task, TaskSpan +from intelligence_layer.evaluation import ( + AggregationLogic, + Example, + MeanAccumulator, + SingleOutputEvaluationLogic, +) + + +class SearchInput(BaseModel): + """The input for a `Search` task. + + Attributes: + query: The text to be searched with. + """ + + query: str + + +class SearchOutput(BaseModel, Generic[ID]): + """The output of a `Search` task. + + Attributes: + results: Each result contains a text and corresponding score. + """ + + results: Sequence[SearchResult[ID]] + + +class Search(Generic[ID], Task[SearchInput, SearchOutput[ID]]): + """Performs search to find documents. + + Given a query, this task will utilize a retriever to fetch relevant text search results. + Each result consists of a string representation of the content and an associated score + indicating its relevance to the provided query. + + Args: + retriever: Implements logic to retrieve matching texts to the query. + + Example: + >>> from os import getenv + >>> from intelligence_layer.connectors import ( + ... DocumentIndexClient, + ... ) + >>> from intelligence_layer.connectors import ( + ... DocumentIndexRetriever, + ... ) + >>> from intelligence_layer.core import InMemoryTracer + >>> from intelligence_layer.examples import Search, SearchInput + + + >>> document_index = DocumentIndexClient(getenv("AA_TOKEN")) + >>> retriever = DocumentIndexRetriever(document_index, "asymmetric", "aleph-alpha", "wikipedia-de", 3) + >>> task = Search(retriever) + >>> input = SearchInput(query="When did East and West Germany reunite?") + >>> tracer = InMemoryTracer() + >>> output = task.run(input, tracer) + """ + + def __init__(self, retriever: BaseRetriever[ID]): + super().__init__() + self._retriever = retriever + + def do_run(self, input: SearchInput, task_span: TaskSpan) -> SearchOutput[ID]: + results = self._retriever.get_relevant_documents_with_scores(input.query) + return SearchOutput(results=results) + + +class ExpectedSearchOutput(BaseModel, Generic[ID]): + document_id: ID + start_idx: int + end_idx: int + + +class SearchEvaluation(BaseModel): + rank: Optional[int] + similarity_score: Optional[float] + + +class SearchEvaluationLogic( + Generic[ID], + SingleOutputEvaluationLogic[ + SearchInput, SearchOutput[ID], ExpectedSearchOutput[ID], SearchEvaluation + ], +): + def do_evaluate_single_output( + self, + example: Example[SearchInput, ExpectedSearchOutput[ID]], + output: SearchOutput[ID], + ) -> SearchEvaluation: + results = output.results + + def same_document(id_a: ID, id_b: ID) -> bool: + return id_a == id_b + + def chunks_overlap(range_a: tuple[int, int], range_b: tuple[int, int]) -> bool: + a_start, a_end = range_a + b_start, b_end = range_b + return a_start < b_end and b_start < a_end + + rank, score = next( + ( + (index + 1, result.score) + for index, result in enumerate(results) + if same_document(result.id, example.expected_output.document_id) + and chunks_overlap( + (result.document_chunk.start, result.document_chunk.end), + ( + example.expected_output.start_idx, + example.expected_output.end_idx, + ), + ) + ), + (None, None), + ) + + return SearchEvaluation(rank=rank, similarity_score=score) + + +class ChunkFound(BaseModel): + found_count: int # found => chunk was within top-k results of retriever + expected_count: int + percentage: float + + +class AggregatedSearchEvaluation(BaseModel): + mean_score: float + mean_reciprocal_rank: float + mean_top_ks: Mapping[int, float] + chunk_found: ChunkFound + + +class SearchAggregationLogic( + AggregationLogic[SearchEvaluation, AggregatedSearchEvaluation] +): + def __init__(self, top_ks_to_evaluate: Sequence[int]) -> None: + assert all(top_k > 0 for top_k in top_ks_to_evaluate) + self.top_ks_to_evaluate = top_ks_to_evaluate + + def aggregate( + self, evaluations: Iterable[SearchEvaluation] + ) -> AggregatedSearchEvaluation: + score_accumulator = MeanAccumulator() + reciprocal_rank_accumulator = MeanAccumulator() + chunk_found_accumulator = MeanAccumulator() + top_k_accumulator = { + top_k: MeanAccumulator() for top_k in self.top_ks_to_evaluate + } + + for evaluation in evaluations: + chunk_found = True if evaluation.rank else False + chunk_found_accumulator.add(chunk_found) + if chunk_found: + assert evaluation.similarity_score and evaluation.rank + + score_accumulator.add(evaluation.similarity_score) + reciprocal_rank_accumulator.add(1 / evaluation.rank) + for top_k in self.top_ks_to_evaluate: + top_k_accumulator[top_k].add( + 1.0 if evaluation.rank <= top_k else 0.0 + ) + + return AggregatedSearchEvaluation( + mean_score=score_accumulator.extract(), + mean_reciprocal_rank=reciprocal_rank_accumulator.extract(), + mean_top_ks={ + top_k: acc.extract() for top_k, acc in top_k_accumulator.items() + }, + chunk_found=ChunkFound( + found_count=int(chunk_found_accumulator._acc), + expected_count=chunk_found_accumulator._n, + percentage=chunk_found_accumulator.extract(), + ), + ) diff --git a/src/intelligence_layer/examples/summarize/__init__.py b/src/intelligence_layer/examples/summarize/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/intelligence_layer/examples/summarize/recursive_summarize.py b/src/intelligence_layer/examples/summarize/recursive_summarize.py new file mode 100644 index 000000000..0864890be --- /dev/null +++ b/src/intelligence_layer/examples/summarize/recursive_summarize.py @@ -0,0 +1,85 @@ +from pydantic import BaseModel + +from intelligence_layer.core import Task, TaskSpan +from intelligence_layer.core.detect_language import Language +from intelligence_layer.examples.summarize.steerable_long_context_summarize import ( + SteerableLongContextSummarize, +) +from intelligence_layer.examples.summarize.summarize import ( + LongContextSummarizeInput, + LongContextSummarizeOutput, + SummarizeOutput, +) + + +class RecursiveSummarizeInput(BaseModel): + """The input for a recursive summarize-task for a text of any length. + + Attributes: + text: A text of any length. + language: The desired language of the summary. ISO 619 str with language e.g. en, fr, etc. + max_tokens: The maximum desired length of the summary in tokens. + """ + + text: str + language: Language = Language("en") + max_tokens: int = 512 + + +class RecursiveSummarize(Task[RecursiveSummarizeInput, SummarizeOutput]): + """This task will summarize the input text recursively until the desired length is reached. + It uses any long-context summarize task to go over text recursively and condense it even further. + + Args: + long_context_summarize_task: Any task that satifies the interface Input: LongContextSummarizeInput and Output: LongContextSummarizeOutput. + Defaults to :class:`SteerableLongContextSummarize` + """ + + def __init__( + self, + long_context_summarize_task: ( + Task[LongContextSummarizeInput, LongContextSummarizeOutput] | None + ) = None, + ) -> None: + self.long_context_summarize_task = ( + long_context_summarize_task or SteerableLongContextSummarize() + ) + + def do_run( + self, input: RecursiveSummarizeInput, task_span: TaskSpan + ) -> SummarizeOutput: + num_partial_summaries = 0 + text_to_summarize = input.text + summary = "" + num_generated_tokens = 0 + while True: + summarize_output = self.long_context_summarize_task.run( + LongContextSummarizeInput( + text=text_to_summarize, language=input.language + ), + task_span, + ) + # If the number of chunks stayed the same, we assume that no further summarization has taken place and we return the previous summary + if num_partial_summaries == len(summarize_output.partial_summaries): + break + num_partial_summaries = len(summarize_output.partial_summaries) + + partial_summaries = summarize_output.partial_summaries + num_generated_tokens = sum( + partial_summary.generated_tokens + for partial_summary in partial_summaries + ) + summary = "\n".join( + partial_summary.summary for partial_summary in partial_summaries + ) + # If the number of chunks is 1 we want to return the new summary since we assume that no further summarization will take place with our prompt + if ( + len(summarize_output.partial_summaries) == 1 + or num_generated_tokens < input.max_tokens + ): + break + text_to_summarize = summary + + return SummarizeOutput( + summary=summary.strip(), generated_tokens=num_generated_tokens + ) diff --git a/src/intelligence_layer/examples/summarize/steerable_long_context_summarize.py b/src/intelligence_layer/examples/summarize/steerable_long_context_summarize.py new file mode 100644 index 000000000..388da1b8e --- /dev/null +++ b/src/intelligence_layer/examples/summarize/steerable_long_context_summarize.py @@ -0,0 +1,76 @@ +from intelligence_layer.core import ( + Chunk, + ChunkInput, + ChunkOutput, + ControlModel, + LuminousControlModel, + Task, + TaskSpan, +) +from intelligence_layer.examples.summarize.steerable_single_chunk_summarize import ( + SteerableSingleChunkSummarize, +) +from intelligence_layer.examples.summarize.summarize import ( + LongContextSummarizeInput, + LongContextSummarizeOutput, + PartialSummary, + SingleChunkSummarizeInput, + SummarizeOutput, +) + + +class SteerableLongContextSummarize( + Task[LongContextSummarizeInput, LongContextSummarizeOutput] +): + """Condenses a long text into a summary. + + Generate a summary given an instruction setup. + + Args: + summarize: The summarize task that is used to summarize a single chunk. + Make sure that this and the chunk task use the same model. + Defaults to :class:`SteerableSingleChunkSummarize` . + chunk: The chunk task that is used to chunk the long text into smaller pieces + such that a single chunk fits into the context of the model. + Make sure that this and the summarize task use the same model. + Defaults to :class:`Chunk` . + model: A valid Aleph Alpha control model. This is passed on to the + default summarize and chunk tasks. So it is ignored when the + defaults for both tasks are overwritten. + Defaults to luminous-base-control. + """ + + def __init__( + self, + summarize: Task[SingleChunkSummarizeInput, SummarizeOutput] | None = None, + chunk: Task[ChunkInput, ChunkOutput] | None = None, + model: ControlModel | None = None, + ) -> None: + super().__init__() + model = model or LuminousControlModel("luminous-base-control") + self._summarize = summarize or SteerableSingleChunkSummarize( + model, max_generated_tokens=512 + ) + self._chunk_task = chunk or Chunk(model, max_tokens_per_chunk=1024) + + def do_run( + self, input: LongContextSummarizeInput, task_span: TaskSpan + ) -> LongContextSummarizeOutput: + chunk_output = self._chunk_task.run(ChunkInput(text=input.text), task_span) + summary_outputs = self._summarize.run_concurrently( + [ + SingleChunkSummarizeInput(chunk=chunk, language=input.language) + for chunk in chunk_output.chunks + ], + task_span, + ) + return LongContextSummarizeOutput( + partial_summaries=[ + PartialSummary( + summary=summary_output.summary, + chunk=chunk, + generated_tokens=summary_output.generated_tokens, + ) + for summary_output, chunk in zip(summary_outputs, chunk_output.chunks) + ] + ) diff --git a/src/intelligence_layer/examples/summarize/steerable_single_chunk_summarize.py b/src/intelligence_layer/examples/summarize/steerable_single_chunk_summarize.py new file mode 100644 index 000000000..35e3b5f54 --- /dev/null +++ b/src/intelligence_layer/examples/summarize/steerable_single_chunk_summarize.py @@ -0,0 +1,60 @@ +from typing import Mapping + +from intelligence_layer.core import ( + CompleteInput, + ControlModel, + Language, + LuminousControlModel, + Task, + TaskSpan, +) +from intelligence_layer.examples.summarize.summarize import ( + SingleChunkSummarizeInput, + SummarizeOutput, +) + +INSTRUCTION_CONFIGS = { + Language("en"): "Summarize the text in a single paragraph.", + Language("de"): "Fasse den Text in einem Paragraphen zusammen.", +} + + +class SteerableSingleChunkSummarize(Task[SingleChunkSummarizeInput, SummarizeOutput]): + """Summarises a text given an instruction. + + Args: + model: A valid Aleph Alpha control model. + maximum_tokens: The maximum number of tokens to be generated by the model. + This is not intended to steer the generation length, but instead will cut off the generation at the specified limit. + Note that maximum tokens + chunk size + prompt length should not exceed the context size of the model. + instruction_configs: A mapping of valid `Language` to `str` for each + supported language. + """ + + def __init__( + self, + model: ControlModel | None = None, + max_generated_tokens: int = 256, + instruction_configs: Mapping[Language, str] = INSTRUCTION_CONFIGS, + ) -> None: + self._model = model or LuminousControlModel("luminous-base-control") + self._max_generated_tokens = max_generated_tokens + self._instruction_configs = instruction_configs + + def do_run( + self, input: SingleChunkSummarizeInput, task_span: TaskSpan + ) -> SummarizeOutput: + instruction = self._instruction_configs.get(input.language) + if not instruction: + raise ValueError(f"Could not find `prompt_config` for {input.language}.") + completion = self._model.complete( + CompleteInput( + prompt=self._model.to_instruct_prompt(instruction, input.chunk), + maximum_tokens=self._max_generated_tokens, + ), + task_span, + ) + return SummarizeOutput( + summary=completion.completion.strip(), + generated_tokens=completion.generated_tokens, + ) diff --git a/src/intelligence_layer/examples/summarize/summarize.py b/src/intelligence_layer/examples/summarize/summarize.py new file mode 100644 index 000000000..e3d3e7a5c --- /dev/null +++ b/src/intelligence_layer/examples/summarize/summarize.py @@ -0,0 +1,193 @@ +from typing import Iterable, Sequence, Union + +from pydantic import BaseModel + +from intelligence_layer.core import Language, TextChunk +from intelligence_layer.evaluation import ( + AggregationLogic, + BleuGrader, + Example, + MeanAccumulator, + RougeGrader, + SingleOutputEvaluationLogic, +) + + +class LongContextSummarizeInput(BaseModel): + """The input for a summarize-task for a text of any length. + + Attributes: + text: A text of any length. + language: The desired language of the summary. ISO 619 str with language e.g. en, fr, etc. + """ + + text: str + language: Language = Language("en") + + +class PartialSummary(BaseModel): + """The summary of a single chunk. + + Attributes: + summary: The summary generated by the task. + chunk: The source chunk. + generated_tokens: The number of tokens generated for the summary + """ + + summary: str + chunk: TextChunk + generated_tokens: int + + +class LongContextSummarizeOutput(BaseModel): + """The output of a summarize-task for a text of any length. + + Attributes: + partial_summaries: Chunk-wise summaries. + """ + + partial_summaries: Sequence[PartialSummary] + + +class SingleChunkSummarizeInput(BaseModel): + """The input for a summarize-task that only deals with a single chunk. + + Attributes: + chunk: The text chunk to be summarized. + language: The desired language of the summary. ISO 619 str with language e.g. en, fr, etc. + """ + + chunk: TextChunk + language: Language = Language("en") + + +class SummarizeOutput(BaseModel): + """The output of a summarize-task. + + Attributes: + summary: The summary generated by the task. + generated_tokens: The number of tokens generated for the summary. + """ + + summary: str + generated_tokens: int + + +class SummarizeEvaluation(BaseModel): + """The evaluation of a summarization run. + + Attributes: + bleu: roughly corresponds to precision + rouge: rougly corresponds to recall + output: The actual output from the task run + """ + + bleu: float + rouge: float + output: Union[SummarizeOutput, LongContextSummarizeOutput] + + +class AggregatedSummarizeEvaluation(BaseModel): + """The aggregated evaluation of a summarization implementation against a dataset. + Attributes: + aggregate_bleu: average over BLEU-scores + aggregate_rouge: average over ROUGE-scores + """ + + aggregate_bleu: float + aggregate_rouge: float + + +class SingleChunkSummarizeAggregationLogic( + AggregationLogic[SummarizeEvaluation, AggregatedSummarizeEvaluation] +): + def aggregate( + self, evaluations: Iterable[SummarizeEvaluation] + ) -> AggregatedSummarizeEvaluation: + return aggregate_summarize_evaluation(evaluations) + + +class SingleChunkSummarizeEvaluationLogic( + SingleOutputEvaluationLogic[ + SingleChunkSummarizeInput, + SummarizeOutput, + str, + SummarizeEvaluation, + ] +): + def __init__(self) -> None: + super().__init__() + self.bleu_grader = BleuGrader() + self.rouge_grader = RougeGrader() + + def do_evaluate_single_output( + self, + example: Example[SingleChunkSummarizeInput, str], + output: SummarizeOutput, + ) -> SummarizeEvaluation: + bleu_score = self.bleu_grader.calculate_bleu( + output.summary, example.expected_output + ) + rouge_score = self.rouge_grader.calculate_rouge( + output.summary, example.expected_output + ) + + return SummarizeEvaluation( + bleu=bleu_score, rouge=rouge_score.recall, output=output + ) + + +class LongContextSummarizeAggregationLogic( + AggregationLogic[SummarizeEvaluation, AggregatedSummarizeEvaluation] +): + def aggregate( + self, evaluations: Iterable[SummarizeEvaluation] + ) -> AggregatedSummarizeEvaluation: + return aggregate_summarize_evaluation(evaluations) + + +class LongContextSummarizeEvaluationLogic( + SingleOutputEvaluationLogic[ + LongContextSummarizeInput, + LongContextSummarizeOutput, + str, + SummarizeEvaluation, + ] +): + def __init__(self) -> None: + super().__init__() + self.bleu_grader = BleuGrader() + self.rouge_grader = RougeGrader() + + def do_evaluate_single_output( + self, + example: Example[LongContextSummarizeInput, str], + output: LongContextSummarizeOutput, + ) -> SummarizeEvaluation: + joint_summary = " ".join( + partial_summary.summary for partial_summary in output.partial_summaries + ) + bleu_score = self.bleu_grader.calculate_bleu( + joint_summary, example.expected_output + ) + rouge_score = self.rouge_grader.calculate_rouge( + joint_summary, example.expected_output + ) + + return SummarizeEvaluation( + bleu=bleu_score, rouge=rouge_score.recall, output=output + ) + + +def aggregate_summarize_evaluation( + evaluations: Iterable[SummarizeEvaluation], +) -> AggregatedSummarizeEvaluation: + acc_bleu = MeanAccumulator() + acc_rouge = MeanAccumulator() + for evaluation in evaluations: + acc_bleu.add(evaluation.bleu) + acc_rouge.add(evaluation.rouge) + return AggregatedSummarizeEvaluation( + aggregate_bleu=acc_bleu.extract(), + aggregate_rouge=acc_rouge.extract(), + ) diff --git a/src/intelligence_layer/py.typed b/src/intelligence_layer/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/style_guide.md b/style_guide.md new file mode 100644 index 000000000..485eccc36 --- /dev/null +++ b/style_guide.md @@ -0,0 +1,232 @@ +# Intelligence Layer Style Guideline + +Welcome to the project's style guide, a foundational document that ensures consistency, clarity, and quality in our collaborative efforts. +As we work together, adhering to the guidelines outlined here will streamline our process, making our code more readable and maintainable for all team members. + +## Building a new task + +To make sure that we approach building new tasks in a unified way, consider this example task: + +``` python +# Standard import pattern: +# - Sort imports alphabetically. +# - Avoid wildcrad imports. +# - Normally consists of three blocks, separated by one newline each. +# 1) Built-in libraries +import math +from typing import Sequence + +# 2) Third-party libraries +from intelligence_layer.connectors.limited_concurrency_client import LimitedConcurrencyClient +from pydantic import BaseModel +import requests # type: ignore +# Use 'type: ignore' for libraries that cause mypy issues (if there's no other fix). + +# 3) Local application libraries +from intelligence_layer.examples.nested_task import NestedTask +from intelligence_layer.core.task import Task +from intelligence_layer.core.tracer import Tracer + + +# Two newlines in between separate classes or between classes and imports. +class ExampleTaskInput(BaseModel): + """Some documentation should go here. For information on this, see below.""" + # Each task can receive its required input in one of three ways: + # 1) In the input of the task: + # - The only parameters here should be the really "dynamic" ones, i.e. the ones that change from run to run. + # -> As a rule of thumb: For QA, a query will change each run, a model will not. + some_query: str + some_number: int + + +class ExampleTaskOutput(BaseModel): + """Some documentation should go here as well.""" + some_result: str + + +class ExampleTask(Task[ExampleTaskInput, ExampleTaskOutput]): + """Even more fun documentation.""" + # 2) As constants: + # - Makes it clear that these exact parameters are required for the `Task` or at least are central to it. + # - Sends a clear signal that these parameters should not be touched. + CONSTANT_PROMPT = "This prompt defines this task and its sole reason for existing." + CONSTANT_MODEL = "unique_feature_llm" # only works with this model and no other + + # 3) In the `__init__`: + # - Used for non-dynamic parameters, that stay the same for each task but may differ for task instances. + # - Used for parameters that are initialized some time before and handed down/reused, such as the AA client. + def __init__( + init_model: str, + init_client: AlephAlphaClientProtocol + ) -> None: + super().__init__() + # In general: most attributes should be private, unless there is a specific reason for them being public. + self._init_model = init_model # Used if multiple models can be used. + self._init_client = init_client # Client should only be instantiated once, therefore the proper place is here. + self._nested_task = NestedTask(init_client) # Try instantiating all tasks in the `__init__`, rather than in `run` or elsewhere. + + # For now, we assume that run will be the only explicitly public method for each `Task`. + # `run` should be the first method after dunder methods. + def run(self, input: ExampleTaskInput, tracer: Tracer) -> ExampleOutput: + return self._some_calculation(input.some_number) + + # Example for a private method. + # All such methods follow after `run`. + def _some_calculation(some_number: int) -> float: + return math.exp(some_number) +``` + +## Documentation + +Generally, adhere to this [guideline](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). + +## Documentation Guide: Jupyter Notebooks vs. Docstrings + +When documenting our codebase, we focus on two primary channels: Jupyter notebooks and docstrings. +The objective is to provide both a high-level understanding and detailed implementation specifics. +Here's how we differentiate and allocate content between them: + +### Jupyter Notebooks + +**Purpose**: Jupyter notebooks are used to provide a comprehensive overview and walkthrough of the tasks. They are ideal for understanding the purpose, usage, and evaluation of a task. + +- **High-level Overview**: + - **Problem Definition**: Describe the specific problem or challenge this task addresses. + - **Comparison**: (Optional) Highlight how this task stands out or differs from other tasks in our codebase. +- **Detailed Walkthrough**: + - **Input/Output Specifications**: Clearly define the expected input format and the resulting output. + Mention any constraints or specific requirements. + - **Debugging Insights**: Explain what information is available in the trace and how it can aid in troubleshooting. + - **Use-case Examples**: What are concrete use-cases I can solve with this task? + Run through examples. + - **Evaluation Metrics**: (Optional) Suggest methods or metrics to evaluate the performance or accuracy of this task. + +### Docstrings + +**Purpose**: Docstrings give a quickstart overview. They provide the necessary information for a user to be able to use this class/function in a correct manner. Not more, not less. + +- **Summary**: + - **One-Liner**: What does this class/function do? + - **Brief Description**: What actually happens when I run this? What are need-to-know specifics? +- **Implementation Specifics**: + - **Parameters & Their Significance**: List all parameters the class/function accepts. + For each parameter, describe its role and why it's necessary. + - **Requirements & Limitations**: What does this parameter require? + Are there any limitations, such as text length? + Is there anything else a user must know to use this? + - **Usage Guidelines**: (Optional) Provide notes, insights or warnings about how to correctly use this class/function. + Mention any nuances, potential pitfalls, or best practices. + +--- + +By maintaining clear distinctions between the two documentation streams, we ensure that both users and developers have the necessary tools and information at their disposal for efficient task execution and code modification. + +## Jupyter notebooks + +Notebooks shall be used in a tutorial-like manner to educate users about certain tasks, functionalities & more. + +### When do we start a new notebook? + +Documenting our LLM-based Tasks using Jupyter notebooks is crucial for clarity and ease of use. +However, we must strike a balance between consolidation and over-segmentation. +Here are the guidelines to determine when to start a new notebook: + +- **Unified Purpose**: If a group of tasks shares a common objective or serves a similar function, they should be documented together in a single notebook. +This avoids redundancy and provides users with a centralized resource. +For instance, if there are multiple tasks all related to similar clasifications, they may be grouped. +- **Complexity & Length**: If detailing a task or a group of tasks would result in an exceedingly long or complex notebook, it's advisable to split them. +Each notebook should be digestible and focused, ensuring that users don't get overwhelmed. +- **Distinct Usage Scenarios**: If tasks have distinctly different use cases or are applied in separate stages of a project, they should have individual notebooks. +This ensures that users can quickly find and reference the specific task they need without sifting through unrelated content. +- **Interdependence**: Tasks that are interdependent or are typically used in tandem should be documented together. +This offers users a streamlined guide on how to use them in sequence or conjunction. +For example: `SingleChunkQA` & `TextHighlight`. +- **Feedback & Updates**: If a particular task receives frequent updates or modifications, it might be beneficial to keep it separate. +This ensures that changes to one task don't clutter or complicate the documentation of others. + +In summary, while our goal is to keep our documentation organized and avoid excessive fragmentation, we must also ensure that each notebook is comprehensive and user-friendly. +When in doubt, consider the user's perspective: Would they benefit from a consolidated guide, or would they find it easier to navigate separate, focused notebooks? + +## Docstrings + +### Task documentation + +Document any `Task` like so: +``` python +class MyTask: + """Start with a one-line description of the task, like this. + + Follow up with a more detailed description, outlining the purpose & general functioning of the task. + + Note: + What is important? Does your task require a certain type of model? Any usage recommendations? + + Args: + example_arg: Any parameter provided in the '__init__' of this task. + + Attributes: + EXAMPLE_CONSTANT: Any constant that may be defined within the class. + example_non_private_attribute: Any attribute defined within the '__init__' that is not private. + + Example: + >>> var = "Describe here how to use this task end to end" + >>> print("End on one newline.") + End on one newline. + """ +``` + +Do not document the `run`` function of a class. Avoid documenting any other (private) functions. + +### Input and output documentation + +Document the inputs and outputs for a specific task like so: + +``` python +class MyInput(BaseModel): + """This is the input for this (suite of) task(s). + + Attributes: + horse: Everybody knows what a horse is. + chunk: We know what a chunk is, but does a user? + crazy_deep_llm_example_param: Yeah, this probably deserves some explanation. + """ + +# Any output should be documented in a similar manner +``` + +### Defaults + +Certain parameters in each task are recurring. Where possible, we shall try to use certain standard documentation. + +``` python +""" +client: Aleph Alpha client instance for running model related API calls. +model: A valid Aleph Alpha model name. +""" +``` + +### Module documentation + +We **do not document the module**, as we assume imports like: + +``` python +from intelligence_layer.complete import Complete +completion_task = Complete() +``` + +rather than: + +``` python +from intelligence_layer import complete +completion_task = complete.Complete() +``` + +This ensures that the documentation is easily accessible by hovering over the imported task. + +## When to use a tracer + +Each task's input and output are automatically logged. +For most task, we assume that this suffices. + +Exceptions would be complicated, task-specific implementations. +An example would the classify logprob calculation. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..9016f142e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,157 @@ +from os import getenv +from pathlib import Path +from typing import Sequence, cast + +from aleph_alpha_client import Client, Image +from dotenv import load_dotenv +from faker import Faker +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.connectors import ( + AlephAlphaClientProtocol, + Document, + DocumentChunk, + DocumentIndexClient, + DocumentIndexRetriever, + LimitedConcurrencyClient, + QdrantInMemoryRetriever, + RetrieverType, +) +from intelligence_layer.core import LuminousControlModel, NoOpTracer, Task, TaskSpan +from intelligence_layer.evaluation import ( + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, +) + + +@fixture(scope="session") +def token() -> str: + load_dotenv() + token = getenv("AA_TOKEN") + assert isinstance(token, str) + return token + + +@fixture(scope="session") +def client(token: str) -> AlephAlphaClientProtocol: + """Provide fixture for api. + + Args: + token: AA Token + """ + return LimitedConcurrencyClient(Client(token), max_concurrency=10) + + +@fixture(scope="session") +def luminous_control_model(client: AlephAlphaClientProtocol) -> LuminousControlModel: + return LuminousControlModel("luminous-base-control", client) + + +@fixture +def no_op_tracer() -> NoOpTracer: + return NoOpTracer() + + +@fixture(scope="session") +def prompt_image() -> Image: + image_source_path = Path(__file__).parent / "dog-and-cat-cover.jpg" + return cast(Image, Image.from_file(image_source_path)) # from_file lacks type-hint + + +@fixture +def asymmetric_in_memory_retriever( + client: AlephAlphaClientProtocol, + in_memory_retriever_documents: Sequence[Document], +) -> QdrantInMemoryRetriever: + return QdrantInMemoryRetriever( + in_memory_retriever_documents, + client=client, + k=2, + retriever_type=RetrieverType.ASYMMETRIC, + ) + + +@fixture +def symmetric_in_memory_retriever( + client: AlephAlphaClientProtocol, + in_memory_retriever_documents: Sequence[Document], +) -> QdrantInMemoryRetriever: + return QdrantInMemoryRetriever( + in_memory_retriever_documents, + client=client, + k=2, + retriever_type=RetrieverType.SYMMETRIC, + ) + + +@fixture +def document_index(token: str) -> DocumentIndexClient: + return DocumentIndexClient(token) + + +@fixture +def document_index_retriever( + document_index: DocumentIndexClient, +) -> DocumentIndexRetriever: + return DocumentIndexRetriever( + document_index, + index_name="asymmetric", + namespace="aleph-alpha", + collection="wikipedia-de", + k=2, + ) + + +def to_document(document_chunk: DocumentChunk) -> Document: + return Document(text=document_chunk.text, metadata=document_chunk.metadata) + + +class DummyStringInput(BaseModel): + input: str + + @classmethod + def any(cls) -> "DummyStringInput": + fake = Faker() + return cls(input=fake.text()) + + +class DummyStringOutput(BaseModel): + output: str + + @classmethod + def any(cls) -> "DummyStringOutput": + fake = Faker() + return cls(output=fake.text()) + + +class DummyStringTask(Task[DummyStringInput, DummyStringOutput]): + def do_run(self, input: DummyStringInput, task_span: TaskSpan) -> DummyStringOutput: + return DummyStringOutput.any() + + +@fixture +def dummy_string_task() -> DummyStringTask: + return DummyStringTask() + + +@fixture +def in_memory_dataset_repository() -> InMemoryDatasetRepository: + return InMemoryDatasetRepository() + + +@fixture +def in_memory_run_repository() -> InMemoryRunRepository: + return InMemoryRunRepository() + + +@fixture +def in_memory_evaluation_repository() -> InMemoryEvaluationRepository: + return InMemoryEvaluationRepository() + + +@fixture +def in_memory_aggregation_repository() -> InMemoryAggregationRepository: + return InMemoryAggregationRepository() diff --git a/tests/connectors/argilla/test_argilla_client.py b/tests/connectors/argilla/test_argilla_client.py new file mode 100644 index 000000000..383f8ed70 --- /dev/null +++ b/tests/connectors/argilla/test_argilla_client.py @@ -0,0 +1,298 @@ +from time import sleep +from typing import Callable, Iterable, Sequence, TypeVar +from uuid import uuid4 + +import pytest +from dotenv import load_dotenv +from pydantic import BaseModel +from pytest import fixture +from requests import HTTPError + +from intelligence_layer.connectors.argilla.argilla_client import ( + ArgillaClient, + ArgillaEvaluation, + DefaultArgillaClient, + Field, + Question, + RecordData, +) + + +class DummyInput(BaseModel): + query: str + + +class DummyOutput(BaseModel): + answer: str + + +ExpectedOutput = str + + +ReturnValue = TypeVar("ReturnValue") + + +def retry( + f: Callable[[], ReturnValue], until: Callable[[ReturnValue], bool] +) -> ReturnValue: + for i in range(10): + r = f() + if until(r): + return r + sleep(0.1) + assert False, f"Condition not met after {i} retries" + + +@fixture +def argilla_client() -> DefaultArgillaClient: + load_dotenv() + return DefaultArgillaClient(total_retries=8) + + +@fixture +def workspace_id(argilla_client: DefaultArgillaClient) -> Iterable[str]: + try: + workspace_id = argilla_client.ensure_workspace_exists(str(uuid4())) + yield workspace_id + finally: + argilla_client.delete_workspace(workspace_id) + + +@fixture +def qa_dataset_id(argilla_client: DefaultArgillaClient, workspace_id: str) -> str: + dataset_name = "test-dataset" + fields = [ + Field(name="question", title="Question"), + Field(name="answer", title="Answer"), + ] + questions = [ + Question( + name="rate-answer", + title="Rate the answer", + description="1 means bad, 3 means amazing.", + options=list(range(1, 4)), + ) + ] + return argilla_client.ensure_dataset_exists( + workspace_id, dataset_name, fields, questions + ) + + +@fixture +def qa_records( + argilla_client: ArgillaClient, qa_dataset_id: str +) -> Sequence[RecordData]: + records = [ + RecordData( + content={"question": "What is 1+1?", "answer": str(i)}, + example_id=str(i), + metadata={"model_1": "luminous-base"}, + ) + for i in range(60) + ] + for record in records: + argilla_client.add_record(qa_dataset_id, record) + return records + + +@fixture +def long_qa_records( + argilla_client: ArgillaClient, qa_dataset_id: str +) -> Sequence[RecordData]: + records = [ + RecordData( + content={"question": "?", "answer": str(i)}, + example_id=str(i), + metadata={"model_1": "luminous-base"}, + ) + for i in range(1024) + ] + for record in records: + argilla_client.add_record(qa_dataset_id, record) + return records + + +@pytest.mark.docker +def test_error_on_non_existent_dataset( + argilla_client: DefaultArgillaClient, +) -> None: + with pytest.raises(HTTPError): + list(argilla_client.records("non_existent_dataset_id")) + + +@pytest.mark.docker +def test_records_returns_records_previously_added( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, + qa_records: Sequence[RecordData], +) -> None: + actual_records = argilla_client.records(qa_dataset_id) + + assert sorted(qa_records, key=lambda r: r.example_id) == sorted( + [RecordData(**record.model_dump()) for record in actual_records], + key=lambda r: r.example_id, + ) + + +@pytest.mark.docker +def test_evaluations_returns_evaluation_results( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, + qa_records: Sequence[RecordData], +) -> None: + evaluations = [ + ArgillaEvaluation( + example_id=record.example_id, + record_id=record.id, + responses={"rate-answer": 1}, + metadata=record.metadata, + ) + for record in argilla_client.records(qa_dataset_id) + ] + for evaluation in evaluations: + argilla_client.create_evaluation(evaluation) + + actual_evaluations = retry( + lambda: list(argilla_client.evaluations(qa_dataset_id)), + lambda current: len(current) == len(evaluations), + ) + + assert sorted(actual_evaluations, key=lambda e: e.record_id) == sorted( + evaluations, key=lambda e: e.record_id + ) + + +@pytest.mark.docker +def test_split_dataset_works( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, + qa_records: Sequence[RecordData], +) -> None: + n_splits = 5 + record_metadata = [ + record.metadata for record in argilla_client.records(qa_dataset_id) + ] + argilla_client.split_dataset(qa_dataset_id, n_splits) + + all_records = list(argilla_client.records(qa_dataset_id)) + for split in range(n_splits): + assert ( + sum([record.metadata["split"] == str(split) for record in all_records]) + == 12 + ) + + new_metadata_list = [record.metadata for record in all_records] + for old_metadata, new_metadata in zip(record_metadata, new_metadata_list): + del new_metadata["split"] # type: ignore + assert old_metadata == new_metadata + + +@pytest.mark.docker +def test_split_dataset_twice_works( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, + qa_records: Sequence[RecordData], +) -> None: + n_splits = 5 + argilla_client.split_dataset(qa_dataset_id, n_splits) + + n_splits = 1 + argilla_client.split_dataset(qa_dataset_id, n_splits) + + all_records = list(argilla_client.records(qa_dataset_id)) + + assert sum([record.metadata["split"] == "0" for record in all_records]) == 60 + + response = argilla_client.session.get( + f"http://localhost:6900/api/v1/me/datasets/{qa_dataset_id}/metadata-properties" + ).json() + metadata_properties = response["items"][0] + assert len(metadata_properties["settings"]["values"]) == 1 + + +@pytest.mark.docker +def test_split_dataset_works_with_uneven_splits( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, + qa_records: Sequence[RecordData], +) -> None: + n_splits = 7 + argilla_client.split_dataset(qa_dataset_id, n_splits) + + all_records = list(argilla_client.records(qa_dataset_id)) + n_records_per_split = [] + for split in range(n_splits): + n_records_per_split.append( + sum([record.metadata["split"] == str(split) for record in all_records]) + ) + assert n_records_per_split == [9, 9, 9, 9, 8, 8, 8] + + +@pytest.mark.docker +def test_add_record_adds_multiple_records_with_same_content( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, +) -> None: + first_data = RecordData( + content={"question": "What is 1+1?", "answer": "1"}, + example_id="0", + metadata={"first": "1", "second": "2"}, + ) + second_data = RecordData( + content={"question": "What is 1+1?", "answer": "2"}, + example_id="0", + metadata={"first": "2", "second": "1"}, + ) + + argilla_client.add_record(qa_dataset_id, first_data) + argilla_client.add_record(qa_dataset_id, second_data) + assert len(list(argilla_client.records(qa_dataset_id))) == 2 + + +@pytest.mark.docker +def test_add_record_does_not_put_example_id_into_metadata( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, +) -> None: + first_data = RecordData( + content={"question": "What is 1+1?", "answer": "1"}, + example_id="0", + metadata={"first": "1", "second": "2"}, + ) + second_data = RecordData( + content={"question": "What is 1+1?", "answer": "2"}, + example_id="0", + metadata={"first": "2", "second": "1"}, + ) + + argilla_client.add_record(qa_dataset_id, first_data) + argilla_client.add_record(qa_dataset_id, second_data) + records = list(argilla_client.records(qa_dataset_id)) + for record in records: + assert "example_id" not in record.metadata.keys() + assert record.example_id == "0" + + +@pytest.mark.docker +def test_split_dataset_can_split_long_dataset( + argilla_client: DefaultArgillaClient, + qa_dataset_id: str, + long_qa_records: Sequence[RecordData], +) -> None: + n_splits = 2 + record_metadata = [ + record.metadata for record in argilla_client.records(qa_dataset_id) + ] + argilla_client.split_dataset(qa_dataset_id, n_splits) + + all_records = list(argilla_client.records(qa_dataset_id)) + for split in range(n_splits): + assert ( + sum([record.metadata["split"] == str(split) for record in all_records]) + == 512 + ) + + new_metadata_list = [record.metadata for record in all_records] + for old_metadata, new_metadata in zip(record_metadata, new_metadata_list): + del new_metadata["split"] # type: ignore + assert old_metadata == new_metadata diff --git a/tests/connectors/document_index/test_document_index.py b/tests/connectors/document_index/test_document_index.py new file mode 100644 index 000000000..47c65b347 --- /dev/null +++ b/tests/connectors/document_index/test_document_index.py @@ -0,0 +1,209 @@ +from http import HTTPStatus + +import pytest +from pytest import fixture, raises + +from intelligence_layer.connectors.document_index.document_index import ( + CollectionPath, + DocumentContents, + DocumentFilterQueryParams, + DocumentIndexClient, + DocumentPath, + IndexPath, + ResourceNotFound, + SearchQuery, +) + + +@fixture +def aleph_alpha_namespace() -> str: + return "aleph-alpha" + + +@fixture +def collection_path(aleph_alpha_namespace: str) -> CollectionPath: + return CollectionPath(namespace=aleph_alpha_namespace, collection="ci-collection") + + +@fixture +def document_path( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> DocumentPath: + document_index.create_collection(collection_path) + return DocumentPath( + collection_path=collection_path, document_name="Example Document" + ) + + +@fixture +def document_contents() -> DocumentContents: + text = """John Stith Pemberton, the inventor of the world-renowned beverage Coca-Cola, was a figure whose life was marked by creativity, entrepreneurial spirit, and the turbulent backdrop of 19th-century America. Born on January 8, 1831, in Knoxville, Georgia, Pemberton grew up in an era of profound transformation and change. + +Pemberton began his professional journey by studying medicine and pharmacy. After earning a degree in pharmacy, he started his career as a druggist in Columbus, Georgia. He was known for his keen interest in creating medicinal concoctions and was well-respected in his community. His early creations included various medicines and tonics, which were typical of the times when pharmacists often concocted their own remedies. + +Pemberton's life took a significant turn during the American Civil War. He served as a lieutenant colonel in the Confederate Army, and it was during this period that he sustained a wound that led him to become dependent on morphine. This personal struggle with addiction likely influenced his later work in seeking out alternatives and remedies for pain relief. + +In the post-war years, Pemberton relocated to Atlanta, Georgia, where he continued to experiment with various medicinal syrups and tonics. It was during this time, in the late 19th century, that he developed a beverage he initially called "Pemberton's French Wine Coca." This concoction was inspired by Vin Mariani, a popular French tonic wine that contained coca leaves. Pemberton's beverage was intended to serve not just as a refreshing drink but also as a remedy for various ailments, including morphine addiction, indigestion, and headaches. + +However, in 1886, when Atlanta introduced prohibition legislation, Pemberton was compelled to create a non-alcoholic version of his beverage. He experimented with a combination of carbonated water, coca leaf extract, kola nut, and other ingredients, eventually perfecting the formula for what would soon become Coca-Cola. The name was suggested by his bookkeeper, Frank Robinson, who also created the distinctive cursive logo that is still in use today. + +Pemberton advertised his new creation as a "brain tonic" and "temperance drink," asserting that it could alleviate headaches and fatigue. However, due to his declining health and financial difficulties, Pemberton was eventually compelled to sell portions of his business to various partners. Shortly before his death in 1888, he sold his remaining stake in Coca-Cola to Asa G. Candler, a fellow pharmacist and businessman. + +Under Candler's leadership, Coca-Cola transformed from a pharmacist's concoction into a mass-produced and marketed beverage that became a staple of American culture and a global icon. Despite the changes and the immense growth of the brand, the legacy of John Stith Pemberton as the inventor of Coca-Cola remains an integral part of the beverage's history. + +Pemberton's life story is a testament to the spirit of innovation and resilience. His creation, borne out of personal struggles and the context of his times, went on to transcend its origins and become a symbol recognized across the globe. Today, when we think of Coca-Cola, we are reminded of Pemberton's journey from a small-town pharmacist to the creator of one of the world's most enduring and beloved brands.""" + return DocumentContents(contents=[text], metadata={"Some": "Metadata"}) + + +@pytest.mark.internal +def test_document_index_sets_authorization_header_for_given_token() -> None: + token = "some-token" + + document_index = DocumentIndexClient(token) + + assert document_index.headers["Authorization"] == f"Bearer {token}" + + +@pytest.mark.internal +def test_document_index_sets_no_authorization_header_when_token_is_none() -> None: + document_index = DocumentIndexClient(None) + + assert "Authorization" not in document_index.headers + + +@pytest.mark.internal +def test_document_index_lists_namespaces(document_index: DocumentIndexClient) -> None: + namespaces = document_index.list_namespaces() + + assert "aleph-alpha" in namespaces + + +@pytest.mark.internal +def test_document_index_creates_collection( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + document_index.create_collection(collection_path) + collections = document_index.list_collections(collection_path.namespace) + + assert collection_path in collections + + +@pytest.mark.internal +def test_document_index_adds_document( + document_index: DocumentIndexClient, + document_path: DocumentPath, + document_contents: DocumentContents, +) -> None: + document_index.add_document(document_path, document_contents) + assert document_contents == document_index.document(document_path) + + +@pytest.mark.internal +def test_document_index_searches_asymmetrically( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + document_path = DocumentPath( + collection_path=collection_path, + document_name="test_document_index_searches_asymmetrically", # is always there + ) + search_query = SearchQuery(query="Who likes pizza?", max_results=1, min_score=0.0) + search_result = document_index.search( + document_path.collection_path, "asymmetric", search_query + ) + + assert "Mark" in search_result[0].section + + +@pytest.mark.internal +def test_document_index_deletes_document( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + document_path = DocumentPath( + collection_path=collection_path, document_name="Document to be deleted" + ) + document_contents = DocumentContents.from_text("Some text...") + + document_index.add_document(document_path, document_contents) + document_index.delete_document(document_path) + document_paths = document_index.documents(document_path.collection_path) + + assert not any(d.document_path == document_path for d in document_paths) + + +def test_document_index_raises_on_getting_non_existing_document( + document_index: DocumentIndexClient, +) -> None: + non_existing_document = DocumentPath( + collection_path=CollectionPath(namespace="does", collection="not"), + document_name="exist", + ) + with raises(ResourceNotFound) as exception_info: + document_index.document(non_existing_document) + assert exception_info.value.status_code == HTTPStatus.NOT_FOUND + assert ( + non_existing_document.collection_path.namespace in exception_info.value.message + ) + + +def test_document_path_from_string() -> None: + abc = DocumentPath.from_slash_separated_str("a/b/c") + assert abc == DocumentPath( + collection_path=CollectionPath(namespace="a", collection="b"), document_name="c" + ) + with raises(AssertionError): + DocumentPath.from_slash_separated_str("a/c") + + +def test_document_list_all_documents( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + filter_result = document_index.documents(collection_path) + + assert len(filter_result) == 2 + + +def test_document_list_max_n_documents( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + filter_query_params = DocumentFilterQueryParams(max_documents=1, starts_with=None) + + filter_result = document_index.documents(collection_path, filter_query_params) + + assert len(filter_result) == 1 + + +def test_document_list_documents_with_matching_prefix( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + prefix = "Example" + filter_query_params = DocumentFilterQueryParams( + max_documents=None, starts_with=prefix + ) + + filter_result = document_index.documents(collection_path, filter_query_params) + + assert len(filter_result) == 1 + assert filter_result[0].document_path.document_name.startswith(prefix) + + +def test_document_path_is_immutable() -> None: + path = DocumentPath( + collection_path=CollectionPath(namespace="1", collection="2"), document_name="3" + ) + dictionary = {} + dictionary[path] = 1 + + assert dictionary[path] == 1 + + +def test_document_indexes_are_returned( + document_index: DocumentIndexClient, collection_path: CollectionPath +) -> None: + index_names = document_index.list_assigned_index_names(collection_path) + index_name = index_names[0] + index_configuration = document_index.index_configuration( + IndexPath(namespace=collection_path.namespace, index=index_name) + ) + + assert index_configuration.embedding_type == "asymmetric" + assert index_configuration.chunk_size == 512 diff --git a/tests/connectors/retrievers/test_document_index_retriever.py b/tests/connectors/retrievers/test_document_index_retriever.py new file mode 100644 index 000000000..c9f6ca10c --- /dev/null +++ b/tests/connectors/retrievers/test_document_index_retriever.py @@ -0,0 +1,23 @@ +import pytest + +from intelligence_layer.connectors.retrievers.document_index_retriever import ( + DocumentIndexRetriever, +) + +QUERY = "Who likes pizza?" +TEXTS = [ + "Gegenwart \nDurch italienische Auswanderer verbreitete sich die Pizza gegen Ende des 19. Jahrhunderts auch in den USA. Im Oktober 1937 wurde in Frankfurt am Main erstmals eine Pizza auf dem damaligen Festhallengelände im Rahmen der 7. Internationalen Kochkunst-Ausstellung bei der Messe Frankfurt zubereitet. Nach dem Zweiten Weltkrieg wurde Pizza auch in Europa außerhalb Italiens bekannter. Die erste Pizzeria in Deutschland wurde von Nicolino di Camillo (1921–2015) im März 1952 in Würzburg unter dem Namen Sabbie di Capri eröffnet. Von hier aus begann der Siegeszug der Pizza in Deutschland. Die erste Pizzeria in Wien wurde 1975 von Pasquale Tavella eröffnet. Neben Spaghetti ist die Pizza heute das bekannteste italienische Nationalgericht, sie wird weltweit angeboten.\n\nZubereitung \nZur Zubereitung wird zuerst ein einfacher Hefeteig aus Mehl, Wasser, wenig Hefe, Salz und eventuell etwas Olivenöl hergestellt, gründlich durchgeknetet und nach einer Gehzeit von mindestens einer Stunde bei Zimmertemperatur (bzw. über Nacht im oberen Fach des Kühlschranks) ausgerollt oder mit den bemehlten Händen dünn ausgezogen. Geübte Pizzabäcker ziehen den Teig über den Handrücken und weiten ihn durch Kreisenlassen in der Luft.\n\nDann wird der Teig mit den Zutaten je nach Rezept nicht zu üppig belegt, meist mit passierten Dosentomaten oder Salsa pizzaiola (einer vorher gekochten, sämigen Tomatensauce, die mit Oregano, Basilikum, Knoblauch und anderem kräftig gewürzt ist). Es folgen der Käse (z. B. Mozzarella, Parmesan oder Pecorino) und die übrigen Zutaten, zum Abschluss etwas Olivenöl.\n\nSchließlich wird die Pizza bei einer möglichst hohen Temperatur von 400 bis 500 °C für wenige Minuten kurz gebacken. Dies geschieht in einer möglichst niedrigen Kammer. Ein Stapeln in Einschüben oder separat schaltbare Unter- und Oberhitze ist daher nicht üblich. Der traditionelle Kuppelofen ist gemauert und die Hitze wird über ein Feuer direkt im Backraum erzeugt. Moderne Pizzaöfen werden mit Gas oder Strom beheizt.", + "Verbreitet in Italien ist auch die Pizza bianca (weiße Pizza), jegliche Pizza-Variation, die ohne Tomatensoße zubereitet wird.\n\nEine Calzone (italienisch für „Hose“) ist eine Pizza, bei welcher der Teigfladen vor dem Backen über dem Belag zusammengeklappt wird. Die traditionelle Füllung besteht aus Ricotta, rohem Schinken, Pilzen, Mozzarella, Parmesan und Oregano. Ursprünglich wurde die Calzone nicht im Ofen, sondern in einer Pfanne in Schmalz oder Öl gebacken, wie es als Pizza fritta in Neapel üblich ist.\n\nIn ganz Italien verbreitet ist die Pizza al taglio („Pizza am Stück“), die auf einem rechteckigen Blech gebacken und in kleineren rechteckigen Stücken verkauft wird. Angeboten wird sie häufig nicht nur in Pizzerien, sondern auch beim Bäcker.\n\nEine neuartige Abwandlung der Pizza ist die Pinsa, die rechteckig und aus einem lockeren Teig gebacken wird.\n\nUS-amerikanische Pizza \nIn den USA sind zwei Typen weit verbreitet, „Chicago-style“ und „New York-style“ Pizza. Während die New Yorker Variante mit ihrem sehr dünnen Boden der italienischen Variante ähnelt, steht die Variante aus Chicago Kopf: Der Teig bildet eine Schüsselform, wird mit Mozzarellascheiben ausgelegt und mit weiteren Zutaten gefüllt. Zum Schluss wird das ganze von oben mit zerkleinerten Tomaten bestrichen und mit Parmesan und Oregano bestreut.\n\nAuch die Pizza Hawaii mit Kochschinken und Ananas ist wahrscheinlich nordamerikanischen Ursprungs.\n\nIn Deutschland ist eine weitere Variante als „American Pizza“ populär, die sich vor allem durch einen dicken, luftigen Boden auszeichnet und u. a. durch die Restaurantkette Pizza Hut bekannt ist.\n\nKoschere Pizza", +] + + +@pytest.mark.internal +def test_document_index_retriever( + document_index_retriever: DocumentIndexRetriever, +) -> None: + documents = document_index_retriever.get_relevant_documents_with_scores(QUERY) + assert documents[0].document_chunk.text[0:30] in TEXTS[0] + assert documents[1].document_chunk.text[0:30] in TEXTS[1] + document_path = documents[0].id + assert document_path.collection_path == document_index_retriever._collection_path + assert document_path.document_name == "Pizza" diff --git a/tests/connectors/retrievers/test_qdrant_in_memory_retriever.py b/tests/connectors/retrievers/test_qdrant_in_memory_retriever.py new file mode 100644 index 000000000..f1bb2297b --- /dev/null +++ b/tests/connectors/retrievers/test_qdrant_in_memory_retriever.py @@ -0,0 +1,38 @@ +from typing import Sequence + +from pytest import fixture + +from intelligence_layer.connectors.retrievers.base_retriever import Document +from intelligence_layer.connectors.retrievers.qdrant_in_memory_retriever import ( + QdrantInMemoryRetriever, +) +from tests.conftest import to_document + + +@fixture +def in_memory_retriever_documents() -> Sequence[Document]: + return [ + Document(text="I do not like rain"), + Document(text="Summer is warm"), + Document(text="We are so back"), + ] + + +def test_asymmetric_in_memory_retriever( + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, + in_memory_retriever_documents: Sequence[Document], +) -> None: + query = "Do you like summer?" + documents = asymmetric_in_memory_retriever.get_relevant_documents_with_scores(query) + assert in_memory_retriever_documents[1] == to_document(documents[0].document_chunk) + assert len(documents) <= 2 + + +def test_symmetric_in_memory_retriever( + symmetric_in_memory_retriever: QdrantInMemoryRetriever, + in_memory_retriever_documents: Sequence[Document], +) -> None: + query = "I hate drizzle" + documents = symmetric_in_memory_retriever.get_relevant_documents_with_scores(query) + assert in_memory_retriever_documents[0] == to_document(documents[0].document_chunk) + assert len(documents) <= 2 diff --git a/tests/connectors/test_limited_concurrency_client.py b/tests/connectors/test_limited_concurrency_client.py new file mode 100644 index 000000000..2cdf1d131 --- /dev/null +++ b/tests/connectors/test_limited_concurrency_client.py @@ -0,0 +1,119 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Lock +from time import sleep +from typing import cast + +import pytest +from aleph_alpha_client import BusyError, CompletionRequest, CompletionResponse, Prompt +from pytest import fixture + +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, + LimitedConcurrencyClient, +) + + +class ConcurrencyCountingClient: + max_concurrency_counter: int = 0 + concurrency_counter: int = 0 + + def __init__(self) -> None: + self.lock = Lock() + + def complete(self, request: CompletionRequest, model: str) -> CompletionResponse: + with self.lock: + self.concurrency_counter += 1 + self.max_concurrency_counter = max( + self.max_concurrency_counter, self.concurrency_counter + ) + sleep(0.01) + with self.lock: + self.concurrency_counter -= 1 + return CompletionResponse( + model_version="model-version", + completions=[], + optimized_prompt=None, + num_tokens_generated=0, + num_tokens_prompt_total=0, + ) + + +class BusyClient: + def __init__( + self, + return_value: CompletionResponse | Exception, + ) -> None: + self.number_of_retries: int = 0 + self.return_value = return_value + + def complete(self, request: CompletionRequest, model: str) -> CompletionResponse: + self.number_of_retries += 1 + if self.number_of_retries < 2: + raise BusyError(503) # type: ignore + else: + if isinstance(self.return_value, Exception): + raise self.return_value + else: + return self.return_value + + +TEST_MAX_CONCURRENCY = 3 + + +@fixture +def concurrency_counting_client() -> ConcurrencyCountingClient: + return ConcurrencyCountingClient() + + +@fixture +def limited_concurrency_client( + concurrency_counting_client: ConcurrencyCountingClient, +) -> LimitedConcurrencyClient: + return LimitedConcurrencyClient( + cast(AlephAlphaClientProtocol, concurrency_counting_client), + TEST_MAX_CONCURRENCY, + ) + + +def test_methods_concurrency_is_limited( + limited_concurrency_client: LimitedConcurrencyClient, + concurrency_counting_client: ConcurrencyCountingClient, +) -> None: + with ThreadPoolExecutor(max_workers=TEST_MAX_CONCURRENCY * 10) as executor: + executor.map( + limited_concurrency_client.complete, + [CompletionRequest(prompt=Prompt(""))] * TEST_MAX_CONCURRENCY * 10, + ["model"] * TEST_MAX_CONCURRENCY * 10, + ) + assert concurrency_counting_client.max_concurrency_counter == TEST_MAX_CONCURRENCY + + +def test_limited_concurrency_client_retries() -> None: + expected_completion = CompletionResponse( + model_version="model-version", + completions=[], + optimized_prompt=None, + num_tokens_generated=0, + num_tokens_prompt_total=0, + ) + busy_client = BusyClient(return_value=expected_completion) + limited_concurrency_client = LimitedConcurrencyClient( + cast(AlephAlphaClientProtocol, busy_client) + ) + completion = limited_concurrency_client.complete( + CompletionRequest(prompt=Prompt("")), "model" + ) + assert completion == expected_completion + + +def test_limited_concurrency_client_throws_exception() -> None: + expected_exception = Exception(404) + busy_client = BusyClient(return_value=expected_exception) + limited_concurrency_client = LimitedConcurrencyClient( + cast(AlephAlphaClientProtocol, busy_client) + ) + with pytest.raises(Exception) as exception_info: + limited_concurrency_client.complete( + CompletionRequest(prompt=Prompt("")), "model" + ) + assert exception_info.value == expected_exception diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/core/test_chunk.py b/tests/core/test_chunk.py new file mode 100644 index 000000000..637eeb609 --- /dev/null +++ b/tests/core/test_chunk.py @@ -0,0 +1,60 @@ +from pytest import fixture + +from intelligence_layer.core import ( + ChunkInput, + ChunkWithIndices, + LuminousControlModel, + NoOpTracer, +) + + +@fixture +def chunk_input() -> ChunkInput: + return ChunkInput( + text="""In the rolling verdant hills of a realm untouched by the passage of modern times, a kingdom thrived under the rule of a benevolent monarch. The king, known for his wisdom and justice, held the loyalty of his people and the respect of his peers. However, beneath the surface of peace, a shadow loomed that would test the mettle of the kingdom's most valiant defenders: the knights. + +These knights, clad in gleaming armor and bearing the colors of their liege, were not mere soldiers but champions of the realm's ideals. They were sworn to protect the innocent, uphold justice, and maintain the peace, guided by a chivalric code that was as much a part of them as the swords they wielded. Among these noble warriors, Sir Aelwyn stood prominent, known across the land for his prowess in battle and his unyielding honor. + +Sir Aelwyn, the youngest knight ever to be granted the title of Master of the Horse, was a figure of legend. His tales were told in every corner of the kingdom, often embellished with each retelling. From his duel with the Giant of Gormouth to his silent vigil in the Haunted Wood, Aelwyn's life was a tapestry of bravery and adventure. Yet, his greatest challenge lay ahead, whispered in fearful murmurs throughout the castle—the rise of the Dragon of Black Hollow. + +The dragon had awoken from a centuries-long slumber, driven by hunger and wrath, laying waste to the villages on the kingdom's fringes. Smoke and despair rose from the once tranquil borders, drawing the attention of the king and his council. With the threat growing each day, the king summoned Sir Aelwyn and tasked him with a quest that could either save the kingdom or doom it forever—to defeat the dragon. + +As Sir Aelwyn prepared for his journey, the castle buzzed with activity. Blacksmiths forged new armor and weapons, alchemists concocted potent draughts, and scholars poured over ancient texts seeking any knowledge that might aid him. The knight spent his nights in the chapel, praying for strength and wisdom, and his days in the training yard, honing his skills against opponents both real and imagined. + +Accompanying Sir Aelwyn were his loyal companions: Sir Rowan, a strategist known for his cunning and intellect; Lady Elara, a knight whose skill with the bow was unmatched; and Dame Miriel, a warrior-poet whose songs could stir the soul as fiercely as her sword could cleave armor. Together, they represented the kingdom's finest, united under a single cause. + +Their journey was fraught with peril. They crossed through the Whispering Forest, where shadows moved with minds of their own, and over the Mountains of Echoes, where the wind carried voices from the past. Each step brought them closer to their quarry, and the signs of the dragon's passage grew ever more ominous—the charred earth, the ruins of once happy homes, and the air heavy with the scent of sulfur. + +As they approached Black Hollow, the landscape grew bleak, and the sky darkened. The dragon, coiled atop a pile of gold and bones, awaited them, its scales shimmering like molten rock. The air crackled with the heat of its breath, and its eyes, glowing like coals, fixed on Sir Aelwyn and his companions. + +The battle was fierce. Sir Rowan directed their movements with precision, while Lady Elara loosed arrows that found chinks in the dragon's armor. Dame Miriel's voice rose above the clamor, her words bolstering their courage and blinding the beast with bursts of radiant light. Sir Aelwyn faced the dragon head-on, his shield absorbing the flames that poured from its maw, his sword striking with the weight of his oath behind each blow. + +Hours seemed like days as the clash continued, the outcome uncertain. Finally, seeing an opening, Sir Aelwyn drove his sword deep into the dragon's heart. With a final roar that shook the heavens, the dragon fell, its reign of terror ended. + +The return to the kingdom was triumphant. The people lined the streets, showering the knights with flowers and cheers. The king welcomed them back as heroes, their deeds to be recorded in the annals of history for generations to come. Sir Aelwyn and his companions had not only saved the kingdom but had also reaffirmed the values it stood for: courage, honor, and a steadfast commitment to the protection of the realm. + +As the celebrations faded, Sir Aelwyn looked out over the kingdom from the castle's highest tower. The peace they had fought for lay stretched before him, a tapestry of green fields and bustling towns. Yet, he knew that this peace was not permanent but a precious moment to be cherished and protected. For as long as there were threats to the realm, there would be knights to face them, their swords ready and their hearts brave. + +In this timeless land, the cycle of challenge and triumph continued, each generation of knights rising to meet the dangers of their times with the same valor and resolve as those who had come before them. And so, the legends grew, each knight adding their thread to the ever-unfolding story of the kingdom and its defenders.""" + ) + + +def test_chunk_with_indices( + luminous_control_model: LuminousControlModel, + chunk_input: ChunkInput, + no_op_tracer: NoOpTracer, +) -> None: + chunk_with_indices = ChunkWithIndices( + luminous_control_model, max_tokens_per_chunk=128 + ) + + output = chunk_with_indices.do_run(chunk_input, no_op_tracer) + + assert all( + c.start_index < output.chunks_with_indices[idx + 1].start_index + for idx, c in enumerate(output.chunks_with_indices[:-1]) + ) + assert all( + c.end_index == output.chunks_with_indices[idx + 1].start_index + for idx, c in enumerate(output.chunks_with_indices[:-1]) + ) diff --git a/tests/core/test_detect_language.py b/tests/core/test_detect_language.py new file mode 100644 index 000000000..7c46b94d2 --- /dev/null +++ b/tests/core/test_detect_language.py @@ -0,0 +1,60 @@ +import pytest + +from intelligence_layer.core import ( + DetectLanguage, + DetectLanguageInput, + Language, + NoOpTracer, +) + + +@pytest.mark.parametrize( + "text_input,expected_language", + [ + ( + "Hello, my name is Niklas. I am working with Pit on this language detection piece.", + Language("en"), + ), + ( + "Hola, mi nombre es Niklas. Estoy trabajando con Pit en esta pieza de detección de idioma.", + Language("es"), + ), + ( + "Ciao, mi chiamo Niklas. Sto lavorando con Pit su questo pezzo di rilevamento della lingua.", + Language("it"), + ), + ( + "Hallo, mein Name ist Niklas. Ich arbeite mit Pit an diesem Stück zur Spracherkennung.", + Language("de"), + ), + ( + "Bonjour, je m'appelle Niklas. Je travaille avec Pit sur cette pièce de détection de langue.", + Language("fr"), + ), + ], +) +def test_detect_language_returns_correct_language( + text_input: str, expected_language: Language +) -> None: + task = DetectLanguage() + input = DetectLanguageInput( + text=text_input, + possible_languages=[Language(lang) for lang in ["en", "de", "fr", "it", "es"]], + ) + tracer = NoOpTracer() + output = task.run(input, tracer) + + assert output.best_fit == expected_language + + +def test_detect_language_returns_non_if_no_language_can_be_detected() -> None: + text = "Je m’appelle Jessica. Je suis une fille, je suis française et j’ai treize ans." # codespell:ignore + task = DetectLanguage() + input = DetectLanguageInput( + text=text, + possible_languages=[Language(lang) for lang in ["en", "de"]], + ) + tracer = NoOpTracer() + output = task.run(input, tracer) + + assert output.best_fit is None diff --git a/tests/core/test_echo.py b/tests/core/test_echo.py new file mode 100644 index 000000000..74ec008cb --- /dev/null +++ b/tests/core/test_echo.py @@ -0,0 +1,153 @@ +from typing import Literal, Sequence + +import tokenizers # type: ignore +from aleph_alpha_client import CompletionResponse, Prompt, Text, Tokens +from aleph_alpha_client.completion import CompletionResult +from pytest import fixture + +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, +) +from intelligence_layer.core import MAX_CONCURRENCY, NoOpTracer, Task, TaskSpan, Token +from intelligence_layer.core.echo import Echo, EchoInput, TokenWithLogProb +from intelligence_layer.core.model import ( + AlephAlphaModel, + CompleteInput, + CompleteOutput, + LuminousControlModel, +) + + +@fixture +def echo_task(luminous_control_model: LuminousControlModel) -> Echo: + return Echo(luminous_control_model) + + +@fixture +def echo_input() -> EchoInput: + return EchoInput( + prompt=Prompt.from_text("The weather is"), + expected_completion="good", + ) + + +class ExpectTextAndTokenItemPromptCompletion(Task[CompleteInput, CompleteOutput]): + def __init__(self, tokenizer: tokenizers.Tokenizer) -> None: + self._tokenizer = tokenizer + + def do_run(self, input: CompleteInput, task_span: TaskSpan) -> CompleteOutput: + input_prompt_items = input.prompt.items + assert len(input_prompt_items) == 2 + assert isinstance(input_prompt_items[0], Text) and isinstance( + input_prompt_items[1], Tokens + ) + log_probs = [ + {self._tokenizer.decode([token_id]): 0.5} + for token_id in input_prompt_items[1].tokens + ] + return CompleteOutput.from_completion_response( + CompletionResponse( + "version", + completions=[CompletionResult(log_probs=log_probs)], + num_tokens_generated=0, + num_tokens_prompt_total=0, + ) + ) + + +class FakeCompleteTaskModel(LuminousControlModel): + def __init__( + self, + name: Literal[ + "luminous-base-control", + "luminous-extended-control", + "luminous-supreme-control", + "luminous-base-control", + "luminous-extended-control", + "luminous-supreme-control", + ], + client: AlephAlphaClientProtocol, + ) -> None: + self.name = name + self._client = client + self._complete = ExpectTextAndTokenItemPromptCompletion(self.get_tokenizer()) + + +def tokenize_completion( + expected_output: str, aleph_alpha_model: AlephAlphaModel +) -> Sequence[Token]: + tokenizer = aleph_alpha_model.get_tokenizer() + assert tokenizer.pre_tokenizer + tokenizer.pre_tokenizer.add_prefix_space = False + encoding: tokenizers.Encoding = tokenizer.encode(expected_output) + return [ + Token( + token=tokenizer.decode([token_id], skip_special_tokens=False), + token_id=token_id, + ) + for token_id in encoding.ids + ] + + +def test_can_run_echo_task(echo_task: Echo, echo_input: EchoInput) -> None: + result = echo_task.run(echo_input, tracer=NoOpTracer()) + + tokens = tokenize_completion(echo_input.expected_completion, echo_task._model) + assert len(tokens) == len(result.tokens_with_log_probs) + assert all([isinstance(t, TokenWithLogProb) for t in result.tokens_with_log_probs]) + for token, result_token in zip(tokens, result.tokens_with_log_probs): + assert token == result_token.token + + +def test_echo_works_with_whitespaces_in_expected_completion( + echo_task: Echo, +) -> None: + expected_completion = " good." + input = EchoInput( + prompt=Prompt.from_text("The weather is"), + expected_completion=expected_completion, + ) + tokens = tokenize_completion(expected_completion, echo_task._model) + + result = echo_task.run(input, tracer=NoOpTracer()) + + assert len(tokens) == len(result.tokens_with_log_probs) + assert all([isinstance(t, TokenWithLogProb) for t in result.tokens_with_log_probs]) + for token, result_token in zip(tokens, result.tokens_with_log_probs): + assert token == result_token.token + + +def test_overlapping_tokens_generate_correct_tokens(echo_task: Echo) -> None: + """This test checks if the echo task correctly tokenizes the expected completion separately + The two tokens when tokenized together will result in a combination of the end of the first token + and the start of the second token. This is not the expected behaviour. + + Args: + echo_task: Fixture used for this test + """ + token1 = "ĠGastronomie" + token2 = "Baby" + + prompt = Prompt.from_text(token1[0 : len(token1) // 2]) + expected_completion = token1[-len(token1) // 2 :] + token2 + input = EchoInput( + prompt=prompt, + expected_completion=expected_completion, + ) + + tokens = tokenize_completion(expected_completion, echo_task._model) + result = echo_task.run(input, tracer=NoOpTracer()) + + assert len(tokens) == len(result.tokens_with_log_probs) + + assert all([isinstance(t, TokenWithLogProb) for t in result.tokens_with_log_probs]) + for token, result_token in zip(tokens, result.tokens_with_log_probs): + assert token == result_token.token + + +def test_run_concurrently_produces_proper_completion_prompts( + client: AlephAlphaClientProtocol, echo_input: EchoInput +) -> None: + echo_task = Echo(FakeCompleteTaskModel("luminous-base-control", client)) + # if this test fails in CI you may need to increase the 50 to 1000 to reproduce this locally + echo_task.run_concurrently([echo_input] * MAX_CONCURRENCY * 50, NoOpTracer()) diff --git a/tests/core/test_model.py b/tests/core/test_model.py new file mode 100644 index 000000000..a668a06c3 --- /dev/null +++ b/tests/core/test_model.py @@ -0,0 +1,82 @@ +from aleph_alpha_client import Prompt, PromptGranularity, Text +from pytest import fixture + +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, +) +from intelligence_layer.core import ( + AlephAlphaModel, + CompleteInput, + ControlModel, + Llama3InstructModel, + LuminousControlModel, + NoOpTracer, +) +from intelligence_layer.core.model import ExplainInput + + +@fixture +def model(client: AlephAlphaClientProtocol) -> ControlModel: + return LuminousControlModel(client=client, name="luminous-base-control") + + +@fixture +def base_model(client: AlephAlphaClientProtocol) -> AlephAlphaModel: + return AlephAlphaModel(client=client, name="luminous-base") + + +def test_model_without_input(model: ControlModel, no_op_tracer: NoOpTracer) -> None: + prompt = model.to_instruct_prompt("What is the capital of Germany?") + input = CompleteInput(prompt=prompt) + assert isinstance(input.model_dump(), dict) + output = model.complete(input, no_op_tracer) + assert isinstance(output.model_dump(), dict) + + assert "Berlin" in output.completion + prompt_text_item = prompt.items[0] + assert isinstance(prompt_text_item, Text) + assert "Input" not in prompt_text_item.text + + +def test_aa_model(base_model: AlephAlphaModel, no_op_tracer: NoOpTracer) -> None: + prompt = Prompt.from_text("The capital of Germany is") + input = CompleteInput(prompt=prompt) + + output = base_model.complete(input, no_op_tracer) + assert "Berlin" in output.completion + + +def test_explain(model: ControlModel, no_op_tracer: NoOpTracer) -> None: + prompt = Prompt.from_text("What is the capital of Germany?") + target = "Berlin." + explain_input = ExplainInput( + prompt=prompt, target=target, prompt_granularity=PromptGranularity.Word + ) + output = model.explain(explain_input, no_op_tracer) + assert output.explanations[0].items[0].scores[5].score > 1 + + +def test_llama_2_model_works(no_op_tracer: NoOpTracer) -> None: + llama_2_model = Llama3InstructModel() + + prompt = llama_2_model.to_instruct_prompt( + "Who likes pizza?", + "Marc and Jessica had pizza together. However, Marc hated it. He only agreed to the date because Jessica likes pizza so much.", + ) + + explain_input = CompleteInput(prompt=prompt) + output = llama_2_model.complete(explain_input, no_op_tracer) + assert "Jessica" in output.completion + + +def test_llama_3_model_works(no_op_tracer: NoOpTracer) -> None: + llama_3_model = Llama3InstructModel() + + prompt = llama_3_model.to_instruct_prompt( + "Who likes pizza?", + "Marc and Jessica had pizza together. However, Marc hated it. He only agreed to the date because Jessica likes pizza so much.", + ) + + explain_input = CompleteInput(prompt=prompt) + output = llama_3_model.complete(explain_input, no_op_tracer) + assert "Jessica" in output.completion diff --git a/tests/core/test_prompt_template.py b/tests/core/test_prompt_template.py new file mode 100644 index 000000000..7afaf8614 --- /dev/null +++ b/tests/core/test_prompt_template.py @@ -0,0 +1,312 @@ +from pathlib import Path +from textwrap import dedent +from typing import List + +from aleph_alpha_client.prompt import Image, PromptItem, Text, Tokens +from liquid.exceptions import LiquidSyntaxError, LiquidTypeError +from pytest import raises + +from intelligence_layer.core.prompt_template import ( + PromptItemCursor, + PromptRange, + PromptTemplate, + RichPrompt, + TextCursor, +) + + +def rich_prompt_from_text(text: str) -> RichPrompt: + return RichPrompt(items=[Text.from_text(text)]) + + +def test_to_prompt_with_text_array() -> None: + template = PromptTemplate( + """ +{%- for name in names -%} +Hello {{name}}! +{% endfor -%} + """ + ) + names = ["World", "Rutger"] + + prompt = template.to_rich_prompt(names=names) + + expected = "".join([f"Hello {name}!\n" for name in names]) + assert prompt == rich_prompt_from_text(expected) + + +def test_to_prompt_with_invalid_input() -> None: + template = PromptTemplate( + """ +{%- for name in names -%} +Hello {{name}}! +{% endfor -%} + """ + ) + + with raises(LiquidTypeError): + template.to_rich_prompt(names=7) + + +def test_to_prompt_with_single_image(prompt_image: Image) -> None: + template = PromptTemplate( + """Some Text. +{{whatever}} +More Text +""" + ) + + prompt = template.to_rich_prompt(whatever=template.placeholder(prompt_image)) + + expected = RichPrompt( + [ + Text.from_text("Some Text.\n"), + prompt_image, + Text.from_text("\nMore Text\n"), + ], + ranges={}, + ) + assert prompt == expected + + +def test_to_prompt_with_image_sequence(prompt_image: Image) -> None: + template = PromptTemplate( + """ +{%- for image in images -%} +{{image}} +{%- endfor -%} + """ + ) + + prompt = template.to_rich_prompt( + images=[template.placeholder(prompt_image), template.placeholder(prompt_image)] + ) + + expected = RichPrompt([prompt_image, prompt_image]) + assert prompt == expected + + +def test_to_prompt_with_mixed_modality_variables(prompt_image: Image) -> None: + template = PromptTemplate("""{{image}}{{name}}{{image}}""") + + prompt = template.to_rich_prompt( + image=template.placeholder(prompt_image), name="whatever" + ) + + expected = RichPrompt([prompt_image, Text.from_text("whatever"), prompt_image]) + assert prompt == expected + + +def test_to_prompt_with_unused_image(prompt_image: Image) -> None: + template = PromptTemplate("cool") + + prompt = template.to_rich_prompt(images=template.placeholder(prompt_image)) + + assert prompt == rich_prompt_from_text("cool") + assert prompt == RichPrompt(items=[Text("cool", controls=[])]) + + +def test_to_prompt_with_multiple_different_images(prompt_image: Image) -> None: + image_source_path = Path(__file__).parent.parent / "image_example.jpg" + second_image = Image.from_file(image_source_path) + + template = PromptTemplate("""{{image_1}}{{image_2}}""") + + prompt = template.to_rich_prompt( + image_1=template.placeholder(prompt_image), + image_2=template.placeholder(second_image), + ) + + assert prompt == RichPrompt([prompt_image, second_image]) + + +def test_to_prompt_with_embedded_prompt(prompt_image: Image) -> None: + user_prompt = RichPrompt([Text.from_text("Cool"), prompt_image]) + + template = PromptTemplate("""{{user_prompt}}""") + + prompt = template.to_rich_prompt(user_prompt=template.embed_prompt(user_prompt)) + + assert prompt == user_prompt + + +def test_to_prompt_does_not_add_whitespace_after_image(prompt_image: Image) -> None: + user_prompt = RichPrompt([prompt_image, Text.from_text("Cool"), prompt_image]) + + template = PromptTemplate("{{user_prompt}}") + + prompt = template.to_rich_prompt(user_prompt=template.embed_prompt(user_prompt)) + + assert prompt == user_prompt + + +def test_to_prompt_skips_empty_strings() -> None: + user_prompt = RichPrompt( + [Text.from_text("Cool"), Text.from_text(""), Text.from_text("Also cool")] + ) + + template = PromptTemplate("{{user_prompt}}") + + prompt = template.to_rich_prompt(user_prompt=template.embed_prompt(user_prompt)) + + assert prompt == RichPrompt([Text.from_text("Cool Also cool")]) + + +def test_to_prompt_adds_whitespaces() -> None: + user_prompt = RichPrompt( + [Text.from_text("start "), Text.from_text("middle"), Text.from_text(" end")] + ) + + template = PromptTemplate("{{user_prompt}}") + + prompt = template.to_rich_prompt(user_prompt=template.embed_prompt(user_prompt)) + + assert prompt == RichPrompt([Text.from_text("start middle end")]) + + +def test_to_prompt_works_with_tokens() -> None: + user_prompt = RichPrompt( + [ + Tokens.from_token_ids([1, 2, 3]), + Text.from_text("cool"), + Tokens.from_token_ids([4, 5, 6]), + ] + ) + + template = PromptTemplate("{{user_prompt}}") + + prompt = template.to_rich_prompt(user_prompt=template.embed_prompt(user_prompt)) + + assert prompt == user_prompt + + +def test_to_prompt_with_empty_template() -> None: + assert PromptTemplate("").to_rich_prompt() == RichPrompt([]) + + +def test_to_prompt_resets_template(prompt_image: Image) -> None: + template = PromptTemplate("{{image}}") + placeholder = template.placeholder(prompt_image) + prompt = template.to_rich_prompt(image=placeholder) + + prompt_with_reset_template = template.to_rich_prompt(image=placeholder) + + assert prompt_with_reset_template != prompt + + +def test_to_prompt_data_returns_ranges(prompt_image: Image) -> None: + embedded_text = "Embedded" + prefix_items: List[PromptItem] = [ + Text.from_text("Prefix Text Item"), + prompt_image, + ] + prefix_text = "Prefix text" + prefix_merged = Text.from_text("Merged Prefix Item") + embedded_merged = Text.from_text("Merged Embedded Item") + embedded_items: List[PromptItem] = [prompt_image] + template = PromptTemplate( + "{{prefix_items}}{{prefix_text}}{% promptrange r1 %}{{embedded_text}}{{embedded_items}}{% endpromptrange %}", + ) + + prompt_data = template.to_rich_prompt( + prefix_items=template.embed_prompt(RichPrompt(prefix_items + [prefix_merged])), + prefix_text=prefix_text, + embedded_text=embedded_text, + embedded_items=template.embed_prompt( + RichPrompt([embedded_merged] + embedded_items) + ), + ) + + ranges = prompt_data.ranges.get("r1") + + assert ranges == [ + PromptRange( + start=TextCursor( + item=len(prefix_items), + position=len(prefix_merged.text) + len(prefix_text), + ), + end=PromptItemCursor(item=len(prefix_items) + len(embedded_items)), + ), + ] + + +def test_to_prompt_data_returns_ranges_for_image_only_prompt( + prompt_image: Image, +) -> None: + template = PromptTemplate( + dedent( + """ + {%- promptrange r1 -%} + {%- promptrange r2 -%} + {{image}} + {%- endpromptrange -%} + {%- endpromptrange -%}""" + ).lstrip() + ) + + prompt_data = template.to_rich_prompt(image=template.placeholder(prompt_image)) + r1 = prompt_data.ranges.get("r1") + + assert r1 == [PromptRange(start=PromptItemCursor(0), end=PromptItemCursor(0))] + assert r1 == prompt_data.ranges.get("r2") + + +def test_to_prompt_data_returns_no_range_with_empty_template() -> None: + template = PromptTemplate("{% promptrange r1 %}{% endpromptrange %}") + + assert template.to_rich_prompt().ranges.get("r1") == [] + + +def test_to_prompt_data_returns_no_empty_ranges(prompt_image: Image) -> None: + template = PromptTemplate( + "{{image}}{% promptrange r1 %}{% endpromptrange %} Some Text" + ) + + assert ( + template.to_rich_prompt(image=template.placeholder(prompt_image)).ranges.get( + "r1" + ) + == [] + ) + + +def test_prompt_template_raises_liquid_error_on_illeagal_range() -> None: + with raises(LiquidSyntaxError): + PromptTemplate( + "{% for i in (1..4) %}{% promptrange r1 %}{% endfor %}{% endpromptrange %}" + ) + + +def test_to_prompt_data_returns_multiple_text_ranges_in_for_loop() -> None: + embedded = "Some Content" + template = PromptTemplate( + "{% for i in (1..4) %}{% promptrange r1 %}{{embedded}}{% endpromptrange %}{% endfor %}" + ) + + prompt_data = template.to_rich_prompt(embedded=embedded) + + assert prompt_data.ranges.get("r1") == [ + PromptRange( + start=TextCursor(item=0, position=i * len(embedded)), + end=TextCursor(item=0, position=(i + 1) * len(embedded)), + ) + for i in range(4) + ] + + +def test_to_prompt_data_returns_multiple_imgae_ranges_in_for_loop( + prompt_image: Image, +) -> None: + template = PromptTemplate( + "{% for i in (1..4) %}{% promptrange r1 %}{{embedded}}{% endpromptrange %}{% endfor %}" + ) + + prompt_data = template.to_rich_prompt(embedded=template.placeholder(prompt_image)) + + assert prompt_data.ranges.get("r1") == [ + PromptRange( + start=PromptItemCursor(item=i), + end=PromptItemCursor(item=i), + ) + for i in range(4) + ] diff --git a/tests/core/test_task.py b/tests/core/test_task.py new file mode 100644 index 000000000..bdc2fa4e8 --- /dev/null +++ b/tests/core/test_task.py @@ -0,0 +1,131 @@ +from concurrent.futures import ThreadPoolExecutor +from functools import wraps +from threading import Lock +from time import sleep +from typing import Callable + +from intelligence_layer.core import InMemorySpan, InMemoryTracer, NoOpTracer, TaskSpan +from intelligence_layer.core.task import MAX_CONCURRENCY, Task + + +class ConcurrencyCounter(Task[None, None]): + max_concurrency_counter: int = 0 + concurrency_counter: int = 0 + + def __init__(self) -> None: + self.lock = Lock() + + def do_run(self, input: None, task_span: TaskSpan) -> None: + with self.lock: + self.concurrency_counter += 1 + self.max_concurrency_counter = max( + self.max_concurrency_counter, self.concurrency_counter + ) + + sleep(0.01) + with self.lock: + self.concurrency_counter -= 1 + + +class DeadlockDetector(Task[None, None]): + def __init__(self) -> None: + super().__init__() + self.inner_task = InnerTask() + + def do_run(self, input: None, task_span: TaskSpan) -> None: + # wait a bit so all DeadlockDetector tasks run before the first InnerTask is submitted + sleep(0.01) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + self.inner_task.run_concurrently, [input], task_span + ) + # wait a bit to ensure the future has finished + # (even if the InnerTasks of all DeadlockDetector tasks are scheduled sequentially) + for i in range(20): + if future.done(): + break + sleep(0.1) + if not future.done(): + executor.shutdown(wait=False) + raise RuntimeError("Deadlock detected") + + +class InnerTask(Task[None, None]): + def do_run(self, input: None, task_span: TaskSpan) -> None: + pass + + +def dummy_decorator( + f: Callable[["BaseTask", None, TaskSpan], None], +) -> Callable[["BaseTask", None, TaskSpan], None]: + @wraps(f) + def wrap( + self: "BaseTask", + input: None, + task_span: TaskSpan, + ) -> None: + return f(self, input, task_span) + + return wrap + + +class BaseTask(Task[None, None]): + @dummy_decorator + def do_run(self, input: None, task_span: TaskSpan) -> None: + task_span.log("Plain", "Entry") + + +class SubTask(BaseTask): + pass + + +class NestedTask(Task[None, None]): + def do_run(self, input: None, task_span: TaskSpan) -> None: + BaseTask().run(input, task_span) + + +def test_run_concurrently() -> None: + task = ConcurrencyCounter() + task.run_concurrently([None] * MAX_CONCURRENCY * 10, NoOpTracer()) + assert task.max_concurrency_counter == MAX_CONCURRENCY + + +def test_run_concurrently_limited() -> None: + task = ConcurrencyCounter() + limit_concurrency = MAX_CONCURRENCY // 2 + task.run_concurrently([None] * MAX_CONCURRENCY * 3, NoOpTracer(), limit_concurrency) + assert task.max_concurrency_counter == limit_concurrency + + +def test_run_concurrently_does_not_deadlock_if_nested() -> None: + task = DeadlockDetector() + task.run_concurrently([None] * MAX_CONCURRENCY, NoOpTracer()) + + +def test_sub_tasks_do_not_introduce_multiple_task_spans() -> None: + tracer = InMemoryTracer() + + SubTask().run(None, tracer) + + assert tracer.entries + assert isinstance(tracer.entries[0], TaskSpan) + assert tracer.entries[0].entries + assert not isinstance(tracer.entries[0].entries[0], TaskSpan) + + +def test_ids_are_set_in_concurrent_run() -> None: + tracer = InMemoryTracer() + task = DeadlockDetector() + + task.run_concurrently([None] * MAX_CONCURRENCY, tracer, trace_id="ID") + assert tracer.entries + assert tracer.entries[0].id() == "ID" + + +def test_ids_are_equal_for_multiple_subtasks() -> None: + tracer = InMemoryTracer() + NestedTask().run(None, tracer, "ID") + assert isinstance(tracer.entries[0], InMemorySpan) + assert tracer.entries[0].id() == "ID" + assert isinstance(tracer.entries[0].entries[0], InMemorySpan) + assert tracer.entries[0].entries[0].id() == "ID" diff --git a/tests/core/test_text_highlight.py b/tests/core/test_text_highlight.py new file mode 100644 index 000000000..e3277eb71 --- /dev/null +++ b/tests/core/test_text_highlight.py @@ -0,0 +1,312 @@ +import pytest +from aleph_alpha_client import Image, Text +from pytest import fixture, raises + +from intelligence_layer.core import ( + AlephAlphaModel, + ControlModel, + NoOpTracer, + PromptTemplate, + RichPrompt, + ScoredTextHighlight, + TextHighlight, + TextHighlightInput, +) + + +@fixture +def text_highlight( + luminous_control_model: ControlModel, +) -> TextHighlight: + return TextHighlight(luminous_control_model) + + +@fixture +def text_highlight_with_clamp( + luminous_control_model: ControlModel, +) -> TextHighlight: + return TextHighlight(luminous_control_model, clamp=True) + + +# To test with multimodal input, we also need a base model. +@fixture +def text_highlight_base() -> TextHighlight: + return TextHighlight(AlephAlphaModel("luminous-base")) + + +def map_to_prompt(prompt: RichPrompt, highlight: ScoredTextHighlight) -> str: + # This is only used for this test file + assert isinstance(prompt.items[0], Text) + return prompt.items[0].text[highlight.start : highlight.end] + + +def test_text_highlight(text_highlight: TextHighlight) -> None: + answer = " Extreme conditions." + prompt_template_str = """Question: What is the ecosystem adapted to? +Text: {% promptrange r1 %}Scientists at the European Southern Observatory announced a groundbreaking discovery today: microbial life detected in the water-rich atmosphere of Proxima Centauri b, our closest neighboring exoplanet. +The evidence, drawn from a unique spectral signature of organic compounds, hints at an ecosystem adapted to extreme conditions. +This finding, while not complex extraterrestrial life, significantly raises the prospects of life's commonality in the universe. +The international community is abuzz with plans for more focused research and potential interstellar missions.{% endpromptrange %} +Answer:""" + rich_prompt = PromptTemplate(prompt_template_str).to_rich_prompt() + + input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset({"r1"}), + ) + output = text_highlight.run(input, NoOpTracer()) + + assert output.highlights + scores = [highlight.score for highlight in output.highlights] + assert all(score >= 0 for score in scores) + max_score, max_index = max((v, i) for i, v in enumerate(scores)) + top_highlight = output.highlights[max_index] + assert max_score == 1 + assert "conditions" in map_to_prompt(rich_prompt, top_highlight) + + +def test_text_highlight_indices_start_at_prompt_range( + text_highlight: TextHighlight, +) -> None: + answer = " Berlin" + prompt_template_str = """Question: Name a capital in the text. +Text: {% promptrange r1 %}Berlin{% endpromptrange %} +Answer:""" + rich_prompt = PromptTemplate(prompt_template_str).to_rich_prompt() + + input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset({"r1"}), + ) + output = text_highlight.run(input, NoOpTracer()) + + scores = [highlight.score for highlight in output.highlights] + max_index = max((v, i) for i, v in enumerate(scores))[1] + top_highlight = output.highlights[max_index] + assert "Berlin" == map_to_prompt(rich_prompt, top_highlight) + + +def test_text_highlight_with_range_without_highlight( + text_highlight: TextHighlight, +) -> None: + answer = " Extreme conditions." + prompt_template_str = """Question: What is the ecosystem adapted to? +{% promptrange no_content %}Mozart was a musician born on 27th january of 1756. He lived in Wien, Österreich. {% endpromptrange %} +{% promptrange content %}Scientists at the European Southern Observatory announced a groundbreaking discovery today: microbial life detected in the water-rich atmosphere of Proxima Centauri b, our closest neighboring exoplanet. +The evidence, drawn from a unique spectral signature of organic compounds, hints at an ecosystem adapted to extreme conditions. +This finding, while not complex extraterrestrial life, significantly raises the prospects of life's commonality in the universe. +The international community is abuzz with plans for more focused research and potential interstellar missions.{% endpromptrange %} +Answer:""" + rich_prompt = PromptTemplate(prompt_template_str).to_rich_prompt(answer=answer) + + input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset(["no_content"]), + ) + output = text_highlight.run(input, NoOpTracer()) + target_sentence = "The evidence, drawn from a unique spectral signature of organic compounds, hints at an ecosystem adapted to extreme conditions." + assert not any( + target_sentence in map_to_prompt(rich_prompt, highlight) + for highlight in output.highlights + ) + # highlights should have a low score as they are not relevant to the answer. + assert len(output.highlights) == 0 + + +def test_text_highlight_with_image_prompt( + text_highlight_base: TextHighlight, prompt_image: Image +) -> None: + prompt_template_str = """Question: {% promptrange question %}What is the Latin name of the brown bear?{% endpromptrange %} +Text: {% promptrange text %}The brown bear (Ursus arctos) is a large bear species found across Eurasia and North America. +Here is an image, just for LOLs: {{image}}{range}abc{{image}}{range} +{% endpromptrange %} +Answer:""" + template = PromptTemplate(prompt_template_str) + rich_prompt = template.to_rich_prompt(image=template.placeholder(prompt_image)) + completion = " The latin name of the brown bear is Ursus arctos." + + input = TextHighlightInput(rich_prompt=rich_prompt, target=completion) + with pytest.raises(ValueError): + text_highlight_base.run(input, NoOpTracer()) + + +def test_text_highlight_without_range(text_highlight: TextHighlight) -> None: + prompt_template_str = """Question: What is the Latin name of the brown bear? +Text: The brown bear (Ursus arctos) is a large bear species found across Eurasia and North America. +Answer:""" + template = PromptTemplate(prompt_template_str) + rich_prompt = template.to_rich_prompt() + completion = " Ursus arctos." + + input = TextHighlightInput(rich_prompt=rich_prompt, target=completion) + output = text_highlight.run(input, NoOpTracer()) + + assert output.highlights + assert any( + "bear" in map_to_prompt(rich_prompt, highlight).lower() + for highlight in output.highlights + ) + + +def test_text_highlight_without_focus_range_highlights_entire_prompt( + text_highlight: TextHighlight, prompt_image: Image +) -> None: + prompt_template_str = """Question: What is the ecosystem adapted to? +Text: {% promptrange r1 %}Scientists at the European Southern Observatory announced a groundbreaking discovery today: microbial life detected in the water-rich atmosphere of Proxima Centauri b, our closest neighboring exoplanet. +The evidence, drawn from a unique spectral signature of organic compounds, hints at an ecosystem adapted to extreme conditions. +This finding, while not complex extraterrestrial life, significantly raises the prospects of life's commonality in the universe. +The international community is abuzz with plans for more focused research and potential interstellar missions.{% endpromptrange %} +Answer:""" + template = PromptTemplate(prompt_template_str) + prompt_with_metadata = template.to_rich_prompt( + image=template.placeholder(prompt_image) + ) + answer = " Extreme conditions." + focus_ranges: frozenset[str] = frozenset() # empty + + input = TextHighlightInput( + rich_prompt=prompt_with_metadata, + target=answer, + focus_ranges=focus_ranges, + ) + output = text_highlight.run(input, NoOpTracer()) + + assert output.highlights + assert any( + "conditions" in prompt_template_str[highlight.start : highlight.end].lower() + for highlight in output.highlights + ) + + +def test_text_highlight_with_unknown_range_raises( + text_highlight: TextHighlight, +) -> None: + prompt_template_str = """Question: What is the ecosystem adapted to? +Text: {% promptrange r1 %}Scientists at the European Southern Observatory announced a groundbreaking discovery today: microbial life detected in the water-rich atmosphere of Proxima Centauri b, our closest neighboring exoplanet. +The evidence, drawn from a unique spectral signature of organic compounds, hints at an ecosystem adapted to extreme conditions. +This finding, while not complex extraterrestrial life, significantly raises the prospects of life's commonality in the universe. +The international community is abuzz with plans for more focused research and potential interstellar missions.{% endpromptrange %} +Answer:""" + template = PromptTemplate(prompt_template_str) + rich_prompt = template.to_rich_prompt() + answer = " Extreme conditions." + + unknown_range_name = "bla" + input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset([unknown_range_name]), + ) + with raises(ValueError) as e: + text_highlight.run(input, NoOpTracer()) + + assert unknown_range_name in str(e.value) + + +def test_text_ranges_do_not_overlap_into_question_when_clamping( + text_highlight_with_clamp: TextHighlight, +) -> None: + instruct = """Beantworte die Frage anhand des Textes. Wenn sich die Frage nicht mit dem Text beantworten lässt, antworte "Unbeantwortbar".\nFrage: What is a hot dog\n""" + prompt_template_str = """{% promptrange instruction %}{{instruct}}{% endpromptrange %} +{% promptrange input %}Zubereitung \nEin Hotdog besteht aus einem erwärmten Brühwürstchen in einem länglichen, meist weichen Weizenbrötchen, das üblicherweise getoastet oder gedämpft wird. Das Hotdogbrötchen wird zur Hälfte der Länge nach aufgeschnitten und ggf. erhitzt. Danach legt man das heiße Würstchen hinein und garniert es mit den Saucen (Ketchup, Senf, Mayonnaise usw.). Häufig werden auch noch weitere Zugaben, etwa Röstzwiebeln, Essiggurken, Sauerkraut oder Krautsalat in das Brötchen gegeben.\n\nVarianten \n\n In Dänemark und teilweise auch in Schweden wird der Hotdog mit leuchtend rot eingefärbten Würstchen (Røde Pølser) hergestellt und kogt (gebrüht) oder risted (gebraten) angeboten. Der dänische Hotdog wird meist mit Röstzwiebeln, gehackten Zwiebeln und süßsauer eingelegten Salatgurken-Scheiben und regional mit Rotkohl garniert. Als Saucen werden Ketchup, milder Senf und dänische Remoulade, die Blumenkohl enthält, verwendet. Der bekannteste Imbiss Dänemarks, der Hotdogs verkauft, ist Annies Kiosk. \n\nWeltweit bekannt sind die Hotdog-Stände der schwedischen Möbelhauskette IKEA, an denen im Möbelhaus hinter den Kassen Hot Dogs der schwedischen Variante zum Selberbelegen mit Röstzwiebeln, Gurken und verschiedenen Soßen verkauft werden. Der Hotdogstand in der Filiale gilt weltweit als eine Art Markenzeichen von IKEA. In Deutschland wird das Gericht meist mit Frankfurter oder Wiener Würstchen zubereitet.\n\n In den USA wird der Hotdog meist auf einem Roller Grill gegart. So bekommt die Wurst einen besonderen Grillgeschmack. Amerikanische Hotdogs werden mit speziellen Pickled Gherkins (Gurkenscheiben) und Relishes (Sweet Relish, Hot Pepper Relish oder Corn Relish), häufig mit mildem Senf (Yellow Mustard, die populärste Hotdog-Zutat) oder mit Ketchup serviert. Auch eine Garnitur aus warmem Sauerkraut ist möglich (Nathan’s Famous in New York).\n{% endpromptrange %} +### Response:""" + template = PromptTemplate(prompt_template_str) + rich_prompt = template.to_rich_prompt(instruct=instruct) + answer = "Ein Hotdog ist ein Würstchen, das in einem Brötchen serviert wird." + + input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset(["input"]), + ) + result = text_highlight_with_clamp.run(input, NoOpTracer()) + for highlight in result.highlights: + assert highlight.start >= len(instruct) + assert 0 < highlight.end + + +def test_highlight_does_not_clamp_when_prompt_ranges_overlap( + text_highlight_with_clamp: TextHighlight, +) -> None: + # given + prompt_template_str = """{% promptrange outer %}t{% promptrange inner %}es{% endpromptrange %}t{% endpromptrange %}""" + template = PromptTemplate(prompt_template_str) + rich_prompt = template.to_rich_prompt() + answer = "Test?" + + highlight_input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset(["outer", "inner"]), + ) + # when + result = text_highlight_with_clamp.run(highlight_input, NoOpTracer()) + + # then + assert result.highlights[0].start == 0 + assert result.highlights[0].end == 4 + + +def test_highlight_clamps_to_the_correct_range_at( + text_highlight_with_clamp: TextHighlight, +) -> None: + # given + prompt_template_str = """{% promptrange short %}t{% endpromptrange %}e{% promptrange long %}st{% endpromptrange %}""" + template = PromptTemplate(prompt_template_str) + rich_prompt = template.to_rich_prompt() + answer = "Test?" + + highlight_input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset(["short", "long"]), + ) + # when + result = text_highlight_with_clamp.run(highlight_input, NoOpTracer()) + + # then + assert result.highlights[0].start == 2 + assert result.highlights[0].end == 4 + + +@pytest.mark.parametrize( + "prompt, start, end, score", + [ # scores taken from test + ("""t{% promptrange short %}e{% endpromptrange%}st""", 1, 2, 0.44712114 / 4), + ("""{% promptrange short %}te{% endpromptrange%}st""", 0, 2, 0.44712114 / 2), + ("""te{% promptrange short %}st{% endpromptrange%}""", 2, 4, 0.44712114 / 2), + ( + """te{% promptrange short %}st .....{% endpromptrange%}""", + 2, + 4, + 0.5348406757698838, + ), + ], +) +def test_highlight_clamps_end_correctly( + text_highlight_with_clamp: TextHighlight, + prompt: str, + start: int, + end: int, + score: int, +) -> None: + # given + template = PromptTemplate(prompt) + rich_prompt = template.to_rich_prompt() + answer = "Test?" + + highlight_input = TextHighlightInput( + rich_prompt=rich_prompt, + target=answer, + focus_ranges=frozenset(["short"]), + ) + # when + result = text_highlight_with_clamp.run(highlight_input, NoOpTracer()) + + # then + assert result.highlights[0].start == start + assert result.highlights[0].end == end + assert abs(result.highlights[0].score - score) <= 0.005 diff --git a/tests/core/test_tracer.py b/tests/core/test_tracer.py new file mode 100644 index 000000000..a4f82891c --- /dev/null +++ b/tests/core/test_tracer.py @@ -0,0 +1,392 @@ +import contextlib +import json +import os +import time +from pathlib import Path +from typing import Any, Iterator, Optional +from unittest.mock import Mock + +import pytest +import requests +from aleph_alpha_client import Prompt +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from pytest import fixture + +from intelligence_layer.core import ( + CompleteInput, + CompleteOutput, + CompositeTracer, + FileTracer, + InMemorySpan, + InMemoryTaskSpan, + InMemoryTracer, + LogEntry, + LuminousControlModel, + OpenTelemetryTracer, + Task, + TaskSpan, + utc_now, +) +from intelligence_layer.core.tracer.persistent_tracer import TracerLogEntryFailed +from intelligence_layer.core.tracer.tracer import ErrorValue + + +@fixture +def complete( + luminous_control_model: LuminousControlModel, +) -> Task[CompleteInput, CompleteOutput]: + return luminous_control_model.complete_task() + + +@fixture +def open_telemetry_tracer() -> tuple[str, OpenTelemetryTracer]: + service_name = "test-service" + url = "http://localhost:16686/api/traces?service=" + service_name + resource = Resource.create({SERVICE_NAME: service_name}) + provider = TracerProvider(resource=resource) + trace.set_tracer_provider(provider) + processor = BatchSpanProcessor(OTLPSpanExporter()) + provider.add_span_processor(processor) + openTracer = OpenTelemetryTracer(trace.get_tracer("intelligence-layer")) + return (url, openTracer) + + +def test_composite_tracer_id_consistent_across_children( + file_tracer: FileTracer, +) -> None: + input = "input" + tracer1 = InMemoryTracer() + + TestTask().run(input, CompositeTracer([tracer1])) + assert isinstance(tracer1.entries[0], InMemorySpan) + assert tracer1.entries[0].id() == tracer1.entries[0].entries[0].id() + + +def test_tracer_id_exists_for_all_children_of_task_span() -> None: + tracer = InMemoryTracer() + parent_span = tracer.task_span("child", "input", trace_id="ID") + parent_span.span("child2") + + assert isinstance(tracer.entries[0], InMemorySpan) + assert tracer.entries[0].id() == "ID" + + assert tracer.entries[0].entries[0].id() == tracer.entries[0].id() + + parent_span.task_span("child3", "input") + assert tracer.entries[0].entries[1].id() == tracer.entries[0].id() + + +def test_tracer_id_exists_for_all_children_of_span() -> None: + tracer = InMemoryTracer() + parent_span = tracer.span("child", trace_id="ID") + parent_span.span("child2") + + assert isinstance(tracer.entries[0], InMemorySpan) + assert tracer.entries[0].id() == "ID" + assert tracer.entries[0].entries[0].id() == tracer.entries[0].id() + + parent_span.task_span("child3", "input") + assert tracer.entries[0].entries[1].id() == tracer.entries[0].id() + + +def test_can_add_child_tracer() -> None: + tracer = InMemoryTracer() + tracer.span("child") + + assert len(tracer.entries) == 1 + + log = tracer.entries[0] + assert isinstance(log, InMemoryTracer) + assert log.name == "child" + assert len(log.entries) == 0 + + +def test_can_add_parent_and_child_entries() -> None: + parent = InMemoryTracer() + with parent.span("child") as child: + child.log("Two", 2) + + assert isinstance(parent.entries[0], InMemoryTracer) + assert isinstance(parent.entries[0].entries[0], LogEntry) + + +def test_task_logs_error_value() -> None: + tracer = InMemoryTracer() + + with pytest.raises(ValueError): + with tracer.span("failing task"): + raise ValueError("my bad, sorry") + + assert isinstance(tracer.entries[0], InMemorySpan) + assert isinstance(tracer.entries[0].entries[0], LogEntry) + error = tracer.entries[0].entries[0].value + assert isinstance(error, ErrorValue) + assert error.message == "my bad, sorry" + assert error.error_type == "ValueError" + assert error.stack_trace.startswith("Traceback") + + +def test_task_span_records_error_value() -> None: + tracer = InMemoryTracer() + + with pytest.raises(ValueError): + with tracer.task_span("failing task", None): + raise ValueError("my bad, sorry") + + assert isinstance(tracer.entries[0], InMemoryTaskSpan) + error = tracer.entries[0].output + assert isinstance(error, ErrorValue) + assert error.message == "my bad, sorry" + assert error.error_type == "ValueError" + assert error.stack_trace.startswith("Traceback") + + +def test_task_automatically_logs_input_and_output( + complete: Task[CompleteInput, CompleteOutput], +) -> None: + tracer = InMemoryTracer() + input = CompleteInput(prompt=Prompt.from_text("test")) + output = complete.run(input=input, tracer=tracer) + + assert len(tracer.entries) == 1 + task_span = tracer.entries[0] + assert isinstance(task_span, InMemoryTaskSpan) + assert task_span.name == type(complete).__name__ + assert task_span.input == input + assert task_span.output == output + assert task_span.start_timestamp and task_span.end_timestamp + assert task_span.start_timestamp < task_span.end_timestamp + + +def test_tracer_can_set_custom_start_time_for_log_entry() -> None: + tracer = InMemoryTracer() + timestamp = utc_now() + + with tracer.span("span") as span: + span.log("log", "message", timestamp) + + assert isinstance(tracer.entries[0], InMemorySpan) + assert isinstance(tracer.entries[0].entries[0], LogEntry) + assert tracer.entries[0].entries[0].timestamp == timestamp + + +def test_tracer_can_set_custom_start_time_for_span() -> None: + tracer = InMemoryTracer() + start = utc_now() + + span = tracer.span("span", start) + + assert span.start_timestamp == start + + +def test_span_sets_end_timestamp() -> None: + tracer = InMemoryTracer() + start = utc_now() + + span = tracer.span("span", start) + span.end() + + assert span.end_timestamp and span.start_timestamp <= span.end_timestamp + + +def test_span_only_updates_end_timestamp_once() -> None: + tracer = InMemoryTracer() + + span = tracer.span("span") + end = utc_now() + span.end(end) + span.end() + + assert span.end_timestamp == end + + +def test_composite_tracer(complete: Task[CompleteInput, CompleteOutput]) -> None: + tracer1 = InMemoryTracer() + tracer2 = InMemoryTracer() + input = CompleteInput(prompt=Prompt.from_text("test")) + complete.run(input=input, tracer=CompositeTracer([tracer1, tracer2])) + + assert tracer1 == tracer2 + + +class TestSubTask(Task[None, None]): + def do_run(self, input: None, task_span: TaskSpan) -> None: + task_span.log("subtask", "value") + + +class TestTask(Task[str, str]): + sub_task = TestSubTask() + + def do_run(self, input: str, task_span: TaskSpan) -> str: + with task_span.span("span") as sub_span: + sub_span.log("message", "a value") + self.sub_task.run(None, sub_span) + self.sub_task.run(None, task_span) + + return "output" + + +@fixture +def file_tracer(tmp_path: Path) -> FileTracer: + return FileTracer(tmp_path / "log.log") + + +def test_file_tracer(file_tracer: FileTracer) -> None: + input = "input" + expected = InMemoryTracer() + + TestTask().run(input, CompositeTracer([expected, file_tracer])) + + log_tree = file_tracer.trace() + assert log_tree == expected + + +def test_file_tracer_retrieves_correct_trace(file_tracer: FileTracer) -> None: + input = "input" + expected = InMemoryTracer() + compositeTracer = CompositeTracer([expected, file_tracer]) + TestTask().run(input, compositeTracer, "ID1") + TestTask().run(input, file_tracer, "ID2") + log_tree = file_tracer.trace("ID1") + assert log_tree == expected + + +def test_file_tracer_handles_tracer_log_entry_failed_exception( + file_tracer: FileTracer, +) -> None: + file_tracer._log_entry = Mock( # type: ignore[method-assign] + side_effect=[TracerLogEntryFailed("Hi I am an error", "21"), None] + ) + + try: + file_tracer.task_span( + task_name="mock_task_name", input="42", timestamp=None, trace_id="21" + ) + except Exception as exception: + assert False, f"'Unexpected exception: {exception}" + + +def test_file_tracer_raises_non_log_entry_failed_exceptions( + file_tracer: FileTracer, +) -> None: + file_tracer._log_entry = Mock(side_effect=[Exception("Hi I am an error", "21")]) # type: ignore[method-assign] + with pytest.raises(Exception): + file_tracer.task_span( + task_name="mock_task_name", input="42", timestamp=None, trace_id="21" + ) + + +# take from and modified: https://stackoverflow.com/questions/2059482/temporarily-modify-the-current-processs-environment +@contextlib.contextmanager +def set_env(name: str, value: str | None) -> Iterator[None]: + old_environ = dict(os.environ) + if value is None: + if os.getenv(name, None) is not None: + os.environ.pop(name) + else: + os.environ[name] = value + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + +def test_in_memory_tracer_trace_viewer_doesnt_crash_if_it_cant_reach() -> None: + # note that this test sets the environment variable, which might + # become a problem with multi-worker tests + ENV_VARIABLE_NAME = "TRACE_VIEWER_URL" + # ensure that the code works even with the variable is not set + with set_env(ENV_VARIABLE_NAME, None): + expected = InMemoryTracer() + expected._ipython_display_() + + +@pytest.mark.docker +def test_open_telemetry_tracer_check_consistency_in_trace_ids( + open_telemetry_tracer: tuple[str, OpenTelemetryTracer], +) -> None: + tracing_service, tracer = open_telemetry_tracer + expected_trace_id = tracer.ensure_id(None) + TestTask().run("test-input", tracer, trace_id=expected_trace_id) + trace = _get_trace_by_id(tracing_service, expected_trace_id) + + assert trace is not None + assert _get_trace_id_from_trace(trace) == expected_trace_id + spans = trace["spans"] + assert len(spans) == 4 + for span in spans: + assert _get_trace_id_from_span(span) == expected_trace_id + + +@pytest.mark.docker +def test_open_telemetry_tracer_loggs_input_and_output( + open_telemetry_tracer: tuple[str, OpenTelemetryTracer], + complete: Task[CompleteInput, CompleteOutput], +) -> None: + tracing_service, tracer = open_telemetry_tracer + input = CompleteInput(prompt=Prompt.from_text("test")) + trace_id = tracer.ensure_id(None) + complete.run(input, tracer, trace_id) + trace = _get_trace_by_id(tracing_service, trace_id) + + assert trace is not None + + spans = trace["spans"] + assert spans is not [] + + task_span = next((span for span in spans if span["references"] == []), None) + assert task_span is not None + + tags = task_span["tags"] + open_tel_input_tag = [tag for tag in tags if tag["key"] == "input"] + assert len(open_tel_input_tag) == 1 + + open_tel_output_tag = [tag for tag in tags if tag["key"] == "output"] + assert len(open_tel_output_tag) == 1 + + +def _get_trace_by_id(tracing_service: str, wanted_trace_id: str) -> Optional[Any]: + request_timeout_in_seconds = 10 + traces = _get_current_traces(tracing_service) + if traces: + for current_trace in traces: + trace_id = _get_trace_id_from_trace(current_trace) + if trace_id == wanted_trace_id: + return trace + + request_start = time.time() + while time.time() - request_start < request_timeout_in_seconds: + traces = _get_current_traces(tracing_service) + if traces: + for current_trace in traces: + trace_id = _get_trace_id_from_trace(current_trace) + if trace_id == wanted_trace_id: + return current_trace + time.sleep(0.1) + return None + + +def _get_current_traces(tracing_service: str) -> Any: + response = requests.get(tracing_service) + response_text = json.loads(response.text) + return response_text["data"] + + +def _get_trace_id_from_trace(trace: Any) -> Optional[str]: + spans = trace["spans"] + if not spans: + return None + return _get_trace_id_from_span(spans[0]) + + +def _get_trace_id_from_span(span: Any) -> Optional[str]: + tags = span["tags"] + if not tags: + return None + trace_id_tag = next(tag for tag in tags if tag["key"] == "trace_id") + return str(trace_id_tag["value"]) diff --git a/tests/dog-and-cat-cover.jpg b/tests/dog-and-cat-cover.jpg new file mode 100644 index 000000000..0ba55163a Binary files /dev/null and b/tests/dog-and-cat-cover.jpg differ diff --git a/tests/evaluation/__init__.py b/tests/evaluation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/evaluation/conftest.py b/tests/evaluation/conftest.py new file mode 100644 index 000000000..8167d7c91 --- /dev/null +++ b/tests/evaluation/conftest.py @@ -0,0 +1,265 @@ +from datetime import datetime +from os import getenv +from pathlib import Path +from typing import Iterable, Sequence +from uuid import uuid4 + +from dotenv import load_dotenv +from fsspec.implementations.memory import MemoryFileSystem # type: ignore +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.connectors import ( + ArgillaClient, + ArgillaEvaluation, + Field, + Question, + RecordData, +) +from intelligence_layer.core import Task, Tracer, utc_now +from intelligence_layer.evaluation import ( + AggregationOverview, + DatasetRepository, + EvaluationOverview, + Example, + ExampleEvaluation, + FileAggregationRepository, + FileEvaluationRepository, + FileRunRepository, + InMemoryDatasetRepository, + InMemoryRunRepository, + InstructComparisonArgillaAggregationLogic, + Runner, + RunOverview, +) +from tests.conftest import DummyStringInput, DummyStringOutput + +FAIL_IN_EVAL_INPUT = "fail in eval" +FAIL_IN_TASK_INPUT = "fail in task" + + +class DummyTask(Task[str, str]): + def do_run(self, input: str, tracer: Tracer) -> str: + if input == FAIL_IN_TASK_INPUT: + raise RuntimeError(input) + return input + + +class DummyStringEvaluation(BaseModel): + same: bool + + +class DummyEvaluation(BaseModel): + result: str + + +class DummyAggregatedEvaluation(BaseModel): + score: float + + +class DummyAggregatedEvaluationWithResultList(BaseModel): + results: Sequence[DummyEvaluation] + + +@fixture +def sequence_examples() -> Iterable[Example[str, None]]: + return [ + Example(input="success", expected_output=None, id="example-1"), + Example(input=FAIL_IN_TASK_INPUT, expected_output=None, id="example-2"), + Example(input=FAIL_IN_EVAL_INPUT, expected_output=None, id="example-3"), + ] + + +@fixture +def evaluation_id() -> str: + return "evaluation-id-1" + + +@fixture +def successful_example_evaluation( + evaluation_id: str, +) -> ExampleEvaluation[DummyEvaluation]: + return ExampleEvaluation( + evaluation_id=evaluation_id, + example_id="successful_example", + result=DummyEvaluation(result="result"), + ) + + +@fixture +def file_aggregation_repository(tmp_path: Path) -> FileAggregationRepository: + return FileAggregationRepository(tmp_path) + + +@fixture +def file_evaluation_repository(tmp_path: Path) -> FileEvaluationRepository: + return FileEvaluationRepository(tmp_path) + + +@fixture +def file_run_repository(tmp_path: Path) -> FileRunRepository: + return FileRunRepository(tmp_path) + + +@fixture +def string_dataset_id( + dummy_string_examples: Iterable[Example[DummyStringInput, DummyStringOutput]], + in_memory_dataset_repository: DatasetRepository, +) -> str: + return in_memory_dataset_repository.create_dataset( + examples=dummy_string_examples, dataset_name="test-dataset" + ).id + + +@fixture +def dummy_aggregated_evaluation() -> DummyAggregatedEvaluation: + return DummyAggregatedEvaluation(score=0.5) + + +@fixture +def run_overview() -> RunOverview: + return RunOverview( + dataset_id="dataset-id", + id="run-id-1", + start=utc_now(), + end=utc_now(), + failed_example_count=0, + successful_example_count=3, + description="test run overview 1", + ) + + +@fixture +def evaluation_overview( + evaluation_id: str, run_overview: RunOverview +) -> EvaluationOverview: + return EvaluationOverview( + id=evaluation_id, + start=utc_now(), + run_overviews=frozenset([run_overview]), + description="test evaluation overview 1", + ) + + +@fixture +def aggregation_overview( + evaluation_overview: EvaluationOverview, + dummy_aggregated_evaluation: DummyAggregatedEvaluation, +) -> AggregationOverview[DummyAggregatedEvaluation]: + now = datetime.now() + return AggregationOverview( + evaluation_overviews=frozenset([evaluation_overview]), + id="aggregation-id", + start=now, + end=now, + successful_evaluation_count=5, + crashed_during_evaluation_count=3, + description="dummy-evaluator", + statistics=dummy_aggregated_evaluation, + ) + + +@fixture +def dummy_string_example() -> Example[DummyStringInput, DummyStringOutput]: + return Example( + input=DummyStringInput.any(), expected_output=DummyStringOutput.any() + ) + + +@fixture +def dummy_string_examples( + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> Iterable[Example[DummyStringInput, DummyStringOutput]]: + return [dummy_string_example] + + +@fixture +def dummy_runner( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, +) -> Runner[str, str]: + return Runner( + DummyTask(), + in_memory_dataset_repository, + in_memory_run_repository, + "dummy-runner", + ) + + +@fixture +def argilla_aggregation_logic() -> InstructComparisonArgillaAggregationLogic: + return InstructComparisonArgillaAggregationLogic() + + +class StubArgillaClient(ArgillaClient): + _expected_workspace_id: str + _expected_fields: Sequence[Field] + _expected_questions: Sequence[Question] + _datasets: dict[str, list[RecordData]] = {} + _score = 3.0 + + def ensure_dataset_exists( + self, + workspace_id: str, + _: str, + fields: Sequence[Field], + questions: Sequence[Question], + ) -> str: + if workspace_id != self._expected_workspace_id: + raise Exception("Incorrect workspace id") + elif fields != self._expected_fields: + raise Exception("Incorrect fields") + elif questions != self._expected_questions: + raise Exception("Incorrect questions") + dataset_id = str(uuid4()) + self._datasets[dataset_id] = [] + return dataset_id + + def add_record(self, dataset_id: str, record: RecordData) -> None: + if dataset_id not in self._datasets: + raise Exception("Add record: dataset not found") + self._datasets[dataset_id].append(record) + + def evaluations(self, dataset_id: str) -> Iterable[ArgillaEvaluation]: + dataset = self._datasets.get(dataset_id) + assert dataset + return [ + ArgillaEvaluation( + example_id=record.example_id, + record_id="ignored", + responses={"human-score": self._score}, + metadata=dict(), + ) + for record in dataset + ] + + def split_dataset(self, dataset_id: str, n_splits: int) -> None: + raise NotImplementedError + + +@fixture +def stub_argilla_client() -> StubArgillaClient: + return StubArgillaClient() + + +@fixture() +def temp_file_system() -> Iterable[MemoryFileSystem]: + mfs = MemoryFileSystem() + + try: + yield mfs + finally: + mfs.store.clear() + + +@fixture(scope="session") +def hugging_face_test_repository_id() -> str: + return f"Aleph-Alpha/IL-temp-tests-{str(uuid4())}" + + +@fixture(scope="session") +def hugging_face_token() -> str: + load_dotenv() + token = getenv("HUGGING_FACE_TOKEN") + assert isinstance(token, str) + return token diff --git a/tests/evaluation/test_accumulator.py b/tests/evaluation/test_accumulator.py new file mode 100644 index 000000000..c61e6ae8c --- /dev/null +++ b/tests/evaluation/test_accumulator.py @@ -0,0 +1,22 @@ +from intelligence_layer.evaluation import MeanAccumulator + + +def test_mean_accumulator_returns_mean() -> None: + acc = MeanAccumulator() + assert acc.extract() == 0.0 + acc.add(1) + assert acc.extract() == 1.0 + acc.add(0) + assert acc.extract() == 0.5 + + +def test_mean_accumulator_returns_stdev_and_se() -> None: + acc = MeanAccumulator() + assert acc.standard_deviation() == 0.0 + assert acc.standard_error() == 0.0 + acc.add(1) + assert acc.standard_deviation() == 0.0 + assert acc.standard_error() == 0.0 + acc.add(0) + assert acc.standard_deviation() == 0.5 + assert round(acc.standard_error(), 3) == 0.354 diff --git a/tests/evaluation/test_aggregation_repository.py b/tests/evaluation/test_aggregation_repository.py new file mode 100644 index 000000000..2be37d85a --- /dev/null +++ b/tests/evaluation/test_aggregation_repository.py @@ -0,0 +1,152 @@ +from typing import Iterable +from unittest.mock import patch +from uuid import uuid4 + +from _pytest.fixtures import FixtureRequest +from fsspec.implementations.memory import MemoryFileSystem # type: ignore +from pytest import fixture, mark + +from intelligence_layer.core import utc_now +from intelligence_layer.evaluation import ( + AggregationOverview, + AggregationRepository, + EvaluationOverview, +) +from intelligence_layer.evaluation.aggregation.hugging_face_aggregation_repository import ( + HuggingFaceAggregationRepository, +) +from tests.evaluation.conftest import DummyAggregatedEvaluation + +test_repository_fixtures = [ + "file_aggregation_repository", + "in_memory_aggregation_repository", + "mocked_hugging_face_aggregation_repository", +] + + +@fixture +def mocked_hugging_face_aggregation_repository( + temp_file_system: MemoryFileSystem, +) -> Iterable[HuggingFaceAggregationRepository]: + class_to_patch = "intelligence_layer.evaluation.aggregation.hugging_face_aggregation_repository.HuggingFaceAggregationRepository" + with patch(f"{class_to_patch}.create_repository", autospec=True), patch( + f"{class_to_patch}.delete_repository", + autospec=True, + ): + repo = HuggingFaceAggregationRepository( + repository_id="doesn't-matter", + token="non-existing-token", + private=True, + ) + repo._file_system = temp_file_system + yield repo + + +@fixture +def aggregation_overviews( + evaluation_overview: EvaluationOverview, + dummy_aggregated_evaluation: DummyAggregatedEvaluation, +) -> Iterable[AggregationOverview[DummyAggregatedEvaluation]]: + aggregation_ids = [str(uuid4()) for _ in range(10)] + aggregation_overviews: list[AggregationOverview[DummyAggregatedEvaluation]] = [] + for aggregation_id in aggregation_ids: + now = utc_now() + aggregation_overviews.append( + AggregationOverview( + id=aggregation_id, + evaluation_overviews=frozenset([evaluation_overview]), + start=now, + end=now, + successful_evaluation_count=5, + crashed_during_evaluation_count=3, + statistics=dummy_aggregated_evaluation, + description="dummy-aggregator", + ) + ) + return aggregation_overviews + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_aggregation_repository_stores_and_returns_an_aggregation_overview( + repository_fixture: str, + request: FixtureRequest, + aggregation_overview: AggregationOverview[DummyAggregatedEvaluation], +) -> None: + aggregation_repository: AggregationRepository = request.getfixturevalue( + repository_fixture + ) + + aggregation_repository.store_aggregation_overview(aggregation_overview) + stored_aggregation_overview = aggregation_repository.aggregation_overview( + aggregation_overview.id, DummyAggregatedEvaluation + ) + + assert stored_aggregation_overview == aggregation_overview + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_aggregation_overview_returns_none_for_not_existing_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + aggregation_repository: AggregationRepository = request.getfixturevalue( + repository_fixture + ) + + stored_aggregation_overview = aggregation_repository.aggregation_overview( + "not-existing-id", DummyAggregatedEvaluation + ) + + assert stored_aggregation_overview is None + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_aggregation_overviews_returns_all_aggregation_overviews( + repository_fixture: str, + request: FixtureRequest, + aggregation_overviews: Iterable[AggregationOverview[DummyAggregatedEvaluation]], +) -> None: + aggregation_repository: AggregationRepository = request.getfixturevalue( + repository_fixture + ) + for aggregation_overview in aggregation_overviews: + aggregation_repository.store_aggregation_overview(aggregation_overview) + + stored_aggregation_overviews = list( + aggregation_repository.aggregation_overviews(DummyAggregatedEvaluation) + ) + + assert stored_aggregation_overviews == sorted( + aggregation_overviews, key=lambda overview: overview.id + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_aggregation_overview_ids_returns_sorted_ids( + repository_fixture: str, + request: FixtureRequest, + aggregation_overviews: Iterable[AggregationOverview[DummyAggregatedEvaluation]], +) -> None: + aggregation_repository: AggregationRepository = request.getfixturevalue( + repository_fixture + ) + for aggregation_overview in aggregation_overviews: + aggregation_repository.store_aggregation_overview(aggregation_overview) + + stored_aggregation_ids = list(aggregation_repository.aggregation_overview_ids()) + + assert stored_aggregation_ids == sorted( + [aggregation_overview.id for aggregation_overview in aggregation_overviews] + ) diff --git a/tests/evaluation/test_argilla_evaluation_repository.py b/tests/evaluation/test_argilla_evaluation_repository.py new file mode 100644 index 000000000..60c1bf04d --- /dev/null +++ b/tests/evaluation/test_argilla_evaluation_repository.py @@ -0,0 +1,305 @@ +from itertools import chain +from typing import Iterable, Sequence, Tuple +from uuid import uuid4 + +from pytest import fixture + +from intelligence_layer.connectors import ArgillaEvaluation, Field, Question, RecordData +from intelligence_layer.core import utc_now +from intelligence_layer.evaluation import ( + ArgillaEvaluationRepository, + EvaluationOverview, + ExampleEvaluation, + FailedExampleEvaluation, + InMemoryEvaluationRepository, + RecordDataSequence, + RunOverview, +) +from tests.evaluation.conftest import DummyEvaluation, StubArgillaClient + + +@fixture +def argilla_workspace_id() -> str: + return "workspace-id" + + +@fixture +def argilla_client_fields() -> Sequence[Field]: + return [] + + +@fixture +def argilla_client_questions() -> Sequence[Question]: + return [] + + +@fixture +def stub_argilla_client_with_defaults( + stub_argilla_client: StubArgillaClient, + argilla_workspace_id: str, + argilla_client_fields: Sequence[Field], + argilla_client_questions: Sequence[Question], +) -> StubArgillaClient: + stub_argilla_client._expected_workspace_id = argilla_workspace_id + stub_argilla_client._expected_fields = argilla_client_fields + stub_argilla_client._expected_questions = argilla_client_questions + return stub_argilla_client + + +@fixture +def argilla_evaluation_repository( + stub_argilla_client_with_defaults: StubArgillaClient, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + argilla_workspace_id: str, + argilla_client_fields: Sequence[Field], + argilla_client_questions: Sequence[Question], +) -> ArgillaEvaluationRepository: + return ArgillaEvaluationRepository( + argilla_client=stub_argilla_client_with_defaults, + evaluation_repository=in_memory_evaluation_repository, + workspace_id=argilla_workspace_id, + fields=argilla_client_fields, + questions=argilla_client_questions, + ) + + +@fixture +def argilla_evaluation_repository_with_example_evaluations( + argilla_evaluation_repository: ArgillaEvaluationRepository, +) -> Tuple[ + str, + ArgillaEvaluationRepository, + list[ExampleEvaluation[RecordDataSequence]], + list[ExampleEvaluation[RecordDataSequence]], +]: + dataset_id = argilla_evaluation_repository.initialize_evaluation() + + successful_example_evaluation_ids = [str(uuid4()) for _ in range(10)] + + successful_example_evaluations = [] + for example_evaluation_id in successful_example_evaluation_ids: + example_evaluation = ExampleEvaluation( + evaluation_id=dataset_id, + example_id=example_evaluation_id, + result=RecordDataSequence( + records=[ + RecordData( + content={}, example_id=example_evaluation_id, metadata={} + ) + ] + ), + ) + successful_example_evaluations.append(example_evaluation) + argilla_evaluation_repository.store_example_evaluation(example_evaluation) + + failed_example_evaluation_ids = [str(uuid4()) for _ in range(10)] + failed_example_evaluations = [] + for example_evaluation_id in failed_example_evaluation_ids: + failed_example_evaluation: ExampleEvaluation[RecordDataSequence] = ( + ExampleEvaluation( + evaluation_id=dataset_id, + example_id=example_evaluation_id, + result=FailedExampleEvaluation(error_message="error"), + ) + ) + failed_example_evaluations.append(failed_example_evaluation) + ( + argilla_evaluation_repository.store_example_evaluation( + failed_example_evaluation + ) + ) + + return ( + dataset_id, + argilla_evaluation_repository, + successful_example_evaluations, + failed_example_evaluations, + ) + + +@fixture +def evaluation_overviews(run_overview: RunOverview) -> Iterable[EvaluationOverview]: + evaluation_overviews = [] + evaluation_ids = [str(uuid4()) for _ in range(10)] + for evaluation_id in evaluation_ids: + evaluation_overviews.append( + EvaluationOverview( + id=evaluation_id, + start=utc_now(), + run_overviews=frozenset([run_overview]), + description="test evaluation overview 1", + ) + ) + + return evaluation_overviews + + +def test_create_evaluation_dataset_returns_dataset_id( + argilla_evaluation_repository: ArgillaEvaluationRepository, +) -> None: + dataset_id = argilla_evaluation_repository.initialize_evaluation() + + assert dataset_id != "" + + +def test_evaluation_overview_returns_none_for_not_existing_id( + argilla_evaluation_repository: ArgillaEvaluationRepository, + evaluation_overview: EvaluationOverview, +) -> None: + argilla_evaluation_repository.store_evaluation_overview(evaluation_overview) + + stored_evaluation_overview = argilla_evaluation_repository.evaluation_overview( + "not-existing-id" + ) + + assert stored_evaluation_overview is None + + +def test_evaluation_overview_returns_evaluation_overview( + argilla_evaluation_repository: ArgillaEvaluationRepository, + evaluation_overview: EvaluationOverview, +) -> None: + argilla_evaluation_repository.store_evaluation_overview(evaluation_overview) + + stored_evaluation_overview = argilla_evaluation_repository.evaluation_overview( + evaluation_overview.id + ) + + assert stored_evaluation_overview == evaluation_overview + + +def test_evaluation_overviews_returns_sorted_evaluation_overviews( + argilla_evaluation_repository: ArgillaEvaluationRepository, + evaluation_overviews: Iterable[EvaluationOverview], +) -> None: + for evaluation_overview in evaluation_overviews: + argilla_evaluation_repository.store_evaluation_overview(evaluation_overview) + + stored_evaluation_overviews = list( + argilla_evaluation_repository.evaluation_overviews() + ) + + assert stored_evaluation_overviews == sorted( + evaluation_overviews, key=lambda overview: overview.id + ) + + +def test_evaluation_overview_ids_returns_sorted_ids( + argilla_evaluation_repository: ArgillaEvaluationRepository, + evaluation_overviews: Iterable[EvaluationOverview], +) -> None: + sorted_evaluation_overview_ids = sorted( + [overview.id for overview in evaluation_overviews] + ) + for evaluation_overview in evaluation_overviews: + argilla_evaluation_repository.store_evaluation_overview(evaluation_overview) + + evaluation_overview_ids = argilla_evaluation_repository.evaluation_overview_ids() + + assert evaluation_overview_ids == sorted_evaluation_overview_ids + + +def test_example_evaluations_returns_sorted_example_evaluations( + argilla_evaluation_repository: ArgillaEvaluationRepository, + argilla_evaluation_repository_with_example_evaluations: Tuple[ + str, + ArgillaEvaluationRepository, + list[ExampleEvaluation[DummyEvaluation]], + list[ExampleEvaluation[DummyEvaluation]], + ], +) -> None: + ( + dataset_id, + argilla_evaluation_repository, + successful_evaluation_examples, + failed_evaluation_examples, + ) = argilla_evaluation_repository_with_example_evaluations + all_sorted_evaluation_examples = sorted( + chain(successful_evaluation_examples, failed_evaluation_examples), + key=lambda example: example.example_id, + ) + + example_evaluations = argilla_evaluation_repository.example_evaluations( + dataset_id, ArgillaEvaluation + ) + + assert len(example_evaluations) == len(all_sorted_evaluation_examples) + for i, example_evaluation in enumerate(example_evaluations): + assert ( + example_evaluation.example_id + == all_sorted_evaluation_examples[i].example_id + ) + assert ( + example_evaluation.evaluation_id + == all_sorted_evaluation_examples[i].evaluation_id + ) + + +def test_successful_example_evaluations_returns_sorted_successful_example_evaluations( + argilla_evaluation_repository: ArgillaEvaluationRepository, + argilla_evaluation_repository_with_example_evaluations: Tuple[ + str, + ArgillaEvaluationRepository, + list[ExampleEvaluation[DummyEvaluation]], + list[ExampleEvaluation[DummyEvaluation]], + ], +) -> None: + ( + dataset_id, + argilla_evaluation_repository, + successful_evaluation_examples, + failed_evaluation_examples, + ) = argilla_evaluation_repository_with_example_evaluations + sorted_successful_evaluation_examples = sorted( + successful_evaluation_examples, key=lambda example: example.example_id + ) + + example_evaluations = argilla_evaluation_repository.successful_example_evaluations( + dataset_id, ArgillaEvaluation + ) + + assert len(example_evaluations) == len(sorted_successful_evaluation_examples) + for i, example_evaluation in enumerate(example_evaluations): + assert ( + example_evaluation.example_id + == sorted_successful_evaluation_examples[i].example_id + ) + assert ( + example_evaluation.evaluation_id + == sorted_successful_evaluation_examples[i].evaluation_id + ) + + +def test_failed_example_evaluations_returns_sorted_failed_example_evaluations( + argilla_evaluation_repository: ArgillaEvaluationRepository, + argilla_evaluation_repository_with_example_evaluations: Tuple[ + str, + ArgillaEvaluationRepository, + list[ExampleEvaluation[DummyEvaluation]], + list[ExampleEvaluation[DummyEvaluation]], + ], +) -> None: + ( + dataset_id, + argilla_evaluation_repository, + successful_evaluation_examples, + failed_evaluation_examples, + ) = argilla_evaluation_repository_with_example_evaluations + sorted_failed_evaluation_examples = sorted( + failed_evaluation_examples, key=lambda example: example.example_id + ) + + example_evaluations = argilla_evaluation_repository.failed_example_evaluations( + dataset_id, ArgillaEvaluation + ) + + assert len(example_evaluations) == len(sorted_failed_evaluation_examples) + for i, example_evaluation in enumerate(example_evaluations): + assert ( + example_evaluation.example_id + == sorted_failed_evaluation_examples[i].example_id + ) + assert ( + example_evaluation.evaluation_id + == sorted_failed_evaluation_examples[i].evaluation_id + ) diff --git a/tests/evaluation/test_argilla_evaluator.py b/tests/evaluation/test_argilla_evaluator.py new file mode 100644 index 000000000..ecee6a7c3 --- /dev/null +++ b/tests/evaluation/test_argilla_evaluator.py @@ -0,0 +1,241 @@ +import random +from typing import Iterable, Sequence, cast + +from pytest import fixture + +from intelligence_layer.connectors import ArgillaEvaluation, Field, Question, RecordData +from intelligence_layer.evaluation import ( + AggregationLogic, + ArgillaAggregator, + ArgillaEvaluationLogic, + ArgillaEvaluationRepository, + ArgillaEvaluator, + Example, + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, + InstructComparisonArgillaAggregationLogic, + RecordDataSequence, + Runner, + SuccessfulExampleOutput, +) +from tests.conftest import DummyStringInput, DummyStringOutput, DummyStringTask +from tests.evaluation.conftest import DummyAggregatedEvaluation, StubArgillaClient + + +class DummyStringTaskArgillaAggregationLogic( + AggregationLogic[ + ArgillaEvaluation, + DummyAggregatedEvaluation, + ] +): + def aggregate( + self, + evaluations: Iterable[ArgillaEvaluation], + ) -> DummyAggregatedEvaluation: + evaluations = list(evaluations) + total_human_score = sum( + cast(float, a.responses["human-score"]) for a in evaluations + ) + return DummyAggregatedEvaluation( + score=total_human_score / len(evaluations), + ) + + +class DummyStringTaskArgillaEvaluationLogic( + ArgillaEvaluationLogic[ + DummyStringInput, + DummyStringOutput, + DummyStringOutput, + ] +): + def _to_record( + self, + example: Example[DummyStringInput, DummyStringOutput], + *output: SuccessfulExampleOutput[DummyStringOutput], + ) -> RecordDataSequence: + assert len(output) == 1 + single_output = output[0].output + return RecordDataSequence( + records=[ + RecordData( + content={ + "input": example.input.input, + "output": single_output.output, + }, + example_id=example.id, + ) + ] + ) + + +@fixture +def arg() -> StubArgillaClient: + return StubArgillaClient() + + +@fixture() +def argilla_questions() -> Sequence[Question]: + return [ + Question( + name="question", + title="title", + description="description", + options=[1], + ) + ] + + +@fixture() +def argilla_fields() -> Sequence[Field]: + return [ + Field(name="output", title="Output"), + Field(name="input", title="Input"), + ] + + +@fixture +def argilla_evaluation_repository( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + stub_argilla_client: StubArgillaClient, + argilla_questions: Sequence[Question], + argilla_fields: Sequence[Field], +) -> ArgillaEvaluationRepository: + stub_argilla_client._expected_workspace_id = "workspace-id" + stub_argilla_client._expected_questions = argilla_questions + stub_argilla_client._expected_fields = argilla_fields + + workspace_id = stub_argilla_client._expected_workspace_id + + return ArgillaEvaluationRepository( + in_memory_evaluation_repository, + stub_argilla_client, + workspace_id, + argilla_fields, + argilla_questions, + ) + + +@fixture +def string_argilla_evaluator( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + argilla_evaluation_repository: ArgillaEvaluationRepository, +) -> ArgillaEvaluator[ + DummyStringInput, + DummyStringOutput, + DummyStringOutput, +]: + evaluator = ArgillaEvaluator( + in_memory_dataset_repository, + in_memory_run_repository, + argilla_evaluation_repository, + "dummy-string-task", + DummyStringTaskArgillaEvaluationLogic(), + ) + return evaluator + + +@fixture +def string_argilla_aggregator( + argilla_evaluation_repository: ArgillaEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, +) -> ArgillaAggregator[DummyAggregatedEvaluation]: + aggregator = ArgillaAggregator( + argilla_evaluation_repository, + in_memory_aggregation_repository, + "dummy-string-task", + DummyStringTaskArgillaAggregationLogic(), + ) + return aggregator + + +@fixture +def string_argilla_runner( + dummy_string_task: DummyStringTask, + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, +) -> Runner[DummyStringInput, DummyStringOutput]: + return Runner( + dummy_string_task, + in_memory_dataset_repository, + in_memory_run_repository, + "dummy-task", + ) + + +def test_argilla_evaluator_can_do_sync_evaluation( + string_argilla_evaluator: ArgillaEvaluator[ + DummyStringInput, + DummyStringOutput, + DummyStringOutput, + ], + string_argilla_runner: Runner[DummyStringInput, DummyStringOutput], + string_dataset_id: str, +) -> None: + argilla_client = cast( + StubArgillaClient, + string_argilla_evaluator._evaluation_repository._client, # type: ignore + ) + + run_overview = string_argilla_runner.run_dataset(string_dataset_id) + eval_overview = string_argilla_evaluator.evaluate_runs(run_overview.id) + examples_iter = string_argilla_evaluator._dataset_repository.examples( + string_dataset_id, DummyStringInput, DummyStringOutput + ) + assert examples_iter is not None + + assert eval_overview.id in argilla_client._datasets + saved_dataset = argilla_client._datasets[eval_overview.id] + examples = list(examples_iter) + assert len(saved_dataset) == len(examples) + assert saved_dataset[0].example_id == examples[0].id + assert saved_dataset[0].content["input"] == examples[0].input.input + + +def test_argilla_evaluator_can_aggregate_evaluation( + string_argilla_evaluator: ArgillaEvaluator[ + DummyStringInput, + DummyStringOutput, + DummyStringOutput, + ], + string_argilla_runner: Runner[DummyStringInput, DummyStringOutput], + string_dataset_id: str, + string_argilla_aggregator: ArgillaAggregator[DummyAggregatedEvaluation], +) -> None: + # given + argilla_client = cast( + StubArgillaClient, + string_argilla_evaluator._evaluation_repository._client, # type: ignore + ) + # when + run_overview = string_argilla_runner.run_dataset(string_dataset_id) + eval_overview = string_argilla_evaluator.evaluate_runs(run_overview.id) + aggregated_eval_overview = string_argilla_aggregator.aggregate_evaluation( + eval_overview.id + ) + # then + assert aggregated_eval_overview.statistics.score == argilla_client._score + + +def test_argilla_aggregation_logic_works() -> None: + argilla_aggregation_logic = InstructComparisonArgillaAggregationLogic() + evaluations = ( + ArgillaEvaluation( + example_id=str(i), + record_id=str(i), + responses={"winner": random.choices([1, 2, 3], [0.5, 0.25, 0.25], k=1)[0]}, + metadata={ + "first": "player_1", + "second": "player_2" if i < 9000 else "player_3", + }, + ) + for i in range(10000) + ) + aggregation = argilla_aggregation_logic.aggregate(evaluations) + assert aggregation.scores["player_1"].elo > aggregation.scores["player_2"].elo + assert ( + aggregation.scores["player_3"].elo_standard_error + > aggregation.scores["player_1"].elo_standard_error + ) diff --git a/tests/evaluation/test_dataset_repository.py b/tests/evaluation/test_dataset_repository.py new file mode 100644 index 000000000..2b94b42ac --- /dev/null +++ b/tests/evaluation/test_dataset_repository.py @@ -0,0 +1,337 @@ +from pathlib import Path +from typing import Any, Iterable +from unittest.mock import patch + +import pytest +from fsspec.implementations.memory import MemoryFileSystem # type: ignore +from pytest import FixtureRequest, fixture, mark, raises + +from intelligence_layer.evaluation import ( + DatasetRepository, + Example, + FileDatasetRepository, +) +from intelligence_layer.evaluation.dataset.hugging_face_dataset_repository import ( + HuggingFaceDatasetRepository, +) +from tests.conftest import DummyStringInput, DummyStringOutput + + +@fixture +def file_dataset_repository(tmp_path: Path) -> FileDatasetRepository: + return FileDatasetRepository(tmp_path) + + +@fixture +def mocked_hugging_face_dataset_repository( + temp_file_system: MemoryFileSystem, +) -> HuggingFaceDatasetRepository: + class_to_patch = "intelligence_layer.evaluation.dataset.hugging_face_dataset_repository.HuggingFaceDatasetRepository" + with patch(f"{class_to_patch}.create_repository", autospec=True), patch( + f"{class_to_patch}.delete_repository", + autospec=True, + ): + repo = HuggingFaceDatasetRepository( + repository_id="doesn't-matter", + token="non-existing-token", + private=True, + ) + repo._file_system = temp_file_system + return repo + + +test_repository_fixtures = [ + "file_dataset_repository", + "in_memory_dataset_repository", + "mocked_hugging_face_dataset_repository", +] + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_dataset_repository_with_custom_id( + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + dataset = dataset_repository.create_dataset( + examples=[dummy_string_example], + dataset_name="test-dataset", + id="my-custom-dataset-id", + ) + + assert dataset.id == "my-custom-dataset-id" + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_dataset_repository_can_create_and_store_a_dataset( + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + dataset = dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name="test-dataset" + ) + stored_dataset = dataset_repository.dataset(dataset.id) + stored_examples = list( + dataset_repository.examples( + dataset.id, + input_type=DummyStringInput, + expected_output_type=DummyStringOutput, + ) + ) + + assert stored_dataset == dataset + assert len(stored_examples) == 1 + assert stored_examples[0] == dummy_string_example + + +@patch(target="intelligence_layer.evaluation.dataset.domain.uuid4", return_value="1234") +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_dataset_repository_ensures_unique_dataset_ids( + _mock_uuid4: Any, # this is necessary as otherwise the other fixtures aren't found + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name="test-dataset" + ) + with raises(ValueError): + dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name="test-dataset" + ) + + +@patch( + target="intelligence_layer.evaluation.dataset.file_dataset_repository.LocalFileSystem.exists", + return_value=True, +) +def test_file_system_dataset_repository_avoids_overriding_existing_files( + _mock: Any, + tmp_path: Path, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository = FileDatasetRepository(root_directory=tmp_path) + + with raises(ValueError): + dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name="test-dataset" + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_dataset_returns_none_for_not_existing_dataset_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + stored_dataset = dataset_repository.dataset("not-existing-dataset-id") + + assert stored_dataset is None + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_delete_dataset_deletes_a_dataset( + repository_fixture: str, + request: FixtureRequest, + dummy_string_examples: Iterable[Example[DummyStringInput, DummyStringOutput]], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + dataset = dataset_repository.create_dataset( + examples=dummy_string_examples, dataset_name="test-dataset" + ) + + dataset_repository.delete_dataset(dataset.id) + + assert dataset_repository.dataset(dataset.id) is None + with pytest.raises(ValueError): + dataset_repository.examples(dataset.id, DummyStringInput, DummyStringOutput) + + dataset_repository.delete_dataset( + dataset.id + ) # tests whether function is idempotent + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_deleting_a_nonexistant_repo_does_not_cause_an_exception( + repository_fixture: str, + request: FixtureRequest, +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + dataset_repository.delete_dataset("non-existant-id") + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_datasets_returns_all_sorted_dataset( + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + datasets = [] + for i in range(10): + datasets.append( + dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name=f"test-dataset_{i}" + ) + ) + + stored_datasets = list(dataset_repository.datasets()) + + assert stored_datasets == sorted(datasets, key=lambda dataset: dataset.id) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_dataset_ids_returns_all_sorted_ids( + repository_fixture: str, + request: FixtureRequest, + dummy_string_examples: Iterable[Example[DummyStringInput, DummyStringOutput]], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + dataset_ids = [ + dataset_repository.create_dataset( + examples=dummy_string_examples, dataset_name="test-dataset" + ).id + for _ in range(10) + ] + + stored_dataset_ids = dataset_repository.dataset_ids() + + assert stored_dataset_ids == sorted(dataset_ids) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_examples_returns_all_examples_sorted_by_their_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + examples = [ + Example( + input=DummyStringInput.any(), + expected_output=DummyStringOutput.any(), + ) + for i in range(0, 10) + ] + dataset = dataset_repository.create_dataset( + examples=examples, dataset_name="test-dataset" + ) + + stored_examples = list( + dataset_repository.examples(dataset.id, DummyStringInput, DummyStringOutput) + ) + + assert stored_examples == sorted(examples, key=lambda example: example.id) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_examples_raises_value_error_for_not_existing_dataset_id( + repository_fixture: str, request: FixtureRequest +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + with pytest.raises(ValueError): + dataset_repository.examples( + "not_existing_dataset_id", DummyStringInput, type(None) + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_returns_example_for_existing_dataset_id_and_example_id( + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + dataset = dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name="test-dataset" + ) + + example = dataset_repository.example( + dataset.id, dummy_string_example.id, DummyStringInput, DummyStringOutput + ) + + assert example == dummy_string_example + + +@mark.parametrize("repository_fixture", test_repository_fixtures) +def test_example_returns_none_for_not_existant_example_id( + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + dataset = dataset_repository.create_dataset( + examples=[dummy_string_example], dataset_name="test-dataset" + ) + + examples = dataset_repository.example( + dataset.id, "not_existing_example_id", DummyStringInput, DummyStringOutput + ) + + assert examples is None + + +@mark.parametrize("repository_fixture", test_repository_fixtures) +def test_example_raises_error_for_not_existing_dataset_id( + repository_fixture: str, + request: FixtureRequest, + dummy_string_example: Example[DummyStringInput, DummyStringOutput], +) -> None: + dataset_repository: DatasetRepository = request.getfixturevalue(repository_fixture) + + with pytest.raises(ValueError): + dataset_repository.example( + "not_existing_dataset_id", + dummy_string_example.id, + DummyStringInput, + DummyStringOutput, + ) + with pytest.raises(ValueError): + dataset_repository.example( + "not_existing_dataset_id", + "not_existing_example_id", + DummyStringInput, + DummyStringOutput, + ) diff --git a/tests/evaluation/test_domain.py b/tests/evaluation/test_domain.py new file mode 100644 index 000000000..e4db9d584 --- /dev/null +++ b/tests/evaluation/test_domain.py @@ -0,0 +1,63 @@ +from pytest import raises + +from intelligence_layer.core import InMemorySpan, InMemoryTaskSpan, LogEntry, utc_now +from intelligence_layer.evaluation import ( + AggregationOverview, + EvaluationFailed, + LogTrace, + SpanTrace, + TaskSpanTrace, +) +from intelligence_layer.evaluation.run.trace import _to_trace_entry +from tests.evaluation.conftest import DummyAggregatedEvaluation + + +def test_to_trace_entry() -> None: + now = utc_now() + entry = _to_trace_entry( + InMemoryTaskSpan( + name="task", + input="input", + output="output", + start_timestamp=now, + end_timestamp=now, + entries=[ + LogEntry(message="message", value="value", trace_id="ID"), + InMemorySpan( + name="span", start_timestamp=now, end_timestamp=now, trace_id="ID" + ), + ], + trace_id="ID", + ) + ) + + assert entry == TaskSpanTrace( + name="task", + input="input", + output="output", + start=now, + end=now, + traces=[ + LogTrace(message="message", value="value"), + SpanTrace(name="span", traces=[], start=now, end=now), + ], + ) + + +def test_deserialize_task_trace() -> None: + trace = TaskSpanTrace( + name="task", + start=utc_now(), + end=utc_now(), + traces=[], + input=[{"a": "b"}], + output=["c"], + ) + assert trace.model_validate_json(trace.model_dump_json()) == trace + + +def test_raise_on_exception_for_evaluation_run_overview( + aggregation_overview: AggregationOverview[DummyAggregatedEvaluation], +) -> None: + with raises(EvaluationFailed): + aggregation_overview.raise_on_evaluation_failure() diff --git a/tests/evaluation/test_elo.py b/tests/evaluation/test_elo.py new file mode 100644 index 000000000..b32525308 --- /dev/null +++ b/tests/evaluation/test_elo.py @@ -0,0 +1,70 @@ +from itertools import combinations +from typing import Sequence + +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.evaluation import EloCalculator, MatchOutcome, WinRateCalculator + + +@fixture +def players() -> Sequence[str]: + return [str(i + 1) for i in range(10)] + + +@fixture +def matches(players: Sequence[str]) -> Sequence[tuple[str, str, MatchOutcome]]: + return [ + (player_a, player_b, MatchOutcome.A_WINS) + for player_a, player_b in combinations(players, 2) + ] + + +class MatchOutcomeModel(BaseModel): + match_outcome: MatchOutcome + + +def test_match_outcome_serializes() -> None: + match_outcome_model = MatchOutcomeModel(match_outcome=MatchOutcome.A_WINS) + dumped = match_outcome_model.model_dump_json() + loaded = MatchOutcomeModel.model_validate_json(dumped) + + assert loaded == match_outcome_model + + +def test_elo_calculator_works( + players: Sequence[str], matches: Sequence[tuple[str, str, MatchOutcome]] +) -> None: + elo_calculator = EloCalculator(players) + elo_calculator.calculate(matches) + + sorted_scores = { + k: v + for k, v in sorted( + elo_calculator.ratings.items(), key=lambda item: item[1], reverse=True + ) + } + assert [int(i) for i in players] == [int(i) for i in sorted_scores.keys()] + assert ( + round(sum(score for score in sorted_scores.values()) / len(sorted_scores), 0) + == 1500 + ) + + +def test_win_rate_calculator_works( + players: Sequence[str], matches: Sequence[tuple[str, str, MatchOutcome]] +) -> None: + win_rate_calculator = WinRateCalculator(players) + scores = win_rate_calculator.calculate(matches) + + sorted_scores = { + k: v for k, v in sorted(scores.items(), key=lambda item: item[1], reverse=True) + } + assert [int(i) for i in players] == [int(i) for i in sorted_scores.keys()] + assert ( + round( + sum(score for score in sorted_scores.values()) / len(sorted_scores), + 5, + ) + == 0.5 + ) diff --git a/tests/evaluation/test_evaluation_repository.py b/tests/evaluation/test_evaluation_repository.py new file mode 100644 index 000000000..4fabb8ce3 --- /dev/null +++ b/tests/evaluation/test_evaluation_repository.py @@ -0,0 +1,406 @@ +from datetime import datetime +from itertools import chain +from typing import Iterable +from uuid import uuid4 + +import pytest +from _pytest.fixtures import FixtureRequest +from pytest import fixture, mark + +from intelligence_layer.core import utc_now +from intelligence_layer.evaluation import ( + EvaluationOverview, + EvaluationRepository, + ExampleEvaluation, + ExampleTrace, + FailedExampleEvaluation, + RunOverview, + TaskSpanTrace, +) +from tests.evaluation.conftest import DummyEvaluation + +test_repository_fixtures = [ + "file_evaluation_repository", + "in_memory_evaluation_repository", +] + + +@fixture +def task_span_trace() -> TaskSpanTrace: + now = datetime.now() + return TaskSpanTrace( + name="task name", traces=[], start=now, end=now, input="input", output="output" + ) + + +@fixture +def example_trace( + task_span_trace: TaskSpanTrace, +) -> ExampleTrace: + return ExampleTrace( + run_id="evaluation-id", + example_id="example-id", + trace=task_span_trace, + ) + + +@fixture +def successful_example_evaluations( + evaluation_id: str, +) -> Iterable[ExampleEvaluation[DummyEvaluation]]: + example_ids = [str(uuid4()) for _ in range(10)] + example_evaluations = [] + for example_id in example_ids: + example_evaluations.append( + ExampleEvaluation( + evaluation_id=evaluation_id, + example_id=example_id, + result=DummyEvaluation(result="result"), + ) + ) + return example_evaluations + + +@fixture +def failed_example_evaluations( + evaluation_id: str, +) -> Iterable[ExampleEvaluation[DummyEvaluation]]: + example_ids = [str(uuid4()) for _ in range(10)] + example_evaluations: list[ExampleEvaluation[DummyEvaluation]] = [] + for example_id in example_ids: + example_evaluations.append( + ExampleEvaluation( + evaluation_id=evaluation_id, + example_id=example_id, + result=FailedExampleEvaluation(error_message="some error"), + ) + ) + return example_evaluations + + +@fixture +def evaluation_overviews(run_overview: RunOverview) -> Iterable[EvaluationOverview]: + evaluation_ids = [str(uuid4()) for _ in range(10)] + evaluation_overviews = [] + for evaluation_id in evaluation_ids: + evaluation_overviews.append( + EvaluationOverview( + id=evaluation_id, + start=utc_now(), + run_overviews=frozenset([run_overview]), + description="test evaluation overview 1", + ) + ) + return evaluation_overviews + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_create_evaluation_dataset_returns_an_evaluation_dataset_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + + evaluation_id = evaluation_repository.initialize_evaluation() + + assert len(evaluation_id) > 0 + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_evaluation_overview_ids_returns_all_sorted_ids( + repository_fixture: str, + request: FixtureRequest, + run_overview: RunOverview, + evaluation_overviews: Iterable[EvaluationOverview], +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + for evaluation_overview in evaluation_overviews: + evaluation_repository.store_evaluation_overview(evaluation_overview) + + stored_evaluation_ids = list(evaluation_repository.evaluation_overview_ids()) + + assert stored_evaluation_ids == sorted( + [evaluation_overview.id for evaluation_overview in evaluation_overviews] + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_evaluation_returns_none_if_example_id_does_not_exist( + repository_fixture: str, + request: FixtureRequest, + successful_example_evaluation: ExampleEvaluation[DummyEvaluation], +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + evaluation_repository.store_example_evaluation(successful_example_evaluation) + + stored_example_evaluation = evaluation_repository.example_evaluation( + successful_example_evaluation.evaluation_id, + "not-existing-example-id", + DummyEvaluation, + ) + + assert stored_example_evaluation is None + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_evaluation_throws_if_evaluation_id_does_not_exist( + repository_fixture: str, + request: FixtureRequest, + successful_example_evaluation: ExampleEvaluation[DummyEvaluation], +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + evaluation_repository.store_example_evaluation(successful_example_evaluation) + + with pytest.raises(ValueError): + evaluation_repository.example_evaluation( + "not-existing-eval-id", + successful_example_evaluation.example_id, + DummyEvaluation, + ) + with pytest.raises(ValueError): + evaluation_repository.example_evaluation( + "not-existing-eval-id", + "not-existing-example-id", + DummyEvaluation, + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_evaluation_repository_stores_and_returns_an_example_evaluation( + repository_fixture: str, + request: FixtureRequest, + successful_example_evaluation: ExampleEvaluation[DummyEvaluation], +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + + evaluation_repository.store_example_evaluation(successful_example_evaluation) + example_evaluation = evaluation_repository.example_evaluation( + successful_example_evaluation.evaluation_id, + successful_example_evaluation.example_id, + DummyEvaluation, + ) + + assert example_evaluation == successful_example_evaluation + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_evaluation_repository_stores_and_returns_a_failed_example_evaluation( + repository_fixture: str, + request: FixtureRequest, +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + evaluation_id = "evaluation-id" + failed_example_evaluation: ExampleEvaluation[FailedExampleEvaluation] = ( + ExampleEvaluation( + evaluation_id=evaluation_id, + example_id="example-id", + result=FailedExampleEvaluation(error_message="some error"), + ) + ) + + evaluation_repository.store_example_evaluation(failed_example_evaluation) + example_evaluation = evaluation_repository.example_evaluation( + evaluation_id, + failed_example_evaluation.example_id, + FailedExampleEvaluation, + ) + + assert example_evaluation == failed_example_evaluation + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_evaluations_returns_an_error_for_not_existing_evaluation_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + with pytest.raises(ValueError): + evaluation_repository.example_evaluations( + "not-existing-evaluation-id", DummyEvaluation + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_evaluations_returns_all_example_evaluations( + repository_fixture: str, + request: FixtureRequest, + successful_example_evaluations: Iterable[ExampleEvaluation[DummyEvaluation]], + failed_example_evaluations: Iterable[ExampleEvaluation[DummyEvaluation]], + evaluation_id: str, +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + all_example_evaluations: list[ExampleEvaluation[DummyEvaluation]] = [] + + for example_evaluation in chain( + successful_example_evaluations, failed_example_evaluations + ): + evaluation_repository.store_example_evaluation(example_evaluation) + all_example_evaluations.append(example_evaluation) + + example_evaluations = evaluation_repository.example_evaluations( + evaluation_id, DummyEvaluation + ) + + assert list(example_evaluations) == sorted( + all_example_evaluations, + key=lambda example_evaluation: example_evaluation.example_id, + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_successful_example_evaluations_returns_all_successful_example_evaluations( + repository_fixture: str, + request: FixtureRequest, + evaluation_id: str, + successful_example_evaluations: Iterable[ExampleEvaluation[DummyEvaluation]], + failed_example_evaluations: Iterable[ExampleEvaluation[DummyEvaluation]], +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + + for example_evaluation in chain( + successful_example_evaluations, failed_example_evaluations + ): + evaluation_repository.store_example_evaluation(example_evaluation) + + example_evaluations = evaluation_repository.successful_example_evaluations( + evaluation_id, DummyEvaluation + ) + + assert list(example_evaluations) == sorted( + successful_example_evaluations, + key=lambda example_evaluation: example_evaluation.example_id, + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_file_repository_can_fetch_failed_examples_from_evaluation_run( + repository_fixture: str, + request: FixtureRequest, + evaluation_id: str, + successful_example_evaluations: Iterable[ExampleEvaluation[DummyEvaluation]], + failed_example_evaluations: Iterable[ExampleEvaluation[DummyEvaluation]], +) -> None: + evaluation_repository = request.getfixturevalue(repository_fixture) + for example_evaluation in chain( + successful_example_evaluations, failed_example_evaluations + ): + evaluation_repository.store_example_evaluation(example_evaluation) + example_evaluations = evaluation_repository.failed_example_evaluations( + evaluation_id, DummyEvaluation + ) + + assert list(example_evaluations) == sorted( + failed_example_evaluations, + key=lambda example_evaluation: example_evaluation.example_id, + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_store_evaluation_overview_stores_and_returns_given_evaluation_overview( + repository_fixture: str, + request: FixtureRequest, + evaluation_overview: EvaluationOverview, +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + + evaluation_repository.store_evaluation_overview(evaluation_overview) + retrieved_evaluation_overview = evaluation_repository.evaluation_overview( + evaluation_overview.id + ) + + assert retrieved_evaluation_overview == evaluation_overview + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_evaluation_overview_returns_none_for_a_not_existing_overview_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + + evaluation_overview = evaluation_repository.evaluation_overview( + "not-existing-evaluation-id" + ) + + assert evaluation_overview is None + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_evaluation_overviews_returns_all_evaluation_overviews( + repository_fixture: str, + request: FixtureRequest, + run_overview: RunOverview, + evaluation_overviews: Iterable[EvaluationOverview], +) -> None: + evaluation_repository: EvaluationRepository = request.getfixturevalue( + repository_fixture + ) + for evaluation_overview in evaluation_overviews: + evaluation_repository.store_evaluation_overview(evaluation_overview) + + stored_evaluation_overviews = list(evaluation_repository.evaluation_overviews()) + + assert stored_evaluation_overviews == sorted( + evaluation_overviews, key=lambda eval_overview: eval_overview.id + ) diff --git a/tests/evaluation/test_evaluator.py b/tests/evaluation/test_evaluator.py new file mode 100644 index 000000000..c0e0d0274 --- /dev/null +++ b/tests/evaluation/test_evaluator.py @@ -0,0 +1,561 @@ +from typing import Generic, Iterable, Optional, TypeVar + +import pytest +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.core import ( + InMemoryTaskSpan, + InMemoryTracer, + Input, + NoOpTracer, + Output, + Task, + Tracer, +) +from intelligence_layer.evaluation import ( + AggregatedEvaluation, + AggregationLogic, + Aggregator, + Evaluation, + EvaluationLogic, + Evaluator, + Example, + ExpectedOutput, + FailedExampleEvaluation, + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, + MeanAccumulator, + Runner, + SingleOutputEvaluationLogic, + SuccessfulExampleOutput, +) +from tests.evaluation.conftest import ( + FAIL_IN_EVAL_INPUT, + FAIL_IN_TASK_INPUT, + DummyAggregatedEvaluationWithResultList, + DummyEvaluation, +) + + +class DummyAggregationLogic( + AggregationLogic[DummyEvaluation, DummyAggregatedEvaluationWithResultList] +): + def aggregate( + self, evaluations: Iterable[DummyEvaluation] + ) -> DummyAggregatedEvaluationWithResultList: + return DummyAggregatedEvaluationWithResultList(results=list(evaluations)) + + +class DummyEvaluationLogic( + SingleOutputEvaluationLogic[ + str, + str, + None, + DummyEvaluation, + ] +): + def do_evaluate_single_output( + self, + example: Example[str, None], + output: str, + ) -> DummyEvaluation: + if output == FAIL_IN_EVAL_INPUT: + raise RuntimeError(output) + return DummyEvaluation(result="pass") + + +class ComparisonEvaluation(BaseModel): + is_equal: bool + + +class ComparisonAggregation(BaseModel): + equal_ratio: float + + +class ComparingAggregationLogic( + AggregationLogic[ + ComparisonEvaluation, + ComparisonAggregation, + ] +): + def aggregate( + self, evaluations: Iterable[ComparisonEvaluation] + ) -> ComparisonAggregation: + acc = MeanAccumulator() + for evaluation in evaluations: + acc.add(1.0 if evaluation.is_equal else 0.0) + return ComparisonAggregation(equal_ratio=acc.extract()) + + +class ComparingEvaluationLogic( + EvaluationLogic[ + str, + str, + None, + ComparisonEvaluation, + ] +): + def do_evaluate( + self, example: Example[str, None], *output: SuccessfulExampleOutput[str] + ) -> ComparisonEvaluation: + unwrapped_output = [o.output for o in output] + return ComparisonEvaluation( + is_equal=unwrapped_output[1:] == unwrapped_output[:-1] + ) + + +class DummyEvaluatorWithoutTypeHints(DummyEvaluationLogic): + def do_evaluate( # type: ignore + self, example: Example[str, None], *output: SuccessfulExampleOutput[str] + ) -> DummyEvaluation: + return super().do_evaluate(example, *output) + + +class DummyTaskWithoutTypeHints(Task[str, str]): + # type hint for return value missing on purpose for testing + def do_run(self, input: str, tracer: Tracer): # type: ignore + return input + + +@fixture +def sequence_good_examples() -> Iterable[Example[str, None]]: + return [ + Example(input="success", expected_output=None), + Example(input="success", expected_output=None), + Example(input=FAIL_IN_EVAL_INPUT, expected_output=None), + ] + + +@fixture +def dummy_eval_logic() -> DummyEvaluationLogic: + return DummyEvaluationLogic() + + +@fixture +def dummy_aggregate_logic() -> DummyAggregationLogic: + return DummyAggregationLogic() + + +@fixture +def dummy_evaluator( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + dummy_eval_logic: DummyEvaluationLogic, +) -> Evaluator[str, str, None, DummyEvaluation]: + return Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "dummy-evaluator", + dummy_eval_logic, + ) + + +@fixture +def dummy_aggregator( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + dummy_eval_logic: DummyEvaluationLogic, + dummy_aggregate_logic: DummyAggregationLogic, +) -> Aggregator[DummyEvaluation, DummyAggregatedEvaluationWithResultList]: + return Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "dummy-evaluator", + dummy_aggregate_logic, + ) + + +@fixture +def dataset_id( + sequence_examples: Iterable[Example[str, None]], + in_memory_dataset_repository: InMemoryDatasetRepository, +) -> str: + return in_memory_dataset_repository.create_dataset( + examples=sequence_examples, dataset_name="test-dataset" + ).id + + +@fixture +def good_dataset_id( + sequence_good_examples: Iterable[Example[str, None]], + in_memory_dataset_repository: InMemoryDatasetRepository, +) -> str: + return in_memory_dataset_repository.create_dataset( + examples=sequence_good_examples, dataset_name="test-dataset" + ).id + + +@fixture +def comparing_eval_logic() -> ComparingEvaluationLogic: + return ComparingEvaluationLogic() + + +@fixture +def comparing_aggregation_logic() -> ComparingAggregationLogic: + return ComparingAggregationLogic() + + +@fixture +def comparing_evaluator( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + comparing_eval_logic: ComparingEvaluationLogic, +) -> Evaluator[str, str, None, ComparisonEvaluation]: + return Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "comparing-evaluator", + comparing_eval_logic, + ) + + +@fixture +def comparing_aggregator( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + comparing_aggregation_logic: ComparingAggregationLogic, +) -> Aggregator[ComparisonEvaluation, ComparisonAggregation]: + return Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "comparing-evaluator", + comparing_aggregation_logic, + ) + + +def test_eval_and_aggregate_runs_returns_generic_statistics( + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dummy_runner: Runner[str, str], + dataset_id: str, +) -> None: + run_overview = dummy_runner.run_dataset(dataset_id) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = dummy_aggregator.aggregate_evaluation(evaluation_overview.id) + + assert next(iter(aggregation_overview.run_overviews())).dataset_id == dataset_id + assert aggregation_overview.successful_evaluation_count == 1 + assert aggregation_overview.failed_evaluation_count == 2 + + +def test_evaluator_aborts_on_error( + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dummy_runner: Runner[str, str], + dataset_id: str, +) -> None: + run_overview = dummy_runner.run_dataset(dataset_id) + + with pytest.raises(RuntimeError): + dummy_evaluator.evaluate_runs(run_overview.id, abort_on_error=True) + + +def test_eval_and_aggregate_runs_uses_passed_tracer( + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dataset_id: str, + dummy_runner: Runner[str, str], +) -> None: + in_memory_tracer = InMemoryTracer() + run_overview = dummy_runner.run_dataset(dataset_id, in_memory_tracer) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + dummy_aggregator.aggregate_evaluation(evaluation_overview.id) + + entries = in_memory_tracer.entries + assert len(entries) == 3 + assert all([isinstance(e, InMemoryTaskSpan) for e in entries]) + + +def test_eval_and_aggregate_runs_stores_example_evaluations( + dummy_runner: Runner[str, str], + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dataset_id: str, +) -> None: + evaluation_repository = dummy_evaluator._evaluation_repository + dataset_repository = dummy_evaluator._dataset_repository + examples = list(dataset_repository.examples(dataset_id, str, type(None))) + + run_overview = dummy_runner.run_dataset(dataset_id, NoOpTracer()) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = dummy_aggregator.aggregate_evaluation(evaluation_overview.id) + assert next(iter(aggregation_overview.evaluation_overviews)) == evaluation_overview + + success_result = evaluation_repository.example_evaluation( + evaluation_overview.id, + examples[0].id, + DummyEvaluation, + ) + failure_result_task = evaluation_repository.example_evaluation( + evaluation_overview.id, + examples[1].id, + DummyEvaluation, + ) + failure_result_eval = evaluation_repository.example_evaluation( + evaluation_overview.id, + examples[2].id, + DummyEvaluation, + ) + + assert success_result and isinstance(success_result.result, DummyEvaluation) + assert failure_result_task is None + assert failure_result_eval and isinstance( + failure_result_eval.result, FailedExampleEvaluation + ) + + +def test_failed_evaluations_returns_only_failed_evaluations( + dummy_runner: Runner[str, str], + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dataset_id: str, + sequence_examples: Iterable[Example[str, None]], +) -> None: + run_overview = dummy_runner.run_dataset(dataset_id, NoOpTracer()) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + failed_evaluations = list( + dummy_evaluator.failed_evaluations(evaluation_overview.id) + ) + + assert len(failed_evaluations) == 1 + assert isinstance(failed_evaluations[0].evaluation.result, FailedExampleEvaluation) + assert failed_evaluations[0].example.id == list(sequence_examples)[-1].id + + +def test_eval_and_aggregate_runs_stores_example_traces( + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dataset_id: str, + dummy_runner: Runner[str, str], +) -> None: + run_repository = dummy_evaluator._run_repository + dataset_repository = dummy_evaluator._dataset_repository + dataset: Optional[Iterable[Example[str, None]]] = dataset_repository.examples( + dataset_id, str, type(None) + ) + assert dataset is not None + + run_overview = dummy_runner.run_dataset(dataset_id) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = dummy_aggregator.aggregate_evaluation(evaluation_overview.id) + + examples = list(dataset) + success_result = run_repository.example_trace( + aggregation_overview.run_ids[0], examples[0].id + ) + failure_result_task = run_repository.example_trace( + aggregation_overview.run_ids[0], examples[1].id + ) + failure_result_eval = run_repository.example_trace( + aggregation_overview.run_ids[0], examples[2].id + ) + + assert success_result + assert failure_result_task + assert failure_result_eval + assert success_result.trace.input == "success" + assert failure_result_task.trace.input == FAIL_IN_TASK_INPUT + assert failure_result_eval.trace.input == FAIL_IN_EVAL_INPUT + + +def test_eval_and_aggregate_runs_stores_aggregated_results( + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dummy_runner: Runner[str, str], + dataset_id: str, +) -> None: + aggregation_repository = dummy_aggregator._aggregation_repository + + run_overview = dummy_runner.run_dataset(dataset_id) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = dummy_aggregator.aggregate_evaluation(evaluation_overview.id) + loaded_evaluation_run_overview = aggregation_repository.aggregation_overview( + aggregation_overview.id, DummyAggregatedEvaluationWithResultList + ) + + assert aggregation_overview == loaded_evaluation_run_overview + + +def test_evaluate_can_evaluate_multiple_runs( + comparing_evaluator: Evaluator[str, str, None, ComparisonEvaluation], + comparing_aggregator: Aggregator[ComparisonEvaluation, ComparisonAggregation], + string_dataset_id: str, + dummy_runner: Runner[str, str], +) -> None: + run_overview1 = dummy_runner.run_dataset(string_dataset_id) + run_overview2 = dummy_runner.run_dataset(string_dataset_id) + + evaluation_overview = comparing_evaluator.evaluate_runs( + run_overview1.id, run_overview2.id + ) + aggregation_overview = comparing_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + assert aggregation_overview.statistics.equal_ratio == 1 + + +def test_aggregate_evaluation_can_aggregate_multiple_evals( + comparing_evaluator: Evaluator[str, str, None, ComparisonEvaluation], + comparing_aggregator: Aggregator[ComparisonEvaluation, ComparisonAggregation], + string_dataset_id: str, + dummy_runner: Runner[str, str], +) -> None: + run_overview_1 = dummy_runner.run_dataset(string_dataset_id) + run_overview_2 = dummy_runner.run_dataset(string_dataset_id) + + evaluation_overview_1 = comparing_evaluator.evaluate_runs(run_overview_1.id) + evaluation_overview_2 = comparing_evaluator.evaluate_runs( + run_overview_1.id, run_overview_2.id + ) + + aggregation_overview = comparing_aggregator.aggregate_evaluation( + evaluation_overview_1.id, evaluation_overview_1.id, evaluation_overview_2.id + ) + + assert len(list(aggregation_overview.run_overviews())) == 2 + assert aggregation_overview.statistics.equal_ratio == 1 + + +def test_evaluator_type_magic_works( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, +) -> None: + class EvaluationType(BaseModel): + pass + + class AggregatedEvaluationType(BaseModel): + pass + + types = { + "Input": str, + "Output": str, + "ExpectedOutput": type(None), + "Evaluation": EvaluationType, + } + + A = TypeVar("A", bound=BaseModel) + + class Mailman(Generic[A]): + pass + + class ChildEvaluationLogic(EvaluationLogic[Input, Output, None, Evaluation]): + def do_evaluate( + self, + example: Example[Input, ExpectedOutput], + *output: SuccessfulExampleOutput[Output], + ) -> Evaluation: + return None # type: ignore + + class ChildAggregationLogic(AggregationLogic[Evaluation, AggregatedEvaluation]): + def aggregate(self, evaluations: Iterable[Evaluation]) -> AggregatedEvaluation: + return None # type: ignore + + class GrandChildEvaluationLogic(ChildEvaluationLogic[Input, str, A]): + pass + + class GreatGrandChildEvaluationLogic( + Mailman[EvaluationType], GrandChildEvaluationLogic[str, EvaluationType] + ): + pass + + timmy: Evaluator[ + str, + str, + None, + EvaluationType, + ] = Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "dummy", + evaluation_logic=GreatGrandChildEvaluationLogic(), + ) + who_is_timmy = timmy._get_types() + + assert who_is_timmy == types + + +def test_aggregator_type_magic_works( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, +) -> None: + class EvaluationType(BaseModel): + pass + + class AggregatedEvaluationType(BaseModel): + pass + + types = { + "Evaluation": EvaluationType, + "AggregatedEvaluation": AggregatedEvaluationType, + } + + class ChildAggregationLogic(AggregationLogic[Evaluation, AggregatedEvaluationType]): + def aggregate( + self, evaluations: Iterable[Evaluation] + ) -> AggregatedEvaluationType: + return None # type: ignore + + class GrandChildAggregationLogic(ChildAggregationLogic[EvaluationType]): + pass + + timmy: Aggregator[EvaluationType, AggregatedEvaluationType] = Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "dummy", + aggregation_logic=GrandChildAggregationLogic(), + ) + who_is_timmy = timmy._get_types() + + assert who_is_timmy == types + + +def test_eval_and_aggregate_runs_only_runs_n_examples( + dummy_evaluator: Evaluator[str, str, None, DummyEvaluation], + dummy_aggregator: Aggregator[ + DummyEvaluation, DummyAggregatedEvaluationWithResultList + ], + dummy_runner: Runner[str, str], + good_dataset_id: str, +) -> None: + run_overview = dummy_runner.run_dataset(good_dataset_id) + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = dummy_aggregator.aggregate_evaluation(evaluation_overview.id) + + evaluation_overview = dummy_evaluator.evaluate_runs(run_overview.id, num_examples=2) + aggregation_overview_n = dummy_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + + assert ( + aggregation_overview.successful_evaluation_count + + aggregation_overview.crashed_during_evaluation_count + == 3 + ) + assert ( + aggregation_overview_n.successful_evaluation_count + + aggregation_overview_n.crashed_during_evaluation_count + == 2 + ) diff --git a/tests/evaluation/test_file_evaluation_repository.py b/tests/evaluation/test_file_evaluation_repository.py new file mode 100644 index 000000000..41144520b --- /dev/null +++ b/tests/evaluation/test_file_evaluation_repository.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel + +from intelligence_layer.evaluation.evaluation.file_evaluation_repository import ( + FileEvaluationRepository, +) + +"""Contains specific test for the FileEvaluationRepository. For more generic +tests, check the test_evaluation_repository file.""" + + +class DummyType(BaseModel): + pass + + +def test_evaluation_overview_ids_does_not_create_a_folder( + file_evaluation_repository: FileEvaluationRepository, +) -> None: + assert not file_evaluation_repository._eval_root_directory().exists() + try: + file_evaluation_repository.evaluation_overview_ids() + except FileNotFoundError: + pass + assert not file_evaluation_repository._eval_root_directory().exists() + + +def test_evaluation_overview_does_not_create_a_folder( + file_evaluation_repository: FileEvaluationRepository, +) -> None: + assert not file_evaluation_repository._eval_root_directory().exists() + assert not file_evaluation_repository._eval_directory("Non-existent").exists() + + file_evaluation_repository.evaluation_overview("Non-existent") + assert not file_evaluation_repository._eval_root_directory().exists() + + +def test_example_evaluations_does_not_create_a_folder( + file_evaluation_repository: FileEvaluationRepository, +) -> None: + assert not file_evaluation_repository._eval_root_directory().exists() + assert not file_evaluation_repository._eval_directory("Non-existent").exists() + + try: + file_evaluation_repository.example_evaluations("Non-existent", DummyType) + except ValueError: + pass + assert not file_evaluation_repository._eval_root_directory().exists() + + +def test_example_evaluation_does_not_create_a_folder( + file_evaluation_repository: FileEvaluationRepository, +) -> None: + assert not file_evaluation_repository._eval_root_directory().exists() + assert not file_evaluation_repository._eval_directory("Non-existent").exists() + + try: + file_evaluation_repository.example_evaluation( + "Non-existent", "Non-existent", DummyType + ) + except ValueError: + pass + assert not file_evaluation_repository._eval_root_directory().exists() diff --git a/tests/evaluation/test_file_run_repository.py b/tests/evaluation/test_file_run_repository.py new file mode 100644 index 000000000..417939db1 --- /dev/null +++ b/tests/evaluation/test_file_run_repository.py @@ -0,0 +1,57 @@ +from pydantic import BaseModel + +from intelligence_layer.evaluation.run.file_run_repository import FileRunRepository + +"""Contains specific test for the FileRunRepository. For more generic +tests, check the test_run_repository file.""" + + +class DummyType(BaseModel): + pass + + +def test_run_overview_ids_does_not_create_a_folder( + file_run_repository: FileRunRepository, +) -> None: + assert not file_run_repository._run_root_directory().exists() + try: + file_run_repository.run_overview_ids() + except FileNotFoundError: + pass + assert not file_run_repository._run_root_directory().exists() + + +def test_run_overview_does_not_create_a_folder( + file_run_repository: FileRunRepository, +) -> None: + assert not file_run_repository._run_root_directory().exists() + assert not file_run_repository._run_directory("Non-existent").exists() + + file_run_repository.run_overview("Non-existent") + assert not file_run_repository._run_root_directory().exists() + + +def test_example_runs_does_not_create_a_folder( + file_run_repository: FileRunRepository, +) -> None: + assert not file_run_repository._run_root_directory().exists() + assert not file_run_repository._run_directory("Non-existent").exists() + + try: + file_run_repository.example_outputs("Non-existent", DummyType) + except ValueError: + pass + assert not file_run_repository._run_root_directory().exists() + + +def test_example_run_does_not_create_a_folder( + file_run_repository: FileRunRepository, +) -> None: + assert not file_run_repository._run_root_directory().exists() + assert not file_run_repository._run_directory("Non-existent").exists() + + try: + file_run_repository.example_output("Non-existent", "Non-existent", DummyType) + except ValueError: + pass + assert not file_run_repository._run_root_directory().exists() diff --git a/tests/evaluation/test_graders.py b/tests/evaluation/test_graders.py new file mode 100644 index 000000000..0a6ef4a27 --- /dev/null +++ b/tests/evaluation/test_graders.py @@ -0,0 +1,151 @@ +import langdetect # type: ignore +from pytest import fixture + +from intelligence_layer.evaluation import HighlightCoverageGrader, LanguageMatchesGrader + + +@fixture(scope="session", autouse=True) +def set_deterministic_seed() -> None: + langdetect.DetectorFactory.seed = 0 + + +def test_language_matches_grader_correctly_detects_languages_match() -> None: + grader = LanguageMatchesGrader() + assert grader.languages_match( + "This is a test of a sentence in one language.", + "This is also a test of another sentence in the same language.", + ) + + +def test_language_matches_grader_correctly_detects_languages_dont_match() -> None: + grader = LanguageMatchesGrader() + assert not grader.languages_match( + "This is a test of a sentence in one language.", + "Dies ist ein weiterer Test eines anderen Satzes in einer anderen Sprache.", + ) + + +def test_language_matches_grader_returns_true_if_input_language_is_unclear() -> None: + grader = LanguageMatchesGrader() + assert grader.languages_match( + "This is a test of a sentence in one language. Das ist ein Test eines anderen Satzes in einer anderen Sprache.", + "", + ) + + +def test_language_matches_grader_returns_false_if_output_language_is_unclear() -> None: + grader = LanguageMatchesGrader() + assert not grader.languages_match( + "This is a test of a sentence in one language.", + "This is a test of a sentence in one language. Das ist ein Test eines anderen Satzes in einer anderen Sprache.", + ) + + +def test_language_matches_grader_can_handle_difficult_input() -> None: + grader = LanguageMatchesGrader() + assert grader.languages_match( + "1. This test is difficult. 2. And it has esp. diff. characters.", + "a) Here is a another test.\nb) How can it handle enumerations?", + ) + + +def test_language_matches_grader_empty_input_and_output() -> None: + grader = LanguageMatchesGrader() + assert grader.languages_match( + "", + "", + ) + + +def test_highlight_coverage_grader_returns_perfect_score_if_exact_match() -> None: + grader = HighlightCoverageGrader() + generated_highlights = [(3, 5)] + expected_highlights = [(3, 5)] + + assert ( + grader.compute_fscores( + generated_highlight_indices=generated_highlights, + expected_highlight_indices=expected_highlights, + ).f_score + == 1.0 + ) + + +def test_highlight_coverage_grader_returns_worst_score_if_no_expected_highlight_identified() -> ( + None +): + grader = HighlightCoverageGrader() + generated_highlights = [(0, 3)] + expected_highlights = [(3, 5)] + + assert ( + grader.compute_fscores( + generated_highlight_indices=generated_highlights, + expected_highlight_indices=expected_highlights, + ).f_score + == 0.0 + ) + + +def test_highlight_coverage_grader_returns_perfect_score_if_highlights_exactly_identified_but_split_up() -> ( + None +): + grader = HighlightCoverageGrader() + generated_highlights = [(3, 4), (4, 7)] + expected_highlights = [(3, 7)] + + assert ( + grader.compute_fscores( + generated_highlight_indices=generated_highlights, + expected_highlight_indices=expected_highlights, + ).f_score + == 1.0 + ) + + +def test_highlight_coverage_grader_returns_perfect_score_if_highlights_exactly_identified_but_merged() -> ( + None +): + grader = HighlightCoverageGrader() + generated_highlights = [(3, 6)] + expected_highlights = [(3, 5), (5, 6)] + + assert ( + grader.compute_fscores( + generated_highlight_indices=generated_highlights, + expected_highlight_indices=expected_highlights, + ).f_score + == 1.0 + ) + + +def test_highlight_coverage_grader_returns_perfect_score_if_all_but_more_highlights_identified_and_beta_equals_zero() -> ( + None +): + grader = HighlightCoverageGrader(beta_factor=0) + generated_highlights = [(3, 5)] + expected_highlights = [(3, 6)] + + assert ( + grader.compute_fscores( + generated_highlight_indices=generated_highlights, + expected_highlight_indices=expected_highlights, + ).f_score + == 1.0 + ) + + +def test_highlight_coverage_grader_returns_nonzero_nonperfect_score_with_some_false_negatives_and_some_false_positives() -> ( + None +): + grader = HighlightCoverageGrader() + generated_highlights = [(3, 5)] + expected_highlights = [(4, 7)] + + assert ( + grader.compute_fscores( + generated_highlight_indices=generated_highlights, + expected_highlight_indices=expected_highlights, + ).f_score + == 0.4 + ) diff --git a/tests/evaluation/test_hugging_face_aggregation_repository.py b/tests/evaluation/test_hugging_face_aggregation_repository.py new file mode 100644 index 000000000..550c172c2 --- /dev/null +++ b/tests/evaluation/test_hugging_face_aggregation_repository.py @@ -0,0 +1,70 @@ +from typing import Iterable +from uuid import uuid4 + +import huggingface_hub # type: ignore +from _pytest.fixtures import fixture + +from intelligence_layer.core import utc_now +from intelligence_layer.evaluation import ( + AggregationOverview, + HuggingFaceAggregationRepository, +) +from tests.evaluation.conftest import DummyAggregatedEvaluation + + +@fixture +def dummy_aggregated_evaluation() -> DummyAggregatedEvaluation: + return DummyAggregatedEvaluation(score=0.5) + + +# these fixtures should only be used once and are here for readable tests +# because creating/deleting HuggingFace repositories can be rate-limited +@fixture(scope="session") +def hugging_face_aggregation_repository( + hugging_face_token: str, hugging_face_test_repository_id: str +) -> Iterable[HuggingFaceAggregationRepository]: + try: + yield HuggingFaceAggregationRepository( + hugging_face_test_repository_id, + token=hugging_face_token, + private=True, + ) + finally: + huggingface_hub.delete_repo( + repo_id=hugging_face_test_repository_id, + token=hugging_face_token, + repo_type="dataset", + missing_ok=True, + ) + + +@fixture +def aggregation_overview( + dummy_aggregated_evaluation: DummyAggregatedEvaluation, +) -> AggregationOverview[DummyAggregatedEvaluation]: + return AggregationOverview( + evaluation_overviews=frozenset([]), + id=str(uuid4()), + start=utc_now(), + end=utc_now(), + successful_evaluation_count=0, + crashed_during_evaluation_count=0, + description="", + statistics=dummy_aggregated_evaluation, + ) + + +def test_repository_operations( + hugging_face_aggregation_repository: HuggingFaceAggregationRepository, + aggregation_overview: AggregationOverview[DummyAggregatedEvaluation], +) -> None: + hugging_face_aggregation_repository.store_aggregation_overview(aggregation_overview) + overview = hugging_face_aggregation_repository.aggregation_overview( + aggregation_overview.id, DummyAggregatedEvaluation + ) + + assert ( + aggregation_overview.id + in hugging_face_aggregation_repository.aggregation_overview_ids() + ) + assert overview is not None diff --git a/tests/evaluation/test_hugging_face_dataset_repository.py b/tests/evaluation/test_hugging_face_dataset_repository.py new file mode 100644 index 000000000..e4c77131d --- /dev/null +++ b/tests/evaluation/test_hugging_face_dataset_repository.py @@ -0,0 +1,165 @@ +from pathlib import Path +from typing import Iterable, Sequence, Tuple +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fsspec import AbstractFileSystem # type: ignore +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.core.task import Input +from intelligence_layer.evaluation import Dataset, Example, HuggingFaceDatasetRepository +from intelligence_layer.evaluation.dataset.domain import ExpectedOutput + + +class HuggingFaceDatasetRepositoryTestWrapper(HuggingFaceDatasetRepository): + def __init__( + self, repository_id: str, token: str, private: bool, caching: bool = True + ) -> None: + super().__init__(repository_id, token, private, caching) + self.counter = 0 + + def examples( + self, + dataset_id: str, + input_type: type[Input], + expected_output_type: type[ExpectedOutput], + ) -> Iterable[Example[Input, ExpectedOutput]]: + self.counter += 1 + return super().examples(dataset_id, input_type, expected_output_type) + + +@fixture +def cached_mocked_hugging_face_dataset_wrapper( + temp_file_system: AbstractFileSystem, +) -> HuggingFaceDatasetRepository: + class_to_patch = "intelligence_layer.evaluation.dataset.hugging_face_dataset_repository.HuggingFaceDatasetRepository" + with patch(f"{class_to_patch}.create_repository", autospec=True), patch( + f"{class_to_patch}.delete_repository", + autospec=True, + ): + repo = HuggingFaceDatasetRepositoryTestWrapper( + repository_id="doesn't-matter", + token="non-existing-token", + private=True, + caching=True, + ) + repo._file_system = temp_file_system + return repo + + +def test_opens_files_only_once_when_reading_multiple_examples( + cached_mocked_hugging_face_dataset_wrapper: HuggingFaceDatasetRepositoryTestWrapper, +) -> None: + dataset = cached_mocked_hugging_face_dataset_wrapper.create_dataset([], "temp") + + cached_mocked_hugging_face_dataset_wrapper.example(dataset.id, "", str, str) + cached_mocked_hugging_face_dataset_wrapper.example(dataset.id, "", str, str) + + assert cached_mocked_hugging_face_dataset_wrapper.counter == 1 + + +def test_forgets_datasets_after_deleting_one( + cached_mocked_hugging_face_dataset_wrapper: HuggingFaceDatasetRepositoryTestWrapper, +) -> None: + dataset = cached_mocked_hugging_face_dataset_wrapper.create_dataset([], "temp") + + cached_mocked_hugging_face_dataset_wrapper.example(dataset.id, "", str, str) + cached_mocked_hugging_face_dataset_wrapper.delete_dataset(dataset.id) + + with pytest.raises(ValueError): + cached_mocked_hugging_face_dataset_wrapper.examples(dataset.id, str, str) + + +class DummyAggregatedEvaluation(BaseModel): + score: float + + +# these fixtures should only be used once and are here for readable tests +# because creating/deleting HuggingFace repositories can be rate-limited +@fixture(scope="session") +def hugging_face_dataset_repository( + hugging_face_test_repository_id: str, hugging_face_token: str +) -> Iterable[HuggingFaceDatasetRepository]: + repo = HuggingFaceDatasetRepository( + repository_id=hugging_face_test_repository_id, + token=hugging_face_token, + private=True, + ) + try: + yield repo + finally: + repo.delete_repository() + + +@fixture(scope="session") +def hugging_face_repository_with_dataset_and_examples( + hugging_face_dataset_repository: HuggingFaceDatasetRepository, +) -> Iterable[ + Tuple[HuggingFaceDatasetRepository, Dataset, Sequence[Example[str, str]]] +]: + examples = [ + Example(input="hey", expected_output="hey hey"), + Example(input="hi", expected_output="hi hi"), + ] + id = str(uuid4()) + try: + dataset = hugging_face_dataset_repository.create_dataset( + examples=examples, dataset_name="test-hg-dataset", id=id + ) + except Exception as e: + hugging_face_dataset_repository.delete_dataset(id) + raise e + + try: + yield hugging_face_dataset_repository, dataset, examples + finally: + hugging_face_dataset_repository.delete_dataset(id) + + +def test_hugging_face_repository_supports_all_operations_for_created_dataset( + hugging_face_repository_with_dataset_and_examples: Tuple[ + HuggingFaceDatasetRepository, Dataset, Sequence[Example[str, str]] + ], +) -> None: + (hugging_face_repository, dataset, examples) = ( + hugging_face_repository_with_dataset_and_examples + ) + + # created dataset is stored + stored_dataset = hugging_face_repository.dataset(dataset.id) + assert stored_dataset == dataset + # datasets are stored in a subdirectory on huggingface + path_to_file = Path( + f"datasets/{hugging_face_repository._repository_id}/datasets/{dataset.id}/{dataset.id}.json" + ) + assert hugging_face_repository.exists(path_to_file) + + stored_dataset_ids = list(hugging_face_repository.dataset_ids()) + # non-dataset files are not retrieved as datasets + assert ".gitattributes" not in stored_dataset_ids + assert "README.md" not in stored_dataset_ids + # created dataset is included + assert dataset.id in stored_dataset_ids + + # given examples are stored and can be accessed via their ID + assert list(hugging_face_repository.examples(dataset.id, str, str)) == sorted( + examples, key=lambda e: e.id + ) + for example in examples: + assert ( + hugging_face_repository.example(dataset.id, example.id, str, str) == example + ) + + # one gets None for a not existing example ID + assert ( + hugging_face_repository.example(dataset.id, "not-existing-example-id", str, str) + is None + ) + + # deleting an existing dataset works + hugging_face_repository.delete_dataset(dataset.id) + with pytest.raises(ValueError): + hugging_face_repository.examples(dataset.id, str, str) + assert hugging_face_repository.dataset(dataset.id) is None diff --git a/tests/evaluation/test_hugging_face_repository.py b/tests/evaluation/test_hugging_face_repository.py new file mode 100644 index 000000000..3c7e9b6bc --- /dev/null +++ b/tests/evaluation/test_hugging_face_repository.py @@ -0,0 +1,45 @@ +from uuid import uuid4 + +import huggingface_hub # type: ignore + +from intelligence_layer.evaluation.infrastructure.hugging_face_repository import ( + HuggingFaceRepository, +) + + +def test_hugging_face_repository_can_create_and_delete_a_repository( + hugging_face_token: str, +) -> None: + repository_id = f"Aleph-Alpha/test-{uuid4()}" + + assert not huggingface_hub.repo_exists( + repo_id=repository_id, + token=hugging_face_token, + repo_type="dataset", + ), f"This is very unlikely but it seems that the repository with the ID {repository_id} already exists." + + created_repository = HuggingFaceRepository( + repository_id=repository_id, + token=hugging_face_token, + private=True, + ) + + try: + assert huggingface_hub.repo_exists( + repo_id=repository_id, + token=hugging_face_token, + repo_type="dataset", + ) + created_repository.delete_repository() + assert not huggingface_hub.repo_exists( + repo_id=repository_id, + token=hugging_face_token, + repo_type="dataset", + ) + finally: + huggingface_hub.delete_repo( + repo_id=repository_id, + token=hugging_face_token, + repo_type="dataset", + missing_ok=True, + ) diff --git a/tests/evaluation/test_instruct_comparison_argilla_evaluator.py b/tests/evaluation/test_instruct_comparison_argilla_evaluator.py new file mode 100644 index 000000000..15648a200 --- /dev/null +++ b/tests/evaluation/test_instruct_comparison_argilla_evaluator.py @@ -0,0 +1,286 @@ +from collections import defaultdict +from itertools import combinations +from typing import Iterable, Sequence +from uuid import uuid4 + +from aleph_alpha_client import CompletionResponse +from aleph_alpha_client.completion import CompletionResult +from faker import Faker +from pytest import fixture + +from intelligence_layer.connectors.argilla.argilla_client import ( + ArgillaClient, + ArgillaEvaluation, + Field, + Question, + RecordData, +) +from intelligence_layer.core import CompleteOutput, InstructInput, utc_now +from intelligence_layer.evaluation import ( + AggregatedInstructComparison, + ArgillaAggregator, + ArgillaEvaluationRepository, + ArgillaEvaluator, + EloCalculator, + Example, + ExampleOutput, + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, + InstructComparisonArgillaAggregationLogic, + MatchOutcome, + RunOverview, +) +from intelligence_layer.evaluation.evaluation.argilla_evaluator import ( + create_instruct_comparison_argilla_evaluation_classes, +) + + +class ArgillaFake(ArgillaClient): + def __init__(self) -> None: + self.records: dict[str, list[RecordData]] = defaultdict(list) + + def ensure_dataset_exists( + self, + workspace_id: str, + dataset_name: str, + fields: Sequence[Field], + questions: Sequence[Question], + ) -> str: + return str(uuid4()) + + def add_record(self, dataset_id: str, record: RecordData) -> None: + self.records[dataset_id].append(record) + + def evaluations(self, dataset_id: str) -> Iterable[ArgillaEvaluation]: + return [ + ArgillaEvaluation( + example_id=r.example_id, + record_id=str(uuid4()), + responses={ + "winner": ( + 1 if int(r.metadata["first"]) < int(r.metadata["second"]) else 2 + ) + }, + metadata=r.metadata, + ) + for r in self.records[dataset_id] + ] + + def record_data(self, dataset_id: str) -> Sequence[RecordData]: + return self.records.get(dataset_id, []) + + def split_dataset(self, dataset_id: str, n_splits: int) -> None: + raise NotImplementedError + + +@fixture +def argilla_fake() -> ArgillaClient: + return ArgillaFake() + + +@fixture +def argilla_repository( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + argilla_fake: ArgillaClient, +) -> ArgillaEvaluationRepository: + ( + evaluation_logic, + evaluation_repository, + ) = create_instruct_comparison_argilla_evaluation_classes( + "workspace", in_memory_evaluation_repository, argilla_fake, None + ) + return evaluation_repository + + +@fixture +def evaluator( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + argilla_fake: ArgillaClient, +) -> ArgillaEvaluator[InstructInput, CompleteOutput, None]: + ( + evaluation_logic, + evaluation_repository, + ) = create_instruct_comparison_argilla_evaluation_classes( + "workspace", in_memory_evaluation_repository, argilla_fake, None + ) + return ArgillaEvaluator( + in_memory_dataset_repository, + in_memory_run_repository, + evaluation_repository, + "instruct-evaluator", + evaluation_logic, + ) + + +@fixture +def aggregator( + argilla_repository: ArgillaEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + argilla_aggregation_logic: InstructComparisonArgillaAggregationLogic, +) -> ArgillaAggregator[AggregatedInstructComparison]: + return ArgillaAggregator( + argilla_repository, + in_memory_aggregation_repository, + "instruct-evaluator", + argilla_aggregation_logic, + ) + + +@fixture +def any_instruct_output() -> CompleteOutput: + faker = Faker() + return CompleteOutput.from_completion_response( + CompletionResponse( + model_version="", + completions=[CompletionResult(completion=faker.text())], + num_tokens_generated=0, + num_tokens_prompt_total=0, + ), + ) + + +def create_dummy_dataset( + in_memory_dataset_repository: InMemoryDatasetRepository, +) -> str: + example_id = "example_id" + instruction = "inst" + instruction_input = "some text" + + return in_memory_dataset_repository.create_dataset( + examples=[ + Example( + id=example_id, + input=InstructInput(instruction=instruction, input=instruction_input), + expected_output=None, + ) + ], + dataset_name="test-dataset", + ).id + + +def create_dummy_runs( + in_memory_run_repository: InMemoryRunRepository, + any_instruct_output: CompleteOutput, + run_ids: Sequence[str], + dataset_id: str, +) -> None: + for run_id in run_ids: + in_memory_run_repository.store_example_output( + example_output=ExampleOutput( + run_id=run_id, example_id="example_id", output=any_instruct_output + ) + ) + in_memory_run_repository.store_run_overview( + RunOverview( + dataset_id=dataset_id, + id=run_id, + start=utc_now(), + end=utc_now(), + failed_example_count=0, + successful_example_count=1, + description="runner", + ) + ) + + +def test_evaluate_run_submits_pairwise_comparison_records( + evaluator: ArgillaEvaluator[InstructInput, CompleteOutput, None], + aggregator: ArgillaAggregator[AggregatedInstructComparison], + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + any_instruct_output: CompleteOutput, + argilla_fake: ArgillaFake, +) -> None: + run_count = 10 + run_ids = [f"{i}" for i in range(run_count)] + dataset_id = create_dummy_dataset(in_memory_dataset_repository) + create_dummy_runs( + in_memory_run_repository, any_instruct_output, run_ids, dataset_id + ) + + evaluation_overview = evaluator.evaluate_runs(*run_ids) + + pairs = combinations(run_ids, 2) + assert sorted( + tuple(sorted((record_data.metadata["first"], record_data.metadata["second"]))) + for record_data in argilla_fake.record_data(evaluation_overview.id) + ) == sorted(pairs) + + elo_score = aggregator.aggregate_evaluation(evaluation_overview.id) + scores = elo_score.statistics.scores + # lower id always wins, should be sorted + for i in range(run_count - 1): + assert scores[run_ids[i]].elo > scores[run_ids[i + 1]].elo + assert scores[run_ids[i]].win_rate > scores[run_ids[i + 1]].win_rate + + +def test_evaluate_run_only_evaluates_high_priority( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + any_instruct_output: CompleteOutput, + argilla_fake: ArgillaFake, +) -> None: + relevant_ids = frozenset({"1", "2"}) + eval_logic, eval_repository = create_instruct_comparison_argilla_evaluation_classes( + "workspace", in_memory_evaluation_repository, argilla_fake, relevant_ids + ) + evaluator = ArgillaEvaluator( + in_memory_dataset_repository, + in_memory_run_repository, + eval_repository, + "instruct-evaluator", + eval_logic, + ) + + run_count = 10 + run_ids = [f"{i}" for i in range(run_count)] + dataset_id = create_dummy_dataset(in_memory_dataset_repository) + + create_dummy_runs( + in_memory_run_repository, any_instruct_output, run_ids, dataset_id + ) + + evaluation_overview = evaluator.evaluate_runs(*run_ids) + + def relevant_ids_in_record(record: RecordData) -> bool: + players = [record.metadata["first"], record.metadata["second"]] + return any(id in players for id in relevant_ids) + + records = argilla_fake.record_data(evaluation_overview.id) + assert all(relevant_ids_in_record(record) for record in records) + assert len(records) == sum(run_count - (i + 1) for i in range(len(relevant_ids))) + + +def test_elo_calculating_works_as_expected() -> None: + player1 = "player1" + player2 = "player2" + matches = [ + ( + player1, + player2, + MatchOutcome.A_WINS, + ) + for _ in range(10) + ] + elo = EloCalculator([player1, player2]) + elo.calculate(matches) + + assert elo.ratings[player1] > 1500 + assert elo.ratings[player2] < 1500 + + comeback_matches = [ + ( + player1, + player2, + MatchOutcome.B_WINS, + ) + for i in range(10) + ] + elo.calculate(comeback_matches) + + assert elo.ratings[player2] > elo.ratings[player1] diff --git a/tests/evaluation/test_repository_navigator.py b/tests/evaluation/test_repository_navigator.py new file mode 100644 index 000000000..742d4882d --- /dev/null +++ b/tests/evaluation/test_repository_navigator.py @@ -0,0 +1,403 @@ +from typing import Sequence + +import pytest +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.core import Task, TaskSpan +from intelligence_layer.evaluation import ( + Dataset, + DatasetRepository, + EvaluationLogic, + EvaluationOverview, + EvaluationRepository, + Evaluator, + Example, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, + RepositoryNavigator, + Runner, + RunOverview, + RunRepository, + SuccessfulExampleOutput, + evaluation_lineages_to_pandas, + run_lineages_to_pandas, +) + + +class DummyExample(Example[str, str]): + data: str + + +class DummyTask(Task[str, str]): + def do_run(self, input: str, task_span: TaskSpan) -> str: + return f"{input} -> output" + + +class DummyEval(BaseModel): + eval: str + + +class DummyEvalLogic(EvaluationLogic[str, str, str, DummyEval]): + def do_evaluate( + self, example: Example[str, str], *output: SuccessfulExampleOutput[str] + ) -> DummyEval: + output_str = ", ".join(o.output for o in output) + return DummyEval( + eval=f"{example.input}, {example.expected_output}, {output_str} -> evaluation" + ) + + +@fixture +def dataset_repository() -> DatasetRepository: + return InMemoryDatasetRepository() + + +@fixture +def examples() -> Sequence[DummyExample]: + return [ + DummyExample(input="input0", expected_output="expected_output0", data="data0"), + DummyExample(input="input1", expected_output="expected_output1", data="data1"), + ] + + +@fixture +def dataset( + dataset_repository: DatasetRepository, examples: Sequence[DummyExample] +) -> Dataset: + return dataset_repository.create_dataset( + examples, + "test", + ) + + +@fixture +def run_repository() -> RunRepository: + return InMemoryRunRepository() + + +@fixture +def runner( + dataset_repository: DatasetRepository, run_repository: RunRepository +) -> Runner[str, str]: + return Runner(DummyTask(), dataset_repository, run_repository, "Runner") + + +@fixture +def run_overview( + runner: Runner[str, str], + dataset: Dataset, +) -> RunOverview: + return runner.run_dataset(dataset.id) + + +@fixture +def additional_run_overview( + dataset_repository: DatasetRepository, + run_repository: RunRepository, + dataset: Dataset, +) -> RunOverview: + return Runner( + DummyTask(), dataset_repository, run_repository, "Runner2" + ).run_dataset(dataset.id) + + +@fixture +def evaluation_repository() -> EvaluationRepository: + return InMemoryEvaluationRepository() + + +@fixture +def evaluator( + dataset_repository: DatasetRepository, + run_repository: RunRepository, + evaluation_repository: EvaluationRepository, +) -> Evaluator[str, str, str, DummyEval]: + return Evaluator( + dataset_repository, + run_repository, + evaluation_repository, + "Evaluator", + DummyEvalLogic(), + ) + + +@fixture +def evaluation_overview( + evaluator: Evaluator[str, str, str, DummyEval], + run_overview: RunOverview, + additional_run_overview: RunOverview, +) -> EvaluationOverview: + return evaluator.evaluate_runs(run_overview.id, additional_run_overview.id) + + +@fixture +def repository_navigator( + dataset_repository: DatasetRepository, + run_repository: RunRepository, + evaluation_repository: EvaluationRepository, +) -> RepositoryNavigator: + return RepositoryNavigator( + dataset_repository, run_repository, evaluation_repository + ) + + +def test_works_on_run_overviews( + repository_navigator: RepositoryNavigator, + run_overview: RunOverview, +) -> None: + # when + res = list(repository_navigator.run_lineages(run_overview.id, str, str, str)) + + # then + res = sorted(res, key=lambda result: result.example.input) + for i in range(2): + assert res[i].example.input == f"input{i}" + assert res[i].example.expected_output == f"expected_output{i}" + assert res[i].output.output == f"input{i} -> output" + + +def test_works_run_lineages_work_with_runner( + runner: Runner[str, str], + run_overview: RunOverview, +) -> None: + # when + res = runner.run_lineages(run_overview.id, str) + + # then + res = sorted(res, key=lambda result: result.example.input) + for i in range(2): + assert res[i].example.input == f"input{i}" + assert res[i].example.expected_output == f"expected_output{i}" + assert res[i].output.output == f"input{i} -> output" + + +def test_works_on_evaluation( + repository_navigator: RepositoryNavigator, + evaluation_overview: EvaluationOverview, +) -> None: + # when + res = list( + repository_navigator.evaluation_lineages( + evaluation_overview.id, str, str, str, DummyEval + ) + ) + + # then + res = sorted(res, key=lambda result: result.example.input) + for i in range(2): + assert res[i].example.input == f"input{i}" + assert res[i].example.expected_output == f"expected_output{i}" + assert len(res[i].outputs) == 2 + assert res[i].outputs[0].output == f"input{i} -> output" + eval_result = res[i].evaluation.result + assert isinstance(eval_result, DummyEval) + assert eval_result.eval.startswith(f"input{i}") + + +def test_works_evaluation_lineages_work_with_evaluator( + evaluator: Evaluator[str, str, str, DummyEval], + evaluation_overview: EvaluationOverview, +) -> None: + # when + res = list(evaluator.evaluation_lineages(evaluation_overview.id)) + + # then + res = sorted(res, key=lambda result: result.example.input) + for i in range(2): + assert res[i].example.input == f"input{i}" + assert res[i].example.expected_output == f"expected_output{i}" + assert len(res[i].outputs) == 2 + assert res[i].outputs[0].output == f"input{i} -> output" + eval_result = res[i].evaluation.result + assert isinstance(eval_result, DummyEval) + assert eval_result.eval.startswith(f"input{i}") + + +def test_initialization_gives_warning_if_not_compatible() -> None: + dataset_repository = InMemoryDatasetRepository() + run_repository = InMemoryRunRepository() + + x = RepositoryNavigator(dataset_repository, run_repository) + with pytest.raises(ValueError): + list(x.evaluation_lineages("irrelevant", str, str, str, DummyEval)) + with pytest.raises(ValueError): + x.evaluation_lineage("irrelevant", "irrelevant", str, str, str, DummyEval) + + +def test_get_run_lineage_for_single_example( + examples: Sequence[DummyExample], + repository_navigator: RepositoryNavigator, + run_overview: RunOverview, +) -> None: + # when + res = repository_navigator.run_lineage( + run_overview.id, examples[0].id, str, str, str + ) + + # Then + assert res is not None + assert res.example.input == "input0" + assert res.output.output == "input0 -> output" + + +def test_get_run_lineage_for_single_example_works_with_runner( + examples: Sequence[DummyExample], + runner: Runner[str, str], + run_overview: RunOverview, +) -> None: + # when + res = runner.run_lineage(run_overview.id, examples[0].id, str) + + # Then + assert res is not None + assert res.example.input == "input0" + assert res.output.output == "input0 -> output" + + +def test_get_eval_lineage_for_single_example( + examples: Sequence[DummyExample], + repository_navigator: RepositoryNavigator, + evaluation_overview: EvaluationOverview, +) -> None: + # when + res = repository_navigator.evaluation_lineage( + evaluation_overview.id, examples[0].id, str, str, str, DummyEval + ) + + # Then + assert res is not None + assert res.example.input == "input0" + assert res.outputs[0].output == "input0 -> output" + assert len(res.outputs) == 2 + eval_result = res.evaluation.result + assert isinstance(eval_result, DummyEval) + assert eval_result.eval.startswith("input0") + + +def test_get_eval_lineage_for_single_example_works_with_evaluator( + examples: Sequence[DummyExample], + evaluator: Evaluator[str, str, str, DummyEval], + evaluation_overview: EvaluationOverview, +) -> None: + # when + res = evaluator.evaluation_lineage(evaluation_overview.id, examples[0].id) + + # Then + assert res is not None + assert res.example.input == "input0" + assert res.outputs[0].output == "input0 -> output" + assert len(res.outputs) == 2 + eval_result = res.evaluation.result + assert isinstance(eval_result, DummyEval) + assert eval_result.eval.startswith("input0") + + +def test_get_run_lineage_for_non_existent_example_returns_none( + repository_navigator: RepositoryNavigator, + run_overview: RunOverview, +) -> None: + res = repository_navigator.run_lineage( + run_overview.id, "non-existent-id", str, str, str + ) + + assert res is None + + +def test_get_eval_lineage_for_non_existent_example_returns_none( + repository_navigator: RepositoryNavigator, + evaluation_overview: EvaluationOverview, +) -> None: + res = repository_navigator.evaluation_lineage( + evaluation_overview.id, "non-existent-id", str, str, str, DummyEval + ) + + assert res is None + + +def test_get_run_lineage_for_non_existent_run_id_returns_none( + repository_navigator: RepositoryNavigator, +) -> None: + with pytest.raises(ValueError): + repository_navigator.run_lineage("non-existent-id", "irrelevant", str, str, str) + + +def test_get_eval_lineage_for_non_existent_eval_id_returns_none( + repository_navigator: RepositoryNavigator, +) -> None: + with pytest.raises(ValueError): + repository_navigator.evaluation_lineage( + "non-existent-id", "irrelevant", str, str, str, DummyEval + ) + + +def test_smoke_run_lineage_tree_view( + repository_navigator: RepositoryNavigator, + run_overview: RunOverview, +) -> None: + for lineage in repository_navigator.run_lineages(run_overview.id, str, str, str): + lineage._rich_render() + + +def test_smoke_evaluation_lineage_tree_view( + repository_navigator: RepositoryNavigator, + evaluation_overview: EvaluationOverview, +) -> None: + for lineage in repository_navigator.evaluation_lineages( + evaluation_overview.id, str, str, str, DummyEval + ): + lineage._rich_render() + + +def test_run_lineages_to_pandas( + repository_navigator: RepositoryNavigator, + run_overview: RunOverview, +) -> None: + # Given + lineages = list(repository_navigator.run_lineages(run_overview.id, str, str, str)) + lineages.sort(key=lambda lineage: (lineage.example.id, lineage.output.run_id)) + + # When + df = run_lineages_to_pandas(lineages).reset_index() + # df.sort_index(inplace=True) + + # Then + assert [lineage.example.id for lineage in lineages] == df["example_id"].to_list() + assert [lineage.output.run_id for lineage in lineages] == df["run_id"].to_list() + assert [lineage.example.input for lineage in lineages] == df["input"].to_list() + assert [lineage.example.expected_output for lineage in lineages] == df[ + "expected_output" + ].to_list() + assert [lineage.output.output for lineage in lineages] == df["output"].to_list() + assert lineages == df["lineage"].to_list() + + +def test_evaluation_lineages_to_pandas( + repository_navigator: RepositoryNavigator, + evaluation_overview: EvaluationOverview, +) -> None: + # Given + lineages = list( + repository_navigator.evaluation_lineages( + evaluation_overview.id, str, str, str, DummyEval + ) + ) + + # When + df = evaluation_lineages_to_pandas(lineages) + + # Then + count = 0 + for lineage in lineages: + for output in lineage.outputs: + row = df.loc[ + lineage.example.id, lineage.evaluation.evaluation_id, output.run_id # type: ignore + ] + assert lineage.example.input == row.input + assert lineage.example.expected_output == row.expected_output + assert output.output == row.output + assert lineage == row.lineage + count += 1 + + assert count == len(df) diff --git a/tests/evaluation/test_run.py b/tests/evaluation/test_run.py new file mode 100644 index 000000000..15df23124 --- /dev/null +++ b/tests/evaluation/test_run.py @@ -0,0 +1,129 @@ +from pathlib import Path +from typing import Iterable, Sequence + +from dotenv import load_dotenv +from pydantic import BaseModel +from pytest import fixture + +from intelligence_layer.connectors import AlephAlphaClientProtocol +from intelligence_layer.core import Task, TaskSpan +from intelligence_layer.evaluation import ( + AggregationLogic, + EvaluationLogic, + Example, + FileAggregationRepository, + FileDatasetRepository, + SuccessfulExampleOutput, +) +from intelligence_layer.evaluation.run_evaluation import main + +load_dotenv() + + +@fixture +def examples() -> Sequence[Example[None, None]]: + return [Example(input=None, expected_output=None)] + + +class DummyEvaluation(BaseModel): + correct: bool + + +class DummyAggregation(BaseModel): + correct_rate: float + + +class DummyTask(Task[None, None]): + def __init__(self) -> None: + pass + + def do_run(self, input: None, task_span: TaskSpan) -> None: + return input + + +class DummyTaskWithClient(DummyTask): + def __init__(self, client: AlephAlphaClientProtocol) -> None: + pass + + +class DummyAggregationLogic(AggregationLogic[DummyEvaluation, DummyAggregation]): + def aggregate(self, evaluations: Iterable[DummyEvaluation]) -> DummyAggregation: + list(evaluations) + return DummyAggregation(correct_rate=1.0) + + +class DummyEvaluationLogic(EvaluationLogic[None, None, None, DummyEvaluation]): + def do_evaluate( + self, example: Example[None, None], *output: SuccessfulExampleOutput[None] + ) -> DummyEvaluation: + return DummyEvaluation(correct=True) + + +def test_run_evaluation( + tmp_path: Path, examples: Sequence[Example[None, None]] +) -> None: + dataset_path = tmp_path / "dataset" + dataset_repository = FileDatasetRepository(dataset_path) + dataset_id = dataset_repository.create_dataset( + examples=examples, dataset_name="test-dataset" + ).id + + aggregation_path = tmp_path / "eval" + aggregation_repository = FileAggregationRepository(aggregation_path) + + main( + [ + "", + "--eval-logic", + "tests.evaluation.test_run.DummyEvaluationLogic", + "--aggregation-logic", + "tests.evaluation.test_run.DummyAggregationLogic", + "--task", + "tests.evaluation.test_run.DummyTask", + "--dataset-repository-path", + str(dataset_path), + "--dataset-id", + dataset_id, + "--target-dir", + str(aggregation_path), + "--description", + "dummy-evaluator", + ] + ) + ids = aggregation_repository.aggregation_overview_ids() + assert len(ids) == 1 + overview = aggregation_repository.aggregation_overview(ids[0], DummyAggregation) + assert overview + assert overview.successful_evaluation_count == 1 + + +def test_run_evaluation_with_task_with_client( + tmp_path: Path, examples: Sequence[Example[None, None]] +) -> None: + dataset_path = tmp_path / "dataset" + dataset_repository = FileDatasetRepository(dataset_path) + dataset_id = dataset_repository.create_dataset( + examples=examples, dataset_name="test-dataset" + ).id + + eval_path = tmp_path / "eval" + + main( + [ + "", + "--eval-logic", + "tests.evaluation.test_run.DummyEvaluationLogic", + "--aggregation-logic", + "tests.evaluation.test_run.DummyAggregationLogic", + "--task", + "tests.evaluation.test_run.DummyTaskWithClient", + "--dataset-repository-path", + str(dataset_path), + "--dataset-id", + dataset_id, + "--target-dir", + str(eval_path), + "--description", + "dummy-evaluator", + ] + ) diff --git a/tests/evaluation/test_run_repository.py b/tests/evaluation/test_run_repository.py new file mode 100644 index 000000000..d931f1db8 --- /dev/null +++ b/tests/evaluation/test_run_repository.py @@ -0,0 +1,314 @@ +from datetime import datetime +from typing import Iterable, Sequence, cast +from uuid import uuid4 + +import pytest +from _pytest.fixtures import FixtureRequest +from pytest import fixture, mark + +from intelligence_layer.core import ( + CompositeTracer, + InMemoryTaskSpan, + InMemoryTracer, + utc_now, +) +from intelligence_layer.evaluation import ( + ExampleOutput, + ExampleTrace, + RunOverview, + RunRepository, + TaskSpanTrace, +) +from intelligence_layer.evaluation.run.domain import FailedExampleRun +from tests.conftest import DummyStringInput + +test_repository_fixtures = [ + "file_run_repository", + "in_memory_run_repository", +] + + +@fixture +def run_overviews() -> Sequence[RunOverview]: + run_overview_ids = [str(uuid4()) for _ in range(10)] + run_overviews = [] + for run_id in run_overview_ids: + run_overview = RunOverview( + dataset_id="dataset-id", + id=run_id, + start=utc_now(), + end=utc_now(), + failed_example_count=0, + successful_example_count=1, + description="test run overview", + ) + run_overviews.append(run_overview) + return run_overviews + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_run_repository_stores_and_returns_example_output( + repository_fixture: str, + request: FixtureRequest, +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_id = "run-id" + example_id = "example-id" + example_output = ExampleOutput(run_id=run_id, example_id=example_id, output=None) + + run_repository.store_example_output(example_output) + stored_example_output = run_repository.example_output( + run_id, example_id, type(None) + ) + + assert stored_example_output == example_output + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_output_returns_none_for_not_existing_example_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_id = "run-id" + example_id = "example-id" + example_output = ExampleOutput(run_id=run_id, example_id=example_id, output=None) + run_repository.store_example_output(example_output) + + assert ( + run_repository.example_output(run_id, "not-existing-example-id", type(None)) + is None + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_example_output_returns_none_for_not_existing_run_id( + repository_fixture: str, + request: FixtureRequest, +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_id = "run-id" + example_id = "example-id" + example_output = ExampleOutput(run_id=run_id, example_id=example_id, output=None) + run_repository.store_example_output(example_output) + + with pytest.raises(ValueError): + run_repository.example_output("not-existing-run-id", example_id, type(None)) + with pytest.raises(ValueError): + run_repository.example_output( + "not-existing-run-id", "not-existing-example-id", type(None) + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_can_store_and_return_example_evaluation_tracer_and_trace( + repository_fixture: str, + request: FixtureRequest, +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_id = "run_id" + example_id = "example_id" + now = datetime.now() + in_memory_tracer = InMemoryTracer() + + tracer = run_repository.example_tracer(run_id, example_id) + CompositeTracer([tracer, in_memory_tracer]).task_span( + "task", DummyStringInput(input="input"), now + ) + example_trace = run_repository.example_trace(run_id, example_id) + + expected_trace = ExampleTrace( + run_id=run_id, + example_id=example_id, + trace=TaskSpanTrace.from_task_span( + cast(InMemoryTaskSpan, in_memory_tracer.entries[0]) + ), + ) + assert example_trace == expected_trace + + +@mark.parametrize("repository_fixture", test_repository_fixtures) +def test_run_example_output_ids_returns_all_sorted_ids( + repository_fixture: str, request: FixtureRequest, run_overview: RunOverview +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_repository.store_run_overview(run_overview) + example_ids = [str(uuid4()) for _ in range(10)] + for example_id in example_ids: + example_output = ExampleOutput( + run_id=run_overview.id, example_id=example_id, output=None + ) + run_repository.store_example_output(example_output) + + example_output_ids = run_repository.example_output_ids(run_overview.id) + + assert example_output_ids == sorted(example_ids) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_run_example_outputs_returns_sorted_run_example_outputs( + repository_fixture: str, request: FixtureRequest, run_overview: RunOverview +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_repository.store_run_overview(run_overview) + example_ids = [str(uuid4()) for _ in range(10)] + expected_example_outputs = [] + for example_id in example_ids: + example_output = ExampleOutput( + run_id=run_overview.id, example_id=example_id, output=None + ) + run_repository.store_example_output(example_output) + expected_example_outputs.append(example_output) + + example_outputs = list(run_repository.example_outputs(run_overview.id, type(None))) + + assert example_outputs == sorted( + expected_example_outputs, + key=lambda example: (example.run_id, example.example_id), + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_run_repository_stores_and_returns_a_run_overview( + repository_fixture: str, request: FixtureRequest, run_overview: RunOverview +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + + run_repository.store_run_overview(run_overview) + stored_run_overview = run_repository.run_overview(run_overview.id) + + assert stored_run_overview == run_overview + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_run_overview_returns_none_for_not_existing_run_id( + repository_fixture: str, request: FixtureRequest, run_overview: RunOverview +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + + stored_run_overview = run_repository.run_overview("not-existing-id") + + assert stored_run_overview is None + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_run_overviews_returns_all_sorted_run_overviews( + repository_fixture: str, + request: FixtureRequest, + run_overviews: Iterable[RunOverview], +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + + for run_overview in run_overviews: + run_repository.store_run_overview(run_overview) + + stored_run_overviews = list(run_repository.run_overviews()) + + assert stored_run_overviews == sorted( + run_overviews, key=lambda overview: overview.id + ) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_run_overview_ids_returns_all_sorted_ids( + repository_fixture: str, + request: FixtureRequest, + run_overviews: Iterable[RunOverview], +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_overview_ids = [run_overview.id for run_overview in run_overviews] + for run_overview in run_overviews: + run_repository.store_run_overview(run_overview) + + stored_run_overview_ids = list(run_repository.run_overview_ids()) + + assert stored_run_overview_ids == sorted(run_overview_ids) + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_failed_example_outputs_returns_only_failed_examples( + repository_fixture: str, request: FixtureRequest, run_overview: RunOverview +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_repository.store_run_overview(run_overview) + + run_repository.store_example_output( + ExampleOutput( + run_id=run_overview.id, + example_id="1", + output=FailedExampleRun(error_message="test"), + ) + ) + run_repository.store_example_output( + ExampleOutput(run_id=run_overview.id, example_id="2", output=None) + ) + + failed_outputs = list( + run_repository.failed_example_outputs( + run_id=run_overview.id, output_type=type(None) + ) + ) + + assert len(failed_outputs) == 1 + assert failed_outputs[0].example_id == "1" + + +@mark.parametrize( + "repository_fixture", + test_repository_fixtures, +) +def test_successful_example_outputs_returns_only_successful_examples( + repository_fixture: str, request: FixtureRequest, run_overview: RunOverview +) -> None: + run_repository: RunRepository = request.getfixturevalue(repository_fixture) + run_repository.store_run_overview(run_overview) + + run_repository.store_example_output( + ExampleOutput( + run_id=run_overview.id, + example_id="1", + output=FailedExampleRun(error_message="test"), + ) + ) + run_repository.store_example_output( + ExampleOutput(run_id=run_overview.id, example_id="2", output=None) + ) + + successful_outputs = list( + run_repository.successful_example_outputs( + run_id=run_overview.id, output_type=type(None) + ) + ) + + assert len(successful_outputs) == 1 + assert successful_outputs[0].example_id == "2" diff --git a/tests/evaluation/test_runner.py b/tests/evaluation/test_runner.py new file mode 100644 index 000000000..d07021b0b --- /dev/null +++ b/tests/evaluation/test_runner.py @@ -0,0 +1,85 @@ +from typing import Iterable + +import pytest + +from intelligence_layer.core import InMemoryTracer +from intelligence_layer.evaluation import ( + Example, + InMemoryDatasetRepository, + InMemoryRunRepository, + Runner, +) +from tests.evaluation.conftest import FAIL_IN_TASK_INPUT, DummyTask + + +def test_runner_runs_dataset( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + sequence_examples: Iterable[Example[str, None]], +) -> None: + examples = list(sequence_examples) + task = DummyTask() + runner = Runner( + task, in_memory_dataset_repository, in_memory_run_repository, "dummy-runner" + ) + + dataset_id = in_memory_dataset_repository.create_dataset( + examples=examples, dataset_name="test-dataset" + ).id + overview = runner.run_dataset(dataset_id) + outputs = list( + in_memory_run_repository.example_outputs( + overview.id, output_type=runner.output_type() + ) + ) + + assert set(output.example_id for output in outputs) == set( + example.id for example in examples + ) + + failed_runs = list(runner.failed_runs(overview.id, type(None))) + assert len(failed_runs) == 1 + assert failed_runs[0].example.id == examples[1].id + + +def test_runner_aborts_on_error( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + sequence_examples: Iterable[Example[str, None]], +) -> None: + task = DummyTask() + runner = Runner( + task, in_memory_dataset_repository, in_memory_run_repository, "dummy-runner" + ) + + dataset_id = in_memory_dataset_repository.create_dataset( + examples=sequence_examples, dataset_name="test-dataset" + ).id + with pytest.raises(RuntimeError): + runner.run_dataset(dataset_id, abort_on_error=True) + + +def test_runner_runs_n_examples( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, +) -> None: + task = DummyTask() + tracer = InMemoryTracer() + runner = Runner( + task, in_memory_dataset_repository, in_memory_run_repository, "dummy-runner" + ) + examples = [ + Example(input="success", expected_output=None, id="example-1"), + Example(input=FAIL_IN_TASK_INPUT, expected_output=None, id="example-2"), + ] + + dataset_id = in_memory_dataset_repository.create_dataset( + examples=examples, dataset_name="test-dataset" + ).id + overview = runner.run_dataset(dataset_id) + overview_with_tracer = runner.run_dataset(dataset_id, tracer, num_examples=1) + + assert overview.failed_example_count == 1 + assert overview.successful_example_count == 1 + assert overview_with_tracer.successful_example_count == 1 + assert overview_with_tracer.failed_example_count == 0 diff --git a/tests/evaluation/test_single_huggingface_dataset_repository.py b/tests/evaluation/test_single_huggingface_dataset_repository.py new file mode 100644 index 000000000..34170debc --- /dev/null +++ b/tests/evaluation/test_single_huggingface_dataset_repository.py @@ -0,0 +1,93 @@ +from datasets import Dataset, DatasetDict # type: ignore +from pytest import fixture + +from intelligence_layer.evaluation import ( + MultipleChoiceInput, + SingleHuggingfaceDatasetRepository, +) + + +@fixture +def mmlu_test_example() -> DatasetDict: + # this is the first example of the hails/mmlu dataset. + # The metadata is not completely correct on the DatasetDict, but enough for the current case + data = DatasetDict( + { + "test": Dataset.from_list( + [ + { + "question": "Which of the following best describes the balance the Supreme Court has struck between the establishment clause and the free-exercise clause?", + "subject": "high_school_government_and_politics", + "choices": [ + 'Freedom of speech is protected except in certain situations, such as yelling "fire" in a crowded theater.', + "Once a church has been recognized by the federal government, its tax-exempt status can never be revoked.", + "Once Congress has created an administrative agency, that agency can be dissolved only by a constitutional amendment.", + "State-sponsored prayer during school hours is prohibited, but voluntary prayer by student groups before school is allowed.", + ], + "answer": 3, + } + ] + ), + "validation": Dataset.from_list( + [ + { + "question": "Which of the following situations does NOT occur in a federal state?", + "subject": "high_school_geography", + "choices": [ + "Central government possesses a two-level system of government.", + "Central government governs country as a single unit.", + "It often possesses a written constitution.", + "Lower-level divisions have unique powers.", + ], + "answer": 1, + } + ] + ), + "dev": Dataset.from_list( + [ + { + "question": "Box a nongovernmental not-for-profit organization had the following transactions during the year: Proceeds from sale of investments $80000 Purchase of property plant and equipment $10000 Proceeds from long-term debt $100000 Loss on sale of investment $5000 What amount should be reported as net cash provided by financing activities in Box's statement of cash flows?", + "subject": "professional_accounting", + "choices": ["$70,000", "$75,000", "$80,000", "100000"], + "answer": 3, + } + ] + ), + } + ) + return data + + +def test_load_example_for_existing_dataset(mmlu_test_example: DatasetDict) -> None: + dataset = mmlu_test_example + repository = SingleHuggingfaceDatasetRepository(dataset) + + examples = repository.examples( + dataset_id="", input_type=MultipleChoiceInput, expected_output_type=str + ) + + first_example = next(iter(examples)) + assert ( + first_example.input.question + == "Which of the following best describes the balance the Supreme Court has struck between the establishment clause and the free-exercise clause?" + ) + + assert first_example.input.choices == [ + 'Freedom of speech is protected except in certain situations, such as yelling "fire" in a crowded theater.', + "Once a church has been recognized by the federal government, its tax-exempt status can never be revoked.", + "Once Congress has created an administrative agency, that agency can be dissolved only by a constitutional amendment.", + "State-sponsored prayer during school hours is prohibited, but voluntary prayer by student groups before school is allowed.", + ] + + assert first_example.expected_output == "D" + + assert first_example.id == "0" + + first_example_by_id = repository.example( + dataset_id="", + example_id=str(0), + input_type=MultipleChoiceInput, + expected_output_type=str, + ) + + assert first_example == first_example_by_id diff --git a/tests/image_example.jpg b/tests/image_example.jpg new file mode 100644 index 000000000..fdeea9eaa Binary files /dev/null and b/tests/image_example.jpg differ diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py new file mode 100644 index 000000000..2ff50ca8f --- /dev/null +++ b/tests/test_fastapi.py @@ -0,0 +1,27 @@ +"""Test prediction via api.""" + +from http import HTTPStatus + +from dotenv import load_dotenv +from fastapi.testclient import TestClient +from pytest import fixture + +from run import app + +load_dotenv() + + +@fixture +def client() -> TestClient: + """Provide fixture for api.""" + return TestClient(app) + + +def test_classify(client: TestClient) -> None: + response = client.post( + "/classify", json={"chunk": "Hello", "labels": ["yes", "no"]} + ) + assert response.status_code == HTTPStatus.OK + assert response.headers.get("content-type", "") == "application/json" + data = response.json() + assert "scores" in data diff --git a/tests/use_cases/classify/test_classify.py b/tests/use_cases/classify/test_classify.py new file mode 100644 index 000000000..b54bd7334 --- /dev/null +++ b/tests/use_cases/classify/test_classify.py @@ -0,0 +1,242 @@ +from typing import Iterable, List, Sequence + +from pytest import fixture + +from intelligence_layer.connectors import AlephAlphaClientProtocol +from intelligence_layer.core import Task, TextChunk +from intelligence_layer.evaluation import ( + Aggregator, + DatasetRepository, + Evaluator, + Example, + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + Runner, + RunRepository, +) +from intelligence_layer.examples import ( + AggregatedMultiLabelClassifyEvaluation, + ClassifyInput, + EmbeddingBasedClassify, + LabelWithExamples, + MultiLabelClassifyAggregationLogic, + MultiLabelClassifyEvaluation, + MultiLabelClassifyEvaluationLogic, + MultiLabelClassifyOutput, +) + + +@fixture +def embedding_based_classify( + client: AlephAlphaClientProtocol, +) -> EmbeddingBasedClassify: + labels_with_examples = [ + LabelWithExamples( + name="positive", + examples=[ + "I really like this.", + "Wow, your hair looks great!", + "We're so in love.", + ], + ), + LabelWithExamples( + name="negative", + examples=[ + "I really dislike this.", + "Ugh, Your hair looks horrible!", + "We're not in love anymore.", + ], + ), + LabelWithExamples( + name="finance", + examples=[ + "I have a bank meeting tomorrow.", + "My stocks gained 100% today!", + "The merger went through just fine.", + ], + ), + LabelWithExamples( + name="school", + examples=[ + "Mrs. Smith was in terrible mood today.", + "I really liked English class today", + "The next exam period is making me anxious.", + ], + ), + ] + return EmbeddingBasedClassify(labels_with_examples, client=client) + + +@fixture +def embedding_based_classify_example() -> List[Example[ClassifyInput, Sequence[str]]]: + return [ + Example( + input=ClassifyInput( + chunk=TextChunk("My university biology class really sucks."), + labels=frozenset(["positive", "negative", "finance", "school"]), + ), + expected_output=["positive", "school"], + ) + ] + + +@fixture +def embedding_based_classify_examples( + embedding_based_classify_example: List[Example[ClassifyInput, Sequence[str]]], +) -> List[Example[ClassifyInput, Sequence[str]]]: + return embedding_based_classify_example + [ + Example( + input=ClassifyInput( + chunk=TextChunk("My university banking class really sucks."), + labels=frozenset(["positive", "negative", "finance", "school"]), + ), + expected_output=["negative", "finance", "school"], + ), + Example( + input=ClassifyInput( + chunk=TextChunk("I did great on the recent exam."), + labels=frozenset(["positive", "negative", "finance", "school"]), + ), + expected_output=["positive", "school"], + ), + Example( + input=ClassifyInput( + chunk=TextChunk("Dogs are animals"), + labels=frozenset(["positive", "negative", "finance", "school"]), + ), + expected_output=[], + ), + ] + + +@fixture +def single_entry_dataset_name( + in_memory_dataset_repository: InMemoryDatasetRepository, + embedding_based_classify_example: Iterable[Example[ClassifyInput, Sequence[str]]], +) -> str: + return in_memory_dataset_repository.create_dataset( + examples=embedding_based_classify_example, dataset_name="test-dataset" + ).id + + +@fixture +def multiple_entries_dataset_name( + in_memory_dataset_repository: InMemoryDatasetRepository, + embedding_based_classify_examples: Iterable[Example[ClassifyInput, Sequence[str]]], +) -> str: + return in_memory_dataset_repository.create_dataset( + examples=embedding_based_classify_examples, dataset_name="test-dataset" + ).id + + +@fixture +def multi_label_classify_evaluation_logic() -> MultiLabelClassifyEvaluationLogic: + return MultiLabelClassifyEvaluationLogic() + + +@fixture +def multi_label_classify_aggregation_logic() -> MultiLabelClassifyAggregationLogic: + return MultiLabelClassifyAggregationLogic() + + +@fixture +def classify_evaluator( + in_memory_dataset_repository: DatasetRepository, + in_memory_run_repository: RunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + multi_label_classify_evaluation_logic: MultiLabelClassifyEvaluationLogic, +) -> Evaluator[ + ClassifyInput, + MultiLabelClassifyOutput, + Sequence[str], + MultiLabelClassifyEvaluation, +]: + return Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "multi-label-classify", + multi_label_classify_evaluation_logic, + ) + + +@fixture +def classify_aggregator( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + multi_label_classify_aggregation_logic: MultiLabelClassifyAggregationLogic, +) -> Aggregator[ + MultiLabelClassifyEvaluation, + AggregatedMultiLabelClassifyEvaluation, +]: + return Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "multi-label-classify", + multi_label_classify_aggregation_logic, + ) + + +@fixture +def classify_runner( + embedding_based_classify: Task[ClassifyInput, MultiLabelClassifyOutput], + in_memory_dataset_repository: DatasetRepository, + in_memory_run_repository: RunRepository, +) -> Runner[ClassifyInput, MultiLabelClassifyOutput]: + return Runner( + embedding_based_classify, + in_memory_dataset_repository, + in_memory_run_repository, + "multi-label-classify", + ) + + +def test_multi_label_classify_evaluator_single_example( + single_entry_dataset_name: str, + classify_evaluator: Evaluator[ + ClassifyInput, + MultiLabelClassifyOutput, + Sequence[str], + MultiLabelClassifyEvaluation, + ], + classify_runner: Runner[ClassifyInput, MultiLabelClassifyOutput], +) -> None: + run_overview = classify_runner.run_dataset(single_entry_dataset_name) + + evaluation_overview = classify_evaluator.evaluate_runs(run_overview.id) + evaluation = classify_evaluator._evaluation_repository.example_evaluations( + evaluation_overview.id, + MultiLabelClassifyEvaluation, + )[0].result + + assert isinstance(evaluation, MultiLabelClassifyEvaluation) + assert evaluation.tp == frozenset({"school"}) + assert evaluation.tn == frozenset({"finance"}) + assert evaluation.fp == frozenset({"negative"}) + assert evaluation.fn == frozenset({"positive"}) + + +def test_multi_label_classify_evaluator_full_dataset( + multiple_entries_dataset_name: str, + classify_evaluator: Evaluator[ + ClassifyInput, + MultiLabelClassifyOutput, + Sequence[str], + MultiLabelClassifyEvaluation, + ], + classify_aggregator: Aggregator[ + MultiLabelClassifyEvaluation, AggregatedMultiLabelClassifyEvaluation + ], + classify_runner: Runner[ClassifyInput, MultiLabelClassifyOutput], +) -> None: + run_overview = classify_runner.run_dataset(multiple_entries_dataset_name) + + evaluation_overview = classify_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = classify_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + + assert {"positive", "negative", "finance", "school"} == set( + aggregation_overview.statistics.class_metrics.keys() + ) diff --git a/tests/use_cases/classify/test_embedding_based_classify.py b/tests/use_cases/classify/test_embedding_based_classify.py new file mode 100644 index 000000000..97c16fe5f --- /dev/null +++ b/tests/use_cases/classify/test_embedding_based_classify.py @@ -0,0 +1,158 @@ +from typing import Sequence + +from pytest import fixture, raises +from qdrant_client.http.models import models + +from intelligence_layer.connectors.limited_concurrency_client import ( + AlephAlphaClientProtocol, +) +from intelligence_layer.connectors.retrievers.base_retriever import Document +from intelligence_layer.connectors.retrievers.qdrant_in_memory_retriever import ( + QdrantInMemoryRetriever, +) +from intelligence_layer.core import NoOpTracer +from intelligence_layer.core.chunk import TextChunk +from intelligence_layer.examples.classify.classify import ( + ClassifyInput, + MultiLabelClassifyOutput, +) +from intelligence_layer.examples.classify.embedding_based_classify import ( + EmbeddingBasedClassify, + LabelWithExamples, + QdrantSearch, + QdrantSearchInput, +) +from tests.conftest import to_document + + +@fixture +def in_memory_retriever_documents() -> Sequence[Document]: + return [ + Document( + text="Germany reunited. I kind of fit and am of the correct type.", + metadata={"type": "doc"}, + ), + Document( + text="Cats are small animals. Well, I do not fit at all and I am of the correct type.", + metadata={"type": "no doc"}, + ), + Document( + text="Germany reunited in 1990. This document fits perfectly but it is of the wrong type.", + metadata={"type": "no doc"}, + ), + ] + + +@fixture +def qdrant_search( + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, +) -> QdrantSearch: + return QdrantSearch(asymmetric_in_memory_retriever) + + +@fixture +def embedding_based_classify( + client: AlephAlphaClientProtocol, +) -> EmbeddingBasedClassify: + labels_with_examples = [ + LabelWithExamples( + name="positive", + examples=[ + "I really like this.", + "Wow, your hair looks great!", + "We're so in love.", + ], + ), + LabelWithExamples( + name="negative", + examples=[ + "I really dislike this.", + "Ugh, Your hair looks horrible!", + "We're not in love anymore.", + ], + ), + ] + return EmbeddingBasedClassify(labels_with_examples, client=client) + + +def test_qdrant_search( + qdrant_search: QdrantSearch, + no_op_tracer: NoOpTracer, + in_memory_retriever_documents: Sequence[Document], +) -> None: + search_input = QdrantSearchInput( + query="When did Germany reunite?", + filter=models.Filter( + must=[ + models.FieldCondition( + key="metadata.type", + match=models.MatchValue(value="doc"), + ), + ] + ), + ) + result = qdrant_search.run(search_input, no_op_tracer) + assert [to_document(r.document_chunk) for r in result.results] == [ + in_memory_retriever_documents[0] + ] + + +def test_embedding_based_classify_returns_score_for_all_labels( + embedding_based_classify: EmbeddingBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "negative"}), + ) + classify_output = embedding_based_classify.run(classify_input, NoOpTracer()) + + # Output contains everything we expect + assert isinstance(classify_output, MultiLabelClassifyOutput) + assert classify_input.labels == set(r for r in classify_output.scores) + + +def test_embedding_based_classify_raises_for_unknown_label( + embedding_based_classify: EmbeddingBasedClassify, +) -> None: + unknown_label = "neutral" + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "negative", unknown_label}), + ) + with raises(ValueError) as _: + embedding_based_classify.run(classify_input, NoOpTracer()) + + +def test_embedding_based_classify_works_for_empty_labels_in_request( + embedding_based_classify: EmbeddingBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset(), + ) + result = embedding_based_classify.run(classify_input, NoOpTracer()) + assert result.scores == {} + + +def test_embedding_based_classify_works_without_examples( + client: AlephAlphaClientProtocol, +) -> None: + labels_with_examples = [ + LabelWithExamples( + name="positive", + examples=[], + ), + LabelWithExamples( + name="negative", + examples=[], + ), + ] + embedding_based_classify = EmbeddingBasedClassify( + labels_with_examples, client=client + ) + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset(), + ) + result = embedding_based_classify.run(classify_input, NoOpTracer()) + assert result.scores == {} diff --git a/tests/use_cases/classify/test_keyword_extract.py b/tests/use_cases/classify/test_keyword_extract.py new file mode 100644 index 000000000..cbe87630f --- /dev/null +++ b/tests/use_cases/classify/test_keyword_extract.py @@ -0,0 +1,33 @@ +import pytest + +from intelligence_layer.core import NoOpTracer +from intelligence_layer.core.chunk import TextChunk +from intelligence_layer.core.detect_language import Language, LanguageNotSupportedError +from intelligence_layer.examples.classify.keyword_extract import ( + KeywordExtract, + KeywordExtractInput, +) + + +@pytest.fixture() +def keyword_extract() -> KeywordExtract: + return KeywordExtract() + + +def test_keyword_extract_works(keyword_extract: KeywordExtract) -> None: + input = KeywordExtractInput( + chunk=TextChunk("I really like my computer"), language=Language("en") + ) + + result = keyword_extract.run(input, NoOpTracer()) + assert "computer" in [keyword.lower() for keyword in result.keywords] + + +def test_keyword_extract_raises_for_unsupported_language( + keyword_extract: KeywordExtract, +) -> None: + input = KeywordExtractInput( + chunk=TextChunk("text about computers"), language=Language("pt") + ) + with pytest.raises(LanguageNotSupportedError) as _: + keyword_extract.run(input, NoOpTracer()) diff --git a/tests/use_cases/classify/test_prompt_based_classify.py b/tests/use_cases/classify/test_prompt_based_classify.py new file mode 100644 index 000000000..c6b5a2648 --- /dev/null +++ b/tests/use_cases/classify/test_prompt_based_classify.py @@ -0,0 +1,314 @@ +from typing import Sequence + +import pytest +from pytest import fixture + +from intelligence_layer.core import InMemoryTracer, NoOpTracer, TextChunk +from intelligence_layer.core.model import LuminousControlModel +from intelligence_layer.evaluation import ( + Aggregator, + DatasetRepository, + Evaluator, + Example, + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + Runner, + RunRepository, +) +from intelligence_layer.examples.classify.classify import ( + AggregatedSingleLabelClassifyEvaluation, + ClassifyInput, + SingleLabelClassifyAggregationLogic, + SingleLabelClassifyEvaluation, + SingleLabelClassifyEvaluationLogic, + SingleLabelClassifyOutput, +) +from intelligence_layer.examples.classify.prompt_based_classify import ( + PromptBasedClassify, +) + + +@fixture +def prompt_based_classify( + luminous_control_model: LuminousControlModel, +) -> PromptBasedClassify: + return PromptBasedClassify(luminous_control_model) + + +@fixture +def single_label_classify_eval_logic() -> SingleLabelClassifyEvaluationLogic: + return SingleLabelClassifyEvaluationLogic() + + +@fixture +def single_label_classify_aggregation_logic() -> SingleLabelClassifyAggregationLogic: + return SingleLabelClassifyAggregationLogic() + + +@fixture +def classify_evaluator( + in_memory_dataset_repository: DatasetRepository, + in_memory_run_repository: RunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + single_label_classify_eval_logic: SingleLabelClassifyEvaluationLogic, +) -> Evaluator[ + ClassifyInput, + SingleLabelClassifyOutput, + str, + SingleLabelClassifyEvaluation, +]: + return Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "single-label-classify", + single_label_classify_eval_logic, + ) + + +@fixture +def classify_aggregator( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + single_label_classify_aggregation_logic: SingleLabelClassifyAggregationLogic, +) -> Aggregator[ + SingleLabelClassifyEvaluation, + AggregatedSingleLabelClassifyEvaluation, +]: + return Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "single-label-classify", + single_label_classify_aggregation_logic, + ) + + +@fixture +def classify_runner( + prompt_based_classify: PromptBasedClassify, + in_memory_dataset_repository: DatasetRepository, + in_memory_run_repository: RunRepository, +) -> Runner[ClassifyInput, SingleLabelClassifyOutput]: + return Runner( + prompt_based_classify, + in_memory_dataset_repository, + in_memory_run_repository, + "prompt-based-classify", + ) + + +def test_prompt_based_classify_returns_score_for_all_labels( + prompt_based_classify: PromptBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "negative"}), + ) + + classify_output = prompt_based_classify.run(classify_input, NoOpTracer()) + + # Output contains everything we expect + assert isinstance(classify_output, SingleLabelClassifyOutput) + assert classify_input.labels == set(r for r in classify_output.scores) + + +def test_prompt_based_classify_accomodates_labels_starting_with_spaces( + prompt_based_classify: PromptBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), labels=frozenset({" positive", "negative"}) + ) + + tracer = InMemoryTracer() + classify_output = prompt_based_classify.run(classify_input, tracer) + + # Output contains everything we expect + assert classify_input.labels == set(r for r in classify_output.scores) + + +def test_prompt_based_classify_accomodates_labels_starting_with_different_spaces( + prompt_based_classify: PromptBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), labels=frozenset({" positive", " positive"}) + ) + + classify_output = prompt_based_classify.run(classify_input, NoOpTracer()) + + # Output contains everything we expect + assert classify_input.labels == set(r for r in classify_output.scores) + assert classify_output.scores[" positive"] != classify_output.scores[" positive"] + + +def test_prompt_based_classify_sentiment_classification( + prompt_based_classify: PromptBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), labels=frozenset({"positive", "negative"}) + ) + + classify_output = prompt_based_classify.run(classify_input, NoOpTracer()) + + # Verify we got a higher positive score + assert classify_output.scores["positive"] > classify_output.scores["negative"] + + +def test_prompt_based_classify_emotion_classification( + prompt_based_classify: PromptBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("I love my job"), + labels=frozenset({"happy", "sad", "frustrated", "angry"}), + ) + + classify_output = prompt_based_classify.run(classify_input, NoOpTracer()) + + # Verify it correctly calculated happy + assert classify_output.scores["happy"] == max(classify_output.scores.values()) + + +def test_prompt_based_classify_handles_labels_starting_with_same_token( + prompt_based_classify: PromptBasedClassify, +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "positive positive"}), + ) + + classify_output = prompt_based_classify.run(classify_input, NoOpTracer()) + + assert classify_input.labels == set(r for r in classify_output.scores) + + +def test_can_evaluate_classify( + in_memory_dataset_repository: InMemoryDatasetRepository, + classify_runner: Runner[ClassifyInput, SingleLabelClassifyOutput], + in_memory_evaluation_repository: InMemoryEvaluationRepository, + classify_evaluator: Evaluator[ + ClassifyInput, + SingleLabelClassifyOutput, + Sequence[str], + SingleLabelClassifyEvaluation, + ], + prompt_based_classify: PromptBasedClassify, +) -> None: + example = Example( + input=ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "negative"}), + ), + expected_output="positive", + ) + + dataset_id = in_memory_dataset_repository.create_dataset( + examples=[example], dataset_name="test-dataset" + ).id + + run_overview = classify_runner.run_dataset(dataset_id) + evaluation_overview = classify_evaluator.evaluate_runs(run_overview.id) + + evaluation = in_memory_evaluation_repository.example_evaluations( + evaluation_overview.id, + SingleLabelClassifyEvaluation, + )[0].result + + assert isinstance(evaluation, SingleLabelClassifyEvaluation) + assert evaluation.correct is True + + +def test_classify_warns_on_missing_label( + in_memory_dataset_repository: InMemoryDatasetRepository, + classify_runner: Runner[ClassifyInput, SingleLabelClassifyOutput], + in_memory_evaluation_repository: InMemoryEvaluationRepository, + classify_evaluator: Evaluator[ + ClassifyInput, + SingleLabelClassifyOutput, + Sequence[str], + SingleLabelClassifyEvaluation, + ], + prompt_based_classify: PromptBasedClassify, +) -> None: + example = Example( + input=ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "negative"}), + ), + expected_output="SomethingElse", + ) + + dataset_id = in_memory_dataset_repository.create_dataset( + examples=[example], dataset_name="test-dataset" + ).id + + run_overview = classify_runner.run_dataset(dataset_id) + + pytest.warns(RuntimeWarning, classify_evaluator.evaluate_runs, run_overview.id) + + +def test_can_aggregate_evaluations( + classify_evaluator: Evaluator[ + ClassifyInput, + SingleLabelClassifyOutput, + Sequence[str], + SingleLabelClassifyEvaluation, + ], + classify_aggregator: Aggregator[ + SingleLabelClassifyEvaluation, + AggregatedSingleLabelClassifyEvaluation, + ], + in_memory_dataset_repository: InMemoryDatasetRepository, + classify_runner: Runner[ClassifyInput, SingleLabelClassifyOutput], +) -> None: + positive: str = "positive" + correct_example = Example( + input=ClassifyInput( + chunk=TextChunk("This is good"), + labels=frozenset({"positive", "negative"}), + ), + expected_output=positive, + ) + incorrect_example = Example( + input=ClassifyInput( + chunk=TextChunk("This is extremely bad"), + labels=frozenset({"positive", "negative"}), + ), + expected_output=positive, + ) + dataset_id = in_memory_dataset_repository.create_dataset( + examples=[correct_example, incorrect_example], dataset_name="test-dataset" + ).id + + run_overview = classify_runner.run_dataset(dataset_id) + evaluation_overview = classify_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = classify_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + + assert aggregation_overview.statistics.percentage_correct == 0.5 + + +def test_aggregating_evaluations_works_with_empty_list( + classify_evaluator: Evaluator[ + ClassifyInput, + SingleLabelClassifyOutput, + Sequence[str], + SingleLabelClassifyEvaluation, + ], + classify_aggregator: Aggregator[ + SingleLabelClassifyEvaluation, + AggregatedSingleLabelClassifyEvaluation, + ], + classify_runner: Runner[ClassifyInput, SingleLabelClassifyOutput], + in_memory_dataset_repository: DatasetRepository, +) -> None: + dataset_id = in_memory_dataset_repository.create_dataset( + examples=[], dataset_name="test-dataset" + ).id + run_overview = classify_runner.run_dataset(dataset_id) + evaluation_overview = classify_evaluator.evaluate_runs(run_overview.id) + aggregation_overview = classify_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + + assert aggregation_overview.statistics.percentage_correct == 0 diff --git a/tests/use_cases/classify/test_prompt_based_classify_with_definitions.py b/tests/use_cases/classify/test_prompt_based_classify_with_definitions.py new file mode 100644 index 000000000..9181fe240 --- /dev/null +++ b/tests/use_cases/classify/test_prompt_based_classify_with_definitions.py @@ -0,0 +1,52 @@ +from typing import Sequence + +from pytest import fixture + +from intelligence_layer.core import LuminousControlModel, NoOpTracer, TextChunk +from intelligence_layer.examples import ( + ClassifyInput, + LabelWithDefinition, + PromptBasedClassifyWithDefinitions, + SingleLabelClassifyOutput, +) + + +@fixture +def labels_with_definitions() -> Sequence[LabelWithDefinition]: + return [ + LabelWithDefinition( + name="Dinosaur", definition="Any text that is about dinosaurs." + ), + LabelWithDefinition(name="Plant", definition="Any text that is about plants."), + LabelWithDefinition( + name="Toy", definition="Everything that has something to do with toys." + ), + ] + + +@fixture +def prompt_based_classify_with_definitions( + luminous_control_model: LuminousControlModel, + labels_with_definitions: Sequence[LabelWithDefinition], +) -> PromptBasedClassifyWithDefinitions: + return PromptBasedClassifyWithDefinitions( + labels_with_definitions, luminous_control_model + ) + + +def test_prompt_based_classify_with_definitions_returns_score_for_all_labels( + prompt_based_classify_with_definitions: PromptBasedClassifyWithDefinitions, + labels_with_definitions: Sequence[LabelWithDefinition], +) -> None: + classify_input = ClassifyInput( + chunk=TextChunk("I love my cactus!"), + labels=frozenset(label.name for label in labels_with_definitions), + ) + + classify_output = prompt_based_classify_with_definitions.run( + classify_input, NoOpTracer() + ) + + # Output contains everything we expect + assert isinstance(classify_output, SingleLabelClassifyOutput) + assert classify_input.labels == set(r for r in classify_output.scores) diff --git a/tests/use_cases/qa/conftest.py b/tests/use_cases/qa/conftest.py new file mode 100644 index 000000000..408091d2d --- /dev/null +++ b/tests/use_cases/qa/conftest.py @@ -0,0 +1,35 @@ +from typing import Sequence + +from pytest import fixture + +from intelligence_layer.connectors import Document +from intelligence_layer.core import LuminousControlModel +from intelligence_layer.examples import MultipleChunkQa, SingleChunkQa + + +@fixture +def single_chunk_qa(luminous_control_model: LuminousControlModel) -> SingleChunkQa: + return SingleChunkQa(luminous_control_model) + + +@fixture +def multiple_chunk_qa(single_chunk_qa: SingleChunkQa) -> MultipleChunkQa: + return MultipleChunkQa(single_chunk_qa) + + +@fixture +def in_memory_retriever_documents() -> Sequence[Document]: + return [ + Document(text=t) + for t in [ + "Robert Moses''' (December 18, 1888 – July 29, 1981) was an American [[urban planner]] and public official who worked in the [[New York metropolitan area]] during the early to mid 20th century. Despite never being elected to any office, Moses is regarded as one of the most powerful and influential individuals in the history of New York City and New York State. The grand scale of his infrastructural projects and his philosophy of urban development influenced a generation of engineers, architects, and urban planners across the United States.\n\nMoses held various positions throughout his more than forty-year long career. He at times held up to 12 titles simultaneously, including [[New York City Parks Commissioner]] and chairman of the [[Long Island State Park Commission]].{{Cite web|url=https://www.pbs.org/wnet/need-to-know/environment/the-legacy-of-robert-moses/16018/|title=The legacy of Robert Moses|last=Sarachan|first=Sydney|date=January 17, 2013|website=Need to Know {{!}} PBS|language=en-US|access-date=December 3, 2019}} Having worked closely with New York governor [[Al Smith]] early in his career, Moses became expert in writing laws and navigating and manipulating the inner workings of state government. He created and led numerous semi-autonomous [[Public authority|public authorities]], through which he controlled millions of dollars in revenue and directly issued [[Bond (finance)|bonds]] to fund new ventures with little outside input or oversight.", + "Moses's projects transformed the New York area and revolutionized the way cities in the U.S. were designed and built. As Long Island State Park Commissioner, Moses oversaw the construction of [[Jones Beach State Park]], the most visited public beach in the United States,{{cite news |url=http://www.longislandexchange.com/jones-beach.html |website=Long Island Exchange |title=Jones Beach |access-date=November 21, 2012 |archive-url=https://web.archive.org/web/20130121130008/http://www.longislandexchange.com/jones-beach.html |archive-date=January 21, 2013 |url-status=dead }} and was the primary architect of the [[Parkways in New York|New York State Parkway System]]. As head of the [[MTA Bridges and Tunnels|Triborough Bridge Authority]], Moses had near-complete control over bridges and tunnels in New York City as well as the tolls collected from them, and built, among others, the [[Robert F. Kennedy Bridge|Triborough Bridge]], the [[Brooklyn–Battery Tunnel]], and the [[Throgs Neck Bridge]], as well as several major highways. These roadways and bridges, alongside [[urban renewal]] efforts that saw the destruction of huge swaths of tenement housing and their replacement with large [[New York City Housing Authority|public housing projects]], transformed the physical fabric of New York and inspired other cities to undertake similar development endeavors.\n\nMoses's reputation declined following the publication of [[Robert Caro]]'s [[Pulitzer Prize]]-winning biography ''[[The Power Broker]]'' (1974), which cast doubt on the purported benefits of many of Moses's projects and further cast Moses as racist. In large part because of ''The Power Broker'', Moses is today considered a controversial figure in the history of New York City.", + "==Early life and career==", + "Moses was born in [[New Haven, Connecticut]], on December 18, 1888, to [[German Jewish]] parents, Bella (Silverman) and Emanuel Moses.{{cite news | url=https://www.nytimes.com/learning/general/onthisday/bday/1218.html | title=Robert Moses, Master Builder, is Dead at 92| newspaper=The New York Times |archive-url=https://web.archive.org/web/20160305003155/https://www.nytimes.com/learning/general/onthisday/bday/1218.html |archive-date=March 5, 2016 |url-status=dead}}{{sfn|Caro|1974|p=25}} He spent the first nine years of his life living at 83 Dwight Street in New Haven, two blocks from [[Yale University]]. In 1897, the Moses family moved to New York City,{{sfn|Caro|1974|pp=29}} where they lived on East 46th Street off Fifth Avenue.{{cite web |url=http://www.newsday.com/community/guide/lihistory/ny-history-hs722a,0,7092161.story |title=The Master Builder |access-date=April 4, 2007 |last=DeWan |first=George |year=2007 |website=Long Island History |publisher=Newsday |archive-url=https://web.archive.org/web/20061211045554/http://www.newsday.com/community/guide/lihistory/ny-history-hs722a%2C0%2C7092161.story |archive-date=December 11, 2006 |url-status=dead }} Moses's father was a successful department store owner and [[real estate]] speculator in New Haven. In order for the family to move to New York City, he sold his real estate holdings and store, then retired.{{sfn|Caro|1974|pp=29}} Moses's mother was active in the [[settlement movement]], with her own love of building.", + "Robert Moses and his brother Paul attended several schools for their elementary and [[secondary education]], including the [[Ethical Culture Fieldston School|Ethical Culture School]], the [[Dwight School]] and the [[Mohegan Lake, New York#Historic places|Mohegan Lake School]], a military academy near [[Peekskill, New York|Peekskill]].{{sfn|Caro|1974|pp=35}}\n\nAfter graduating from [[Yale College]] (B.A., 1909) and [[Wadham College]], [[Oxford University|Oxford]] (B.A., Jurisprudence, 1911; M.A., 1913), and earning a Ph.D. in [[political science]] from [[Columbia University]] in 1914, Moses became attracted to New York City reform politics.{{Cite web|url=http://c250.columbia.edu/c250_celebrates/remarkable_columbians/robert_moses.html|title = Robert Moses}} A committed [[idealism|idealist]], he developed several plans to rid New York of [[Patronage#Politics|patronage hiring]] practices, including being the lead author of a 1919 proposal to reorganize the New York state government. None went very far, but Moses, due to his intelligence, caught the notice of [[Belle Moskowitz]], a friend and trusted advisor to Governor [[Al Smith]].{{sfn|Caro|1974}} When the state [[Secretary of State of New York|Secretary of State's]] position became appointive rather than elective, Smith named Moses. He served from 1927 to 1929.{{cite news |date=December 19, 1928 |title=Moses Resigns State Position |url=http://cdsun.library.cornell.edu/cgi-bin/cornell?a=d&d=CDS19281219.2.63.7# |newspaper=Cornell Daily Sun |location=Ithaca, NY |page=8}}", + 'Moses rose to power with Smith, who was elected as governor in 1918, and then again in 1922. With Smith\'s support, Moses set in motion a sweeping consolidation of the New York State government. During that period Moses began his first foray into large-scale public work initiatives, while drawing on Smith\'s political power to enact legislation. This helped create the new [[Long Island State Park Commission]] and the State Council of Parks.{{cite web|last=Gutfreund|first=Owen|title=Moses, Robert|url=http://www.anb.org/articles/07/07-00375.html|publisher=Anb.org|access-date=December 24, 2014}} In 1924, Governor Smith appointed Moses chairman of the State Council of Parks and president of the Long Island State Park Commission.{{Cite book|title=Encyclopedia of the City|url=https://archive.org/details/encyclopediacity00cave|url-access=limited|last=Caves|first=R. W.|publisher=Routledge|year=2004|isbn=978-0-415-25225-6|pages=[https://archive.org/details/encyclopediacity00cave/page/n512 472]}} This centralization allowed Smith to run a government later used as a model for Franklin D. Roosevelt\'s [[New Deal]] federal government.{{or|date=October 2022}} Moses also received numerous commissions that he carried out efficiently, such as the development of [[Jones Beach State Park]].{{cn|date=October 2022}} Displaying a strong command of [[law]] as well as matters of [[engineering]], Moses became known for his skill in drafting legislation, and was called "the best bill drafter in [[Albany, New York|Albany]]".{{cite news |title=Annals of Power |first=Robert A. |last=Caro |author-link=Robert Caro |url=http://archives.newyorker.com/?', + "i=1974-07-22#folio=032 |magazine=[[The New Yorker]] |date=July 22, 1974 |access-date=September 1, 2011}} At a time when the public was accustomed to [[Tammany Hall]] corruption and incompetence, Moses was seen as a savior of government.{{sfn|Caro|1974}}\n\nShortly after [[President of the United States|President]] [[Franklin Delano Roosevelt|Franklin D. Roosevelt's]] [[First inauguration of Franklin D. Roosevelt|inauguration]] in 1933, the [[United States federal government|federal government]] found itself with millions of [[New Deal]] dollars to spend, yet states and cities had few projects ready. Moses was one of the few local officials who had projects [[shovel ready]]. For that reason, New York City was able to obtain significant [[Works Progress Administration]] (WPA), [[Civilian Conservation Corps]] (CCC), and other Depression-era funding. One of his most influential and longest-lasting positions was that of Parks Commissioner of New York City, a role he served from January 18, 1934, to May 23, 1960.{{Cite web|url=https://www.nycgovparks.org/about/history/commissioners|title=New York City Parks Commissioners : NYC Parks|website=www.nycgovparks.org|language=en|access-date=March 29, 2018}}", + "==Offices held==\nThe many offices and professional titles that Moses held gave him unusually broad power to shape urban development in the New York metropolitan region. These include, according to the New York Preservation Archive Project:{{Cite web|url=http://www.nypap.org/preservation-history/robert-moses/|title=Robert Moses {{!}}|website=www.nypap.org|language=en-US|access-date=March 29, 2018}}\n*[[Long Island State Park Commission]] (President, 1924–1963)\n* New York State Council of Parks (Chairman, 1924–1963)\n*[[Secretary of State of New York|New York Secretary of State]] (1927–1928)\n* Bethpage State Park Authority (President, 1933–1963)\n* Emergency Public Works Commission (Chairman, 1933–1934)\n* Jones Beach Parkway Authority (President, 1933–1963)\n*[[New York City Department of Parks and Recreation|New York City Department of Parks]] (Commissioner, 1934–1960)\n* [[Triborough Bridge]] and Tunnel Authority (Chairman, 1934–1968)\n* New York City Planning Commission (Commissioner, 1942–1960)\n* New York State Power Authority (Chairman, 1954–1962)\n* [[1964 New York World's Fair|New York's World Fair]] (President, 1960–1966)\n* Office of the Governor of New York (Special Advisor on Housing, 1974–1975)", + "==Influence==\nDuring the 1920s, Moses sparred with [[Franklin D. Roosevelt]], then head of the Taconic State Park Commission, who favored the prompt construction of a [[parkway]] through the [[Hudson Valley]]. Moses succeeded in diverting funds to his Long Island parkway projects (the [[Northern State Parkway]], the [[Southern State Parkway]] and the [[Wantagh State Parkway]]), although the [[Taconic State Parkway]] was later completed as well.{{cite web|url=http://www.nycroads.com/roads/taconic/ |title=Taconic State Parkway |website=NYCRoads.com |access-date=May 25, 2006}} Moses helped build Long Island's [[Meadowbrook State Parkway]]. It was the first fully divided limited access highway in the world.{{cite book|last=Leonard|first=Wallock|title=The Myth of The Master Builder|year=1991|publisher=Journal of Urban History|page=339}}\n\nMoses was a highly influential figure in the initiation of many of the reforms that restructured New York state's government during the 1920s. A 'Reconstruction Commission' headed by Moses produced a highly influential report that provided recommendations that would largely be adopted, including the consolidation of 187 existing agencies under 18 departments, a new executive budget system, and the four-year term limit for the governorship.{{sfn|Caro|1974|pp=106, 260}}", + ] + ] diff --git a/tests/use_cases/qa/test_long_context_qa.py b/tests/use_cases/qa/test_long_context_qa.py new file mode 100644 index 000000000..ded9a27c0 --- /dev/null +++ b/tests/use_cases/qa/test_long_context_qa.py @@ -0,0 +1,79 @@ +from pytest import fixture + +from intelligence_layer.core import NoOpTracer +from intelligence_layer.core.model import LuminousControlModel +from intelligence_layer.examples.qa.long_context_qa import ( + LongContextQa, + LongContextQaInput, +) + +LONG_TEXT = """Robert Moses''' (December 18, 1888 – July 29, 1981) was an American [[urban planner]] and public official who worked in the [[New York metropolitan area]] during the early to mid 20th century. Despite never being elected to any office, Moses is regarded as one of the most powerful and influential individuals in the history of New York City and New York State. The grand scale of his infrastructural projects and his philosophy of urban development influenced a generation of engineers, architects, and urban planners across the United States. + +Moses held various positions throughout his more than forty-year long career. He at times held up to 12 titles simultaneously, including [[New York City Parks Commissioner]] and chairman of the [[Long Island State Park Commission]].{{Cite web|url=https://www.pbs.org/wnet/need-to-know/environment/the-legacy-of-robert-moses/16018/|title=The legacy of Robert Moses|last=Sarachan|first=Sydney|date=January 17, 2013|website=Need to Know {{!}} PBS|language=en-US|access-date=December 3, 2019}} Having worked closely with New York governor [[Al Smith]] early in his career, Moses became expert in writing laws and navigating and manipulating the inner workings of state government. He created and led numerous semi-autonomous [[Public authority|public authorities]], through which he controlled millions of dollars in revenue and directly issued [[Bond (finance)|bonds]] to fund new ventures with little outside input or oversight. + +Moses's projects transformed the New York area and revolutionized the way cities in the U.S. were designed and built. As Long Island State Park Commissioner, Moses oversaw the construction of [[Jones Beach State Park]], the most visited public beach in the United States,{{cite news |url=http://www.longislandexchange.com/jones-beach.html |website=Long Island Exchange |title=Jones Beach |access-date=November 21, 2012 |archive-url=https://web.archive.org/web/20130121130008/http://www.longislandexchange.com/jones-beach.html |archive-date=January 21, 2013 |url-status=dead }} and was the primary architect of the [[Parkways in New York|New York State Parkway System]]. As head of the [[MTA Bridges and Tunnels|Triborough Bridge Authority]], Moses had near-complete control over bridges and tunnels in New York City as well as the tolls collected from them, and built, among others, the [[Robert F. Kennedy Bridge|Triborough Bridge]], the [[Brooklyn–Battery Tunnel]], and the [[Throgs Neck Bridge]], as well as several major highways. These roadways and bridges, alongside [[urban renewal]] efforts that saw the destruction of huge swaths of tenement housing and their replacement with large [[New York City Housing Authority|public housing projects]], transformed the physical fabric of New York and inspired other cities to undertake similar development endeavors. + +Moses's reputation declined following the publication of [[Robert Caro]]'s [[Pulitzer Prize]]-winning biography ''[[The Power Broker]]'' (1974), which cast doubt on the purported benefits of many of Moses's projects and further cast Moses as racist. In large part because of ''The Power Broker'', Moses is today considered a controversial figure in the history of New York City. + +==Early life and career== +Moses was born in [[New Haven, Connecticut]], on December 18, 1888, to [[German Jewish]] parents, Bella (Silverman) and Emanuel Moses.{{cite news | url=https://www.nytimes.com/learning/general/onthisday/bday/1218.html | title=Robert Moses, Master Builder, is Dead at 92| newspaper=The New York Times |archive-url=https://web.archive.org/web/20160305003155/https://www.nytimes.com/learning/general/onthisday/bday/1218.html |archive-date=March 5, 2016 |url-status=dead}}{{sfn|Caro|1974|p=25}} He spent the first nine years of his life living at 83 Dwight Street in New Haven, two blocks from [[Yale University]]. In 1897, the Moses family moved to New York City,{{sfn|Caro|1974|pp=29}} where they lived on East 46th Street off Fifth Avenue.{{cite web |url=http://www.newsday.com/community/guide/lihistory/ny-history-hs722a,0,7092161.story |title=The Master Builder |access-date=April 4, 2007 |last=DeWan |first=George |year=2007 |website=Long Island History |publisher=Newsday |archive-url=https://web.archive.org/web/20061211045554/http://www.newsday.com/community/guide/lihistory/ny-history-hs722a%2C0%2C7092161.story |archive-date=December 11, 2006 |url-status=dead }} Moses's father was a successful department store owner and [[real estate]] speculator in New Haven. In order for the family to move to New York City, he sold his real estate holdings and store, then retired.{{sfn|Caro|1974|pp=29}} Moses's mother was active in the [[settlement movement]], with her own love of building. Robert Moses and his brother Paul attended several schools for their elementary and [[secondary education]], including the [[Ethical Culture Fieldston School|Ethical Culture School]], the [[Dwight School]] and the [[Mohegan Lake, New York#Historic places|Mohegan Lake School]], a military academy near [[Peekskill, New York|Peekskill]].{{sfn|Caro|1974|pp=35}} + +After graduating from [[Yale College]] (B.A., 1909) and [[Wadham College]], [[Oxford University|Oxford]] (B.A., Jurisprudence, 1911; M.A., 1913), and earning a Ph.D. in [[political science]] from [[Columbia University]] in 1914, Moses became attracted to New York City reform politics.{{Cite web|url=http://c250.columbia.edu/c250_celebrates/remarkable_columbians/robert_moses.html|title = Robert Moses}} A committed [[idealism|idealist]], he developed several plans to rid New York of [[Patronage#Politics|patronage hiring]] practices, including being the lead author of a 1919 proposal to reorganize the New York state government. None went very far, but Moses, due to his intelligence, caught the notice of [[Belle Moskowitz]], a friend and trusted advisor to Governor [[Al Smith]].{{sfn|Caro|1974}} When the state [[Secretary of State of New York|Secretary of State's]] position became appointive rather than elective, Smith named Moses. He served from 1927 to 1929.{{cite news |date=December 19, 1928 |title=Moses Resigns State Position |url=http://cdsun.library.cornell.edu/cgi-bin/cornell?a=d&d=CDS19281219.2.63.7# |newspaper=Cornell Daily Sun |location=Ithaca, NY |page=8}} + +Moses rose to power with Smith, who was elected as governor in 1918, and then again in 1922. With Smith's support, Moses set in motion a sweeping consolidation of the New York State government. During that period Moses began his first foray into large-scale public work initiatives, while drawing on Smith's political power to enact legislation. This helped create the new [[Long Island State Park Commission]] and the State Council of Parks.{{cite web|last=Gutfreund|first=Owen|title=Moses, Robert|url=http://www.anb.org/articles/07/07-00375.html|publisher=Anb.org|access-date=December 24, 2014}} In 1924, Governor Smith appointed Moses chairman of the State Council of Parks and president of the Long Island State Park Commission.{{Cite book|title=Encyclopedia of the City|url=https://archive.org/details/encyclopediacity00cave|url-access=limited|last=Caves|first=R. W.|publisher=Routledge|year=2004|isbn=978-0-415-25225-6|pages=[https://archive.org/details/encyclopediacity00cave/page/n512 472]}} This centralization allowed Smith to run a government later used as a model for Franklin D. Roosevelt's [[New Deal]] federal government.{{or|date=October 2022}} Moses also received numerous commissions that he carried out efficiently, such as the development of [[Jones Beach State Park]].{{cn|date=October 2022}} Displaying a strong command of [[law]] as well as matters of [[engineering]], Moses became known for his skill in drafting legislation, and was called "the best bill drafter in [[Albany, New York|Albany]]".{{cite news |title=Annals of Power |first=Robert A. |last=Caro |author-link=Robert Caro |url=http://archives.newyorker.com/?i=1974-07-22#folio=032 |magazine=[[The New Yorker]] |date=July 22, 1974 |access-date=September 1, 2011}} At a time when the public was accustomed to [[Tammany Hall]] corruption and incompetence, Moses was seen as a savior of government.{{sfn|Caro|1974}} + +Shortly after [[President of the United States|President]] [[Franklin Delano Roosevelt|Franklin D. Roosevelt's]] [[First inauguration of Franklin D. Roosevelt|inauguration]] in 1933, the [[United States federal government|federal government]] found itself with millions of [[New Deal]] dollars to spend, yet states and cities had few projects ready. Moses was one of the few local officials who had projects [[shovel ready]]. For that reason, New York City was able to obtain significant [[Works Progress Administration]] (WPA), [[Civilian Conservation Corps]] (CCC), and other Depression-era funding. One of his most influential and longest-lasting positions was that of Parks Commissioner of New York City, a role he served from January 18, 1934, to May 23, 1960.{{Cite web|url=https://www.nycgovparks.org/about/history/commissioners|title=New York City Parks Commissioners : NYC Parks|website=www.nycgovparks.org|language=en|access-date=March 29, 2018}} + +==Offices held== +The many offices and professional titles that Moses held gave him unusually broad power to shape urban development in the New York metropolitan region. These include, according to the New York Preservation Archive Project:{{Cite web|url=http://www.nypap.org/preservation-history/robert-moses/|title=Robert Moses {{!}}|website=www.nypap.org|language=en-US|access-date=March 29, 2018}} +*[[Long Island State Park Commission]] (President, 1924–1963) +* New York State Council of Parks (Chairman, 1924–1963) +*[[Secretary of State of New York|New York Secretary of State]] (1927–1928) +* Bethpage State Park Authority (President, 1933–1963) +* Emergency Public Works Commission (Chairman, 1933–1934) +* Jones Beach Parkway Authority (President, 1933–1963) +*[[New York City Department of Parks and Recreation|New York City Department of Parks]] (Commissioner, 1934–1960) +* [[Triborough Bridge]] and Tunnel Authority (Chairman, 1934–1968) +* New York City Planning Commission (Commissioner, 1942–1960) +* New York State Power Authority (Chairman, 1954–1962) +* [[1964 New York World's Fair|New York's World Fair]] (President, 1960–1966) +* Office of the Governor of New York (Special Advisor on Housing, 1974–1975) + +==Influence== +During the 1920s, Moses sparred with [[Franklin D. Roosevelt]], then head of the Taconic State Park Commission, who favored the prompt construction of a [[parkway]] through the [[Hudson Valley]]. Moses succeeded in diverting funds to his Long Island parkway projects (the [[Northern State Parkway]], the [[Southern State Parkway]] and the [[Wantagh State Parkway]]), although the [[Taconic State Parkway]] was later completed as well.{{cite web|url=http://www.nycroads.com/roads/taconic/ |title=Taconic State Parkway |website=NYCRoads.com |access-date=May 25, 2006}} Moses helped build Long Island's [[Meadowbrook State Parkway]]. It was the first fully divided limited access highway in the world.{{cite book|last=Leonard|first=Wallock|title=The Myth of The Master Builder|year=1991|publisher=Journal of Urban History|page=339}} + +Moses was a highly influential figure in the initiation of many of the reforms that restructured New York state's government during the 1920s. A 'Reconstruction Commission' headed by Moses produced a highly influential report that provided recommendations that would largely be adopted, including the consolidation of 187 existing agencies under 18 departments, a new executive budget system, and the four-year term limit for the governorship.{{sfn|Caro|1974|pp=106, 260}}""" + + +@fixture +def long_context_qa(luminous_control_model: LuminousControlModel) -> LongContextQa: + return LongContextQa(model=luminous_control_model) + + +def test_qa_with_answer(long_context_qa: LongContextQa) -> None: + question = "What is the name of the book about Robert Moses?" + input = LongContextQaInput(text=LONG_TEXT, question=question) + output = long_context_qa.run(input, NoOpTracer()) + assert output.answer + assert "The Power Broker" in output.answer + + +def test_qa_with_no_answer(long_context_qa: LongContextQa) -> None: + question = "Who is the President of the united states?" + input = LongContextQaInput(text=LONG_TEXT, question=question) + output = long_context_qa.run(input, NoOpTracer()) + + assert output.answer is None + + +def test_multiple_qa_on_single_task_instance(long_context_qa: LongContextQa) -> None: + question = "Where was Robert Moses born?" + input = LongContextQaInput(text=LONG_TEXT, question=question) + output = long_context_qa.run(input, NoOpTracer()) + + input = LongContextQaInput( + text="This is some arbitrary text without content,", question=question + ) + output = long_context_qa.run(input, NoOpTracer()) + + assert output.answer is None diff --git a/tests/use_cases/qa/test_multiple_chunk_qa.py b/tests/use_cases/qa/test_multiple_chunk_qa.py new file mode 100644 index 000000000..564553396 --- /dev/null +++ b/tests/use_cases/qa/test_multiple_chunk_qa.py @@ -0,0 +1,70 @@ +from typing import Sequence + +from intelligence_layer.core import NoOpTracer +from intelligence_layer.core.chunk import TextChunk +from intelligence_layer.core.detect_language import Language +from intelligence_layer.examples.qa.multiple_chunk_qa import ( + MultipleChunkQa, + MultipleChunkQaInput, +) + +CHUNK_CONTAINING_ANSWER = TextChunk( + "Paul Nicolas lost his mother at the age of 3, and then his father in 1914.[3] He was raised by his mother-in-law together with his brother Henri. " + "He began his football career with Saint-Mandé Club in 1916. Initially, he played as a defender, but he quickly realized that his destiny laid at the " + "forefront since he scored many goals.[3] In addition to his goal-scoring instinct, Nicolas also stood out for his strong character on the pitch, " + "and these two qualities combined eventually drew the attention of Mr. Fort, the then president of the Gallia Club, who signed him as a centre-forward in 1916." +) +RELATED_CHUNK_WITHOUT_ANSWER = TextChunk( + "In addition to his goal-scoring instinct, Nicolas also stood out for his strong character on the pitch, and these two qualities combined eventually drew the " + "attention of Mr. Fort, the then president of the Gallia Club, who signed him as a centre-forward in 1916. " +) +RELATED_QUESTION = "What is the name of Paul Nicolas' brother?" +IMPORTANT_PART_OF_CORRECT_ANSWER = "Henri" +UNRELATED_QUESTION = "What is the the capital of Germany?" + + +def test_multiple_chunk_qa_with_mulitple_chunks( + multiple_chunk_qa: MultipleChunkQa, +) -> None: + chunks: Sequence[TextChunk] = [ + CHUNK_CONTAINING_ANSWER, + RELATED_CHUNK_WITHOUT_ANSWER, + ] + + input = MultipleChunkQaInput(chunks=chunks, question=RELATED_QUESTION) + output = multiple_chunk_qa.run(input, NoOpTracer()) + + assert output.answer + assert IMPORTANT_PART_OF_CORRECT_ANSWER in output.answer + assert len(output.subanswers) == 1 + assert output.subanswers[0].chunk == chunks[0] + assert any( + IMPORTANT_PART_OF_CORRECT_ANSWER + in CHUNK_CONTAINING_ANSWER[highlight.start : highlight.end] + for highlight in output.subanswers[0].highlights + ) + + +def test_multiple_chunk_qa_without_answer(multiple_chunk_qa: MultipleChunkQa) -> None: + chunks: Sequence[TextChunk] = [CHUNK_CONTAINING_ANSWER] + + input = MultipleChunkQaInput(chunks=chunks, question=UNRELATED_QUESTION) + output = multiple_chunk_qa.run(input, NoOpTracer()) + + assert output.answer is None + + +def test_multiple_chunk_qa_with_spanish_question( + multiple_chunk_qa: MultipleChunkQa, +) -> None: + question = "¿Cómo se llama el hermano de Paul Nicola?" + chunks = [CHUNK_CONTAINING_ANSWER, CHUNK_CONTAINING_ANSWER] + + input = MultipleChunkQaInput( + chunks=chunks, question=question, language=Language("es") + ) + output = multiple_chunk_qa.run(input, NoOpTracer()) + + assert len(output.subanswers) == len(chunks) + assert output.answer + assert "hermano" in output.answer diff --git a/tests/use_cases/qa/test_multiple_chunk_retriever_qa.py b/tests/use_cases/qa/test_multiple_chunk_retriever_qa.py new file mode 100644 index 000000000..6e5ded7b9 --- /dev/null +++ b/tests/use_cases/qa/test_multiple_chunk_retriever_qa.py @@ -0,0 +1,37 @@ +from pytest import fixture + +from intelligence_layer.connectors import QdrantInMemoryRetriever +from intelligence_layer.core import LuminousControlModel, NoOpTracer +from intelligence_layer.core.tracer.in_memory_tracer import InMemoryTracer +from intelligence_layer.examples import ( + ExpandChunks, + MultipleChunkRetrieverQa, + RetrieverBasedQaInput, +) + + +@fixture +def multiple_chunk_retriever_qa( + luminous_control_model: LuminousControlModel, + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, +) -> MultipleChunkRetrieverQa[int]: + return MultipleChunkRetrieverQa( + retriever=asymmetric_in_memory_retriever, + model=luminous_control_model, + expand_chunks=ExpandChunks( + asymmetric_in_memory_retriever, luminous_control_model, 256 + ), + ) + + +def test_multiple_chunk_retriever_qa_using_in_memory_retriever( + multiple_chunk_retriever_qa: MultipleChunkRetrieverQa[int], + no_op_tracer: NoOpTracer, +) -> None: + question = "When was Robert Moses born?" + input = RetrieverBasedQaInput(question=question) + tracer = InMemoryTracer() + output = multiple_chunk_retriever_qa.run(input, tracer) + assert output.answer + assert "1888" in output.answer + assert len(output.sources) == 5 diff --git a/tests/use_cases/qa/test_retriever_based_qa.py b/tests/use_cases/qa/test_retriever_based_qa.py new file mode 100644 index 000000000..732cf50bb --- /dev/null +++ b/tests/use_cases/qa/test_retriever_based_qa.py @@ -0,0 +1,58 @@ +from pytest import fixture + +from intelligence_layer.connectors.document_index.document_index import DocumentPath +from intelligence_layer.connectors.retrievers.document_index_retriever import ( + DocumentIndexRetriever, +) +from intelligence_layer.connectors.retrievers.qdrant_in_memory_retriever import ( + QdrantInMemoryRetriever, +) +from intelligence_layer.core import NoOpTracer +from intelligence_layer.examples import ( + MultipleChunkQa, + RetrieverBasedQa, + RetrieverBasedQaInput, +) + + +@fixture +def retriever_based_qa_with_in_memory_retriever( + multiple_chunk_qa: MultipleChunkQa, + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, +) -> RetrieverBasedQa[int]: + return RetrieverBasedQa( + retriever=asymmetric_in_memory_retriever, multi_chunk_qa=multiple_chunk_qa + ) + + +@fixture +def retriever_based_qa_with_document_index( + multiple_chunk_qa: MultipleChunkQa, document_index_retriever: DocumentIndexRetriever +) -> RetrieverBasedQa[DocumentPath]: + return RetrieverBasedQa( + retriever=document_index_retriever, multi_chunk_qa=multiple_chunk_qa + ) + + +def test_retriever_based_qa_using_in_memory_retriever( + retriever_based_qa_with_in_memory_retriever: RetrieverBasedQa[int], + no_op_tracer: NoOpTracer, +) -> None: + question = "When was Robert Moses born?" + input = RetrieverBasedQaInput(question=question) + output = retriever_based_qa_with_in_memory_retriever.run(input, no_op_tracer) + assert output.answer + assert "1888" in output.answer + assert output.subanswers[0].id == 3 + + +def test_retriever_based_qa_with_document_index( + retriever_based_qa_with_document_index: RetrieverBasedQa[DocumentPath], + no_op_tracer: NoOpTracer, +) -> None: + question = "When was Robert Moses born?" + input = RetrieverBasedQaInput(question=question) + output = retriever_based_qa_with_document_index.run(input, no_op_tracer) + assert output.answer + assert "1888" in output.answer + assert output.subanswers[0].id.document_name == "Robert Moses (Begriffsklärung)" diff --git a/tests/use_cases/qa/test_single_chunk_qa.py b/tests/use_cases/qa/test_single_chunk_qa.py new file mode 100644 index 000000000..b76f10dfe --- /dev/null +++ b/tests/use_cases/qa/test_single_chunk_qa.py @@ -0,0 +1,121 @@ +import pytest + +from intelligence_layer.core import ( + Language, + LuminousControlModel, + NoOpTracer, + TextChunk, + TextHighlight, +) +from intelligence_layer.core.detect_language import LanguageNotSupportedError +from intelligence_layer.examples.qa.single_chunk_qa import ( + QaSetup, + SingleChunkQa, + SingleChunkQaInput, +) + + +def test_qa_with_answer(single_chunk_qa: SingleChunkQa) -> None: + input_text = "Paul Nicolas lost his mother at the age of 3, and then his father in 1914.[3] He was raised by his mother-in-law together with his brother Henri. He began his football career with Saint-Mandé Club in 1916. Initially, he played as a defender, but he quickly realized that his destiny laid at the forefront since he scored many goals.[3] In addition to his goal-scoring instinct, Nicolas also stood out for his strong character on the pitch, and these two qualities combined eventually drew the attention of Mr. Fort, the then president of the Gallia Club, who signed him as a centre-forward in 1916." + input = SingleChunkQaInput( + chunk=TextChunk(input_text), + question="What is the name of Paul Nicolas' brother?", + language=Language("en"), + ) + output = single_chunk_qa.run(input, NoOpTracer()) + + assert output.answer + assert "Henri" in output.answer + assert any( + "Henri" in input_text[highlight.start : highlight.end] and highlight.score == 1 + for highlight in output.highlights + ) + + +def test_qa_with_no_answer(single_chunk_qa: SingleChunkQa) -> None: + input = SingleChunkQaInput( + chunk=TextChunk( + "Paul Nicolas lost his mother at the age of 3, and then his father in 1914.[3] He was raised by his mother-in-law together with his brother Henri. He began his football career with Saint-Mandé Club in 1916. Initially, he played as a defender, but he quickly realized that his destiny laid at the forefront since he scored many goals.[3] In addition to his goal-scoring instinct, Nicolas also stood out for his strong character on the pitch, and these two qualities combined eventually drew the attention of Mr. Fort, the then president of the Gallia Club, who signed him as a centre-forward in 1916." + ), + question="What is the capital of Germany?", + ) + output = single_chunk_qa.run(input, NoOpTracer()) + + assert output.answer is None + + +def test_language_not_supported_exception(single_chunk_qa: SingleChunkQa) -> None: + input = SingleChunkQaInput( + chunk=TextChunk( + "Paul Nicolas stracił matkę w wieku 3 lat, a następnie ojca w 1914 r.[3] Wychowywała go teściowa wraz z bratem Henrim. Karierę piłkarską rozpoczął w klubie Saint-Mandé w 1916 roku. Początkowo grał jako obrońca, ale szybko zdał sobie sprawę, że jego przeznaczeniem jest gra w pierwszym składzie, ponieważ strzelał wiele bramek[3]. Oprócz instynktu bramkarskiego, Nicolas wyróżniał się również silnym charakterem na boisku, a te dwie cechy w połączeniu ostatecznie zwróciły uwagę pana Forta, ówczesnego prezesa klubu Gallia, który podpisał z nim kontrakt jako środkowym napastnikiem w 1916 roku." + ), + question="Jaka jest stolica Niemiec?", + language=Language("pl"), + ) + + with pytest.raises(LanguageNotSupportedError): + single_chunk_qa.run(input, NoOpTracer()) + + +def test_qa_with_logit_bias_for_no_answer( + luminous_control_model: LuminousControlModel, +) -> None: + first_token = "no" + max_tokens = 5 + config = { + Language("en"): QaSetup( + unformatted_instruction='{{question}}\nIf there\'s no answer, say "{{no_answer_text}}". Only answer the question based on the text.', + no_answer_str=f"{first_token} answer in text", + no_answer_logit_bias=1000.0, + ) + } + single_chunk_qa = SingleChunkQa( + luminous_control_model, instruction_config=config, maximum_tokens=max_tokens + ) + + input = SingleChunkQaInput( + chunk=TextChunk( + "Paul Nicolas lost his mother at the age of 3, and then his father in 1914.[3] He was raised by his mother-in-law together with his brother Henri. He began his football career with Saint-Mandé Club in 1916. Initially, he played as a defender, but he quickly realized that his destiny laid at the forefront since he scored many goals.[3] In addition to his goal-scoring instinct, Nicolas also stood out for his strong character on the pitch, and these two qualities combined eventually drew the attention of Mr. Fort, the then president of the Gallia Club, who signed him as a centre-forward in 1916." + ), + question="When did he lose his mother?", + ) + output = single_chunk_qa.run(input, NoOpTracer()) + + # on CI, this is tokenized as "nonononono" rather than "no no no no no" + # Likely, this is because some test changes the tokenizer state to remove the whitespace + # We should fix this, but for now, I'll assert both + acceptable_answers = [ + " ".join([first_token] * max_tokens), + first_token * max_tokens, + ] + assert any(output.answer == a for a in acceptable_answers) + + +def test_qa_highlights_will_not_become_out_of_bounds( + single_chunk_qa: SingleChunkQa, +) -> None: + input_text = """Zubereitung +Ein Hotdog besteht aus einem erwärmten Brühwürstchen in einem länglichen, meist weichen Weizenbrötchen, das üblicherweise getoastet oder gedämpft wird. Das Hotdogbrötchen wird zur Hälfte der Länge nach aufgeschnitten und ggf. erhitzt. Danach legt man das heiße Würstchen hinein und garniert es mit den Saucen (Ketchup, Senf, Mayonnaise usw.). Häufig werden auch noch weitere Zugaben, etwa Röstzwiebeln, Essiggurken, Sauerkraut oder Krautsalat in das Brötchen gegeben. + +Varianten + +In Dänemark und teilweise auch in Schweden wird der Hotdog mit leuchtend rot eingefärbten Würstchen (Røde Pølser) hergestellt und kogt (gebrüht) oder risted (gebraten) angeboten. Der dänische Hotdog wird meist mit Röstzwiebeln, gehackten Zwiebeln und süßsauer eingelegten Salatgurken-Scheiben und regional mit Rotkohl garniert. Als Saucen werden Ketchup, milder Senf und dänische Remoulade, die Blumenkohl enthält, verwendet. Der bekannteste Imbiss Dänemarks, der Hotdogs verkauft, ist Annies Kiosk. + +Weltweit bekannt sind die Hotdog-Stände der schwedischen Möbelhauskette IKEA, an denen im Möbelhaus hinter den Kassen Hot Dogs der schwedischen Variante zum Selberbelegen mit Röstzwiebeln, Gurken und verschiedenen Soßen verkauft werden. Der Hotdogstand in der Filiale gilt weltweit als eine Art Markenzeichen von IKEA. In Deutschland wird das Gericht meist mit Frankfurter oder Wiener Würstchen zubereitet. + +In den USA wird der Hotdog meist auf einem Roller Grill gegart. So bekommt die Wurst einen besonderen Grillgeschmack. Amerikanische Hotdogs werden mit speziellen Pickled Gherkins (Gurkenscheiben) und Relishes (Sweet Relish, Hot Pepper Relish oder Corn Relish), häufig mit mildem Senf (Yellow Mustard, die populärste Hotdog-Zutat) oder mit Ketchup serviert. Auch eine Garnitur aus warmem Sauerkraut ist möglich (Nathan’s Famous in New York).""" + model = LuminousControlModel("luminous-supreme-control") + qa_task = SingleChunkQa( + text_highlight=TextHighlight(model=model, granularity=None, clamp=True), + model=model, + ) + input = SingleChunkQaInput( + chunk=TextChunk(input_text), + question="What is a hot dog", + language=Language("de"), + ) + output = qa_task.run(input, NoOpTracer()) + + for highlight in output.highlights: + assert highlight.start >= 0 + assert 0 < highlight.end <= len(input_text) diff --git a/tests/use_cases/search/test_expand_chunk.py b/tests/use_cases/search/test_expand_chunk.py new file mode 100644 index 000000000..0611d812b --- /dev/null +++ b/tests/use_cases/search/test_expand_chunk.py @@ -0,0 +1,170 @@ +from typing import Sequence + +from pytest import fixture + +from intelligence_layer.connectors import ( + Document, + DocumentChunk, + QdrantInMemoryRetriever, +) +from intelligence_layer.core import LuminousControlModel, NoOpTracer +from intelligence_layer.examples import ExpandChunks, ExpandChunksInput + + +@fixture +def in_memory_retriever_documents() -> Sequence[Document]: + return [ + Document( + text="""In the rolling verdant hills of a realm untouched by the passage of modern times, a kingdom thrived under the rule of a benevolent monarch. The king, known for his wisdom and justice, held the loyalty of his people and the respect of his peers. However, beneath the surface of peace, a shadow loomed that would test the mettle of the kingdom's most valiant defenders: the knights. + +These knights, clad in gleaming armor and bearing the colors of their liege, were not mere soldiers but champions of the realm's ideals. They were sworn to protect the innocent, uphold justice, and maintain the peace, guided by a chivalric code that was as much a part of them as the swords they wielded. Among these noble warriors, Sir Aelwyn stood prominent, known across the land for his prowess in battle and his unyielding honor. + +Sir Aelwyn, the youngest knight ever to be granted the title of Master of the Horse, was a figure of legend. His tales were told in every corner of the kingdom, often embellished with each retelling. From his duel with the Giant of Gormouth to his silent vigil in the Haunted Wood, Aelwyn's life was a tapestry of bravery and adventure. Yet, his greatest challenge lay ahead, whispered in fearful murmurs throughout the castle—the rise of the Dragon of Black Hollow. + +The dragon had awoken from a centuries-long slumber, driven by hunger and wrath, laying waste to the villages on the kingdom's fringes. Smoke and despair rose from the once tranquil borders, drawing the attention of the king and his council. With the threat growing each day, the king summoned Sir Aelwyn and tasked him with a quest that could either save the kingdom or doom it forever—to defeat the dragon. + +As Sir Aelwyn prepared for his journey, the castle buzzed with activity. Blacksmiths forged new armor and weapons, alchemists concocted potent draughts, and scholars poured over ancient texts seeking any knowledge that might aid him. The knight spent his nights in the chapel, praying for strength and wisdom, and his days in the training yard, honing his skills against opponents both real and imagined. + +Accompanying Sir Aelwyn were his loyal companions: Sir Rowan, a strategist known for his cunning and intellect; Lady Elara, a knight whose skill with the bow was unmatched; and Dame Miriel, a warrior-poet whose songs could stir the soul as fiercely as her sword could cleave armor. Together, they represented the kingdom's finest, united under a single cause. + +Their journey was fraught with peril. They crossed through the Whispering Forest, where shadows moved with minds of their own, and over the Mountains of Echoes, where the wind carried voices from the past. Each step brought them closer to their quarry, and the signs of the dragon's passage grew ever more ominous—the charred earth, the ruins of once happy homes, and the air heavy with the scent of sulfur. + +As they approached Black Hollow, the landscape grew bleak, and the sky darkened. The dragon, coiled atop a pile of gold and bones, awaited them, its scales shimmering like molten rock. The air crackled with the heat of its breath, and its eyes, glowing like coals, fixed on Sir Aelwyn and his companions. + +The battle was fierce. Sir Rowan directed their movements with precision, while Lady Elara loosed arrows that found chinks in the dragon's armor. Dame Miriel's voice rose above the clamor, her words bolstering their courage and blinding the beast with bursts of radiant light. Sir Aelwyn faced the dragon head-on, his shield absorbing the flames that poured from its maw, his sword striking with the weight of his oath behind each blow. + +Hours seemed like days as the clash continued, the outcome uncertain. Finally, seeing an opening, Sir Aelwyn drove his sword deep into the dragon's heart. With a final roar that shook the heavens, the dragon fell, its reign of terror ended. + +The return to the kingdom was triumphant. The people lined the streets, showering the knights with flowers and cheers. The king welcomed them back as heroes, their deeds to be recorded in the annals of history for generations to come. Sir Aelwyn and his companions had not only saved the kingdom but had also reaffirmed the values it stood for: courage, honor, and a steadfast commitment to the protection of the realm. + +As the celebrations faded, Sir Aelwyn looked out over the kingdom from the castle's highest tower. The peace they had fought for lay stretched before him, a tapestry of green fields and bustling towns. Yet, he knew that this peace was not permanent but a precious moment to be cherished and protected. For as long as there were threats to the realm, there would be knights to face them, their swords ready and their hearts brave. + +In this timeless land, the cycle of challenge and triumph continued, each generation of knights rising to meet the dangers of their times with the same valor and resolve as those who had come before them. And so, the legends grew, each knight adding their thread to the ever-unfolding story of the kingdom and its defenders.""" + ) + ] + + +def build_expand_chunk_input( + document: Document, index_ranges: Sequence[tuple[int, int]] +) -> ExpandChunksInput[int]: + return ExpandChunksInput( + document_id=0, + chunks_found=[ + DocumentChunk( + text=document.text[index_range[0] : index_range[1]], + start=index_range[0], + end=index_range[1], + ) + for index_range in index_ranges + ], + ) + + +@fixture +def wholly_included_expand_chunk_input( + in_memory_retriever_documents: Sequence[Document], +) -> ExpandChunksInput[int]: + assert len(in_memory_retriever_documents) == 1 + start_index, end_index = ( + int(len(in_memory_retriever_documents[0].text) * 0.5), + int(len(in_memory_retriever_documents[0].text) * 0.55), + ) + + return build_expand_chunk_input( + in_memory_retriever_documents[0], [(start_index, end_index)] + ) + + +@fixture +def overlapping_expand_chunk_input( + in_memory_retriever_documents: Sequence[Document], +) -> ExpandChunksInput[int]: + assert len(in_memory_retriever_documents) == 1 + start_index, end_index = ( + int(len(in_memory_retriever_documents[0].text) * 0.2), + int(len(in_memory_retriever_documents[0].text) * 0.8), + ) + + return build_expand_chunk_input( + in_memory_retriever_documents[0], [(start_index, end_index)] + ) + + +@fixture +def multiple_chunks_expand_chunk_input( + in_memory_retriever_documents: Sequence[Document], +) -> ExpandChunksInput[int]: + assert len(in_memory_retriever_documents) == 1 + start_index_1, end_index_1 = ( + int(len(in_memory_retriever_documents[0].text) * 0.3), + int(len(in_memory_retriever_documents[0].text) * 0.4), + ) + start_index_2, end_index_2 = ( + int(len(in_memory_retriever_documents[0].text) * 0.45), + int(len(in_memory_retriever_documents[0].text) * 0.6), + ) + + return build_expand_chunk_input( + in_memory_retriever_documents[0], + [(start_index_1, end_index_1), (start_index_2, end_index_2)], + ) + + +def test_expand_chunk_works_for_wholly_included_chunk( + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, + luminous_control_model: LuminousControlModel, + wholly_included_expand_chunk_input: ExpandChunksInput[int], + no_op_tracer: NoOpTracer, +) -> None: + expand_chunk_task = ExpandChunks( + asymmetric_in_memory_retriever, luminous_control_model, 256 + ) + expand_chunk_output = expand_chunk_task.run( + wholly_included_expand_chunk_input, no_op_tracer + ) + + assert ( + len(expand_chunk_output.chunks) + == 1 + == len(wholly_included_expand_chunk_input.chunks_found) + ) + assert ( + wholly_included_expand_chunk_input.chunks_found[0].text + in expand_chunk_output.chunks[0].chunk + ) + + +def test_expand_chunk_works_for_overlapping_chunk( + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, + luminous_control_model: LuminousControlModel, + overlapping_expand_chunk_input: ExpandChunksInput[int], + no_op_tracer: NoOpTracer, +) -> None: + expand_chunk_task = ExpandChunks( + asymmetric_in_memory_retriever, luminous_control_model, 256 + ) + expand_chunk_output = expand_chunk_task.run( + overlapping_expand_chunk_input, no_op_tracer + ) + + assert len(expand_chunk_output.chunks) == 4 + + +def test_expand_chunk_works_for_multiple_chunks( + asymmetric_in_memory_retriever: QdrantInMemoryRetriever, + luminous_control_model: LuminousControlModel, + multiple_chunks_expand_chunk_input: ExpandChunksInput[int], + no_op_tracer: NoOpTracer, +) -> None: + expand_chunk_task = ExpandChunks( + asymmetric_in_memory_retriever, luminous_control_model, 256 + ) + expand_chunk_output = expand_chunk_task.run( + multiple_chunks_expand_chunk_input, no_op_tracer + ) + + assert len(expand_chunk_output.chunks) == 3 + + combined_chunks = "".join(chunk.chunk for chunk in expand_chunk_output.chunks) + for chunk_found in multiple_chunks_expand_chunk_input.chunks_found: + assert chunk_found.text in combined_chunks diff --git a/tests/use_cases/search/test_search.py b/tests/use_cases/search/test_search.py new file mode 100644 index 000000000..481df585c --- /dev/null +++ b/tests/use_cases/search/test_search.py @@ -0,0 +1,197 @@ +from statistics import mean +from typing import Sequence + +from pytest import fixture + +from intelligence_layer.connectors import ( + Document, + DocumentChunk, + QdrantInMemoryRetriever, + SearchResult, +) +from intelligence_layer.core import NoOpTracer +from intelligence_layer.evaluation import Example +from intelligence_layer.examples import ( + ExpectedSearchOutput, + Search, + SearchAggregationLogic, + SearchEvaluation, + SearchEvaluationLogic, + SearchInput, + SearchOutput, +) +from tests.conftest import to_document + + +@fixture +def in_memory_retriever_documents() -> Sequence[Document]: + return [ + Document(text="I do not like rain"), + Document(text="Summer is warm"), + Document(text="We are so back"), + ] + + +@fixture +def search(asymmetric_in_memory_retriever: QdrantInMemoryRetriever) -> Search[int]: + return Search(asymmetric_in_memory_retriever) + + +@fixture +def expected_output() -> ExpectedSearchOutput[str]: + return ExpectedSearchOutput( + document_id="1", + start_idx=0, + end_idx=5, + ) + + +@fixture +def example( + expected_output: ExpectedSearchOutput[str], +) -> Example[SearchInput, ExpectedSearchOutput[str]]: + return Example(input=SearchInput(query=""), expected_output=expected_output) + + +@fixture +def search_eval_logic() -> SearchEvaluationLogic[str]: + return SearchEvaluationLogic[str]() + + +@fixture +def search_evaluations() -> Sequence[SearchEvaluation]: + return [ + SearchEvaluation(rank=1, similarity_score=0.7), + SearchEvaluation(rank=3, similarity_score=0.6), + SearchEvaluation(rank=10, similarity_score=0.5), + SearchEvaluation(rank=None, similarity_score=None), + SearchEvaluation(rank=None, similarity_score=None), + ] + + +@fixture +def search_aggregation_logic() -> SearchAggregationLogic: + return SearchAggregationLogic(top_ks_to_evaluate=[1, 3]) + + +def test_search( + search: Search[int], + no_op_tracer: NoOpTracer, + in_memory_retriever_documents: Sequence[Document], +) -> None: + search_input = SearchInput(query="Are we so back?") + result = search.run(search_input, no_op_tracer) + assert [to_document(r.document_chunk) for r in result.results] == [ + in_memory_retriever_documents[2] + ] + assert result.results[0].document_chunk.start == 0 + assert ( + result.results[0].document_chunk.end + == len(in_memory_retriever_documents[2].text) - 1 + ) + + +def test_search_evaluation_logic_works_for_overlapping_output( + example: Example[SearchInput, ExpectedSearchOutput[str]], + search_eval_logic: SearchEvaluationLogic[str], +) -> None: + output = SearchOutput( + results=[ + SearchResult( + id="1", + score=0.5, + document_chunk=DocumentChunk(text="llo", start=2, end=5), + ) + ] + ) + eval = search_eval_logic.do_evaluate_single_output(example, output) + + assert eval.rank == 1 + assert eval.similarity_score == output.results[0].score + + +def test_search_evaluation_logic_works_for_wholly_included_output( + example: Example[SearchInput, ExpectedSearchOutput[str]], + search_eval_logic: SearchEvaluationLogic[str], +) -> None: + output = SearchOutput( + results=[ + SearchResult( + id="1", + score=0.5, + document_chunk=DocumentChunk(text="l", start=2, end=3), + ) + ] + ) + eval = search_eval_logic.do_evaluate_single_output(example, output) + + assert eval.rank == 1 + assert eval.similarity_score == output.results[0].score + + +def test_search_evaluation_logic_works_for_identical_ranges( + example: Example[SearchInput, ExpectedSearchOutput[str]], + search_eval_logic: SearchEvaluationLogic[str], +) -> None: + output = SearchOutput( + results=[ + SearchResult( + id="1", + score=0.5, + document_chunk=DocumentChunk(text="hallo", start=0, end=5), + ) + ] + ) + eval = search_eval_logic.do_evaluate_single_output(example, output) + + assert eval.rank == 1 + assert eval.similarity_score == output.results[0].score + + +def test_search_evaluation_logic_works_for_non_overlapping_output( + example: Example[SearchInput, ExpectedSearchOutput[str]], + search_eval_logic: SearchEvaluationLogic[str], +) -> None: + output = SearchOutput( + results=[ + SearchResult( + id="1", + score=0.5, + document_chunk=DocumentChunk(text=" test.", start=5, end=10), + ) + ] + ) + eval = search_eval_logic.do_evaluate_single_output(example, output) + + assert not eval.rank + assert not eval.similarity_score + + +def test_search_aggregation_logic_works( + search_evaluations: Sequence[SearchEvaluation], + search_aggregation_logic: SearchAggregationLogic, +) -> None: + aggregations = search_aggregation_logic.aggregate(search_evaluations) + + assert ( + aggregations.mean_score + == mean( + [ + eval.similarity_score + for eval in search_evaluations + if eval.similarity_score + ] + ) + == 0.6 + ) + assert ( + round(aggregations.mean_reciprocal_rank, 5) + == round(mean([1 / eval.rank for eval in search_evaluations if eval.rank]), 5) + == round((1 + (1 / 3) + (1 / 10)) / 3, 5) + ) + assert aggregations.mean_top_ks + assert aggregations.chunk_found.found_count == 3 + assert aggregations.chunk_found.expected_count == len(search_evaluations) == 5 + assert aggregations.chunk_found.percentage == 3 / 5 + assert aggregations.mean_top_ks[1] == 1 / 3 + assert aggregations.mean_top_ks[3] == 2 / 3 diff --git a/tests/use_cases/summarize/__init__.py b/tests/use_cases/summarize/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/use_cases/summarize/conftest.py b/tests/use_cases/summarize/conftest.py new file mode 100644 index 000000000..eb10792d7 --- /dev/null +++ b/tests/use_cases/summarize/conftest.py @@ -0,0 +1,52 @@ +from pytest import fixture + +from intelligence_layer.core import Chunk, LuminousControlModel, TextChunk +from intelligence_layer.examples.summarize.steerable_long_context_summarize import ( + SteerableLongContextSummarize, +) +from intelligence_layer.examples.summarize.steerable_single_chunk_summarize import ( + SteerableSingleChunkSummarize, +) + + +@fixture +def steerable_single_chunk_summarize( + luminous_control_model: LuminousControlModel, +) -> SteerableSingleChunkSummarize: + return SteerableSingleChunkSummarize(luminous_control_model) + + +@fixture +def chunk() -> TextChunk: + return TextChunk( + "The brown bear (Ursus arctos) is a large bear species found across Eurasia and North America.[1][3] In North America, the populations of brown bears are called grizzly bears, while the subspecies that inhabits the Kodiak Islands of Alaska is known as the Kodiak bear. It is one of the largest living terrestrial members of the order Carnivora, rivaled in size only by its closest relative, the polar bear (Ursus maritimus), which is much less variable in size and slightly bigger on average.[4][5][6][7][8] The brown bear's range includes parts of Russia, Central Asia, the Himalayas, China, Canada, the United States, Hokkaido, Scandinavia, Finland, the Balkans, the Picos de Europa and the Carpathian region (especially Romania), Iran, Anatolia, and the Caucasus.[1][9] The brown bear is recognized as a national and state animal in several European countries.[10]" + ) + + +@fixture +def steerable_long_context_summarize( + luminous_control_model: LuminousControlModel, +) -> SteerableLongContextSummarize: + return SteerableLongContextSummarize( + summarize=SteerableSingleChunkSummarize( + luminous_control_model, max_generated_tokens=128 + ), + chunk=Chunk(luminous_control_model, max_tokens_per_chunk=1024), + ) + + +@fixture +def long_text() -> str: + return """The brown bear (Ursus arctos) is a large bear species found across Eurasia and North America.[1][3] In North America, the populations of brown bears are called grizzly bears, while the subspecies that inhabits the Kodiak Islands of Alaska is known as the Kodiak bear. It is one of the largest living terrestrial members of the order Carnivora, rivaled in size only by its closest relative, the polar bear (Ursus maritimus), which is much less variable in size and slightly bigger on average.[4][5][6][7][8] The brown bear's range includes parts of Russia, Central Asia, the Himalayas, China, Canada, the United States, Hokkaido, Scandinavia, Finland, the Balkans, the Picos de Europa and the Carpathian region (especially Romania), Iran, Anatolia, and the Caucasus.[1][9] The brown bear is recognized as a national and state animal in several European countries.[10] + +While the brown bear's range has shrunk, and it has faced local extinctions across its wide range, it remains listed as a least concern species by the International Union for Conservation of Nature (IUCN) with a total estimated population in 2017 of 110,000. As of 2012, this and the American black bear are the only bear species not classified as threatened by the IUCN, though the large sizes of both bears may be a disadvantage due to increased competition with humans.[1][3][7] Populations that were hunted to extinction in the 19th and 20th centuries are the Atlas bear of North Africa and the Californian, Ungavan[11][12] and Mexican populations of the grizzly bear of North America. Many of the populations in the southern parts of Eurasia are highly endangered as well.[1][13] One of the smaller-bodied forms, the Himalayan brown bear, is critically endangered, occupying only 2% of its former range and threatened by uncontrolled poaching for its body parts.[14] The Marsican brown bear of central Italy is one of several currently isolated populations of the Eurasian brown bear and is believed to have a population of just 50 to 60 bears.[10][15] + +Evolution and taxonomy +The brown bear is sometimes referred to as the bruin, from Middle English. This name originated in the fable History of Reynard the Fox translated by William Caxton from Middle Dutch bruun or bruyn, meaning brown (the color).[16][17] In the mid-19th century United States, the brown bear was termed "Old Ephraim" and sometimes as "Moccasin Joe".[18] + +The scientific name of the brown bear, Ursus arctos, comes from the Latin ursus, meaning "bear",[19] and the Greek ἄρκτος/arktos, also meaning "bear".[20] + +Generalized names and evolution +Brown bears are thought to have evolved from Ursus etruscus in Asia.[21][22] The brown bear, per Kurten (1976), has been stated as "clearly derived from the Asian population of Ursus savini about 800,000 years ago; spread into Europe, to the New World."[23] A genetic analysis indicated that the brown bear lineage diverged from the cave bear species complex approximately 1.2–1.4 million years ago, but did not clarify if U. savini persisted as a paraspecies for the brown bear before perishing.[24] The oldest fossils positively identified as from this species occur in China from about 0.5 million years ago. Brown bears entered Europe about 250,000 years ago and North Africa shortly after.[21][25] Brown bear remains from the Pleistocene period are common in the British Isles, where it is thought they might have outcompeted cave bears (Ursus spelaeus). The species entered Alaska 100,000 years ago, though they did not move south until 13,000 years ago.[21] It is speculated that brown bears were unable to migrate south until the extinction of the much larger giant short-faced bear (Arctodus simus).[26][27] + +Several paleontologists suggest the possibility of two separate brown bear migrations. First, the inland brown bears, also known as grizzlies, are thought to stem from narrow-skulled bears which migrated from northern Siberia to central Alaska and the rest of the continent. Moreover, the Kodiak bears descend from broad-skulled bears from Kamchatka, which colonized the Alaskan peninsula. Brown bear fossils discovered in Ontario, Ohio, Kentucky and Labrador show that the species occurred farther east than indicated in historic records.[21] In North America, two types of the subspecies Ursus arctos horribilis are generally recognized—the coastal brown bear and the inland grizzly bear; these two types broadly define the range of sizes of all brown bear subspecies.[13]""" diff --git a/tests/use_cases/summarize/test_recursive_summarize.py b/tests/use_cases/summarize/test_recursive_summarize.py new file mode 100644 index 000000000..ce8dbc5f2 --- /dev/null +++ b/tests/use_cases/summarize/test_recursive_summarize.py @@ -0,0 +1,97 @@ +import os +from pathlib import Path + +from aleph_alpha_client import Client, CompletionRequest, CompletionResponse +from pytest import fixture + +from intelligence_layer.core import Chunk, LuminousControlModel, NoOpTracer +from intelligence_layer.examples import RecursiveSummarize +from intelligence_layer.examples.summarize.recursive_summarize import ( + RecursiveSummarizeInput, +) +from intelligence_layer.examples.summarize.steerable_long_context_summarize import ( + SteerableLongContextSummarize, +) +from intelligence_layer.examples.summarize.steerable_single_chunk_summarize import ( + SteerableSingleChunkSummarize, +) + + +class RecursiveCountingClient(Client): + recursive_counter: int = 0 + + def complete(self, request: CompletionRequest, model: str) -> CompletionResponse: + self.recursive_counter += 1 + return super().complete(request, model) + + +short_text = """The brown bear (Ursus arctos) is a large bear species found across Eurasia and North America.[1][3] In North America, the populations of brown bears are called grizzly bears, while the subspecies that inhabits the Kodiak Islands of Alaska is known as the Kodiak bear. It is one of the largest living terrestrial members of the order Carnivora, rivaled in size only by its closest relative, the polar bear (Ursus maritimus), which is much less variable in size and slightly bigger on average.[4][5][6][7][8] The brown bear's range includes parts of Russia, Central Asia, the Himalayas, China, Canada, the United States, Hokkaido, Scandinavia, Finland, the Balkans, the Picos de Europa and the Carpathian region (especially Romania), Iran, Anatolia, and the Caucasus.[1][9] The brown bear is recognized as a national and state animal in several European countries.[10]""" + + +@fixture +def recursive_counting_client() -> RecursiveCountingClient: + aa_token = os.getenv("AA_TOKEN") + assert aa_token + return RecursiveCountingClient(aa_token) + + +@fixture +def very_long_text() -> str: + with open( + Path(__file__).parent / "very_long_text.txt", "r", encoding="utf-8" + ) as file: + return file.read() + + +def test_recursive_summarize_stops_when_hitting_max_tokens( + very_long_text: str, + steerable_long_context_summarize: SteerableLongContextSummarize, +) -> None: + max_tokens = 1000 + input = RecursiveSummarizeInput(text=very_long_text, max_tokens=max_tokens) + task = RecursiveSummarize(steerable_long_context_summarize) + output = task.run(input, NoOpTracer()) + + assert len(output.summary) < len(very_long_text) + assert output.generated_tokens < max_tokens + assert "new orleans" in output.summary.lower() + + +def test_recursive_summarize_stops_when_num_partial_summaries_stays_same( + steerable_long_context_summarize: SteerableLongContextSummarize, +) -> None: + max_tokens = 2048 + input = RecursiveSummarizeInput(text=short_text, max_tokens=max_tokens) + task = RecursiveSummarize(steerable_long_context_summarize) + output = task.run(input, NoOpTracer()) + + assert output.generated_tokens > 50 + + +def test_recursive_summarize_stops_when_num_partial_summaries_stays_same_with_empty_text( + steerable_long_context_summarize: SteerableLongContextSummarize, +) -> None: + max_tokens = 2048 + input = RecursiveSummarizeInput(text="", max_tokens=max_tokens) + task = RecursiveSummarize(steerable_long_context_summarize) + output = task.run(input, NoOpTracer()) + + assert output.generated_tokens == 0 + + +def test_recursive_summarize_stops_after_one_chunk( + recursive_counting_client: RecursiveCountingClient, +) -> None: + model = LuminousControlModel( + name="luminous-base-control", client=recursive_counting_client + ) + + long_context_high_compression_summarize = SteerableLongContextSummarize( + summarize=SteerableSingleChunkSummarize(model, max_generated_tokens=100), + chunk=Chunk(model, max_tokens_per_chunk=1500), + ) + input = RecursiveSummarizeInput(text=short_text) + task = RecursiveSummarize(long_context_high_compression_summarize) + task.run(input, NoOpTracer()) + + assert recursive_counting_client.recursive_counter == 1 diff --git a/tests/use_cases/summarize/test_steerable_long_context_summarize.py b/tests/use_cases/summarize/test_steerable_long_context_summarize.py new file mode 100644 index 000000000..f40e27944 --- /dev/null +++ b/tests/use_cases/summarize/test_steerable_long_context_summarize.py @@ -0,0 +1,59 @@ +from intelligence_layer.core import Chunk, Language, LuminousControlModel, NoOpTracer +from intelligence_layer.examples import ( + LongContextSummarizeInput, + SteerableLongContextSummarize, +) +from intelligence_layer.examples.summarize.steerable_single_chunk_summarize import ( + SteerableSingleChunkSummarize, +) + + +def test_steerable_long_context_summarize_en( + steerable_long_context_summarize: SteerableLongContextSummarize, + long_text: str, +) -> None: + input = LongContextSummarizeInput(text=long_text) + output = steerable_long_context_summarize.run(input, NoOpTracer()) + + assert output.partial_summaries + assert any( + "bear" in partial_summary.summary + for partial_summary in output.partial_summaries + ) + assert len( + " ".join( + partial_summary.summary for partial_summary in output.partial_summaries + ) + ) < len(long_text) + + +def test_steerable_long_context_summarize_adapts_to_instruction( + luminous_control_model: LuminousControlModel, + long_text: str, +) -> None: + input = LongContextSummarizeInput(text=long_text) + steerable_long_context_summarize_keyword = SteerableLongContextSummarize( + summarize=SteerableSingleChunkSummarize( + luminous_control_model, + max_generated_tokens=128, + instruction_configs={Language("en"): "Summarize using bullet points."}, + ), + chunk=Chunk(luminous_control_model, max_tokens_per_chunk=512), + ) + + output = steerable_long_context_summarize_keyword.run(input, NoOpTracer()) + + assert output.partial_summaries + assert any( + "bear" in partial_summary.summary + for partial_summary in output.partial_summaries + ) + assert all( + partial_summary.summary.startswith("- ") + for partial_summary in output.partial_summaries + ) + assert len( + " ".join( + partial_summary.summary for partial_summary in output.partial_summaries + ) + ) < len(long_text) diff --git a/tests/use_cases/summarize/test_summarize.py b/tests/use_cases/summarize/test_summarize.py new file mode 100644 index 000000000..350eb5c6b --- /dev/null +++ b/tests/use_cases/summarize/test_summarize.py @@ -0,0 +1,268 @@ +from pytest import fixture + +from intelligence_layer.core import Language, NoOpTracer, TextChunk +from intelligence_layer.evaluation import ( + Aggregator, + DatasetRepository, + EvaluationRepository, + Evaluator, + Example, + InMemoryAggregationRepository, + InMemoryDatasetRepository, + InMemoryEvaluationRepository, + InMemoryRunRepository, + Runner, + RunRepository, +) +from intelligence_layer.examples import ( + AggregatedSummarizeEvaluation, + LongContextSummarizeAggregationLogic, + LongContextSummarizeEvaluationLogic, + LongContextSummarizeInput, + LongContextSummarizeOutput, + SingleChunkSummarizeAggregationLogic, + SingleChunkSummarizeEvaluationLogic, + SingleChunkSummarizeInput, + SteerableLongContextSummarize, + SteerableSingleChunkSummarize, + SummarizeEvaluation, + SummarizeOutput, +) + + +@fixture +def single_chunk_summarize_aggregation_logic() -> SingleChunkSummarizeAggregationLogic: + return SingleChunkSummarizeAggregationLogic() + + +@fixture +def single_chunk_summarize_eval_logic() -> SingleChunkSummarizeEvaluationLogic: + return SingleChunkSummarizeEvaluationLogic() + + +@fixture +def single_chunk_summarize_evaluator( + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, + in_memory_evaluation_repository: InMemoryEvaluationRepository, + single_chunk_summarize_eval_logic: SingleChunkSummarizeEvaluationLogic, +) -> Evaluator[ + SingleChunkSummarizeInput, + SummarizeOutput, + str, + SummarizeEvaluation, +]: + return Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "single-chunk-summarize", + single_chunk_summarize_eval_logic, + ) + + +@fixture +def single_chunk_summarize_aggregator( + in_memory_evaluation_repository: InMemoryEvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + single_chunk_summarize_aggregation_logic: SingleChunkSummarizeAggregationLogic, +) -> Aggregator[SummarizeEvaluation, AggregatedSummarizeEvaluation]: + return Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "single-chunk-summarize", + single_chunk_summarize_aggregation_logic, + ) + + +@fixture +def single_chunk_summarize_runner( + steerable_single_chunk_summarize: SteerableSingleChunkSummarize, + in_memory_dataset_repository: InMemoryDatasetRepository, + in_memory_run_repository: InMemoryRunRepository, +) -> Runner[SingleChunkSummarizeInput, SummarizeOutput]: + return Runner( + steerable_single_chunk_summarize, + in_memory_dataset_repository, + in_memory_run_repository, + "single-chunk-summarize", + ) + + +@fixture +def long_context_summarize_evaluation_logic() -> LongContextSummarizeEvaluationLogic: + return LongContextSummarizeEvaluationLogic() + + +@fixture +def long_context_summarize_aggregation_logic() -> LongContextSummarizeAggregationLogic: + return LongContextSummarizeAggregationLogic() + + +@fixture +def long_context_summarize_evaluator( + in_memory_dataset_repository: DatasetRepository, + in_memory_run_repository: RunRepository, + in_memory_evaluation_repository: EvaluationRepository, + long_context_summarize_evaluation_logic: LongContextSummarizeEvaluationLogic, +) -> Evaluator[ + LongContextSummarizeInput, + LongContextSummarizeOutput, + str, + SummarizeEvaluation, +]: + return Evaluator( + in_memory_dataset_repository, + in_memory_run_repository, + in_memory_evaluation_repository, + "long-context-summarize", + long_context_summarize_evaluation_logic, + ) + + +@fixture +def long_context_summarize_aggregator( + in_memory_evaluation_repository: EvaluationRepository, + in_memory_aggregation_repository: InMemoryAggregationRepository, + long_context_summarize_aggregation_logic: LongContextSummarizeAggregationLogic, +) -> Aggregator[ + SummarizeEvaluation, + AggregatedSummarizeEvaluation, +]: + return Aggregator( + in_memory_evaluation_repository, + in_memory_aggregation_repository, + "long-context-summarize", + long_context_summarize_aggregation_logic, + ) + + +@fixture +def long_context_summarize_runner( + steerable_long_context_summarize: SteerableLongContextSummarize, + in_memory_dataset_repository: DatasetRepository, + in_memory_run_repository: InMemoryRunRepository, +) -> Runner[LongContextSummarizeInput, LongContextSummarizeOutput]: + return Runner( + steerable_long_context_summarize, + in_memory_dataset_repository, + in_memory_run_repository, + "long-context-summarize", + ) + + +def test_single_chunk_summarize_evaluator( + single_chunk_summarize_evaluator: Evaluator[ + SingleChunkSummarizeInput, + SummarizeOutput, + str, + SummarizeEvaluation, + ], + single_chunk_summarize_aggregator: Aggregator[ + SummarizeEvaluation, + AggregatedSummarizeEvaluation, + ], + single_chunk_summarize_runner: Runner[str, str], + chunk: TextChunk, + no_op_tracer: NoOpTracer, + in_memory_dataset_repository: InMemoryDatasetRepository, +) -> None: + input = SingleChunkSummarizeInput(chunk=chunk, language=Language("en")) + bad_example = Example( + input=input, expected_output="Heute ist das Wetter schön.", id="bad" + ) + good_example = Example( + input=input, + expected_output="The brown bear is a large mammal that lives in Eurasia and North America.", + id="good", + ) + dataset_id = in_memory_dataset_repository.create_dataset( + examples=[good_example, bad_example], dataset_name="test-dataset" + ).id + run_overview = single_chunk_summarize_runner.run_dataset(dataset_id) + + evaluation_overview = single_chunk_summarize_evaluator.evaluate_runs( + run_overview.id + ) + aggregation_overview = single_chunk_summarize_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + + assert aggregation_overview.successful_evaluation_count == 2 + individual_evaluation_id = next(iter(aggregation_overview.evaluation_overviews)).id + good_result = ( + single_chunk_summarize_evaluator._evaluation_repository.example_evaluation( + individual_evaluation_id, + "good", + SummarizeEvaluation, + ) + ) + bad_result = ( + single_chunk_summarize_evaluator._evaluation_repository.example_evaluation( + individual_evaluation_id, + "bad", + SummarizeEvaluation, + ) + ) + assert bad_result and isinstance(bad_result.result, SummarizeEvaluation) + assert good_result and isinstance(good_result.result, SummarizeEvaluation) + assert bad_result.result.bleu < good_result.result.bleu + assert bad_result.result.rouge < good_result.result.rouge + + +def test_long_context_summarize_evaluator( + long_context_summarize_evaluator: Evaluator[ + LongContextSummarizeInput, + LongContextSummarizeOutput, + str, + SummarizeEvaluation, + ], + long_context_summarize_aggregator: Aggregator[ + SummarizeEvaluation, + AggregatedSummarizeEvaluation, + ], + long_context_summarize_runner: Runner[str, str], + in_memory_dataset_repository: InMemoryDatasetRepository, + long_text: str, +) -> None: + input = LongContextSummarizeInput(text=long_text, language=Language("en")) + bad_example = Example( + input=input, expected_output="Heute ist das Wetter schön.", id="bad" + ) + good_example = Example( + input=input, + expected_output="The brown bear is a large mammal that lives in Eurasia and North America.", + id="good", + ) + dataset_id = in_memory_dataset_repository.create_dataset( + examples=[good_example, bad_example], dataset_name="test-dataset" + ).id + run_overview = long_context_summarize_runner.run_dataset(dataset_id) + + evaluation_overview = long_context_summarize_evaluator.evaluate_runs( + run_overview.id + ) + aggregation_overview = long_context_summarize_aggregator.aggregate_evaluation( + evaluation_overview.id + ) + + assert aggregation_overview.successful_evaluation_count == 2 + individual_evaluation_id = next(iter(aggregation_overview.evaluation_overviews)).id + good_result = ( + long_context_summarize_evaluator._evaluation_repository.example_evaluation( + individual_evaluation_id, + "good", + SummarizeEvaluation, + ) + ) + bad_result = ( + long_context_summarize_evaluator._evaluation_repository.example_evaluation( + individual_evaluation_id, + "bad", + SummarizeEvaluation, + ) + ) + assert bad_result and isinstance(bad_result.result, SummarizeEvaluation) + assert good_result and isinstance(good_result.result, SummarizeEvaluation) + assert bad_result.result.bleu < good_result.result.bleu + assert bad_result.result.rouge < good_result.result.rouge diff --git a/tests/use_cases/summarize/very_long_text.txt b/tests/use_cases/summarize/very_long_text.txt new file mode 100644 index 000000000..72a4bc53a --- /dev/null +++ b/tests/use_cases/summarize/very_long_text.txt @@ -0,0 +1,301 @@ +Main menu + +WikipediaThe Free Encyclopedia +Search Wikipedia +Search +Create account +Log in + +Personal tools +Contents hide +(Top) +Etymology and nicknames +History +Toggle History subsection +Geography +Toggle Geography subsection +Elevation +Cityscape +Historic and residential architecture +Tallest buildings +Climate +Threat from tropical cyclones +Demographics +Toggle Demographics subsection +Economy +Toggle Economy subsection +Culture and contemporary life +Toggle Culture and contemporary life subsection +Sports +National protected areas +Government +Crime +Education +Toggle Education subsection +Media +Transportation +Toggle Transportation subsection +Notable people +Sister cities +See also +Notes +References +Further reading +External links +New Orleans + +Article +Talk +Read +View source +View history + +Tools +Coordinates: 29°58′00″N 90°04′50″W +Page semi-protected +From Wikipedia, the free encyclopedia +For other uses, see New Orleans (disambiguation). +"The Big Easy", "NOLA", and "City of New Orleans" redirect here. For other uses, see The Big Easy (disambiguation), NOLA (disambiguation), and City of New Orleans (disambiguation). +New Orleans +La Nouvelle-Orléans (French) +Consolidated city-parish + +Central Business District + +Bourbon Street + +St. Louis Cathedral + +New Orleans Streetcars + +Superdome + +University of New Orleans + +Crescent City Connection +Flag of New Orleans +Flag +Official seal of New Orleans +Seal +Official logo of New Orleans +Logo +Nickname(s): "The Crescent City", "The Big Easy", "The City That Care Forgot", "NOLA", "The City of Yes", "Hollywood South", "The Creole City" +MapWikimedia | © OpenStreetMap +Interactive map of New Orleans +New Orleans is located in LouisianaNew OrleansNew Orleans +Location in Louisiana +Show map of Louisiana +Show map of the United States +Show all +Coordinates: 29°58′00″N 90°04′50″W +Country United States +State Louisiana +Parish Orleans +Founded 1718; 305 years ago +Founded by Jean-Baptiste Le Moyne de Bienville +Named for Philippe II, Duke of Orléans (1674–1723) +Government + • Type Mayor–council + • Mayor LaToya Cantrell (D) + • Council New Orleans City Council +Area[1] + • Consolidated city-parish 349.85 sq mi (906.10 km2) + • Land 169.42 sq mi (438.80 km2) + • Water 180.43 sq mi (467.30 km2) + • Metro 3,755.2 sq mi (9,726.6 km2) +Elevation −6.5 to 20 ft (−2 to 6 m) +Population (2020)[2] + • Consolidated city-parish 383,997 + • Density 2,267/sq mi (875/km2) + • Urban[3] 963,212 (US: 49th) + • Urban density 3,563.8/sq mi (1,376.0/km2) + • Metro 1,270,530 (US: 45th) +Demonym New Orleanian +Time zone UTC−6 (CST) + • Summer (DST) UTC−5 (CDT) +Area code 504 +FIPS code 22-55000 +GNIS feature ID 1629985 +Website nola.gov +New Orleans (/ˈɔːrl(i)ənz/ OR-l(ee)ənz, /ɔːrˈliːnz/ or-LEENZ,[4] locally /ˈɔːrlənz/ OR-lənz;[5] French: La Nouvelle-Orléans [la nuvɛlɔʁleɑ̃] ⓘ) is a consolidated city-parish located along the Mississippi River in the southeastern region of the U.S. state of Louisiana. With a population of 383,997 according to the 2020 U.S. census,[6] it is the most populous city in Louisiana, third most populous city in the Deep South, and the twelfth-most populous city in the southeastern United States. Serving as a major port, New Orleans is considered an economic and commercial hub for the broader Gulf Coast region of the United States. New Orleans is also a region of French Louisiana, along with the Cajun Country.[7] + +New Orleans is world-renowned for its distinctive music, Creole cuisine, unique dialects, and its annual celebrations and festivals, most notably Mardi Gras. The historic heart of the city is the French Quarter, known for its French and Spanish Creole architecture and vibrant nightlife along Bourbon Street. The city has been described as the "most unique" in the United States,[8][9][10][11] owing in large part to its cross-cultural and multilingual heritage.[12] Additionally, New Orleans has increasingly been known as "Hollywood South" due to its prominent role in the film industry and in pop culture.[13][14] + +Founded in 1718 by French colonists, New Orleans was once the territorial capital of French Louisiana before becoming part of the United States in the Louisiana Purchase of 1803. New Orleans in 1840 was the third most populous city in the United States,[15] and it was the largest city in the American South from the Antebellum era until after World War II. The city has historically been very vulnerable to flooding, due to its high rainfall, low lying elevation, poor natural drainage, and proximity to multiple bodies of water. State and federal authorities have installed a complex system of levees and drainage pumps in an effort to protect the city.[16][17] + +New Orleans was severely affected by Hurricane Katrina in August 2005, which flooded more than 80% of the city, killed more than 1,800 people, and displaced thousands of residents, causing a population decline of over 50%.[18] Since Katrina, major redevelopment efforts have led to a rebound in the city's population. Concerns have been expressed about gentrification, new residents buying property in formerly closely knit communities, and displacement of longtime residents.[19][20][21][22] Additionally, high rates of violent crime continue to plague the city with New Orleans experiencing 280 murders in 2022, resulting in the highest per capita homicide rate in the United States.[23][24] + +The city and Orleans Parish (French: paroisse d'Orléans) are coterminous.[25] As of 2017, Orleans Parish is the third most populous parish in Louisiana, behind East Baton Rouge Parish and neighboring Jefferson Parish.[26] The city and parish are bounded by St. Tammany Parish and Lake Pontchartrain to the north, St. Bernard Parish and Lake Borgne to the east, Plaquemines Parish to the south, and Jefferson Parish to the south and west. + +The city anchors the larger Greater New Orleans metropolitan area, which had a population of 1,271,845 in 2020.[27] Greater New Orleans is the most populous metropolitan statistical area (MSA) in Louisiana and, since the 2020 census, has been the 46th most populous MSA in the United States.[28] + +Etymology and nicknames + +The New Orleans cityscape in early February 2007 +The name of New Orleans derives from the original French name (La Nouvelle-Orléans), which was given to the city in honor of Philippe II, Duke of Orléans, who served as Louis XV's regent from 1715 to 1723.[29] The French city of Orléans itself is named after the Roman emperor Aurelian, originally being known as Aurelianum. Thus, by extension, since New Orleans is also named after Aurelian, its name in Latin would translate to Nova Aurelia. + +Following France's defeat in the Seven Years' War and the Treaty of Paris, which was signed in 1763, France transferred possession of Louisiana to Spain. The Spanish renamed the city to Nueva Orleans (pronounced [ˌnweβa oɾleˈans]), which was used until 1800.[30] When the United States acquired possession from France in 1803, the French name was adopted and anglicized to become the modern name, which is still in use today. + +New Orleans has several nicknames, including these: + +Crescent City, alluding to the course of the Lower Mississippi River around and through the city.[31] +The Big Easy, possibly a reference by musicians in the early 20th century to the relative ease of finding work there.[32][33] +The City that Care Forgot, used since at least 1938,[34] referring to the outwardly easygoing, carefree nature of the residents.[33] +NOLA, the acronym for New Orleans, Louisiana. +History +Main article: History of New Orleans +For a chronological guide, see Timeline of New Orleans. +French–Spanish colonial era +Main articles: Louisiana (New France), New France, Treaty of Paris (1763), Louisiana (New Spain), and New Spain +See also: Seven Years' War, French and Indian War, Gulf Coast campaign, Spain and the American Revolutionary War, Third Treaty of San Ildefonso, and Treaty of Aranjuez (1801) +Historical affiliations + Kingdom of France 1718–1763 + Kingdom of Spain 1763–1802 + French First Republic 1802–1803 + United States of America 1803–1861 + State of Louisiana 1861 + 1861–1862 + United States of America 1862–present + +La Nouvelle-Orléans (New Orleans) was founded in the spring of 1718 (May 7 has become the traditional date to mark the anniversary, but the actual day is unknown)[35] by the French Mississippi Company, under the direction of Jean-Baptiste Le Moyne de Bienville, on land inhabited by the Chitimacha. It was named for Philippe II, Duke of Orléans, who was regent of the Kingdom of France at the time.[29] His title came from the French city of Orléans. The French colony of Louisiana was ceded to the Spanish Empire in the 1763 Treaty of Paris, following France's defeat by Great Britain in the Seven Years' War. During the American Revolutionary War, New Orleans was an important port for smuggling aid to the American revolutionaries, and transporting military equipment and supplies up the Mississippi River. Beginning in the 1760s, Filipinos began to settle in and around New Orleans.[36] Bernardo de Gálvez y Madrid, Count of Gálvez successfully directed a southern campaign against the British from the city in 1779.[37] Nueva Orleans (the name of New Orleans in Spanish)[38] remained under Spanish control until 1803, when it reverted briefly to French rule. Nearly all of the surviving 18th-century architecture of the Vieux Carré (French Quarter) dates from the Spanish period, notably excepting the Old Ursuline Convent.[39] + + +The Revolt took place in what is now Natchez National Historical Park in Natchez, Mississippi. +As a French colony, Louisiana faced struggles with numerous Native American tribes, who were navigating the competing interests of France, Spain, and England, as well as traditional rivals. Notably, the Natchez, whose traditional lands were along the Mississippi near the modern city of Natchez, Mississippi, had a series of wars culminating in the Natchez Revolt that began in 1729 with the Natchez overrunning Fort Rosalie. Approximately 230 French colonists were killed and the Natchez settlement destroyed, causing fear and concern in New Orleans and the rest of the territory.[40] In retaliation, then-governor Étienne Perier launched a campaign to completely destroy the Natchez nation and its Native allies.[41] By 1731, the Natchez people had been killed, enslaved, or dispersed among other tribes, but the campaign soured relations between France and the territory's Native Americans leading directly into the Chickasaw Wars of the 1730s.[42] + +Relations with Louisiana's Native American population remained a concern into the 1740s for governor Marquis de Vaudreuil. In the early 1740s traders from the Thirteen Colonies crossed into the Appalachian Mountains. The Native American tribes would now operate dependent on which of various European colonists would most benefit them. Several of these tribes and especially the Chickasaw and Choctaw would trade goods and gifts for their loyalty.[43] The economic issue in the colony, which continued under Vaudreuil, resulted in many raids by Native American tribes, taking advantage of the French weakness. In 1747 and 1748, the Chickasaw would raid along the east bank of the Mississippi all the way south to Baton Rouge. These raids would often force residents of French Louisiana to take refuge in New Orleans proper. + +Inability to find labor was the most pressing issue in the young colony. The colonists turned to sub-Saharan African slaves to make their investments in Louisiana profitable. In the late 1710s the transatlantic slave trade imported enslaved Africans into the colony. This led to the biggest shipment in 1716 where several trading ships appeared with slaves as cargo to the local residents in a one-year span. + +By 1724, the large number of blacks in Louisiana prompted the institutionalizing of laws governing slavery within the colony.[44] These laws required that slaves be baptized in the Roman Catholic faith, slaves be married in the church; the slave law formed in the 1720s is known as the Code Noir, which would bleed into the antebellum period of the American South as well. Louisiana slave culture had its own distinct Afro-Creole society that called on past cultures and the situation for slaves in the New World. Afro-Creole was present in religious beliefs and the Louisiana Creole language. The religion most associated with this period was called Voodoo.[45][46] + +In the city of New Orleans an inspiring mixture of foreign influences created a melting pot of culture that is still celebrated today. By the end of French colonization in Louisiana, New Orleans was recognized commercially in the Atlantic world. Its inhabitants traded across the French commercial system. New Orleans was a hub for this trade both physically and culturally because it served as the exit point to the rest of the globe for the interior of the North American continent. + +In one instance the French government established a chapter house of sisters in New Orleans. The Ursuline sisters after being sponsored by the Company of the Indies, founded a convent in the city in 1727.[47] At the end of the colonial era, the Ursuline Academy maintained a house of 70 boarding and 100 day students. Today numerous schools in New Orleans can trace their lineage from this academy. + + +1724 plan for Saint Louis Parish Church, New Orleans, Louisiana, by Adrien de Pauger +Another notable example is the street plan and architecture still distinguishing New Orleans today. French Louisiana had early architects in the province who were trained as military engineers and were now assigned to design government buildings. Pierre Le Blond de Tour and Adrien de Pauger, for example, planned many early fortifications, along with the street plan for the city of New Orleans.[48] After them in the 1740s, Ignace François Broutin, as engineer-in-chief of Louisiana, reworked the architecture of New Orleans with an extensive public works program. + +French policy-makers in Paris attempted to set political and economic norms for New Orleans. It acted autonomously in much of its cultural and physical aspects, but also stayed in communication with the foreign trends as well. + +After the French relinquished West Louisiana to the Spanish, New Orleans merchants attempted to ignore Spanish rule and even re-institute French control on the colony. The citizens of New Orleans held a series of public meetings during 1765 to keep the populace in opposition of the establishment of Spanish rule. Anti-Spanish passions in New Orleans reached their highest level after two years of Spanish administration in Louisiana. On October 27, 1768, a mob of local residents, spiked the guns guarding New Orleans and took control of the city from the Spanish.[49] The rebellion organized a group to sail for Paris, where it met with officials of the French government. This group brought with them a long memorial to summarize the abuses the colony had endured from the Spanish. King Louis XV and his ministers reaffirmed Spain's sovereignty over Louisiana. + +United States territorial era +Main articles: Louisiana Purchase, Territory of Orleans, and Dominican Creoles +The Third Treaty of San Ildefonso in 1800 restored French control of New Orleans and Louisiana, but Napoleon sold both to the United States in the Louisiana Purchase in 1803.[50] Thereafter, the city grew rapidly with influxes of Americans, French, Creoles and Africans. Later immigrants were Irish, Germans, Poles and Italians. Major commodity crops of sugar and cotton were cultivated with slave labor on nearby large plantations. + +Between 1791 and 1810, thousands of St. Dominican refugees from the Haitian Revolution, both whites and free people of color (affranchis or gens de couleur libres), arrived in New Orleans; a number brought their slaves with them, many of whom were native Africans or of full-blood descent.[51] While Governor Claiborne and other officials wanted to keep out additional free black people, the French Creoles wanted to increase the French-speaking population. In addition to bolstering the territory's French-speaking population, these refugees had a significant impact on the culture of Louisiana, including developing its sugar industry and cultural institutions.[52] + +As more refugees were allowed into the Territory of Orleans, St. Dominican refugees who had first gone to Cuba also arrived.[53] Many of the white Francophones had been deported by officials in Cuba in 1809 as retaliation for Bonapartist schemes.[54] Nearly 90 percent of these immigrants settled in New Orleans. The 1809 migration brought 2,731 whites, 3,102 free people of color (of mixed-race European and African descent), and 3,226 slaves of primarily African descent, doubling the city's population. The city became 63 percent black, a greater proportion than Charleston, South Carolina's 53 percent at that time.[53] + +Battle of New Orleans +Main articles: Battle of New Orleans and War of 1812 + +The Battle of New Orleans (1815) +Plan of the city and suburbs of New Orleans : from an actual survey made in 1815 +Plan of the city and suburbs of New Orleans: from a survey made in 1815[55] +During the final campaign of the War of 1812, the British sent a force of 11,000 in an attempt to capture New Orleans. Despite great challenges, General Andrew Jackson, with support from the U.S. Navy, successfully cobbled together a force of militia from Louisiana and Mississippi, U.S. Army regulars, a large contingent of Tennessee state militia, Kentucky frontiersmen and local privateers (the latter led by the pirate Jean Lafitte), to decisively defeat the British, led by Sir Edward Pakenham, in the Battle of New Orleans on January 8, 1815.[56] + +The armies had not learned of the Treaty of Ghent, which had been signed on December 24, 1814 (however, the treaty did not call for cessation of hostilities until after both governments had ratified it. The U.S. government ratified it on February 16, 1815). The fighting in Louisiana began in December 1814 and did not end until late January, after the Americans held off the Royal Navy during a ten-day siege of Fort St. Philip (the Royal Navy went on to capture Fort Bowyer near Mobile, before the commanders received news of the peace treaty).[56] + +Port + +Mississippi River steamboats at New Orleans, 1853 +As a port, New Orleans played a major role during the antebellum period in the Atlantic slave trade. The port handled commodities for export from the interior and imported goods from other countries, which were warehoused and transferred in New Orleans to smaller vessels and distributed along the Mississippi River watershed. The river was filled with steamboats, flatboats and sailing ships. Despite its role in the slave trade, New Orleans at the time also had the largest and most prosperous community of free persons of color in the nation, who were often educated, middle-class property owners.[57][58] + +Dwarfing the other cities in the Antebellum South, New Orleans had the U.S.' largest slave market. The market expanded after the United States ended the international trade in 1808. Two-thirds of the more than one million slaves brought to the Deep South arrived via forced migration in the domestic slave trade. The money generated by the sale of slaves in the Upper South has been estimated at 15 percent of the value of the staple crop economy. The slaves were collectively valued at half a billion dollars. The trade spawned an ancillary economy—transportation, housing and clothing, fees, etc., estimated at 13.5 percent of the price per person, amounting to tens of billions of dollars (2005 dollars, adjusted for inflation) during the antebellum period, with New Orleans as a prime beneficiary.[59] + +According to historian Paul Lachance, + +the addition of white immigrants [from Saint-Domingue] to the white creole population enabled French-speakers to remain a majority of the white population until almost 1830. If a substantial proportion of free persons of color and slaves had not also spoken French, however, the Gallic community would have become a minority of the total population as early as 1820.[60] + +After the Louisiana Purchase, numerous Anglo-Americans migrated to the city. The population doubled in the 1830s and by 1840, New Orleans had become the nation's wealthiest and the third-most populous city, after New York and Baltimore.[61] German and Irish immigrants began arriving in the 1840s, working as port laborers. In this period, the state legislature passed more restrictions on manumissions of slaves and virtually ended it in 1852.[62] + +In the 1850s, white Francophones remained an intact and vibrant community in New Orleans. They maintained instruction in French in two of the city's four school districts (all served white students).[63] In 1860, the city had 13,000 free people of color (gens de couleur libres), the class of free, mostly mixed-race people that expanded in number during French and Spanish rule. They set up some private schools for their children. The census recorded 81 percent of the free people of color as mulatto, a term used to cover all degrees of mixed race.[62] Mostly part of the Francophone group, they constituted the artisan, educated and professional class of African Americans. The mass of blacks were still enslaved, working at the port, in domestic service, in crafts, and mostly on the many large, surrounding sugarcane plantations. + +Throughout New Orleans' history, until the early 20th century when medical and scientific advances ameliorated the situation, the city suffered repeated epidemics of yellow fever and other tropical and infectious diseases.[64] In the first half of the 19th century, yellow fever epidemics killed over 150,000 people in New Orleans.[65] + +After growing by 45 percent in the 1850s, by 1860, the city had nearly 170,000 people.[66] It had grown in wealth, with a "per capita income [that] was second in the nation and the highest in the South."[66] The city had a role as the "primary commercial gateway for the nation's booming midsection."[66] The port was the nation's third largest in terms of tonnage of imported goods, after Boston and New York, handling 659,000 tons in 1859.[66] + +Civil War–Reconstruction era +See also: New Orleans in the American Civil War and Louisiana in the American Civil War + +The starving people of New Orleans under Union occupation during the Civil War, 1862 +As the Creole elite feared, the American Civil War changed their world. In April 1862, following the city's occupation by the Union Navy after the Battle of Forts Jackson and St. Philip, Gen. Benjamin F. Butler – a respected Massachusetts lawyer serving in that state's militia – was appointed military governor. New Orleans residents supportive of the Confederacy nicknamed him "Beast" Butler, because of an order he issued. After his troops had been assaulted and harassed in the streets by women still loyal to the Confederate cause, his order warned that such future occurrences would result in his men treating such women as those "plying their avocation in the streets", implying that they would treat the women like prostitutes. Accounts of this spread widely. He also came to be called "Spoons" Butler because of the alleged looting that his troops did while occupying the city, during which time he himself supposedly pilfered silver flatware.[67] + +Significantly, Butler abolished French-language instruction in city schools. Statewide measures in 1864 and, after the war, 1868 further strengthened the English-only policy imposed by federal representatives. With the predominance of English speakers, that language had already become dominant in business and government.[63] By the end of the 19th century, French usage had faded. It was also under pressure from Irish, Italian and German immigrants.[68] However, as late as 1902 "one-fourth of the population of the city spoke French in ordinary daily intercourse, while another two-fourths was able to understand the language perfectly,"[69] and as late as 1945, many elderly Creole women spoke no English.[70] The last major French language newspaper, L'Abeille de la Nouvelle-Orléans (New Orleans Bee), ceased publication on December 27, 1923, after 96 years.[71] According to some sources, Le Courrier de la Nouvelle Orleans continued until 1955.[72] + +As the city was captured and occupied early in the war, it was spared the destruction through warfare suffered by many other cities of the American South. The Union Army eventually extended its control north along the Mississippi River and along the coastal areas. As a result, most of the southern portion of Louisiana was originally exempted from the liberating provisions of the 1863 Emancipation Proclamation issued by President Abraham Lincoln. Large numbers of rural ex-slaves and some free people of color from the city volunteered for the first regiments of Black troops in the War. Led by Brigadier General Daniel Ullman (1810–1892), of the 78th Regiment of New York State Volunteers Militia, they were known as the "Corps d'Afrique". While that name had been used by a militia before the war, that group was composed of free people of color. The new group was made up mostly of former slaves. They were supplemented in the last two years of the War by newly organized United States Colored Troops, who played an increasingly important part in the war.[73] + +Violence throughout the South, especially the Memphis Riots of 1866 followed by the New Orleans Riot in the same year, led Congress to pass the Reconstruction Act and the Fourteenth Amendment, extending the protections of full citizenship to freedmen and free people of color. Louisiana and Texas were put under the authority of the "Fifth Military District" of the United States during Reconstruction. Louisiana was readmitted to the Union in 1868. Its Constitution of 1868 granted universal male suffrage and established universal public education. Both blacks and whites were elected to local and state offices. In 1872, lieutenant governor P.B.S. Pinchback, who was of mixed race, succeeded Henry Clay Warmouth for a brief period as Republican governor of Louisiana, becoming the first governor of African descent of a U.S. state (the next African American to serve as governor of a U.S. state was Douglas Wilder, elected in Virginia in 1989). New Orleans operated a racially integrated public school system during this period. + +Wartime damage to levees and cities along the Mississippi River adversely affected southern crops and trade. The federal government contributed to restoring infrastructure. The nationwide financial recession and Panic of 1873 adversely affected businesses and slowed economic recovery. + + +Black and white dockworkers resting on cotton bales. +From 1868, elections in Louisiana were marked by violence, as white insurgents tried to suppress black voting and disrupt Republican Party gatherings. The disputed 1872 gubernatorial election resulted in conflicts that ran for years. The "White League", an insurgent paramilitary group that supported the Democratic Party, was organized in 1874 and operated in the open, violently suppressing the black vote and running off Republican officeholders. In 1874, in the Battle of Liberty Place, 5,000 members of the White League fought with city police to take over the state offices for the Democratic candidate for governor, holding them for three days. By 1876, such tactics resulted in the white Democrats, the so-called Redeemers, regaining political control of the state legislature. The federal government gave up and withdrew its troops in 1877, ending Reconstruction. + +In 1892 the racially integrated unions of New Orleans led a general strike in the city from November 8 to 12, shutting down the city & winning the vast majority of their demands.[74][75] + +Jim Crow era +Dixiecrats passed Jim Crow laws, establishing racial segregation in public facilities. In 1889, the legislature passed a constitutional amendment incorporating a "grandfather clause" that effectively disfranchised freedmen as well as the propertied people of color manumitted before the war. Unable to vote, African Americans could not serve on juries or in local office, and were closed out of formal politics for generations. The Southern U.S. was ruled by a white Democratic Party. Public schools were racially segregated and remained so until 1960. + +New Orleans' large community of well-educated, often French-speaking free persons of color (gens de couleur libres), who had been free prior to the Civil War, fought against Jim Crow. They organized the Comité des Citoyens (Citizens Committee) to work for civil rights. As part of their legal campaign, they recruited one of their own, Homer Plessy, to test whether Louisiana's newly enacted Separate Car Act was constitutional. Plessy boarded a commuter train departing New Orleans for Covington, Louisiana, sat in the car reserved for whites only, and was arrested. The case resulting from this incident, Plessy v. Ferguson, was heard by the U.S. Supreme Court in 1896. The court ruled that "separate but equal" accommodations were constitutional, effectively upholding Jim Crow measures. + +In practice, African American public schools and facilities were underfunded across the South. The Supreme Court ruling contributed to this period as the nadir of race relations in the United States. The rate of lynchings of black men was high across the South, as other states also disfranchised blacks and sought to impose Jim Crow. Nativist prejudices also surfaced. Anti-Italian sentiment in 1891 contributed to the lynchings of 11 Italians, some of whom had been acquitted of the murder of the police chief. Some were shot and killed in the jail where they were detained. It was the largest mass lynching in U.S. history.[76][77] In July 1900 the city was swept by white mobs rioting after Robert Charles, a young African American, killed a policeman and temporarily escaped. The mob killed him and an estimated 20 other blacks; seven whites died in the days-long conflict, until a state militia suppressed it. + +20th century + +Esplanade Avenue at Burgundy Street, looking lakewards (north) towards Lake Pontchartrain in 1900 + +1943 waiting line at wartime Rationing Board office in New Orleans + +Richard Nixon in New Orleans, August 1970. Royal at Iberville Streets, heading to Canal Street. +New Orleans' economic and population zenith in relation to other American cities occurred in the antebellum period. It was the nation's fifth-largest city in 1860 (after New York, Philadelphia, Boston and Baltimore) and was significantly larger than all other southern cities.[78] From the mid-19th century onward rapid economic growth shifted to other areas, while New Orleans' relative importance steadily declined. The growth of railways and highways decreased river traffic, diverting goods to other transportation corridors and markets.[78] Thousands of the most ambitious people of color left the state in the Great Migration around World War II and after, many for West Coast destinations. From the late 1800s, most censuses recorded New Orleans slipping down the ranks in the list of largest American cities (New Orleans' population still continued to increase throughout the period, but at a slower rate than before the Civil War). + +In 1929 the New Orleans streetcar strike during which serious unrest occurred.[79] It is also credited for the creation of the distinctly Louisianan Po' boy sandwich.[80][81] + +By the mid-20th century, New Orleanians recognized that their city was no longer the leading urban area in the South. By 1950, Houston, Dallas, and Atlanta exceeded New Orleans in size, and in 1960 Miami eclipsed New Orleans, even as the latter's population reached its historic peak.[78] As with other older American cities, highway construction and suburban development drew residents from the center city to newer housing outside. The 1970 census recorded the first absolute decline in population since the city became part of the United States in 1803. The Greater New Orleans metropolitan area continued expanding in population, albeit more slowly than other major Sun Belt cities. While the port remained one of the nation's largest, automation and containerization cost many jobs. The city's former role as banker to the South was supplanted by larger peer cities. New Orleans' economy had always been based more on trade and financial services than on manufacturing, but the city's relatively small manufacturing sector also shrank after World War II. Despite some economic development successes under the administrations of DeLesseps "Chep" Morrison (1946–1961) and Victor "Vic" Schiro (1961–1970), metropolitan New Orleans' growth rate consistently lagged behind more vigorous cities. + +Civil Rights movement +During the later years of Morrison's administration, and for the entirety of Schiro's, the city was a center of the Civil Rights movement. The Southern Christian Leadership Conference was founded in New Orleans, and lunch counter sit-ins were held in Canal Street department stores. A prominent and violent series of confrontations occurred in 1960 when the city attempted school desegregation, following the Supreme Court ruling in Brown v. Board of Education (1954). When six-year-old Ruby Bridges integrated William Frantz Elementary School in the Ninth Ward, she was the first child of color to attend a previously all-white school in the South. Much controversy preceded the 1956 Sugar Bowl at Tulane Stadium, when the Pitt Panthers, with African-American fullback Bobby Grier on the roster, met the Georgia Tech Yellow Jackets.[82] There had been controversy over whether Grier should be allowed to play due to his race, and whether Georgia Tech should even play at all due to Georgia's Governor Marvin Griffin's opposition to racial integration.[83][84][85] After Griffin publicly sent a telegram to the state's Board Of Regents requesting Georgia Tech not to engage in racially integrated events, Georgia Tech's president Blake R. Van Leer rejected the request and threatened to resign. The game went on as planned. [86] + +The Civil Rights movement's success in gaining federal passage of the Civil Rights Act of 1964 and the Voting Rights Act of 1965 renewed constitutional rights, including voting for blacks. Together, these resulted in the most far-reaching changes in New Orleans' 20th century history.[87] Though legal and civil equality were re-established by the end of the 1960s, a large gap in income levels and educational attainment persisted between the city's White and African American communities.[88] As the middle class and wealthier members of both races left the center city, its population's income level dropped, and it became proportionately more African American. From 1980, the African American majority elected primarily officials from its own community. They struggled to narrow the gap by creating conditions conducive to the economic uplift of the African American community. + +New Orleans became increasingly dependent on tourism as an economic mainstay during the administrations of Sidney Barthelemy (1986–1994) and Marc Morial (1994–2002). Relatively low levels of educational attainment, high rates of household poverty, and rising crime threatened the city's prosperity in the later decades of the century.[88] The negative effects of these socioeconomic conditions aligned poorly with the changes in the late-20th century to the economy of the United States, which reflected a post-industrial, knowledge-based paradigm in which mental skills and education were more important to advancement than manual skills. + +Drainage and flood control +See also: Drainage in New Orleans + +A view of the New Orleans Central Business District, as seen from the Mississippi River USS New Orleans (LPD-18) in foreground (2007) +In the 20th century, New Orleans' government and business leaders believed they needed to drain and develop outlying areas to provide for the city's expansion. The most ambitious development during this period was a drainage plan devised by engineer and inventor A. Baldwin Wood, designed to break the surrounding swamp's stranglehold on the city's geographic expansion. Until then, urban development in New Orleans was largely limited to higher ground along the natural river levees and bayous. + +Wood's pump system allowed the city to drain huge tracts of swamp and marshland and expand into low-lying areas. Over the 20th century, rapid subsidence, both natural and human-induced, resulted in these newly populated areas subsiding to several feet below sea level.[89][90] + +New Orleans was vulnerable to flooding even before the city's footprint departed from the natural high ground near the Mississippi River. In the late 20th century, however, scientists and New Orleans residents gradually became aware of the city's increased vulnerability. In 1965, flooding from Hurricane Betsy killed dozens of residents, although the majority of the city remained dry. The rain-induced flood of May 8, 1995, demonstrated the weakness of the pumping system. After that event, measures were undertaken to dramatically upgrade pumping capacity. By the 1980s and 1990s, scientists observed that extensive, rapid, and ongoing erosion of the marshlands and swamp surrounding New Orleans, especially that related to the Mississippi River–Gulf Outlet Canal, had the unintended result of leaving the city more vulnerable than before to hurricane-induced catastrophic storm surges. + +21st century +Hurricane Katrina + +Hurricane Katrina at its New Orleans landfall +See also: Effects of Hurricane Katrina in New Orleans and Drainage in New Orleans +New Orleans was catastrophically affected by what Raymond B. Seed called "the worst engineering disaster in the world since Chernobyl", when the federal levee system failed during Hurricane Katrina on August 29, 2005.[91] By the time the hurricane approached the city on August 29, 2005, most residents had evacuated. As the hurricane passed through the Gulf Coast region, the city's federal flood protection system failed, resulting in the worst civil engineering disaster in American history at the time.[92] Floodwalls and levees constructed by the United States Army Corps of Engineers failed below design specifications and 80% of the city flooded. Tens of thousands of residents who had remained were rescued or otherwise made their way to shelters of last resort at the Louisiana Superdome or the New Orleans Morial Convention Center. More than 1,500 people were recorded as having died in Louisiana, most in New Orleans, while others remain unaccounted for.[93][94] Before Hurricane Katrina, the city called for the first mandatory evacuation in its history, to be followed by another mandatory evacuation three years later with Hurricane Gustav. + +Hurricane Rita +Main article: Hurricane Rita +The city was declared off-limits to residents while efforts to clean up after Hurricane Katrina began. The approach of Hurricane Rita in September 2005 caused repopulation efforts to be postponed,[95] and the Lower Ninth Ward was reflooded by Rita's storm surge.[94] + +Post-disaster recovery +Main article: Reconstruction of New Orleans + +An aerial view from a United States Navy helicopter showing floodwaters around the Louisiana Superdome (stadium) and surrounding area (2005) +Because of the scale of damage, many people resettled permanently outside the area. Federal, state, and local efforts supported recovery and rebuilding in severely damaged neighborhoods. The U.S. Census Bureau in July 2006 estimated the population to be 223,000; a subsequent study estimated that 32,000 additional residents had moved to the city as of March 2007, bringing the estimated population to 255,000, approximately 56% of the pre-Katrina population level. Another estimate, based on utility usage from July 2007, estimated the population to be approximately 274,000 or 60% of the pre-Katrina population. These estimates are somewhat smaller to a third estimate, based on mail delivery records, from the Greater New Orleans Community Data Center in June 2007, which indicated that the city had regained approximately two-thirds of its pre-Katrina population.[96] In 2008, the U.S. Census Bureau revised its population estimate for the city upward, to 336,644.[97] Most recently, by July 2015, the population was back up to 386,617—80% of what it was in 2000.[98] + +Several major tourist events and other forms of revenue for the city have returned. Large conventions returned.[99][100] College bowl games returned for the 2006–2007 season. The New Orleans Saints returned that season. The New Orleans Hornets (now named the Pelicans) returned to the city for the 2007–2008 season. New Orleans hosted the 2008 NBA All-Star Game in addition to Super Bowl XLVII. + +Major annual events such as Mardi Gras, Voodoo Experience, and the Jazz & Heritage Festival were never displaced or canceled. A new annual festival, "The Running of the Bulls New Orleans", was created in 2007.[101] + +Hurricane Ida +Main article: Hurricane Ida +On August 29, 2021, Hurricane Ida, a category 4 hurricane, made landfall West of New Orleans, where the Hurricane Ida tornado outbreak caused damage.