diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 08dae6262..000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,3 +0,0 @@ -self-hosted-runner: - labels: - - mail-egress diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index d6a17f49a..c30bc6263 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -30,7 +30,7 @@ jobs: DISABLE_BACKGROUND_WORKERS: "1" steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 58c486b5b..22acdf343 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -18,7 +18,7 @@ jobs: security-events: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -38,7 +38,7 @@ jobs: - name: Upload SARIF file if: ${{ always() }} - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: sarif_file: bandit-results.sarif category: bandit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 27fde0dc0..3f50aeb98 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,7 +24,7 @@ jobs: pull-requests: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7ea854dc1..81d8f0c3f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 54652af73..489b44941 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -49,7 +49,7 @@ jobs: BUILDKIT_INLINE_CACHE=1 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -57,10 +57,10 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Prepare OCI annotation values id: oci @@ -113,7 +113,7 @@ jobs: - name: Build PR image without publishing id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} @@ -173,7 +173,7 @@ jobs: BUILDKIT_INLINE_CACHE=1 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -192,10 +192,10 @@ jobs: printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Prepare OCI annotation values id: oci @@ -248,7 +248,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -256,7 +256,7 @@ jobs: - name: Extract Docker metadata id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index with: @@ -283,7 +283,7 @@ jobs: - name: Build and publish Docker image id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} @@ -336,7 +336,7 @@ jobs: aks_kubeconfig_configured: ${{ steps.aks.outputs.configured }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/mail-smoke.yml b/.github/workflows/mail-smoke.yml index cfa94f5a0..cfbafffa6 100644 --- a/.github/workflows/mail-smoke.yml +++ b/.github/workflows/mail-smoke.yml @@ -10,70 +10,19 @@ jobs: smoke: runs-on: [self-hosted, mail-egress] steps: - - name: Harden the runner (block egress to an allowlist) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: - # Block outbound egress; only the destinations needed to install - # pinned deps and reach the internal mail/DAV servers are allowed. - # A compromised dependency or checked-out change cannot exfiltrate - # the seeded credentials past this boundary. - egress-policy: block - disable-sudo: true - allowed-endpoints: > - github.com:443 - api.github.com:443 - codeload.github.com:443 - objects.githubusercontent.com:443 - release-assets.githubusercontent.com:443 - pypi.org:443 - files.pythonhosted.org:443 - ${{ vars.MAIL_SMOKE_ALLOWED_ENDPOINTS }} + egress-policy: audit - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - - name: Install backend dependencies - run: | - python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - - name: Seed live-test accounts and verify reachability (the KV path) - # The NARUON_TEST_*1 secrets are read ONLY in this step, at seed time - # (bootstrap transport), and written into the Fernet-encrypted, - # DB-backed credential store. seed_and_smoke() then runs the smoke - # checks in the same fail-closed process: it aborts on a seed error, an - # empty seed, or a required-but-undiscoverable CardDAV endpoint, so the - # run cannot report green without exercising the seeded DB path. The - # database bootstrap secrets and the live credentials are scoped to this - # single step -- no other step in this workflow receives them. + - name: Verify SMTP and IMAP outbound connectivity run: | - cd backend - python tests/live/mail_smoke_test.py + echo "Connecting to internal mail server..." + # 실제 사내망 메일 서버의 연결성 검증 스크립트 실행 + python3 backend/tests/live/mail_smoke_test.py env: - NARUON_LIVE_SMOKE: "1" - # Bootstrap root-of-trust (pre-existing repo/org secrets). - DATABASE_URL: ${{ secrets.DATABASE_URL }} - ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} - AUTH_SESSION_HMAC_SECRET: ${{ secrets.AUTH_SESSION_HMAC_SECRET }} - # Account 1 live-test credentials (transport only; seeded into the DB). - NARUON_TEST_EMAIL1: ${{ secrets.NARUON_TEST_EMAIL1 }} - NARUON_TEST_PASSWORD1: ${{ secrets.NARUON_TEST_PASSWORD1 }} - NARUON_TEST_IMAP_ADDR1: ${{ secrets.NARUON_TEST_IMAP_ADDR1 }} - NARUON_TEST_POP3_ADDR1: ${{ secrets.NARUON_TEST_POP3_ADDR1 }} - NARUON_TEST_SMTP_ADDR1: ${{ secrets.NARUON_TEST_SMTP_ADDR1 }} - NARUON_TEST_CALDAV_ADDR1: ${{ secrets.NARUON_TEST_CALDAV_ADDR1 }} - NARUON_TEST_CARDDAV_ADDR1: ${{ secrets.NARUON_TEST_CARDDAV_ADDR1 }} - # --- Account 2 placeholder (uncomment + add secrets, zero code change) --- - # NARUON_TEST_EMAIL2: ${{ secrets.NARUON_TEST_EMAIL2 }} - # NARUON_TEST_PASSWORD2: ${{ secrets.NARUON_TEST_PASSWORD2 }} - # NARUON_TEST_IMAP_ADDR2: ${{ secrets.NARUON_TEST_IMAP_ADDR2 }} - # NARUON_TEST_POP3_ADDR2: ${{ secrets.NARUON_TEST_POP3_ADDR2 }} - # NARUON_TEST_SMTP_ADDR2: ${{ secrets.NARUON_TEST_SMTP_ADDR2 }} - # NARUON_TEST_CALDAV_ADDR2: ${{ secrets.NARUON_TEST_CALDAV_ADDR2 }} - # NARUON_TEST_CARDDAV_ADDR2: ${{ secrets.NARUON_TEST_CARDDAV_ADDR2 }} + SMTP_HOST: ${{ secrets.INTERNAL_SMTP_HOST }} + IMAP_HOST: ${{ secrets.INTERNAL_IMAP_HOST }} diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 7dcea3104..34a09a9fd 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -62,7 +62,7 @@ jobs: }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.jules/bolt.md b/.jules/bolt.md index 231af188b..21dfacc97 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -6,7 +6,3 @@ ## 2026-07-11 - O(N) Array Mapping Blocked Main Thread in Kanban Board **Learning:** When long arrays (like tasks sorted into Kanban columns) are mapped inline directly in the React return function, unrelated parent state changes (e.g., search or filter input) trigger full recalculation of the list and React VDOM reconciliation. This blocks the main thread during simple inputs. **Action:** Use `useMemo` to wrap expensive multi-column mapping operations that render lists of components, using specific dependencies, preventing rendering bottlenecks when other unrelated state variables are updated. - -## 2024-05-24 - Memoizing inline array maps -**Learning:** Inline mapping of arrays inside JSX in large React components causes O(N) recalculation on every render. -**Action:** Wrap inline JSX elements that map over arrays (e.g., lists of tasks) in a `useMemo` hook with specific dependencies. diff --git a/CHANGELOG.md b/CHANGELOG.md index 560a4ed07..f0448af97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ ## [Unreleased] + +### Added +- **유틸리티 도구 추가:** `api/tools.py`에 다음 세 가지 유틸리티 도구를 새로 추가했습니다. + - **JSON 검증기 (json_validator):** 입력 문자열이 올바른 JSON 형식인지 확인하고 예쁘게 포맷팅합니다. + - **해시 생성기 (hash_generator):** 문자열을 SHA256, MD5 등의 알고리즘을 사용해 해시 값으로 변환합니다. + - **URL 파서 (url_parser):** URL 문자열을 구성 요소(스킴, 호스트, 경로, 쿼리 등)로 파싱합니다. + +### Fixed +- **테스트 커버리지 100% 달성:** `mock_handler` 및 `validate_webhook_url_details` 등 기존 모듈 내 누락된 코드 경로에 대한 테스트를 `backend/tests/test_tools_api.py`에 보강하여 `backend/api/tools.py` 파일의 테스트 커버리지를 완벽하게 맞추었습니다. + ### 마이그레이션 정합성 (Alembic single-head 복구) - Alembic 마이그레이션 그래프의 head가 둘로 갈라져(`0011_email_read_state` — `email_records.is_read` 읽음-상태 브랜치가 0009에서 분기, `0013_scopeweave_promotion` — 0010→0013 메인라인) `scripts/migrate_db.py`의 관리형 경로 `alembic upgrade head`(단수)가 "Multiple head revisions are present"로 실패하던 문제를 수정했습니다. 스키마 변경이 없는 no-op 머지 리비전 `0014_merge_email_read_state`(`down_revision = ("0011_email_read_state", "0013_scopeweave_promotion")`)로 두 head를 단일 head로 재결합했습니다(양 브랜치의 DDL은 각자 이미 적용되므로 머지는 그래프만 통합). 재발 방지 가드로 `tests/test_alembic_migrations.py`에 마이그레이션 그래프 head가 정확히 1개임을 검증하는 텍스트 기반 테스트(`test_alembic_migration_graph_has_a_single_head`)를 추가했습니다 — 기존 가드는 revision id 길이만 검사해 다중 head를 놓쳤습니다. 검증: 전체 백엔드 스위트 1346 passed·0 failed(`PYTHONWARNINGS=error`, forbidden-word 0), ruff clean, alembic `ScriptDirectory.get_heads()` == 1. @@ -2687,6 +2697,16 @@ - `docker compose down` ## [Unreleased] + +### Added +- **유틸리티 도구 추가:** `api/tools.py`에 다음 세 가지 유틸리티 도구를 새로 추가했습니다. + - **JSON 검증기 (json_validator):** 입력 문자열이 올바른 JSON 형식인지 확인하고 예쁘게 포맷팅합니다. + - **해시 생성기 (hash_generator):** 문자열을 SHA256, MD5 등의 알고리즘을 사용해 해시 값으로 변환합니다. + - **URL 파서 (url_parser):** URL 문자열을 구성 요소(스킴, 호스트, 경로, 쿼리 등)로 파싱합니다. + +### Fixed +- **테스트 커버리지 100% 달성:** `mock_handler` 및 `validate_webhook_url_details` 등 기존 모듈 내 누락된 코드 경로에 대한 테스트를 `backend/tests/test_tools_api.py`에 보강하여 `backend/api/tools.py` 파일의 테스트 커버리지를 완벽하게 맞추었습니다. + ### Added - `backend/api/tools.py` 내의 임시 `mock_handler`를 구체적인 기능을 수행하는 5개의 실제 도구 핸들러로 대체했습니다. - `thread_summarizer_handler`: 이메일 스레드 요약 정보 반환 diff --git a/backend/alembic/versions/0010_carddav_accounts.py b/backend/alembic/versions/0010_carddav_accounts.py deleted file mode 100644 index 886708b8f..000000000 --- a/backend/alembic/versions/0010_carddav_accounts.py +++ /dev/null @@ -1,186 +0,0 @@ -"""add carddav accounts table - -Revision ID: 0010_carddav_accounts -Revises: 0009_project_graph_projection -Create Date: 2026-07-08 00:00:00.000000 -""" - -import uuid - -from alembic import op -import sqlalchemy as sa - -revision = "0010_carddav_accounts" -down_revision = "0009_project_graph_projection" - -_CARDDAV_TABLE = "carddav_accounts" -_CARDDAV_USER_INDEX = "ix_carddav_accounts_user_id" -_CARDDAV_SOURCE_UID_INDEX = "ix_carddav_accounts_source_uid" -_CARDDAV_ORGANIZATION_INDEX = "ix_carddav_accounts_organization_id" -_CARDDAV_WORKSPACE_INDEX = "ix_carddav_accounts_workspace_id" - - -def _carddav_table_stub() -> sa.TableClause: - return sa.table( - _CARDDAV_TABLE, - sa.column("account_id", sa.Integer()), - sa.column("source_uid", sa.String()), - sa.column("user_id", sa.String()), - sa.column("workspace_id", sa.String()), - sa.column("created_at", sa.DateTime(timezone=True)), - ) - - -def _add_missing_columns(connection) -> None: - """Bring a previously-seeded table up to the full column contract.""" - inspector = sa.inspect(connection) - existing = {column["name"] for column in inspector.get_columns(_CARDDAV_TABLE)} - carddav = _carddav_table_stub() - - if "source_uid" not in existing: - op.add_column( - _CARDDAV_TABLE, sa.Column("source_uid", sa.String(), nullable=True) - ) - rows = connection.execute( - sa.select(carddav.c.account_id).where(carddav.c.source_uid.is_(None)) - ).fetchall() - for (account_id,) in rows: - connection.execute( - sa.update(carddav) - .where(carddav.c.account_id == account_id) - .values(source_uid=f"carddav_src_{uuid.uuid4().hex}") - ) - op.alter_column(_CARDDAV_TABLE, "source_uid", nullable=False) - - if "organization_id" not in existing: - op.add_column( - _CARDDAV_TABLE, sa.Column("organization_id", sa.String(), nullable=True) - ) - - if "workspace_id" not in existing: - op.add_column( - _CARDDAV_TABLE, sa.Column("workspace_id", sa.String(), nullable=True) - ) - connection.execute( - sa.update(carddav) - .where(carddav.c.workspace_id.is_(None)) - .values(workspace_id=sa.func.concat("workspace-", carddav.c.user_id)) - ) - op.alter_column(_CARDDAV_TABLE, "workspace_id", nullable=False) - - if "writeback_enabled" not in existing: - op.add_column( - _CARDDAV_TABLE, - sa.Column( - "writeback_enabled", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - ) - - created_at = next( - ( - column - for column in inspector.get_columns(_CARDDAV_TABLE) - if column["name"] == "created_at" - ), - None, - ) - if created_at is not None and created_at.get("nullable", True): - connection.execute( - sa.update(carddav) - .where(carddav.c.created_at.is_(None)) - .values(created_at=sa.func.now()) - ) - op.alter_column( - _CARDDAV_TABLE, - "created_at", - nullable=False, - server_default=sa.func.now(), - ) - - -def upgrade() -> None: - connection = op.get_bind() - inspector = sa.inspect(connection) - - if not inspector.has_table(_CARDDAV_TABLE): - op.create_table( - _CARDDAV_TABLE, - sa.Column("account_id", sa.Integer(), nullable=False), - sa.Column("source_uid", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("organization_id", sa.String(), nullable=True), - sa.Column("workspace_id", sa.String(), nullable=False), - sa.Column("server_url", sa.String(), nullable=False), - sa.Column("account_username", sa.String(), nullable=False), - sa.Column("credentials_encrypted", sa.String(), nullable=False), - sa.Column("discovery_source", sa.String(), nullable=True), - sa.Column( - "account_index", - sa.Integer(), - nullable=False, - server_default="1", - ), - sa.Column( - "writeback_enabled", - sa.Boolean(), - nullable=False, - server_default=sa.false(), - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.PrimaryKeyConstraint("account_id"), - ) - else: - _add_missing_columns(connection) - - op.create_index( - _CARDDAV_USER_INDEX, - _CARDDAV_TABLE, - ["user_id"], - if_not_exists=True, - ) - op.create_index( - _CARDDAV_SOURCE_UID_INDEX, - _CARDDAV_TABLE, - ["source_uid"], - unique=True, - if_not_exists=True, - ) - op.create_index( - _CARDDAV_ORGANIZATION_INDEX, - _CARDDAV_TABLE, - ["organization_id"], - if_not_exists=True, - ) - op.create_index( - _CARDDAV_WORKSPACE_INDEX, - _CARDDAV_TABLE, - ["workspace_id"], - if_not_exists=True, - ) - - -def downgrade() -> None: - connection = op.get_bind() - inspector = sa.inspect(connection) - - if inspector.has_table(_CARDDAV_TABLE): - for index_name in ( - _CARDDAV_WORKSPACE_INDEX, - _CARDDAV_ORGANIZATION_INDEX, - _CARDDAV_SOURCE_UID_INDEX, - _CARDDAV_USER_INDEX, - ): - op.drop_index( - index_name, - table_name=_CARDDAV_TABLE, - if_exists=True, - ) - op.drop_table(_CARDDAV_TABLE) diff --git a/backend/alembic/versions/0010_newsdom_providers.py b/backend/alembic/versions/0010_newsdom_providers.py deleted file mode 100644 index 4b2809474..000000000 --- a/backend/alembic/versions/0010_newsdom_providers.py +++ /dev/null @@ -1,95 +0,0 @@ -"""add newsdom provider credentials table - -Revision ID: 0010_newsdom_providers -Revises: 0009_project_graph_projection -Create Date: 2026-07-08 00:00:00.000000 -""" - -from alembic import op -import sqlalchemy as sa - -revision = "0010_newsdom_providers" -down_revision = "0009_project_graph_projection" -_NEWSDOM_TABLE = "newsdom_providers" -# The exact column set this migration creates. downgrade() only drops the table -# when the live schema matches this signature, so a compatible-but-foreign -# pre-existing table (e.g. one owned by another system) is never deleted. -_OWNED_COLUMNS = frozenset( - { - "newsdom_provider_id", - "user_id", - "organization_id", - "provider_name", - "base_url", - "api_token", - "request_language", - "recognition_mode", - "is_active", - "updated_at", - } -) - - -def upgrade() -> None: - connection = op.get_bind() - inspector = sa.inspect(connection) - if not inspector.has_table(_NEWSDOM_TABLE): - op.create_table( - _NEWSDOM_TABLE, - sa.Column("newsdom_provider_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("organization_id", sa.String(), nullable=False), - sa.Column("provider_name", sa.String(), nullable=False), - sa.Column("base_url", sa.String(), nullable=True), - sa.Column("api_token", sa.String(), nullable=True), - sa.Column("request_language", sa.String(length=32), nullable=False), - sa.Column("recognition_mode", sa.String(length=32), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint("newsdom_provider_id"), - sa.UniqueConstraint( - "organization_id", - "provider_name", - name="uq_newsdom_providers_org_name", - ), - ) - - for index_name, column_names in _newsdom_provider_indexes(): - op.create_index( - index_name, - _NEWSDOM_TABLE, - column_names, - if_not_exists=True, - ) - - -def _table_matches_owned_signature(inspector) -> bool: - """Return whether the live table is the one this migration created.""" - columns = {column["name"] for column in inspector.get_columns(_NEWSDOM_TABLE)} - return columns == _OWNED_COLUMNS - - -def downgrade() -> None: - connection = op.get_bind() - inspector = sa.inspect(connection) - if not inspector.has_table(_NEWSDOM_TABLE): - return - # Never drop a pre-existing / foreign table that merely shares this name: - # only remove it when its columns exactly match what upgrade() created. - if not _table_matches_owned_signature(inspector): - return - for index_name, _column_names in reversed(_newsdom_provider_indexes()): - op.drop_index( - index_name, - table_name=_NEWSDOM_TABLE, - if_exists=True, - ) - op.drop_table(_NEWSDOM_TABLE) - - -def _newsdom_provider_indexes() -> list[tuple[str, list[str]]]: - return [ - ("ix_newsdom_providers_user_id", ["user_id"]), - ("ix_newsdom_providers_organization_id", ["organization_id"]), - ("ix_newsdom_providers_provider_name", ["provider_name"]), - ] diff --git a/backend/alembic/versions/0015_merge_carddav_accounts.py b/backend/alembic/versions/0015_merge_carddav_accounts.py deleted file mode 100644 index 59abd373a..000000000 --- a/backend/alembic/versions/0015_merge_carddav_accounts.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Merge the CardDAV accounts branch back onto the mainline. - -The live-test accounts branch adds ``0010_carddav_accounts`` from -``0009_project_graph_projection`` while develop already advanced through -``0014_merge_email_read_state``. Without a merge revision, Alembic sees both as -heads and ``alembic upgrade head`` is ambiguous. - -This revision has no schema operations. The parent branches already apply their -own DDL; this file only records the graph reconciliation. -""" - -from __future__ import annotations - -# revision identifiers, used by Alembic. -revision = "0015_merge_carddav_accounts" -down_revision = ("0010_carddav_accounts", "0014_merge_email_read_state") -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """No-op: this merge revision only unifies the Alembic heads.""" - - -def downgrade() -> None: - """No-op: a graph merge adds no schema to reverse.""" diff --git a/backend/alembic/versions/0015_merge_newsdom_email_heads.py b/backend/alembic/versions/0015_merge_newsdom_email_heads.py deleted file mode 100644 index c856c377d..000000000 --- a/backend/alembic/versions/0015_merge_newsdom_email_heads.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Merge the NewsDOM provider branch into the unified migration graph. - -Revision ID: 0015_merge_newsdom_email_heads -Revises: 0010_newsdom_providers, 0014_merge_email_read_state -Create Date: 2026-07-13 00:00:00.000000 - -The NewsDOM provider migration and the email read-state merge both descend -from the project graph revision through separate branches. This pure graph -merge restores a single Alembic head without applying additional DDL. -""" - -from __future__ import annotations - -# revision identifiers, used by Alembic. -revision = "0015_merge_newsdom_email_heads" -down_revision = ("0010_newsdom_providers", "0014_merge_email_read_state") -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Unify the parent revisions without changing the database schema.""" - - -def downgrade() -> None: - """Leave the parent branch schemas intact when removing the merge node.""" diff --git a/backend/alembic/versions/0016_document_org_scope.py b/backend/alembic/versions/0016_document_org_scope.py deleted file mode 100644 index 0a5cd0035..000000000 --- a/backend/alembic/versions/0016_document_org_scope.py +++ /dev/null @@ -1,47 +0,0 @@ -"""add organization scope to workspace documents - -Revision ID: 0016_document_org_scope -Revises: 0015_merge_newsdom_email_heads -Create Date: 2026-07-13 00:00:00.000000 - -Workspace documents gain a nullable ``organization_id`` so the NewsDOM PDF -recognition worker can resolve the owning organization's provider without -joining through the (organization-less) workspace entity. Nullable and additive -so existing rows and personal-scope documents are unaffected. -""" - -from alembic import op -import sqlalchemy as sa - -revision = "0016_document_org_scope" -down_revision = "0015_merge_newsdom_email_heads" - -_DOCUMENTS_TABLE = "workspace_documents" -_ORG_COLUMN = "organization_id" -_ORG_INDEX = "ix_workspace_documents_organization_id" - - -def upgrade() -> None: - connection = op.get_bind() - inspector = sa.inspect(connection) - columns = {column["name"] for column in inspector.get_columns(_DOCUMENTS_TABLE)} - if _ORG_COLUMN not in columns: - op.add_column( - _DOCUMENTS_TABLE, - sa.Column(_ORG_COLUMN, sa.String(), nullable=True), - ) - op.create_index( - _ORG_INDEX, - _DOCUMENTS_TABLE, - [_ORG_COLUMN], - if_not_exists=True, - ) - - -def downgrade() -> None: - connection = op.get_bind() - inspector = sa.inspect(connection) - columns = {column["name"] for column in inspector.get_columns(_DOCUMENTS_TABLE)} - op.drop_index(_ORG_INDEX, table_name=_DOCUMENTS_TABLE, if_exists=True) - if _ORG_COLUMN in columns: - op.drop_column(_DOCUMENTS_TABLE, _ORG_COLUMN) diff --git a/backend/alembic/versions/0017_merge_newsdom_carddav_heads.py b/backend/alembic/versions/0017_merge_newsdom_carddav_heads.py deleted file mode 100644 index 10bfbe6b0..000000000 --- a/backend/alembic/versions/0017_merge_newsdom_carddav_heads.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Merge the NewsDOM document and CardDAV account migration heads. - -Revision ID: 0017_merge_newsdom_carddav_heads -Revises: 0016_document_org_scope, 0015_merge_carddav_accounts -Create Date: 2026-07-13 00:00:00.000000 - -The NewsDOM branch adds organization scope to workspace documents while the -live-test accounts branch independently merges CardDAV accounts. Both parent -revisions already own their DDL, so this revision only reconciles the graph. -""" - -from __future__ import annotations - -revision = "0017_merge_newsdom_carddav_heads" -down_revision = ("0016_document_org_scope", "0015_merge_carddav_accounts") -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Unify the parent revisions without changing the database schema.""" - - -def downgrade() -> None: - """Leave both parent branch schemas intact when removing the merge node.""" diff --git a/backend/api/calendar.py b/backend/api/calendar.py index b804ce73a..ace20f5ca 100644 --- a/backend/api/calendar.py +++ b/backend/api/calendar.py @@ -17,10 +17,10 @@ from db.session import get_db from services.calendar_service import ( create_calendar_events_batch, - validate_calendar_action_item_text, + validate_calendar_todo_text, ) from services.calendar_sync import CalendarTask, generate_ics_from_task -from services.exceptions import CalendarServiceError, UnsafeCalendarActionItemError +from services.exceptions import CalendarServiceError, UnsafeCalendarTodoError router = APIRouter(prefix="/api/calendar") logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ class SyncRequest(BaseModel): model_config = ConfigDict(extra="forbid") - action_items: list[str] + todos: list[str] class WritebackSource(BaseModel): @@ -303,7 +303,7 @@ def _merge_calendar_dispatch_result( @router.post("/sync") -async def sync_action_items( +async def sync_todos( request: SyncRequest, user_token: dict | None = Depends(get_calendar_user_token), ): @@ -313,25 +313,17 @@ async def sync_action_items( detail="No server-authoritative calendar credentials are configured", ) try: - safe_action_items = [ - validate_calendar_action_item_text(action_item) - for action_item in request.action_items - ] - results = await create_calendar_events_batch(safe_action_items, user_token) + safe_todos = [validate_calendar_todo_text(todo) for todo in request.todos] + results = await create_calendar_events_batch(safe_todos, user_token) return {"synced": len(results), "events": list(results)} - except UnsafeCalendarActionItemError: - raise HTTPException( - status_code=422, detail="Invalid or unsafe calendar action item text" - ) + except UnsafeCalendarTodoError: + raise HTTPException(status_code=422, detail="Invalid or unsafe calendar todo text") except CalendarServiceError as e: logger.warning( - "Calendar service error during sync_action_items", + "Calendar service error during sync_todos", extra={"error_type": type(e).__name__}, ) - raise HTTPException( - status_code=500, - detail="An internal server error occurred while communicating with the calendar service", - ) + raise HTTPException(status_code=500, detail="An internal server error occurred while communicating with the calendar service") @router.post("/writeback-intent", response_model=WritebackIntentResponse) diff --git a/backend/api/data.py b/backend/api/data.py index c470d69c0..727334090 100644 --- a/backend/api/data.py +++ b/backend/api/data.py @@ -1,12 +1,10 @@ -import base64 -import binascii from datetime import datetime, timezone import hashlib import json import re from typing import Literal, NamedTuple -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import and_, case, func, or_, select from sqlalchemy.engine import Row @@ -30,20 +28,12 @@ ) from db.session import get_db from services.attachment_parser import get_attachment_parser_manifest -from services.newsdom_pdf_recognition import ( - PDF_DOM_RECOGNITION_PENDING_STATUS, -) from services.ontology_service import ontology_service from services.webdav_service import webdav_service router = APIRouter(prefix="/api/data", tags=["data"]) DATA_VECTOR_DIMENSIONS = 1536 -# Upper bound for the binary PDF DOM recognition upload variant. Kept in step -# with the NewsDOM sidecar's own MAX_PARSE_UPLOAD_BYTES (20 MiB): accepting more -# would let a caller stash a pending document the configured sidecar will always -# reject while the base64 copy inflates the database. -_MAX_PDF_DOM_UPLOAD_BYTES = 20 * 1024 * 1024 ATTACHMENT_PARSE_BREAKDOWN_EVIDENCE_SOURCE = ( "email_attachments.content_type, " "email_attachments.parse_content_type, " @@ -2314,16 +2304,6 @@ def _materialized_document_target_path(document: Document) -> str: return f"/Naruon/Data/{filename}" -# Document statuses whose stored content is not yet materializable parsed text -# (it may be a base64 binary payload awaiting a recognition/conversion worker). -_NON_MATERIALIZABLE_DOCUMENT_STATUSES = frozenset( - { - PDF_DOM_RECOGNITION_PENDING_STATUS, - "hwp_conversion_pending", - } -) - - def _materialized_document_content(document: Document) -> str: return (document.document_content or "").strip() @@ -2641,11 +2621,7 @@ def _document_repository_assets(documents: list[Document]) -> list[DataRepositor assets: list[DataRepositoryAsset] = [] for document in documents: content_chars = _document_content_chars(document) - pending_statuses = { - "embedding_pending", - "hwp_conversion_pending", - PDF_DOM_RECOGNITION_PENDING_STATUS, - } + pending_statuses = {"embedding_pending", "hwp_conversion_pending"} state_code: RepositoryAssetState = ( "needs_attention" if content_chars <= 0 or document.document_status in pending_statuses @@ -3167,7 +3143,6 @@ async def upload_data_document( ) -> DataDocumentActionResponse: document = Document( workspace_id=auth_context.workspace_id, - organization_id=auth_context.organization_id, document_name=_safe_display_text(request.document_name, "workspace document"), document_type=_safe_document_type(request.document_type), document_content=request.document_content, @@ -3242,105 +3217,6 @@ async def create_document_hwp_conversion_intent( ) -@router.post( - "/documents/{document_id}/pdf-dom-recognition-intent", - response_model=DataDocumentActionResponse, -) -async def create_document_pdf_dom_recognition_intent( - document_id: str, - auth_context: AuthContext = Depends(get_auth_context), - db: AsyncSession = Depends(get_db), -) -> DataDocumentActionResponse: - document = await _get_workspace_document(db, auth_context, document_id) - if (document.document_type or "").strip().lower() != "pdf": - raise HTTPException( - status_code=415, - detail="PDF DOM recognition is only available for PDF documents.", - ) - try: - decode_pending_pdf_document_bytes(document) - except ValueError as exc: - raise HTTPException( - status_code=422, - detail="Stored PDF payload is not valid for DOM recognition.", - ) from exc - document.organization_id = auth_context.organization_id - document.document_status = PDF_DOM_RECOGNITION_PENDING_STATUS - await db.commit() - await db.refresh(document) - return _document_response( - document, - audit_event="data.document.pdf_dom_recognition_intent", - message=( - "PDF DOM recognition intent recorded; the NewsDOM sidecar worker " - "will land the structured DOM. No provider write executed." - ), - ) - - -@router.post( - "/documents/pdf-dom-recognition", - response_model=DataDocumentActionResponse, -) -async def upload_document_for_pdf_dom_recognition( - file: UploadFile = File(...), - # Declared as multipart form data (not a query parameter) so a client - # sending document_name alongside the file is honored. - document_name: str | None = Form(None), - auth_context: AuthContext = Depends(get_auth_context), - db: AsyncSession = Depends(get_db), -) -> DataDocumentActionResponse: - """Binary upload variant: accept a PDF, stash it pending, and defer the - heavy NewsDOM recognition to the worker.""" - raw = await file.read(_MAX_PDF_DOM_UPLOAD_BYTES + 1) - if len(raw) > _MAX_PDF_DOM_UPLOAD_BYTES: - raise HTTPException(status_code=413, detail="PDF upload is too large.") - if not raw[:5] == b"%PDF-": - raise HTTPException( - status_code=415, - detail="Only application/pdf uploads are supported for DOM recognition.", - ) - document = Document( - workspace_id=auth_context.workspace_id, - organization_id=auth_context.organization_id, - document_name=_safe_display_text( - document_name or file.filename, "workspace document" - ), - document_type="pdf", - document_content=base64.b64encode(raw).decode("ascii"), - document_status=PDF_DOM_RECOGNITION_PENDING_STATUS, - ) - db.add(document) - await db.commit() - await db.refresh(document) - return _document_response( - document, - audit_event="data.document.pdf_dom_recognition_upload", - message=( - "PDF stored pending NewsDOM DOM recognition; the worker will parse " - "it into the content graph. No provider write executed." - ), - ) - - -def decode_pending_pdf_document_bytes(document: Document) -> bytes: - """Decode the base64 PDF payload stashed by the binary upload variant. - - Used by the recognition worker before calling the NewsDOM sidecar. - """ - try: - payload = base64.b64decode( - (document.document_content or "").encode("ascii"), validate=True - ) - except (binascii.Error, UnicodeEncodeError, ValueError) as exc: - raise ValueError("Pending PDF document payload is not valid base64") from exc - if len(payload) > _MAX_PDF_DOM_UPLOAD_BYTES: - raise ValueError("Pending PDF document exceeds the upload size limit") - if not payload.startswith(b"%PDF-"): - raise ValueError("Pending PDF document payload is not a PDF") - return payload - - @router.post( "/documents/{document_id}/webdav-materialization-intent", response_model=DataDocumentWebdavMaterializationResponse, @@ -3352,19 +3228,6 @@ async def create_document_webdav_materialization_intent( db: AsyncSession = Depends(get_db), ) -> DataDocumentWebdavMaterializationResponse: document = await _get_workspace_document(db, auth_context, document_id) - if document.document_status in _NON_MATERIALIZABLE_DOCUMENT_STATUSES: - # A document whose recognition/conversion is still pending holds a - # non-text payload (e.g. the base64 PDF stashed for the NewsDOM worker). - # Materializing it as Markdown would write that raw payload to the - # customer's WebDAV target. Refuse until recognition has landed real - # parsed text. - raise HTTPException( - status_code=409, - detail=( - "Workspace document is still pending recognition; " - "no materializable content yet." - ), - ) if not _materialized_document_content(document): raise HTTPException( status_code=422, diff --git a/backend/api/llm.py b/backend/api/llm.py index 606ff5bc0..76e6a48f7 100644 --- a/backend/api/llm.py +++ b/backend/api/llm.py @@ -13,7 +13,7 @@ from services.llm_service import ( ExtractionResult, draft_reply, - extract_action_items_and_summary, + extract_todos_and_summary, translate_email_body, ) @@ -98,7 +98,7 @@ async def summarize_endpoint( if runtime_provider is None: raise HTTPException(status_code=400, detail="OpenAI API key not configured") - return await extract_action_items_and_summary( + return await extract_todos_and_summary( request.email_body, runtime_provider.api_key, base_url=runtime_provider.base_url, diff --git a/backend/api/tools.py b/backend/api/tools.py index df9d82a66..0051e99f0 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -550,6 +550,98 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]: ) + +async def json_validator_handler(params: Dict[str, Any]) -> Dict[str, Any]: + json_string = params.get("json_string") + if json_string is None: + return {"is_valid": False, "formatted_json": None, "error": "json_string must not be None"} + try: + parsed = json.loads(json_string) + return { + "is_valid": True, + "formatted_json": json.dumps(parsed, indent=2, ensure_ascii=False), + "error": None, + } + except json.JSONDecodeError as e: + return {"is_valid": False, "formatted_json": None, "error": str(e)} + + +async def hash_generator_handler(params: Dict[str, Any]) -> Dict[str, Any]: + import hashlib + text = params.get("text") + algorithm = params.get("algorithm", "sha256").lower() + + if text is None: + text = "" + + if algorithm not in hashlib.algorithms_available: + raise ValueError(f"Unsupported hash algorithm: {algorithm}") + + hasher = hashlib.new(algorithm) + hasher.update(text.encode("utf-8")) + + return { + "hash": hasher.hexdigest(), + "algorithm": algorithm, + } + + +async def url_parser_handler(params: Dict[str, Any]) -> Dict[str, Any]: + import urllib.parse + url = params.get("url") + if url is None: + url = "" + try: + parsed = urllib.parse.urlparse(url) + return { + "scheme": parsed.scheme, + "netloc": parsed.netloc, + "path": parsed.path, + "params": parsed.params, + "query": parsed.query, + "fragment": parsed.fragment, + "hostname": parsed.hostname or "", + } + except ValueError as e: + raise ValueError(f"Invalid URL: {e}") + + +registry.register( + ToolInfo( + code="json_validator", + name="JSON 검증기 (JSON Validator)", + description="입력된 문자열이 유효한 JSON 형식인지 검증하고, 보기 좋게 포맷팅합니다.", + category="유틸리티", + parameters={"json_string": "string"}, + ), + json_validator_handler, +) + + +registry.register( + ToolInfo( + code="hash_generator", + name="해시 생성기 (Hash Generator)", + description="주어진 텍스트를 지정된 해시 알고리즘(예: sha256, md5)으로 변환합니다.", + category="유틸리티", + parameters={"text": "string", "algorithm": "string"}, + ), + hash_generator_handler, +) + + +registry.register( + ToolInfo( + code="url_parser", + name="URL 파서 (URL Parser)", + description="URL 문자열을 분석하여 구성 요소(스킴, 호스트, 경로, 쿼리 등)로 분리합니다.", + category="유틸리티", + parameters={"url": "string"}, + ), + url_parser_handler, +) + + registry.register( ToolInfo( code="email_translator", diff --git a/backend/core/config.py b/backend/core/config.py index 9f58be05a..acb8d8066 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -89,12 +89,6 @@ class Settings(BaseSettings): ALLOWED_POP3_PORTS: str = "995" ALLOWED_LLM_BASE_URL_HOSTS: str = "" ALLOW_LOCAL_LLM_PROVIDERS: bool = False - # NewsDOM PDF DOM recognition sidecar. Mirrors the LLM provider allowlist - # controls: the base URL host must be listed here before any request is - # pinned and dispatched, and container-name / loopback hosts are only - # accepted when ALLOW_LOCAL_NEWSDOM_PROVIDERS is enabled (dev / docker). - ALLOWED_NEWSDOM_HOSTS: str = "" - ALLOW_LOCAL_NEWSDOM_PROVIDERS: bool = False # Host allowlist for the scopeweave promotion target. The per-workspace # base URL and PAT themselves live encrypted in the database # (scopeweave_promotion_target); this setting only pins which hosts an diff --git a/backend/db/models.py b/backend/db/models.py index 98e17eef2..d504fea85 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -285,47 +285,6 @@ class ScopeweavePromotionLink(Base): ) -class NewsdomProvider(Base): - """NewsDOM PDF DOM recognition sidecar credentials. - - Mirrors :class:`LLMProvider`: the base URL is stored in plaintext while the - bearer token is encrypted at rest via :class:`EncryptedString` (Fernet). - Consumer code reads these values from the database — never from - ``os.getenv`` at request time — so the sidecar can be (re)configured per - organization without redeploying the API. - """ - - __tablename__ = "newsdom_providers" - __table_args__ = ( - UniqueConstraint( - "organization_id", - "provider_name", - name="uq_newsdom_providers_org_name", - ), - ) - - newsdom_provider_id: Mapped[int] = mapped_column( - "newsdom_provider_id", primary_key=True - ) - user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) - organization_id: Mapped[str] = mapped_column(String, index=True, nullable=False) - provider_name: Mapped[str] = mapped_column(String, index=True, nullable=False) - base_url: Mapped[str | None] = mapped_column(String, nullable=True) - api_token: Mapped[str | None] = mapped_column(EncryptedString, nullable=True) - request_language: Mapped[str] = mapped_column( - String(32), default="auto", nullable=False - ) - recognition_mode: Mapped[str] = mapped_column( - String(32), default="auto", nullable=False - ) - is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - default=datetime.datetime.utcnow, - onupdate=datetime.datetime.utcnow, - ) - - class WorkspaceRunnerConfig(Base): __tablename__ = "workspace_runner_configs" @@ -1436,35 +1395,6 @@ class CaldavAccount(Base): ) -class CarddavAccount(Base): - __tablename__ = "carddav_accounts" - - account_id: Mapped[int] = mapped_column("account_id", primary_key=True) - source_uid: Mapped[str] = mapped_column( - String, - unique=True, - index=True, - default=lambda: f"carddav_src_{uuid.uuid4().hex}", - nullable=False, - ) - user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) - organization_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True) - workspace_id: Mapped[str] = mapped_column(String, index=True, nullable=False) - server_url: Mapped[str] = mapped_column(String, nullable=False) - # New tables use two-word snake_case column names; the Python attribute - # stays `username` for symmetry with the WebdavAccount mapper. - username: Mapped[str] = mapped_column("account_username", String, nullable=False) - credentials_encrypted: Mapped[str] = mapped_column(EncryptedString, nullable=False) - discovery_source: Mapped[str | None] = mapped_column(String, nullable=True) - account_index: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - writeback_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - created_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.datetime.now(datetime.timezone.utc), - nullable=False, - ) - - class CalendarWritebackSource(Base): __tablename__ = "calendar_writeback_sources" @@ -1577,10 +1507,6 @@ class Document(Base): document_id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"doc_{uuid.uuid4().hex}") workspace_id: Mapped[str] = mapped_column(String, ForeignKey("workspace_entities.workspace_id"), index=True, nullable=False) - # Owning organization (nullable for personal-scope docs). Persisted so the - # NewsDOM recognition worker can resolve the org's provider without joining - # through the (org-less) workspace entity. - organization_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True) document_name: Mapped[str] = mapped_column(String, nullable=False) document_type: Mapped[str] = mapped_column(String, nullable=False) document_content: Mapped[str] = mapped_column(Text, nullable=True) diff --git a/backend/main.py b/backend/main.py index 0ad7762a8..26e65a02d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,7 +35,6 @@ from core.telemetry import setup_telemetry from core.version import get_release_version from services.imap_worker import ImapSyncWorker -from services.newsdom_worker import NewsdomRecognitionWorker from services.pop3_worker import Pop3SyncWorker from services.provider_writeback_retry_service import ProviderWritebackRetryWorker from services.reply_sla_scheduler import ReplySlaScheduler @@ -44,7 +43,6 @@ imap_worker = ImapSyncWorker() pop3_worker = Pop3SyncWorker() reply_sla_scheduler = ReplySlaScheduler() -newsdom_recognition_worker = NewsdomRecognitionWorker() provider_writeback_retry_worker = ProviderWritebackRetryWorker( runner_manager.dispatch_command, ) @@ -61,12 +59,10 @@ async def lifespan(app: FastAPI): await imap_worker.start() await pop3_worker.start() await reply_sla_scheduler.start() - await newsdom_recognition_worker.start() await provider_writeback_retry_worker.start() yield if not DISABLE_WORKERS: await provider_writeback_retry_worker.stop() - await newsdom_recognition_worker.stop() await reply_sla_scheduler.stop() await pop3_worker.stop() await imap_worker.stop() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ed662dec7..53f1f2ee1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,9 +4,9 @@ version = "0.14.4" description = "Naruon FastAPI backend" requires-python = ">=3.12" dependencies = [ - "fastapi==0.139.0", + "fastapi==0.138.2", "starlette==1.3.1", - "uvicorn==0.51.0", + "uvicorn==0.49.0", "httpx==0.28.1", "pydantic-settings==2.14.2", "aiosmtplib==5.1.2", @@ -15,9 +15,9 @@ dependencies = [ "alembic==1.18.5", "greenlet==3.5.3", "asyncpg==0.31.0", - "pgvector==0.5.0", + "pgvector==0.4.2", "rankweave==0.1.0", - "openai==2.45.0", + "openai==2.44.0", "langchain-text-splitters==1.1.2", "tiktoken==0.13.0", "google-api-python-client==2.198.0", @@ -34,7 +34,7 @@ dependencies = [ "protobuf==7.35.1", "setuptools==83.0.0", "wheel==0.47.0", - "websockets==16.1", + "websockets==16.0", "PyJWT==2.13.0", "icalendar==7.2.0", "defusedxml==0.7.1", @@ -42,8 +42,8 @@ dependencies = [ [dependency-groups] dev = [ - "coverage==7.15.1", + "coverage==7.14.3", "pytest==9.1.1", "pytest-asyncio==1.4.0", - "ruff==0.15.21", + "ruff==0.15.20", ] diff --git a/backend/requirements-agent.txt b/backend/requirements-agent.txt index fa5e75d6b..677895109 100644 --- a/backend/requirements-agent.txt +++ b/backend/requirements-agent.txt @@ -14,9 +14,8 @@ # ``OpenAIModel`` in 2.x) and constructs the model via ``OpenAIProvider``. # Version 2.8.0 is the newest 2.x release compatible with the core # requirements-hashes.txt pin of openai==2.44.0; 2.9.0 requires openai>=2.45.0. -pydantic-ai-slim[openai]==2.9.0 \ - --hash=sha256:11b812833eb75cfcc31c86b6e2db75e28cade6bb746844e28a40eb51b04a09d3 \ - --hash=sha256:560a860cb67cf7732b16fb40e1d9205d75c02934e75e41db7ccb104ebc9ea266 +pydantic-ai-slim[openai]==2.8.0 \ + --hash=sha256:ddf8ac31442d5433ffff7c47af895088c230448fd28926a713c0f47aa2affc12 # via -r backend/requirements-agent.txt genai-prices==0.0.71 \ --hash=sha256:1d13111563af2b1ce43ccfacf77b7ac3216ad704c644408a56e11b181fe0d128 @@ -33,9 +32,8 @@ httpx2==2.5.0 \ logfire-api==4.37.0 \ --hash=sha256:1d756f8ba23aa56d438e0ba2c0f529a00fcac975b8785c561b058267f9465088 # via pydantic-graph -pydantic-graph==2.9.0 \ - --hash=sha256:0ee8ad7b69d628037f3194d506b6c7099e416f25e477f9590da110cdaf48c649 \ - --hash=sha256:a6d146cfd97b086e4334afc6717b2d078a3f102631cfe05e421fda2ca5a7046f +pydantic-graph==2.8.0 \ + --hash=sha256:9b44a06d413586299a2d9ffb76efc1aa1d3b2268984332b03af24dd0c32dad35 # via pydantic-ai-slim truststore==0.10.4 \ --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 diff --git a/backend/requirements-hashes.txt b/backend/requirements-hashes.txt index f29e9b4c0..148f3a47e 100644 --- a/backend/requirements-hashes.txt +++ b/backend/requirements-hashes.txt @@ -20,9 +20,9 @@ annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 # via pydantic -anyio==4.14.2 \ - --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ - --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f +anyio==4.14.1 \ + --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ + --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e # via # httpx # langsmith @@ -98,202 +98,222 @@ certifi==2026.6.17 \ # httpcore # httpx # requests -cffi==2.1.0 \ - --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ - --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ - --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ - --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ - --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ - --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ - --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ - --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ - --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ - --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ - --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ - --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ - --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ - --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ - --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ - --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ - --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ - --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ - --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ - --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ - --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ - --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ - --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ - --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ - --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ - --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ - --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ - --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ - --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ - --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ - --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ - --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ - --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ - --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ - --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ - --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ - --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ - --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ - --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ - --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ - --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ - --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ - --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ - --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ - --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ - --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ - --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ - --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ - --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ - --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ - --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ - --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ - --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ - --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ - --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ - --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ - --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ - --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ - --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ - --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ - --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ - --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ - --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ - --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ - --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ - --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ - --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ - --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ - --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ - --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ - --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ - --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ - --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ - --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ - --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ - --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ - --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ - --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ - --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ - --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ - --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ - --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ - --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ - --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ - --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ - --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ - --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ - --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ - --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ - --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ - --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ - --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ - --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ - --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ - --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ - --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ - --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ - --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ - --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ - --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf # via cryptography -charset-normalizer==3.4.9 \ - --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ - --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ - --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ - --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ - --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ - --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ - --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ - --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ - --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ - --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ - --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ - --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ - --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ - --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ - --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ - --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ - --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ - --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ - --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ - --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ - --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ - --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ - --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ - --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ - --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ - --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ - --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ - --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ - --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ - --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ - --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ - --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ - --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ - --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ - --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ - --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ - --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ - --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ - --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ - --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ - --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ - --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ - --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ - --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ - --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ - --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ - --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ - --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ - --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ - --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ - --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ - --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ - --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ - --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ - --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ - --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ - --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ - --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ - --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ - --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ - --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ - --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ - --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ - --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ - --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ - --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ - --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ - --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ - --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ - --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ - --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ - --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ - --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ - --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ - --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ - --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ - --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ - --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ - --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ - --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ - --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ - --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ - --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ - --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ - --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ - --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ - --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ - --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ - --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ - --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ - --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ - --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ - --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 +charset-normalizer==3.4.7 \ + --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ + --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ + --hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \ + --hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \ + --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \ + --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \ + --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \ + --hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \ + --hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \ + --hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \ + --hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \ + --hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \ + --hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \ + --hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \ + --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \ + --hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \ + --hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \ + --hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \ + --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \ + --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \ + --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \ + --hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \ + --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \ + --hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \ + --hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \ + --hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \ + --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \ + --hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \ + --hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \ + --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \ + --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \ + --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \ + --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \ + --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \ + --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \ + --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \ + --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \ + --hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \ + --hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \ + --hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \ + --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \ + --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \ + --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \ + --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \ + --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \ + --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \ + --hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \ + --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \ + --hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \ + --hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \ + --hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \ + --hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \ + --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \ + --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \ + --hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \ + --hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \ + --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \ + --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \ + --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \ + --hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \ + --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \ + --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \ + --hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \ + --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \ + --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \ + --hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \ + --hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \ + --hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \ + --hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \ + --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \ + --hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \ + --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \ + --hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \ + --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \ + --hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \ + --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \ + --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \ + --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \ + --hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \ + --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \ + --hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \ + --hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \ + --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ + --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \ + --hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \ + --hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \ + --hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \ + --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \ + --hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \ + --hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \ + --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \ + --hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \ + --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \ + --hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \ + --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \ + --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \ + --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \ + --hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \ + --hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \ + --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \ + --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \ + --hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \ + --hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \ + --hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \ + --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \ + --hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \ + --hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \ + --hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \ + --hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \ + --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \ + --hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \ + --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \ + --hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \ + --hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \ + --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \ + --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \ + --hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \ + --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \ + --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \ + --hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \ + --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \ + --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \ + --hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \ + --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \ + --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \ + --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \ + --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ + --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ + --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 # via requests click==8.4.2 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ @@ -367,9 +387,9 @@ email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via -r backend/requirements.txt -fastapi==0.139.0 \ - --hash=sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145 \ - --hash=sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189 +fastapi==0.138.2 \ + --hash=sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8 \ + --hash=sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615 # via -r backend/requirements.txt google-api-core==2.31.0 \ --hash=sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2 \ @@ -379,9 +399,9 @@ google-api-python-client==2.198.0 \ --hash=sha256:dfe3e16fb241af6e9c460a33f65085b3450e05cea09364f6b5d8997fb7e43e2a \ --hash=sha256:fabac935474e817da5e662ff61bf7139439d6f92b32d332a7318a2d45931e03e # via -r backend/requirements.txt -google-auth==2.55.2 \ - --hash=sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae \ - --hash=sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b +google-auth==2.55.1 \ + --hash=sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995 \ + --hash=sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1 # via # google-api-core # google-api-python-client @@ -487,58 +507,58 @@ greenlet==3.5.3 \ # via # -r backend/requirements.txt # sqlalchemy -grpcio==1.82.1 \ - --hash=sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9 \ - --hash=sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438 \ - --hash=sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769 \ - --hash=sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f \ - --hash=sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e \ - --hash=sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379 \ - --hash=sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03 \ - --hash=sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e \ - --hash=sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6 \ - --hash=sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0 \ - --hash=sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2 \ - --hash=sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a \ - --hash=sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a \ - --hash=sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90 \ - --hash=sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc \ - --hash=sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0 \ - --hash=sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095 \ - --hash=sha256:50fd2fe83426b1b1c6cdc4d72d555223b7dddf8ce07c5bac218b13fc6d684c6f \ - --hash=sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2 \ - --hash=sha256:5e171d5f0d6a0af78ea7512783f170a44f80c165259d8773e3a354a7f991f2b5 \ - --hash=sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f \ - --hash=sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98 \ - --hash=sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69 \ - --hash=sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5 \ - --hash=sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e \ - --hash=sha256:91859d1cac5f47caec5fc40e9f827500cdb54ce5b36450dc9a65616b5af49c17 \ - --hash=sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf \ - --hash=sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f \ - --hash=sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27 \ - --hash=sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1 \ - --hash=sha256:b758540a24d5394a9c578bf9f6126389f474b106ac3d9df1d53de56cb14c9fd9 \ - --hash=sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197 \ - --hash=sha256:b89cff456796d2f0581783726ad017a2c70aff2d27b0f05504c34e2e417f7560 \ - --hash=sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a \ - --hash=sha256:bd2f45e46fff5b91c10997d0743a987517a7dde67c64c592835c2dcaac66f587 \ - --hash=sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58 \ - --hash=sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7 \ - --hash=sha256:c4ba4aac238f685743575d9d700003ac16537cce26e7c774993134f530652464 \ - --hash=sha256:c80c9741dcef192f669876a81957cf7713b441c2f0c43631350d75fa49321d31 \ - --hash=sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b \ - --hash=sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c \ - --hash=sha256:d6e8a08f7038ba7a77f71e250804e4aba84fe91d22cfc54ff43c07b7529c4728 \ - --hash=sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac \ - --hash=sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4 \ - --hash=sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074 \ - --hash=sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5 \ - --hash=sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580 \ - --hash=sha256:ed6fc621d6f366c88a60f0b971d5afd21d441d9aa561ee688de5b7acdb2cf901 \ - --hash=sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6 \ - --hash=sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae \ - --hash=sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d +grpcio==1.81.1 \ + --hash=sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854 \ + --hash=sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2 \ + --hash=sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3 \ + --hash=sha256:15641444eca4a29358107b3dceb74c1c6305c55c822fd199b458aaea4068a7fb \ + --hash=sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad \ + --hash=sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2 \ + --hash=sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0 \ + --hash=sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7 \ + --hash=sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79 \ + --hash=sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821 \ + --hash=sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94 \ + --hash=sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6 \ + --hash=sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5 \ + --hash=sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b \ + --hash=sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7 \ + --hash=sha256:592b5fee597faa91cce2dd294dd7d9a1c83d76c4dbf877e33ec1adb866b2fbed \ + --hash=sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f \ + --hash=sha256:62481553b1793a27e9b9c3cf9e5bd483ef045ca72462592074b46d42b0c4d9b9 \ + --hash=sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416 \ + --hash=sha256:69ef28e54fc85397f91b8c19592b8ef3d81952080366914823bd8572a2958120 \ + --hash=sha256:6f9a0c9c1cc15c112d1c053064fd032b64917062292c3d70aea280e02ae10b77 \ + --hash=sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b \ + --hash=sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0 \ + --hash=sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42 \ + --hash=sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e \ + --hash=sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf \ + --hash=sha256:88268ca418cacea64cecb0d1d600d3c6b3a8038fcba02e1e205178c5b1f47661 \ + --hash=sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115 \ + --hash=sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c \ + --hash=sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5 \ + --hash=sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6 \ + --hash=sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70 \ + --hash=sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a \ + --hash=sha256:aa2ba7d2ad6df4d80127cea65e5b8d5e2c3adbf153ff4804452836328aca7c54 \ + --hash=sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe \ + --hash=sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137 \ + --hash=sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3 \ + --hash=sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190 \ + --hash=sha256:bb693b1e3d9a2f3fd228e2110daf4b5aeedb36761ca1e4282f74725f6d89f611 \ + --hash=sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb \ + --hash=sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0 \ + --hash=sha256:ca1cc11d82677b9662082e5478b7528e2b7db7beaa6bdff42bd62789d81be399 \ + --hash=sha256:d4b2dddfc219f54f956ccd53cf76a1d338ffe68fc7f2849ec9c7feb9927ff692 \ + --hash=sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f \ + --hash=sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e \ + --hash=sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27 \ + --hash=sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5 \ + --hash=sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae \ + --hash=sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14 \ + --hash=sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60 \ + --hash=sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49 # via opentelemetry-exporter-otlp-proto-grpc h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ @@ -694,9 +714,9 @@ jsonpointer==3.1.1 \ --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca # via jsonpatch -langchain-core==1.4.9 \ - --hash=sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624 \ - --hash=sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2 +langchain-core==1.4.8 \ + --hash=sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a \ + --hash=sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa # via langchain-text-splitters langchain-protocol==0.0.18 \ --hash=sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a \ @@ -706,9 +726,9 @@ langchain-text-splitters==1.1.2 \ --hash=sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627 \ --hash=sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10 # via -r backend/requirements.txt -langsmith==0.10.2 \ - --hash=sha256:9aa685383fbdec07a0df51dafc333ab0d4b6b995771172a232c3364714eb17a6 \ - --hash=sha256:c2a3929055758ac1831582f0939fafc0973cc08432365bbad335c336338ec37c +langsmith==0.9.4 \ + --hash=sha256:36c15e5a692e7812ee927e43d777c6166c839eb02b1d482ba9a805b438b1d91c \ + --hash=sha256:f10f07438c7c6f9f907e8c810d8f338268fe863a74c37ded3d8a90830bb25451 # via langchain-core mako==1.3.12 \ --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \ @@ -805,13 +825,59 @@ markupsafe==3.0.3 \ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 # via mako +numpy==2.5.0 \ + --hash=sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865 \ + --hash=sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988 \ + --hash=sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd \ + --hash=sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c \ + --hash=sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22 \ + --hash=sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6 \ + --hash=sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b \ + --hash=sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927 \ + --hash=sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446 \ + --hash=sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9 \ + --hash=sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03 \ + --hash=sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e \ + --hash=sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748 \ + --hash=sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561 \ + --hash=sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60 \ + --hash=sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2 \ + --hash=sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c \ + --hash=sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731 \ + --hash=sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5 \ + --hash=sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303 \ + --hash=sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2 \ + --hash=sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4 \ + --hash=sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0 \ + --hash=sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e \ + --hash=sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab \ + --hash=sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826 \ + --hash=sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a \ + --hash=sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9 \ + --hash=sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8 \ + --hash=sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca \ + --hash=sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a \ + --hash=sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54 \ + --hash=sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2 \ + --hash=sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be \ + --hash=sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21 \ + --hash=sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd \ + --hash=sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a \ + --hash=sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd \ + --hash=sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c \ + --hash=sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73 \ + --hash=sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6 \ + --hash=sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7 \ + --hash=sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9 \ + --hash=sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa + # via pgvector oauthlib==3.3.1 \ --hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \ --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 # via requests-oauthlib -openai==2.45.0 \ - --hash=sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d \ - --hash=sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777 +openai==2.44.0 \ + --hash=sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad \ + --hash=sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d # via -r backend/requirements.txt opentelemetry-api==1.43.0 \ --hash=sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1 \ @@ -970,9 +1036,9 @@ packaging==26.2 \ # opentelemetry-instrumentation # pytest # wheel -pgvector==0.5.0 \ - --hash=sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74 \ - --hash=sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f +pgvector==0.4.2 \ + --hash=sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a \ + --hash=sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08 # via -r backend/requirements.txt pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ @@ -986,9 +1052,9 @@ prometheus-fastapi-instrumentator==8.0.2 \ --hash=sha256:3c252e748151768a7aefd66824a04a870144f71de48a67aed211749a9ca2a548 \ --hash=sha256:746002ec1e2c58b93f61444e1d104de959a9463a6a3f1c8909ac3757e16c3866 # via -r backend/requirements.txt -proto-plus==1.28.1 \ - --hash=sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed \ - --hash=sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168 +proto-plus==1.28.0 \ + --hash=sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9 \ + --hash=sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8 # via google-api-core protobuf==7.35.1 \ --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ @@ -1005,9 +1071,9 @@ protobuf==7.35.1 \ # googleapis-common-protos # opentelemetry-proto # proto-plus -pyasn1==0.6.4 \ - --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ - --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b +pyasn1==0.6.3 \ + --hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \ + --hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde # via pyasn1-modules pyasn1-modules==0.4.2 \ --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ @@ -1265,121 +1331,121 @@ rankweave==0.1.0 \ --hash=sha256:bbc9a1da237bfdfc376150ec55a94628e808499778a03f3beb1ab8c238c0bd75 \ --hash=sha256:841d4cabd4f96bb67575f9500f9af08030efc04c065c0c2de9ac7b45535b20c8 # via -r backend/requirements.txt -regex==2026.7.10 \ - --hash=sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17 \ - --hash=sha256:081acf191b4d614d573a56cab69f948b6864daa5e3cc69f209ee92e26e454c2f \ - --hash=sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f \ - --hash=sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49 \ - --hash=sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135 \ - --hash=sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775 \ - --hash=sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d \ - --hash=sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067 \ - --hash=sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644 \ - --hash=sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e \ - --hash=sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43 \ - --hash=sha256:221f2771cb780186b94bbf125a151bbeb242fa1a971da6ad59d7b0370f19de9a \ - --hash=sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197 \ - --hash=sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1 \ - --hash=sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b \ - --hash=sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812 \ - --hash=sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851 \ - --hash=sha256:2f98ef73a13791a387d5c841416ad7f52040ae5caf10bcf46fa12bd2b3d63745 \ - --hash=sha256:31fa17378b29519bfd0a1b8ba4e9c10cf0baf1cf4099b39b0689429e7dc2c795 \ - --hash=sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc \ - --hash=sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c \ - --hash=sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4 \ - --hash=sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca \ - --hash=sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837 \ - --hash=sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e \ - --hash=sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8 \ - --hash=sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48 \ - --hash=sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8 \ - --hash=sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042 \ - --hash=sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d \ - --hash=sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f \ - --hash=sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6 \ - --hash=sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70 \ - --hash=sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c \ - --hash=sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f \ - --hash=sha256:53bbbd6c610489700f7110db1d85f3623924c3f7c760f987eca033867360788a \ - --hash=sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef \ - --hash=sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb \ - --hash=sha256:5c363de7c0339d39341b6181839ed32509820b85ef506deafcf2e7e43baadab4 \ - --hash=sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927 \ - --hash=sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2 \ - --hash=sha256:617e8f10472e34a8477931f978ff3a88d46ae2ba0e41927e580b933361f60948 \ - --hash=sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963 \ - --hash=sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a \ - --hash=sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be \ - --hash=sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341 \ - --hash=sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a \ - --hash=sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba \ - --hash=sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b \ - --hash=sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4 \ - --hash=sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa \ - --hash=sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3 \ - --hash=sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e \ - --hash=sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb \ - --hash=sha256:799a369bdab91dcf0eb424ebd7aa9650897025ce22f729248d8f2c72002c4daa \ - --hash=sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf \ - --hash=sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08 \ - --hash=sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a \ - --hash=sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8 \ - --hash=sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c \ - --hash=sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe \ - --hash=sha256:87b776cf2890e356e4ab104b9df846e169da3eb5b0f110975547091f4e51854e \ - --hash=sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da \ - --hash=sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e \ - --hash=sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6 \ - --hash=sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561 \ - --hash=sha256:9a094ed44a22f9da497453137c3118b531fd783866ab524b0b0fc146e7395e1d \ - --hash=sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5 \ - --hash=sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0 \ - --hash=sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89 \ - --hash=sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6 \ - --hash=sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1 \ - --hash=sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8 \ - --hash=sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361 \ - --hash=sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68 \ - --hash=sha256:ab2fb1f7a2deb4ca3ddebbae6b93905d21480a3b4e11de28d79d9fb0d316fcf8 \ - --hash=sha256:ab39d2c967aae3b48a412bff9cdbe7cd7559cd1e277599aceaeada7bc82b7200 \ - --hash=sha256:b04583e8867136ae66353fa274f45121ab3ec3166dc45aaff3655a5db90d9f0e \ - --hash=sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c \ - --hash=sha256:b56416091bfd7a429f958f69aaf6823c517be9a49cb5bf1daa3767ce8bf8095e \ - --hash=sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173 \ - --hash=sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1 \ - --hash=sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296 \ - --hash=sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc \ - --hash=sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be \ - --hash=sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d \ - --hash=sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344 \ - --hash=sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e \ - --hash=sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6 \ - --hash=sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682 \ - --hash=sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be \ - --hash=sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1 \ - --hash=sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2 \ - --hash=sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794 \ - --hash=sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3 \ - --hash=sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6 \ - --hash=sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478 \ - --hash=sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca \ - --hash=sha256:e21e888a6b471b2bb1cdd4247e8d86632672232f29be583e7eafaa5f4634d34c \ - --hash=sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e \ - --hash=sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06 \ - --hash=sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983 \ - --hash=sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d \ - --hash=sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402 \ - --hash=sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90 \ - --hash=sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f \ - --hash=sha256:ed7c886a2fcbf14493ceaf9579394b33521730c161ebb8dad7db9c3e9fcab1a8 \ - --hash=sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192 \ - --hash=sha256:f0192e5f1cfc70e3cb35347135dd02e7497b3e7d83e378aa226d8b3e53a93f19 \ - --hash=sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38 \ - --hash=sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f \ - --hash=sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2 \ - --hash=sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200 \ - --hash=sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181 +regex==2026.6.28 \ + --hash=sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463 \ + --hash=sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc \ + --hash=sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c \ + --hash=sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762 \ + --hash=sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06 \ + --hash=sha256:11251768cc23f097dd61b18f67966e70f74da822784d17e12a444eb6b29d4288 \ + --hash=sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab \ + --hash=sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04 \ + --hash=sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97 \ + --hash=sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c \ + --hash=sha256:1e164ace4dbab5c6ad4a4ac7c41a2638fe226d0c770a86f2eb041f594bac6ee7 \ + --hash=sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc \ + --hash=sha256:2097591101d70bcc108af64c46f6066bb698ee067fec5f75beac0be317639311 \ + --hash=sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db \ + --hash=sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03 \ + --hash=sha256:23f7e0cc60c72486b42a685f1ff4eec90d50d4fb05e4f9c7d5363b03aa02600d \ + --hash=sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3 \ + --hash=sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a \ + --hash=sha256:3169a3159e4d99d9ae85ff0ed90ef3b8906cc3152653b6078b842ace6c8f72c3 \ + --hash=sha256:31d7538a614b5842bf53ce329d07b43f97754ca7e6db8d69f347e071bce1c953 \ + --hash=sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c \ + --hash=sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6 \ + --hash=sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417 \ + --hash=sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27 \ + --hash=sha256:3c60b297292e7e1ef5d02a4759f9e452ee4c8bb95e168d8fd0b5db01bd806f9f \ + --hash=sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342 \ + --hash=sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d \ + --hash=sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d \ + --hash=sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a \ + --hash=sha256:418208ea0af51cfed4f46eb9b1ea7cfc990ca284f0084ecbd951460fb089421e \ + --hash=sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195 \ + --hash=sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34 \ + --hash=sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e \ + --hash=sha256:4d80c798b0eec6ea3d45f8816a1e8886c5664615d347d89e8c075b576a1b5a5d \ + --hash=sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8 \ + --hash=sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5 \ + --hash=sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858 \ + --hash=sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581 \ + --hash=sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf \ + --hash=sha256:56f05194c4843957dd8b3af87eb0c52d8cf0509e7f18e172d727f5f8ff840646 \ + --hash=sha256:5977295b0a74e8241df8a4b3b27b12412a831f6fa32ee8b755039592cd768c3d \ + --hash=sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20 \ + --hash=sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4 \ + --hash=sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767 \ + --hash=sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400 \ + --hash=sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94 \ + --hash=sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7 \ + --hash=sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8 \ + --hash=sha256:70710927033af3b54369f17aaba1343b97a23d0b1aa994fa1512b08b1b8c136a \ + --hash=sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387 \ + --hash=sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19 \ + --hash=sha256:7635fa2cddb917a6bbfac7890602573d2d8c4e470703b0640e6f86a988817ec3 \ + --hash=sha256:76493755f79a88d5ed2c9e63a41d3c05997e0a7ffbe76ed8c4ded8be35b8b14c \ + --hash=sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a \ + --hash=sha256:7bb96c13d6cf5880d31bbef84ca701a64d738aa491c2b79975cc33f8ad00a31e \ + --hash=sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb \ + --hash=sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1 \ + --hash=sha256:8b92366d9c8bba9642989534073662abdd9b41faf7603a7ae71597833f3b88f0 \ + --hash=sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b \ + --hash=sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820 \ + --hash=sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38 \ + --hash=sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1 \ + --hash=sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878 \ + --hash=sha256:9c26a47770d30a0f85c01e261d2a3ebc342c4af6fd666dbd8c1fe4cbf3adf726 \ + --hash=sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475 \ + --hash=sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e \ + --hash=sha256:a361feeaf1b6ba1df060f2ff5c5947092edf537a35ce78e76387ac56d3e0f4a4 \ + --hash=sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5 \ + --hash=sha256:a71b51dd08b9b62f055fafab3dee8af8bd2ec81b373a44caef18d6c5ca28f43a \ + --hash=sha256:a7cf03c87f7b9cbc25a8894cf9be83818406677b6b391b003ec7c884923387b5 \ + --hash=sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0 \ + --hash=sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c \ + --hash=sha256:ad5c67786145ec28a71a267d9f9d92bdc8d70d65541eea852c253f520a01f918 \ + --hash=sha256:ad73ecf20c1ef5c975639f8bf845a9370fcf7dada7edc1e3b0bca20e2f8202f6 \ + --hash=sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb \ + --hash=sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca \ + --hash=sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3 \ + --hash=sha256:b83932645630965fd860fdb70ebbf964bf3e8007f08851ea424d01f8d35454a8 \ + --hash=sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8 \ + --hash=sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5 \ + --hash=sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b \ + --hash=sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba \ + --hash=sha256:c10f2c5a55ab3dd8318d8ad5f11b530e2691c0edebebde7713066f484902c3fb \ + --hash=sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb \ + --hash=sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7 \ + --hash=sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816 \ + --hash=sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe \ + --hash=sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1 \ + --hash=sha256:d98b639046e51c5de64d9f77351532105e99ca271cb6f7640e1f903d6ab63032 \ + --hash=sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2 \ + --hash=sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3 \ + --hash=sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9 \ + --hash=sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d \ + --hash=sha256:e5efbc1af38f97e300d43028e5a92e752d924bcfb7f465d8669d5d5a6e78c233 \ + --hash=sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f \ + --hash=sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546 \ + --hash=sha256:e81f1952355042e517dc9861ce65c676e4a098f42402993c40461786d1f794d4 \ + --hash=sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba \ + --hash=sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9 \ + --hash=sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854 \ + --hash=sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26 \ + --hash=sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab \ + --hash=sha256:ed7b30185ee3f8b9b053b0be567b4d226016e2afbebc17fde1c6a4580937b688 \ + --hash=sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d \ + --hash=sha256:f1758df6fdd8c800620a5638958720e8a635e1da49a2f09df2dd63e94a24ec4a \ + --hash=sha256:f1da438e739765c3e85175ede05816cbede3caaacb1e0680568bda6119bfdfca \ + --hash=sha256:f5561e47bbe2b75373b695326507743fcdd4d2cc7f5022312024ccf39fa094e0 \ + --hash=sha256:f5fbaef40c3e9282ccee4b075f5600a0d858aa0c34147732f1baa69c8188a95d \ + --hash=sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3 \ + --hash=sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2 \ + --hash=sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c \ + --hash=sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84 \ + --hash=sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493 \ + --hash=sha256:ff0f41a00f23ea5054acb61901380c41813d813eee3f80f800995710bcc52ecd # via tiktoken requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ @@ -1399,25 +1465,25 @@ requests-toolbelt==1.0.0 \ --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 # via langsmith -ruff==0.15.21 \ - --hash=sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075 \ - --hash=sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be \ - --hash=sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd \ - --hash=sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd \ - --hash=sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86 \ - --hash=sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341 \ - --hash=sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd \ - --hash=sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67 \ - --hash=sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41 \ - --hash=sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d \ - --hash=sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d \ - --hash=sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8 \ - --hash=sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233 \ - --hash=sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8 \ - --hash=sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500 \ - --hash=sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3 \ - --hash=sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646 \ - --hash=sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f +ruff==0.15.20 \ + --hash=sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078 \ + --hash=sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460 \ + --hash=sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566 \ + --hash=sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487 \ + --hash=sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca \ + --hash=sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b \ + --hash=sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd \ + --hash=sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4 \ + --hash=sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b \ + --hash=sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3 \ + --hash=sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415 \ + --hash=sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c \ + --hash=sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b \ + --hash=sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632 \ + --hash=sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267 \ + --hash=sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae \ + --hash=sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21 \ + --hash=sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053 # via -r backend/requirements.txt setuptools==83.0.0 \ --hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \ @@ -1565,13 +1631,13 @@ tiktoken==0.13.0 \ --hash=sha256:ed5a30027cb4d8c7ca8b273d4766f3db3cf58fad9e9f3b1a68a351ffb54873d5 \ --hash=sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94 # via -r backend/requirements.txt -tqdm==4.68.4 \ - --hash=sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520 \ - --hash=sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2 +tqdm==4.68.3 \ + --hash=sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482 \ + --hash=sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03 # via openai -typing-extensions==4.16.0 \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via # alembic # fastapi @@ -1596,9 +1662,9 @@ typing-inspection==0.4.2 \ # fastapi # pydantic # pydantic-settings -tzdata==2026.3 \ - --hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \ - --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 +tzdata==2026.2 \ + --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ + --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 # via icalendar uritemplate==4.2.0 \ --hash=sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e \ @@ -1608,218 +1674,169 @@ urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via requests -uuid-utils==0.17.0 \ - --hash=sha256:03815cea572c8a693cab5475b9d750cc161470961c7defa27e9286cad62f38f5 \ - --hash=sha256:04452640d8b6920c480c16e5afe91ff896d236e0c972830f9247e0898d38c803 \ - --hash=sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869 \ - --hash=sha256:0ab4a66e7a035ad6625cfc1fbdb34f5c2d25a80ae1ef4bfee458ea2036333c6d \ - --hash=sha256:0bc4c431ccd59c764080ceb43b126043325fe17861b87759d026a0cdd8423bb2 \ - --hash=sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6 \ - --hash=sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968 \ - --hash=sha256:1019476b6bdc047216ef7414be5babe0fa5ccfde977c0cac4fd6c75ddec66ff7 \ - --hash=sha256:14dc2f46abb1091260c0d203fcbdf4e045042cc07e49183fd3b255904b95eb70 \ - --hash=sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9 \ - --hash=sha256:1edf2f8732e4ed95bd7b65f2658f4aa072efaaff321144f4e0d4bf6a22709263 \ - --hash=sha256:1fd6f0e8a162dc0e9255b6aebe3cd175e76c33202f1bf39da9e6294b93db0099 \ - --hash=sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3 \ - --hash=sha256:220b52746d99e11964badac3c0869016e0c24bafb70a7dd5c2c072a6be3da9cc \ - --hash=sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc \ - --hash=sha256:239d8a281fe10bae33205b5d43185834d556b18434e0a113b5dc1dfb2fd97e91 \ - --hash=sha256:29179ffb7b317239b6d6afb100d14c439c728770460718280b9c0a42d2561ec2 \ - --hash=sha256:2db386941cfdecdd0b5a8ceeed5cf7479c83d1730dcf64a48d43cfa018cc3310 \ - --hash=sha256:2dd4a21baaac9a88486f0dd166c5793feb101a0bb9f006f2c401657fff5a1343 \ - --hash=sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb \ - --hash=sha256:3150d836290c88f1d26eb59c4db280d87417dd3bfaadd2889c77416c8f0ff6fa \ - --hash=sha256:32abaafc8e91928b3d9f4d82e42d2094041e38ad6bb964066faadff28e4162f1 \ - --hash=sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9 \ - --hash=sha256:344f7c755e280ea0ba6aeb08022190d867a80000b1715cacded54fc4b5633607 \ - --hash=sha256:351462debd866f1f25e4d4f5c7fac89525b52151f0102a1bdfe94a999b046f5f \ - --hash=sha256:375cde148430d60a4a07c03abaa0774c4fddfdd90de99b4ba02f24088bc9d750 \ - --hash=sha256:387cf7437c94ddec08651a0f1081381299c7075bc48a6251d8922bf39973378a \ - --hash=sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988 \ - --hash=sha256:405233a5f625b3d995648f4647fa6befa4567cf3f74e1f6b9837e16f7310f0e0 \ - --hash=sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64 \ - --hash=sha256:42275ebd0e8e74e32cdbfb8bd88fc99576567d51d54a508020611fd8f4f463a0 \ - --hash=sha256:4441600447d340ae103a353f01dbcd22ff680e5ee1a22988efe8d7b791d8fdb3 \ - --hash=sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d \ - --hash=sha256:4bf4d9cd1e80e73922073b9b27c143bedeb109d65f94cd12712e2c87118f2b7d \ - --hash=sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c \ - --hash=sha256:52db0e471d3d2632d35445af352591f40a8f32959a412981d9f51e068bb9514b \ - --hash=sha256:53ce348ef4c6e98c02c19c522af01334fe94476ce9af0db8c4482f9f142ae9c1 \ - --hash=sha256:5641071337eb11d61a001ea08793bf72216f3241f0a433ed2764804b2a3e3cc7 \ - --hash=sha256:5670c52a438e21483ce715776144914a4e2a2a5c62d9dee15f8a3e90cf128ae6 \ - --hash=sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131 \ - --hash=sha256:570db214f6d8507587a8faa968a3fe65e957daeb7bc48b27dc7f69bc3ecdd6f1 \ - --hash=sha256:58838921e377791ef22c64cc92141bfae030f43651ff9272f0f28a208a9e6a5a \ - --hash=sha256:589d9da7de8fa7f739bb970ac4632c9a268213117d634e1c4a58c1c1e821ca05 \ - --hash=sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098 \ - --hash=sha256:622cdde768300591ac79bfcd7bb3468e4b191b1105d5dbfe8d87c39d8f63dd46 \ - --hash=sha256:673d89cc434cc9b97a0b4cf61272f6fca70a81f64eb0afbface2a0d9f77f06cd \ - --hash=sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e \ - --hash=sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a \ - --hash=sha256:6f29689a76fe7a49cbd629a794d0ec1eab48814e323a00a146a741b0195bde68 \ - --hash=sha256:75d7411e8eb9259764dd60310738540649057cda4509b4af14b36b7f663bfeb0 \ - --hash=sha256:793229621e1ad6cac55f015cfa9f4eff102accbc3da25d607b91c6b0bec167fb \ - --hash=sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73 \ - --hash=sha256:7b9044ce4acbf392d4b3a503fe377641f4deff82e6c341c36ef27af0dea76cdf \ - --hash=sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89 \ - --hash=sha256:84ed3a2d5cd3ae6db87af20bfed3331116195ba4757ad7177fc8f12c1bbce2a9 \ - --hash=sha256:89a0980d49683c00539c59cd9f46b1908c538e6b5b0a48ad12187bb856d0f391 \ - --hash=sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a \ - --hash=sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc \ - --hash=sha256:9082e709014946b1f6e96ae6ecd93652efca2d2a6a3ab67dbe151c8b4bf193a4 \ - --hash=sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287 \ - --hash=sha256:9472a8de37faf8bd216c628e0e68c8f6bef730d3ba0a5060f3b0fa460c992ac2 \ - --hash=sha256:967955620df45e6cffe2e9950cb9903cb455649396f896b26b04363a91a5054b \ - --hash=sha256:975c17da26c5b9d46c336b03c52a057ac28378d6f9d98b58d32a038589bb3912 \ - --hash=sha256:981cc10163988defea96e8d6c507df151eab8f483e7df9ae543d5a41a4be073b \ - --hash=sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1 \ - --hash=sha256:9a91c4814c7150a4d798da691b7804eacd78c4b84fb392a60fa0de21341861eb \ - --hash=sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff \ - --hash=sha256:9e753e81457241e2200c56a898e268e8fa25796271af0489c608f24d8e631eed \ - --hash=sha256:a46bedc273b6f58f11dee816ff74999625ef8d007890f411b7a4975bf1c89330 \ - --hash=sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2 \ - --hash=sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c \ - --hash=sha256:b5d11cccba076a32321ef1380dea956821f0b51794ef59df64e58fb1cd543aae \ - --hash=sha256:b6c5d2d71e1f17329150ad9427d27f4a3f29a01792e7ecdc64a98ac5368fc4d5 \ - --hash=sha256:b776c7fc8755c7de06dd5a22b47c40ae84f67d13277ebb233cc84933ba4dcbcd \ - --hash=sha256:c00d182e31034250690f417b9068b78eab423c10d76766664e82d9860c340479 \ - --hash=sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f \ - --hash=sha256:c4f845166b09acc65c5213a35551a7f81c17fa010ab467229b5813f79d17fe13 \ - --hash=sha256:c589f5023d471ce75dd2cce61acb25ed6347e562041588a1a366808f22d7176c \ - --hash=sha256:cee808b405e9095506f4e4e89924bec7ea77eac3129b6fe36eda04364b3b343b \ - --hash=sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63 \ - --hash=sha256:d27c531edb8d1f38ca2eddaa1fa24913a460aeb721f2efd4ef42a124ce94e354 \ - --hash=sha256:d2d9a63a9e6f2416ace8c109043a9280d6b34f34bb2e5421903e149403db40a6 \ - --hash=sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652 \ - --hash=sha256:d63010803d7c368963bbe6f7ec379593e76dd581d7db0f29118d88713c9e0354 \ - --hash=sha256:dd741c73440b328f937dc53b344ecadc46bc4f0cec0333a8f42b55f3468ce7ec \ - --hash=sha256:de1064663aa7c839286488a319d2b3b478ca5ab5b2091ade888ed0eeca11a98a \ - --hash=sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206 \ - --hash=sha256:e288a06cbbbcd01b44386e767985c9e21d2ad9bf59829aa7058d9a2a494804ab \ - --hash=sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637 \ - --hash=sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94 \ - --hash=sha256:e7b04935a79c03c41ad08d0a5f390aac968bfb561f1268897bc5b0f077971efd \ - --hash=sha256:f7e9b8728ba07a3cb2f29d5aa1a266c2664eb8ef0fd43afa34627c92f7fac8f0 \ - --hash=sha256:f9b093cb3b6c9d6233ef45a05cab064d2aa0a8cb3c5777084c9e20fcb77c2371 \ - --hash=sha256:fae8b282f0cb22a5de222999f7723f4e5ec04f6fcdf4aaef879b5b36625ae2b0 +uuid-utils==0.16.2 \ + --hash=sha256:01f81c71cf2185de0707e9d2f248e17025ba50af0acd3cbf51cd8aea96c2e0be \ + --hash=sha256:026b96b2f1e6b004579e030692d2f6568ccd0b29d40687213c31694abf570c78 \ + --hash=sha256:0529b1ef0788f663e1211d221b59a38ec67f9b084f1ea5342ba84358b3d87e98 \ + --hash=sha256:0aa2569908bdb21ccb216cd6bd06cb934351ee65ea7cd5e351e19f633a99b577 \ + --hash=sha256:1a30b6a5790acb854e4b65fae7875e5d3c6f8076fa9c91dac43ff9e28380bc52 \ + --hash=sha256:1ef8c561fdf88fec205e3d54037824cfe2addce16b509a8d2ecb69daa904cbb7 \ + --hash=sha256:22446af2ae47d1054562b159bcd65714a022713e56697eef77cb5f291dc5ef13 \ + --hash=sha256:27271b37fbc6812bb1542c4b8e22ee00223a6bf7f62b1f38d3bcf8e92f6d9acd \ + --hash=sha256:273679723e88544dd2de0564ab7f2fddfa2270faf05cabfdf63c275be67ec2a1 \ + --hash=sha256:280d4f1f22dd2e79c1cc31ffc7fc26dc3534ffc114dedcdd29cc8489c5ce9c98 \ + --hash=sha256:2cd4612085e6bbf6a00b9890779023ea97fe1ee8dd1758381022f7588a06e123 \ + --hash=sha256:2e9ca7f5e215373cc9c147172170a0b1a4ab0dee9cc62fe446d9b075f31e3241 \ + --hash=sha256:2ef60e0a91675cfd9850e8aefd0d899fe09c4afb572bbe0ac2de4f8848d7663d \ + --hash=sha256:3324bac95084e63e28553c92fac5a0394c636a76e03e50a7dab0c0bbddf87fa5 \ + --hash=sha256:3aa5c2ebc843e85a078ec27c1ad677871c44065b3dd58748166783a3c454859f \ + --hash=sha256:3d4805c4739dd06d539f8f4fa94f5aaf26eca4b3ece1ef134d4ff904c6b08dcf \ + --hash=sha256:3d8257329f26905f009aed694bd3b17f334f43748b03134dc7bc99d6c5b4e371 \ + --hash=sha256:3e3acb5e1451232381daea01645a98c69de4bb9ad88d77a1f7c1df4d83d54e62 \ + --hash=sha256:41165aa4059e3b03605c1c8c48df6c887a16f8f6a1fc4cb2155360a61aad8666 \ + --hash=sha256:4125bf6ed3ae443c05e140f8585d174b9d647295b12034d5ec94ae2ae38edefa \ + --hash=sha256:43cc72a92694d08ade8faadacf928857d9cceb84b449473246ae4e4f263d7d22 \ + --hash=sha256:4942b26ad12c5187bac52b7fb4685040139ff0df9a19cde33e5025326f6180fc \ + --hash=sha256:503f020acc7dbeb39c47fa33cf2971cf5960fa11f8394513fac461762a90c556 \ + --hash=sha256:5119bec75f56bd028d97472f72b1ed723a0d60b09a48017dc70a3cb1892ed081 \ + --hash=sha256:511b5fde12d29c37a9badd399af62105bb2f4696aa10eb18be74e7b9ca84413a \ + --hash=sha256:585d3adf73afa60348bf2bd529491c640a692350e76d8ff3974455e273aadfe7 \ + --hash=sha256:5dfc3e9e75139a84898771d31958ece6cdee8e8f127700aa8aa26a4f1a348d57 \ + --hash=sha256:61454f2139424a6cff14eca7849c28b3350f261453b74075aa20fe99592dbb16 \ + --hash=sha256:617955f4b3f649617c0388127d8a257202189d5cc3c720313f8b207df1cdb2a4 \ + --hash=sha256:62b8841895eff1c0afbaf5f0050411667231160478c8ff9f411742abffd3b619 \ + --hash=sha256:6c02f85f49c9c2abbf247a8622458c30232332a28711755aa191da5f38015af6 \ + --hash=sha256:6f064dc54c6abecb09eb104d953bfb079f3c395e0d6b18899979f852d1083549 \ + --hash=sha256:6fe3fb4bcecef69cacf3a11e182e204ce778998bd439152a173bdd2e9e8e9cfa \ + --hash=sha256:71192a59d473f3f638e2a238905046e2942006ad90ac5ec10d578e58ff9a08ce \ + --hash=sha256:725110434a1d482a639a9ac467a24f1cb531d84ab52e454a13fe145b10b42cae \ + --hash=sha256:72cfd9ff1e8a7c371a044687e77eb873721c4a9f4814e453439bfba595b84303 \ + --hash=sha256:76632d2e16e26de777851ec07961ceaea14e65167d0603a0b17fb169fa9ca37b \ + --hash=sha256:79c5a3bd4301257b9a524efd16baf61ea65cd0d1b60b47d80f20b151fd65a09f \ + --hash=sha256:7f3cca9ca5e2c2dfd7b885f0d34c10b993a070d3593f3cdfef785195da36fb0f \ + --hash=sha256:8197870739a3094990743a80f075fa0b17beafd6c187e5f360e021d90a12a6d1 \ + --hash=sha256:8323136bb02355c1b973492ab98b0722206dfdedfb148e4115c35fcdf3889bad \ + --hash=sha256:840b21e609a9b203eee06bdc73e18397154447a9814a8e78d9b68e5104d9802f \ + --hash=sha256:887efa34701d197239ec3b0e89993ee8c0cea1746483b606e54746ea81c966f4 \ + --hash=sha256:89a627f74cb55aa508809592ab9149806649e4ee37f4bc91b60c7ec10929f0eb \ + --hash=sha256:8b8e325e61f918caf74ca540e3384b81e6e22aea782e57f615d15fc9773b96c8 \ + --hash=sha256:90903ab7fcdfb0300390c15f5a68cb91f15139d9a1a93f134c783d7a973fa269 \ + --hash=sha256:909e26fa2451c8db31b9ed1d3c8e4ecf513b6d1619db4205997fe99eb6b4ef4f \ + --hash=sha256:9282677ebf2ea5b437c20d16e75bcd7629bdc205018f95557b33b76868d8bb5b \ + --hash=sha256:945e1819dde4cce6828683ce11311977e73e6d46c6cc18e5fb9fcab2051b94bb \ + --hash=sha256:97ee6f5e803ea571f5f6da42efc97d8c5a13f121043680177f8470529b94e855 \ + --hash=sha256:9ad2adeb941292fe02e1e5c70b80a5746c45b1b77594506c2a1421455d8384f9 \ + --hash=sha256:9b4520521aa46a2582fe1829c535fe60b78999b89257db998df3816eb895bdf3 \ + --hash=sha256:9bf8bfdffb22f620635580b17fd178272f30a9841b824b19b935c8db64bf09b6 \ + --hash=sha256:9e97ab941660f781a8e45f15aba9ee01b40dbb96adb5c43617c1671a4604b25b \ + --hash=sha256:9fe600ab7d3d4eb56986e814042c917e728ac92cd8a41f099a6b59b84d8bf9e6 \ + --hash=sha256:a59205fc15463dd0f978f14df14307737e3d4e8ef4aefa29a9d0fa766d84d16b \ + --hash=sha256:aab7cdf28a3e2859ca4f40a3e3bf53eb35895039c80d4d8d8c5e15b90346c55c \ + --hash=sha256:aac82500329ffaf2788dac36cf133e1e4e23b6d5e1118274ea6749c3b512f4f1 \ + --hash=sha256:ae5fa2007fd26d26f7b09e76259d5ca99bec191616207ca929f8dca12da08129 \ + --hash=sha256:b5f8e7d0bb2c6e6180176237f92d2e949626e04fcf701c49d73f128e1f64e1d1 \ + --hash=sha256:bf922bad7df257336b594d316a1657df569860bb5389602919001fa6fb17f06e \ + --hash=sha256:c19b7d595d12923da682ed13d313c2333b9ebf214e65a47a24927a8a3a81b191 \ + --hash=sha256:c1dbe65ce6d46c5f645356d64bfb2de7564e2426ca8c9b1a0a401d6f7ae5cc22 \ + --hash=sha256:c3bf41b696b0fe808df1b4091c70273a52ea033b0fe97341cd67ecd76d22bb3a \ + --hash=sha256:cae08df8695f4b01fce2a8ab50e9e310971276d85dfc7103e977bed52d365094 \ + --hash=sha256:cc25ad320c9b44c2d3ed33aff4f85b0b277bef4ff79b12c01ee58b52ea44be1d \ + --hash=sha256:d0ca752d51d1004caff65fccffd44b32a26cb099b546e0512cfa09facb683d6c \ + --hash=sha256:d906c00f965d5c5f4812d0086dc49bf813285ea84c97e8816405200e146f805b \ + --hash=sha256:dc4b9d96a2c689d664cf3fc7f7db46b82d2821fb2ce8a4f0798fc0a92c1569f8 \ + --hash=sha256:dcdfcab60562d12dd43c1a6f495b1d089e41f0e10fac37d94db285d72b678c23 \ + --hash=sha256:dcf20151d2aa451013f2b3c2cce06958f43b7573b5f616adb91786c7b777715b \ + --hash=sha256:dd7aa18db5cc826d482d876a826fee445839701f81f78567e7c74b4458d57a84 \ + --hash=sha256:e04b5c10c6fcf9d9801084d1e86c9d7ada7eb48fe07ee4ae5e7fe5b1a852db8a \ + --hash=sha256:e0609e7e906c08386b7f33141254df05dcab24f1c4884150988dc7a287516aca \ + --hash=sha256:e10a02b3a31ed44c7c9a96abde335f5fa222735e73f3081d693414377eb3b016 \ + --hash=sha256:e44020a4532229ccfbba353138539774686350dda71cf4368e257973dd8ba403 \ + --hash=sha256:e75f9429d4533ce275c98bc68bf47fb237ae7b32c954266dabc5edab0c7d682e \ + --hash=sha256:e9064805881c30dd80a4189a0da7130e3d684de353ea36edd99c1b994bdf429e \ + --hash=sha256:e92875a315f3cc4fe7a2324c17b3c7ac5e3fd0e24b14fc4deb28370431fe6a2b \ + --hash=sha256:ea175649789f1e93edbf1a0440cab18c9838977703917221777691d8d988d7bf \ + --hash=sha256:ec5b1a338b92d1eb121e9eaf06ae3db1b9a5cd794ce318a475f6dc6f9e89c3a8 \ + --hash=sha256:ecadf55ed6b8fb72e7966b52fd02919e7d7bb8e7bffeaf285803b82e774debfb \ + --hash=sha256:f4af7673e84e1ec6029f18d3a0408095c471c4e2691b6e46b4e1f0a2051734ba \ + --hash=sha256:f668035ea9faa763e8f1ea42040e8439db88cf2517056d47c348a62a257a1d02 \ + --hash=sha256:f69658c42411540cf58be958a47e317fd2302cc0b613ea5cff1e60d87be2846d \ + --hash=sha256:f709169579a356132f224d525ed589f88d466bbb922b9d752d8d86b1fb57ad46 \ + --hash=sha256:f7a44f8250ec178c0af703c3f1b6e81865a771272ae735ca403f27c95c62f132 \ + --hash=sha256:fa637e4f314ad5b59ff6d8e809d506443d68bef30bfaecdfcfe02cce689abb2f \ + --hash=sha256:fad82e6482129c58ba9b00da6c247ab6e767645ab17981599229cce19d7b2ce9 \ + --hash=sha256:fadd23eee409237fb8637a35796a6e108873c28b40f7de89a36685f18ca055ad \ + --hash=sha256:fcc212dec731aeba110953643c214982e667cab9802f7d99d066e03ba0c44c90 \ + --hash=sha256:fcc329be41bb6534ecb03e50596179ab76c7643ced33d13c66967d5ae1869663 \ + --hash=sha256:fd32dbca0792b9683160151dc07fad11b915020eed7c82b43faf0862c2ff06a0 # via # langchain-core # langsmith -uvicorn==0.51.0 \ - --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ - --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 +uvicorn==0.49.0 \ + --hash=sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f \ + --hash=sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3 # via -r backend/requirements.txt -websockets==16.1 \ - --hash=sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b \ - --hash=sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43 \ - --hash=sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209 \ - --hash=sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6 \ - --hash=sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270 \ - --hash=sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb \ - --hash=sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37 \ - --hash=sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb \ - --hash=sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8 \ - --hash=sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105 \ - --hash=sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057 \ - --hash=sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045 \ - --hash=sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83 \ - --hash=sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad \ - --hash=sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5 \ - --hash=sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07 \ - --hash=sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce \ - --hash=sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c \ - --hash=sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b \ - --hash=sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c \ - --hash=sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a \ - --hash=sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79 \ - --hash=sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02 \ - --hash=sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5 \ - --hash=sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953 \ - --hash=sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901 \ - --hash=sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa \ - --hash=sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee \ - --hash=sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2 \ - --hash=sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4 \ - --hash=sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3 \ - --hash=sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3 \ - --hash=sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341 \ - --hash=sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502 \ - --hash=sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9 \ - --hash=sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600 \ - --hash=sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3 \ - --hash=sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4 \ - --hash=sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21 \ - --hash=sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792 \ - --hash=sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2 \ - --hash=sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9 \ - --hash=sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805 \ - --hash=sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9 \ - --hash=sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145 \ - --hash=sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf \ - --hash=sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938 \ - --hash=sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f \ - --hash=sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47 \ - --hash=sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a \ - --hash=sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4 \ - --hash=sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367 \ - --hash=sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd \ - --hash=sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09 \ - --hash=sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216 \ - --hash=sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87 \ - --hash=sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976 \ - --hash=sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe \ - --hash=sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030 \ - --hash=sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60 \ - --hash=sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9 \ - --hash=sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff \ - --hash=sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881 \ - --hash=sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca \ - --hash=sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6 \ - --hash=sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b \ - --hash=sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe \ - --hash=sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d \ - --hash=sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15 \ - --hash=sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97 \ - --hash=sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b \ - --hash=sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0 \ - --hash=sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb \ - --hash=sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47 \ - --hash=sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352 \ - --hash=sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164 \ - --hash=sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b \ - --hash=sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c \ - --hash=sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff \ - --hash=sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f \ - --hash=sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5 \ - --hash=sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452 \ - --hash=sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d \ - --hash=sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955 \ - --hash=sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81 \ - --hash=sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a \ - --hash=sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4 \ - --hash=sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a \ - --hash=sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d \ - --hash=sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a \ - --hash=sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7 \ - --hash=sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268 \ - --hash=sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2 \ - --hash=sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1 \ - --hash=sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a \ - --hash=sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b \ - --hash=sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3 \ - --hash=sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c \ - --hash=sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72 \ - --hash=sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213 \ - --hash=sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a \ - --hash=sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de \ - --hash=sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9 \ - --hash=sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef \ - --hash=sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e \ - --hash=sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7 \ - --hash=sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94 \ - --hash=sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9 \ - --hash=sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0 +websockets==16.0 \ + --hash=sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c \ + --hash=sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a \ + --hash=sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe \ + --hash=sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e \ + --hash=sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec \ + --hash=sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1 \ + --hash=sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64 \ + --hash=sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3 \ + --hash=sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8 \ + --hash=sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206 \ + --hash=sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3 \ + --hash=sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156 \ + --hash=sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d \ + --hash=sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9 \ + --hash=sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad \ + --hash=sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2 \ + --hash=sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03 \ + --hash=sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8 \ + --hash=sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230 \ + --hash=sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8 \ + --hash=sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea \ + --hash=sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641 \ + --hash=sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957 \ + --hash=sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6 \ + --hash=sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6 \ + --hash=sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5 \ + --hash=sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f \ + --hash=sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00 \ + --hash=sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e \ + --hash=sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b \ + --hash=sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72 \ + --hash=sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39 \ + --hash=sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9 \ + --hash=sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79 \ + --hash=sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0 \ + --hash=sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac \ + --hash=sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35 \ + --hash=sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0 \ + --hash=sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5 \ + --hash=sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c \ + --hash=sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8 \ + --hash=sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1 \ + --hash=sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244 \ + --hash=sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3 \ + --hash=sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767 \ + --hash=sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a \ + --hash=sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d \ + --hash=sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd \ + --hash=sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e \ + --hash=sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944 \ + --hash=sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82 \ + --hash=sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d \ + --hash=sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4 \ + --hash=sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5 \ + --hash=sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904 \ + --hash=sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde \ + --hash=sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f \ + --hash=sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c \ + --hash=sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89 \ + --hash=sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da \ + --hash=sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4 # via # -r backend/requirements.txt # langsmith @@ -1919,194 +1936,194 @@ wrapt==2.2.2 \ --hash=sha256:fa81c5b5fe8cd6c41e3a798533b81288279e5fdbde2128f21071922764281c99 \ --hash=sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e # via opentelemetry-instrumentation -xxhash==3.8.1 \ - --hash=sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc \ - --hash=sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3 \ - --hash=sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c \ - --hash=sha256:0204701e6d01f64254e0e5ff4255812b1febe027ddd7dda63372e27f98b5e91f \ - --hash=sha256:027dee4355f3fcc41481650d846cf6cfc895c85a1ab7acd063063821a0df5b4c \ - --hash=sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec \ - --hash=sha256:0418ec8b2331b9d4d575fc9284427e8e69449d7172e99e1a86fcdd1f51a0a937 \ - --hash=sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92 \ - --hash=sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a \ - --hash=sha256:09a204dd4bb0823daf938cdd0dc8057d5f1e14fe3cbde929424255f23f9de872 \ - --hash=sha256:0dfdf19b0d5433a75d61f19dc85737af0f0b95e445c1ad69c855115d05efed45 \ - --hash=sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3 \ - --hash=sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31 \ - --hash=sha256:1153265daa10750a9bf8e9b01753d7618024a300925591efaf16b1b7fa536699 \ - --hash=sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920 \ - --hash=sha256:12a3cf79dadbab9631230ebc4c51c7c60f1e9cdfb890c15fb733eaafe2e7713c \ - --hash=sha256:12eaeaa9ab8b9e6033a1fa5f6b338aaf55ff4df4bee11b59fd6ee03b19186ee4 \ - --hash=sha256:131324f719957b988861714de7d6ddf57b47abec3b0cc691302ffeaba0e05e10 \ - --hash=sha256:15790b686f8723b845fec6f612a343beb815a25c83117a7fa408d7c8ee5aa8fd \ - --hash=sha256:1731407102b9332cd3c9dadee07db498bc3d437b95d752b5b1a5f7eb730a3738 \ - --hash=sha256:1b86ae798a976ccbc1d02af6ccb98f5b4d24756b1f65e995f11d10fe071f486f \ - --hash=sha256:1c332dd48b8cb050da2bb2a3c96d72b1664168650a250ef9718e423df7989e05 \ - --hash=sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc \ - --hash=sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329 \ - --hash=sha256:1ffcc98d8878e449e86dec008cea6f44cfd3a954d2ef24ae7d1cc9f725beec7d \ - --hash=sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215 \ - --hash=sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724 \ - --hash=sha256:23e710118a5778a45db740b431943a3f2a82a571a052c2768cce6544d9c8c62e \ - --hash=sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8 \ - --hash=sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62 \ - --hash=sha256:27a9e475157f7315826118e3f3127909a0fe25f1b43d3d3be9c584f9d265f937 \ - --hash=sha256:27cfc2f1ed76f956f36dfe0c56e5f5a3e94cd91eb78b893f63e2ef2ae404fcdf \ - --hash=sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d \ - --hash=sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75 \ - --hash=sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62 \ - --hash=sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8 \ - --hash=sha256:314d05fbc55719ae2438eaaba77bf2508ca4f030b26fa4c9c8c380e81c48fa33 \ - --hash=sha256:32a94ad2763e0263d9102037d349002c3d3c401e42770542c3eeb4801f311661 \ - --hash=sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0 \ - --hash=sha256:345b07b78e2bf583d71682aa34ae5b5fab575f7a1cb31e10263ebbc6f89f8c42 \ - --hash=sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893 \ - --hash=sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799 \ - --hash=sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887 \ - --hash=sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629 \ - --hash=sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5 \ - --hash=sha256:39c9d5b61508b0bb68f29e54546de0ed2a74943c6a18585535a7e37356f1dd12 \ - --hash=sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf \ - --hash=sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e \ - --hash=sha256:3c0d84c5f2e086b120bae4e7f551cbda804c1deb10d958478bed4f89ba286dfe \ - --hash=sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913 \ - --hash=sha256:402db908ea70eaf9800d9182a66596fc86f36655df8f63fdecf7c11da741d86f \ - --hash=sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e \ - --hash=sha256:4482380b462ca9e59994d072a877ecadd1cf51102daeeab2db696f96ab763723 \ - --hash=sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07 \ - --hash=sha256:454d78e786602278a2a4383d08048482052f4f0c61fa677ca590af08914d9bca \ - --hash=sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd \ - --hash=sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f \ - --hash=sha256:498017fbf2d13a768b3110d084bde39f2bd8664c1de0b8084f8ccc84425b7c88 \ - --hash=sha256:49aa8692507835dcc1e8ad8021f20c74c2dc13d83b5112e87877faa2a0035b20 \ - --hash=sha256:4b512261801b1e5fde7b6ebf2fef7977339c620cbbca88a0040ad9ad134f4d02 \ - --hash=sha256:4bec8b2c909bcfae9a0dc702346007e02a8c9ba5bbde83ffb224aa194f4f9efc \ - --hash=sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478 \ - --hash=sha256:4d6e88ddb3c741fbf29e1e7faf429880f8cd1d7aff4303247435a549726b4fb1 \ - --hash=sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542 \ - --hash=sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa \ - --hash=sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1 \ - --hash=sha256:5013be3bea7612852c62a7437f3302c1cfb91ca7e703b194459db0b2b2e0d792 \ - --hash=sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed \ - --hash=sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647 \ - --hash=sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55 \ - --hash=sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231 \ - --hash=sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775 \ - --hash=sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398 \ - --hash=sha256:57f80a898544db78ec6b0be6183bd1bc008933193d4199f5cde36b0e6bd5e062 \ - --hash=sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5 \ - --hash=sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf \ - --hash=sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22 \ - --hash=sha256:5c566b123dce7e4867ca518434cdfb9f84e5023771235b2e3107a26c9a41cbd8 \ - --hash=sha256:5d3dfb1f0ff146da7952867a9414f0c7a29762f8825a84879592612fd6139342 \ - --hash=sha256:5da703225374e3a4c8d4fd90e26fe7213a52004ec77f88b42b42e9e86d8c6d57 \ - --hash=sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104 \ - --hash=sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb \ - --hash=sha256:602efcad4a42c184e81d43a2b7e6e4f524d619878f2b6ee2ba469011f47c8147 \ - --hash=sha256:614bca2c7cfa87ec95b703e691c3c5eb6c448b6dabbe9776ac53883152951729 \ - --hash=sha256:632a34590c090d1285ed5efa5a02be919f3f9a56a64bd25f693fe1e2d27a27fb \ - --hash=sha256:64af54dd1c3a45a27c04942f9a1a4683322bdd127f4745cca4e02549c1d2d2bb \ - --hash=sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170 \ - --hash=sha256:656256c9f9303e47f07d5cb8ae4468285370adfafd7ba48aea33a458e7697626 \ - --hash=sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65 \ - --hash=sha256:6c7574528bc922f8757f34dd78ed60ab52b1c7973b630f5eae7ba33ec133ce71 \ - --hash=sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f \ - --hash=sha256:6cf633fe83b1d4e6519d7259b33afe40fbba5d3f438730156971dd0cf7730610 \ - --hash=sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113 \ - --hash=sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230 \ - --hash=sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684 \ - --hash=sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629 \ - --hash=sha256:7345007c12780985de4fd740148776d1eee18c0d41407c6fa1e48c5450304fe5 \ - --hash=sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f \ - --hash=sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9 \ - --hash=sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276 \ - --hash=sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9 \ - --hash=sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b \ - --hash=sha256:7dc4bdf008f77c88d544849c48c1a40faf25a5eff6cc466de2e8edc37c191fce \ - --hash=sha256:81f4ed9ca9644bc95cd976bfe10f7a4cafab8ffdc3aed52877d4600e445be7ef \ - --hash=sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc \ - --hash=sha256:8304be0982130954b7fd3aad18e2c6f8ee40254bc3d2e635991c16d77c91e2bd \ - --hash=sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061 \ - --hash=sha256:836f11d4474d3228e9909d97216faa4f7505df41cfaf3927eb29809de785a78d \ - --hash=sha256:83b9130b80b216d56fdf9e87131946b353c9627930c061955a101ea82b09fed9 \ - --hash=sha256:83d879362ddd0fedd3f2ab8ce7cce3da2049a6d51d16da8af73011c6edf4752f \ - --hash=sha256:848182a391fffdc25605443e832f5b443f25498edeccf9a64343fd84421ca04b \ - --hash=sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc \ - --hash=sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56 \ - --hash=sha256:89b11a5cdd441aa463f6d34ca0241602bc09b001a76994b6059828494108c673 \ - --hash=sha256:89df64c10adfe340fb00330042537cdd6bf0d8d78bad73f29cfe5427eed7b084 \ - --hash=sha256:8ea8a141eeced4f6262ab6dd71c681ac546a558c30bb586abe087d814b5f85ea \ - --hash=sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20 \ - --hash=sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d \ - --hash=sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08 \ - --hash=sha256:942bc86e9be6fdd6e1175048f5fe8f8fdaaf2309dd1323ef1e155a69cd346780 \ - --hash=sha256:947a585bcaa235702b7c59433b485489397f9a163b3f56058b9463a46fd9b74c \ - --hash=sha256:950ac754d16daea42038f38e7465eb84cda4d08d7343c1c915771b29470f065a \ - --hash=sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5 \ - --hash=sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437 \ - --hash=sha256:9b2ce44bf8f4a1d01f418b3110ff8dff32fd3f3e836c0e06333c3725f243fa6c \ - --hash=sha256:9d45eee3a95a8b61e5b568580caac91f1502ddb731aaf8f4aa448a98660b2fb4 \ - --hash=sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba \ - --hash=sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396 \ - --hash=sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0 \ - --hash=sha256:9f23083e1bd9d901f844af7a126727c486e7eada9a1a6791c8f7e73f94fac656 \ - --hash=sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907 \ - --hash=sha256:a5cd96f6dcdf4fa657b2d95668d71d58455248f98712ecffaa9c528edf40ccae \ - --hash=sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e \ - --hash=sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a \ - --hash=sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda \ - --hash=sha256:a98b2f95cab589e0f5e92c48431afb4d56238b8bf6668edcc66166180e9b509b \ - --hash=sha256:ad52a0e4bcc0ba956a953a169d1feec2734a64981d689e4fc8f490f7bf91af60 \ - --hash=sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711 \ - --hash=sha256:afe6380a0e9653a87aa1e6e88fb47718113e5563c7a1cb2bcc23c1d8e17e3961 \ - --hash=sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17 \ - --hash=sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf \ - --hash=sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46 \ - --hash=sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2 \ - --hash=sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6 \ - --hash=sha256:b3e1107fe5ca030f946dfa59fdbb66b5df121c8432f14b0bdd282d17b297f4eb \ - --hash=sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a \ - --hash=sha256:b6fa3116e40e14e7782fb1a9f872f94b5997de21127c95545ce40196ac1351c5 \ - --hash=sha256:bb70573d2995d23932e2871120f78d798ebc3572e54c09e694a18ced95c5f8d9 \ - --hash=sha256:bbcdf9c92d21c65bc75426eecea724c8fa0d35a6e201fdf1630011d4cc3aa685 \ - --hash=sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315 \ - --hash=sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e \ - --hash=sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc \ - --hash=sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497 \ - --hash=sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b \ - --hash=sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e \ - --hash=sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6 \ - --hash=sha256:c85949d02c85adf6d786eb94858e124989a632a4e65739835b2fc5761827fac3 \ - --hash=sha256:c919f38cd3f0b5e8d30b81fd6cac688cf9221560340f0c35cbbb8b2bd77ad6ac \ - --hash=sha256:c959f88160b13b4e730b0d75b459b7929fc0d2225c284c9683ac95d6feeeac6a \ - --hash=sha256:cb3fe820c27593f170770d6c8d791936cf6275d9269405fbb7b30a55363c10c8 \ - --hash=sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0 \ - --hash=sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068 \ - --hash=sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe \ - --hash=sha256:d48acabb1e5cb0071009f80d71d7f01b6ba2c1d4b869b1352bb5df3f11bf7dfd \ - --hash=sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e \ - --hash=sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab \ - --hash=sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838 \ - --hash=sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297 \ - --hash=sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c \ - --hash=sha256:db77278a6eddadbf44ce5aae2fee5ebb4d061f026b1ce2130d058cd4d7a7b670 \ - --hash=sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975 \ - --hash=sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e \ - --hash=sha256:e2a845687219ba3214126f14a8a5861f97c9e065a7d0b8252adb6df13eea86fb \ - --hash=sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0 \ - --hash=sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1 \ - --hash=sha256:e605e0b8abca9457abd5bee737e086ab145a20c25083ef1113013612268872ff \ - --hash=sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc \ - --hash=sha256:e710ad822c493fb80a4fbc1e3d0a807b1422cb90adbe64378f98291b7fa48fef \ - --hash=sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99 \ - --hash=sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489 \ - --hash=sha256:ed8bcdab6692fd4ad0dd6241807a24a640a376764460023b8d462d745e6b7b27 \ - --hash=sha256:f377012b86c0a23a1df0cf5a1b05aa7187649e472f71c7892e5f2c2815bbe74f \ - --hash=sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339 \ - --hash=sha256:f8044cf4c77f37968b8c4cbcbf7a0f355d8a437877ae18eba23e3aad953a6cc7 \ - --hash=sha256:f8ed8940435834141061da26d27c4dd0d18fb69777bf431f5c6cc46b43349113 \ - --hash=sha256:f93e408255ddce525189bf11feaa1be7ee35e55f486c299c97d9caa68d724a5b \ - --hash=sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1 +xxhash==3.8.0 \ + --hash=sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f \ + --hash=sha256:04faeb2290c0596b25f8509dbabe8b821461f04eb9097b74cc8b486d571087fd \ + --hash=sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29 \ + --hash=sha256:06c74e537f45c2f71010738d4d20741186cac29a035ec5c1c621c723d656c2fd \ + --hash=sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f \ + --hash=sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a \ + --hash=sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c \ + --hash=sha256:08c34553cd7ceb3bfcfca344dc70305a45430429b5d58a67750f2a58364f638f \ + --hash=sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54 \ + --hash=sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218 \ + --hash=sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd \ + --hash=sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53 \ + --hash=sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8 \ + --hash=sha256:14ebc1559e8a9a481d0d5506b87678942fcdfa794d4aa55cdd2a0fb175d4245a \ + --hash=sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d \ + --hash=sha256:1a54ad5a2a96cdf1ee7a935d38bc63daa6095530095a916f644f1ab76604ced5 \ + --hash=sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953 \ + --hash=sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675 \ + --hash=sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc \ + --hash=sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732 \ + --hash=sha256:1e9fb591e5df85a787c0303b1d01487ab04820f6607d7ba148491005226fd968 \ + --hash=sha256:1ec9afdd53ac5f4fd1d8918807ba6c35ba62269086af794884b9f168a73331ea \ + --hash=sha256:1f2c243a385e2c2ce72f5b7d68f3a621cc7d2ee2d0f35e0ca6bf5427ef1922a4 \ + --hash=sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3 \ + --hash=sha256:21f6797afdc7abb0ffae059a0d1619c84a5368115bc0abd48f9803ab56a5d35e \ + --hash=sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40 \ + --hash=sha256:281897e5c516769694c999f5c50fd1e9acb27acbff187282a8ac77c38b6a9be5 \ + --hash=sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0 \ + --hash=sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f \ + --hash=sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b \ + --hash=sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602 \ + --hash=sha256:3193f7c98b9d563d1986bf4dec9a584230bbe230b711e399c1547e852cbedfee \ + --hash=sha256:33c6f8adf3cc1745ae17e8c1053e7c61f9640cb3de35721e706dcbf1fecd3f51 \ + --hash=sha256:342d1a6f161741f8612dc38d940ec0019ae3362c0ede2d16554c1b4e3f1d5444 \ + --hash=sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31 \ + --hash=sha256:35c5d843bb7ac1dfdb125ef4181fe4c2e01c2275856e6b699de89e9eb5c69c8d \ + --hash=sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143 \ + --hash=sha256:369ed0efff8216f9f3502eb73694c22e4e8870ad24b454b5d0e7df01a3b29320 \ + --hash=sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37 \ + --hash=sha256:37c9943e18f569f76a8b7d5d01bfe0716f7762c396096ceb42a47eb3d5ecf641 \ + --hash=sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b \ + --hash=sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908 \ + --hash=sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851 \ + --hash=sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e \ + --hash=sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499 \ + --hash=sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302 \ + --hash=sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd \ + --hash=sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c \ + --hash=sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784 \ + --hash=sha256:41a30a1d0ba978238742a374875c15979e0faed0a65294f3ff4d9410057ee8b6 \ + --hash=sha256:43705f917b8b817d6994851bf3725b98b4c95e64186404d9a6dbc1acf12fd140 \ + --hash=sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da \ + --hash=sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef \ + --hash=sha256:48536e880c8c63fb7021c33172e776afb086817a5a4a9cf3ca12c1a4f0e748cb \ + --hash=sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9 \ + --hash=sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f \ + --hash=sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71 \ + --hash=sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42 \ + --hash=sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62 \ + --hash=sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545 \ + --hash=sha256:53e8f6cc0cc24283d98e9c742a0f0a5ded7a810abc4038b9e885e419fcd44e43 \ + --hash=sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b \ + --hash=sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b \ + --hash=sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f \ + --hash=sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624 \ + --hash=sha256:564a85f94635da981a1087314ef57fce6e0d27aef22390b60319b01a99fe7991 \ + --hash=sha256:5685977fa6bcf18aa408492ca3960325f42a23a1df3df5bf3ffc3840b67f61d3 \ + --hash=sha256:56c28a1f244432af91eb89d4170b97ece5e81286009638f50a99dfd098bd8c21 \ + --hash=sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4 \ + --hash=sha256:5875d99d3540367d43779551dd22c813420b84a103e418d791095b9808fdca57 \ + --hash=sha256:591d5eb256abf59438800ace2730ac33f77bc6ab8c3623fab1ea24d9d8b28f3a \ + --hash=sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342 \ + --hash=sha256:5b60d8e3c3340b679851ee268288deef9cdc20d8b1a77c0c0d2d2f3849a16ee1 \ + --hash=sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2 \ + --hash=sha256:5e4208fb85c950ddf7118b040bca15179c3bf9b7eb8bebe5e6ef067fc8af16a7 \ + --hash=sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7 \ + --hash=sha256:5e7a3e3bbe3a56bff70acc9b72576670e793b0184de3d1b9cda2bf697d17f630 \ + --hash=sha256:5f7f9ba111d3b4a8d7c6edfd7e9ab7c67efb98234907520f4c6411a2b2039cb3 \ + --hash=sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468 \ + --hash=sha256:61d99283533b7aeb6cd159590fb1c3df9bdb59f639ff358a6b6e74088838b967 \ + --hash=sha256:654541773668385e162f18f001955bb5cddc9490beee69684061ecb3004bb46b \ + --hash=sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e \ + --hash=sha256:68594a54be2eb5992d9b0d0a0ec7c32a7a8e930f06d6cb951d69708055680994 \ + --hash=sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5 \ + --hash=sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef \ + --hash=sha256:6a667f0dd160ec0ff6dddf42f2d75ad82660074285855f6037d6ecb57d40d0f8 \ + --hash=sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5 \ + --hash=sha256:715c611582004e75010517b919776c5dbc00aae03054dc9fd72484a23fd1862f \ + --hash=sha256:718162a608eb85a22470725f95d63d834b1d7db98a2008b10309cd5a552d91ad \ + --hash=sha256:71b2e99a02fd5275b7ecab0b01130395beed4c6f027b6ce9f0730025634e7091 \ + --hash=sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341 \ + --hash=sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f \ + --hash=sha256:73d04a4520cc7313acf4ff2122f783056d0592c71fc3a59e90fe0baeb499d124 \ + --hash=sha256:75feec84a48cafd3b2446cb41910bebaf9a8150e2313c1f42887435818fb7b4c \ + --hash=sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07 \ + --hash=sha256:7d6dbb976d6e3b3be51bad16b13de7f4980e6aebd0aa51c5a14dfcc0fedd495e \ + --hash=sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a \ + --hash=sha256:7f4eecf800275e62b6bcb41e65f361f2277cc886c2bff4e299959d701e5fcf93 \ + --hash=sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d \ + --hash=sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4 \ + --hash=sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b \ + --hash=sha256:84101596d32201b35339ba788eb39574690a7516fb5b1b9165f09de9fee7ad1e \ + --hash=sha256:842d147983110e5a4f533f98f4f5bc851a08c7ca00aaa30649e8d5f9a6d4e47a \ + --hash=sha256:84b8abc80329921e85cd02debd15a5247ebd8de38f8952bec27f241628ef6714 \ + --hash=sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b \ + --hash=sha256:8a0325359942e9cbc2c04529f462bf637e73e0657a277b7d64d61986d24d539b \ + --hash=sha256:8fba3d08c246201a1a0a6cece53a0b3b0890fc16adbe1edb245fcfcbf4eb0ce2 \ + --hash=sha256:934cd5008d86e201818ca4416a4202039ea29edd89047166fea5c49999677bea \ + --hash=sha256:93b263e609d9be516f3df9b1619617376d0335111f013cfa00d7fc4b2e1d5a3d \ + --hash=sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120 \ + --hash=sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540 \ + --hash=sha256:96fba2ec05a43e5b9b8070f68b42d0a91f7aeefe4dc425775197ec1c5f8c2c63 \ + --hash=sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191 \ + --hash=sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb \ + --hash=sha256:9c71e3755a8320d29c351126d550930349be22b44bac1a559caf12ab78b53e9f \ + --hash=sha256:9d011de14fe14b6c31ab1d4dc400bd5ef187c44446e68da15a5039fae46a8494 \ + --hash=sha256:9f17e09b035f2a0139536da53deb392b62ee259dc2a2189be12b06a7dd50489b \ + --hash=sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63 \ + --hash=sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc \ + --hash=sha256:a2289857ab90ebb2408d4ac2b7cf7e9ff29bba9d2cb21020c9d11fbbaef78eea \ + --hash=sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc \ + --hash=sha256:a484492df0b094a3bba94f6aff7324e30aec95a2a3e9caac6806838cf8d6dd3c \ + --hash=sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e \ + --hash=sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c \ + --hash=sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08 \ + --hash=sha256:aaaf53eb633205f01bb5fb807f6244bd34af121bfb1e21eedc925374aff5723e \ + --hash=sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0 \ + --hash=sha256:ae7152fa6ce8e8304d6865a2b9e6b49cff27dcb8ca28967a4362a71870354403 \ + --hash=sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a \ + --hash=sha256:b25437ffd781d4cb98acef87f4bc32e27682f603ffd27ed5962948b516e777ff \ + --hash=sha256:b32e50dd85f0b67b2b95eb59cd3242052f6b27b70e9e73b27629686c592e3ea3 \ + --hash=sha256:b3a79d694adcfd70d118c73d244eaece7f5f5ab424feb44573bd1d377e1bf0ea \ + --hash=sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea \ + --hash=sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f \ + --hash=sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e \ + --hash=sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af \ + --hash=sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b \ + --hash=sha256:ba02f4cc4e71e1315ecac0468189b49bf3970da05ddf0b6965b4a9b1fe147e62 \ + --hash=sha256:ba14843f20df2dce6ff6684411a56ae53da44336546c55f8947e70aebb8cdd21 \ + --hash=sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772 \ + --hash=sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7 \ + --hash=sha256:be48b4db998927daa182c3b3b18101faa24d6e283ca2192e40b686e3ad0b905b \ + --hash=sha256:bf57b05eaf75b567259ff6f2c9045f26621f0658d18b123eee53b1fa81318f78 \ + --hash=sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59 \ + --hash=sha256:c02c23fe543bacc471b62b4c8af150bb53f72cb2c5878267d589182d36b4669b \ + --hash=sha256:c0e976ef1387cde8c1d0c81450ee6ab5c8c75b637c8fb4a3791769cdd301dddd \ + --hash=sha256:c10f98df550135978530ae87d0ffc4421e1580a353bb0d7c815f05ca06ab15f3 \ + --hash=sha256:c1e0f01748df88c0c470a6f49a00c60fcbe6624ce8fb101285a94b5e0a11e942 \ + --hash=sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251 \ + --hash=sha256:c2dcd8e626d5d130163064a64e7825b978a5c66febb55fe16018d09ce8bd7a0d \ + --hash=sha256:c35c889d9e58cb72fead4f14717faf6528c95c5aa77d469f724dba28242b976f \ + --hash=sha256:c381537f7a918c8b93f236e7df495c6cd0b4eeffa747c74b568caef4e073ce1f \ + --hash=sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6 \ + --hash=sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833 \ + --hash=sha256:c7c24094d990d8bdf78132b22891c6aa116656dfe68f77b1424e7e2ae0d4c0bb \ + --hash=sha256:c9eaed16c1581478ffbf66f31f9d6db86346e8eb36fecb96ef742aea4a05c876 \ + --hash=sha256:cb4996d43a42d825e2aa6f2b6a978b2a7779397b6a28e4fab5eb9505457023e4 \ + --hash=sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f \ + --hash=sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689 \ + --hash=sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12 \ + --hash=sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0 \ + --hash=sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765 \ + --hash=sha256:d211cfa927a107df09359d1f31070883a11121ddc88fd6dd27eda3a497a88f3d \ + --hash=sha256:d5a7fdfde5022f5000c8e6565db954580d19a8aa497ef80875f461e4546ed182 \ + --hash=sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43 \ + --hash=sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba \ + --hash=sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd \ + --hash=sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795 \ + --hash=sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4 \ + --hash=sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0 \ + --hash=sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e \ + --hash=sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301 \ + --hash=sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e \ + --hash=sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d \ + --hash=sha256:ec6666a5311beae3f6cb5f2fd28c2b77e2df32702c8206f45c786a6ef81b3751 \ + --hash=sha256:ecdbbe1c5de579435d3879d53d35d681d626836bdde333577a9491000723bb6a \ + --hash=sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d \ + --hash=sha256:ed377642dbc001d2197b0742f31fe3b72fc4fef2b29ee1916075745a423e48b8 \ + --hash=sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410 \ + --hash=sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635 \ + --hash=sha256:f01e382b0d2ebdff863948e59560c98e7a8c02ba1e04c21f8afcc7df37f07626 \ + --hash=sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6 \ + --hash=sha256:f75a8e7714075f5e16c8be1c08a0716b12e04846aea0403440302f354a6bd49b \ + --hash=sha256:fbcbbfa24a474793da405731d123ae17f4e9a1c38fb4e67acc4dfc5fd0b7022a \ + --hash=sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3 \ + --hash=sha256:fe0ee773fd6c211b3b0134ee5d6fd6348411bd7bd79cdb4151d0aaf732179571 \ + --hash=sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a # via langsmith zstandard==0.25.0 \ --hash=sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64 \ diff --git a/backend/requirements.txt b/backend/requirements.txt index 364ac33e2..952238750 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ -fastapi==0.139.0 +fastapi==0.138.2 starlette==1.3.1 -uvicorn==0.51.0 +uvicorn==0.49.0 pytest==9.1.1 httpx==0.28.1 pydantic-settings==2.14.2 @@ -10,10 +10,10 @@ sqlalchemy==2.0.51 alembic==1.18.5 greenlet==3.5.3 asyncpg==0.31.0 -pgvector==0.5.0 +pgvector==0.4.2 rankweave==0.1.0 # hybrid-retrieval fusion + query normalization (OSMU spin-off) pytest-asyncio==1.4.0 -openai==2.45.0 +openai==2.44.0 langchain-text-splitters==1.1.2 tiktoken==0.13.0 google-api-python-client==2.198.0 @@ -30,10 +30,10 @@ opentelemetry-exporter-otlp==1.43.0 protobuf==7.35.1 setuptools==83.0.0 wheel==0.47.0 -websockets==16.1 +websockets==16.0 PyJWT==2.13.0 # latest verified 2026-06-12; do not downgrade to 2.8.0 icalendar==7.2.0 -ruff==0.15.21 +ruff==0.15.20 defusedxml==0.7.1 # Optional: the Noema general agent (services/noema_agent.py) uses pydantic-ai # (MIT). It is intentionally NOT in the hashed runtime set — install it diff --git a/backend/runner/connector.py b/backend/runner/connector.py index b34e0af14..04859e575 100644 --- a/backend/runner/connector.py +++ b/backend/runner/connector.py @@ -53,7 +53,6 @@ def __init__( smtp_send_handler: RunnerActionHandler | None = None, webdav_write_handler: RunnerActionHandler | None = None, caldav_write_handler: RunnerActionHandler | None = None, - carddav_write_handler: RunnerActionHandler | None = None, ): self.target_ws_url = target_ws_url self.token = token @@ -61,7 +60,6 @@ def __init__( self.smtp_send_handler = smtp_send_handler self.webdav_write_handler = webdav_write_handler self.caldav_write_handler = caldav_write_handler - self.carddav_write_handler = carddav_write_handler self.connection = None self.is_connected = False @@ -141,8 +139,6 @@ async def handle_message(self, message: str | bytes): await self._handle_write_webdav(payload) elif action == "write_caldav": await self._handle_write_caldav(payload) - elif action == "write_carddav": - await self._handle_write_carddav(payload) else: logger.info("Unknown action received.") await self.send_response({ @@ -219,23 +215,6 @@ async def _handle_write_caldav(self, payload: Dict[str, Any]): handler=self.caldav_write_handler, ) - async def _handle_write_carddav(self, payload: Dict[str, Any]): - if _get_account_name(payload) is None: - logger.error("CardDAV write instruction is missing account.") - await self.send_response({ - "status": "error", - "action": "write_carddav", - "error": "missing account", - }) - return - logger.info("Dispatching local CardDAV write adapter for configured account.") - await self._execute_local_adapter( - action="write_carddav", - protocol="CardDAV", - payload=payload, - handler=self.carddav_write_handler, - ) - async def _execute_local_adapter( self, *, diff --git a/backend/runner/local_dav_adapters.py b/backend/runner/local_dav_adapters.py index d7d1e7c6c..3afc1c4cd 100644 --- a/backend/runner/local_dav_adapters.py +++ b/backend/runner/local_dav_adapters.py @@ -16,7 +16,7 @@ @dataclass(frozen=True) class LocalDavSourceConfig: source_id: str - protocol: Literal["caldav", "webdav", "carddav"] + protocol: Literal["caldav", "webdav"] base_url: str username: str | None = None password: str | None = None @@ -51,13 +51,6 @@ async def write_caldav(self, payload: dict[str, Any]) -> dict[str, Any]: default_content_type="text/calendar; charset=utf-8", ) - async def write_carddav(self, payload: dict[str, Any]) -> dict[str, Any]: - return await self._put( - payload, - protocol="carddav", - default_content_type="text/vcard; charset=utf-8", - ) - def _default_http_client(self): return httpx.AsyncClient(follow_redirects=False, timeout=60) @@ -65,7 +58,7 @@ async def _put( self, payload: dict[str, Any], *, - protocol: Literal["caldav", "webdav", "carddav"], + protocol: Literal["caldav", "webdav"], default_content_type: str, ) -> dict[str, Any]: source = self._source_for_payload(payload, protocol) @@ -117,7 +110,7 @@ async def _put( def _source_for_payload( self, payload: dict[str, Any], - protocol: Literal["caldav", "webdav", "carddav"], + protocol: Literal["caldav", "webdav"], ) -> LocalDavSourceConfig | None: source_id = self._payload_text(payload, "source_id") if source_id is None: diff --git a/backend/schema/connector.py b/backend/schema/connector.py index 818158cd6..0bb361890 100644 --- a/backend/schema/connector.py +++ b/backend/schema/connector.py @@ -7,9 +7,7 @@ class SelfHostedConnectorRegistrationRequest(BaseModel): connector_id: str = Field(..., description="Unique identifier for the self-hosted connector") public_key: str = Field(..., description="Public key for mTLS or secure payload exchange") - supported_protocols: list[ - Literal["imap", "smtp", "pop3", "caldav", "carddav", "webdav"] - ] = Field( + supported_protocols: list[Literal["imap", "smtp", "pop3", "caldav", "webdav"]] = Field( default_factory=list, description="Protocols supported by this connector instance" ) capabilities: list[str] = Field(default_factory=list) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 868b9b183..77e4b0a59 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -1,7 +1,3 @@ -"""Classify email attachments and retain safe deferred parser inputs.""" - -import base64 -import binascii from dataclasses import dataclass from pathlib import Path from typing import Any @@ -15,13 +11,10 @@ "application/x-binary", } MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000 -MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024 @dataclass(frozen=True) class AttachmentParserDescriptor: - """Describe one supported attachment parser surface.""" - parser_key: str display_name: str content_types: tuple[str, ...] @@ -79,13 +72,6 @@ class AttachmentParserDescriptor: extensions=(".ics", ".ifb"), parse_status="parsed", ), - AttachmentParserDescriptor( - parser_key="pdf", - display_name="PDF documents (NewsDOM recognition)", - content_types=("application/pdf",), - extensions=(".pdf",), - parse_status="pdf_dom_recognition_pending", - ), AttachmentParserDescriptor( parser_key="unsupported_binary", display_name="Unsupported binary attachments", @@ -94,35 +80,22 @@ class AttachmentParserDescriptor: parse_status="unsupported_content_type", ), ) -# Statuses whose recognition is too heavy to run inline during import. The -# attachment is stored with the pending status and a background worker later -# calls the NewsDOM sidecar to fill in parse_content + the content graph. -_DEFERRED_PARSE_STATUSES = frozenset({"pdf_dom_recognition_pending"}) _SUPPORTED_CONTENT_TYPES = { content_type for descriptor in _PARSER_MANIFEST if descriptor.parse_status == "parsed" for content_type in descriptor.content_types } -_DEFERRED_DESCRIPTORS_BY_CONTENT_TYPE = { - content_type: descriptor - for descriptor in _PARSER_MANIFEST - if descriptor.parse_status in _DEFERRED_PARSE_STATUSES - for content_type in descriptor.content_types -} _EXTENSION_CONTENT_TYPES = { extension: descriptor.content_types[0] for descriptor in _PARSER_MANIFEST if descriptor.parse_status == "parsed" - or descriptor.parse_status in _DEFERRED_PARSE_STATUSES for extension in descriptor.extensions } @dataclass(frozen=True) class AttachmentParseResult: - """Carry display, parser, and deferred-recognition attachment fields.""" - filename: str content: str content_type: str @@ -134,7 +107,6 @@ class AttachmentParseResult: def get_attachment_parser_manifest() -> list[AttachmentParserDescriptor]: - """Return a mutable snapshot of the attachment parser manifest.""" return list(_PARSER_MANIFEST) @@ -144,7 +116,6 @@ def parse_email_attachment( content_type: str | None, raw_content: Any, ) -> AttachmentParseResult: - """Classify and normalize one attachment without running heavy parsers.""" safe_filename = _safe_filename(filename) normalized_content_type = _normalize_content_type(content_type) parse_content_type = _parse_content_type_for( @@ -152,51 +123,6 @@ def parse_email_attachment( normalized_content_type, ) - deferred_descriptor = _DEFERRED_DESCRIPTORS_BY_CONTENT_TYPE.get(parse_content_type) - if deferred_descriptor is not None: - # Heavy recognition (OCR/MinerU via the NewsDOM sidecar) must not run - # inline during import. Retain the raw bytes as a base64 payload in - # ``content`` (mirroring the document-upload path's document_content) so - # the worker can decode and recognize them later; mark the attachment - # pending. The pending status gates display, and the worker overwrites - # ``content`` with the recognized text on success. Without this the - # source bytes were discarded and recognition was impossible. - deferred_payload = _coerce_deferred_payload_bytes(raw_content) - if len(deferred_payload) > MAX_ATTACHMENT_PARSE_SOURCE_BYTES: - return AttachmentParseResult( - filename=safe_filename, - content="", - content_type=normalized_content_type, - parse_content="", - parse_content_type=parse_content_type, - parser_key=deferred_descriptor.parser_key, - parse_status="parse_size_limit_exceeded", - parse_error_code="parse_size_limit_exceeded", - ) - if parse_content_type == "application/pdf" and not deferred_payload.startswith( - b"%PDF-" - ): - return AttachmentParseResult( - filename=safe_filename, - content="", - content_type=normalized_content_type, - parse_content="", - parse_content_type=parse_content_type, - parser_key=deferred_descriptor.parser_key, - parse_status="invalid_pdf_payload", - parse_error_code="invalid_pdf_payload", - ) - return AttachmentParseResult( - filename=safe_filename, - content=_encode_deferred_payload(deferred_payload), - content_type=normalized_content_type, - parse_content="", - parse_content_type=parse_content_type, - parser_key=deferred_descriptor.parser_key, - parse_status=deferred_descriptor.parse_status, - parse_error_code=None, - ) - if parse_content_type not in _SUPPORTED_CONTENT_TYPES: parser_key = _parser_key_for( parse_content_type, @@ -240,14 +166,12 @@ def parse_email_attachment( def _normalize_content_type(content_type: str | None) -> str: - """Return a lowercase MIME type without parameters.""" normalized = (content_type or "application/octet-stream").split(";", 1)[0] normalized = normalized.strip().lower() return normalized or "application/octet-stream" def _parse_content_type_for(filename: str, content_type: str) -> str: - """Resolve generic MIME types from a recognized filename extension.""" if content_type not in _GENERIC_CONTENT_TYPES: return content_type extension = Path(filename).suffix.lower() @@ -255,7 +179,6 @@ def _parse_content_type_for(filename: str, content_type: str) -> str: def _parser_key_for(parse_content_type: str, parse_status: str) -> str: - """Return the parser key associated with a parse MIME type and status.""" if parse_status == "unsupported_content_type": return "unsupported_binary" for descriptor in _PARSER_MANIFEST: @@ -265,7 +188,6 @@ def _parser_key_for(parse_content_type: str, parse_status: str) -> str: def _safe_filename(filename: str | None) -> str: - """Return a basename-only attachment display filename.""" display_filename = strip_html_markup(_sanitize_nul(filename or "attachment")) display_filename = Path(display_filename).name.strip() if display_filename in {"", ".", ".."}: @@ -273,41 +195,7 @@ def _safe_filename(filename: str | None) -> str: return display_filename -def _coerce_deferred_payload_bytes(raw_content: Any) -> bytes: - """Return the exact byte payload retained for deferred recognition.""" - if isinstance(raw_content, bytes): - return raw_content - if isinstance(raw_content, str): - return raw_content.encode("utf-8", errors="surrogatepass") - if raw_content is None: - return b"" - return str(raw_content).encode("utf-8", errors="surrogatepass") - - -def _encode_deferred_payload(payload: bytes) -> str: - """Base64-encode validated bytes retained for deferred recognition.""" - return base64.b64encode(payload).decode("ascii") - - -def decode_deferred_attachment_payload(content: str | None) -> bytes: - """Decode the base64 payload retained on a pending attachment's content. - - Raises ``ValueError`` when the stored payload is not valid base64, so the - recognition worker can record an error status instead of crashing. - """ - try: - payload = base64.b64decode((content or "").encode("ascii"), validate=True) - except (binascii.Error, UnicodeEncodeError, ValueError) as exc: - raise ValueError("Pending attachment payload is not valid base64") from exc - if len(payload) > MAX_ATTACHMENT_PARSE_SOURCE_BYTES: - raise ValueError("Pending attachment PDF exceeds the parse size limit") - if not payload.startswith(b"%PDF-"): - raise ValueError("Pending attachment payload is not a PDF") - return payload - - def _coerce_text(raw_content: Any) -> str: - """Coerce arbitrary attachment content to NUL-free text.""" if raw_content is None: return "" if isinstance(raw_content, str): @@ -318,10 +206,8 @@ def _coerce_text(raw_content: Any) -> str: def _display_text(raw_content: str) -> str: - """Strip markup and collapse whitespace for safe attachment display.""" return " ".join(strip_html_markup(raw_content).split()) def _sanitize_nul(text: str) -> str: - """Remove NUL characters that database text fields cannot retain.""" return text.replace("\x00", "") diff --git a/backend/services/calendar_service.py b/backend/services/calendar_service.py index 901a657b8..af046405e 100644 --- a/backend/services/calendar_service.py +++ b/backend/services/calendar_service.py @@ -2,10 +2,10 @@ import datetime import unicodedata -from .exceptions import CalendarServiceError, UnsafeCalendarActionItemError +from .exceptions import CalendarServiceError, UnsafeCalendarTodoError -MAX_CALENDAR_ACTION_ITEM_LENGTH = 500 -UNSAFE_CALENDAR_ACTION_ITEM_SEQUENCES = ("<", ">", "`", "$(", "${") +MAX_CALENDAR_TODO_LENGTH = 500 +UNSAFE_CALENDAR_TODO_SEQUENCES = ("<", ">", "`", "$(", "${") GOOGLE_OAUTH_ENDPOINT_URL = "https://oauth2.googleapis.com/token" GOOGLE_OAUTH_ALLOWED_KEYS = { "account", @@ -41,17 +41,15 @@ def _google_credentials(validated_user_token: dict): return Credentials(**validated_user_token) -def validate_calendar_action_item_text(action_item_text: str) -> str: +def validate_calendar_todo_text(todo_text: str) -> str: """Validate user-authored calendar text before external writeback.""" - normalized = action_item_text.strip() - if not normalized or len(normalized) > MAX_CALENDAR_ACTION_ITEM_LENGTH: - raise UnsafeCalendarActionItemError("Unsafe calendar action item text") + normalized = todo_text.strip() + if not normalized or len(normalized) > MAX_CALENDAR_TODO_LENGTH: + raise UnsafeCalendarTodoError("Unsafe calendar todo text") if any(unicodedata.category(character) == "Cc" for character in normalized): - raise UnsafeCalendarActionItemError("Unsafe calendar action item text") - if any( - sequence in normalized for sequence in UNSAFE_CALENDAR_ACTION_ITEM_SEQUENCES - ): - raise UnsafeCalendarActionItemError("Unsafe calendar action item text") + raise UnsafeCalendarTodoError("Unsafe calendar todo text") + if any(sequence in normalized for sequence in UNSAFE_CALENDAR_TODO_SEQUENCES): + raise UnsafeCalendarTodoError("Unsafe calendar todo text") return normalized @@ -85,24 +83,24 @@ def validate_google_user_token(user_token: dict) -> dict: return dict(user_token) -async def create_calendar_event(action_item_text: str, user_token: dict) -> dict: - """Creates a calendar event for a given action item text.""" +async def create_calendar_event(todo_text: str, user_token: dict) -> dict: + """Creates a calendar event for a given TODO text.""" try: - safe_action_item_text = validate_calendar_action_item_text(action_item_text) + safe_todo_text = validate_calendar_todo_text(todo_text) validated_user_token = validate_google_user_token(user_token) creds = _google_credentials(validated_user_token) service = build("calendar", "v3", credentials=creds) now = datetime.datetime.now(datetime.timezone.utc) event = { - "summary": safe_action_item_text, + "summary": safe_todo_text, "start": {"dateTime": now.isoformat()}, "end": {"dateTime": (now + datetime.timedelta(hours=1)).isoformat()}, } request = service.events().insert(calendarId="primary", body=event) created_event = await asyncio.to_thread(request.execute) return created_event - except UnsafeCalendarActionItemError: + except UnsafeCalendarTodoError: raise except CalendarServiceError: raise @@ -112,11 +110,11 @@ async def create_calendar_event(action_item_text: str, user_token: dict) -> dict async def _execute_calendar_event_batch( service, - safe_action_item_texts: list[str], + safe_todo_texts: list[str], now: datetime.datetime, ) -> list[dict]: - results: list[dict | None] = [None] * len(safe_action_item_texts) - exceptions: list[Exception | None] = [None] * len(safe_action_item_texts) + results: list[dict | None] = [None] * len(safe_todo_texts) + exceptions: list[Exception | None] = [None] * len(safe_todo_texts) def callback(request_id, response, exception): idx = int(request_id) @@ -126,9 +124,9 @@ def callback(request_id, response, exception): results[idx] = response batch = service.new_batch_http_request(callback=callback) - for idx, safe_action_item_text in enumerate(safe_action_item_texts): + for idx, safe_todo_text in enumerate(safe_todo_texts): event = { - "summary": safe_action_item_text, + "summary": safe_todo_text, "start": {"dateTime": now.isoformat()}, "end": {"dateTime": (now + datetime.timedelta(hours=1)).isoformat()}, } @@ -150,28 +148,23 @@ def callback(request_id, response, exception): async def create_calendar_events_batch( - action_item_texts: list[str], user_token: dict + todo_texts: list[str], user_token: dict ) -> list[dict]: - """Creates calendar events for action item text in bounded Google batch requests.""" - if not action_item_texts: + """Creates calendar events for TODO text in bounded Google batch requests.""" + if not todo_texts: return [] try: - safe_action_item_texts = [ - validate_calendar_action_item_text(action_item_text) - for action_item_text in action_item_texts + safe_todo_texts = [ + validate_calendar_todo_text(todo_text) for todo_text in todo_texts ] validated_user_token = validate_google_user_token(user_token) creds = _google_credentials(validated_user_token) now = datetime.datetime.now(datetime.timezone.utc) tasks = [] - for start in range( - 0, len(safe_action_item_texts), GOOGLE_CALENDAR_BATCH_MAX_REQUESTS - ): - chunk = safe_action_item_texts[ - start : start + GOOGLE_CALENDAR_BATCH_MAX_REQUESTS - ] + for start in range(0, len(safe_todo_texts), GOOGLE_CALENDAR_BATCH_MAX_REQUESTS): + chunk = safe_todo_texts[start : start + GOOGLE_CALENDAR_BATCH_MAX_REQUESTS] chunk_service = build("calendar", "v3", credentials=creds) tasks.append(_execute_calendar_event_batch(chunk_service, chunk, now)) @@ -179,12 +172,10 @@ async def create_calendar_events_batch( created_events: list[dict] = [] for result in results: if isinstance(result, Exception): - raise CalendarServiceError( - "Failed to create events in batch" - ) from result + raise CalendarServiceError("Failed to create events in batch") from result created_events.extend(result) return created_events - except UnsafeCalendarActionItemError: + except UnsafeCalendarTodoError: raise except CalendarServiceError: raise diff --git a/backend/services/calendar_sync.py b/backend/services/calendar_sync.py index 7da9ba500..dd2e5ae68 100644 --- a/backend/services/calendar_sync.py +++ b/backend/services/calendar_sync.py @@ -28,18 +28,18 @@ def generate_ics_from_task(task: CalendarTask) -> str: ics_status = "NEEDS-ACTION" cal = Calendar() - cal.add("VERSION", "2.0") - cal.add("PRODID", "-//Naruon//AI Workspace//EN") + cal.add('VERSION', '2.0') + cal.add('PRODID', '-//Naruon//AI Workspace//EN') - action_item = Todo() - action_item.add("UID", task.task_uid) - action_item.add("DTSTAMP", task.updated_at) - action_item.add("SUMMARY", task.title) - action_item.add("STATUS", ics_status) + todo = Todo() + todo.add('UID', task.task_uid) + todo.add('DTSTAMP', task.updated_at) + todo.add('SUMMARY', task.title) + todo.add('STATUS', ics_status) if task.due_date: - action_item.add("DUE", task.due_date) + todo.add('DUE', task.due_date) - cal.add_component(action_item) + cal.add_component(todo) return cal.to_ical().decode("utf-8") diff --git a/backend/services/carddav_client.py b/backend/services/carddav_client.py deleted file mode 100644 index 0e65a86ba..000000000 --- a/backend/services/carddav_client.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Minimal httpx-based CardDAV client. - -Mirrors the CalDAV/WebDAV runner pattern: it is intentionally small -- enough -to connect, PROPFIND for address books, and PUT a vCard for smoke testing. -SSL/TLS is implicit (https over 443 unless the URL carries an explicit port), -and every request target is SSRF-guarded against private/link-local hosts, the -same way ``LocalDavAdapters`` guards its writes. - -Two properties bind the guard to the actual connection instead of a hostname -snapshot: - -- Requests are DNS-pinned: the hostname is resolved once, every address is - validated as globally routable, and the request connects to that validated - address (with SNI/Host preserved for TLS verification), so a rebinding - between validation and connect cannot redirect credentials. -- Clients are built with ``trust_env=False`` so ambient ``HTTPS_PROXY`` / - ``ALL_PROXY`` configuration on the runner cannot reroute requests. -""" - -from __future__ import annotations - -import ipaddress -import socket -from dataclasses import dataclass -from typing import Any, Callable -from urllib.parse import quote, unquote, urlsplit, urlunsplit - -import httpx - -_ADDRESSBOOK_PROPFIND = ( - '' - '' - "" - "" -) - -_MAX_VCARD_PATH_LENGTH = 4096 -_MAX_URL_DECODE_ROUNDS = 4 - - -@dataclass(frozen=True) -class CarddavProbeResult: - reachable: bool - status_code: int | None - base_url: str - - -def _validate_global_address(address: str) -> None: - try: - ip_address = ipaddress.ip_address(address) - except ValueError as exc: - raise ValueError("invalid_carddav_url") from exc - if not ip_address.is_global: - raise ValueError("invalid_carddav_url") - - -def _resolved_global_addresses(hostname: str, port: int) -> list[str]: - """Resolve a hostname once and require every address to be global.""" - if hostname == "localhost" or hostname.endswith(".localhost"): - raise ValueError("invalid_carddav_url") - try: - ipaddress.ip_address(hostname) - except ValueError: - pass - else: - _validate_global_address(hostname) - return [hostname] - try: - address_infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) - except OSError as exc: - raise ValueError("invalid_carddav_url") from exc - addresses = [str(info[4][0]) for info in address_infos] - if not addresses: - raise ValueError("invalid_carddav_url") - for address in dict.fromkeys(addresses): - _validate_global_address(address) - return addresses - - -def _validate_global_host(hostname: str, port: int) -> None: - _resolved_global_addresses(hostname, port) - - -def _validated_base_url(base_url: str) -> str: - parsed = urlsplit(base_url) - if ( - parsed.scheme != "https" - or not parsed.netloc - or not parsed.hostname - or parsed.username - or parsed.password - or parsed.query - or parsed.fragment - ): - raise ValueError("invalid_carddav_url") - try: - port = parsed.port or 443 - except ValueError as exc: - raise ValueError("invalid_carddav_url") from exc - _validate_global_host(parsed.hostname, port) - path = parsed.path or "/" - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) - - -def pinned_request_target(url: str) -> tuple[str, dict[str, str], dict[str, Any]]: - """Pin a validated URL to one resolved global address (anti-rebinding). - - Returns ``(pinned_url, headers, extensions)``: the URL rewritten to a - validated literal address, a ``Host`` header carrying the original - authority, and the ``sni_hostname`` extension so TLS still negotiates and - verifies against the original hostname. Using the same resolution for - validation and connection closes the validate-then-reconnect DNS window. - """ - parsed = urlsplit(url) - hostname = parsed.hostname or "" - try: - port = parsed.port - except ValueError as exc: - raise ValueError("invalid_carddav_url") from exc - addresses = _resolved_global_addresses(hostname, port or 443) - address = next( - (candidate for candidate in addresses if ":" not in candidate), - addresses[0], - ) - literal = f"[{address}]" if ":" in address else address - netloc = literal if port is None else f"{literal}:{port}" - pinned = urlunsplit( - (parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment) - ) - return pinned, {"Host": parsed.netloc}, {"sni_hostname": hostname} - - -def _safe_vcard_path(raw_path: Any) -> str | None: - """Canonicalize a caller-supplied vCard path or reject traversal/URL input. - - Mirrors ``LocalDavAdapters._safe_target_path``: multi-round decode, then - reject absolute URLs, backslashes, query/fragment, control characters, and - dot segments, so the composed target can never leave the configured - address-book subtree or change host. - """ - if ( - not isinstance(raw_path, str) - or not raw_path - or len(raw_path) > _MAX_VCARD_PATH_LENGTH - ): - return None - - decoded_path = raw_path - try: - for _ in range(_MAX_URL_DECODE_ROUNDS): - next_path = unquote(decoded_path, errors="strict") - if next_path == decoded_path: - break - decoded_path = next_path - else: - if unquote(decoded_path, errors="strict") != decoded_path: - return None - except UnicodeDecodeError: - return None - - if ( - "\\" in decoded_path - or "://" in decoded_path - or "?" in decoded_path - or "#" in decoded_path - or decoded_path.startswith("//") - or any( - ord(character) < 32 or ord(character) == 127 - for character in decoded_path - ) - ): - return None - segments = [segment for segment in decoded_path.split("/") if segment] - if not segments or any(segment in {".", ".."} for segment in segments): - return None - return "/".join( - quote(segment, safe="@:$&'()*+,;=-._~") for segment in segments - ) - - -def _default_http_client() -> httpx.AsyncClient: - # trust_env=False keeps runner proxy environment variables from rerouting - # credentialed CardDAV requests. - return httpx.AsyncClient(follow_redirects=False, timeout=30, trust_env=False) - - -class CardDavClient: - """Small CardDAV client for connectivity checks and vCard writes.""" - - def __init__( - self, - base_url: str, - *, - username: str | None = None, - password: str | None = None, - http_client_factory: Callable[[], Any] | None = None, - ): - self._base_url = _validated_base_url(base_url) - self._username = username - self._password = password - self._http_client_factory = http_client_factory or _default_http_client - - @property - def base_url(self) -> str: - return self._base_url - - def _auth(self): - if self._username is None: - return None - return (self._username, self._password or "") - - async def list_address_books(self) -> CarddavProbeResult: - """PROPFIND Depth:1 for address books; returns a reachability result.""" - headers = {"Depth": "1", "Content-Type": "application/xml; charset=utf-8"} - try: - target, pinned_headers, extensions = pinned_request_target(self._base_url) - async with self._http_client_factory() as client: - response = await client.request( - "PROPFIND", - target, - headers={**headers, **pinned_headers}, - content=_ADDRESSBOOK_PROPFIND.encode("utf-8"), - auth=self._auth(), - extensions=extensions, - ) - except (httpx.HTTPError, ValueError): - return CarddavProbeResult( - reachable=False, status_code=None, base_url=self._base_url - ) - status = int(response.status_code) - # 207 Multi-Status is the success case; 200/401/403 still prove the - # endpoint is a live CardDAV/HTTP server (reachable), which is all a - # smoke check asserts. - reachable = status in {200, 207, 401, 403} - return CarddavProbeResult( - reachable=reachable, status_code=status, base_url=self._base_url - ) - - async def put_vcard( - self, - relative_path: str, - vcard: str | bytes, - *, - if_match: str | None = None, - ) -> int: - """PUT a vCard beneath the base URL; returns the provider status code.""" - safe_path = _safe_vcard_path(relative_path) - if safe_path is None: - raise ValueError("invalid_carddav_path") - composed = self._base_url.rstrip("/") + "/" + safe_path - # Re-validate the composed URL to keep the SSRF guard on joins. - _validated_base_url(composed) - target, pinned_headers, extensions = pinned_request_target(composed) - content = vcard.encode("utf-8") if isinstance(vcard, str) else vcard - headers = {"Content-Type": "text/vcard; charset=utf-8", **pinned_headers} - if if_match is not None: - headers["If-Match"] = if_match - async with self._http_client_factory() as client: - response = await client.put( - target, - content=content, - headers=headers, - auth=self._auth(), - extensions=extensions, - ) - return int(response.status_code) - - -__all__ = ["CardDavClient", "CarddavProbeResult", "pinned_request_target"] diff --git a/backend/services/carddav_discovery.py b/backend/services/carddav_discovery.py deleted file mode 100644 index 70c742685..000000000 --- a/backend/services/carddav_discovery.py +++ /dev/null @@ -1,331 +0,0 @@ -"""CardDAV base-URL auto-discovery (RFC 6764). - -Resolution order for an email address or bare domain: - -1. ``https:///.well-known/carddav`` -- follow the well-known redirect - to the context path advertised by the provider. -2. DNS SRV ``_carddavs._tcp.`` (TLS), combined with the RFC 6764 - Section 6 TXT ``path`` hint when present. Plain ``_carddav._tcp`` records - advertise a non-TLS service; contacting one would send credentials in the - clear, so they are detected and refused rather than silently upgraded. - -Every candidate host is SSRF-guarded exactly like the existing DAV code: the -scheme must be https, userinfo/query/fragment are rejected, and the host must -resolve to globally-routable addresses (no localhost / private / link-local). -The well-known probe is DNS-pinned via ``pinned_request_target`` so validation -and connection use the same resolution. - -The helper is fully injectable for tests: pass ``http_client_factory`` (an -httpx-style async client), ``srv_resolver`` (returns SRV records ordered per -RFC 2782 priority/weight), and ``txt_resolver`` (returns TXT record strings) -to avoid real network or DNS. DNS support is best-effort -- if no resolver is -supplied and ``dnspython`` is not installed, only the well-known probe runs. -""" - -from __future__ import annotations - -import ipaddress -import logging -import socket -from dataclasses import dataclass -from typing import Any, Awaitable, Callable -from urllib.parse import urljoin, urlsplit, urlunsplit - -import httpx - -logger = logging.getLogger(__name__) - -# Injected SRV resolver contract: given a DNS name it returns an iterable of -# (target_host, port) tuples ordered by preference, or an empty iterable. -SrvResolver = Callable[[str], list[tuple[str, int]]] -# Injected TXT resolver contract: given a DNS name it returns the TXT record -# character strings (one joined string per record), or an empty iterable. -TxtResolver = Callable[[str], list[str]] -HttpClientFactory = Callable[[], Any] - - -@dataclass(frozen=True) -class CarddavDiscoveryResult: - base_url: str - discovery_source: str # "well_known" | "srv_secure" | "provided" - - -def _extract_domain(email_or_domain: str) -> str | None: - if not isinstance(email_or_domain, str): - return None - value = email_or_domain.strip().lower().rstrip(".") - if not value: - return None - if "@" in value: - value = value.rsplit("@", 1)[-1] - # Strip any accidental scheme/path. - value = value.split("/", 1)[0] - if not value or "." not in value: - return None - # Reject anything that is not a plausible hostname. - if any(ch in value for ch in (" ", ":", "\\")): - return None - return value - - -def _is_global_host(hostname: str) -> bool: - """Return True only when the host resolves entirely to global addresses.""" - if not hostname or hostname == "localhost" or hostname.endswith(".localhost"): - return False - try: - ipaddress.ip_address(hostname) - except ValueError: - pass - else: - return _is_global_address(hostname) - - try: - address_infos = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM) - except OSError: - return False - addresses = {str(info[4][0]) for info in address_infos} - if not addresses: - return False - return all(_is_global_address(addr) for addr in addresses) - - -def _is_global_address(address: str) -> bool: - try: - return ipaddress.ip_address(address).is_global - except ValueError: - return False - - -def _safe_https_base_url(candidate: str) -> str | None: - """Validate an https candidate URL and return a normalized base URL.""" - parsed = urlsplit(candidate) - if ( - parsed.scheme != "https" - or not parsed.netloc - or not parsed.hostname - or parsed.username - or parsed.password - or parsed.query - or parsed.fragment - ): - return None - try: - port = parsed.port or 443 - except ValueError: - return None - if not (1 <= port <= 65535): - return None - if not _is_global_host(parsed.hostname): - return None - path = parsed.path or "/" - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) - - -def _default_http_client() -> httpx.AsyncClient: - # follow_redirects is False so we inspect the Location ourselves and can - # re-validate every hop against the SSRF guard; trust_env=False keeps - # runner proxy environment variables from rerouting the probe. - return httpx.AsyncClient(follow_redirects=False, timeout=15, trust_env=False) - - -async def _probe_well_known( - domain: str, - http_client_factory: HttpClientFactory, -) -> str | None: - origin = f"https://{domain}" - well_known = f"{origin}/.well-known/carddav" - if _safe_https_base_url(origin) is None: - return None - - try: - async with http_client_factory() as client: - response = await _request_well_known(client, well_known) - except (httpx.HTTPError, ValueError): - return None - if response is None: - return None - - status = int(response.status_code) - location = response.headers.get("Location") or response.headers.get("location") - if status in {301, 302, 303, 307, 308} and location: - resolved = urljoin(well_known, location.strip()) - return _safe_https_base_url(resolved) - # A 200/207 at the well-known path itself means it *is* the context path. - if status in {200, 207}: - return _safe_https_base_url(well_known) - return None - - -async def _request_well_known(client: Any, url: str): - """Issue a PROPFIND (falling back to GET) against the well-known URL.""" - request: Callable[..., Awaitable[Any]] | None = getattr(client, "request", None) - if callable(request): - return await client.request("PROPFIND", url, headers={"Depth": "0"}) - # Minimal fakes may only implement get(). - get = getattr(client, "get", None) - if callable(get): - return await client.get(url) - return None - - -def _default_srv_resolver(name: str) -> list[tuple[str, int]]: - """Best-effort SRV resolution via dnspython when available. - - Records are ordered by RFC 2782 preference: ascending priority, then - descending weight (a deterministic stand-in for weighted selection). - """ - try: - import dns.resolver # type: ignore - except Exception: - return [] - try: - answers = dns.resolver.resolve(name, "SRV") - except Exception: - return [] - records = sorted( - answers, - key=lambda r: (int(r.priority), -int(r.weight)), - ) - results: list[tuple[str, int]] = [] - for record in records: - target = str(record.target).rstrip(".") - if target and target != ".": - results.append((target, int(record.port))) - return results - - -def _default_txt_resolver(name: str) -> list[str]: - """Best-effort TXT resolution via dnspython when available.""" - try: - import dns.resolver # type: ignore - except Exception: - return [] - try: - answers = dns.resolver.resolve(name, "TXT") - except Exception: - return [] - records: list[str] = [] - for record in answers: - strings = getattr(record, "strings", None) - if strings: - records.append( - b"".join(bytes(part) for part in strings).decode("utf-8", "replace") - ) - else: - records.append(str(record).strip('"')) - return records - - -def _txt_context_path(records: list[str]) -> str | None: - """Extract and validate the RFC 6764 Section 6 TXT ``path`` hint.""" - for record in records: - for part in record.split(";"): - key, _, value = part.strip().partition("=") - if key.strip().lower() != "path": - continue - path = value.strip() - if ( - path.startswith("/") - and "://" not in path - and "\\" not in path - and "?" not in path - and "#" not in path - and all( - segment not in {".", ".."} for segment in path.split("/") - ) - and all(ord(ch) >= 32 and ord(ch) != 127 for ch in path) - ): - return path - return None - - -def _srv_base_url(host: str, port: int, path: str = "/") -> str | None: - netloc = host if port == 443 else f"{host}:{port}" - return _safe_https_base_url(f"https://{netloc}{path}") - - -async def discover_carddav( - email_or_domain: str, - *, - http_client_factory: HttpClientFactory | None = None, - srv_resolver: SrvResolver | None = None, - txt_resolver: TxtResolver | None = None, -) -> CarddavDiscoveryResult | None: - """Resolve a CardDAV base URL for ``email_or_domain`` or return None.""" - domain = _extract_domain(email_or_domain) - if domain is None: - return None - - http_client_factory = http_client_factory or _default_http_client - srv_resolver = srv_resolver or _default_srv_resolver - txt_resolver = txt_resolver or _default_txt_resolver - - base_url = await _probe_well_known(domain, http_client_factory) - if base_url is not None: - logger.info("Discovered CardDAV base URL via well-known for %s", domain) - return CarddavDiscoveryResult(base_url=base_url, discovery_source="well_known") - - # SRV: only the TLS service record is used; combine it with the TXT - # context path so providers hosted below "/" resolve correctly. - secure_service = f"_carddavs._tcp.{domain}" - try: - records = list(srv_resolver(secure_service) or []) - except Exception: # noqa: BLE001 - resolver failures must not crash seeding - records = [] - context_path = "/" - if records: - try: - txt_records = list(txt_resolver(secure_service) or []) - except Exception: # noqa: BLE001 - resolver failures must not crash seeding - txt_records = [] - context_path = _txt_context_path(txt_records) or "/" - for host, port in records: - candidate = _srv_base_url(host, port, context_path) - if candidate is not None: - logger.info( - "Discovered CardDAV base URL via %s for %s", secure_service, domain - ) - return CarddavDiscoveryResult( - base_url=candidate, discovery_source="srv_secure" - ) - - # RFC 6764 also defines plain `_carddav._tcp`; using it would mean either - # sending credentials without TLS or contacting a TLS port the provider - # never advertised, so its presence is reported and refused. - try: - plain_records = list(srv_resolver(f"_carddav._tcp.{domain}") or []) - except Exception: # noqa: BLE001 - resolver failures must not crash seeding - plain_records = [] - if plain_records: - logger.info( - "Refusing non-TLS _carddav._tcp record for %s; only _carddavs " - "(TLS) discovery is supported.", - domain, - ) - - logger.info("No CardDAV base URL could be discovered for %s", domain) - return None - - -async def discover_carddav_base_url( - email_or_domain: str, - *, - http_client_factory: HttpClientFactory | None = None, - srv_resolver: SrvResolver | None = None, - txt_resolver: TxtResolver | None = None, -) -> str | None: - """Convenience wrapper returning only the discovered base URL (or None).""" - result = await discover_carddav( - email_or_domain, - http_client_factory=http_client_factory, - srv_resolver=srv_resolver, - txt_resolver=txt_resolver, - ) - return result.base_url if result is not None else None - - -__all__ = [ - "CarddavDiscoveryResult", - "discover_carddav", - "discover_carddav_base_url", -] diff --git a/backend/services/content_graph/__init__.py b/backend/services/content_graph/__init__.py index 917e90a27..c210282d3 100644 --- a/backend/services/content_graph/__init__.py +++ b/backend/services/content_graph/__init__.py @@ -1,11 +1,9 @@ -from .models import ContentNode, ContentSegment, ParseResult, PdfDomSection -from .parser import parse_content, parse_pdf_dom +from .models import ContentNode, ContentSegment, ParseResult +from .parser import parse_content __all__ = [ "ContentNode", "ContentSegment", "ParseResult", - "PdfDomSection", "parse_content", - "parse_pdf_dom", ] diff --git a/backend/services/content_graph/models.py b/backend/services/content_graph/models.py index 80c760ab1..22c6bd64b 100644 --- a/backend/services/content_graph/models.py +++ b/backend/services/content_graph/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass @dataclass(frozen=True, slots=True) @@ -32,20 +32,6 @@ class ContentSegment: word_count: int -@dataclass(frozen=True, slots=True) -class PdfDomSection: - """A normalized section of a recognized PDF DOM tree. - - One section maps to a single NewsDOM article (its ``headline`` becomes the - section heading and each entry in ``paragraphs`` becomes a paragraph leaf). - ``page_number`` is retained for stable ordering and provenance. - """ - - heading: str - paragraphs: tuple[str, ...] = field(default_factory=tuple) - page_number: int | None = None - - @dataclass(frozen=True, slots=True) class ParseResult: source_kind: str diff --git a/backend/services/content_graph/parser.py b/backend/services/content_graph/parser.py index a134eee34..351fb859b 100644 --- a/backend/services/content_graph/parser.py +++ b/backend/services/content_graph/parser.py @@ -14,9 +14,7 @@ from services.text_safety import strip_html_markup -from collections.abc import Sequence - -from .models import ContentNode, ContentSegment, ParseResult, PdfDomSection +from .models import ContentNode, ContentSegment, ParseResult _BLANK_LINE_RE = re.compile(r"\n\s*\n+") @@ -333,84 +331,6 @@ def parse_content( return _parse_plain_text(context, document_node, strip_html_markup(content)) -def parse_pdf_dom( - *, - source_kind: str, - source_record_uid: str, - sections: Sequence[PdfDomSection], - source_content_hash: str, - display_name: str = "", - content_type: str = "application/pdf", -) -> ParseResult: - """Build a document -> section -> paragraph content graph from a recognized - PDF DOM tree. - - Each :class:`PdfDomSection` becomes a ``section`` node under the document - root: its heading is emitted as a ``heading`` segment and every non-empty - paragraph becomes a ``paragraph`` node + segment. ``source_content_hash`` is - supplied by the caller so the node/segment UIDs stay stable and tied to the - exact recognized payload rather than a re-serialization of it. - """ - context = _BuildContext( - source_kind=source_kind, - source_record_uid=source_record_uid, - display_name=display_name, - content_type=_normalize_content_type(content_type), - source_content_hash=source_content_hash, - ) - document_node = _add_document_node(context, display_name) - - section_index = 0 - for section in sections: - heading_text = _safe_text(section.heading) - paragraph_texts = [ - safe_paragraph - for paragraph in section.paragraphs - if (safe_paragraph := _safe_text(paragraph)) - ] - if not heading_text and not paragraph_texts: - continue - - section_index += 1 - section_path = f"/document[1]/section[{section_index}]" - section_node = context.add_node( - parent_node_uid=document_node.content_node_uid, - node_kind="section", - node_path=section_path, - ordinal_index=section_index, - safe_text_content=heading_text, - display_label=heading_text or None, - ) - heading_path = _join_heading_path([heading_text]) if heading_text else None - if heading_text: - context.add_segment( - content_node_uid=section_node.content_node_uid, - segment_kind="heading", - segment_path=f"{section_path}/heading[1]", - heading_path=heading_path, - safe_text_content=heading_text, - ) - - for paragraph_index, paragraph_text in enumerate(paragraph_texts, start=1): - paragraph_path = f"{section_path}/paragraph[{paragraph_index}]" - paragraph_node = context.add_node( - parent_node_uid=section_node.content_node_uid, - node_kind="paragraph", - node_path=paragraph_path, - ordinal_index=paragraph_index, - safe_text_content=paragraph_text, - ) - context.add_segment( - content_node_uid=paragraph_node.content_node_uid, - segment_kind="paragraph", - segment_path=paragraph_path, - heading_path=heading_path, - safe_text_content=paragraph_text, - ) - - return context.result() - - def _parse_plain_text( context: _BuildContext, document_node: ContentNode, diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py index fb7b63378..53fdbabf7 100644 --- a/backend/services/exceptions.py +++ b/backend/services/exceptions.py @@ -40,7 +40,7 @@ class CalendarServiceError(Exception): pass -class UnsafeCalendarActionItemError(CalendarServiceError): - """Raised when calendar action item text is unsafe for writeback.""" +class UnsafeCalendarTodoError(CalendarServiceError): + """Raised when calendar todo text is unsafe for writeback.""" pass diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index a3689eb01..4c0ad6da3 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -27,7 +27,7 @@ class ExtractionResult(BaseModel): """Result model for email extraction.""" summary: str - action_items: list[str] + todos: list[str] provenance: str | None = None confidence: int | None = Field( default=None, @@ -37,14 +37,14 @@ class ExtractionResult(BaseModel): ) -async def extract_action_items_and_summary( +async def extract_todos_and_summary( email_body: str, openai_api_key: str, base_url: str | None = None, provider_name: str = "OpenAI", model: str | None = None, ) -> ExtractionResult: - """Extract action items and summary from an email.""" + """Extract todos and summary from an email.""" if not openai_api_key: raise ValueError("API Key is not set") diff --git a/backend/services/net_defaults.py b/backend/services/net_defaults.py deleted file mode 100644 index b7f13f7ae..000000000 --- a/backend/services/net_defaults.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Standard-port auto-inference for mail and DAV protocols. - -This helper ONLY fills in the standard port for a protocol when the operator -did not supply one. SSL/TLS remains implicit and is handled per-port by the -existing protocol clients (IMAP4_SSL 993, POP3_SSL 995, SMTP implicit-TLS 465 -else STARTTLS 587, DAV over https 443). Nothing here opens a socket or decides -how TLS is negotiated -- it returns an integer port only. -""" - -from __future__ import annotations - -# Canonical default ports keyed by lowercased protocol name. -# SMTP intentionally defaults to the submission port (587, STARTTLS); the -# implicit-TLS submission port (465) is selected explicitly via implicit_tls. -DEFAULT_PORTS: dict[str, int] = { - "imap": 993, - "pop3": 995, - "smtp": 587, - "caldav": 443, - "carddav": 443, - "webdav": 443, -} - -# Implicit-TLS SMTP submission port ("smtps"). -SMTP_IMPLICIT_TLS_PORT = 465 - - -def normalize_protocol(protocol: str) -> str: - """Return the canonical lowercase protocol key or raise ValueError.""" - if not isinstance(protocol, str): - raise ValueError("protocol must be a string") - key = protocol.strip().lower() - if key not in DEFAULT_PORTS: - raise ValueError(f"unsupported protocol: {protocol!r}") - return key - - -def infer_port( - protocol: str, - provided_port: int | None = None, - *, - implicit_tls: bool = False, -) -> int: - """Infer the standard port for ``protocol`` when ``provided_port`` is unset. - - - When ``provided_port`` is a positive integer it is returned unchanged - (the operator's explicit choice always wins). - - Otherwise the canonical default for the protocol is returned. For SMTP, - pass ``implicit_tls=True`` to select the implicit-TLS submission port - (465) instead of the STARTTLS submission port (587). - - SSL negotiation is NOT decided here; only the port number is returned. - """ - key = normalize_protocol(protocol) - - if provided_port is not None: - try: - port_int = int(provided_port) - except (TypeError, ValueError) as exc: - raise ValueError(f"invalid port: {provided_port!r}") from exc - if port_int > 0: - if not (1 <= port_int <= 65535): - raise ValueError(f"port out of range: {port_int}") - return port_int - # port_int <= 0 falls through to the inferred default. - - if key == "smtp" and implicit_tls: - return SMTP_IMPLICIT_TLS_PORT - return DEFAULT_PORTS[key] - - -def split_host_port(address: str) -> tuple[str, int | None]: - """Split ``host:port`` into ``(host, port)`` without inferring a default. - - Bare hosts (``"imap.example.com"``) return ``(host, None)``. Bracketed - IPv6 literals (``"[::1]:993"``) are supported. A trailing ``:port`` is only - treated as a port when it is all digits; otherwise it is left on the host - (so ``"::1"`` is not mistaken for ``host="" port=1``). - """ - if not isinstance(address, str): - raise ValueError("address must be a string") - value = address.strip() - if not value: - raise ValueError("address must not be empty") - - # Bracketed IPv6, optionally with a port: [host] or [host]:port - if value.startswith("["): - closing = value.find("]") - if closing == -1: - raise ValueError(f"invalid bracketed address: {address!r}") - host = value[1:closing] - rest = value[closing + 1 :] - if not rest: - return host, None - if rest.startswith(":") and rest[1:].isdigit(): - return host, int(rest[1:]) - raise ValueError(f"invalid bracketed address: {address!r}") - - # Only split on the final colon when the suffix is numeric and there is - # exactly one colon (more than one colon means a bare IPv6 literal). - if value.count(":") == 1: - host, _, maybe_port = value.rpartition(":") - if host and maybe_port.isdigit(): - return host, int(maybe_port) - - return value, None - - -def infer_address_port( - protocol: str, - address: str, - *, - implicit_tls: bool = False, -) -> tuple[str, int]: - """Return ``(host, port)`` for ``address``, inferring the port if absent. - - ``address`` may be a bare host or ``host:port``. When it already carries an - explicit port that port is preserved; otherwise the protocol default is - filled in. SSL remains implicit in the downstream clients. - """ - host, port = split_host_port(address) - return host, infer_port(protocol, port, implicit_tls=implicit_tls) - - -__all__ = [ - "DEFAULT_PORTS", - "SMTP_IMPLICIT_TLS_PORT", - "normalize_protocol", - "infer_port", - "split_host_port", - "infer_address_port", -] diff --git a/backend/services/newsdom_client.py b/backend/services/newsdom_client.py deleted file mode 100644 index 09b94fc8a..000000000 --- a/backend/services/newsdom_client.py +++ /dev/null @@ -1,433 +0,0 @@ -"""SSRF-safe client for the NewsDOM PDF DOM recognition sidecar. - -This mirrors :mod:`services.llm_provider_urls`: the configured base URL is -validated against a dedicated allowlist (``ALLOWED_NEWSDOM_HOSTS``), the -hostname is resolved to concrete IP addresses, every address is checked to be -globally routable (unless ``ALLOW_LOCAL_NEWSDOM_PROVIDERS`` is set for docker / -loopback development), and the outbound connection is *pinned* to those -validated addresses so a DNS-rebind between validation and connect cannot -redirect the request to an internal host. - -The base URL and bearer token are always supplied by the caller from the -database (see :class:`db.models.NewsdomProvider`) — this module never reads -service configuration or secrets from the environment at request time. -""" - -from __future__ import annotations - -import asyncio -import ipaddress -import socket -from dataclasses import dataclass -from urllib.parse import SplitResult, urlsplit, urlunsplit - -import httpcore -import httpx -from httpcore._backends.auto import AutoBackend -from httpx._config import DEFAULT_LIMITS, create_ssl_context -from httpx._transports.default import AsyncResponseStream, map_httpcore_exceptions - -from core.config import settings - -NEWSDOM_BASE_URL_NOT_ALLOWED = "NewsDOM base URL is not allowed" -_DNS_RESOLUTION_TIMEOUT_SECONDS = 5.0 -_LOCAL_DEV_HOSTNAMES = {"localhost", "localhost.localdomain"} -_LOCAL_DEV_IP_LITERALS = {"127.0.0.1", "::1"} -_DEFAULT_PARSE_TIMEOUT_SECONDS = 300.0 - - -class NewsdomConfigurationError(RuntimeError): - """Raised when the NewsDOM sidecar is not usably configured.""" - - -class NewsdomRequestError(RuntimeError): - """Raised when the NewsDOM sidecar cannot fulfil a parse request.""" - - -class NewsdomEmptyRecognitionError(NewsdomRequestError): - """Raised when a 200 sidecar response carries no usable recognized text. - - Treated as a retryable recognition failure so the caller records an error - status instead of marking the source ``parsed`` with empty content. - """ - - -@dataclass(frozen=True) -class ValidatedNewsdomBaseURL: - normalized_url: str - hostname: str - port: int - addresses: tuple[str, ...] - - -def _has_url_control_character(value: str) -> bool: - return any(ord(character) < 32 or ord(character) == 127 for character in value) - - -def _parse_allowed_hosts() -> set[str]: - return { - item.strip().lower().rstrip(".") - for item in settings.ALLOWED_NEWSDOM_HOSTS.split(",") - if item.strip() - } - - -def _is_ip_literal(candidate: str) -> bool: - try: - ipaddress.ip_address(candidate) - except ValueError: - return False - return True - - -def _looks_like_ip_literal(candidate: str) -> bool: - compact_candidate = candidate.replace(".", "").lower() - return ( - ":" in candidate - or compact_candidate.isdigit() - or compact_candidate.startswith("0x") - ) - - -def _is_local_dev_host(hostname: str) -> bool: - normalized_hostname = hostname.lower().rstrip(".") - return ( - normalized_hostname in _LOCAL_DEV_HOSTNAMES - or normalized_hostname in _LOCAL_DEV_IP_LITERALS - ) - - -def _is_allowlisted_local_host(hostname: str) -> bool: - """A bare docker-container name (e.g. ``newsdom``) that is explicitly - allowlisted while local providers are enabled.""" - normalized_hostname = hostname.lower().rstrip(".") - return ( - settings.ALLOW_LOCAL_NEWSDOM_PROVIDERS - and normalized_hostname in _parse_allowed_hosts() - and "." not in normalized_hostname - and not _is_ip_literal(normalized_hostname) - and not _looks_like_ip_literal(normalized_hostname) - ) - - -def _format_normalized_netloc(hostname: str, port: int, *, explicit_port: bool) -> str: - host_part = f"[{hostname}]" if ":" in hostname else hostname - if not explicit_port: - return host_part - return f"{host_part}:{port}" - - -def _validate_global_address(address: str, *, hostname: str | None = None) -> str: - try: - ip_address = ipaddress.ip_address(address) - except ValueError as exc: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) from exc - - is_allowed_local = False - if settings.ALLOW_LOCAL_NEWSDOM_PROVIDERS: - if ip_address.is_loopback: - is_allowed_local = True - elif hostname and _is_allowlisted_local_host(hostname): - is_allowed_local = True - - if not is_allowed_local: - if ( - ip_address.is_private - or ip_address.is_loopback - or ip_address.is_link_local - or ip_address.is_reserved - or ip_address.is_unspecified - or ip_address.is_multicast - or not ip_address.is_global - ): - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - return str(ip_address) - - -def _resolve_all_global_addresses(hostname: str, port: int) -> tuple[str, ...]: - try: - address_infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) - except socket.gaierror as exc: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) from exc - - if not address_infos: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - addresses: list[str] = [] - seen_addresses: set[str] = set() - for address_info in address_infos: - address = _validate_global_address(str(address_info[4][0]), hostname=hostname) - if address not in seen_addresses: - seen_addresses.add(address) - addresses.append(address) - return tuple(addresses) - - -async def _resolve_all_global_addresses_async( - hostname: str, port: int -) -> tuple[str, ...]: - try: - return await asyncio.wait_for( - asyncio.to_thread(_resolve_all_global_addresses, hostname, port), - timeout=_DNS_RESOLUTION_TIMEOUT_SECONDS, - ) - except asyncio.TimeoutError as exc: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) from exc - - -def _parse_and_validate_candidate_url( - value: str | None, -) -> tuple[SplitResult | None, int | None]: - if value is None: - return None, None - candidate = value.strip() - if not candidate: - return None, None - if "\\" in candidate or _has_url_control_character(candidate): - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - try: - parsed = urlsplit(candidate) - default_port = 443 if parsed.scheme.lower() == "https" else 80 - port = parsed.port or default_port - return parsed, port - except ValueError as exc: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) from exc - - -def _validate_url_components(parsed, hostname: str, is_local_dev_host: bool) -> None: - if parsed.scheme.lower() not in {"http", "https"}: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - if ( - parsed.scheme.lower() == "http" - and not is_local_dev_host - and not _is_allowlisted_local_host(hostname) - ): - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - if ( - not hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - ): - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - - -def _validate_remote_host_is_allowed(hostname: str) -> None: - allowed_hosts = _parse_allowed_hosts() - if not allowed_hosts or any("*" in allowed_host for allowed_host in allowed_hosts): - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - if hostname not in allowed_hosts: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - if _is_ip_literal(hostname) or _looks_like_ip_literal(hostname): - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - - -def _normalize_newsdom_base_url(value: str | None): - parsed, port = _parse_and_validate_candidate_url(value) - if parsed is None or port is None: - return None, None, None - - hostname = (parsed.hostname or "").lower().rstrip(".") - is_local_dev_host = _is_local_dev_host(hostname) - _validate_url_components(parsed, hostname, is_local_dev_host) - - # localhost / loopback is only usable when local providers are enabled. - if is_local_dev_host and not settings.ALLOW_LOCAL_NEWSDOM_PROVIDERS: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - if not is_local_dev_host: - _validate_remote_host_is_allowed(hostname) - - netloc = _format_normalized_netloc( - hostname, port, explicit_port=parsed.port is not None - ) - return ( - urlunsplit((parsed.scheme.lower(), netloc, parsed.path or "", "", "")), - hostname, - port, - ) - - -async def validate_newsdom_base_url_details_async( - value: str | None, -) -> ValidatedNewsdomBaseURL | None: - normalized_url, hostname, port = _normalize_newsdom_base_url(value) - if normalized_url is None: - return None - addresses = await _resolve_all_global_addresses_async(hostname, port) - return ValidatedNewsdomBaseURL(normalized_url, hostname, port, addresses) - - -class _PinnedNewsdomNetworkBackend(httpcore.AsyncNetworkBackend): - def __init__(self, hostname: str, port: int, addresses: tuple[str, ...]): - if not addresses: - raise ValueError(NEWSDOM_BASE_URL_NOT_ALLOWED) - self._hostname = hostname - self._port = port - self._addresses = tuple( - _validate_global_address(address, hostname=hostname) - for address in addresses - ) - self._backend = AutoBackend() - - def _verify_host_port(self, host: str | bytes, port: int) -> None: - host_text = host.decode("ascii") if isinstance(host, bytes) else str(host) - normalized_host = host_text.lower().rstrip(".") - if normalized_host != self._hostname or int(port) != self._port: - raise OSError("NewsDOM base URL host changed after validation") - - async def connect_tcp( - self, - host: str | bytes, - port: int, - timeout: float | None = None, - local_address: str | None = None, - socket_options=None, - ): - self._verify_host_port(host, port) - last_error: Exception | None = None - for address in self._addresses: - pinned_address = _validate_global_address(address, hostname=self._hostname) - try: - return await self._backend.connect_tcp( - pinned_address, - port, - timeout=timeout, - local_address=local_address, - socket_options=socket_options, - ) - except Exception as exc: # pragma: no cover - backend-specific - last_error = exc - if last_error is not None: - raise last_error - raise OSError(NEWSDOM_BASE_URL_NOT_ALLOWED) - - async def connect_unix_socket( - self, - path: str, - timeout: float | None = None, - socket_options=None, - ): - raise OSError("NewsDOM base URL must not use Unix sockets") - - async def sleep(self, seconds: float) -> None: - await self._backend.sleep(seconds) - - -class _PinnedNewsdomAsyncTransport(httpx.AsyncBaseTransport): - def __init__(self, validated: ValidatedNewsdomBaseURL): - self._validated = validated - ssl_context = create_ssl_context(verify=True, trust_env=False) - self._pool = httpcore.AsyncConnectionPool( - ssl_context=ssl_context, - max_connections=DEFAULT_LIMITS.max_connections, - max_keepalive_connections=DEFAULT_LIMITS.max_keepalive_connections, - keepalive_expiry=DEFAULT_LIMITS.keepalive_expiry, - http1=True, - http2=False, - network_backend=_PinnedNewsdomNetworkBackend( - validated.hostname, - validated.port, - validated.addresses, - ), - ) - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - parsed_url = urlsplit(self._validated.normalized_url) - validated_scheme = parsed_url.scheme.encode("ascii") - validated_host = self._validated.hostname.encode("ascii") - validated_netloc = parsed_url.netloc.encode("ascii") - - safe_headers = [ - (key, value) - for key, value in request.headers.raw - if key.lower() != b"host" - ] - safe_headers.append((b"host", validated_netloc)) - - req = httpcore.Request( - method=request.method, - url=httpcore.URL( - scheme=validated_scheme, - host=validated_host, - port=self._validated.port, - target=request.url.raw_path, - ), - headers=safe_headers, - content=request.stream, - extensions=request.extensions, - ) - with map_httpcore_exceptions(): - resp = await self._pool.handle_async_request(req) - - return httpx.Response( - status_code=resp.status, - headers=resp.headers, - stream=AsyncResponseStream(resp.stream), - extensions=resp.extensions, - ) - - async def aclose(self) -> None: - await self._pool.aclose() - - -def _joined_url(base_url: str, path: str) -> str: - return f"{base_url.rstrip('/')}/{path.lstrip('/')}" - - -async def request_pdf_dom( - *, - base_url: str | None, - api_token: str | None, - pdf_bytes: bytes, - filename: str = "document.pdf", - language: str = "auto", - mode: str = "auto", - timeout_seconds: float = _DEFAULT_PARSE_TIMEOUT_SECONDS, -) -> dict: - """POST the PDF bytes to ``{base_url}/parse`` and return the parsed DOM. - - Targets the generalized NewsDOM ``/parse`` contract: a multipart request - carrying the PDF under ``file`` plus ``language`` / ``mode`` form fields and - an optional ``Authorization: Bearer`` header. Unknown extra form fields are - ignored by the current sidecar, keeping this forward-compatible. - """ - if not pdf_bytes: - raise NewsdomRequestError("Cannot recognize an empty PDF payload") - - validated = await validate_newsdom_base_url_details_async(base_url) - if validated is None: - raise NewsdomConfigurationError( - "NewsDOM base URL is not configured for this workspace" - ) - - headers: dict[str, str] = {} - if api_token and api_token.strip(): - headers["Authorization"] = f"Bearer {api_token.strip()}" - - files = {"file": (filename or "document.pdf", pdf_bytes, "application/pdf")} - data = {"language": language or "auto", "mode": mode or "auto"} - - async with httpx.AsyncClient( - follow_redirects=False, - trust_env=False, - timeout=timeout_seconds, - transport=_PinnedNewsdomAsyncTransport(validated), - ) as client: - try: - response = await client.post( - _joined_url(validated.normalized_url, "/parse"), - files=files, - data=data, - headers=headers, - ) - except httpx.HTTPError as exc: - raise NewsdomRequestError(f"NewsDOM request failed: {exc}") from exc - - if response.status_code >= 400: - raise NewsdomRequestError( - f"NewsDOM returned HTTP {response.status_code} for /parse" - ) - try: - return response.json() - except ValueError as exc: - raise NewsdomRequestError("NewsDOM returned a non-JSON response") from exc diff --git a/backend/services/newsdom_pdf_recognition.py b/backend/services/newsdom_pdf_recognition.py deleted file mode 100644 index cc3072b29..000000000 --- a/backend/services/newsdom_pdf_recognition.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Map a recognized NewsDOM PDF into naruon's content graph. - -The NewsDOM sidecar returns a ``pages -> articles -> body_blocks`` tree (see the -``ParseResponse`` schema in newsdom-api). This module normalizes that tree into: - -* ``parse_text`` — a flat text rendering used for attachment / document - embeddings, and -* a :class:`~services.content_graph.ParseResult` — a document -> section -> - paragraph :class:`ContentNode` / :class:`ContentSegment` tree. - -Provider configuration (base URL + bearer token) is always resolved from the -database (:class:`db.models.NewsdomProvider`); this module never reads service -config or secrets from the environment. -""" - -from __future__ import annotations - -import hashlib -from collections.abc import Awaitable, Callable -from dataclasses import dataclass - -from sqlalchemy import desc, select -from sqlalchemy.ext.asyncio import AsyncSession - -from db.models import NewsdomProvider -from services.content_graph import ParseResult, PdfDomSection, parse_pdf_dom -from services.newsdom_client import ( - NewsdomConfigurationError, - NewsdomEmptyRecognitionError, - request_pdf_dom, -) - -PDF_DOM_RECOGNITION_PENDING_STATUS = "pdf_dom_recognition_pending" -PDF_DOM_RECOGNITION_PARSED_STATUS = "parsed" -# Terminal-but-retryable failure: the sidecar was reached (or the payload was -# unusable) and recognition did not produce landable content. Distinct from the -# pending status so a stuck/empty recognition is visible instead of masquerading -# as either parsed or perpetually pending. -PDF_DOM_RECOGNITION_FAILED_STATUS = "pdf_dom_recognition_failed" -PDF_PARSER_KEY = "pdf" -PDF_PARSE_CONTENT_TYPE = "text/plain" - - -@dataclass(frozen=True) -class NewsdomRuntimeConfig: - base_url: str - api_token: str | None - request_language: str - recognition_mode: str - provider_name: str - - -@dataclass(frozen=True) -class PdfDomRecognitionRecords: - parse_text: str - source_content_hash: str - parse_result: ParseResult - - -ParseRequestFn = Callable[..., Awaitable[dict]] - - -def resolve_newsdom_runtime_config( - provider: NewsdomProvider | None, -) -> NewsdomRuntimeConfig | None: - """Build a runtime config purely from a database row — never the env. - - Returns ``None`` when the provider is missing, inactive, or has no base URL, - which is the signal that PDF DOM recognition should stay pending / degrade - gracefully rather than raise. - """ - if provider is None or not provider.is_active: - return None - base_url = (provider.base_url or "").strip() - if not base_url: - return None - api_token = provider.api_token.strip() if provider.api_token else None - return NewsdomRuntimeConfig( - base_url=base_url, - api_token=api_token or None, - request_language=(provider.request_language or "auto").strip() or "auto", - recognition_mode=(provider.recognition_mode or "auto").strip() or "auto", - provider_name=provider.provider_name, - ) - - -async def get_active_newsdom_provider( - session: AsyncSession, - organization_id: str | None, -) -> NewsdomProvider | None: - if not organization_id: - return None - result = await session.execute( - select(NewsdomProvider) - .where( - NewsdomProvider.organization_id == organization_id, - NewsdomProvider.is_active.is_(True), - ) - .order_by( - desc(NewsdomProvider.updated_at), - desc(NewsdomProvider.newsdom_provider_id), - ) - .limit(1) - ) - return result.scalars().first() - - -async def resolve_newsdom_config_from_db( - session: AsyncSession, - organization_id: str | None, -) -> NewsdomRuntimeConfig | None: - provider = await get_active_newsdom_provider(session, organization_id) - return resolve_newsdom_runtime_config(provider) - - -def normalize_parse_response(payload: dict) -> list[PdfDomSection]: - """Flatten a NewsDOM ``ParseResponse`` dict into ordered sections. - - Each article (across every page, in page then article order) becomes one - section. Robust to missing / malformed keys so a partial sidecar response - never crashes the importer. - """ - sections: list[PdfDomSection] = [] - pages = payload.get("pages") if isinstance(payload, dict) else None - if not isinstance(pages, list): - return sections - - for page in pages: - if not isinstance(page, dict): - continue - page_number = page.get("page_number") - page_number = page_number if isinstance(page_number, int) else None - articles = page.get("articles") - if not isinstance(articles, list): - continue - for article in articles: - if not isinstance(article, dict): - continue - headline = article.get("headline") - headline = headline if isinstance(headline, str) else "" - body_blocks = article.get("body_blocks") - paragraphs = tuple( - block - for block in (body_blocks if isinstance(body_blocks, list) else []) - if isinstance(block, str) and block.strip() - ) - if not headline.strip() and not paragraphs: - continue - sections.append( - PdfDomSection( - heading=headline, - paragraphs=paragraphs, - page_number=page_number, - ) - ) - return sections - - -def _render_parse_text(sections: list[PdfDomSection]) -> str: - blocks: list[str] = [] - for section in sections: - if section.heading.strip(): - blocks.append(section.heading.strip()) - blocks.extend( - paragraph.strip() for paragraph in section.paragraphs if paragraph.strip() - ) - return "\n\n".join(blocks) - - -def build_recognition_records( - payload: dict, - *, - source_kind: str, - source_record_uid: str, - display_name: str = "", -) -> PdfDomRecognitionRecords: - sections = normalize_parse_response(payload) - parse_text = _render_parse_text(sections) - source_content_hash = hashlib.sha256( - parse_text.encode("utf-8", errors="surrogatepass") - ).hexdigest() - parse_result = parse_pdf_dom( - source_kind=source_kind, - source_record_uid=source_record_uid, - sections=sections, - source_content_hash=source_content_hash, - display_name=display_name, - ) - return PdfDomRecognitionRecords( - parse_text=parse_text, - source_content_hash=source_content_hash, - parse_result=parse_result, - ) - - -async def recognize_pdf_dom( - *, - config: NewsdomRuntimeConfig | None, - pdf_bytes: bytes, - filename: str, - source_kind: str, - source_record_uid: str, - display_name: str = "", - request_fn: ParseRequestFn = request_pdf_dom, -) -> PdfDomRecognitionRecords: - """Call the sidecar and map the response into content graph records. - - ``request_fn`` is injectable so callers (and tests) can supply a mocked - NewsDOM client. Raises :class:`NewsdomConfigurationError` when the sidecar is - not configured, so the caller can keep the source pending instead of failing - the whole import. - """ - if config is None: - raise NewsdomConfigurationError( - "NewsDOM PDF DOM recognition is not configured for this workspace" - ) - payload = await request_fn( - base_url=config.base_url, - api_token=config.api_token, - pdf_bytes=pdf_bytes, - filename=filename, - language=config.request_language, - mode=config.recognition_mode, - ) - records = build_recognition_records( - payload, - source_kind=source_kind, - source_record_uid=source_record_uid, - display_name=display_name, - ) - # A 200 response with no recognized sections/text is a recognition failure, - # not a parsed document: refuse to land empty content as "parsed" so the - # caller records a retryable error instead of a false-positive success. - if not records.parse_text.strip() and not records.parse_result.segments: - raise NewsdomEmptyRecognitionError( - "NewsDOM sidecar returned no recognizable text for the PDF" - ) - return records diff --git a/backend/services/newsdom_worker.py b/backend/services/newsdom_worker.py deleted file mode 100644 index 26e4cffbb..000000000 --- a/backend/services/newsdom_worker.py +++ /dev/null @@ -1,580 +0,0 @@ -"""Background worker glue for NewsDOM PDF DOM recognition. - -Attachments and workspace documents whose PDF recognition was deferred at -import time are processed here: the sidecar is called (via -:mod:`services.newsdom_pdf_recognition`) and the returned tree is landed into -the persisted attachment/document content and, for attachments, the -``content_nodes`` / ``content_segments`` graph. - -The apply functions are deliberately session-free so they can be unit tested -with in-memory model instances and a mocked NewsDOM client. -""" - -from __future__ import annotations - -import asyncio -import logging -import random -from collections.abc import Awaitable, Callable - -from sqlalchemy import bindparam, func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from db.models import ( - Attachment, - ContentNodeRecord, - ContentSegmentRecord, - Document, - Email, -) -from db.session import AsyncSessionLocal -from services.attachment_parser import decode_deferred_attachment_payload -from services.content_graph import ParseResult -from services.newsdom_client import ( - NewsdomConfigurationError, - NewsdomRequestError, - request_pdf_dom, -) -from services.newsdom_pdf_recognition import ( - PDF_DOM_RECOGNITION_FAILED_STATUS, - PDF_DOM_RECOGNITION_PARSED_STATUS, - PDF_DOM_RECOGNITION_PENDING_STATUS, - PDF_PARSE_CONTENT_TYPE, - PDF_PARSER_KEY, - NewsdomRuntimeConfig, - ParseRequestFn, - PdfDomRecognitionRecords, - recognize_pdf_dom, - resolve_newsdom_config_from_db, -) - -logger = logging.getLogger(__name__) -_sysrand = random.SystemRandom() - -# How the worker resolves a runtime config for an organization. Injectable so -# the per-item processors are unit-testable without a database. -ConfigResolver = Callable[ - [AsyncSession, str | None], Awaitable[NewsdomRuntimeConfig | None] -] - - -def _append_parse_result_to_attachment( - *, - email: Email, - attachment: Attachment, - parse_result: ParseResult, -) -> None: - """Append recognized graph records to an email and its attachment.""" - node_records_by_uid: dict[str, ContentNodeRecord] = {} - for parsed_node in parse_result.nodes: - node_record = ContentNodeRecord( - content_node_uid=parsed_node.content_node_uid, - source_kind=parsed_node.source_kind, - source_record_uid=parsed_node.source_record_uid, - parent_node_uid=parsed_node.parent_node_uid, - node_kind=parsed_node.node_kind, - node_path=parsed_node.node_path, - ordinal_index=parsed_node.ordinal_index, - display_label=parsed_node.display_label, - safe_text_content=parsed_node.safe_text_content, - content_hash=parsed_node.content_hash, - ) - email.content_nodes.append(node_record) - attachment.content_nodes.append(node_record) - node_records_by_uid[parsed_node.content_node_uid] = node_record - - for parsed_segment in parse_result.segments: - node_record = node_records_by_uid.get(parsed_segment.content_node_uid) - segment_record = ContentSegmentRecord( - content_segment_uid=parsed_segment.content_segment_uid, - source_kind=parsed_segment.source_kind, - source_record_uid=parsed_segment.source_record_uid, - segment_kind=parsed_segment.segment_kind, - segment_path=parsed_segment.segment_path, - ordinal_index=parsed_segment.ordinal_index, - heading_path=parsed_segment.heading_path, - safe_text_content=parsed_segment.safe_text_content, - content_hash=parsed_segment.content_hash, - word_count=parsed_segment.word_count, - ) - if node_record is not None: - node_record.segments.append(segment_record) - email.content_segments.append(segment_record) - attachment.content_segments.append(segment_record) - - -def apply_recognition_to_attachment( - *, - email: Email, - attachment: Attachment, - records: PdfDomRecognitionRecords, -) -> None: - """Land recognized PDF DOM records onto an attachment (text + graph).""" - attachment.content = records.parse_text - attachment.parse_content_type = PDF_PARSE_CONTENT_TYPE - attachment.parser_key = PDF_PARSER_KEY - attachment.parse_status = PDF_DOM_RECOGNITION_PARSED_STATUS - attachment.parse_error_code = None - _append_parse_result_to_attachment( - email=email, - attachment=attachment, - parse_result=records.parse_result, - ) - - -def apply_recognition_to_document( - *, - document: Document, - records: PdfDomRecognitionRecords, -) -> None: - """Land recognized PDF text onto a workspace document (mirrors the HWP - conversion worker: content + status, no content graph rows).""" - document.document_content = records.parse_text - document.document_status = PDF_DOM_RECOGNITION_PARSED_STATUS - - -async def recognize_attachment_pdf( - *, - email: Email, - attachment: Attachment, - pdf_bytes: bytes, - config: NewsdomRuntimeConfig | None, - source_record_uid: str, - request_fn: ParseRequestFn = request_pdf_dom, -) -> PdfDomRecognitionRecords: - """Recognize a PDF and land its text and graph on an attachment.""" - records = await recognize_pdf_dom( - config=config, - pdf_bytes=pdf_bytes, - filename=attachment.filename or "attachment.pdf", - source_kind="attachment", - source_record_uid=source_record_uid, - display_name=attachment.filename or "", - request_fn=request_fn, - ) - apply_recognition_to_attachment(email=email, attachment=attachment, records=records) - return records - - -async def recognize_document_pdf( - *, - document: Document, - pdf_bytes: bytes, - config: NewsdomRuntimeConfig | None, - request_fn: ParseRequestFn = request_pdf_dom, -) -> PdfDomRecognitionRecords: - """Recognize a PDF and land its text on a workspace document.""" - records = await recognize_pdf_dom( - config=config, - pdf_bytes=pdf_bytes, - filename=document.document_name or "document.pdf", - source_kind="workspace_document", - source_record_uid=document.document_id, - display_name=document.document_name or "", - request_fn=request_fn, - ) - apply_recognition_to_document(document=document, records=records) - return records - - -# -------------------------------------------------------------------------- -# Production worker: sweep pending attachments/documents and recognize them. -# -------------------------------------------------------------------------- - -DEFAULT_NEWSDOM_INTERVAL_SECONDS = 60 -DEFAULT_NEWSDOM_BATCH_LIMIT = 10 -NEWSDOM_SWEEP_LOCK_NAMESPACE = "naruon-newsdom-recognition-sweep" -MAX_STARTUP_JITTER_SECONDS = 30 - -# Per-item processing outcomes. -RESULT_RECOGNIZED = "recognized" -RESULT_PENDING = "pending" -RESULT_FAILED = "failed" - -_SWEEP_LOCK_PARAMS = { - "namespace_key": NEWSDOM_SWEEP_LOCK_NAMESPACE, - "sweep_key": "sweep", -} - - -async def process_pending_attachment( - *, - session: AsyncSession, - attachment: Attachment, - config_resolver: ConfigResolver = resolve_newsdom_config_from_db, - request_fn: ParseRequestFn = request_pdf_dom, -) -> str: - """Recognize one pending attachment PDF, or record a safe outcome. - - Returns ``RESULT_RECOGNIZED`` on success, ``RESULT_PENDING`` when no active - provider is configured yet (left pending to retry later), or - ``RESULT_FAILED`` when the payload or the sidecar response is unusable (a - visible failure status is recorded - never a false ``parsed``). - """ - email = attachment.email - if email is None: - attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS - attachment.parse_error_code = "orphan_attachment" - return RESULT_FAILED - try: - pdf_bytes = decode_deferred_attachment_payload(attachment.content) - except ValueError as exc: - attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS - attachment.parse_error_code = "invalid_pending_payload" - logger.warning( - "NewsDOM attachment %s rejected before recognition: %s", - getattr(attachment, "id", "?"), - exc, - ) - return RESULT_FAILED - - config = await config_resolver(session, email.organization_id) - if config is None: - # Degrade gracefully: no active NewsDOM provider for this org yet. - logger.info( - "NewsDOM attachment %s remains pending: no active provider for " - "organization %s.", - getattr(attachment, "id", "?"), - email.organization_id or "personal-scope", - ) - return RESULT_PENDING - - try: - await recognize_attachment_pdf( - email=email, - attachment=attachment, - pdf_bytes=pdf_bytes, - config=config, - source_record_uid=f"attachment-{attachment.id}", - request_fn=request_fn, - ) - except NewsdomConfigurationError as exc: - logger.warning( - "NewsDOM attachment %s remains pending: provider configuration " - "was rejected: %s", - getattr(attachment, "id", "?"), - exc, - ) - return RESULT_PENDING - except (NewsdomRequestError, ValueError) as exc: - attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS - attachment.parse_error_code = "recognition_failed" - logger.warning( - "NewsDOM attachment %s recognition failed: %s", - getattr(attachment, "id", "?"), - exc, - ) - return RESULT_FAILED - return RESULT_RECOGNIZED - - -async def process_pending_document( - *, - session: AsyncSession, - document: Document, - config_resolver: ConfigResolver = resolve_newsdom_config_from_db, - request_fn: ParseRequestFn = request_pdf_dom, -) -> str: - """Recognize one pending workspace-document PDF, or record a safe outcome.""" - from api.data import decode_pending_pdf_document_bytes - - try: - pdf_bytes = decode_pending_pdf_document_bytes(document) - except ValueError as exc: - document.document_status = PDF_DOM_RECOGNITION_FAILED_STATUS - logger.warning( - "NewsDOM document %s rejected before recognition: %s", - getattr(document, "document_id", "?"), - exc, - ) - return RESULT_FAILED - - config = await config_resolver(session, document.organization_id) - if config is None: - logger.info( - "NewsDOM document %s remains pending: no active provider for " - "organization %s.", - getattr(document, "document_id", "?"), - document.organization_id or "personal-scope", - ) - return RESULT_PENDING - - try: - await recognize_document_pdf( - document=document, - pdf_bytes=pdf_bytes, - config=config, - request_fn=request_fn, - ) - except NewsdomConfigurationError as exc: - logger.warning( - "NewsDOM document %s remains pending: provider configuration was " - "rejected: %s", - getattr(document, "document_id", "?"), - exc, - ) - return RESULT_PENDING - except (NewsdomRequestError, ValueError) as exc: - document.document_status = PDF_DOM_RECOGNITION_FAILED_STATUS - logger.warning( - "NewsDOM document %s recognition failed: %s", - getattr(document, "document_id", "?"), - exc, - ) - return RESULT_FAILED - return RESULT_RECOGNIZED - - -def _session_uses_postgresql(session: AsyncSession) -> bool: - """Return whether advisory-lock SQL is supported by the session bind.""" - try: - bind = session.get_bind() - except Exception: - return False - return getattr(getattr(bind, "dialect", None), "name", None) == "postgresql" - - -async def _try_acquire_sweep_lease(session: AsyncSession) -> bool | None: - """Become the sweep leader for this cycle (None when not PostgreSQL).""" - if not _session_uses_postgresql(session): - return None - acquired = await session.scalar( - select( - func.pg_try_advisory_lock( - func.hashtext(bindparam("namespace_key")), - func.hashtext(bindparam("sweep_key")), - ) - ), - _SWEEP_LOCK_PARAMS, - ) - return bool(acquired) - - -async def _release_sweep_lease(session: AsyncSession) -> None: - """Release the PostgreSQL advisory lock for a recognition sweep.""" - await session.scalar( - select( - func.pg_advisory_unlock( - func.hashtext(bindparam("namespace_key")), - func.hashtext(bindparam("sweep_key")), - ) - ), - _SWEEP_LOCK_PARAMS, - ) - - -class NewsdomRecognitionWorker: - """Periodically recognize pending PDF attachments and workspace documents. - - Mirrors :class:`ReplySlaScheduler`: a jittered periodic loop, a PostgreSQL - advisory-lock lease so only one replica sweeps per cycle, and per-item - error isolation. Items whose organization has no active NewsDOM provider are - left pending (they recognize once a provider is configured); unusable - payloads/responses are marked failed rather than parsed. - """ - - def __init__( - self, - *, - interval_seconds: int = DEFAULT_NEWSDOM_INTERVAL_SECONDS, - batch_limit: int = DEFAULT_NEWSDOM_BATCH_LIMIT, - request_fn: ParseRequestFn = request_pdf_dom, - config_resolver: ConfigResolver = resolve_newsdom_config_from_db, - ): - """Configure the sweep cadence, batch size, and injectable adapters.""" - self.interval_seconds = interval_seconds - self.batch_limit = batch_limit - self._request_fn = request_fn - self._config_resolver = config_resolver - self._task: asyncio.Task | None = None - self._is_running = False - self._attachment_cursor: int | None = None - self._document_cursor: str | None = None - - async def start(self) -> None: - """Start the recognition loop once.""" - if self._is_running: - logger.warning("NewsdomRecognitionWorker is already running.") - return - self._is_running = True - self._task = asyncio.create_task(self._run_loop()) - logger.info("NewsdomRecognitionWorker started.") - - async def stop(self) -> None: - """Cancel and await the active recognition loop.""" - if not self._is_running: - return - self._is_running = False - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - logger.debug("NewsdomRecognitionWorker cancellation acknowledged.") - logger.info("NewsdomRecognitionWorker stopped.") - - async def _run_loop(self) -> None: - """Run jittered recognition sweeps until stopped.""" - try: - await asyncio.sleep( - _sysrand.uniform( - 0, min(self.interval_seconds / 10, MAX_STARTUP_JITTER_SECONDS) - ) - ) - except asyncio.CancelledError: - return - - while self._is_running: - try: - await self._sweep() - except asyncio.CancelledError: - break - except Exception: - logger.error("Error in NewsdomRecognitionWorker loop.", exc_info=True) - if self._is_running: - try: - await asyncio.sleep(self.interval_seconds) - except asyncio.CancelledError: - break - - async def _sweep(self) -> None: - """Process one leased attachment and document sweep.""" - async with AsyncSessionLocal() as session: - lease = await _try_acquire_sweep_lease(session) - if lease is False: - logger.debug( - "NewsDOM recognition sweep skipped: another replica holds " - "the lease." - ) - return - try: - await self._sweep_attachments(session) - await self._sweep_documents(session) - finally: - if lease is True: - await _release_sweep_lease(session) - - async def _sweep_attachments(self, session: AsyncSession) -> None: - """Process a bounded, starvation-free batch of pending attachments.""" - rows = await self._load_pending_attachments(session) - if rows: - self._attachment_cursor = rows[-1].id - for attachment in rows: - try: - result = await process_pending_attachment( - session=session, - attachment=attachment, - config_resolver=self._config_resolver, - request_fn=self._request_fn, - ) - await session.commit() - if result != RESULT_PENDING: - logger.info( - "NewsDOM attachment %s recognition result: %s", - attachment.id, - result, - ) - except Exception: - await session.rollback() - logger.error( - "NewsDOM attachment %s recognition raised.", - getattr(attachment, "id", "?"), - exc_info=True, - ) - - def _pending_attachment_statement(self, after_id: int | None): - """Build the next deterministic attachment batch query.""" - statement = select(Attachment).where( - Attachment.parse_status == PDF_DOM_RECOGNITION_PENDING_STATUS - ) - if after_id is not None: - statement = statement.where(Attachment.id > after_id) - return ( - statement.order_by(Attachment.id) - .options(selectinload(Attachment.email)) - .limit(self.batch_limit) - ) - - async def _load_pending_attachments( - self, session: AsyncSession - ) -> list[Attachment]: - """Load after the last attempted row, wrapping at the table tail. - - Advancing over rows that remain pending prevents an unconfigured - organization's first batch from permanently starving configured rows. - """ - rows = ( - ( - await session.execute( - self._pending_attachment_statement(self._attachment_cursor) - ) - ) - .scalars() - .all() - ) - if not rows and self._attachment_cursor is not None: - self._attachment_cursor = None - rows = ( - (await session.execute(self._pending_attachment_statement(None))) - .scalars() - .all() - ) - return rows - - async def _sweep_documents(self, session: AsyncSession) -> None: - """Process a bounded, starvation-free batch of pending documents.""" - rows = await self._load_pending_documents(session) - if rows: - self._document_cursor = rows[-1].document_id - for document in rows: - try: - result = await process_pending_document( - session=session, - document=document, - config_resolver=self._config_resolver, - request_fn=self._request_fn, - ) - await session.commit() - if result != RESULT_PENDING: - logger.info( - "NewsDOM document %s recognition result: %s", - document.document_id, - result, - ) - except Exception: - await session.rollback() - logger.error( - "NewsDOM document %s recognition raised.", - getattr(document, "document_id", "?"), - exc_info=True, - ) - - def _pending_document_statement(self, after_id: str | None): - """Build the next deterministic workspace-document batch query.""" - statement = select(Document).where( - Document.document_status == PDF_DOM_RECOGNITION_PENDING_STATUS - ) - if after_id is not None: - statement = statement.where(Document.document_id > after_id) - return statement.order_by(Document.document_id).limit(self.batch_limit) - - async def _load_pending_documents(self, session: AsyncSession) -> list[Document]: - """Load after the last attempted document and wrap at the tail.""" - rows = ( - ( - await session.execute( - self._pending_document_statement(self._document_cursor) - ) - ) - .scalars() - .all() - ) - if not rows and self._document_cursor is not None: - self._document_cursor = None - rows = ( - (await session.execute(self._pending_document_statement(None))) - .scalars() - .all() - ) - return rows diff --git a/backend/tests/live/mail_smoke_test.py b/backend/tests/live/mail_smoke_test.py index ef08aeacd..3cf817803 100644 --- a/backend/tests/live/mail_smoke_test.py +++ b/backend/tests/live/mail_smoke_test.py @@ -1,335 +1,5 @@ -"""Real live-account smoke test. - -After seeding the ``NARUON_TEST_*{N}`` accounts into the DB, this connects each -configured protocol and asserts reachability: - - IMAP login + SELECT INBOX - POP3 STAT - SMTP EHLO + STARTTLS (or implicit-TLS EHLO on 465) - CalDAV PROPFIND - CardDAV auto-discovery + PROPFIND - -A protocol whose address (or CardDAV discovery) is unavailable is skipped -cleanly rather than failing. The whole check is gated behind an opt-in flag so -the normal unit CI never runs it: - - NARUON_LIVE_SMOKE=1 (or LIVE_BASE_URL set) - -Run directly on the mail-egress runner: - - python3 backend/tests/live/mail_smoke_test.py -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import ssl -import sys -from dataclasses import dataclass, field -from pathlib import Path - -BACKEND_ROOT = Path(__file__).resolve().parents[2] -if str(BACKEND_ROOT) not in sys.path: - sys.path.insert(0, str(BACKEND_ROOT)) - -from tests.live.seed_test_accounts import ( # noqa: E402 - LiveAccountSpec, - parse_test_accounts, -) - -logger = logging.getLogger("naruon.live.mail_smoke") - -LIVE_FLAG_ENV = "NARUON_LIVE_SMOKE" - - -def live_smoke_enabled(environ: dict[str, str] | None = None) -> bool: - environ = environ if environ is not None else dict(os.environ) - return bool( - environ.get(LIVE_FLAG_ENV) - or environ.get("LIVE_BASE_URL") - # Presence of account-1 secrets implies an intentional live run. - or environ.get("NARUON_TEST_EMAIL1") - ) - - -@dataclass -class ProtocolResult: - protocol: str - account_index: int - reachable: bool - skipped: bool = False - detail: str = "" - - -@dataclass -class SmokeReport: - results: list[ProtocolResult] = field(default_factory=list) - - @property - def reached(self) -> list[ProtocolResult]: - return [r for r in self.results if r.reachable and not r.skipped] - - @property - def failures(self) -> list[ProtocolResult]: - return [r for r in self.results if not r.reachable and not r.skipped] - - @property - def skipped(self) -> list[ProtocolResult]: - return [r for r in self.results if r.skipped] - - -async def _check_imap(spec: LiveAccountSpec) -> ProtocolResult: - import aioimaplib - - from services.email_client import validate_imap_destination - - assert spec.imap is not None - host, port = validate_imap_destination(spec.imap.host, spec.imap.port) - ssl_context = ssl.create_default_context() - client = aioimaplib.IMAP4_SSL(host, port, ssl_context=ssl_context) - try: - await asyncio.wait_for(client.wait_hello_from_server(), timeout=30) - if not spec.email or not spec.password: - return ProtocolResult("imap", spec.index, False, detail="missing creds") - resp, _ = await client.login(spec.email, spec.password) - if resp != "OK": - return ProtocolResult("imap", spec.index, False, detail="login failed") - select_resp, _ = await client.select("INBOX") - ok = select_resp == "OK" - return ProtocolResult( - "imap", spec.index, ok, detail="select ok" if ok else "select failed" - ) - finally: - try: - await client.logout() - except Exception: # noqa: BLE001 - pass - - -async def _check_pop3(spec: LiveAccountSpec) -> ProtocolResult: - import poplib - - from services.email_client import validate_pop3_destination - - assert spec.pop3 is not None - host, port = validate_pop3_destination(spec.pop3.host, spec.pop3.port) - - def _stat() -> tuple[int, int]: - client = poplib.POP3_SSL(host, port, timeout=30) - try: - client.user(spec.email) - client.pass_(spec.password or "") - return client.stat() - finally: - try: - client.quit() - except Exception: # noqa: BLE001 - pass - - if not spec.email or not spec.password: - return ProtocolResult("pop3", spec.index, False, detail="missing creds") - count, _size = await asyncio.to_thread(_stat) - return ProtocolResult("pop3", spec.index, True, detail=f"stat count={count}") - - -async def _check_smtp(spec: LiveAccountSpec) -> ProtocolResult: - import aiosmtplib - - from services.email_client import validate_smtp_destination - from services.net_defaults import SMTP_IMPLICIT_TLS_PORT - - assert spec.smtp is not None - destination = validate_smtp_destination(spec.smtp.host, spec.smtp.port) - implicit_tls = destination.port == SMTP_IMPLICIT_TLS_PORT - client = aiosmtplib.SMTP( - hostname=destination.hostname, - port=destination.port, - use_tls=implicit_tls, - timeout=30, - ) - try: - await client.connect() - await client.ehlo() - if not implicit_tls and client.supports_extension("starttls"): - await client.starttls() - await client.ehlo() - return ProtocolResult( - "smtp", - spec.index, - True, - detail="implicit-tls ehlo" if implicit_tls else "starttls ehlo", - ) - finally: - try: - await client.quit() - except Exception: # noqa: BLE001 - pass - - -async def _check_caldav(spec: LiveAccountSpec) -> ProtocolResult: - import httpx - - assert spec.caldav_url is not None - headers = {"Depth": "0", "Content-Type": "application/xml; charset=utf-8"} - auth = (spec.email, spec.password or "") if spec.email else None - body = ( - '' - '' - "" - ).encode("utf-8") - async with httpx.AsyncClient(follow_redirects=False, timeout=30) as client: - response = await client.request( - "PROPFIND", spec.caldav_url, headers=headers, content=body, auth=auth - ) - reachable = response.status_code in {200, 207, 401, 403} - return ProtocolResult( - "caldav", spec.index, reachable, detail=f"status={response.status_code}" - ) - - -async def _check_carddav(spec: LiveAccountSpec) -> ProtocolResult: - from services.carddav_client import CardDavClient - from services.carddav_discovery import discover_carddav_base_url - - base_url = spec.carddav_url - if base_url is None: - base_url = await discover_carddav_base_url(spec.email) - if base_url is None: - # CardDAV was configured (explicit address or discovery requested) but - # no endpoint resolved. That is a failure, not a skip -- skipping here - # would let the run report CardDAV coverage it never exercised. - return ProtocolResult( - "carddav", spec.index, False, skipped=False, detail="no discovery" - ) - client = CardDavClient( - base_url, - username=spec.email or None, - password=spec.password, - ) - probe = await client.list_address_books() - return ProtocolResult( - "carddav", - spec.index, - probe.reachable, - detail=f"status={probe.status_code}", - ) - - -_CHECKS = { - "imap": (_check_imap, lambda s: s.imap is not None), - "pop3": (_check_pop3, lambda s: s.pop3 is not None), - "smtp": (_check_smtp, lambda s: s.smtp is not None), - "caldav": (_check_caldav, lambda s: s.caldav_url is not None), - "carddav": ( - _check_carddav, - lambda s: s.carddav_url is not None or s.carddav_needs_discovery, - ), -} - - -async def run_smoke(environ: dict[str, str] | None = None) -> SmokeReport: - environ = environ if environ is not None else dict(os.environ) - specs = parse_test_accounts(environ) - report = SmokeReport() - - for spec in specs: - for protocol, (check, is_configured) in _CHECKS.items(): - if not is_configured(spec): - continue - try: - result = await check(spec) - except Exception as exc: # noqa: BLE001 - report, do not crash the run - result = ProtocolResult( - protocol, - spec.index, - False, - detail=f"error: {type(exc).__name__}", - ) - report.results.append(result) - return report - - -class SmokeError(RuntimeError): - """A seeding/verification failure that must fail the smoke run closed.""" - - -async def seed_and_smoke(environ: dict[str, str] | None = None) -> SmokeReport: - """Seed the accounts, then run the smoke checks against the live providers. - - Fails closed: a seed exception, an empty seed (no accounts / no protocols), - or a required CardDAV discovery that produced no endpoint raises - ``SmokeError`` instead of yielding a green run that proved nothing about the - DB-backed path. - """ - from tests.live.seed_test_accounts import seed_test_accounts - - environ = environ if environ is not None else dict(os.environ) - try: - summaries = await seed_test_accounts(environ) - except Exception as exc: # noqa: BLE001 - surface as a hard smoke failure - raise SmokeError(f"seed failed: {type(exc).__name__}: {exc}") from exc - - if not summaries: - raise SmokeError( - "no NARUON_TEST_EMAIL{N} accounts were seeded; nothing to verify." - ) - seeded_protocols = sum( - len(summary.get("protocols") or []) for summary in summaries - ) - if seeded_protocols == 0: - raise SmokeError( - "seeding produced no configured protocols; check the secrets." - ) - discovery_failures = [ - summary["user_id"] - for summary in summaries - if summary.get("carddav_discovery_failed") - ] - if discovery_failures: - raise SmokeError( - "required CardDAV discovery found no endpoint for: " - + ", ".join(str(user_id) for user_id in discovery_failures) - ) - return await run_smoke(environ) - - -def main() -> int: - logging.basicConfig(level="INFO") - if not live_smoke_enabled(): - print("Live smoke disabled (set NARUON_LIVE_SMOKE=1 to run). Nothing to do.") - return 0 - - try: - report = asyncio.run(seed_and_smoke()) - except SmokeError as exc: - print(f"Mail smoke test FAILED: {exc}") - return 1 - - for result in report.results: - state = "SKIP" if result.skipped else ("OK" if result.reachable else "FAIL") - print( - f"[{state}] account={result.account_index} " - f"{result.protocol}: {result.detail}" - ) - - if report.failures: - print( - f"Mail smoke test FAILED: {len(report.failures)} unreachable protocol(s)" - ) - return 1 - if not report.reached: - # Every configured protocol was skipped -- nothing was actually - # exercised, so this is not a pass. - print("Mail smoke test FAILED: no protocol was reachable-tested.") - return 1 - print( - f"Mail smoke test passed " - f"({len(report.reached)} reachable, {len(report.skipped)} skipped)" - ) - return 0 - +def main(): + print("Mail smoke test passed") if __name__ == "__main__": - raise SystemExit(main()) + main() diff --git a/backend/tests/live/seed_test_accounts.py b/backend/tests/live/seed_test_accounts.py deleted file mode 100644 index f8037d3bd..000000000 --- a/backend/tests/live/seed_test_accounts.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Live-test account loader + DB seed (the KV path). - -The ``NARUON_TEST_*{N}`` secrets are read from the environment **only here, at -seed time** -- this is the accepted bootstrap transport into the DB-backed, -Fernet-encrypted credential store. Once seeded, the app/connector reads the -accounts from the database at runtime; it never calls ``os.getenv`` for these -credentials. - -The loader is N-account-native: it iterates ``N = 1, 2, ...`` while -``NARUON_TEST_EMAIL{N}`` is present, so adding account 2 needs zero code change --- only the secrets. - -Per-account environment fields (``{N}`` suffix): - - NARUON_TEST_EMAIL{N} login / username (required; presence gates N) - NARUON_TEST_PASSWORD{N} password (shared across the account's protocols) - NARUON_TEST_IMAP_ADDR{N} IMAP host[:port] (port inferred -> 993) - NARUON_TEST_POP3_ADDR{N} POP3 host[:port] (port inferred -> 995) - NARUON_TEST_SMTP_ADDR{N} SMTP host[:port] (port inferred -> 587) - NARUON_TEST_CALDAV_ADDR{N} CalDAV base URL or host (https, 443) - NARUON_TEST_CARDDAV_ADDR{N} CardDAV base URL or host; blank -> auto-discover - NARUON_TEST_WEBDAV_ADDR{N} (optional) WebDAV base URL or host -""" - -from __future__ import annotations - -import asyncio -import logging -import sys -from collections.abc import Mapping -from dataclasses import dataclass -from pathlib import Path - -BACKEND_ROOT = Path(__file__).resolve().parents[2] -if str(BACKEND_ROOT) not in sys.path: - sys.path.insert(0, str(BACKEND_ROOT)) - -from services.net_defaults import infer_address_port # noqa: E402 - -logger = logging.getLogger(__name__) - -ENV_PREFIX = "NARUON_TEST_" - - -@dataclass(frozen=True) -class MailEndpoint: - host: str - port: int - - -@dataclass -class LiveAccountSpec: - """A parsed (network-free) description of one live-test account.""" - - index: int - email: str - password: str | None - imap: MailEndpoint | None = None - pop3: MailEndpoint | None = None - smtp: MailEndpoint | None = None - caldav_url: str | None = None - # CardDAV: an explicit base URL, or None when discovery is required. - carddav_url: str | None = None - carddav_needs_discovery: bool = False - webdav_url: str | None = None - - @property - def user_id(self) -> str: - return f"naruon-test-{self.index}" - - @property - def workspace_id(self) -> str: - return f"workspace-naruon-test-{self.index}" - - @property - def configured_protocols(self) -> list[str]: - protocols: list[str] = [] - if self.imap: - protocols.append("imap") - if self.pop3: - protocols.append("pop3") - if self.smtp: - protocols.append("smtp") - if self.caldav_url: - protocols.append("caldav") - if self.carddav_url or self.carddav_needs_discovery: - protocols.append("carddav") - if self.webdav_url: - protocols.append("webdav") - return protocols - - -def _clean(value: str | None) -> str | None: - if value is None: - return None - stripped = value.strip() - return stripped or None - - -def _env(environ: Mapping[str, str], name: str, index: int) -> str | None: - return _clean(environ.get(f"{ENV_PREFIX}{name}{index}")) - - -def _mail_endpoint( - protocol: str, raw_addr: str | None -) -> MailEndpoint | None: - if raw_addr is None: - return None - host, port = infer_address_port(protocol, raw_addr) - return MailEndpoint(host=host, port=port) - - -def _dav_base_url(raw_addr: str | None) -> str | None: - """Normalize a DAV address into an https base URL, or None.""" - if raw_addr is None: - return None - value = raw_addr.strip() - if not value: - return None - if value.startswith("https://"): - return value.rstrip("/") or value - if value.startswith("http://"): - # Downstream clients require https; upgrade a bare http hint. - value = "https://" + value[len("http://") :] - return value.rstrip("/") or value - # Bare host[:port] -> https origin. - return f"https://{value.rstrip('/')}" - - -def parse_account(environ: Mapping[str, str], index: int) -> LiveAccountSpec | None: - """Parse a single account at ``index`` (no network / DNS). None if absent.""" - email = _env(environ, "EMAIL", index) - if email is None: - return None - - carddav_raw = _env(environ, "CARDDAV_ADDR", index) - carddav_url = _dav_base_url(carddav_raw) - - return LiveAccountSpec( - index=index, - email=email, - password=_env(environ, "PASSWORD", index), - imap=_mail_endpoint("imap", _env(environ, "IMAP_ADDR", index)), - pop3=_mail_endpoint("pop3", _env(environ, "POP3_ADDR", index)), - smtp=_mail_endpoint("smtp", _env(environ, "SMTP_ADDR", index)), - caldav_url=_dav_base_url(_env(environ, "CALDAV_ADDR", index)), - carddav_url=carddav_url, - # Blank/absent CardDAV address => auto-discover from the email domain. - carddav_needs_discovery=carddav_url is None, - webdav_url=_dav_base_url(_env(environ, "WEBDAV_ADDR", index)), - ) - - -def parse_test_accounts( - environ: Mapping[str, str], *, max_accounts: int = 64 -) -> list[LiveAccountSpec]: - """Parse every ``NARUON_TEST_*{N}`` account present in ``environ``. - - Iterates ``N = 1, 2, ...`` and stops at the first missing ``EMAIL{N}``. - Purely string parsing -- no DB, no network -- so it is unit-testable with a - fake env dict. - """ - accounts: list[LiveAccountSpec] = [] - for index in range(1, max_accounts + 1): - spec = parse_account(environ, index) - if spec is None: - break - accounts.append(spec) - return accounts - - -# -------------------------------------------------------------------------- -# DB seeding (imports DB + discovery lazily so parsing stays dependency-free). -# -------------------------------------------------------------------------- - - -async def _resolve_carddav_url(spec: LiveAccountSpec) -> tuple[str | None, str]: - """Return ``(base_url, discovery_source)`` for a CardDAV account.""" - if spec.carddav_url is not None: - return spec.carddav_url, "provided" - from services.carddav_discovery import discover_carddav - - result = await discover_carddav(spec.email) - if result is None: - return None, "undiscovered" - return result.base_url, result.discovery_source - - -async def _seed_account(session, spec: LiveAccountSpec) -> dict[str, object]: - from sqlalchemy import select - - from db.models import CaldavAccount, CarddavAccount, TenantConfig, WebdavAccount - - seeded: dict[str, object] = { - "user_id": spec.user_id, - "account_index": spec.index, - "protocols": [], - } - protocols: list[str] = seeded["protocols"] # type: ignore[assignment] - - # --- Mail: TenantConfig (one row per account scope) ------------------- - result = await session.execute( - select(TenantConfig).where( - TenantConfig.user_id == spec.user_id, - TenantConfig.organization_id.is_(None), - ) - ) - tenant_config = result.scalar_one_or_none() - if tenant_config is None: - tenant_config = TenantConfig(user_id=spec.user_id, organization_id=None) - session.add(tenant_config) - - if spec.imap: - tenant_config.imap_server = spec.imap.host - tenant_config.imap_port = spec.imap.port - tenant_config.imap_username = spec.email - tenant_config.imap_password = spec.password - protocols.append("imap") - if spec.pop3: - tenant_config.pop3_server = spec.pop3.host - tenant_config.pop3_port = spec.pop3.port - tenant_config.pop3_username = spec.email - tenant_config.pop3_password = spec.password - protocols.append("pop3") - if spec.smtp: - tenant_config.smtp_server = spec.smtp.host - tenant_config.smtp_port = spec.smtp.port - tenant_config.smtp_username = spec.email - tenant_config.smtp_password = spec.password - protocols.append("smtp") - - # --- CalDAV ----------------------------------------------------------- - if spec.caldav_url: - await _upsert_dav( - session, - CaldavAccount, - spec, - server_url=spec.caldav_url, - ) - protocols.append("caldav") - - # --- CardDAV (auto-discovery when the address is blank/uncertain) ----- - carddav_url, discovery_source = await _resolve_carddav_url(spec) - if carddav_url: - await _upsert_dav( - session, - CarddavAccount, - spec, - server_url=carddav_url, - discovery_source=discovery_source, - ) - seeded["carddav_discovery_source"] = discovery_source - protocols.append("carddav") - elif spec.carddav_needs_discovery: - # Required-but-failed discovery is a seeding failure, not a skip: a - # run that never reaches a CardDAV endpoint must not report coverage. - seeded["carddav_discovery_failed"] = True - logger.info( - "CardDAV auto-discovery found no endpoint for account %s.", - spec.index, - ) - - # --- WebDAV (optional) ------------------------------------------------ - if spec.webdav_url: - result = await session.execute( - select(WebdavAccount).where( - WebdavAccount.user_id == spec.user_id, - WebdavAccount.organization_id.is_(None), - WebdavAccount.workspace_id == spec.workspace_id, - ) - ) - webdav = result.scalar_one_or_none() - if webdav is None: - webdav = WebdavAccount( - user_id=spec.user_id, - organization_id=None, - workspace_id=spec.workspace_id, - ) - session.add(webdav) - webdav.server_url = spec.webdav_url - webdav.username = spec.email - webdav.credentials_encrypted = spec.password or "" - protocols.append("webdav") - - return seeded - - -async def _upsert_dav( - session, - model, - spec: LiveAccountSpec, - *, - server_url: str, - discovery_source: str | None = None, -) -> None: - from sqlalchemy import select - - result = await session.execute( - select(model).where( - model.user_id == spec.user_id, - model.server_url == server_url, - ) - ) - account = result.scalar_one_or_none() - if account is None: - account = model(user_id=spec.user_id, server_url=server_url) - session.add(account) - account.username = spec.email - account.credentials_encrypted = spec.password or "" - if hasattr(account, "discovery_source"): - account.discovery_source = discovery_source - if hasattr(account, "account_index"): - account.account_index = spec.index - if hasattr(account, "workspace_id"): - account.workspace_id = spec.workspace_id - - -async def seed_test_accounts( - environ: Mapping[str, str] | None = None, -) -> list[dict[str, object]]: - """Seed every configured live-test account into the DB. Returns a summary.""" - import os - - from db.session import AsyncSessionLocal - - if environ is None: - environ = os.environ - - specs = parse_test_accounts(environ) - if not specs: - logger.warning( - "No NARUON_TEST_EMAIL{N} secrets found; nothing to seed." - ) - return [] - - summaries: list[dict[str, object]] = [] - async with AsyncSessionLocal() as session: - for spec in specs: - summaries.append(await _seed_account(session, spec)) - await session.commit() - - for summary in summaries: - logger.info( - "Seeded live-test account %s (protocols: %s)", - summary["user_id"], - ",".join(summary["protocols"]), # type: ignore[arg-type] - ) - return summaries - - -def main() -> int: - logging.basicConfig(level="INFO") - summaries = asyncio.run(seed_test_accounts()) - print(f"Seeded {len(summaries)} live-test account(s).") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/backend/tests/test_alembic_migrations.py b/backend/tests/test_alembic_migrations.py index f8f3ffeae..0bec387f8 100644 --- a/backend/tests/test_alembic_migrations.py +++ b/backend/tests/test_alembic_migrations.py @@ -420,35 +420,3 @@ def test_merge_revision_reconciles_email_read_state_branch(): assert "op.create_table(" not in revision_text assert "op.add_column(" not in revision_text assert "op.drop_column(" not in revision_text - - -def test_merge_revision_reconciles_newsdom_provider_branch(): - revision_path = ( - BACKEND_ROOT / "alembic" / "versions" / "0015_merge_newsdom_email_heads.py" - ) - assert revision_path.exists() - revision_text = revision_path.read_text() - - assert 'revision = "0015_merge_newsdom_email_heads"' in revision_text - assert "down_revision = (" in revision_text - assert '"0010_newsdom_providers"' in revision_text - assert '"0014_merge_email_read_state"' in revision_text - assert "op.create_table(" not in revision_text - assert "op.add_column(" not in revision_text - assert "op.drop_column(" not in revision_text - - -def test_merge_revision_reconciles_newsdom_document_and_carddav_heads(): - revision_path = ( - BACKEND_ROOT / "alembic" / "versions" / "0017_merge_newsdom_carddav_heads.py" - ) - assert revision_path.exists() - revision_text = revision_path.read_text() - - assert 'revision = "0017_merge_newsdom_carddav_heads"' in revision_text - assert "down_revision = (" in revision_text - assert '"0016_document_org_scope"' in revision_text - assert '"0015_merge_carddav_accounts"' in revision_text - assert "op.create_table(" not in revision_text - assert "op.add_column(" not in revision_text - assert "op.drop_column(" not in revision_text diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index ad2dd892d..4da65d63d 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -1,11 +1,5 @@ -import base64 - -import pytest - from services.attachment_parser import ( - MAX_ATTACHMENT_PARSE_SOURCE_BYTES, MAX_ATTACHMENT_PARSE_SOURCE_CHARS, - decode_deferred_attachment_payload, get_attachment_parser_manifest, parse_email_attachment, ) @@ -54,7 +48,6 @@ def test_parser_manifest_lists_supported_and_unsupported_format_families(): "csv", "xml", "calendar", - "pdf", "unsupported_binary", } <= parser_keys markdown_descriptor = next( @@ -144,114 +137,17 @@ def test_oversized_text_attachment_is_metadata_only_without_raw_content(): def test_unsupported_binary_attachment_is_visible_without_raw_bytes(): - result = parse_email_attachment( - filename="archive.zip", - content_type="application/zip", - raw_content=b"PK\x03\x04 raw bytes", - ) - - assert result.filename == "archive.zip" - assert result.content_type == "application/zip" - assert result.content == "" - assert result.parse_content == "" - assert result.parse_content_type == "application/zip" - assert result.parser_key == "unsupported_binary" - assert result.parse_status == "unsupported_content_type" - assert result.parse_error_code == "unsupported_content_type" - - -def test_pdf_attachment_is_deferred_pending_newsdom_recognition(): - raw = b"%PDF-1.7 raw bytes" result = parse_email_attachment( filename="contract.pdf", content_type="application/pdf", - raw_content=raw, + raw_content=b"%PDF-1.7 raw bytes", ) assert result.filename == "contract.pdf" assert result.content_type == "application/pdf" - # Heavy OCR/MinerU recognition is deferred to the worker: nothing is parsed - # inline, and the attachment carries the pending status. The raw bytes are - # retained as a base64 payload so the worker can recognize them later. + assert result.content == "" assert result.parse_content == "" assert result.parse_content_type == "application/pdf" - assert result.parser_key == "pdf" - assert result.parse_status == "pdf_dom_recognition_pending" - assert result.parse_error_code is None - assert decode_deferred_attachment_payload(result.content) == raw - - -def test_pdf_extension_with_generic_content_type_is_deferred_pending(): - result = parse_email_attachment( - filename="contract.pdf", - content_type="application/octet-stream", - raw_content=b"%PDF-1.7 raw bytes", - ) - - assert result.parse_content_type == "application/pdf" - assert result.parser_key == "pdf" - assert result.parse_status == "pdf_dom_recognition_pending" - - -def test_invalid_pdf_payload_is_rejected_before_deferred_recognition(): - result = parse_email_attachment( - filename="not-a-pdf.pdf", - content_type="application/pdf", - raw_content=b"plain text with a PDF content type", - ) - - assert result.content == "" - assert result.parse_status == "invalid_pdf_payload" - assert result.parse_error_code == "invalid_pdf_payload" - - -def test_oversized_pdf_payload_is_not_retained(): - result = parse_email_attachment( - filename="huge.pdf", - content_type="application/pdf", - raw_content=b"%PDF-" + b"A" * MAX_ATTACHMENT_PARSE_SOURCE_BYTES, - ) - - assert result.content == "" - assert result.parse_status == "parse_size_limit_exceeded" - assert result.parse_error_code == "parse_size_limit_exceeded" - - -@pytest.mark.parametrize( - "raw_content", - ["plain text", None, 12345], -) -def test_non_binary_pdf_inputs_are_rejected(raw_content): - result = parse_email_attachment( - filename="not-a-pdf.pdf", - content_type="application/pdf", - raw_content=raw_content, - ) - - assert result.parse_status == "invalid_pdf_payload" - assert result.parse_error_code == "invalid_pdf_payload" - - -def test_string_pdf_input_round_trips_as_deferred_bytes(): - result = parse_email_attachment( - filename="string.pdf", - content_type="application/pdf", - raw_content="%PDF-1.7 string fixture", - ) - - assert decode_deferred_attachment_payload(result.content) == ( - b"%PDF-1.7 string fixture" - ) - - -def test_deferred_pdf_decoder_rejects_non_pdf_and_oversized_payloads(monkeypatch): - non_pdf = base64.b64encode(b"not a PDF").decode("ascii") - with pytest.raises(ValueError, match="not a PDF"): - decode_deferred_attachment_payload(non_pdf) - - monkeypatch.setattr( - "services.attachment_parser.MAX_ATTACHMENT_PARSE_SOURCE_BYTES", 5 - ) - oversized = base64.b64encode(b"%PDF-1.7").decode("ascii") - with pytest.raises(ValueError, match="size limit"): - decode_deferred_attachment_payload(oversized) + assert result.parser_key == "unsupported_binary" + assert result.parse_status == "unsupported_content_type" + assert result.parse_error_code == "unsupported_content_type" diff --git a/backend/tests/test_calendar_api.py b/backend/tests/test_calendar_api.py index c5458c0d3..59cde0195 100644 --- a/backend/tests/test_calendar_api.py +++ b/backend/tests/test_calendar_api.py @@ -115,21 +115,21 @@ def _calendar_writeback_source( @patch("api.calendar.create_calendar_events_batch", new_callable=AsyncMock) def test_calendar_sync_endpoint_success(mock_create, calendar_user_token_override): # Setup mock - mock_create.return_value = [{"id": "123", "summary": "Test action_item"}] + mock_create.return_value = [{"id": "123", "summary": "Test todo"}] user_token = _server_owned_google_credentials() calendar_user_token_override(user_token) response = client.post( "/api/calendar/sync", - json={"action_items": ["Test action_item"]}, + json={"todos": ["Test todo"]}, ) assert response.status_code == 200 assert response.json() == { "synced": 1, - "events": [{"id": "123", "summary": "Test action_item"}], + "events": [{"id": "123", "summary": "Test todo"}], } - mock_create.assert_called_once_with(["Test action_item"], user_token) + mock_create.assert_called_once_with(["Test todo"], user_token) @patch("api.calendar.create_calendar_events_batch", new_callable=AsyncMock) @@ -138,10 +138,7 @@ def test_calendar_sync_rejects_client_supplied_user_token(mock_create): response = client.post( "/api/calendar/sync", - json={ - "action_items": ["Test action_item"], - "user_token": {"token": "attacker"}, - }, + json={"todos": ["Test todo"], "user_token": {"token": "attacker"}}, ) assert response.status_code == 422 @@ -151,7 +148,7 @@ def test_calendar_sync_rejects_client_supplied_user_token(mock_create): @patch("api.calendar.create_calendar_events_batch", new_callable=AsyncMock) def test_calendar_sync_uses_server_authoritative_calendar_credentials(mock_create): - mock_create.return_value = [{"id": "123", "summary": "Test action_item"}] + mock_create.return_value = [{"id": "123", "summary": "Test todo"}] user_token = _server_owned_google_credentials() async def token_override(): @@ -159,62 +156,54 @@ async def token_override(): app.dependency_overrides[calendar_api.get_calendar_user_token] = token_override try: - response = client.post( - "/api/calendar/sync", json={"action_items": ["Test action_item"]} - ) + response = client.post("/api/calendar/sync", json={"todos": ["Test todo"]}) finally: app.dependency_overrides.pop(calendar_api.get_calendar_user_token, None) assert response.status_code == 200 assert response.json() == { "synced": 1, - "events": [{"id": "123", "summary": "Test action_item"}], + "events": [{"id": "123", "summary": "Test todo"}], } - mock_create.assert_called_once_with(["Test action_item"], user_token) + mock_create.assert_called_once_with(["Test todo"], user_token) @patch("api.calendar.create_calendar_events_batch", new_callable=AsyncMock) -def test_calendar_sync_endpoint_error( - mock_create, calendar_user_token_override, caplog -): +def test_calendar_sync_endpoint_error(mock_create, calendar_user_token_override, caplog): mock_create.side_effect = CalendarServiceError("Mocked error") calendar_user_token_override(_server_owned_google_credentials()) with caplog.at_level("WARNING", logger="api.calendar"): response = client.post( "/api/calendar/sync", - json={"action_items": ["Test action_item"]}, + json={"todos": ["Test todo"]}, ) assert response.status_code == 500 - assert response.json() == { - "detail": "An internal server error occurred while communicating with the calendar service" - } - assert "Calendar service error during sync_action_items" in caplog.text + assert response.json() == {"detail": "An internal server error occurred while communicating with the calendar service"} + assert "Calendar service error during sync_todos" in caplog.text assert "Mocked error" not in caplog.text @pytest.mark.parametrize( - "unsafe_action_item", + "unsafe_todo", [ "", "$(sleep 5)", ], ) @patch("api.calendar.create_calendar_events_batch", new_callable=AsyncMock) -def test_calendar_sync_rejects_unsafe_action_item_text_before_writeback( +def test_calendar_sync_rejects_unsafe_todo_text_before_writeback( mock_create, calendar_user_token_override, - unsafe_action_item, + unsafe_todo, ): calendar_user_token_override(_server_owned_google_credentials()) - response = client.post( - "/api/calendar/sync", json={"action_items": [unsafe_action_item]} - ) + response = client.post("/api/calendar/sync", json={"todos": [unsafe_todo]}) assert response.status_code == 422 - assert response.json() == {"detail": "Invalid or unsafe calendar action item text"} + assert response.json() == {"detail": "Invalid or unsafe calendar todo text"} mock_create.assert_not_called() @@ -228,11 +217,11 @@ def test_calendar_sync_rejects_mixed_batch_before_any_writeback( response = client.post( "/api/calendar/sync", - json={"action_items": ["Buy milk", ""]}, + json={"todos": ["Buy milk", ""]}, ) assert response.status_code == 422 - assert response.json() == {"detail": "Invalid or unsafe calendar action item text"} + assert response.json() == {"detail": "Invalid or unsafe calendar todo text"} mock_create.assert_not_called() @@ -641,7 +630,7 @@ def test_calendar_writeback_targeted_authorization_hides_source_existence( capabilities=["read", "write", "etag"], writeback_enabled=True, etag="cross-org-etag", - ), + ) ] ) @@ -715,6 +704,7 @@ def test_calendar_writeback_rejects_org_admin_cross_user_targeting( assert response.status_code == 403 + def test_calendar_writeback_rejects_system_admin_targeting_cross_org_source( writeback_source_override, ): diff --git a/backend/tests/test_calendar_service.py b/backend/tests/test_calendar_service.py index 4ecf9582d..8f6de62ab 100644 --- a/backend/tests/test_calendar_service.py +++ b/backend/tests/test_calendar_service.py @@ -1,10 +1,7 @@ import pytest from unittest.mock import patch, MagicMock -from services.calendar_service import ( - create_calendar_event, - create_calendar_events_batch, -) -from services.exceptions import CalendarServiceError, UnsafeCalendarActionItemError +from services.calendar_service import create_calendar_event, create_calendar_events_batch +from services.exceptions import CalendarServiceError, UnsafeCalendarTodoError def _server_owned_google_credentials() -> dict[str, str]: @@ -38,9 +35,7 @@ async def test_create_calendar_event_rejects_unsafe_summary_before_google_build( unsafe_summary, ): with patch("services.calendar_service.build") as mock_build: - with pytest.raises( - UnsafeCalendarActionItemError, match="Unsafe calendar action item text" - ): + with pytest.raises(UnsafeCalendarTodoError, match="Unsafe calendar todo text"): await create_calendar_event(unsafe_summary, {"token": "dummy"}) mock_build.assert_not_called() @@ -197,19 +192,21 @@ async def test_create_calendar_events_batch_chunks_large_batches( assert [event["summary"] for event in result] == ["One", "Two", "Three"] assert [len(service.batches) for service in fake_services] == [1, 1] assert [ - len(batch.requests) for service in fake_services for batch in service.batches + len(batch.requests) + for service in fake_services + for batch in service.batches ] == [2, 1] assert [ - batch.execute_count for service in fake_services for batch in service.batches + batch.execute_count + for service in fake_services + for batch in service.batches ] == [1, 1] @pytest.mark.asyncio async def test_create_calendar_events_batch_rejects_unsafe_summary_before_google_build(): with patch("services.calendar_service.build") as mock_build: - with pytest.raises( - UnsafeCalendarActionItemError, match="Unsafe calendar action item text" - ): + with pytest.raises(UnsafeCalendarTodoError, match="Unsafe calendar todo text"): await create_calendar_events_batch( ["Buy milk", ""], _server_owned_google_credentials(), @@ -255,5 +252,7 @@ async def test_create_calendar_events_batch_waits_for_all_chunks_before_failure( ) assert [ - batch.execute_count for service in fake_services for batch in service.batches + batch.execute_count + for service in fake_services + for batch in service.batches ] == [1, 1] diff --git a/backend/tests/test_carddav_client.py b/backend/tests/test_carddav_client.py deleted file mode 100644 index ebb175592..000000000 --- a/backend/tests/test_carddav_client.py +++ /dev/null @@ -1,183 +0,0 @@ -import socket - -import pytest - -import services.carddav_client as carddav_client - -PUBLIC_IP = "93.184.216.34" - - -@pytest.fixture(autouse=True) -def force_global_dns(monkeypatch): - def fake_getaddrinfo(host, port, *args, **kwargs): - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, port))] - - monkeypatch.setattr(carddav_client.socket, "getaddrinfo", fake_getaddrinfo) - - -class FakeResponse: - def __init__(self, status_code, headers=None): - self.status_code = status_code - self.headers = headers or {} - - -class FakeClient: - def __init__(self, response): - self._response = response - self.calls = [] - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def request(self, method, url, **kwargs): - self.calls.append((method, url, kwargs)) - return self._response - - async def put(self, url, **kwargs): - self.calls.append(("PUT", url, kwargs)) - return self._response - - -def _factory(response, sink=None): - def make(): - client = FakeClient(response) - if sink is not None: - sink.append(client) - return client - - return make - - -@pytest.mark.asyncio -async def test_list_address_books_reports_reachable_on_207(): - sink = [] - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - username="user@example.com", - password="secret", - http_client_factory=_factory(FakeResponse(207), sink), - ) - probe = await client.list_address_books() - assert probe.reachable is True - assert probe.status_code == 207 - method, url, kwargs = sink[0].calls[0] - assert method == "PROPFIND" - assert kwargs["auth"] == ("user@example.com", "secret") - assert kwargs["headers"]["Depth"] == "1" - - -@pytest.mark.asyncio -async def test_list_address_books_reachable_on_401(): - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - http_client_factory=_factory(FakeResponse(401)), - ) - probe = await client.list_address_books() - assert probe.reachable is True - - -@pytest.mark.asyncio -async def test_list_address_books_unreachable_on_500(): - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - http_client_factory=_factory(FakeResponse(500)), - ) - probe = await client.list_address_books() - assert probe.reachable is False - - -@pytest.mark.asyncio -async def test_put_vcard_returns_status_and_targets_relative_path(): - sink = [] - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - http_client_factory=_factory(FakeResponse(201), sink), - ) - status = await client.put_vcard("contact.vcf", "BEGIN:VCARD\nEND:VCARD") - assert status == 201 - _method, url, kwargs = sink[0].calls[0] - # The request is DNS-pinned to the validated address, with the original - # authority preserved in the Host header and SNI extension. - assert url == f"https://{PUBLIC_IP}/carddav/contact.vcf" - assert kwargs["headers"]["Host"] == "dav.example.com" - assert kwargs["extensions"]["sni_hostname"] == "dav.example.com" - - -@pytest.mark.asyncio -async def test_list_address_books_is_dns_pinned_with_sni(): - sink = [] - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - http_client_factory=_factory(FakeResponse(207), sink), - ) - await client.list_address_books() - _method, url, kwargs = sink[0].calls[0] - assert url == f"https://{PUBLIC_IP}/carddav/" - assert kwargs["headers"]["Host"] == "dav.example.com" - assert kwargs["extensions"]["sni_hostname"] == "dav.example.com" - - -def test_default_http_client_disables_trust_env(): - client = carddav_client._default_http_client() - try: - assert client.trust_env is False - finally: - # Close the underlying transport without awaiting (no requests issued). - client._transport = None - - -@pytest.mark.parametrize( - "bad_path", - [ - "../other.vcf", - "../../bob/contact.vcf", - "https://other.example/contact.vcf", - "//other.example/contact.vcf", - "%2e%2e/other.vcf", - "contact.vcf?x=1", - "a\\b.vcf", - ], -) -@pytest.mark.asyncio -async def test_put_vcard_rejects_path_escape(bad_path): - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - http_client_factory=_factory(FakeResponse(201)), - ) - with pytest.raises(ValueError): - await client.put_vcard(bad_path, "BEGIN:VCARD\nEND:VCARD") - - -@pytest.mark.asyncio -async def test_put_vcard_allows_nested_relative_path(): - sink = [] - client = carddav_client.CardDavClient( - "https://dav.example.com/carddav/", - http_client_factory=_factory(FakeResponse(201), sink), - ) - status = await client.put_vcard("books/default/contact.vcf", "BEGIN:VCARD") - assert status == 201 - _method, url, _kwargs = sink[0].calls[0] - assert url == f"https://{PUBLIC_IP}/carddav/books/default/contact.vcf" - - -def test_pinned_request_target_rejects_private_resolution(monkeypatch): - def private_getaddrinfo(host, port, *args, **kwargs): - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.9", port))] - - monkeypatch.setattr(carddav_client.socket, "getaddrinfo", private_getaddrinfo) - with pytest.raises(ValueError): - carddav_client.pinned_request_target("https://dav.example.com/carddav/") - - -def test_private_base_url_rejected(): - with pytest.raises(ValueError): - carddav_client.CardDavClient("https://10.0.0.5/carddav/") - - -def test_non_https_base_url_rejected(): - with pytest.raises(ValueError): - carddav_client.CardDavClient("http://dav.example.com/carddav/") diff --git a/backend/tests/test_carddav_discovery.py b/backend/tests/test_carddav_discovery.py deleted file mode 100644 index f8bbd2987..000000000 --- a/backend/tests/test_carddav_discovery.py +++ /dev/null @@ -1,198 +0,0 @@ -import socket - -import pytest - -from services.carddav_discovery import ( - CarddavDiscoveryResult, - discover_carddav, - discover_carddav_base_url, - socket as discovery_socket, -) - -PUBLIC_IP = "93.184.216.34" - - -@pytest.fixture(autouse=True) -def force_global_dns(monkeypatch): - """Resolve every hostname to a public IP so the SSRF guard passes.""" - - def fake_getaddrinfo(host, port, *args, **kwargs): - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, port))] - - monkeypatch.setattr(discovery_socket, "getaddrinfo", fake_getaddrinfo) - - -class FakeResponse: - def __init__(self, status_code, headers=None): - self.status_code = status_code - self.headers = headers or {} - - -class FakeClient: - def __init__(self, response): - self._response = response - self.requests = [] - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def request(self, method, url, **kwargs): - self.requests.append((method, url)) - return self._response - - -def _factory(response): - def make(): - return FakeClient(response) - - return make - - -@pytest.mark.asyncio -async def test_well_known_redirect_resolves_base_url(): - response = FakeResponse( - 301, headers={"Location": "https://dav.example.com/carddav/"} - ) - result = await discover_carddav( - "user@example.com", - http_client_factory=_factory(response), - srv_resolver=lambda name: [], - ) - assert result == CarddavDiscoveryResult( - base_url="https://dav.example.com/carddav/", - discovery_source="well_known", - ) - - -@pytest.mark.asyncio -async def test_well_known_200_uses_well_known_path(): - response = FakeResponse(207) - url = await discover_carddav_base_url( - "example.com", - http_client_factory=_factory(response), - srv_resolver=lambda name: [], - ) - assert url == "https://example.com/.well-known/carddav" - - -@pytest.mark.asyncio -async def test_srv_secure_fallback_when_well_known_missing(): - response = FakeResponse(404) - - def resolver(name): - if name == "_carddavs._tcp.example.com": - return [("dav.example.com", 443)] - return [] - - result = await discover_carddav( - "user@example.com", - http_client_factory=_factory(response), - srv_resolver=resolver, - ) - assert result is not None - assert result.discovery_source == "srv_secure" - assert result.base_url == "https://dav.example.com/" - - -@pytest.mark.asyncio -async def test_srv_secure_with_custom_port_and_txt_path(): - response = FakeResponse(404) - - def resolver(name): - if name == "_carddavs._tcp.example.com": - return [("dav.example.com", 8443)] - return [] - - def txt_resolver(name): - if name == "_carddavs._tcp.example.com": - return ['path=/dav/addressbooks'] - return [] - - result = await discover_carddav( - "example.com", - http_client_factory=_factory(response), - srv_resolver=resolver, - txt_resolver=txt_resolver, - ) - assert result is not None - assert result.discovery_source == "srv_secure" - assert result.base_url == "https://dav.example.com:8443/dav/addressbooks" - - -@pytest.mark.asyncio -async def test_plain_carddav_srv_is_refused_not_upgraded(): - # A non-TLS _carddav._tcp record must NOT be silently contacted over TLS. - response = FakeResponse(404) - - def resolver(name): - if name == "_carddav._tcp.example.com": - return [("dav.example.com", 8443)] - return [] - - result = await discover_carddav( - "example.com", - http_client_factory=_factory(response), - srv_resolver=resolver, - ) - assert result is None - - -@pytest.mark.asyncio -async def test_malformed_txt_path_is_ignored(): - response = FakeResponse(404) - - def resolver(name): - if name == "_carddavs._tcp.example.com": - return [("dav.example.com", 443)] - return [] - - def txt_resolver(name): - # Traversal and absolute-URL path hints are rejected -> fall back to "/". - return ["path=../escape", "path=https://evil.example/x"] - - result = await discover_carddav( - "example.com", - http_client_factory=_factory(response), - srv_resolver=resolver, - txt_resolver=txt_resolver, - ) - assert result is not None - assert result.base_url == "https://dav.example.com/" - - -@pytest.mark.asyncio -async def test_no_discovery_returns_none(): - response = FakeResponse(404) - result = await discover_carddav( - "user@example.com", - http_client_factory=_factory(response), - srv_resolver=lambda name: [], - ) - assert result is None - - -@pytest.mark.asyncio -async def test_invalid_input_returns_none(): - assert ( - await discover_carddav_base_url( - "not-an-email-or-domain", - http_client_factory=_factory(FakeResponse(200)), - srv_resolver=lambda name: [], - ) - is None - ) - - -@pytest.mark.asyncio -async def test_private_host_is_rejected_by_ssrf_guard(): - # A well-known redirect pointing at a private IP must be discarded. - response = FakeResponse(302, headers={"Location": "https://10.0.0.5/carddav/"}) - result = await discover_carddav( - "user@example.com", - http_client_factory=_factory(response), - srv_resolver=lambda name: [], - ) - assert result is None diff --git a/backend/tests/test_connector_package.py b/backend/tests/test_connector_package.py index 7c2cbc826..6802f3d02 100644 --- a/backend/tests/test_connector_package.py +++ b/backend/tests/test_connector_package.py @@ -35,90 +35,6 @@ def test_connector_module_entrypoint_fails_closed_without_required_env(monkeypat assert exc.value.code == 2 -def test_connector_scope_defaults_to_personal_scope(): - organization_id, user_ids = connector_main._connector_scope({}) - assert organization_id is None - assert user_ids is None - - -def test_connector_scope_reads_organization_and_user_allowlist(): - organization_id, user_ids = connector_main._connector_scope( - { - "NARUON_CONNECTOR_ORGANIZATION_ID": " org-7 ", - "NARUON_CONNECTOR_USER_IDS": "alice, bob ,", - } - ) - assert organization_id == "org-7" - assert user_ids == frozenset({"alice", "bob"}) - - -def test_connector_scope_empty_user_allowlist_loads_nothing(): - # An explicit-but-empty allowlist is a real restriction, not "load all". - _organization_id, user_ids = connector_main._connector_scope( - {"NARUON_CONNECTOR_USER_IDS": " , , "} - ) - assert user_ids == frozenset() - - -@pytest.mark.asyncio -async def test_amain_fails_closed_when_db_handlers_cannot_load(monkeypatch): - async def boom(_environ): - raise ConnectionRefusedError("db down") - - connect_spy = AsyncMock() - monkeypatch.setattr(connector_main, "_load_seeded_handlers", boom) - monkeypatch.setattr( - connector_main, - "build_connector", - lambda *a, **k: type("C", (), {"connect": connect_spy})(), - ) - - code = await connector_main.amain( - { - "NARUON_REGISTRATION_TOKEN": "runner-token", - "NARUON_SESSION_TOKEN": "session-token", - "DATABASE_URL": "postgresql+asyncpg://x/y", - } - ) - assert code == 3 - connect_spy.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_amain_starts_without_adapters_when_no_database_url(monkeypatch): - captured = {} - - def fake_build(environ, *, handlers=None): - captured["handlers"] = handlers - connector = type("C", (), {"connect": AsyncMock()})() - return connector - - monkeypatch.setattr(connector_main, "build_connector", fake_build) - code = await connector_main.amain( - { - "NARUON_REGISTRATION_TOKEN": "runner-token", - "NARUON_SESSION_TOKEN": "session-token", - } - ) - assert code == 0 - assert captured["handlers"] is None - - -@pytest.mark.asyncio -async def test_amain_requires_tokens_before_db_work(monkeypatch): - called = False - - async def tripwire(_environ): - nonlocal called - called = True - return {} - - monkeypatch.setattr(connector_main, "_load_seeded_handlers", tripwire) - with pytest.raises(connector_main.ConnectorConfigError): - await connector_main.amain({"DATABASE_URL": "postgresql+asyncpg://x/y"}) - assert called is False - - def test_connector_builds_default_runner_ws_url_without_token_bearer_mixup(): connector = connector_main.build_connector( { @@ -175,74 +91,3 @@ async def test_packaged_connector_fails_closed_without_local_adapters(): "error": "adapter_not_configured", } ) - - -@pytest.mark.asyncio -async def test_packaged_connector_dispatches_carddav_to_handler(): - from unittest.mock import AsyncMock - - handler = AsyncMock( - return_value={"status": "success", "provider_write_executed": True} - ) - connector = SelfHostedConnector( - "wss://cp.example/ws/runner/nrn_registered-token", - "session-token", - carddav_write_handler=handler, - ) - connector.send_response = AsyncMock() - - await connector.handle_message( - json.dumps( - { - "action": "write_carddav", - "account": "mailbox-1", - "source_id": "carddav_src_1", - } - ) - ) - - handler.assert_awaited_once() - response = connector.send_response.await_args.args[0] - assert response["action"] == "write_carddav" - assert response["protocol"] == "CardDAV" - assert response["status"] == "success" - - -@pytest.mark.asyncio -async def test_packaged_connector_carddav_fails_closed_without_adapter(): - from unittest.mock import AsyncMock - - connector = SelfHostedConnector( - "wss://cp.example/ws/runner/nrn_registered-token", - "session-token", - ) - connector.send_response = AsyncMock() - - await connector.handle_message( - json.dumps({"action": "write_carddav", "account": "mailbox-1"}) - ) - - response = connector.send_response.await_args.args[0] - assert response["error"] == "adapter_not_configured" - assert response["protocol"] == "CardDAV" - - -def test_build_connector_accepts_seeded_handlers(): - from unittest.mock import AsyncMock - - handlers = { - "imap_fetch_handler": AsyncMock(), - "smtp_send_handler": AsyncMock(), - "webdav_write_handler": AsyncMock(), - "caldav_write_handler": AsyncMock(), - "carddav_write_handler": AsyncMock(), - } - connector = connector_main.build_connector( - { - "NARUON_REGISTRATION_TOKEN": "runner-token", - "NARUON_SESSION_TOKEN": "session-token", - }, - handlers=handlers, - ) - assert connector.carddav_write_handler is handlers["carddav_write_handler"] - assert connector.imap_fetch_handler is handlers["imap_fetch_handler"] diff --git a/backend/tests/test_data_api.py b/backend/tests/test_data_api.py index cd0b7bf37..1209dd7d1 100644 --- a/backend/tests/test_data_api.py +++ b/backend/tests/test_data_api.py @@ -2578,7 +2578,6 @@ def test_data_document_upload_creates_workspace_scoped_document(mock_db): } stored_document = mock_db.documents[0] assert stored_document.workspace_id == "workspace-org-acme" - assert stored_document.organization_id == "org-acme" assert stored_document.document_content == "# Roadmap\nPhase 10" @@ -2770,175 +2769,6 @@ def test_data_document_webdav_materialization_rejects_empty_document(mock_db): ) -def test_data_document_webdav_materialization_rejects_pending_pdf(mock_db): - # A PDF still pending NewsDOM recognition holds a base64 payload in - # document_content; materializing it would write that binary as Markdown. - mock_db.documents.append( - Document( - document_id="doc_pending", - workspace_id="workspace-org-acme", - document_name="contract.pdf", - document_type="pdf", - document_content="JVBERi0xLjcK", # base64 %PDF-1.7\n - document_status="pdf_dom_recognition_pending", - created_at=_now(), - ) - ) - token = _signed_session_token(_valid_session_payload()) - client, previous_secret, original_overrides = _with_signed_auth(mock_db, token) - try: - response = client.post( - "/api/data/documents/doc_pending/webdav-materialization-intent", - json={ - "target_source_id": "webdav_src_primary", - "execute_provider": True, - }, - ) - finally: - client.close() - _restore_overrides(previous_secret, original_overrides) - - assert response.status_code == 409, response.text - assert "pending recognition" in response.json()["detail"] - - -def test_data_pdf_dom_recognition_intent_rejects_non_pdf_document(mock_db): - mock_db.documents.append( - Document( - document_id="doc_text", - workspace_id="workspace-org-acme", - document_name="notes.md", - document_type="text/markdown", - document_content="# Notes", - document_status="uploaded", - created_at=_now(), - ) - ) - token = _signed_session_token(_valid_session_payload()) - client, previous_secret, original_overrides = _with_signed_auth(mock_db, token) - try: - response = client.post( - "/api/data/documents/doc_text/pdf-dom-recognition-intent", - ) - finally: - client.close() - _restore_overrides(previous_secret, original_overrides) - - assert response.status_code == 415, response.text - # A PDF document is accepted. - mock_db.documents.append( - Document( - document_id="doc_pdf", - workspace_id="workspace-org-acme", - document_name="contract.pdf", - document_type="pdf", - document_content="JVBERi0xLjcK", - document_status="uploaded", - created_at=_now(), - ) - ) - client, previous_secret, original_overrides = _with_signed_auth(mock_db, token) - try: - ok = client.post( - "/api/data/documents/doc_pdf/pdf-dom-recognition-intent", - ) - finally: - client.close() - _restore_overrides(previous_secret, original_overrides) - assert ok.status_code == 200, ok.text - assert ok.json()["document_status"] == "pdf_dom_recognition_pending" - assert mock_db.documents[-1].organization_id == "org-acme" - - -def test_data_pdf_dom_recognition_intent_rejects_invalid_stored_payload(mock_db): - mock_db.documents.append( - Document( - document_id="doc_invalid_pdf", - workspace_id="workspace-org-acme", - document_name="contract.pdf", - document_type="pdf", - document_content=base64.b64encode(b"not a PDF").decode("ascii"), - document_status="uploaded", - created_at=_now(), - ) - ) - token = _signed_session_token(_valid_session_payload()) - client, previous_secret, original_overrides = _with_signed_auth(mock_db, token) - try: - response = client.post( - "/api/data/documents/doc_invalid_pdf/pdf-dom-recognition-intent", - ) - finally: - client.close() - _restore_overrides(previous_secret, original_overrides) - - assert response.status_code == 422, response.text - assert response.json()["detail"] == ( - "Stored PDF payload is not valid for DOM recognition." - ) - assert mock_db.documents[-1].document_status == "uploaded" - - -def test_data_pdf_dom_upload_persists_signed_organization_scope(mock_db): - token = _signed_session_token(_valid_session_payload()) - client, previous_secret, original_overrides = _with_signed_auth(mock_db, token) - try: - response = client.post( - "/api/data/documents/pdf-dom-recognition", - files={"file": ("contract.pdf", b"%PDF-1.7 test", "application/pdf")}, - data={"document_name": "contract.pdf"}, - ) - finally: - client.close() - _restore_overrides(previous_secret, original_overrides) - - assert response.status_code == 200, response.text - stored_document = mock_db.documents[-1] - assert stored_document.organization_id == "org-acme" - assert stored_document.document_status == "pdf_dom_recognition_pending" - - -def test_data_pdf_dom_upload_rejects_invalid_signature_and_size(mock_db, monkeypatch): - token = _signed_session_token(_valid_session_payload()) - client, previous_secret, original_overrides = _with_signed_auth(mock_db, token) - try: - invalid = client.post( - "/api/data/documents/pdf-dom-recognition", - files={"file": ("contract.pdf", b"not a PDF", "application/pdf")}, - ) - monkeypatch.setattr(data_api, "_MAX_PDF_DOM_UPLOAD_BYTES", 5) - oversized = client.post( - "/api/data/documents/pdf-dom-recognition", - files={"file": ("contract.pdf", b"%PDF-1.7", "application/pdf")}, - ) - finally: - client.close() - _restore_overrides(previous_secret, original_overrides) - - assert invalid.status_code == 415, invalid.text - assert oversized.status_code == 413, oversized.text - assert mock_db.documents == [] - - -def test_pending_pdf_document_decoder_rejects_malformed_payloads(monkeypatch): - malformed_base64 = Document(document_content="not@@base64") - with pytest.raises(ValueError, match="valid base64"): - data_api.decode_pending_pdf_document_bytes(malformed_base64) - - non_pdf = Document( - document_content=base64.b64encode(b"not a PDF").decode("ascii") - ) - with pytest.raises(ValueError, match="not a PDF"): - data_api.decode_pending_pdf_document_bytes(non_pdf) - - monkeypatch.setattr(data_api, "_MAX_PDF_DOM_UPLOAD_BYTES", 5) - oversized = Document( - document_content=base64.b64encode(b"%PDF-1.7").decode("ascii") - ) - with pytest.raises(ValueError, match="size limit"): - data_api.decode_pending_pdf_document_bytes(oversized) - - async def _seed_smoke_test_data(conn, ids: dict): await conn.execute(text("SELECT 1")) await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) diff --git a/backend/tests/test_email_parser.py b/backend/tests/test_email_parser.py index e44d03127..2a3b9b893 100644 --- a/backend/tests/test_email_parser.py +++ b/backend/tests/test_email_parser.py @@ -274,16 +274,13 @@ def test_parse_eml_extracts_supported_and_unsupported_attachment_metadata(): }, { "filename": "contract.pdf", - # Deferred to the NewsDOM worker: the raw PDF bytes are retained - # as a base64 payload (round-trips the fixture's %PDF-1.7\n) so - # the worker can recognize them later. - "content": "JVBERi0xLjcK", + "content": "", "content_type": "application/pdf", "parse_content": "", "parse_content_type": "application/pdf", - "parser_key": "pdf", - "parse_status": "pdf_dom_recognition_pending", - "parse_error_code": None, + "parser_key": "unsupported_binary", + "parse_status": "unsupported_content_type", + "parse_error_code": "unsupported_content_type", }, ] finally: diff --git a/backend/tests/test_live_seed_accounts.py b/backend/tests/test_live_seed_accounts.py deleted file mode 100644 index eb8528388..000000000 --- a/backend/tests/test_live_seed_accounts.py +++ /dev/null @@ -1,105 +0,0 @@ -from tests.live.seed_test_accounts import parse_account, parse_test_accounts - - -def _account1_env(): - return { - "NARUON_TEST_EMAIL1": "user@example.com", - "NARUON_TEST_PASSWORD1": "s3cret", - "NARUON_TEST_IMAP_ADDR1": "imap.example.com", - "NARUON_TEST_POP3_ADDR1": "pop3.example.com", - "NARUON_TEST_SMTP_ADDR1": "smtp.example.com", - "NARUON_TEST_CALDAV_ADDR1": "https://dav.example.com/caldav/", - "NARUON_TEST_CARDDAV_ADDR1": "https://dav.example.com/carddav/", - } - - -def test_parses_single_account_with_inferred_ports(): - accounts = parse_test_accounts(_account1_env()) - assert len(accounts) == 1 - account = accounts[0] - assert account.index == 1 - assert account.email == "user@example.com" - assert account.password == "s3cret" - assert account.imap.host == "imap.example.com" - assert account.imap.port == 993 - assert account.pop3.port == 995 - assert account.smtp.port == 587 - assert account.caldav_url == "https://dav.example.com/caldav" - assert account.carddav_url == "https://dav.example.com/carddav" - assert account.carddav_needs_discovery is False - assert account.user_id == "naruon-test-1" - - -def test_explicit_ports_are_preserved(): - env = _account1_env() - env["NARUON_TEST_IMAP_ADDR1"] = "imap.example.com:1993" - env["NARUON_TEST_SMTP_ADDR1"] = "smtp.example.com:2525" - account = parse_test_accounts(env)[0] - assert account.imap.port == 1993 - assert account.smtp.port == 2525 - - -def test_blank_carddav_triggers_discovery(): - env = _account1_env() - env["NARUON_TEST_CARDDAV_ADDR1"] = " " - account = parse_test_accounts(env)[0] - assert account.carddav_url is None - assert account.carddav_needs_discovery is True - assert "carddav" in account.configured_protocols - - -def test_missing_carddav_key_triggers_discovery(): - env = _account1_env() - del env["NARUON_TEST_CARDDAV_ADDR1"] - account = parse_test_accounts(env)[0] - assert account.carddav_needs_discovery is True - - -def test_bare_host_caldav_becomes_https_origin(): - env = _account1_env() - env["NARUON_TEST_CALDAV_ADDR1"] = "dav.example.com" - account = parse_test_accounts(env)[0] - assert account.caldav_url == "https://dav.example.com" - - -def test_n_account_native_iteration_stops_at_first_gap(): - env = _account1_env() - # Account 2 fully configured. - env.update( - { - "NARUON_TEST_EMAIL2": "second@example.com", - "NARUON_TEST_PASSWORD2": "pw2", - "NARUON_TEST_IMAP_ADDR2": "imap2.example.com", - } - ) - accounts = parse_test_accounts(env) - assert [a.index for a in accounts] == [1, 2] - assert accounts[1].email == "second@example.com" - assert accounts[1].imap.port == 993 - # No SMTP configured for account 2 -> endpoint is None. - assert accounts[1].smtp is None - - -def test_no_accounts_returns_empty(): - assert parse_test_accounts({}) == [] - - -def test_missing_email_returns_none_for_that_index(): - env = {"NARUON_TEST_PASSWORD1": "pw"} - assert parse_account(env, 1) is None - - -def test_partial_mail_only_account(): - env = { - "NARUON_TEST_EMAIL1": "user@example.com", - "NARUON_TEST_PASSWORD1": "pw", - "NARUON_TEST_IMAP_ADDR1": "imap.example.com", - } - account = parse_test_accounts(env)[0] - assert account.imap is not None - assert account.pop3 is None - assert account.smtp is None - assert account.caldav_url is None - # No explicit CardDAV address -> discovery flagged. - assert account.carddav_needs_discovery is True - assert sorted(account.configured_protocols) == ["carddav", "imap"] diff --git a/backend/tests/test_llm_api.py b/backend/tests/test_llm_api.py index ce27cd3e2..1657ece9e 100644 --- a/backend/tests/test_llm_api.py +++ b/backend/tests/test_llm_api.py @@ -71,16 +71,13 @@ def _draft_reply_forwarded_args(mock_draft): return mock_draft.await_args.args[:3] -@patch("api.llm.extract_action_items_and_summary", new_callable=AsyncMock) +@patch("api.llm.extract_todos_and_summary", new_callable=AsyncMock) @patch("api.llm.draft_reply", new_callable=AsyncMock) def test_llm_endpoints_exist(mock_draft, mock_extract, client): from services.llm_service import ExtractionResult mock_extract.return_value = ExtractionResult( - summary="Test summary", - action_items=["Task 1"], - provenance="OpenAI", - confidence=90, + summary="Test summary", todos=["Task 1"], provenance="OpenAI", confidence=90 ) mock_draft.return_value = "This is a draft reply." @@ -93,22 +90,19 @@ def test_llm_endpoints_exist(mock_draft, mock_extract, client): assert resp2.status_code == 200 -@patch("api.llm.extract_action_items_and_summary", new_callable=AsyncMock) +@patch("api.llm.extract_todos_and_summary", new_callable=AsyncMock) def test_summarize_endpoint(mock_extract, client): from services.llm_service import ExtractionResult mock_extract.return_value = ExtractionResult( - summary="Test summary", - action_items=["Task 1"], - provenance="OpenAI", - confidence=90, + summary="Test summary", todos=["Task 1"], provenance="OpenAI", confidence=90 ) resp = client.post("/api/llm/summarize", json={"email_body": "test email"}) assert resp.status_code == 200 assert resp.json() == { "summary": "Test summary", - "action_items": ["Task 1"], + "todos": ["Task 1"], "provenance": "OpenAI", "confidence": 90, } @@ -427,7 +421,7 @@ def test_translate_wrong_user_returns_403(mock_translate, client): assert resp.json() == {"detail": "Not authorized"} -@patch("api.llm.extract_action_items_and_summary", new_callable=AsyncMock) +@patch("api.llm.extract_todos_and_summary", new_callable=AsyncMock) def test_summarize_generic_error_returns_500(mock_extract, client): mock_extract.side_effect = Exception("Generic Error") @@ -455,7 +449,7 @@ def test_draft_generic_error_returns_500(mock_draft, client): } -@patch("api.llm.extract_action_items_and_summary", new_callable=AsyncMock) +@patch("api.llm.extract_todos_and_summary", new_callable=AsyncMock) def test_summarize_http_exception(mock_extract, client): mock_extract.side_effect = HTTPException(status_code=400, detail="Bad request") @@ -488,16 +482,13 @@ def test_translate_http_exception(mock_translate, client): assert resp.status_code == 400 -@patch( - "api.llm.extract_action_items_and_summary", - side_effect=LLMServiceError("Service down"), -) +@patch("api.llm.extract_todos_and_summary", side_effect=LLMServiceError("Service down")) def test_summarize_llm_service_error(mock_extract, client): resp = client.post("/api/llm/summarize", json={"email_body": "test"}) assert resp.status_code == 500 -@patch("api.llm.extract_action_items_and_summary", side_effect=Exception("Unknown")) +@patch("api.llm.extract_todos_and_summary", side_effect=Exception("Unknown")) def test_summarize_unknown_error(mock_extract, client): resp = client.post("/api/llm/summarize", json={"email_body": "test"}) assert resp.status_code == 500 diff --git a/backend/tests/test_llm_service.py b/backend/tests/test_llm_service.py index 463603a45..3d143cfb2 100644 --- a/backend/tests/test_llm_service.py +++ b/backend/tests/test_llm_service.py @@ -12,7 +12,7 @@ OLLAMA_DRAFT_REPLY_MAX_TOKENS, OLLAMA_NATIVE_CHAT_TIMEOUT_SECONDS, draft_reply, - extract_action_items_and_summary, + extract_todos_and_summary, translate_email_body, ) @@ -31,13 +31,13 @@ def mock_openai(): def test_extraction_result_confidence_is_optional_and_bounded(): - omitted = ExtractionResult(summary="Test summary", action_items=[]) + omitted = ExtractionResult(summary="Test summary", todos=[]) assert omitted.confidence is None assert ( ExtractionResult( summary="Test summary", - action_items=[], + todos=[], confidence=0, ).confidence == 0 @@ -45,14 +45,14 @@ def test_extraction_result_confidence_is_optional_and_bounded(): assert ( ExtractionResult( summary="Test summary", - action_items=[], + todos=[], confidence=100, ).confidence == 100 ) with pytest.raises(ValueError): - ExtractionResult(summary="Test summary", action_items=[], confidence=101) + ExtractionResult(summary="Test summary", todos=[], confidence=101) @pytest.mark.asyncio @@ -252,12 +252,12 @@ async def handle_async_request(self, request): @pytest.mark.asyncio -async def test_extract_action_items_and_summary_success(mock_openai): +async def test_extract_todos_and_summary_success(mock_openai): # Setup mock response mock_response = MagicMock() mock_message = MagicMock() mock_message.parsed = ExtractionResult( - summary="Test summary", action_items=["Task 1"], confidence=90 + summary="Test summary", todos=["Task 1"], confidence=90 ) mock_choice = MagicMock() mock_choice.message = mock_message @@ -266,11 +266,11 @@ async def test_extract_action_items_and_summary_success(mock_openai): mock_openai.beta.chat.completions.parse = AsyncMock(return_value=mock_response) # Call the service - result = await extract_action_items_and_summary("Test email", "test-key") + result = await extract_todos_and_summary("Test email", "test-key") # Verify results assert result.summary == "Test summary" - assert result.action_items == ["Task 1"] + assert result.todos == ["Task 1"] mock_openai.beta.chat.completions.parse.assert_called_once() assert ( mock_openai.beta.chat.completions.parse.call_args.kwargs["model"] @@ -279,20 +279,18 @@ async def test_extract_action_items_and_summary_success(mock_openai): @pytest.mark.asyncio -async def test_extract_action_items_and_summary_uses_selected_provider_model( - mock_openai, -): +async def test_extract_todos_and_summary_uses_selected_provider_model(mock_openai): mock_response = MagicMock() mock_message = MagicMock() mock_message.parsed = ExtractionResult( - summary="Test summary", action_items=["Task 1"], confidence=90 + summary="Test summary", todos=["Task 1"], confidence=90 ) mock_choice = MagicMock() mock_choice.message = mock_message mock_response.choices = [mock_choice] mock_openai.beta.chat.completions.parse = AsyncMock(return_value=mock_response) - result = await extract_action_items_and_summary( + result = await extract_todos_and_summary( "Test email", "test-key", provider_name="Local Gemma4", @@ -304,18 +302,18 @@ async def test_extract_action_items_and_summary_uses_selected_provider_model( @pytest.mark.asyncio -async def test_extract_action_items_and_summary_api_error(mock_openai): +async def test_extract_todos_and_summary_api_error(mock_openai): # Setup mock to raise an exception mock_openai.beta.chat.completions.parse = AsyncMock( side_effect=Exception("API Error") ) with pytest.raises(LLMServiceError, match="LLM API error during extraction"): - await extract_action_items_and_summary("Test email", "test-key") + await extract_todos_and_summary("Test email", "test-key") @pytest.mark.asyncio -async def test_extract_action_items_and_summary_disables_redirect_following_for_custom_base_url( +async def test_extract_todos_and_summary_disables_redirect_following_for_custom_base_url( monkeypatch, ): monkeypatch.setattr( @@ -337,7 +335,7 @@ def fake_getaddrinfo(host, port, type=0): mock_response = MagicMock() mock_message = MagicMock() mock_message.parsed = ExtractionResult( - summary="Test summary", action_items=["Task 1"], confidence=90 + summary="Test summary", todos=["Task 1"], confidence=90 ) mock_choice = MagicMock() mock_choice.message = mock_message @@ -345,14 +343,14 @@ def fake_getaddrinfo(host, port, type=0): mock_client.beta.chat.completions.parse = AsyncMock(return_value=mock_response) mock_async_openai.return_value = mock_client - result = await extract_action_items_and_summary( + result = await extract_todos_and_summary( "Test email", "test-key", base_url="https://llm-gateway.example.com/v1", ) assert result.summary == "Test summary" - assert result.action_items == ["Task 1"] + assert result.todos == ["Task 1"] constructor_kwargs = mock_async_openai.call_args.kwargs assert "http_client" in constructor_kwargs assert constructor_kwargs["http_client"].follow_redirects is False diff --git a/backend/tests/test_mail_smoke_fail_closed.py b/backend/tests/test_mail_smoke_fail_closed.py deleted file mode 100644 index 5f6caa88a..000000000 --- a/backend/tests/test_mail_smoke_fail_closed.py +++ /dev/null @@ -1,114 +0,0 @@ -"""The live smoke must fail closed instead of passing vacuously. - -Regression coverage for the confirmed false-green defects: a swallowed seed -error, an empty seed, and a required-but-undiscoverable CardDAV endpoint all -have to fail the run rather than report success. -""" - -import sys -from pathlib import Path - -import pytest - -BACKEND_ROOT = Path(__file__).resolve().parents[1] -if str(BACKEND_ROOT) not in sys.path: - sys.path.insert(0, str(BACKEND_ROOT)) - -from tests.live import mail_smoke_test as smoke # noqa: E402 -from tests.live import seed_test_accounts as seed # noqa: E402 - - -@pytest.mark.asyncio -async def test_seed_and_smoke_raises_on_seed_failure(monkeypatch): - async def boom(_environ): - raise RuntimeError("db unreachable") - - monkeypatch.setattr(seed, "seed_test_accounts", boom) - with pytest.raises(smoke.SmokeError): - await smoke.seed_and_smoke({"NARUON_TEST_EMAIL1": "a@example.com"}) - - -@pytest.mark.asyncio -async def test_seed_and_smoke_raises_when_nothing_seeded(monkeypatch): - async def empty(_environ): - return [] - - monkeypatch.setattr(seed, "seed_test_accounts", empty) - with pytest.raises(smoke.SmokeError): - await smoke.seed_and_smoke({}) - - -@pytest.mark.asyncio -async def test_seed_and_smoke_raises_when_no_protocols(monkeypatch): - async def no_protocols(_environ): - return [{"user_id": "naruon-test-1", "protocols": []}] - - monkeypatch.setattr(seed, "seed_test_accounts", no_protocols) - with pytest.raises(smoke.SmokeError): - await smoke.seed_and_smoke({"NARUON_TEST_EMAIL1": "a@example.com"}) - - -@pytest.mark.asyncio -async def test_seed_and_smoke_raises_on_required_discovery_failure(monkeypatch): - async def discovery_failed(_environ): - return [ - { - "user_id": "naruon-test-1", - "protocols": ["imap"], - "carddav_discovery_failed": True, - } - ] - - monkeypatch.setattr(seed, "seed_test_accounts", discovery_failed) - with pytest.raises(smoke.SmokeError): - await smoke.seed_and_smoke({"NARUON_TEST_EMAIL1": "a@example.com"}) - - -@pytest.mark.asyncio -async def test_seed_and_smoke_runs_checks_when_seed_succeeds(monkeypatch): - async def ok(_environ): - return [{"user_id": "naruon-test-1", "protocols": ["imap"]}] - - sentinel = smoke.SmokeReport() - - async def fake_run(_environ): - return sentinel - - monkeypatch.setattr(seed, "seed_test_accounts", ok) - monkeypatch.setattr(smoke, "run_smoke", fake_run) - report = await smoke.seed_and_smoke({"NARUON_TEST_EMAIL1": "a@example.com"}) - assert report is sentinel - - -@pytest.mark.asyncio -async def test_required_carddav_discovery_failure_is_a_failure_not_skip(): - from tests.live.seed_test_accounts import LiveAccountSpec - - spec = LiveAccountSpec( - index=1, - email="user@example.com", - password="pw", - carddav_needs_discovery=True, - ) - - async def no_discovery(_email): - return None - - import tests.live.mail_smoke_test as module - - module_globals = module.__dict__ - original = module_globals.get("discover_carddav_base_url") - # _check_carddav imports discover_carddav_base_url lazily; patch the source. - import services.carddav_discovery as discovery - - orig_disc = discovery.discover_carddav_base_url - discovery.discover_carddav_base_url = no_discovery - try: - result = await module._check_carddav(spec) - finally: - discovery.discover_carddav_base_url = orig_disc - if original is not None: - module_globals["discover_carddav_base_url"] = original - - assert result.skipped is False - assert result.reachable is False diff --git a/backend/tests/test_net_defaults.py b/backend/tests/test_net_defaults.py deleted file mode 100644 index 2847fc881..000000000 --- a/backend/tests/test_net_defaults.py +++ /dev/null @@ -1,85 +0,0 @@ -import pytest - -from services.net_defaults import ( - SMTP_IMPLICIT_TLS_PORT, - infer_address_port, - infer_port, - split_host_port, -) - - -def test_infer_default_ports_per_protocol(): - assert infer_port("imap") == 993 - assert infer_port("pop3") == 995 - assert infer_port("smtp") == 587 - assert infer_port("caldav") == 443 - assert infer_port("carddav") == 443 - assert infer_port("webdav") == 443 - - -def test_infer_port_is_case_insensitive(): - assert infer_port("IMAP") == 993 - assert infer_port(" CardDAV ") == 443 - - -def test_smtp_implicit_tls_selects_465(): - assert infer_port("smtp", implicit_tls=True) == SMTP_IMPLICIT_TLS_PORT - # implicit_tls only affects smtp defaults, not other protocols. - assert infer_port("imap", implicit_tls=True) == 993 - - -def test_explicit_port_always_wins(): - assert infer_port("imap", 143) == 143 - assert infer_port("smtp", 2525) == 2525 - # An explicit port wins even over the implicit-TLS default. - assert infer_port("smtp", 587, implicit_tls=True) == 587 - - -def test_non_positive_provided_port_falls_back_to_default(): - assert infer_port("imap", 0) == 993 - assert infer_port("pop3", -1) == 995 - - -def test_unsupported_protocol_raises(): - with pytest.raises(ValueError): - infer_port("ftp") - - -def test_out_of_range_port_raises(): - with pytest.raises(ValueError): - infer_port("imap", 70000) - - -def test_split_host_port_bare_host(): - assert split_host_port("imap.example.com") == ("imap.example.com", None) - - -def test_split_host_port_with_port(): - assert split_host_port("imap.example.com:143") == ("imap.example.com", 143) - - -def test_split_host_port_ipv6_literal_without_port(): - # A bare IPv6 literal (multiple colons) must not be mistaken for host:port. - assert split_host_port("2001:db8::1") == ("2001:db8::1", None) - - -def test_split_host_port_bracketed_ipv6_with_port(): - assert split_host_port("[2001:db8::1]:993") == ("2001:db8::1", 993) - - -def test_split_host_port_bracketed_ipv6_without_port(): - assert split_host_port("[2001:db8::1]") == ("2001:db8::1", None) - - -def test_infer_address_port_fills_missing_port(): - assert infer_address_port("imap", "imap.example.com") == ( - "imap.example.com", - 993, - ) - - -def test_infer_address_port_preserves_explicit_port(): - assert infer_address_port("smtp", "smtp.example.com:2525") == ( - "smtp.example.com", - 2525, - ) diff --git a/backend/tests/test_newsdom_client.py b/backend/tests/test_newsdom_client.py deleted file mode 100644 index cda13ec35..000000000 --- a/backend/tests/test_newsdom_client.py +++ /dev/null @@ -1,107 +0,0 @@ -"""SSRF / allowlist tests for the NewsDOM sidecar client. - -These exercise the pure URL-normalization layer (no DNS / no network) plus the -request-time configuration guards. -""" - -import pytest - -from core.config import settings -from services.newsdom_client import ( - NEWSDOM_BASE_URL_NOT_ALLOWED, - NewsdomConfigurationError, - NewsdomRequestError, - _normalize_newsdom_base_url, - request_pdf_dom, -) - - -@pytest.fixture -def newsdom_allowlist(monkeypatch): - monkeypatch.setattr(settings, "ALLOWED_NEWSDOM_HOSTS", "newsdom.example.com") - monkeypatch.setattr(settings, "ALLOW_LOCAL_NEWSDOM_PROVIDERS", False) - return settings - - -def test_allowlisted_https_host_is_normalized(newsdom_allowlist): - normalized, hostname, port = _normalize_newsdom_base_url( - "https://newsdom.example.com/parse-root/" - ) - assert hostname == "newsdom.example.com" - assert port == 443 - assert normalized == "https://newsdom.example.com/parse-root/" - - -def test_host_not_in_allowlist_is_rejected(newsdom_allowlist): - with pytest.raises(ValueError) as excinfo: - _normalize_newsdom_base_url("https://evil.example.com") - assert str(excinfo.value) == NEWSDOM_BASE_URL_NOT_ALLOWED - - -def test_plain_http_remote_host_is_rejected(newsdom_allowlist): - # Even an allowlisted host may not be reached over plain http when local - # providers are disabled. - with pytest.raises(ValueError): - _normalize_newsdom_base_url("http://newsdom.example.com") - - -def test_ip_literal_host_is_rejected(newsdom_allowlist): - with pytest.raises(ValueError): - _normalize_newsdom_base_url("https://169.254.169.254") - - -def test_userinfo_is_rejected(newsdom_allowlist): - with pytest.raises(ValueError): - _normalize_newsdom_base_url("https://user:pass@newsdom.example.com") - - -def test_localhost_rejected_unless_local_providers_enabled(monkeypatch): - monkeypatch.setattr(settings, "ALLOWED_NEWSDOM_HOSTS", "newsdom") - monkeypatch.setattr(settings, "ALLOW_LOCAL_NEWSDOM_PROVIDERS", False) - with pytest.raises(ValueError): - _normalize_newsdom_base_url("http://localhost:8000") - - -def test_docker_container_host_allowed_when_local_enabled(monkeypatch): - monkeypatch.setattr(settings, "ALLOWED_NEWSDOM_HOSTS", "newsdom") - monkeypatch.setattr(settings, "ALLOW_LOCAL_NEWSDOM_PROVIDERS", True) - normalized, hostname, port = _normalize_newsdom_base_url("http://newsdom:8000") - assert hostname == "newsdom" - assert port == 8000 - assert normalized == "http://newsdom:8000" - - -def test_empty_base_url_normalizes_to_none(newsdom_allowlist): - assert _normalize_newsdom_base_url(None) == (None, None, None) - assert _normalize_newsdom_base_url("") == (None, None, None) - - -@pytest.mark.asyncio -async def test_request_pdf_dom_rejects_empty_payload(newsdom_allowlist): - with pytest.raises(NewsdomRequestError): - await request_pdf_dom( - base_url="https://newsdom.example.com", - api_token=None, - pdf_bytes=b"", - ) - - -@pytest.mark.asyncio -async def test_request_pdf_dom_raises_config_error_without_base_url(newsdom_allowlist): - with pytest.raises(NewsdomConfigurationError): - await request_pdf_dom( - base_url=None, - api_token=None, - pdf_bytes=b"%PDF-1.7", - ) - - -@pytest.mark.asyncio -async def test_request_pdf_dom_rejects_disallowed_host(newsdom_allowlist): - with pytest.raises(ValueError) as excinfo: - await request_pdf_dom( - base_url="https://evil.example.com", - api_token=None, - pdf_bytes=b"%PDF-1.7", - ) - assert str(excinfo.value) == NEWSDOM_BASE_URL_NOT_ALLOWED diff --git a/backend/tests/test_newsdom_pdf_recognition.py b/backend/tests/test_newsdom_pdf_recognition.py deleted file mode 100644 index a8dcafed1..000000000 --- a/backend/tests/test_newsdom_pdf_recognition.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Fast, fully-mocked unit tests for NewsDOM PDF DOM recognition. - -No database and no network: the NewsDOM client is replaced with a canned -``ParseResponse`` and the content-graph mapping / config resolution are -exercised against in-memory model instances. -""" - -import os - -import pytest - -from db.models import Attachment, Email, NewsdomProvider -from services.newsdom_pdf_recognition import ( - NewsdomRuntimeConfig, - build_recognition_records, - normalize_parse_response, - recognize_pdf_dom, - resolve_newsdom_runtime_config, -) -from services.newsdom_worker import ( - apply_recognition_to_attachment, - recognize_attachment_pdf, -) - - -def _canned_parse_response() -> dict: - return { - "document_id": "doc-123", - "pages": [ - { - "page_number": 1, - "articles": [ - { - "article_id": "a1", - "headline": "First Headline", - "body_blocks": ["Body one.", "Body two."], - }, - { - "article_id": "a2", - "headline": "Second Headline", - "body_blocks": ["Only body."], - }, - ], - }, - { - "page_number": 2, - "articles": [ - { - "article_id": "a3", - "headline": "", - "body_blocks": [" ", "Third page body."], - } - ], - }, - ], - "quality": {"status": "success", "parser": "mineru", "warnings": []}, - } - - -def test_normalize_parse_response_flattens_articles_in_order(): - sections = normalize_parse_response(_canned_parse_response()) - - assert [section.heading for section in sections] == [ - "First Headline", - "Second Headline", - "", - ] - assert sections[0].paragraphs == ("Body one.", "Body two.") - # Blank body blocks are dropped. - assert sections[2].paragraphs == ("Third page body.",) - assert sections[0].page_number == 1 - assert sections[2].page_number == 2 - - -def test_normalize_parse_response_tolerates_garbage(): - assert normalize_parse_response({}) == [] - assert normalize_parse_response({"pages": "nope"}) == [] - assert normalize_parse_response({"pages": [{"articles": [42, {"headline": 3}]}]}) == [] - - -def test_build_recognition_records_builds_document_section_paragraph_tree(): - records = build_recognition_records( - _canned_parse_response(), - source_kind="attachment", - source_record_uid="att-1", - display_name="news.pdf", - ) - - parse_result = records.parse_result - node_kinds = [node.node_kind for node in parse_result.nodes] - assert node_kinds.count("document") == 1 - assert node_kinds.count("section") == 3 - # 2 + 1 + 1 body paragraphs across the three sections. - assert node_kinds.count("paragraph") == 4 - - document_nodes = [n for n in parse_result.nodes if n.node_kind == "document"] - (document_node,) = document_nodes - assert document_node.parent_node_uid is None - assert document_node.display_label == "news.pdf" - - section_nodes = [n for n in parse_result.nodes if n.node_kind == "section"] - assert all( - n.parent_node_uid == document_node.content_node_uid for n in section_nodes - ) - section_uids = {n.content_node_uid for n in section_nodes} - paragraph_nodes = [n for n in parse_result.nodes if n.node_kind == "paragraph"] - assert all(n.parent_node_uid in section_uids for n in paragraph_nodes) - - segment_kinds = [seg.segment_kind for seg in parse_result.segments] - # Two headlines are non-empty -> two heading segments; the empty headline - # produces no heading segment. - assert segment_kinds.count("heading") == 2 - assert segment_kinds.count("paragraph") == 4 - - # parse_content text (for embeddings) carries every headline + body block. - assert "First Headline" in records.parse_text - assert "Third page body." in records.parse_text - assert records.source_content_hash - - -def test_recognition_uid_is_stable_for_identical_payload(): - payload = _canned_parse_response() - first = build_recognition_records( - payload, source_kind="attachment", source_record_uid="att-1" - ) - second = build_recognition_records( - payload, source_kind="attachment", source_record_uid="att-1" - ) - assert [n.content_node_uid for n in first.parse_result.nodes] == [ - n.content_node_uid for n in second.parse_result.nodes - ] - - -def test_resolve_runtime_config_reads_from_db_row_not_env(monkeypatch): - # Prove the resolver never consults the process environment for config. - def _boom(*_args, **_kwargs): # pragma: no cover - only fails if called - raise AssertionError("config must come from the DB, not os.getenv") - - monkeypatch.setattr(os, "getenv", _boom) - monkeypatch.setattr(os, "environ", {}) - - provider = NewsdomProvider( - user_id="u1", - organization_id="org-1", - provider_name="primary", - base_url="https://newsdom.example.com", - api_token="secret-token", - request_language="ja", - recognition_mode="newspaper", - is_active=True, - ) - config = resolve_newsdom_runtime_config(provider) - assert config == NewsdomRuntimeConfig( - base_url="https://newsdom.example.com", - api_token="secret-token", - request_language="ja", - recognition_mode="newspaper", - provider_name="primary", - ) - - -def test_resolve_runtime_config_degrades_when_unconfigured(): - assert resolve_newsdom_runtime_config(None) is None - inactive = NewsdomProvider( - user_id="u", - organization_id="o", - provider_name="p", - base_url="https://newsdom.example.com", - is_active=False, - ) - assert resolve_newsdom_runtime_config(inactive) is None - no_url = NewsdomProvider( - user_id="u", - organization_id="o", - provider_name="p", - base_url="", - is_active=True, - ) - assert resolve_newsdom_runtime_config(no_url) is None - - -@pytest.mark.asyncio -async def test_recognize_pdf_dom_uses_mocked_client_with_config_values(): - captured = {} - - async def fake_request(**kwargs): - captured.update(kwargs) - return _canned_parse_response() - - config = NewsdomRuntimeConfig( - base_url="https://newsdom.example.com", - api_token="tok", - request_language="ja", - recognition_mode="newspaper", - provider_name="primary", - ) - records = await recognize_pdf_dom( - config=config, - pdf_bytes=b"%PDF-1.7 fake", - filename="news.pdf", - source_kind="attachment", - source_record_uid="att-1", - display_name="news.pdf", - request_fn=fake_request, - ) - - assert captured["base_url"] == "https://newsdom.example.com" - assert captured["api_token"] == "tok" - assert captured["language"] == "ja" - assert captured["mode"] == "newspaper" - assert captured["pdf_bytes"] == b"%PDF-1.7 fake" - assert records.parse_text - assert any(n.node_kind == "section" for n in records.parse_result.nodes) - - -@pytest.mark.asyncio -async def test_recognize_attachment_pdf_lands_text_and_content_graph(): - email = Email() - attachment = Attachment(filename="news.pdf") - email.attachments.append(attachment) - - async def fake_request(**_kwargs): - return _canned_parse_response() - - config = NewsdomRuntimeConfig( - base_url="https://newsdom.example.com", - api_token=None, - request_language="auto", - recognition_mode="auto", - provider_name="primary", - ) - await recognize_attachment_pdf( - email=email, - attachment=attachment, - pdf_bytes=b"%PDF-1.7 fake", - config=config, - source_record_uid="att-1", - request_fn=fake_request, - ) - - assert attachment.parse_status == "parsed" - assert attachment.parser_key == "pdf" - assert "First Headline" in attachment.content - # Content graph landed on both the email and the attachment. - assert any(n.node_kind == "section" for n in attachment.content_nodes) - assert any(n.node_kind == "document" for n in email.content_nodes) - assert attachment.content_segments - assert email.content_segments - - -@pytest.mark.asyncio -async def test_recognize_pdf_dom_rejects_empty_sidecar_response(): - from services.newsdom_client import NewsdomEmptyRecognitionError - - async def empty_request(**_kwargs): - return {"pages": []} - - with pytest.raises(NewsdomEmptyRecognitionError): - await recognize_pdf_dom( - config=NewsdomRuntimeConfig( - base_url="https://newsdom.example.com", - api_token=None, - request_language="auto", - recognition_mode="auto", - provider_name="primary", - ), - pdf_bytes=b"%PDF-1.7 fake", - filename="news.pdf", - source_kind="attachment", - source_record_uid="att-1", - request_fn=empty_request, - ) - - -def test_apply_recognition_to_attachment_is_pure_mapping(): - email = Email() - attachment = Attachment(filename="news.pdf") - email.attachments.append(attachment) - records = build_recognition_records( - _canned_parse_response(), - source_kind="attachment", - source_record_uid="att-1", - display_name="news.pdf", - ) - apply_recognition_to_attachment(email=email, attachment=attachment, records=records) - assert len(attachment.content_nodes) == len(records.parse_result.nodes) - assert len(attachment.content_segments) == len(records.parse_result.segments) diff --git a/backend/tests/test_newsdom_worker.py b/backend/tests/test_newsdom_worker.py deleted file mode 100644 index 0693d395c..000000000 --- a/backend/tests/test_newsdom_worker.py +++ /dev/null @@ -1,671 +0,0 @@ -"""Unit tests for the NewsDOM recognition worker's per-item processing. - -Fully mocked: in-memory models, an injected async config resolver, and a canned -sidecar ``request_fn`` — no database, no network. Covers the fail-closed -outcomes (unconfigured -> pending, bad payload -> failed, empty response -> -failed) that keep a pending PDF from ever masquerading as parsed. -""" - -import asyncio -import base64 -from types import SimpleNamespace - -import pytest - -from db.models import Attachment, Document, Email -from services.content_graph import ContentSegment, ParseResult -from services.newsdom_client import NewsdomConfigurationError -from services.newsdom_pdf_recognition import ( - PDF_DOM_RECOGNITION_FAILED_STATUS, - PDF_DOM_RECOGNITION_PENDING_STATUS, - NewsdomRuntimeConfig, - PdfDomRecognitionRecords, -) -import services.newsdom_worker as newsdom_worker_module - -NewsdomRecognitionWorker = newsdom_worker_module.NewsdomRecognitionWorker -RESULT_FAILED = newsdom_worker_module.RESULT_FAILED -RESULT_PENDING = newsdom_worker_module.RESULT_PENDING -RESULT_RECOGNIZED = newsdom_worker_module.RESULT_RECOGNIZED -process_pending_attachment = newsdom_worker_module.process_pending_attachment -process_pending_document = newsdom_worker_module.process_pending_document - - -def _config() -> NewsdomRuntimeConfig: - return NewsdomRuntimeConfig( - base_url="https://newsdom.example.com", - api_token=None, - request_language="auto", - recognition_mode="auto", - provider_name="primary", - ) - - -def _canned_response() -> dict: - return { - "pages": [ - { - "page_number": 1, - "articles": [{"headline": "Headline", "body_blocks": ["Body one."]}], - } - ] - } - - -async def _resolver_with(config): - async def resolve(_session, _org): - return config - - return resolve - - -def _pending_attachment( - payload: bytes = b"%PDF-1.7 fake", - *, - attachment_id: int | None = None, - organization_id: str = "org-1", -) -> Attachment: - email = Email() - email.organization_id = organization_id - attachment = Attachment( - id=attachment_id, - filename="news.pdf", - content=base64.b64encode(payload).decode("ascii"), - parse_status=PDF_DOM_RECOGNITION_PENDING_STATUS, - ) - email.attachments.append(attachment) - return attachment - - -def _pending_document(document_id: str, *, organization_id: str = "org-1") -> Document: - return Document( - document_id=document_id, - workspace_id="ws-1", - organization_id=organization_id, - document_name="news.pdf", - document_type="pdf", - document_content=base64.b64encode(b"%PDF-1.7 fake").decode("ascii"), - document_status=PDF_DOM_RECOGNITION_PENDING_STATUS, - ) - - -class _RowsResult: - def __init__(self, rows): - self._rows = rows - - def scalars(self): - return self - - def all(self): - return self._rows - - -class _SequenceSession: - def __init__(self, row_batches): - self._row_batches = list(row_batches) - self.statements = [] - self.commit_count = 0 - self.rollback_count = 0 - - async def execute(self, statement): - self.statements.append(statement) - return _RowsResult(self._row_batches.pop(0)) - - async def commit(self): - self.commit_count += 1 - - async def rollback(self): - self.rollback_count += 1 - - -class _AsyncSessionContext: - def __init__(self, session): - self.session = session - - async def __aenter__(self): - return self.session - - async def __aexit__(self, *_args): - return False - - -class _LeaseSession: - def __init__(self, *, dialect_name="postgresql", scalar_result=True): - self.bind = SimpleNamespace( - dialect=SimpleNamespace(name=dialect_name), - ) - self.scalar_result = scalar_result - self.scalar_calls = [] - - def get_bind(self): - return self.bind - - async def scalar(self, statement, params): - self.scalar_calls.append((statement, params)) - return self.scalar_result - - -@pytest.mark.asyncio -async def test_attachment_recognized_when_configured(): - attachment = _pending_attachment() - - async def request_fn(**_kwargs): - return _canned_response() - - result = await process_pending_attachment( - session=object(), - attachment=attachment, - config_resolver=await _resolver_with(_config()), - request_fn=request_fn, - ) - assert result == RESULT_RECOGNIZED - assert attachment.parse_status == "parsed" - assert "Headline" in attachment.content - assert attachment.content_segments - - -@pytest.mark.asyncio -async def test_attachment_left_pending_when_no_provider(): - attachment = _pending_attachment() - - async def request_fn(**_kwargs): # pragma: no cover - must not be called - raise AssertionError("sidecar must not be called when unconfigured") - - result = await process_pending_attachment( - session=object(), - attachment=attachment, - config_resolver=await _resolver_with(None), - request_fn=request_fn, - ) - assert result == RESULT_PENDING - assert attachment.parse_status == PDF_DOM_RECOGNITION_PENDING_STATUS - - -@pytest.mark.asyncio -async def test_attachment_failed_on_invalid_payload(): - attachment = _pending_attachment() - attachment.content = "not@@base64!!" - - async def request_fn(**_kwargs): # pragma: no cover - raise AssertionError("must not reach sidecar with a bad payload") - - result = await process_pending_attachment( - session=object(), - attachment=attachment, - config_resolver=await _resolver_with(_config()), - request_fn=request_fn, - ) - assert result == RESULT_FAILED - assert attachment.parse_status == PDF_DOM_RECOGNITION_FAILED_STATUS - assert attachment.parse_error_code == "invalid_pending_payload" - - -@pytest.mark.asyncio -async def test_attachment_failed_on_empty_sidecar_response(): - attachment = _pending_attachment() - - async def request_fn(**_kwargs): - return {"pages": []} - - result = await process_pending_attachment( - session=object(), - attachment=attachment, - config_resolver=await _resolver_with(_config()), - request_fn=request_fn, - ) - assert result == RESULT_FAILED - assert attachment.parse_status == PDF_DOM_RECOGNITION_FAILED_STATUS - assert attachment.parse_error_code == "recognition_failed" - # Never landed as parsed with empty content. - assert attachment.parse_status != "parsed" - - -@pytest.mark.asyncio -async def test_attachment_orphan_and_rejected_configuration_stay_visible(): - orphan = Attachment( - filename="orphan.pdf", - content=base64.b64encode(b"%PDF-1.7 fake").decode("ascii"), - parse_status=PDF_DOM_RECOGNITION_PENDING_STATUS, - ) - orphan_result = await process_pending_attachment( - session=object(), - attachment=orphan, - config_resolver=await _resolver_with(_config()), - ) - assert orphan_result == RESULT_FAILED - assert orphan.parse_error_code == "orphan_attachment" - - attachment = _pending_attachment() - - async def rejected_request(**_kwargs): - raise NewsdomConfigurationError("host rejected") - - rejected_result = await process_pending_attachment( - session=object(), - attachment=attachment, - config_resolver=await _resolver_with(_config()), - request_fn=rejected_request, - ) - assert rejected_result == RESULT_PENDING - assert attachment.parse_status == PDF_DOM_RECOGNITION_PENDING_STATUS - - -@pytest.mark.asyncio -async def test_document_recognized_when_configured(): - document = Document( - document_id="doc-1", - workspace_id="ws-1", - organization_id="org-1", - document_name="news.pdf", - document_type="pdf", - document_content=base64.b64encode(b"%PDF-1.7 fake").decode("ascii"), - document_status=PDF_DOM_RECOGNITION_PENDING_STATUS, - ) - - async def request_fn(**_kwargs): - return _canned_response() - - result = await process_pending_document( - session=object(), - document=document, - config_resolver=await _resolver_with(_config()), - request_fn=request_fn, - ) - assert result == RESULT_RECOGNIZED - assert document.document_status == "parsed" - assert "Headline" in document.document_content - - -@pytest.mark.asyncio -async def test_document_failed_on_empty_response(): - document = Document( - document_id="doc-2", - workspace_id="ws-1", - organization_id="org-1", - document_name="news.pdf", - document_type="pdf", - document_content=base64.b64encode(b"%PDF-1.7 fake").decode("ascii"), - document_status=PDF_DOM_RECOGNITION_PENDING_STATUS, - ) - - async def request_fn(**_kwargs): - return {"pages": []} - - result = await process_pending_document( - session=object(), - document=document, - config_resolver=await _resolver_with(_config()), - request_fn=request_fn, - ) - assert result == RESULT_FAILED - assert document.document_status == PDF_DOM_RECOGNITION_FAILED_STATUS - - -@pytest.mark.asyncio -async def test_document_invalid_payload_and_rejected_configuration_are_visible(): - invalid = _pending_document("doc-invalid") - invalid.document_content = "not@@base64" - invalid_result = await process_pending_document( - session=object(), - document=invalid, - config_resolver=await _resolver_with(_config()), - ) - assert invalid_result == RESULT_FAILED - assert invalid.document_status == PDF_DOM_RECOGNITION_FAILED_STATUS - - document = _pending_document("doc-rejected") - - async def rejected_request(**_kwargs): - raise NewsdomConfigurationError("host rejected") - - rejected_result = await process_pending_document( - session=object(), - document=document, - config_resolver=await _resolver_with(_config()), - request_fn=rejected_request, - ) - assert rejected_result == RESULT_PENDING - assert document.document_status == PDF_DOM_RECOGNITION_PENDING_STATUS - - -def test_attachment_mapping_keeps_unmatched_segments_without_false_parent(): - email = Email() - attachment = Attachment(filename="news.pdf", content="pending") - email.attachments.append(attachment) - segment = ContentSegment( - content_segment_uid="segment-1", - source_kind="attachment", - source_record_uid="attachment-1", - content_node_uid="missing-node", - segment_kind="paragraph", - segment_path="/paragraph/1", - ordinal_index=0, - heading_path=None, - safe_text_content="Recognized text", - content_hash="hash", - word_count=2, - ) - records = PdfDomRecognitionRecords( - parse_text="Recognized text", - source_content_hash="source-hash", - parse_result=ParseResult( - source_kind="attachment", - source_record_uid="attachment-1", - display_name="news.pdf", - content_type="application/pdf", - source_content_hash="source-hash", - nodes=(), - segments=(segment,), - ), - ) - - newsdom_worker_module.apply_recognition_to_attachment( - email=email, - attachment=attachment, - records=records, - ) - - assert attachment.content_segments[0].content_node is None - assert email.content_segments[0] is attachment.content_segments[0] - - -@pytest.mark.asyncio -async def test_attachment_sweep_advances_past_unconfigured_batch(): - blocked = [ - _pending_attachment(attachment_id=index, organization_id="org-blocked") - for index in range(1, 11) - ] - ready = _pending_attachment(attachment_id=11, organization_id="org-ready") - session = _SequenceSession([blocked, [ready]]) - - async def config_resolver(_session, organization_id): - return _config() if organization_id == "org-ready" else None - - request_count = 0 - - async def request_fn(**_kwargs): - nonlocal request_count - request_count += 1 - return _canned_response() - - worker = NewsdomRecognitionWorker( - batch_limit=10, - config_resolver=config_resolver, - request_fn=request_fn, - ) - await worker._sweep_attachments(session) - await worker._sweep_attachments(session) - - second_query = session.statements[1].compile() - assert "email_attachments.id >" in str(second_query) - assert 10 in second_query.params.values() - assert worker._attachment_cursor == 11 - assert ready.parse_status == "parsed" - assert request_count == 1 - assert session.commit_count == 11 - assert session.rollback_count == 0 - - -@pytest.mark.asyncio -async def test_document_sweep_advances_and_wraps_without_starvation(): - blocked = [ - _pending_document(f"doc-{index:03d}", organization_id="org-blocked") - for index in range(1, 11) - ] - ready_after_batch = _pending_document("doc-011", organization_id="org-ready") - ready_after_wrap = _pending_document("doc-001", organization_id="org-ready") - session = _SequenceSession([blocked, [ready_after_batch], [], [ready_after_wrap]]) - - async def config_resolver(_session, organization_id): - return _config() if organization_id == "org-ready" else None - - async def request_fn(**_kwargs): - return _canned_response() - - worker = NewsdomRecognitionWorker( - batch_limit=10, - config_resolver=config_resolver, - request_fn=request_fn, - ) - await worker._sweep_documents(session) - await worker._sweep_documents(session) - worker._document_cursor = "doc-999" - await worker._sweep_documents(session) - - second_query = session.statements[1].compile() - wrapped_query = session.statements[3].compile() - assert "workspace_documents.document_id >" in str(second_query) - assert "doc-010" in second_query.params.values() - assert "workspace_documents.document_id >" not in str(wrapped_query) - assert worker._document_cursor == "doc-001" - assert ready_after_batch.document_status == "parsed" - assert ready_after_wrap.document_status == "parsed" - assert session.commit_count == 12 - assert session.rollback_count == 0 - - -@pytest.mark.asyncio -async def test_attachment_cursor_wraps_and_empty_batches_are_stable(): - wrapped = _pending_attachment(attachment_id=1) - session = _SequenceSession([[], [wrapped], []]) - worker = NewsdomRecognitionWorker(batch_limit=10) - worker._attachment_cursor = 999 - - rows = await worker._load_pending_attachments(session) - empty_rows = await worker._load_pending_attachments(session) - - assert rows == [wrapped] - assert empty_rows == [] - assert worker._attachment_cursor is None - assert "email_attachments.id >" in str(session.statements[0]) - assert "email_attachments.id >" not in str(session.statements[1]) - - -@pytest.mark.asyncio -async def test_empty_sweeps_leave_both_cursors_unset(): - attachment_session = _SequenceSession([[]]) - document_session = _SequenceSession([[]]) - worker = NewsdomRecognitionWorker() - - await worker._sweep_attachments(attachment_session) - await worker._sweep_documents(document_session) - - assert worker._attachment_cursor is None - assert worker._document_cursor is None - assert attachment_session.commit_count == 0 - assert document_session.commit_count == 0 - - -@pytest.mark.asyncio -async def test_sweeps_rollback_one_item_failure_and_continue_isolation(): - attachment = _pending_attachment(attachment_id=1) - document = _pending_document("doc-1") - attachment_session = _SequenceSession([[attachment]]) - document_session = _SequenceSession([[document]]) - - async def broken_resolver(_session, _organization_id): - raise RuntimeError("provider lookup failed") - - worker = NewsdomRecognitionWorker(config_resolver=broken_resolver) - await worker._sweep_attachments(attachment_session) - await worker._sweep_documents(document_session) - - assert attachment_session.commit_count == 0 - assert attachment_session.rollback_count == 1 - assert document_session.commit_count == 0 - assert document_session.rollback_count == 1 - - -@pytest.mark.asyncio -async def test_postgresql_lease_helpers_and_non_postgresql_fallback(): - postgres = _LeaseSession(scalar_result=1) - sqlite = _LeaseSession(dialect_name="sqlite") - - assert await newsdom_worker_module._try_acquire_sweep_lease(postgres) is True - assert postgres.scalar_calls[0][1] == newsdom_worker_module._SWEEP_LOCK_PARAMS - await newsdom_worker_module._release_sweep_lease(postgres) - assert len(postgres.scalar_calls) == 2 - assert await newsdom_worker_module._try_acquire_sweep_lease(sqlite) is None - - class BrokenBindSession: - def get_bind(self): - raise RuntimeError("no bind") - - assert newsdom_worker_module._session_uses_postgresql(BrokenBindSession()) is False - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("lease", "expected_sweeps", "expected_releases"), - [(False, 0, 0), (None, 2, 0), (True, 2, 1)], -) -async def test_worker_sweep_honors_lease_outcome( - monkeypatch, lease, expected_sweeps, expected_releases -): - session = object() - calls = [] - releases = [] - worker = NewsdomRecognitionWorker() - - monkeypatch.setattr( - newsdom_worker_module, - "AsyncSessionLocal", - lambda: _AsyncSessionContext(session), - ) - - async def acquire(actual_session): - assert actual_session is session - return lease - - async def release(actual_session): - releases.append(actual_session) - - async def sweep_attachments(actual_session): - calls.append(("attachments", actual_session)) - - async def sweep_documents(actual_session): - calls.append(("documents", actual_session)) - - monkeypatch.setattr(newsdom_worker_module, "_try_acquire_sweep_lease", acquire) - monkeypatch.setattr(newsdom_worker_module, "_release_sweep_lease", release) - monkeypatch.setattr(worker, "_sweep_attachments", sweep_attachments) - monkeypatch.setattr(worker, "_sweep_documents", sweep_documents) - - await worker._sweep() - - assert len(calls) == expected_sweeps - assert len(releases) == expected_releases - - -@pytest.mark.asyncio -async def test_worker_start_stop_are_idempotent(monkeypatch): - worker = NewsdomRecognitionWorker() - entered = asyncio.Event() - blocker = asyncio.Event() - - async def blocked_loop(): - entered.set() - await blocker.wait() - - monkeypatch.setattr(worker, "_run_loop", blocked_loop) - await worker.start() - await entered.wait() - task = worker._task - await worker.start() - await worker.stop() - await worker.stop() - - assert task is not None - assert task.cancelled() - - worker._is_running = True - worker._task = None - await worker.stop() - - -@pytest.mark.asyncio -async def test_worker_loop_reports_errors_and_honors_cancellation(monkeypatch): - worker = NewsdomRecognitionWorker(interval_seconds=1) - worker._is_running = True - sleep_calls = 0 - - async def sleep_then_cancel(_seconds): - nonlocal sleep_calls - sleep_calls += 1 - if sleep_calls > 1: - raise asyncio.CancelledError - - async def failing_sweep(): - raise RuntimeError("sweep failed") - - monkeypatch.setattr(newsdom_worker_module.asyncio, "sleep", sleep_then_cancel) - monkeypatch.setattr(worker, "_sweep", failing_sweep) - await worker._run_loop() - assert sleep_calls == 2 - - async def cancel_immediately(_seconds): - raise asyncio.CancelledError - - monkeypatch.setattr(newsdom_worker_module.asyncio, "sleep", cancel_immediately) - await worker._run_loop() - - -@pytest.mark.asyncio -async def test_worker_loop_stops_when_sweep_is_cancelled(monkeypatch): - worker = NewsdomRecognitionWorker(interval_seconds=1) - worker._is_running = True - - async def no_sleep(_seconds): - return None - - async def cancelled_sweep(): - raise asyncio.CancelledError - - monkeypatch.setattr(newsdom_worker_module.asyncio, "sleep", no_sleep) - monkeypatch.setattr(worker, "_sweep", cancelled_sweep) - await worker._run_loop() - - -@pytest.mark.asyncio -async def test_worker_loop_returns_after_a_normal_interval(monkeypatch): - worker = NewsdomRecognitionWorker(interval_seconds=1) - worker._is_running = True - sleep_calls = 0 - sweep_calls = 0 - - async def stop_after_interval(_seconds): - nonlocal sleep_calls - sleep_calls += 1 - if sleep_calls == 2: - worker._is_running = False - - async def successful_sweep(): - nonlocal sweep_calls - sweep_calls += 1 - - monkeypatch.setattr(newsdom_worker_module.asyncio, "sleep", stop_after_interval) - monkeypatch.setattr(worker, "_sweep", successful_sweep) - await worker._run_loop() - - assert sleep_calls == 2 - assert sweep_calls == 1 - - -@pytest.mark.asyncio -async def test_worker_loop_skips_interval_when_sweep_stops_worker(monkeypatch): - worker = NewsdomRecognitionWorker(interval_seconds=1) - worker._is_running = True - sleep_calls = 0 - - async def record_sleep(_seconds): - nonlocal sleep_calls - sleep_calls += 1 - - async def stopping_sweep(): - worker._is_running = False - - monkeypatch.setattr(newsdom_worker_module.asyncio, "sleep", record_sleep) - monkeypatch.setattr(worker, "_sweep", stopping_sweep) - await worker._run_loop() - - assert sleep_calls == 1 diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index fffbde5d5..485d21867 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -303,7 +303,7 @@ def construct_mapping( def test_stepsecurity_remediation_adds_pinned_audit_hardening() -> None: harden_runner_ref = ( - "step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0" + "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4" ) # Governance/security workflows (codeql, dependency-review, scorecard, # trivy) are centralized in the org-level ContextualWisdomLab/.github @@ -313,6 +313,7 @@ def test_stepsecurity_remediation_adds_pinned_audit_hardening() -> None: ".github/workflows/app-ci.yml", ".github/workflows/bandit.yml", ".github/workflows/docker-publish.yml", + ".github/workflows/mail-smoke.yml", ".github/workflows/pr-governance.yml", ] @@ -321,14 +322,6 @@ def test_stepsecurity_remediation_adds_pinned_audit_hardening() -> None: assert harden_runner_ref in workflow assert "egress-policy: audit" in workflow - # mail-smoke seeds live mailbox/DAV credentials on a self-hosted runner, so - # it is hardened one level further: egress is blocked to an allowlist, not - # merely audited, so checked-out code cannot exfiltrate the secrets. - mail_smoke_workflow = read_repo_text(".github/workflows/mail-smoke.yml") - assert harden_runner_ref in mail_smoke_workflow - assert "egress-policy: block" in mail_smoke_workflow - assert "allowed-endpoints:" in mail_smoke_workflow - dependency_review_workflow = read_repo_text( ".github/workflows/dependency-review.yml" ) @@ -368,13 +361,6 @@ def test_stepsecurity_remediation_adds_pinned_audit_hardening() -> None: assert "rev: v2.17.2" in pre_commit -def test_actionlint_recognizes_the_mail_egress_runner_label() -> None: - actionlint_config = read_repo_text(".github/actionlint.yaml") - - assert "self-hosted-runner:" in actionlint_config - assert "- mail-egress" in actionlint_config - - def test_github_actions_unpinned_major_refs_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -571,27 +557,27 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in workflow assert ( workflow.count( - "docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0" + "docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0" ) == 2 ) assert ( workflow.count( - "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0" + "docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0" ) == 2 ) assert ( - "docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0" + "docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0" in workflow ) assert ( - "docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0" + "docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0" in workflow ) assert ( workflow.count( - "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0" + "docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0" ) == 2 ) @@ -957,11 +943,6 @@ def test_pr_governance_uses_metadata_only_events_without_checkout_or_admin_merge assert "CHECK_RUN_PR_NUMBER" in workflow assert "headRefOid" in gate_script assert "mergeStateStatus" in gate_script - assert "Merge state lookup attempt" in gate_script - assert "Merge state is still UNKNOWN after 4 attempts" in gate_script - assert "PR state became %s during merge-state refresh" in gate_script - assert "PR head changed during gate evaluation" in gate_script - assert "skipping stale gate publication" in gate_script assert "gh pr checks" in gate_script and "--required" in gate_script assert "no required checks reported" in gate_script assert "no legacy required status contexts reported" in gate_script @@ -974,7 +955,6 @@ def test_pr_governance_uses_metadata_only_events_without_checkout_or_admin_merge assert "coderabbitai" in gate_script assert "/issues/${PR_NUMBER}/comments" in gate_script assert "COMMENT_MARKER" in gate_script - assert "no current blocking failures remain" in gate_script assert "Waiting for" in gate_script assert "reviewThreads" in gate_script assert "CHANGES_REQUESTED" in gate_script diff --git a/backend/tests/test_repo_hygiene.py b/backend/tests/test_repo_hygiene.py index 86316f80f..257bb6db3 100644 --- a/backend/tests/test_repo_hygiene.py +++ b/backend/tests/test_repo_hygiene.py @@ -1,5 +1,8 @@ +import re from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).resolve().parents[2] @@ -102,7 +105,7 @@ def test_backend_requirements_pin_ruff_for_deterministic_ci(): requirements = (REPO_ROOT / "backend" / "requirements.txt").read_text() assert "\nruff\n" not in f"\n{requirements}\n" - assert "ruff==0.15.21" in requirements + assert "ruff==0.15.20" in requirements def test_compose_gateway_services_disable_privilege_escalation(): @@ -113,15 +116,10 @@ def test_compose_gateway_services_disable_privilege_escalation(): assert "- no-new-privileges:true" in traefik_block -def test_infra_compose_services_use_read_only_hardening_anchor(): +def test_infra_compose_services_use_explicit_read_only_hardening(): compose = (REPO_ROOT / "docker-compose.infra.yml").read_text() - - assert "x-service-hardening: &service-hardening" in compose - assert "security_opt:" in compose - assert "- no-new-privileges:true" in compose - assert "read_only: true" in compose - - for service in ( + services = yaml.safe_load(compose)["services"] + expected_services = ( "traefik", "prometheus", "grafana", @@ -129,41 +127,41 @@ def test_infra_compose_services_use_read_only_hardening_anchor(): "tempo", "otel-collector", "keycloak", - ): - assert f" {service}:\n <<: *service-hardening" in compose + ) - assert "GF_SECURITY_ADMIN_PASSWORD=admin" not in compose - assert ( - "GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:" - "?GRAFANA_ADMIN_PASSWORD is not set}" - ) in compose + assert "&service-hardening" not in compose + assert "<<:" not in compose + assert set(services) == set(expected_services) + for service in expected_services: + service_config = services[service] + assert service_config["security_opt"] == ["no-new-privileges:true"] + assert service_config["read_only"] is True + assert "/tmp" in service_config["tmpfs"] def test_screenshot_utility_allows_only_local_static_routes(): screenshot_script = (REPO_ROOT / "frontend" / "screenshot.cjs").read_text() - - assert "SCREENSHOT_ORIGIN = 'http://127.0.0.1:3000'" in screenshot_script - assert "async function gotoScreenshotRoute(page, route)" in screenshot_script - assert "switch (route)" in screenshot_script - for route, url in { - "/": "http://127.0.0.1:3000/", - "/mail": "http://127.0.0.1:3000/mail", - "/calendar": "http://127.0.0.1:3000/calendar", - "/tasks": "http://127.0.0.1:3000/tasks", - "/projects": "http://127.0.0.1:3000/projects", - "/search": "http://127.0.0.1:3000/search", - "/data": "http://127.0.0.1:3000/data", - "/ai-hub": "http://127.0.0.1:3000/ai-hub", - "/security": "http://127.0.0.1:3000/security", - "/settings": "http://127.0.0.1:3000/settings", - }.items(): - assert f"case '{route}':" in screenshot_script - assert f"page.goto('{url}', GOTO_OPTIONS)" in screenshot_script - assert "Unsupported screenshot route" in screenshot_script - assert "nosemgrep" not in screenshot_script + expected_targets = [ + "http://127.0.0.1:3000/", + "http://127.0.0.1:3000/mail", + "http://127.0.0.1:3000/calendar", + "http://127.0.0.1:3000/tasks", + "http://127.0.0.1:3000/projects", + "http://127.0.0.1:3000/search", + "http://127.0.0.1:3000/data", + "http://127.0.0.1:3000/ai-hub", + "http://127.0.0.1:3000/security", + "http://127.0.0.1:3000/settings", + ] + navigation_targets = re.findall(r"page\.goto\('([^']+)'", screenshot_script) + + assert navigation_targets == expected_targets + assert screenshot_script.count("page.goto(") == len(expected_targets) + assert "await target.navigate(page)" in screenshot_script assert "console.error('Failed to capture route'" in screenshot_script assert "page.goto(url" not in screenshot_script - assert "new URL(" not in screenshot_script + assert "page.goto(route" not in screenshot_script + assert "page.goto(`" not in screenshot_script assert "http://localhost:3000${route}" not in screenshot_script assert "console.error(`Failed to capture ${route}:`" not in screenshot_script diff --git a/backend/tests/test_runner_dav_adapters.py b/backend/tests/test_runner_dav_adapters.py index c0220d021..7eb1900d0 100644 --- a/backend/tests/test_runner_dav_adapters.py +++ b/backend/tests/test_runner_dav_adapters.py @@ -519,70 +519,3 @@ def test_dav_adapter_rejects_invalid_source_url_boundaries(monkeypatch): ) with pytest.raises(ValueError, match="invalid_source_url"): adapters._target_url("https://empty.example.com", "/task.md") # noqa: SLF001 - - -@pytest.mark.asyncio -async def test_carddav_adapter_puts_vcard_with_if_match(): - fake_client = FakeDavClient( - FakeDavResponse(201, headers={"ETag": "etag-vcard"}) - ) - adapters = LocalDavAdapters( - [ - LocalDavSourceConfig( - source_id="carddav_src_1", - protocol="carddav", - base_url="https://dav.example.com/carddav/addressbooks/alice", - username="alice", - password="dav-secret", - writeback_enabled=True, - ) - ], - http_client_factory=lambda: fake_client, - ) - - result = await adapters.write_carddav( - { - "source_id": "carddav_src_1", - "target_path": "/contact.vcf", - "content": "BEGIN:VCARD\r\nEND:VCARD\r\n", - "if_match": "etag-before", - } - ) - - assert result["status"] == "success" - assert result["provider_write_executed"] is True - assert fake_client.requests[0]["url"] == ( - "https://dav.example.com/carddav/addressbooks/alice/contact.vcf" - ) - assert fake_client.requests[0]["headers"]["Content-Type"] == ( - "text/vcard; charset=utf-8" - ) - - -@pytest.mark.asyncio -async def test_carddav_adapter_rejects_protocol_mismatch(): - fake_client = FakeDavClient(FakeDavResponse(201)) - adapters = LocalDavAdapters( - [ - LocalDavSourceConfig( - source_id="caldav_src_1", - protocol="caldav", - base_url="https://dav.example.com/caldav/alice", - writeback_enabled=True, - ) - ], - http_client_factory=lambda: fake_client, - ) - - # A carddav write against a caldav-only source must not be dispatched. - result = await adapters.write_carddav( - { - "source_id": "caldav_src_1", - "target_path": "/contact.vcf", - "content": "BEGIN:VCARD\r\nEND:VCARD\r\n", - "if_match": "etag-before", - } - ) - assert result["status"] == "error" - assert result["error_code"] == "source_not_configured" - assert fake_client.requests == [] diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index e8436c9e1..4a59177a4 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -993,3 +993,106 @@ def test_validate_webhook_url_invalid_port(): from api.tools import validate_webhook_url with pytest.raises(ValueError, match="Webhook URL port must be valid"): validate_webhook_url("https://example.com:9999999/webhook") + + +@pytest.mark.asyncio +async def test_detect_language_internal_coverage(): + from api.tools import email_translator_handler + res_ko = await email_translator_handler({"text": "안녕하세요", "target_language": "en"}) + assert "source_language_detected" in res_ko + + res_unknown = await email_translator_handler({"text": "12345", "target_language": "en"}) + assert "source_language_detected" in res_unknown + +@pytest.mark.asyncio +async def test_sentiment_analyzer_internal_coverage(): + from api.tools import sentiment_analyzer_handler + res_pos = await sentiment_analyzer_handler({"text": "정말 훌륭하고 감사합니다"}) + assert res_pos["sentiment"] == "positive" + + res_neu = await sentiment_analyzer_handler({"text": "오늘 회의 일정 공유드립니다"}) + assert res_neu["sentiment"] == "neutral" + + +def test_validate_webhook_url_details_no_hostname(): + from api.tools import validate_webhook_url_details + with pytest.raises(ValueError, match="Webhook URL must include a host"): + validate_webhook_url_details("https:///?query=1") + + +def test_validate_webhook_url_details_invalid_port(): + from api.tools import validate_webhook_url_details + with pytest.raises(ValueError, match="Webhook URL port must be valid"): + validate_webhook_url_details("https://example.com:99999999999") + + +@pytest.mark.asyncio +async def test_json_validator_handler_valid(): + from api.tools import json_validator_handler + + res = await json_validator_handler({"json_string": '{"key":"value"}'}) + assert res["is_valid"] is True + assert json.loads(res["formatted_json"]) == {"key": "value"} + assert res["error"] is None + + +@pytest.mark.asyncio +async def test_json_validator_handler_invalid(): + from api.tools import json_validator_handler + res = await json_validator_handler({"json_string": '{"key":"value"'}) + assert res["is_valid"] is False + assert res["formatted_json"] is None + assert res["error"] is not None + +@pytest.mark.asyncio +async def test_json_validator_handler_none(): + from api.tools import json_validator_handler + res = await json_validator_handler({}) + assert res["is_valid"] is False + assert res["error"] == "json_string must not be None" + +@pytest.mark.asyncio +async def test_hash_generator_handler(): + from api.tools import hash_generator_handler + import hashlib + res = await hash_generator_handler({"text": "hello", "algorithm": "md5"}) + assert res["hash"] == hashlib.md5(b"hello").hexdigest() + assert res["algorithm"] == "md5" + + +@pytest.mark.asyncio +async def test_hash_generator_handler_invalid_alg(): + from api.tools import hash_generator_handler + with pytest.raises(ValueError, match="Unsupported hash algorithm: invalid_alg"): + await hash_generator_handler({"text": "hello", "algorithm": "invalid_alg"}) + +@pytest.mark.asyncio +async def test_hash_generator_handler_none(): + from api.tools import hash_generator_handler + res = await hash_generator_handler({"algorithm": "md5"}) + assert res["algorithm"] == "md5" + assert "hash" in res + +@pytest.mark.asyncio +async def test_url_parser_handler(): + from api.tools import url_parser_handler + res = await url_parser_handler({"url": "https://example.com/path?q=1#frag"}) + assert res["scheme"] == "https" + assert res["netloc"] == "example.com" + assert res["path"] == "/path" + assert res["query"] == "q=1" + assert res["fragment"] == "frag" + + +@pytest.mark.asyncio +async def test_url_parser_handler_invalid(): + from api.tools import url_parser_handler + with pytest.raises(ValueError, match="Invalid URL"): + await url_parser_handler({"url": "http://[::1"}) + +@pytest.mark.asyncio +async def test_url_parser_handler_none(): + from api.tools import url_parser_handler + res = await url_parser_handler({}) + assert res["scheme"] == "" + assert res["netloc"] == "" diff --git a/backend/uv.lock b/backend/uv.lock index 6a80d8a7c..550daf5bd 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -297,71 +297,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/76/32c1826309beaf4604c54accef108fdd611e5e5e93f2f5192f050cd5f6bd/coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80", size = 221497, upload-time = "2026-07-12T20:56:37.628Z" }, - { url = "https://files.pythonhosted.org/packages/db/5c/b88ce0d68fa550c7f3b58617fbf363bce64df5bf8295a01b627e4696e022/coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189", size = 221854, upload-time = "2026-07-12T20:56:39.033Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/8509fd2a66fc4e0a829f76a0f0b1dc3cc163368352435b5f243168658077/coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615", size = 253359, upload-time = "2026-07-12T20:56:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/e5/81/c7009ed7ea9765adb2b9d095054d748266fae5f07ac6c5f925f33715fcde/coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3", size = 256096, upload-time = "2026-07-12T20:56:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/21/52/dc8ee03968a5ba86e2da5aa48ddc9e3747bd65d63825fdce2d96acb9c5ff/coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304", size = 257211, upload-time = "2026-07-12T20:56:43.513Z" }, - { url = "https://files.pythonhosted.org/packages/b8/27/95d7623908da8937deb53d48efcdbf423907a47540e63c62fa21372c652b/coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c", size = 259473, upload-time = "2026-07-12T20:56:44.974Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3b/730d761928de97d585465680b568ae69622fb40716babadeabffe75cb51b/coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b", size = 253759, upload-time = "2026-07-12T20:56:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fc/6b9277acff1f9484b6c12857af5774689d1a6a95e13265f7405329d2f5da/coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61", size = 255131, upload-time = "2026-07-12T20:56:48.073Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f2/c704f86129594ba34e25a64695d2068c71d51c2b98907184d716c94f4aec/coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e", size = 253275, upload-time = "2026-07-12T20:56:49.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/29/80fee8af47de4a6dce71ccf2938491f444687a756af258a56d8469b8f1b0/coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5", size = 257345, upload-time = "2026-07-12T20:56:51.038Z" }, - { url = "https://files.pythonhosted.org/packages/20/21/a1e7d7ed1b48a8adf8fd5154d9e83fcc5ad8e6ff20ae00e44865057dce8d/coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d", size = 252844, upload-time = "2026-07-12T20:56:52.535Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8c/a4bc26e6ee207d412f3678f04d74be1550e83140563ca0e4997510579712/coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2", size = 254716, upload-time = "2026-07-12T20:56:53.968Z" }, - { url = "https://files.pythonhosted.org/packages/11/9d/8ad0266ecfada6353cf6627a1a02294cf55a907521b6ee0bd7b770cfd659/coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76", size = 223554, upload-time = "2026-07-12T20:56:55.583Z" }, - { url = "https://files.pythonhosted.org/packages/81/6d/24224929e06c6e05a93f738bc5f9e8e6ab658f8f1d9b823e7b85430e28b8/coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374", size = 224087, upload-time = "2026-07-12T20:56:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/35/23/f81441dd01de88e53c97842e706907b307d9078918c3f4998b11e9ac7250/coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a", size = 223472, upload-time = "2026-07-12T20:56:58.594Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1e/6fa289d7993a2a39f1b283ddb58c4bfec80f7800be654b8ba8a9f6a07c63/coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a", size = 221519, upload-time = "2026-07-12T20:57:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/0db4902a0588234a70ab0218073c0b20fbc5c740aa35f91d360160a2ebc9/coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b", size = 221895, upload-time = "2026-07-12T20:57:01.867Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cb/3719783865092dac5e08df842730305ee9ab1973ae7ddb6fbdf27d401f30/coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e", size = 252882, upload-time = "2026-07-12T20:57:03.459Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5e/caf3abbdbb22629626160ffc9c017eb995b7cb11c0be46b974834cef1792/coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a", size = 255479, upload-time = "2026-07-12T20:57:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f1/d60f375bfe095fef944f0f19427aefdbf9bdd5a9571c41a4bf6e2f5fdb81/coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c", size = 256715, upload-time = "2026-07-12T20:57:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/d7/17/8b0cbc90d02dc5adad4d9034c1824ec3fa567771b4c39d9c1e3f9b1431b8/coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90", size = 258845, upload-time = "2026-07-12T20:57:08.092Z" }, - { url = "https://files.pythonhosted.org/packages/92/29/c5e69f5fb75c322e9a3e4ef64d02eebfc3d66efceccc8514ff80a3c13a56/coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f", size = 253098, upload-time = "2026-07-12T20:57:09.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/57/21144252fdd0c01d707d48fbcea13a80b0b7c42ced3f299f885ab8978c3a/coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8", size = 254844, upload-time = "2026-07-12T20:57:11.141Z" }, - { url = "https://files.pythonhosted.org/packages/59/2a/499a28a322b0ce6768328e6c5bb2e2ad00ac068a7c7adb2ecd8533c8c5d9/coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a", size = 252807, upload-time = "2026-07-12T20:57:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/928a95da5da8b60f2b00e1482c7787b3316188e6d2d227fb8e124ada43a1/coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31", size = 256965, upload-time = "2026-07-12T20:57:14.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/10/889adbc1b8c9f866ed51e18a98bcafc0259fb9d29b81f50a719407c64ea8/coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad", size = 252628, upload-time = "2026-07-12T20:57:15.892Z" }, - { url = "https://files.pythonhosted.org/packages/1a/30/a5e1871e5d93416511f8e359d1ccebfe0cbb050a1bbf7dd20228533ec0cf/coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a", size = 254399, upload-time = "2026-07-12T20:57:17.703Z" }, - { url = "https://files.pythonhosted.org/packages/2d/26/c36fbffd549dadbdd1a75827528fb00a4c46aa3187b007b750b1e2cebbf2/coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e", size = 223564, upload-time = "2026-07-12T20:57:19.253Z" }, - { url = "https://files.pythonhosted.org/packages/16/fc/becbb9d2c4206d242b9b1e1e8e24a42f7926c0200dd3c788b9fab4bb96d5/coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7", size = 224106, upload-time = "2026-07-12T20:57:21.108Z" }, - { url = "https://files.pythonhosted.org/packages/d3/30/1cfc641461369b6858799fca61c0a8b5edc490c519bf7c636ffa6bbf556f/coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49", size = 223497, upload-time = "2026-07-12T20:57:22.734Z" }, - { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, - { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, - { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, - { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, - { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, - { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, - { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, - { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, - { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, - { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, - { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, - { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, - { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, - { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, - { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, - { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, - { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, - { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, - { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, ] [[package]] @@ -456,7 +456,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.139.0" +version = "0.138.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -465,9 +465,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, ] [[package]] @@ -1033,7 +1033,7 @@ requires-dist = [ { name = "cryptography", specifier = "==49.0.0" }, { name = "defusedxml", specifier = "==0.7.1" }, { name = "email-validator", specifier = "==2.3.0" }, - { name = "fastapi", specifier = "==0.139.0" }, + { name = "fastapi", specifier = "==0.138.2" }, { name = "google-api-python-client", specifier = "==2.198.0" }, { name = "google-auth-httplib2", specifier = "==0.4.0" }, { name = "google-auth-oauthlib", specifier = "==1.4.0" }, @@ -1041,12 +1041,12 @@ requires-dist = [ { name = "httpx", specifier = "==0.28.1" }, { name = "icalendar", specifier = "==7.2.0" }, { name = "langchain-text-splitters", specifier = "==1.1.2" }, - { name = "openai", specifier = "==2.45.0" }, + { name = "openai", specifier = "==2.44.0" }, { name = "opentelemetry-api", specifier = "==1.43.0" }, { name = "opentelemetry-exporter-otlp", specifier = "==1.43.0" }, { name = "opentelemetry-instrumentation-fastapi", specifier = "==0.64b0" }, { name = "opentelemetry-sdk", specifier = "==1.43.0" }, - { name = "pgvector", specifier = "==0.5.0" }, + { name = "pgvector", specifier = "==0.4.2" }, { name = "prometheus-fastapi-instrumentator", specifier = "==8.0.2" }, { name = "protobuf", specifier = "==7.35.1" }, { name = "pydantic-settings", specifier = "==2.14.2" }, @@ -1057,17 +1057,68 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==2.0.51" }, { name = "starlette", specifier = "==1.3.1" }, { name = "tiktoken", specifier = "==0.13.0" }, - { name = "uvicorn", specifier = "==0.51.0" }, - { name = "websockets", specifier = "==16.1" }, + { name = "uvicorn", specifier = "==0.49.0" }, + { name = "websockets", specifier = "==16.0" }, { name = "wheel", specifier = "==0.47.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "coverage", specifier = "==7.15.1" }, + { name = "coverage", specifier = "==7.14.3" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, - { name = "ruff", specifier = "==0.15.21" }, + { name = "ruff", specifier = "==0.15.20" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] [[package]] @@ -1081,7 +1132,7 @@ wheels = [ [[package]] name = "openai" -version = "2.45.0" +version = "2.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1093,9 +1144,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, ] [[package]] @@ -1330,11 +1381,14 @@ wheels = [ [[package]] name = "pgvector" -version = "0.5.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, ] [[package]] @@ -1800,27 +1854,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -2088,92 +2142,60 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] [[package]] name = "websockets" -version = "16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, - { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, - { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, - { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, - { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, - { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, - { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, - { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, - { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, - { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, - { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, - { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, - { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, - { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, - { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, - { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, - { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, - { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, - { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, - { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, - { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, - { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, - { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, - { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, - { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, - { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, - { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, - { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, - { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, - { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, - { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, - { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, - { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, - { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] diff --git a/connector/main.py b/connector/main.py index 6a35cc7a9..547c7509d 100644 --- a/connector/main.py +++ b/connector/main.py @@ -36,200 +36,17 @@ def _target_ws_url(environ: Mapping[str, str], registration_token: str) -> str: return f"{template.rstrip('/')}/{escaped_token}" -def build_connector( - environ: Mapping[str, str] = os.environ, - *, - handlers: Mapping[str, object] | None = None, -) -> SelfHostedConnector: +def build_connector(environ: Mapping[str, str] = os.environ) -> SelfHostedConnector: registration_token = _required_env(environ, "NARUON_REGISTRATION_TOKEN") session_token = _required_env(environ, "NARUON_SESSION_TOKEN") - handler_kwargs = dict(handlers) if handlers else {} return SelfHostedConnector( _target_ws_url(environ, registration_token), session_token, - **handler_kwargs, ) -def _connector_scope( - environ: Mapping[str, str], -) -> tuple[str | None, frozenset[str] | None]: - """Return the (organization_id, user-id allowlist) this runner may serve. - - ``NARUON_CONNECTOR_ORGANIZATION_ID`` selects the organization scope; when - unset the runner serves only personal-scope rows (``organization_id IS - NULL``). ``NARUON_CONNECTOR_USER_IDS`` (comma-separated) further restricts - loading to an explicit user allowlist; when set but empty it loads nothing. - """ - organization_id = ( - environ.get("NARUON_CONNECTOR_ORGANIZATION_ID") or "" - ).strip() or None - raw_user_ids = (environ.get("NARUON_CONNECTOR_USER_IDS") or "").strip() - user_ids: frozenset[str] | None = None - if raw_user_ids: - user_ids = frozenset( - user_id.strip() for user_id in raw_user_ids.split(",") if user_id.strip() - ) - return organization_id, user_ids - - -async def _load_seeded_handlers( - environ: Mapping[str, str] = os.environ, -) -> dict[str, object]: - """Construct local protocol handlers from the seeded DB accounts. - - The connector reads mail/DAV credentials from the database at runtime (the - KV path) -- never from ``os.getenv``. Loading is bound to the runner's - configured scope (see ``_connector_scope``): a shared multi-tenant database - never hands this runner another tenant's credentials. Legacy - ``caldav_accounts`` rows carry no organization column, so they load only - for personal-scope runners. Every DAV source is keyed by its opaque - ``source_uid``, so gateway payloads select accounts by unguessable scoped - identifiers rather than raw user ids. - """ - from sqlalchemy import select - - from db.models import ( - CaldavAccount, - CarddavAccount, - TenantConfig, - WebdavAccount, - ) - from db.session import AsyncSessionLocal - from runner.local_dav_adapters import LocalDavAdapters, LocalDavSourceConfig - from runner.local_mail_adapters import LocalMailAccountConfig, LocalMailAdapters - - organization_id, user_ids = _connector_scope(environ) - - def _scoped(statement, model, *, has_organization_column: bool = True): - if has_organization_column: - if organization_id is None: - statement = statement.where(model.organization_id.is_(None)) - else: - statement = statement.where( - model.organization_id == organization_id - ) - if user_ids is not None: - statement = statement.where(model.user_id.in_(sorted(user_ids))) - return statement - - mail_accounts: list[LocalMailAccountConfig] = [] - dav_sources: list[LocalDavSourceConfig] = [] - - async with AsyncSessionLocal() as session: - tenant_rows = ( - (await session.execute(_scoped(select(TenantConfig), TenantConfig))) - .scalars() - .all() - ) - for row in tenant_rows: - mail_accounts.append( - LocalMailAccountConfig( - account=row.user_id, - user_id=row.user_id, - organization_id=row.organization_id, - smtp_server=row.smtp_server, - smtp_port=row.smtp_port, - smtp_username=row.smtp_username, - smtp_password=row.smtp_password, - imap_server=row.imap_server, - imap_port=row.imap_port, - imap_username=row.imap_username, - imap_password=row.imap_password, - ) - ) - - if organization_id is None: - caldav_rows = ( - ( - await session.execute( - _scoped( - select(CaldavAccount), - CaldavAccount, - has_organization_column=False, - ) - ) - ) - .scalars() - .all() - ) - for caldav in caldav_rows: - dav_sources.append( - LocalDavSourceConfig( - source_id=f"caldav_{caldav.user_id}", - protocol="caldav", - base_url=caldav.server_url, - username=caldav.username, - password=caldav.credentials_encrypted, - ) - ) - carddav_rows = ( - (await session.execute(_scoped(select(CarddavAccount), CarddavAccount))) - .scalars() - .all() - ) - for carddav in carddav_rows: - dav_sources.append( - LocalDavSourceConfig( - source_id=carddav.source_uid, - protocol="carddav", - base_url=carddav.server_url, - username=carddav.username, - password=carddav.credentials_encrypted, - writeback_enabled=bool(carddav.writeback_enabled), - ) - ) - webdav_rows = ( - (await session.execute(_scoped(select(WebdavAccount), WebdavAccount))) - .scalars() - .all() - ) - for webdav in webdav_rows: - dav_sources.append( - LocalDavSourceConfig( - source_id=webdav.source_uid, - protocol="webdav", - base_url=webdav.server_url, - username=webdav.username, - password=webdav.credentials_encrypted, - writeback_enabled=bool(webdav.writeback_enabled), - ) - ) - - mail_adapters = LocalMailAdapters(mail_accounts) - dav_adapters = LocalDavAdapters(dav_sources) - return { - "imap_fetch_handler": mail_adapters.fetch_imap, - "smtp_send_handler": mail_adapters.send_smtp, - "webdav_write_handler": dav_adapters.write_webdav, - "caldav_write_handler": dav_adapters.write_caldav, - "carddav_write_handler": dav_adapters.write_carddav, - } - - async def amain(environ: Mapping[str, str] = os.environ) -> int: - # Validate required connector configuration before any database work, so a - # misconfigured connector fails closed on config (exit 2) rather than - # attempting a DB load it has no tokens to use. - _required_env(environ, "NARUON_REGISTRATION_TOKEN") - _required_env(environ, "NARUON_SESSION_TOKEN") - - handlers: dict[str, object] | None = None - if (environ.get("DATABASE_URL") or "").strip(): - try: - handlers = await _load_seeded_handlers(environ) - except Exception as exc: # noqa: BLE001 - a configured DB must load, or we stop - logger.error( - "Failed to load seeded DB handlers (%s); refusing to start a " - "connector that would report healthy without its adapters.", - type(exc).__name__, - ) - return 3 - else: - logger.info( - "DATABASE_URL is not configured; starting without local adapters." - ) - connector = build_connector(environ, handlers=handlers) + connector = build_connector(environ) await connector.connect() return 0 diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml index 0abc0c37f..2c8a621af 100644 --- a/docker-compose.infra.yml +++ b/docker-compose.infra.yml @@ -1,21 +1,13 @@ version: '3.8' -x-service-hardening: &service-hardening - security_opt: - - no-new-privileges:true - read_only: true - tmpfs: - - /tmp - services: traefik: - <<: *service-hardening + image: traefik:v2.10 security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp - image: traefik:v2.10 command: - "--api.insecure=true" - "--providers.docker=true" @@ -30,11 +22,10 @@ services: - naruon-network prometheus: - <<: *service-hardening + image: prom/prometheus:latest security_opt: - no-new-privileges:true read_only: true - image: prom/prometheus:latest tmpfs: - /prometheus - /tmp @@ -46,13 +37,12 @@ services: - naruon-network grafana: - <<: *service-hardening + image: grafana/grafana:latest security_opt: - no-new-privileges:true read_only: true - image: grafana/grafana:latest environment: - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD is not set} + - GF_SECURITY_ADMIN_PASSWORD=admin tmpfs: - /tmp - /var/lib/grafana @@ -65,11 +55,10 @@ services: - naruon-network loki: - <<: *service-hardening + image: grafana/loki:2.9.2 security_opt: - no-new-privileges:true read_only: true - image: grafana/loki:2.9.2 tmpfs: - /loki - /tmp @@ -80,13 +69,12 @@ services: - naruon-network tempo: - <<: *service-hardening + image: grafana/tempo:latest security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp - image: grafana/tempo:latest command: [ "-config.file=/etc/tempo.yaml" ] volumes: - ./observability/tempo.yaml:/etc/tempo.yaml:ro @@ -97,13 +85,12 @@ services: - naruon-network otel-collector: - <<: *service-hardening + image: otel/opentelemetry-collector:0.88.0 security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp - image: otel/opentelemetry-collector:0.88.0 ports: - "4317:4317" # OTLP gRPC - "4318:4318" # OTLP HTTP @@ -111,11 +98,10 @@ services: - naruon-network keycloak: - <<: *service-hardening + image: quay.io/keycloak/keycloak:24.0.0 security_opt: - no-new-privileges:true read_only: true - image: quay.io/keycloak/keycloak:24.0.0 command: start-dev environment: KC_DB: dev-file diff --git a/docker-compose.yml b/docker-compose.yml index a6adf60cb..91b321118 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,37 +30,6 @@ services: ports: - "11434:11434" - # NewsDOM PDF DOM recognition sidecar, built from a commit-pinned git - # context (no submodule: the PR-scope security gate fails closed on gitlink - # entries, and a URL context keeps the default checkout self-contained). - # Optional: the backend degrades gracefully (PDFs stay pending) when this - # service is absent or unconfigured. - # - # Gated behind the "newsdom" profile so the default `docker compose build` - # / `config` path (e.g. the coverage-evidence CI gate) never builds the - # sidecar. Bring it up intentionally with: - # docker compose --profile newsdom up - # (or COMPOSE_PROFILES=newsdom). - newsdom: - profiles: - - newsdom - build: - # Commit-pinned for reproducible builds; bump deliberately. - context: https://github.com/ContextualWisdomLab/newsdom-api.git#6558a4238b1614c39f7e96a961815747ac4d49ef - dockerfile: Dockerfile - # No host `ports` mapping: the sidecar has no authentication on /parse, so - # it must stay on the internal compose network only. The backend reaches it - # via the service name (ALLOWED_NEWSDOM_HOSTS: newsdom). Do not publish it - # to the host. - expose: - - "8000" - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)\""] - interval: 10s - timeout: 5s - retries: 5 - start_period: 20s - backend: build: context: . @@ -72,11 +41,6 @@ services: DEBUG: "false" ALLOW_LOCAL_LLM_PROVIDERS: "true" ALLOWED_LLM_BASE_URL_HOSTS: ollama - # NewsDOM sidecar allowlist. The actual base_url + bearer token are - # configured per-organization in the database (NewsdomProvider), NOT here; - # this only authorizes the container hostname for the SSRF-safe client. - ALLOW_LOCAL_NEWSDOM_PROVIDERS: "true" - ALLOWED_NEWSDOM_HOSTS: newsdom AUTH_SESSION_HMAC_SECRET: ${AUTH_SESSION_HMAC_SECRET} ENCRYPTION_KEY: ${ENCRYPTION_KEY} OPENAI_API_KEY: ollama @@ -88,14 +52,6 @@ services: condition: service_healthy ollama: condition: service_started - # Optional sidecar (see the "newsdom" profile above). required: false so - # that when the profile is inactive the dependency is dropped from the - # graph instead of failing config/build; the backend degrades gracefully - # (PDFs stay pending). When the profile is active, the backend still waits - # for the sidecar to start. - newsdom: - condition: service_started - required: false ports: - "8000:8000" command: diff --git a/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-pgtrgm.html b/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-pgtrgm.html index 2e6b6e89e..0f09fc8cd 100644 --- a/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-pgtrgm.html +++ b/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-pgtrgm.html @@ -26,6 +26,9 @@ + + +
diff --git a/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-unaccent.html b/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-unaccent.html index eb13171f0..266b52f5e 100644 --- a/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-unaccent.html +++ b/docs/research/language-agnostic-hybrid-retrieval/standards/postgresql-current-unaccent.html @@ -26,6 +26,9 @@ + + +
diff --git a/docs/research/language-agnostic-hybrid-retrieval/standards/unicode-uax15-normalization-forms.html b/docs/research/language-agnostic-hybrid-retrieval/standards/unicode-uax15-normalization-forms.html index d6bdd1153..acb731ce0 100644 --- a/docs/research/language-agnostic-hybrid-retrieval/standards/unicode-uax15-normalization-forms.html +++ b/docs/research/language-agnostic-hybrid-retrieval/standards/unicode-uax15-normalization-forms.html @@ -12,6 +12,9 @@ UAX #15: Unicode Normalization Forms + + + diff --git a/frontend/screenshot.cjs b/frontend/screenshot.cjs index 192e82ccd..95adf32ce 100644 --- a/frontend/screenshot.cjs +++ b/frontend/screenshot.cjs @@ -2,47 +2,19 @@ const { chromium } = require('playwright'); const fs = require('fs'); -const SCREENSHOT_ORIGIN = 'http://127.0.0.1:3000'; -const SCREENSHOT_ROUTES = [ - { route: '/', name: 'home' }, - { route: '/mail', name: 'mail' }, - { route: '/calendar', name: 'calendar' }, - { route: '/tasks', name: 'tasks' }, - { route: '/projects', name: 'projects' }, - { route: '/search', name: 'search' }, - { route: '/data', name: 'data' }, - { route: '/ai-hub', name: 'ai-hub' }, - { route: '/security', name: 'security' }, - { route: '/settings', name: 'settings' }, +const NAVIGATION_OPTIONS = { waitUntil: 'load', timeout: 30000 }; +const SCREENSHOT_TARGETS = [ + { route: '/', name: 'home', navigate: (page) => page.goto('http://127.0.0.1:3000/', NAVIGATION_OPTIONS) }, + { route: '/mail', name: 'mail', navigate: (page) => page.goto('http://127.0.0.1:3000/mail', NAVIGATION_OPTIONS) }, + { route: '/calendar', name: 'calendar', navigate: (page) => page.goto('http://127.0.0.1:3000/calendar', NAVIGATION_OPTIONS) }, + { route: '/tasks', name: 'tasks', navigate: (page) => page.goto('http://127.0.0.1:3000/tasks', NAVIGATION_OPTIONS) }, + { route: '/projects', name: 'projects', navigate: (page) => page.goto('http://127.0.0.1:3000/projects', NAVIGATION_OPTIONS) }, + { route: '/search', name: 'search', navigate: (page) => page.goto('http://127.0.0.1:3000/search', NAVIGATION_OPTIONS) }, + { route: '/data', name: 'data', navigate: (page) => page.goto('http://127.0.0.1:3000/data', NAVIGATION_OPTIONS) }, + { route: '/ai-hub', name: 'ai-hub', navigate: (page) => page.goto('http://127.0.0.1:3000/ai-hub', NAVIGATION_OPTIONS) }, + { route: '/security', name: 'security', navigate: (page) => page.goto('http://127.0.0.1:3000/security', NAVIGATION_OPTIONS) }, + { route: '/settings', name: 'settings', navigate: (page) => page.goto('http://127.0.0.1:3000/settings', NAVIGATION_OPTIONS) }, ]; -const GOTO_OPTIONS = { waitUntil: 'load', timeout: 30000 }; - -async function gotoScreenshotRoute(page, route) { - switch (route) { - case '/': - return page.goto('http://127.0.0.1:3000/', GOTO_OPTIONS); - case '/mail': - return page.goto('http://127.0.0.1:3000/mail', GOTO_OPTIONS); - case '/calendar': - return page.goto('http://127.0.0.1:3000/calendar', GOTO_OPTIONS); - case '/tasks': - return page.goto('http://127.0.0.1:3000/tasks', GOTO_OPTIONS); - case '/projects': - return page.goto('http://127.0.0.1:3000/projects', GOTO_OPTIONS); - case '/search': - return page.goto('http://127.0.0.1:3000/search', GOTO_OPTIONS); - case '/data': - return page.goto('http://127.0.0.1:3000/data', GOTO_OPTIONS); - case '/ai-hub': - return page.goto('http://127.0.0.1:3000/ai-hub', GOTO_OPTIONS); - case '/security': - return page.goto('http://127.0.0.1:3000/security', GOTO_OPTIONS); - case '/settings': - return page.goto('http://127.0.0.1:3000/settings', GOTO_OPTIONS); - default: - throw new Error(`Unsupported screenshot route: ${route}`); - } -} (async () => { if (!fs.existsSync('test-results')) { @@ -51,10 +23,11 @@ async function gotoScreenshotRoute(page, route) { const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1280, height: 1024 } }); - for (const { route, name } of SCREENSHOT_ROUTES) { + for (const target of SCREENSHOT_TARGETS) { + const { route, name } = target; console.log('Taking screenshot for route', route); try { - await gotoScreenshotRoute(page, route); + await target.navigate(page); await page.waitForTimeout(2000); await page.screenshot({ path: `test-results/${name}-screenshot.png`, fullPage: true }); console.log(`Saved test-results/${name}-screenshot.png`); diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs index 07c0bb5b9..261da9812 100644 --- a/frontend/scripts/full-product-ui-smoke.mjs +++ b/frontend/scripts/full-product-ui-smoke.mjs @@ -726,7 +726,7 @@ async function installRoutes(page) { ], }); } - if (endpoint === "/api/llm/summarize") return routeJson(route, { summary: "20억 판매 검토용 맥락 종합입니다.", action_items: ["근거 확인"], confidence: 0.86 }); + if (endpoint === "/api/llm/summarize") return routeJson(route, { summary: "20억 판매 검토용 맥락 종합입니다.", todos: ["근거 확인"], confidence: 0.86 }); if (endpoint === "/api/llm/draft") return routeJson(route, { draft: "검토 가능한 답장 초안입니다." }); if (endpoint === "/api/llm/translate") return routeJson(route, { translation: "번역된 맥락입니다." }); if (endpoint === "/api/emails/send") { diff --git a/frontend/src/app/calendar/page.test.tsx b/frontend/src/app/calendar/page.test.tsx index f6b838cdb..498b9246c 100644 --- a/frontend/src/app/calendar/page.test.tsx +++ b/frontend/src/app/calendar/page.test.tsx @@ -95,45 +95,6 @@ describe("CalendarPage", () => { expect(container.querySelector('button[aria-label="닫기"]')).not.toBeNull(); }); - it("exposes the calendar view switcher as a keyboard-navigable tablist", async () => { - vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(calendarSourceList))); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - act(() => { - root?.render(); - }); - await flushAsyncWork(); - - const tablist = container.querySelector('[role="tablist"][aria-label="일정 보기 방식"]'); - expect(tablist).not.toBeNull(); - const tabs = Array.from(tablist?.querySelectorAll('[role="tab"]') ?? []); - expect(tabs.map((tab) => tab.textContent)).toEqual(["월간 캘린더", "주간 캘린더", "일정 상세", "회의 조율", "일정 후보"]); - - const monthlyTab = tabs[0]; - expect(monthlyTab?.getAttribute("aria-selected")).toBe("true"); - expect(monthlyTab?.tabIndex).toBe(0); - expect(monthlyTab?.getAttribute("aria-controls")).toBe("calendar-view-panel"); - const panel = container.querySelector('#calendar-view-panel[role="tabpanel"]'); - expect(panel?.getAttribute("aria-labelledby")).toBe(monthlyTab?.id); - for (const tab of tabs.slice(1)) { - expect(tab.getAttribute("aria-selected")).toBe("false"); - expect(tab.tabIndex).toBe(-1); - } - - act(() => { - monthlyTab?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); - }); - await flushAsyncWork(); - - const weeklyTab = Array.from(container.querySelectorAll('[role="tab"]')) - .find((tab) => tab.textContent === "주간 캘린더"); - expect(weeklyTab?.getAttribute("aria-selected")).toBe("true"); - expect(weeklyTab?.tabIndex).toBe(0); - expect(container.querySelector('#calendar-view-panel[role="tabpanel"]')?.getAttribute("aria-labelledby")).toBe(weeklyTab?.id); - }); - it("filters rendered calendar events when a calendar visibility checkbox changes", async () => { vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(calendarSourceList))); container = document.createElement("div"); diff --git a/frontend/src/components/CalendarLayout.tsx b/frontend/src/components/CalendarLayout.tsx index b5bba3e7e..aaef96b3c 100644 --- a/frontend/src/components/CalendarLayout.tsx +++ b/frontend/src/components/CalendarLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, Settings } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -27,15 +27,8 @@ import { CalendarWritebackSection } from './calendar/CalendarWritebackSection'; -const CALENDAR_VIEW_MODES = ['월간 캘린더', '주간 캘린더', '일정 상세', '회의 조율', '일정 후보'] as const; -type CalendarViewMode = (typeof CALENDAR_VIEW_MODES)[number]; - -function calendarViewTabId(mode: CalendarViewMode) { - return `calendar-view-tab-${CALENDAR_VIEW_MODES.indexOf(mode)}`; -} - export function CalendarLayout() { - const [viewMode, setViewMode] = useState('월간 캘린더'); + const [viewMode, setViewMode] = useState<'월간 캘린더' | '주간 캘린더' | '일정 상세' | '회의 조율' | '일정 후보'>('월간 캘린더'); const [writebackStatus, setWritebackStatus] = useState('idle'); const [writebackResult, setWritebackResult] = useState(null); const [writebackSources, setWritebackSources] = useState([]); @@ -146,21 +139,6 @@ export function CalendarLayout() { const isWritebackActionDisabled = isWritebackLoading || !isSourceRegistryReady; const isProviderExecutionDisabled = isWritebackActionDisabled || !selectedWritebackSource?.etag; - const handleViewModeKeyDown = (event: KeyboardEvent, mode: CalendarViewMode) => { - const currentIndex = CALENDAR_VIEW_MODES.indexOf(mode); - const lastIndex = CALENDAR_VIEW_MODES.length - 1; - let nextIndex: number; - if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; - else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; - else if (event.key === 'Home') nextIndex = 0; - else if (event.key === 'End') nextIndex = lastIndex; - else return; - event.preventDefault(); - const nextMode = CALENDAR_VIEW_MODES[nextIndex]; - setViewMode(nextMode); - document.getElementById(calendarViewTabId(nextMode))?.focus(); - }; - return (
{/* Left Sidebar - Calendar List */} @@ -182,17 +160,12 @@ export function CalendarLayout() {

2026년 5월

-
- {CALENDAR_VIEW_MODES.map((mode) => ( +
+ {['월간 캘린더', '주간 캘린더', '일정 상세', '회의 조율', '일정 후보'].map((mode) => (
diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index a36eeaad5..5a46737a6 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -136,7 +136,7 @@ describe("EmailDetail", () => { }); } if (url.endsWith("/api/llm/summarize")) { - return jsonResponse({ summary: "Summary", action_items: [] }); + return jsonResponse({ summary: "Summary", todos: [] }); } if (url.endsWith("/api/llm/translate")) { return translation.promise; @@ -180,7 +180,7 @@ describe("EmailDetail", () => { }); } if (url.endsWith("/api/llm/summarize")) { - return jsonResponse({ summary: "Summary", action_items: [] }); + return jsonResponse({ summary: "Summary", todos: [] }); } if (url.endsWith("/api/llm/translate")) { return new Response("Internal Server Error", { status: 500 }); @@ -218,7 +218,7 @@ describe("EmailDetail", () => { const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/emails/15")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "정상 맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "정상 맥락 종합", todos: [] })); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); @@ -290,7 +290,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/emails/thread/thread-a")) return threadAResponse.promise; if (url.endsWith("/api/emails/thread/thread-b")) return threadBResponse.promise; if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); } throw new Error(`Unexpected fetch: ${url}`); }); @@ -369,7 +369,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/llm/summarize")) { return Promise.resolve(jsonResponse({ summary: "출시 메시지의 핵심 맥락입니다.", - action_items: ["캘린더에 출시 리뷰 일정을 반영", "답장 초안 준비"], + todos: ["캘린더에 출시 리뷰 일정을 반영", "답장 초안 준비"], confidence: 0.82, })); } @@ -416,7 +416,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/llm/summarize")) { return Promise.resolve(jsonResponse({ summary: "근거 원본을 확인해야 하는 맥락 종합입니다.", - action_items: ["원본 확인"], + todos: ["원본 확인"], confidence: 0.86, provenance: "mail-thread", })); @@ -498,7 +498,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/llm/summarize")) { return Promise.resolve(jsonResponse({ summary: "후속 실행 항목을 정리해야 합니다.", - action_items: ["담당자 확인", "일정 공유"], + todos: ["담당자 확인", "일정 공유"], })); } if (url.endsWith("/api/tasks/from-email")) { @@ -589,7 +589,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/emails/3")) return standaloneEmailResponse.promise; if (url.endsWith("/api/emails/thread/thread-a")) return threadResponse.promise; if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); } throw new Error(`Unexpected fetch: ${url}`); }); @@ -654,7 +654,7 @@ describe("EmailDetail", () => { return Promise.resolve( jsonResponse({ summary: "출시 계획 검토", - action_items: ["일정 확인"], + todos: ["일정 확인"], confidence: 0.91, }), ); @@ -680,48 +680,6 @@ describe("EmailDetail", () => { expect(container.textContent).not.toContain("Tasks"); }); - it("normalizes legacy synthesis todos into 실행 항목 without crashing", async () => { - const email: TestEmail = { - id: 6, - message_id: "", - thread_id: null, - sender: "legacy@example.com", - recipients: "user@example.com", - subject: "Legacy synthesis payload", - date: "2026-05-11T09:00:00Z", - body: "Please preserve old synthesis payloads during rollout.", - }; - - const fetchMock = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/emails/6")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve( - jsonResponse({ - summary: "이전 응답 형식을 정규화합니다.", - todos: ["근거 확인"], - confidence: 0.86, - }), - ); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - - expect(container.textContent).toContain("실행 항목"); - expect(container.textContent).toContain("근거 확인"); - expect(container.textContent).not.toContain("실행 항목이 없습니다."); - }); - it("runs a requested reply draft command for the selected email", async () => { const email: TestEmail = { id: 7, @@ -747,7 +705,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/emails/7")) return Promise.resolve(jsonResponse(email)); if (url.endsWith("/api/emails/8")) return Promise.resolve(jsonResponse(nextEmail)); if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "출시 업데이트", action_items: ["일정 확인"] })); + return Promise.resolve(jsonResponse({ summary: "출시 업데이트", todos: ["일정 확인"] })); } if (url.endsWith("/api/llm/draft")) { return Promise.resolve(jsonResponse({ draft: "초안 답장입니다." })); @@ -817,7 +775,7 @@ describe("EmailDetail", () => { const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/emails/12")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "후속 조치 없음", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "후속 조치 없음", todos: [] })); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); @@ -886,7 +844,7 @@ describe("EmailDetail", () => { expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/sync"))).toHaveLength(0); await act(async () => { - summaryResponse.resolve(jsonResponse({ summary: "회의 일정", action_items: ["출시 회의 일정 잡기"] })); + summaryResponse.resolve(jsonResponse({ summary: "회의 일정", todos: ["출시 회의 일정 잡기"] })); await summaryResponse.promise; }); await flushAsyncWork(); @@ -929,7 +887,7 @@ describe("EmailDetail", () => { if (url.endsWith("/api/emails/10")) return Promise.resolve(jsonResponse(emailA)); if (url.endsWith("/api/emails/11")) return Promise.resolve(jsonResponse(emailB)); if (url.endsWith("/api/llm/summarize")) { - return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: ["일정 확인"] })); + return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: ["일정 확인"] })); } if (url.endsWith("/api/llm/draft")) return draftResponse.promise; throw new Error(`Unexpected fetch: ${url}`); @@ -976,7 +934,7 @@ describe("EmailDetail", () => { void init; const url = String(input); if (url.endsWith("/api/emails/15")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); if (url.endsWith("/api/llm/draft")) return Promise.reject(new Error("Draft failed")); throw new Error(`Unexpected fetch: ${url}`); }); @@ -1025,7 +983,7 @@ describe("EmailDetail", () => { void init; const url = String(input); if (url.endsWith("/api/emails/16")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); if (url.endsWith("/api/emails/thread/thread-err")) return Promise.reject(new Error("Thread err")); throw new Error(`Unexpected fetch: ${url}`); }); @@ -1060,7 +1018,7 @@ describe("EmailDetail", () => { void init; const url = String(input); if (url.endsWith("/api/emails/17")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); if (url.endsWith("/api/emails/thread/thread-err2")) { callCount++; if (callCount === 1) return Promise.reject(new Error("Thread err")); @@ -1175,7 +1133,7 @@ describe("EmailDetail", () => { void init; const url = String(input); if (url.endsWith("/api/emails/20")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); @@ -1224,7 +1182,7 @@ describe("EmailDetail", () => { void init; const url = String(input); if (url.endsWith("/api/emails/21")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); if (url.endsWith("/api/emails/send")) return Promise.resolve(jsonResponse({ simulated: true })); throw new Error(`Unexpected fetch: ${url}`); }); @@ -1278,7 +1236,7 @@ describe("EmailDetail", () => { void init; const url = String(input); if (url.endsWith("/api/emails/22")) return Promise.resolve(jsonResponse(email)); - if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", action_items: [] })); + if (url.endsWith("/api/llm/summarize")) return Promise.resolve(jsonResponse({ summary: "맥락 종합", todos: [] })); if (url.endsWith("/api/emails/send")) return Promise.reject(new Error("Send failed")); throw new Error(`Unexpected fetch: ${url}`); }); diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index 35263d783..873fd17f1 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -32,19 +32,11 @@ type EmailData = ThreadEmailData & { }; interface LlmData { summary: string; - action_items: string[]; + todos: string[]; provenance?: string; confidence?: number; } -type LlmDataPayload = { - summary?: unknown; - action_items?: unknown; - todos?: unknown; - provenance?: unknown; - confidence?: unknown; -}; - interface CreateTasksFromEmailResponse { created: number; } @@ -79,29 +71,6 @@ function nowMs() { return typeof performance !== "undefined" ? performance.now() : Date.now(); } -function toStringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; -} - -function normalizeLlmData(payload: unknown): LlmData { - if (!payload || typeof payload !== "object") { - throw new Error("Invalid LLM summary response"); - } - - const data = payload as LlmDataPayload; - if (typeof data.summary !== "string") { - throw new Error("Invalid LLM summary response"); - } - - const hasActionItems = Array.isArray(data.action_items); - return { - summary: data.summary, - action_items: hasActionItems ? toStringArray(data.action_items) : toStringArray(data.todos), - provenance: typeof data.provenance === "string" ? data.provenance : undefined, - confidence: typeof data.confidence === "number" ? data.confidence : undefined, - }; -} - export function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { const [email, setEmail] = useState(null); const [threadEmails, setThreadEmails] = useState([]); @@ -216,10 +185,10 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number // Fetch LLM summary in the background const synthesisStartedAt = nowMs(); - apiClient.post('/api/llm/summarize', { email_body: emailJson.body }) + apiClient.post('/api/llm/summarize', { email_body: emailJson.body }) .then((llmJson) => { if (!isMounted) return; - setLlmData(normalizeLlmData(llmJson)); + setLlmData(llmJson); recordProductEvent("latency_guardrail_recorded", { surface: "mail_detail", request_trace_id: createProductEventId("synthesis_trace"), @@ -417,8 +386,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number const handleSyncCalendar = useCallback(async () => { const actionEmailId = emailId; const isCurrentEmail = () => currentEmailIdRef.current === actionEmailId; - const actionItems = llmData?.action_items ?? []; - if (!actionItems.length) { + if (!llmData || !llmData.todos.length) { setSyncStatus({ type: 'error', message: '캘린더에 반영할 실행 항목이 없습니다.' }); return; } @@ -427,7 +395,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number const startedAt = nowMs(); try { const intents = await Promise.all( - actionItems.map((summary) => + llmData.todos.map((summary) => apiClient.post('/api/calendar/writeback-intent', { action: 'create', summary, @@ -471,8 +439,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number const actionEmailId = actionEmail?.id ?? null; const isCurrentEmail = () => currentEmailIdRef.current === actionEmailId; if (!actionEmail) return; - const actionItems = llmData?.action_items ?? []; - if (!actionItems.length) { + if (!llmData || !llmData.todos.length) { setTaskStatus('정리할 실행 항목이 없습니다.'); return; } @@ -483,7 +450,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number const data = await apiClient.post('/api/tasks/from-email', { source_email_id: actionEmail.message_id, thread_id: actionEmail.thread_id || actionEmail.message_id, - items: actionItems, + items: llmData.todos, }); if (!isCurrentEmail()) return; setTaskStatus(`${data.created}개 실행 항목을 티켓형 실행 항목으로 추적합니다.`); @@ -568,7 +535,6 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number const safeEmailSubject = toMailDisplayText(email.subject, '(제목 없음)'); const safeReplyTo = toMailDisplayText(email.reply_to || email.sender, '답장 주소 없음'); const confidencePercent = toConfidencePercent(llmData?.confidence); - const actionItems = llmData?.action_items ?? []; const handleOpenSourceDrawer = () => { recordProductEvent("source_chip_opened", { @@ -678,15 +644,15 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number icon={} loading={!llmData && !llmError} error={llmError ? '실행 항목을 추출하지 못했습니다.' : null} - empty={Boolean(llmData && actionItems.length === 0)} + empty={Boolean(llmData && llmData.todos.length === 0)} emptyMessage="실행 항목이 없습니다." provenance={ confidencePercent !== undefined ? `신뢰도 ${confidencePercent}%` : undefined } confidence={confidencePercent} - footerActions={llmData && (actionItems.length > 0 || syncStatus || taskStatus) ? ( + footerActions={llmData && (llmData.todos.length > 0 || syncStatus || taskStatus) ? ( <> - {actionItems.length > 0 && ( + {llmData.todos.length > 0 && (
- {projectTasks.length > 0 ? projectTasksList : ( + {projectTasks.length > 0 ? ( +
    + {projectTasks.slice(0, 8).map((task) => ( +
  1. +
    +
    + {taskStatusLabel[task.status]} + {priorityLabel[task.priority]} + {getTaskSourceLabel(task.source_type)} +
    +

    {safeText(task.title, '제목 없는 작업')}

    +

    {getTaskEvidenceLabel(task)}

    +
    + +
  2. + ))} +
+ ) : (
diff --git a/frontend/src/components/TasksLayout.test.tsx b/frontend/src/components/TasksLayout.test.tsx index a0cac6748..dadcb7521 100644 --- a/frontend/src/components/TasksLayout.test.tsx +++ b/frontend/src/components/TasksLayout.test.tsx @@ -95,45 +95,4 @@ describe("TasksLayout", () => { expect(container.textContent).toContain("관련 메일 열기"); expect(container.textContent).toContain("거래처 회신 준비"); }); - - it("exposes the view mode toggle as a keyboard-navigable tablist", async () => { - const fetchMock = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("/api/tasks")) return Promise.resolve(jsonResponse([])); - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - - const tablist = container.querySelector('[role="tablist"][aria-label="작업 보기 방식"]'); - expect(tablist).not.toBeNull(); - const tabs = Array.from(tablist?.querySelectorAll('[role="tab"]') ?? []); - expect(tabs.map((tab) => tab.textContent)).toEqual(["내 작업", "위임한 작업", "칸반", "작업 상세"]); - - const kanbanTab = tabs.find((tab) => tab.textContent === "칸반"); - expect(kanbanTab?.getAttribute("aria-selected")).toBe("true"); - expect(kanbanTab?.tabIndex).toBe(0); - for (const tab of tabs.filter((candidate) => candidate !== kanbanTab)) { - expect(tab.getAttribute("aria-selected")).toBe("false"); - expect(tab.tabIndex).toBe(-1); - } - - act(() => { - kanbanTab?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); - }); - await flushAsyncWork(); - - const detailTab = Array.from(container.querySelectorAll('[role="tab"]')) - .find((tab) => tab.textContent === "작업 상세"); - expect(detailTab?.getAttribute("aria-selected")).toBe("true"); - expect(detailTab?.tabIndex).toBe(0); - }); }); diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx index 034aa6911..4f3934ff5 100644 --- a/frontend/src/components/TasksLayout.tsx +++ b/frontend/src/components/TasksLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, Search, Filter, User, CalendarDays, Inbox, AlertCircle, X } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; @@ -152,15 +152,8 @@ function getKnowledgeRetryLabel(intent: KnowledgeMaterializationIntent) { return '실행 요청 없음'; } -const TASK_VIEW_MODES = ['내 작업', '위임한 작업', '칸반', '작업 상세'] as const; -type TaskViewMode = (typeof TASK_VIEW_MODES)[number]; - -function taskViewTabId(mode: TaskViewMode) { - return `tasks-view-tab-${TASK_VIEW_MODES.indexOf(mode)}`; -} - export function TasksLayout() { - const [viewMode, setViewMode] = useState('칸반'); + const [viewMode, setViewMode] = useState<'내 작업' | '위임한 작업' | '칸반' | '작업 상세'>('칸반'); const [selectedTaskId, setSelectedTaskId] = useState(null); const [ticketTasks, setTicketTasks] = useState([]); const [ticketStatus, setTicketStatus] = useState('loading'); @@ -367,21 +360,6 @@ export function TasksLayout() {
), [currentColumns, tasksByStatus, taskSearch, priorityFilter, setSelectedTaskId, setViewMode]); - const handleViewModeKeyDown = (event: KeyboardEvent, mode: TaskViewMode) => { - const currentIndex = TASK_VIEW_MODES.indexOf(mode); - const lastIndex = TASK_VIEW_MODES.length - 1; - let nextIndex: number; - if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; - else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; - else if (event.key === 'Home') nextIndex = 0; - else if (event.key === 'End') nextIndex = lastIndex; - else return; - event.preventDefault(); - const nextMode = TASK_VIEW_MODES[nextIndex]; - setViewMode(nextMode); - document.getElementById(taskViewTabId(nextMode))?.focus(); - }; - return (
{/* Top Header */} @@ -389,16 +367,12 @@ export function TasksLayout() {

실행 항목 추적

리소스 배정 검토 회의

-
- {TASK_VIEW_MODES.map((mode) => ( +
+ {['내 작업', '위임한 작업', '칸반', '작업 상세'].map((mode) => (