diff --git a/.env.template b/.env.template index 083fb254..97495493 100644 --- a/.env.template +++ b/.env.template @@ -105,7 +105,7 @@ PARAKEET_ASR_URL=http://host.docker.internal:8767 # MongoDB configuration MONGODB_URI=mongodb://mongo:${MONGODB_PORT} -MONGODB_K8S_URI=mongodb://mongodb.${INFRASTRUCTURE_NAMESPACE}.svc.cluster.local:27017/friend +MONGODB_K8S_URI=mongodb://mongodb.${INFRASTRUCTURE_NAMESPACE}.svc.cluster.local:27017/friend-lite # Qdrant configuration QDRANT_BASE_URL=qdrant diff --git a/Makefile b/Makefile index 29a73f75..4a2a3d96 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ export $(shell sed 's/=.*//' config.env | grep -v '^\s*$$' | grep -v '^\s*\#') SCRIPTS_DIR := scripts K8S_SCRIPTS_DIR := $(SCRIPTS_DIR)/k8s -.PHONY: help menu setup-k8s setup-infrastructure setup-rbac setup-storage-pvc config config-docker config-k8s config-all clean deploy deploy-docker deploy-k8s deploy-k8s-full deploy-infrastructure deploy-apps check-infrastructure check-apps build-backend up-backend down-backend k8s-status k8s-cleanup k8s-purge audio-manage +.PHONY: help menu setup-k8s setup-infrastructure setup-rbac setup-storage-pvc config config-docker config-k8s config-all clean deploy deploy-docker deploy-k8s deploy-k8s-full deploy-infrastructure deploy-apps check-infrastructure check-apps build-backend up-backend down-backend k8s-status k8s-cleanup k8s-purge audio-manage test-robot test-robot-integration test-robot-unit test-robot-endpoints test-robot-specific test-robot-clean # Default target .DEFAULT_GOAL := menu @@ -36,6 +36,11 @@ menu: ## Show interactive menu (default) @echo " k8s-cleanup 🧹 Clean up Kubernetes resources" @echo " audio-manage 🎡 Manage audio files" @echo + @echo "πŸ§ͺ Testing:" + @echo " test-robot πŸ§ͺ Run all Robot Framework tests" + @echo " test-robot-integration πŸ”¬ Run integration tests only" + @echo " test-robot-endpoints 🌐 Run endpoint tests only" + @echo @echo "πŸ“ Configuration:" @echo " config-docker 🐳 Generate Docker Compose .env files" @echo " config-k8s ☸️ Generate Kubernetes files (Skaffold env + ConfigMap/Secret)" @@ -95,6 +100,13 @@ help: ## Show detailed help for all targets @echo "🎡 AUDIO MANAGEMENT:" @echo " audio-manage Interactive audio file management" @echo + @echo "πŸ§ͺ ROBOT FRAMEWORK TESTING:" + @echo " test-robot Run all Robot Framework tests" + @echo " test-robot-integration Run integration tests only" + @echo " test-robot-endpoints Run endpoint tests only" + @echo " test-robot-specific FILE=path Run specific test file" + @echo " test-robot-clean Clean up test results" + @echo @echo "πŸ” MONITORING:" @echo " check-infrastructure Check if infrastructure services are running" @echo " check-apps Check if application services are running" @@ -170,7 +182,7 @@ config: config-all ## Generate all configuration files config-docker: ## Generate Docker Compose configuration files @echo "🐳 Generating Docker Compose configuration files..." - @python3 scripts/generate-docker-configs.py + @CONFIG_FILE=config.env.dev python3 scripts/generate-docker-configs.py @echo "βœ… Docker Compose configuration files generated" config-k8s: ## Generate Kubernetes configuration files (Skaffold env + ConfigMap/Secret) @@ -297,3 +309,49 @@ k8s-purge: ## Purge unused images (registry + container) audio-manage: ## Interactive audio file management @echo "🎡 Starting audio file management..." @$(SCRIPTS_DIR)/manage-audio-files.sh + +# ======================================== +# TESTING TARGETS +# ======================================== + +# Define test environment variables +TEST_ENV := BACKEND_URL=http://localhost:8001 ADMIN_EMAIL=test-admin@example.com ADMIN_PASSWORD=test-admin-password-123 + +test-robot: ## Run all Robot Framework tests + @echo "πŸ§ͺ Running all Robot Framework tests..." + @cd tests && $(TEST_ENV) robot --outputdir ../results . + @echo "βœ… All Robot Framework tests completed" + @echo "πŸ“Š Results available in: results/" + +test-robot-integration: ## Run integration tests only + @echo "πŸ§ͺ Running Robot Framework integration tests..." + @cd tests && $(TEST_ENV) robot --outputdir ../results integration/ + @echo "βœ… Robot Framework integration tests completed" + @echo "πŸ“Š Results available in: results/" + +test-robot-unit: ## Run unit tests only + @echo "πŸ§ͺ Running Robot Framework unit tests..." + @cd tests && $(TEST_ENV) robot --outputdir ../results unit/ || echo "⚠️ No unit tests directory found" + @echo "βœ… Robot Framework unit tests completed" + @echo "πŸ“Š Results available in: results/" + +test-robot-endpoints: ## Run endpoint tests only + @echo "πŸ§ͺ Running Robot Framework endpoint tests..." + @cd tests && $(TEST_ENV) robot --outputdir ../results endpoints/ + @echo "βœ… Robot Framework endpoint tests completed" + @echo "πŸ“Š Results available in: results/" + +test-robot-specific: ## Run specific Robot Framework test file (usage: make test-robot-specific FILE=path/to/test.robot) + @echo "πŸ§ͺ Running specific Robot Framework test: $(FILE)" + @if [ -z "$(FILE)" ]; then \ + echo "❌ FILE parameter is required. Usage: make test-robot-specific FILE=path/to/test.robot"; \ + exit 1; \ + fi + @cd tests && $(TEST_ENV) robot --outputdir ../results $(FILE) + @echo "βœ… Robot Framework test completed: $(FILE)" + @echo "πŸ“Š Results available in: results/" + +test-robot-clean: ## Clean up Robot Framework test results + @echo "🧹 Cleaning up Robot Framework test results..." + @rm -rf results/ + @echo "βœ… Test results cleaned" diff --git a/backends/advanced/.env.template b/backends/advanced/.env.template index b00a30c8..44a88de6 100644 --- a/backends/advanced/.env.template +++ b/backends/advanced/.env.template @@ -77,6 +77,12 @@ TRANSCRIPTION_BUFFER_SECONDS=120 # Trigger transcription every N seconds # Auto-stop thresholds SPEECH_INACTIVITY_THRESHOLD_SECONDS=60 # Close conversation after N seconds of no speech +# Speaker enrollment filter (default: false) +# When enabled, only creates conversations when enrolled speakers are detected +# Requires speaker recognition service to be running and speakers to be enrolled +# Set to "true" to enable, "false" or omit to disable +RECORD_ONLY_ENROLLED_SPEAKERS=true + # ======================================== # DATABASE CONFIGURATION # ======================================== diff --git a/backends/advanced/Caddyfile b/backends/advanced/Caddyfile new file mode 100644 index 00000000..e0ffb2f6 --- /dev/null +++ b/backends/advanced/Caddyfile @@ -0,0 +1,107 @@ +# Caddy reverse proxy configuration for Friend-Lite +# Provides automatic HTTPS for microphone access + +# USAGE: +# 1. Start services: docker compose up -d +# 2. Access at: https://localhost (Caddy will use self-signed cert) +# 3. Browser will warn about self-signed cert - accept it +# 4. Microphone access will now work via HTTPS +# +# NOTE: If using Caddy, update docker-compose.yml webui build args: +# VITE_BACKEND_URL: "" (empty for same-origin through Caddy) +# +# For production, replace 'localhost' with your domain name and Caddy +# will automatically obtain Let's Encrypt certificates. + +localhost { + # Enable automatic HTTPS + tls internal + + # WebSocket endpoints - proxy to backend with upgrade support + handle /ws* { + reverse_proxy friend-backend:8000 { + # Caddy automatically handles WebSocket upgrades + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + } + + # API endpoints - proxy to backend + handle /api/* { + reverse_proxy friend-backend:8000 + } + + # Auth endpoints - proxy to backend + handle /auth/* { + reverse_proxy friend-backend:8000 + } + + # Health checks - proxy to backend + handle /health { + reverse_proxy friend-backend:8000 + } + + handle /readiness { + reverse_proxy friend-backend:8000 + } + + # Users endpoints - proxy to backend + handle /users/* { + reverse_proxy friend-backend:8000 + } + + # Audio files - proxy to backend + handle /audio/* { + reverse_proxy friend-backend:8000 + } + + # Everything else - proxy to webui + handle { + reverse_proxy webui:80 + } +} + +# Production configuration (uncomment and modify for your domain) +# yourdomain.com { +# # Caddy automatically obtains Let's Encrypt certificates +# +# # WebSocket endpoints +# handle /ws* { +# reverse_proxy friend-backend:8000 +# } +# +# # API endpoints +# handle /api/* { +# reverse_proxy friend-backend:8000 +# } +# +# # Auth endpoints +# handle /auth/* { +# reverse_proxy friend-backend:8000 +# } +# +# # Health checks +# handle /health { +# reverse_proxy friend-backend:8000 +# } +# +# handle /readiness { +# reverse_proxy friend-backend:8000 +# } +# +# # Users endpoints +# handle /users/* { +# reverse_proxy friend-backend:8000 +# } +# +# # Audio files +# handle /audio/* { +# reverse_proxy friend-backend:8000 +# } +# +# # Everything else - webui +# handle { +# reverse_proxy webui:80 +# } +# } diff --git a/backends/advanced/Dockerfile b/backends/advanced/Dockerfile index be3e1019..7d52fd2c 100644 --- a/backends/advanced/Dockerfile +++ b/backends/advanced/Dockerfile @@ -39,5 +39,9 @@ COPY memory_config.yaml* ./ COPY diarization_config.json* ./ -# Run the application -CMD ["uv", "run", "--extra", "deepgram", "python3", "src/advanced_omi_backend/main.py"] +# Copy and make startup script executable +COPY start.sh ./ +RUN chmod +x start.sh + +# Run the application with workers +CMD ["./start.sh"] diff --git a/backends/advanced/Dockerfile.k8s b/backends/advanced/Dockerfile.k8s index edfe62db..a92bb617 100644 --- a/backends/advanced/Dockerfile.k8s +++ b/backends/advanced/Dockerfile.k8s @@ -36,9 +36,10 @@ COPY . . # Copy memory config (created by init.sh from template) COPY memory_config.yaml ./ +# Copy and make K8s startup script executable +COPY start-k8s.sh ./ +RUN chmod +x start-k8s.sh -# Run the application -# CMD ["uv", "run", "python3", "src/advanced_omi_backend/main.py"] - -# don't sync if deploying prebuilt image to k8s -CMD ["uv", "run", "--no-sync", "python3", "src/advanced_omi_backend/main.py"] +# Run the application with workers +# K8s startup script starts both FastAPI backend and RQ workers with --no-sync +CMD ["./start-k8s.sh"] diff --git a/backends/advanced/docker-compose-test.yml b/backends/advanced/docker-compose-test.yml index c491d89b..625b49be 100644 --- a/backends/advanced/docker-compose-test.yml +++ b/backends/advanced/docker-compose-test.yml @@ -10,6 +10,7 @@ services: ports: - "8001:8000" # Avoid conflict with dev on 8000 volumes: + - ./src:/app/src # Mount source code for easier development - ./data/test_audio_chunks:/app/audio_chunks - ./data/test_debug_dir:/app/debug_dir - ./data/test_data:/app/data @@ -18,6 +19,7 @@ services: - MONGODB_URI=mongodb://mongo-test:27017/test_db - QDRANT_BASE_URL=qdrant-test - QDRANT_PORT=6333 + - REDIS_URL=redis://redis-test:6379/0 - DEBUG_DIR=/app/debug_dir # Import API keys from environment - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} @@ -37,18 +39,22 @@ services: - OPENMEMORY_MCP_URL=${OPENMEMORY_MCP_URL:-http://host.docker.internal:8765} - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} # Disable speaker recognition in test environment to prevent segment duplication - - DISABLE_SPEAKER_RECOGNITION=true + - DISABLE_SPEAKER_RECOGNITION=false + - SPEAKER_SERVICE_URL=https://localhost:8085 + - CORS_ORIGINS=http://localhost:3001,http://localhost:8001,https://localhost:3001,https://localhost:8001 depends_on: qdrant-test: condition: service_started mongo-test: condition: service_healthy + redis-test: + condition: service_started healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/readiness"] interval: 10s timeout: 5s retries: 5 - start_period: 5s + start_period: 30s restart: unless-stopped webui-test: @@ -57,6 +63,9 @@ services: dockerfile: Dockerfile args: - VITE_BACKEND_URL=http://localhost:8001 + - BACKEND_URL=http://localhost:8001 + volumes: + - ./webui/src:/app/src # Mount source code for easier development ports: - "3001:80" # Avoid conflict with dev on 3000 depends_on: @@ -66,6 +75,8 @@ services: condition: service_healthy qdrant-test: condition: service_started + redis-test: + condition: service_started qdrant-test: image: qdrant/qdrant:latest @@ -76,7 +87,7 @@ services: - ./data/test_qdrant_data:/qdrant/storage mongo-test: - image: mongo:4.4.18 + image: mongo:8.0.14 ports: - "27018:27017" # Avoid conflict with dev on 27017 volumes: @@ -90,6 +101,35 @@ services: retries: 10 start_period: 10s + redis-test: + image: redis:7-alpine + ports: + - "6380:6379" # Avoid conflict with dev on 6379 + volumes: + - ./data/test_redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + # caddy: + # image: caddy:2-alpine + # ports: + # - "443:443" + # - "80:80" # HTTP redirect to HTTPS + # volumes: + # - ./Caddyfile-test:/etc/caddy/Caddyfile:ro + # - ./data/caddy_data:/data + # - ./data/caddy_config:/config + # depends_on: + # webui-test: + # condition: service_started + # friend-backend-test: + # condition: service_healthy + # restart: unless-stopped + # CI Considerations (for future implementation): # - GitHub Actions can run these services in isolated containers # - Port conflicts won't exist in CI since each job runs in isolation diff --git a/backends/advanced/docker-compose.yml b/backends/advanced/docker-compose.yml index 00718452..8a0f156d 100644 --- a/backends/advanced/docker-compose.yml +++ b/backends/advanced/docker-compose.yml @@ -15,7 +15,11 @@ services: environment: - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} - MISTRAL_API_KEY=${MISTRAL_API_KEY} + - MISTRAL_MODEL=${MISTRAL_MODEL} - TRANSCRIPTION_PROVIDER=${TRANSCRIPTION_PROVIDER} + - OFFLINE_ASR_TCP_URI=${OFFLINE_ASR_TCP_URI} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL} + - HF_TOKEN=${HF_TOKEN} - SPEAKER_SERVICE_URL=${SPEAKER_SERVICE_URL} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_EMAIL=${ADMIN_EMAIL} @@ -24,30 +28,18 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_BASE_URL=${OPENAI_BASE_URL} - OPENAI_MODEL=${OPENAI_MODEL} - - CORS_ORIGINS=${CORS_ORIGINS} - # OpenMemory MCP configuration - - MEMORY_PROVIDER=${MEMORY_PROVIDER} - - OPENMEMORY_MCP_URL=${OPENMEMORY_MCP_URL:-http://host.docker.internal:8765} - - OPENMEMORY_CLIENT_NAME=${OPENMEMORY_CLIENT_NAME:-friend_lite} - - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} - - OPENMEMORY_TIMEOUT=${OPENMEMORY_TIMEOUT:-30} - # Speech Detection (Speech-Driven Conversations Architecture) - - SPEECH_DETECTION_MIN_WORDS=${SPEECH_DETECTION_MIN_WORDS:-5} - - SPEECH_DETECTION_MIN_CONFIDENCE=${SPEECH_DETECTION_MIN_CONFIDENCE:-0.5} - # Conversation Stop (Automatic Conversation Closure) - - TRANSCRIPTION_BUFFER_SECONDS=${TRANSCRIPTION_BUFFER_SECONDS:-120} - - SPEECH_INACTIVITY_THRESHOLD_SECONDS=${SPEECH_INACTIVITY_THRESHOLD_SECONDS:-60} - - LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY} - - LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY} - - LANGFUSE_HOST=${LANGFUSE_HOST} - - LANGFUSE_ENABLE_TELEMETRY=${LANGFUSE_ENABLE_TELEMETRY} - + - NEO4J_HOST=${NEO4J_HOST} + - NEO4J_USER=${NEO4J_USER} + - NEO4J_PASSWORD=${NEO4J_PASSWORD} + - CORS_ORIGINS=http://localhost:3010,http://localhost:8000,https://localhost:3010,https://localhost:8000,https://100.105.225.45,https://localhost + - REDIS_URL=redis://redis:6379/0 depends_on: - # You may not want qdrant if you are using openmemory_mcp - # qdrant: - # condition: service_started + qdrant: + condition: service_started mongo: condition: service_started + redis: + condition: service_healthy # neo4j-mem0: # condition: service_started healthcheck: @@ -58,68 +50,121 @@ services: start_period: 5s restart: unless-stopped - # Development webui service (default) + # Unified Worker Container + # Runs all workers in a single container for efficiency: + # - 3 RQ workers (transcription, memory, default queues) + # - 1 Audio stream worker (Redis Streams consumer - must be single to maintain sequential chunks) + workers: + build: + context: . + dockerfile: Dockerfile + command: ["./start-workers.sh"] + env_file: + - .env + volumes: + - ./src:/app/src + - ./data/audio_chunks:/app/audio_chunks + - ./data:/app/data + environment: + - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} + - MISTRAL_API_KEY=${MISTRAL_API_KEY} + - MISTRAL_MODEL=${MISTRAL_MODEL} + - TRANSCRIPTION_PROVIDER=${TRANSCRIPTION_PROVIDER} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_BASE_URL=${OPENAI_BASE_URL} + - OPENAI_MODEL=${OPENAI_MODEL} + - LLM_PROVIDER=${LLM_PROVIDER} + - REDIS_URL=redis://redis:6379/0 + depends_on: + redis: + condition: service_healthy + mongo: + condition: service_started + qdrant: + condition: service_started + restart: unless-stopped + webui: + build: + context: ./webui + dockerfile: Dockerfile + args: + # Direct access (http://localhost:3010): + # - VITE_BACKEND_URL=http://localhost:8000 + # - BACKEND_URL=http://localhost:8000 + # For Caddy HTTPS (https://localhost), use: + - VITE_BACKEND_URL= + - BACKEND_URL= + ports: + # - "${WEBUI_PORT:-3010}:80" + - 3010:80 + depends_on: + friend-backend: + condition: service_healthy + restart: unless-stopped + + # Caddy reverse proxy - provides HTTPS for microphone access + # Access at: https://localhost (accepts self-signed cert warning) + caddy: + image: caddy:2-alpine + ports: + - "443:443" + - "80:80" # HTTP redirect to HTTPS + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./data/caddy_data:/data + - ./data/caddy_config:/config + depends_on: + friend-backend: + condition: service_healthy + restart: unless-stopped + + # Development webui service (use with docker-compose --profile dev up) + webui-dev: build: context: ./webui dockerfile: Dockerfile.dev ports: - - "${WEBUI_PORT:-5173}:5173" + - "${WEBUI_DEV_PORT:-5173}:5173" environment: - # Don't set VITE_BACKEND_URL - let frontend auto-detect based on access method - # - VITE_BACKEND_URL=http://${HOST_IP}:${BACKEND_PUBLIC_PORT:-8000} - - VITE_HMR_PORT=443 + - VITE_BACKEND_URL=http://${HOST_IP}:${BACKEND_PUBLIC_PORT:-8000} volumes: - ./webui/src:/app/src - ./webui/public:/app/public depends_on: friend-backend: condition: service_healthy - restart: unless-stopped + profiles: + - dev qdrant: image: qdrant/qdrant:latest ports: - - "6333:6333" # gRPC - - "6334:6334" # HTTP + - "6033:6033" # gRPC + - "6034:6034" # HTTP volumes: - ./data/qdrant_data:/qdrant/storage mongo: - image: mongo:4.4.18 + image: mongo:8.0.14 ports: - "27017:27017" volumes: - ./data/mongo_data:/data/db - # OpenMemory MCP Server - Use external server from extras/openmemory-mcp - # The Friend-Lite backend connects to the external OpenMemory MCP server - # running from extras/openmemory-mcp via host.docker.internal:8765 - # - # To start the external server: - # cd extras/openmemory-mcp && docker compose up -d - - # Nginx reverse proxy for HTTPS access - nginx: - image: nginx:alpine + redis: + image: redis:7-alpine ports: - - "443:443" - - "80:80" + - "6379:6379" # Avoid conflict with dev on 6379 volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - - ./ssl:/etc/nginx/ssl:ro - depends_on: - friend-backend: - condition: service_healthy - webui: - condition: service_started - restart: unless-stopped + - ./data/redis_data:/data + command: redis-server --appendonly yes healthcheck: - test: ["CMD", "curl", "-f", "-k", "https://localhost/health"] - interval: 30s - timeout: 10s - retries: 3 + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 ## Additional @@ -137,6 +182,12 @@ services: # - ./data/neo4j_data:/data # - ./data/neo4j_logs:/logs # restart: unless-stopped + # proxy: + # image: nginx:alpine + # depends_on: [friend-backend, streamlit] + # volumes: + # - ./nginx.conf:/etc/nginx/nginx.conf:ro + # ports: ["80:80"] # publish once; ngrok points here # ollama: # image: ollama/ollama:latest @@ -155,6 +206,16 @@ services: + # Use tailscale instead + # UNCOMMENT OUT FOR LOCAL DEMO - EXPOSES to internet + # ngrok: + # image: ngrok/ngrok:latest + # depends_on: [friend-backend, proxy] + # ports: + # - "4040:4040" # Ngrok web interface + # environment: + # - NGROK_AUTHTOKEN=${NGROK_AUTHTOKEN} + # command: "http proxy:80 --url=${NGROK_URL} --basic-auth=${NGROK_BASIC_AUTH}" # Question: These are named volumes, but they are not being used, right? Can we remove them? diff --git a/backends/advanced/pyproject.toml b/backends/advanced/pyproject.toml index c355509f..5f635cbb 100644 --- a/backends/advanced/pyproject.toml +++ b/backends/advanced/pyproject.toml @@ -21,6 +21,9 @@ dependencies = [ "langfuse>=3.3.0", "spacy>=3.8.2", "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", + "redis>=5.0.0", + "rq>=1.16.0", + "soundfile>=0.12.1", ] [project.optional-dependencies] @@ -48,9 +51,43 @@ line-length = 100 [tool.uv.sources] mem0ai = { git = "https://github.com/AnkushMalaker/mem0.git", rev = "async-client-unbound-var-fix" } +[tool.poetry.dependencies] +robotframework = "^6.1.1" + [tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*", "*Tests"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", + "--spec", + "-v", + "--tb=long", + "--color=yes", + "--durations=10", + "--showlocals", + "--capture=no", +] markers = [ - "integration: marks tests as integration tests", + "integration: marks tests as integration tests (may be slow)", + "unit: marks tests as unit tests (fast, isolated)", + "smoke: marks tests as smoke tests (quick validation)", + "slow: marks tests as slow running tests", + "api: marks tests that test API endpoints", + "memory: marks tests that test memory functionality", + "transcription: marks tests that test transcription functionality", + "auth: marks tests that test authentication", + "database: marks tests that require database access", +] +filterwarnings = [ + "error", + "ignore::UserWarning", + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", ] [dependency-groups] @@ -63,4 +100,11 @@ dev = [ test = [ "pytest>=8.4.1", "pytest-asyncio>=1.0.0", + "pytest-spec>=3.2.0", + "pytest-cov>=6.0.0", + "pytest-xdist>=3.6.0", + "pytest-mock>=3.14.0", + "requests-mock>=1.12.1", + "pytest-json-report>=1.5.0", + "pytest-html>=4.0.0", ] diff --git a/backends/advanced/src/advanced_omi_backend/app_config.py b/backends/advanced/src/advanced_omi_backend/app_config.py new file mode 100644 index 00000000..36a6ae02 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/app_config.py @@ -0,0 +1,119 @@ +""" +Application configuration for Friend-Lite backend. + +Centralizes all application-level configuration including database connections, +service configurations, and environment variables that were previously in main.py. +""" + +import logging +import os +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv +from motor.motor_asyncio import AsyncIOMotorClient + +from advanced_omi_backend.constants import OMI_CHANNELS, OMI_SAMPLE_RATE, OMI_SAMPLE_WIDTH +from advanced_omi_backend.services.transcription import get_transcription_provider + +# Load environment variables +load_dotenv() + +logger = logging.getLogger(__name__) + + +class AppConfig: + """Centralized application configuration.""" + + def __init__(self): + # MongoDB Configuration + self.mongodb_uri = os.getenv("MONGODB_URI", "mongodb://mongo:27017") + self.mongo_client = AsyncIOMotorClient(self.mongodb_uri) + self.db = self.mongo_client.get_default_database("friend-lite") + self.chunks_col = self.db["audio_chunks"] + self.users_col = self.db["users"] + self.speakers_col = self.db["speakers"] + + # Audio Configuration + self.segment_seconds = 60 # length of each stored chunk + self.target_samples = OMI_SAMPLE_RATE * self.segment_seconds + self.audio_chunk_dir = Path("./audio_chunks") + self.audio_chunk_dir.mkdir(parents=True, exist_ok=True) + + # Conversation timeout configuration + self.new_conversation_timeout_minutes = float( + os.getenv("NEW_CONVERSATION_TIMEOUT_MINUTES", "1.5") + ) + + # Audio cropping configuration + self.audio_cropping_enabled = os.getenv("AUDIO_CROPPING_ENABLED", "true").lower() == "true" + self.min_speech_segment_duration = float(os.getenv("MIN_SPEECH_SEGMENT_DURATION", "1.0")) + self.cropping_context_padding = float(os.getenv("CROPPING_CONTEXT_PADDING", "0.1")) + + # Transcription Configuration + self.transcription_provider_name = os.getenv("TRANSCRIPTION_PROVIDER") + self.deepgram_api_key = os.getenv("DEEPGRAM_API_KEY") + self.mistral_api_key = os.getenv("MISTRAL_API_KEY") + + # Get configured transcription provider + self.transcription_provider = get_transcription_provider(self.transcription_provider_name) + if self.transcription_provider: + logger.info( + f"βœ… Using {self.transcription_provider.name} transcription provider ({self.transcription_provider.mode})" + ) + else: + logger.warning("⚠️ No transcription provider configured - speech-to-text will not be available") + + # External Services Configuration + self.qdrant_base_url = os.getenv("QDRANT_BASE_URL", "qdrant") + self.qdrant_port = os.getenv("QDRANT_PORT", "6333") + self.memory_provider = os.getenv("MEMORY_PROVIDER", "friend_lite").lower() + + # Redis Configuration + self.redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + # CORS Configuration + default_origins = "http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,http://127.0.0.1:3002" + self.cors_origins = os.getenv("CORS_ORIGINS", default_origins) + self.allowed_origins = [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + # Tailscale support + self.tailscale_regex = r"http://100\.\d{1,3}\.\d{1,3}\.\d{1,3}:3000" + + # Thread pool configuration + self.max_workers = os.cpu_count() or 4 + + # Memory service configuration + self.memory_service_supports_threshold = self.memory_provider == "friend_lite" + + +# Global configuration instance +app_config = AppConfig() + + +def get_app_config() -> AppConfig: + """Get the global application configuration instance.""" + return app_config + + +def get_audio_chunk_dir() -> Path: + """Get the audio chunk directory.""" + return app_config.audio_chunk_dir + + +def get_mongo_collections(): + """Get MongoDB collections.""" + return { + 'chunks': app_config.chunks_col, + 'users': app_config.users_col, + 'speakers': app_config.speakers_col, + } + + +def get_redis_config(): + """Get Redis configuration.""" + return { + 'url': app_config.redis_url, + 'encoding': "utf-8", + 'decode_responses': False + } \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/app_factory.py b/backends/advanced/src/advanced_omi_backend/app_factory.py new file mode 100644 index 00000000..4d879301 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/app_factory.py @@ -0,0 +1,216 @@ +""" +Application factory for Friend-Lite backend. + +Creates and configures the FastAPI application with all routers, middleware, +and service initializations. +""" + +import asyncio +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +import redis.asyncio as redis +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from advanced_omi_backend.app_config import get_app_config +from advanced_omi_backend.auth import ( + bearer_backend, + cookie_backend, + create_admin_user_if_needed, + current_superuser, + fastapi_users, + websocket_auth, +) +from advanced_omi_backend.users import ( + User, + UserRead, + UserUpdate, + register_client_to_user, +) +from advanced_omi_backend.client_manager import get_client_manager +from advanced_omi_backend.memory import get_memory_service, shutdown_memory_service +from advanced_omi_backend.middleware.app_middleware import setup_middleware +from advanced_omi_backend.routers.api_router import router as api_router +from advanced_omi_backend.routers.modules.health_routes import router as health_router +from advanced_omi_backend.routers.modules.websocket_routes import router as websocket_router +from advanced_omi_backend.services.audio_service import get_audio_stream_service +from advanced_omi_backend.task_manager import init_task_manager, get_task_manager + +logger = logging.getLogger(__name__) +application_logger = logging.getLogger("audio_processing") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifespan events.""" + config = get_app_config() + + # Startup + application_logger.info("Starting application...") + + # Initialize Beanie for all document models + try: + from beanie import init_beanie + from advanced_omi_backend.models.conversation import Conversation + from advanced_omi_backend.models.audio_file import AudioFile + from advanced_omi_backend.models.user import User + + await init_beanie( + database=config.db, + document_models=[User, Conversation, AudioFile], + ) + application_logger.info("Beanie initialized for all document models") + except Exception as e: + application_logger.error(f"Failed to initialize Beanie: {e}") + raise + + # Create admin user if needed + try: + await create_admin_user_if_needed() + except Exception as e: + application_logger.error(f"Failed to create admin user: {e}") + # Don't raise here as this is not critical for startup + + # Initialize task manager + task_manager = init_task_manager() + await task_manager.start() + application_logger.info("Task manager started") + + # Initialize Redis connection for RQ + try: + from advanced_omi_backend.controllers.queue_controller import redis_conn + redis_conn.ping() + application_logger.info("Redis connection established for RQ") + application_logger.info("RQ workers can be started with: rq worker transcription memory default") + except Exception as e: + application_logger.error(f"Failed to connect to Redis for RQ: {e}") + application_logger.warning("RQ queue system will not be available - check Redis connection") + + # Initialize audio stream service for Redis Streams + try: + audio_service = get_audio_stream_service() + await audio_service.connect() + application_logger.info("Audio stream service connected to Redis Streams") + application_logger.info("Audio stream workers can be started with: python -m advanced_omi_backend.workers.audio_stream_worker") + except Exception as e: + application_logger.error(f"Failed to connect audio stream service: {e}") + application_logger.warning("Redis Streams audio processing will not be available") + + # Initialize Redis client for audio streaming producer (used by WebSocket handlers) + try: + app.state.redis_audio_stream = await redis.from_url( + config.redis_url, + encoding="utf-8", + decode_responses=False + ) + from advanced_omi_backend.services.audio_stream import AudioStreamProducer + app.state.audio_stream_producer = AudioStreamProducer(app.state.redis_audio_stream) + application_logger.info("βœ… Redis client for audio streaming producer initialized") + except Exception as e: + application_logger.error(f"Failed to initialize Redis client for audio streaming: {e}", exc_info=True) + application_logger.warning("Audio streaming producer will not be available") + + # Skip memory service pre-initialization to avoid blocking FastAPI startup + # Memory service will be lazily initialized when first used + application_logger.info("Memory service will be initialized on first use (lazy loading)") + + # SystemTracker is used for monitoring and debugging + application_logger.info("Using SystemTracker for monitoring and debugging") + + application_logger.info("Application ready - using application-level processing architecture.") + + logger.info("App ready") + try: + yield + finally: + # Shutdown + application_logger.info("Shutting down application...") + + # Clean up all active clients + client_manager = get_client_manager() + for client_id in client_manager.get_all_client_ids(): + try: + from advanced_omi_backend.controllers.websocket_controller import cleanup_client_state + await cleanup_client_state(client_id) + except Exception as e: + application_logger.error(f"Error cleaning up client {client_id}: {e}") + + # RQ workers shut down automatically when process ends + # No special cleanup needed for Redis connections + + # Shutdown audio stream service + try: + audio_service = get_audio_stream_service() + await audio_service.disconnect() + application_logger.info("Audio stream service disconnected") + except Exception as e: + application_logger.error(f"Error disconnecting audio stream service: {e}") + + # Close Redis client for audio streaming producer + try: + if hasattr(app.state, 'redis_audio_stream') and app.state.redis_audio_stream: + await app.state.redis_audio_stream.close() + application_logger.info("Redis client for audio streaming producer closed") + except Exception as e: + application_logger.error(f"Error closing Redis audio streaming client: {e}") + + # Shutdown task manager + task_manager = get_task_manager() + await task_manager.shutdown() + application_logger.info("Task manager shut down") + + # Stop metrics collection and save final report + application_logger.info("Metrics collection stopped") + + # Shutdown memory service and speaker service + shutdown_memory_service() + application_logger.info("Memory and speaker services shut down.") + + application_logger.info("Shutdown complete.") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + # Create FastAPI application with lifespan management + app = FastAPI(lifespan=lifespan) + + # Set up middleware (CORS, exception handlers) + setup_middleware(app) + + # Include all routers + app.include_router(api_router) + + # Add health check router at root level (not under /api prefix) + app.include_router(health_router) + + # Add WebSocket router at root level (not under /api prefix) + app.include_router(websocket_router) + + # Add authentication routers + app.include_router( + fastapi_users.get_auth_router(cookie_backend), + prefix="/auth/cookie", + tags=["auth"], + ) + app.include_router( + fastapi_users.get_auth_router(bearer_backend), + prefix="/auth/jwt", + tags=["auth"], + ) + + # Add users router for /users/me and other user endpoints + app.include_router( + fastapi_users.get_users_router(UserRead, UserUpdate), + prefix="/users", + tags=["users"], + ) + + # Mount static files LAST (mounts are catch-all patterns) + CHUNK_DIR = Path("/app/audio_chunks") + app.mount("/audio", StaticFiles(directory=CHUNK_DIR), name="audio") + + logger.info("FastAPI application created with all routers and middleware configured") + + return app \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/audio_utils.py b/backends/advanced/src/advanced_omi_backend/audio_utils.py index 2821d126..302d068e 100644 --- a/backends/advanced/src/advanced_omi_backend/audio_utils.py +++ b/backends/advanced/src/advanced_omi_backend/audio_utils.py @@ -6,6 +6,8 @@ import logging import os import time +import uuid as uuid_lib +from pathlib import Path # Type import to avoid circular imports from typing import TYPE_CHECKING, Optional @@ -17,12 +19,185 @@ from advanced_omi_backend.database import AudioChunksRepository logger = logging.getLogger(__name__) +audio_logger = logging.getLogger("audio_processing") # Import constants from main.py (these are defined there) MIN_SPEECH_SEGMENT_DURATION = float(os.getenv("MIN_SPEECH_SEGMENT_DURATION", "1.0")) # seconds CROPPING_CONTEXT_PADDING = float(os.getenv("CROPPING_CONTEXT_PADDING", "0.1")) # seconds +class AudioValidationError(Exception): + """Exception raised when audio validation fails.""" + pass + + +async def validate_and_prepare_audio( + audio_data: bytes, + expected_sample_rate: int = 16000, + convert_to_mono: bool = True +) -> tuple[bytes, int, int, int, float]: + """ + Validate WAV audio data and prepare it for processing. + + Args: + audio_data: Raw WAV file bytes + expected_sample_rate: Expected sample rate (default: 16000 Hz) + convert_to_mono: Whether to convert stereo to mono (default: True) + + Returns: + Tuple of (processed_audio_data, sample_rate, sample_width, channels, duration) + + Raises: + AudioValidationError: If audio validation fails + """ + import io + import wave + import numpy as np + + try: + # Parse WAV file + with wave.open(io.BytesIO(audio_data), "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + channels = wav_file.getnchannels() + frame_count = wav_file.getnframes() + duration = frame_count / sample_rate if sample_rate > 0 else 0 + + # Read audio data + processed_audio = wav_file.readframes(frame_count) + + except Exception as e: + raise AudioValidationError(f"Invalid WAV file: {str(e)}") + + # Validate sample rate + if sample_rate != expected_sample_rate: + raise AudioValidationError( + f"Sample rate must be {expected_sample_rate}Hz, got {sample_rate}Hz" + ) + + # Convert stereo to mono if requested + if convert_to_mono and channels == 2: + audio_logger.info(f"Converting stereo audio to mono") + + if sample_width == 2: + audio_array = np.frombuffer(processed_audio, dtype=np.int16) + elif sample_width == 4: + audio_array = np.frombuffer(processed_audio, dtype=np.int32) + else: + raise AudioValidationError( + f"Unsupported sample width for stereo conversion: {sample_width} bytes" + ) + + # Reshape to separate channels and average + audio_array = audio_array.reshape(-1, 2) + processed_audio = np.mean(audio_array, axis=1).astype(audio_array.dtype).tobytes() + channels = 1 + + audio_logger.debug( + f"Audio validated: {duration:.1f}s, {sample_rate}Hz, {channels}ch, {sample_width} bytes/sample" + ) + + return processed_audio, sample_rate, sample_width, channels, duration + + +async def write_audio_file( + raw_audio_data: bytes, + audio_uuid: str, + client_id: str, + user_id: str, + user_email: str, + timestamp: int, + chunk_dir: Optional[Path] = None, + validate: bool = True +) -> tuple[str, str, float]: + """ + Validate, write audio data to WAV file, and create AudioSession database entry. + + This is shared logic used by both upload and WebSocket streaming paths. + Handles validation, stereoβ†’mono conversion, and database entry creation. + + Args: + raw_audio_data: Raw audio bytes (WAV format if validate=True, or PCM if validate=False) + audio_uuid: Unique identifier for this audio + client_id: Client identifier + user_id: User ID + user_email: User email + timestamp: Timestamp in milliseconds + chunk_dir: Optional directory path (defaults to CHUNK_DIR from config) + validate: Whether to validate and prepare audio (default: True for uploads, False for WebSocket) + + Returns: + Tuple of (wav_filename, file_path, duration) + + Raises: + AudioValidationError: If validation fails (when validate=True) + """ + from easy_audio_interfaces.filesystem.filesystem_interfaces import LocalFileSink + from advanced_omi_backend.config import CHUNK_DIR + from advanced_omi_backend.models.audio_file import AudioFile + + # Validate and prepare audio if needed + if validate: + audio_data, sample_rate, sample_width, channels, duration = \ + await validate_and_prepare_audio(raw_audio_data) + else: + # For WebSocket path - audio is already processed PCM + audio_data = raw_audio_data + sample_rate = 16000 # WebSocket always uses 16kHz + sample_width = 2 + channels = 1 + duration = len(audio_data) / (sample_rate * sample_width * channels) + + # Use provided chunk_dir or default from config + output_dir = chunk_dir or CHUNK_DIR + + # Ensure directory exists + output_dir.mkdir(parents=True, exist_ok=True) + + # Create filename + wav_filename = f"{timestamp}_{client_id}_{audio_uuid}.wav" + file_path = output_dir / wav_filename + + # Create file sink and write audio + sink = LocalFileSink( + file_path=str(file_path), + sample_rate=int(sample_rate), + channels=int(channels), + sample_width=int(sample_width) + ) + + await sink.open() + audio_chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=channels, + audio=audio_data + ) + await sink.write(audio_chunk) + await sink.close() + + audio_logger.info( + f"βœ… Wrote audio file: {wav_filename} ({len(audio_data)} bytes, {duration:.1f}s)" + ) + + # Create AudioFile database entry using Beanie model + audio_file = AudioFile( + audio_uuid=audio_uuid, + audio_path=wav_filename, + client_id=client_id, + timestamp=timestamp, + user_id=user_id, + user_email=user_email, + has_speech=False, # Will be updated by transcription + speech_analysis={} + ) + await audio_file.insert() + + audio_logger.info(f"βœ… Created AudioFile entry for {audio_uuid}") + + return wav_filename, str(file_path), duration + + async def process_audio_chunk( audio_data: bytes, client_id: str, @@ -31,11 +206,11 @@ async def process_audio_chunk( audio_format: dict, client_state: Optional["ClientState"] = None ) -> None: - """Process a single audio chunk through the standard pipeline. + """Process a single audio chunk through Redis Streams pipeline. This function encapsulates the common pattern used across all audio input sources: 1. Create AudioChunk with format details - 2. Queue AudioProcessingItem to processor + 2. Publish to Redis Streams for distributed processing 3. Update client state if provided Args: @@ -47,10 +222,7 @@ async def process_audio_chunk( client_state: Optional ClientState for state updates """ - from advanced_omi_backend.processors import ( - AudioProcessingItem, - get_processor_manager, - ) + from advanced_omi_backend.services.audio_service import get_audio_stream_service # Extract format details rate = audio_format.get("rate", 16000) @@ -71,18 +243,17 @@ async def process_audio_chunk( timestamp=timestamp ) - # Create AudioProcessingItem and queue for processing - processor_manager = get_processor_manager() - processing_item = AudioProcessingItem( + # Publish audio chunk to Redis Streams + audio_service = get_audio_stream_service() + await audio_service.publish_audio_chunk( client_id=client_id, user_id=user_id, user_email=user_email, audio_chunk=chunk, + audio_uuid=None, # Will be generated by worker timestamp=timestamp ) - await processor_manager.queue_audio(processing_item) - # Update client state if provided if client_state is not None: client_state.update_audio_received(chunk) @@ -171,6 +342,49 @@ async def _process_audio_cropping_with_relative_timestamps( return False +def write_pcm_to_wav( + pcm_data: bytes, + output_path: str, + sample_rate: int = 16000, + channels: int = 1, + sample_width: int = 2 +) -> None: + """ + Write raw PCM audio data to a WAV file. + + Args: + pcm_data: Raw PCM audio bytes + output_path: Path to output WAV file + sample_rate: Sample rate in Hz (default: 16000) + channels: Number of audio channels (default: 1 for mono) + sample_width: Sample width in bytes (default: 2 for 16-bit) + """ + import wave + + logger.info( + f"Writing PCM to WAV: {len(pcm_data)} bytes -> {output_path} " + f"(rate={sample_rate}, channels={channels}, width={sample_width})" + ) + + try: + with wave.open(output_path, 'wb') as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width) + wav_file.setframerate(sample_rate) + wav_file.writeframes(pcm_data) + + # Verify file was created + file_size = os.path.getsize(output_path) + duration = len(pcm_data) / (sample_rate * channels * sample_width) + logger.info( + f"βœ… WAV file created: {output_path} ({file_size} bytes, {duration:.2f}s)" + ) + + except Exception as e: + logger.error(f"❌ Failed to write PCM to WAV: {e}") + raise + + async def _crop_audio_with_ffmpeg( original_path: str, speech_segments: list[tuple[float, float]], output_path: str ) -> bool: diff --git a/backends/advanced/src/advanced_omi_backend/auth.py b/backends/advanced/src/advanced_omi_backend/auth.py index 8eefe9c9..fbb334a7 100644 --- a/backends/advanced/src/advanced_omi_backend/auth.py +++ b/backends/advanced/src/advanced_omi_backend/auth.py @@ -82,7 +82,7 @@ async def get_user_manager(user_db=Depends(get_user_db)): # Transport configurations cookie_transport = CookieTransport( - cookie_max_age=3600, # 1 hour + cookie_max_age=86400, # 24 hours (matches JWT lifetime) cookie_secure=COOKIE_SECURE, # Set to False in development if not using HTTPS cookie_httponly=True, cookie_samesite="lax", @@ -171,7 +171,7 @@ async def create_admin_user_if_needed(): ) except Exception as e: - logger.error(f"Failed to create admin user: {e}") + logger.error(f"Failed to create admin user: {e}", exc_info=True) async def websocket_auth(websocket, token: Optional[str] = None) -> Optional[User]: diff --git a/backends/advanced/src/advanced_omi_backend/config.py b/backends/advanced/src/advanced_omi_backend/config.py index 3d738d7a..ceebcad0 100644 --- a/backends/advanced/src/advanced_omi_backend/config.py +++ b/backends/advanced/src/advanced_omi_backend/config.py @@ -13,6 +13,10 @@ logger = logging.getLogger(__name__) +# Data directory paths +DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data")) +CHUNK_DIR = Path("./audio_chunks") # Mounted to ./data/audio_chunks by Docker + # Default diarization settings DEFAULT_DIARIZATION_SETTINGS = { "diarization_source": "pyannote", @@ -37,6 +41,12 @@ "speech_inactivity_threshold": 60, # Speech gap threshold for closure (1 minute) } +# Default audio storage settings +DEFAULT_AUDIO_STORAGE_SETTINGS = { + "audio_base_path": "/app/data", # Main audio directory (where volume is mounted) + "audio_chunks_path": "/app/audio_chunks", # Full path to audio chunks subfolder +} + # Global cache for diarization settings _diarization_settings = None @@ -140,5 +150,18 @@ def get_conversation_stop_settings(): } +def get_audio_storage_settings(): + """Get audio storage settings from environment or defaults.""" + + # Get base path and derive chunks path + audio_base_path = os.getenv("AUDIO_BASE_PATH", DEFAULT_AUDIO_STORAGE_SETTINGS["audio_base_path"]) + audio_chunks_path = os.getenv("AUDIO_CHUNKS_PATH", f"{audio_base_path}/audio_chunks") + + return { + "audio_base_path": audio_base_path, + "audio_chunks_path": audio_chunks_path, + } + + # Initialize settings on module load _diarization_settings = load_diarization_settings_from_file() \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/controllers/audio_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/audio_controller.py new file mode 100644 index 00000000..a805a6f0 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/controllers/audio_controller.py @@ -0,0 +1,176 @@ +""" +Audio file upload and processing controller. + +Handles audio file uploads and processes them directly. +Simplified to write files immediately and enqueue transcription. +""" + +import logging +import time +import uuid + +from fastapi import UploadFile +from fastapi.responses import JSONResponse + +from advanced_omi_backend.audio_utils import AudioValidationError, write_audio_file +from advanced_omi_backend.models.job import JobPriority +from advanced_omi_backend.models.user import User +from advanced_omi_backend.models.conversation import create_conversation + +logger = logging.getLogger(__name__) +audio_logger = logging.getLogger("audio_processing") + + +def generate_client_id(user: User, device_name: str) -> str: + """Generate client ID for uploaded files.""" + user_id_suffix = str(user.id)[-6:] + return f"{user_id_suffix}-{device_name}" + + +async def upload_and_process_audio_files( + user: User, + files: list[UploadFile], + device_name: str = "upload", + auto_generate_client: bool = True, +) -> dict: + """ + Upload audio files and process them directly. + + Simplified flow: + 1. Validate and read WAV file + 2. Write audio file and create AudioSession immediately + 3. Enqueue transcription job (same as WebSocket path) + """ + try: + if not files: + return JSONResponse(status_code=400, content={"error": "No files provided"}) + + processed_files = [] + enqueued_jobs = [] + client_id = generate_client_id(user, device_name) + + for file_index, file in enumerate(files): + try: + # Validate file type (only WAV for now) + if not file.filename or not file.filename.lower().endswith(".wav"): + processed_files.append({ + "filename": file.filename or "unknown", + "status": "error", + "error": "Only WAV files are currently supported", + }) + continue + + audio_logger.info( + f"πŸ“ Uploading file {file_index + 1}/{len(files)}: {file.filename}" + ) + + # Read file content + content = await file.read() + + # Generate audio UUID and timestamp + audio_uuid = str(uuid.uuid4()) + timestamp = int(time.time() * 1000) + + # Validate, write audio file and create AudioSession (all in one) + try: + wav_filename, file_path, duration = await write_audio_file( + raw_audio_data=content, + audio_uuid=audio_uuid, + client_id=client_id, + user_id=user.user_id, + user_email=user.email, + timestamp=timestamp, + validate=True # Validate WAV format, convert stereoβ†’mono + ) + except AudioValidationError as e: + processed_files.append({ + "filename": file.filename, + "status": "error", + "error": str(e), + }) + continue + + audio_logger.info( + f"πŸ“Š {file.filename}: {duration:.1f}s β†’ {wav_filename}" + ) + + # Create conversation immediately for uploaded files + conversation_id = str(uuid.uuid4()) + version_id = str(uuid.uuid4()) + + # Generate title from filename + title = file.filename.rsplit('.', 1)[0][:50] if file.filename else "Uploaded Audio" + + conversation = create_conversation( + conversation_id=conversation_id, + audio_uuid=audio_uuid, + user_id=user.user_id, + client_id=client_id, + title=title, + summary="Processing uploaded audio file..." + ) + await conversation.insert() + + audio_logger.info(f"πŸ“ Created conversation {conversation_id} for uploaded file") + + # Enqueue complete batch processing job chain + from advanced_omi_backend.controllers.queue_controller import start_batch_processing_jobs + + job_ids = start_batch_processing_jobs( + conversation_id=conversation_id, + audio_uuid=audio_uuid, + user_id=user.user_id, + user_email=user.email, + audio_file_path=file_path + ) + + processed_files.append({ + "filename": file.filename, + "status": "processing", + "audio_uuid": audio_uuid, + "conversation_id": conversation_id, + "transcript_job_id": job_ids['transcription'], + "speaker_job_id": job_ids['speaker_recognition'], + "memory_job_id": job_ids['memory'], + "duration_seconds": round(duration, 2), + }) + + enqueued_jobs.append({ + "transcript_job_id": job_ids['transcription'], + "speaker_job_id": job_ids['speaker_recognition'], + "memory_job_id": job_ids['memory'], + "conversation_id": conversation_id, + "audio_uuid": audio_uuid, + "filename": file.filename, + }) + + audio_logger.info( + f"βœ… Processed {file.filename} β†’ conversation {conversation_id}, " + f"jobs: {job_ids['transcription']} β†’ {job_ids['speaker_recognition']} β†’ {job_ids['memory']}" + ) + + except Exception as e: + audio_logger.error(f"Error processing file {file.filename}: {e}") + processed_files.append({ + "filename": file.filename or "unknown", + "status": "error", + "error": str(e), + }) + + return { + "message": f"Uploaded and processing {len(enqueued_jobs)} file(s)", + "client_id": client_id, + "files": processed_files, + "jobs": enqueued_jobs, + "summary": { + "total": len(files), + "processing": len(enqueued_jobs), + "failed": len([f for f in processed_files if f.get("status") == "error"]), + }, + } + + except Exception as e: + audio_logger.error(f"Error in upload_and_process_audio_files: {e}") + return JSONResponse( + status_code=500, content={"error": f"File upload failed: {str(e)}"} + ) diff --git a/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py index e53eef88..c9233dc7 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py @@ -6,6 +6,7 @@ import hashlib import logging import time +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -17,17 +18,17 @@ client_belongs_to_user, get_user_clients_all, ) -from advanced_omi_backend.database import AudioChunksRepository, ProcessingRunsRepository, chunks_col, processing_runs_col, conversations_col, ConversationsRepository +from advanced_omi_backend.database import AudioChunksRepository, chunks_col +from advanced_omi_backend.models.conversation import Conversation from advanced_omi_backend.users import User from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) audio_logger = logging.getLogger("audio_processing") -# Initialize repositories +# Initialize repositories (legacy collections only) chunk_repo = AudioChunksRepository(chunks_col) -processing_runs_repo = ProcessingRunsRepository(processing_runs_col) -conversations_repo = ConversationsRepository(conversations_col) +# ProcessingRunsRepository removed - using RQ job tracking instead async def close_current_conversation(client_id: str, user: User, client_manager: ClientManager): @@ -90,109 +91,152 @@ async def close_current_conversation(client_id: str, user: User, client_manager: ) +async def get_conversation(conversation_id: str, user: User): + """Get a single conversation with full transcript details.""" + try: + # Find the conversation using Beanie + conversation = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation: + return JSONResponse(status_code=404, content={"error": "Conversation not found"}) + + # Check ownership for non-admin users + if not user.is_superuser and conversation.user_id != str(user.user_id): + return JSONResponse(status_code=403, content={"error": "Access forbidden"}) + + # Get audio file paths from audio_chunks collection + audio_chunk = await chunk_repo.get_chunk_by_audio_uuid(conversation.audio_uuid) + audio_path = audio_chunk.get("audio_path") if audio_chunk else None + cropped_audio_path = audio_chunk.get("cropped_audio_path") if audio_chunk else None + + # Format conversation for API response - use model_dump and add computed fields + formatted_conversation = conversation.model_dump( + mode='json', # Automatically converts datetime to ISO strings, handles nested models + exclude={'id'} # Exclude MongoDB internal _id + ) + + # Add computed/external fields not in the model + formatted_conversation.update({ + "timestamp": 0, # Legacy field - using created_at instead + "has_memory": bool(conversation.memories), + "audio_path": audio_path, + "cropped_audio_path": cropped_audio_path, + "version_info": { + "transcript_count": len(conversation.transcript_versions), + "memory_count": len(conversation.memory_versions), + "active_transcript_version": conversation.active_transcript_version, + "active_memory_version": conversation.active_memory_version + } + }) + + return {"conversation": formatted_conversation} + + except Exception as e: + logger.error(f"Error fetching conversation {conversation_id}: {e}") + return JSONResponse(status_code=500, content={"error": "Error fetching conversation"}) + + async def get_conversations(user: User): """Get conversations with speech only (speech-driven architecture).""" try: - # Import conversations collection and repository - conversations_repo = ConversationsRepository(conversations_col) - - # Build query based on user permissions + # Build query based on user permissions using Beanie if not user.is_superuser: # Regular users can only see their own conversations - user_conversations = await conversations_repo.get_user_conversations(str(user.user_id)) + user_conversations = await Conversation.find( + Conversation.user_id == str(user.user_id) + ).sort(-Conversation.created_at).to_list() else: # Admins see all conversations - cursor = conversations_col.find({}).sort("created_at", -1) - user_conversations = await cursor.to_list(length=None) - - # Group conversations by client_id for backwards compatibility - conversations = {} - for conversation in user_conversations: - client_id = conversation["client_id"] - if client_id not in conversations: - conversations[client_id] = [] - - # Get audio file paths from audio_chunks collection - audio_chunk = await chunk_repo.get_chunk_by_audio_uuid(conversation["audio_uuid"]) + user_conversations = await Conversation.find_all().sort(-Conversation.created_at).to_list() + + # Batch fetch all audio chunks in one query to avoid N+1 problem + audio_uuids = [conv.audio_uuid for conv in user_conversations] + audio_chunks_dict = {} + if audio_uuids: + # Fetch all audio chunks at once + chunks_cursor = chunk_repo.col.find({"audio_uuid": {"$in": audio_uuids}}) + async for chunk in chunks_cursor: + audio_chunks_dict[chunk["audio_uuid"]] = chunk + + # Convert conversations to API format + conversations = [] + for conv in user_conversations: + # Get audio file paths from pre-fetched chunks + audio_chunk = audio_chunks_dict.get(conv.audio_uuid) audio_path = audio_chunk.get("audio_path") if audio_chunk else None cropped_audio_path = audio_chunk.get("cropped_audio_path") if audio_chunk else None - # Convert conversation to API format - conversations[client_id].append( - { - "conversation_id": conversation["conversation_id"], - "audio_uuid": conversation["audio_uuid"], - "title": conversation.get("title", "Conversation"), - "summary": conversation.get("summary", ""), - "timestamp": conversation.get("session_start").timestamp() if conversation.get("session_start") else 0, - "created_at": conversation.get("created_at").isoformat() if conversation.get("created_at") else None, - "transcript": conversation.get("transcript", []), - "speakers_identified": conversation.get("speakers_identified", []), - "speaker_names": conversation.get("speaker_names", {}), - "duration_seconds": conversation.get("duration_seconds", 0), - "memories": conversation.get("memories", []), - "has_memory": bool(conversation.get("memories", [])), - "memory_processing_status": conversation.get("memory_processing_status", "pending"), - "action_items": conversation.get("action_items", []), - # Audio file paths for playback - "audio_path": audio_path, - "cropped_audio_path": cropped_audio_path, - } + # Format conversation for list - use model_dump with exclusions + conv_dict = conv.model_dump( + mode='json', # Automatically converts datetime to ISO strings + exclude={'id', 'transcript', 'segments'} # Exclude large fields for list view ) + # Add computed/external fields + conv_dict.update({ + "timestamp": 0, # Legacy field - using created_at instead + "segment_count": len(conv.segments) if conv.segments else 0, + "has_memory": bool(conv.memories), + "audio_path": audio_path, + "cropped_audio_path": cropped_audio_path, + "version_info": { + "transcript_count": len(conv.transcript_versions), + "memory_count": len(conv.memory_versions), + "active_transcript_version": conv.active_transcript_version, + "active_memory_version": conv.active_memory_version + } + }) + + conversations.append(conv_dict) + return {"conversations": conversations} except Exception as e: - logger.error(f"Error fetching conversations: {e}") + logger.exception(f"Error fetching conversations: {e}") return JSONResponse(status_code=500, content={"error": "Error fetching conversations"}) async def get_conversation_by_id(conversation_id: str, user: User): """Get a specific conversation by conversation_id (speech-driven architecture).""" try: - # Import conversations collection and repository - conversations_repo = ConversationsRepository(conversations_col) - - # Get the conversation - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: + # Get the conversation using Beanie + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: return JSONResponse( status_code=404, content={"error": "Conversation not found"} ) # Check if user owns this conversation - if not user.is_superuser and conversation["user_id"] != str(user.user_id): + if not user.is_superuser and conversation_model.user_id != str(user.user_id): return JSONResponse( status_code=403, content={"error": "Access forbidden. You can only access your own conversations."} ) # Get audio file paths from audio_chunks collection - audio_chunk = await chunk_repo.get_chunk_by_audio_uuid(conversation["audio_uuid"]) + audio_chunk = await chunk_repo.get_chunk_by_audio_uuid(conversation_model.audio_uuid) audio_path = audio_chunk.get("audio_path") if audio_chunk else None cropped_audio_path = audio_chunk.get("cropped_audio_path") if audio_chunk else None - # Format conversation for API response - formatted_conversation = { - "conversation_id": conversation["conversation_id"], - "audio_uuid": conversation["audio_uuid"], - "title": conversation.get("title", "Conversation"), - "summary": conversation.get("summary", ""), - "timestamp": conversation.get("session_start").timestamp() if conversation.get("session_start") else 0, - "created_at": conversation.get("created_at").isoformat() if conversation.get("created_at") else None, - "transcript": conversation.get("transcript", []), - "speakers_identified": conversation.get("speakers_identified", []), - "speaker_names": conversation.get("speaker_names", {}), - "duration_seconds": conversation.get("duration_seconds", 0), - "memories": conversation.get("memories", []), - "has_memory": bool(conversation.get("memories", [])), - "memory_processing_status": conversation.get("memory_processing_status", "pending"), - "action_items": conversation.get("action_items", []), - # Audio file paths for playback + # Format conversation for API response - use model_dump and add computed fields + formatted_conversation = conversation_model.model_dump( + mode='json', # Automatically converts datetime to ISO strings, handles nested models + exclude={'id'} # Exclude MongoDB internal _id + ) + + # Add computed/external fields not in the model + formatted_conversation.update({ + "timestamp": 0, # Legacy field - using created_at instead + "has_memory": bool(conversation_model.memories), "audio_path": audio_path, "cropped_audio_path": cropped_audio_path, - } + "version_info": { + "transcript_count": len(conversation_model.transcript_versions), + "memory_count": len(conversation_model.memory_versions), + "active_transcript_version": conversation_model.active_transcript_version, + "active_memory_version": conversation_model.active_memory_version + } + }) return {"conversation": formatted_conversation} @@ -249,7 +293,6 @@ async def reprocess_audio_cropping(audio_uuid: str, user: User): # Check if file exists - try multiple possible locations possible_paths = [ - Path("/app/data/audio_chunks") / audio_path, Path("/app/audio_chunks") / audio_path, Path(audio_path), # fallback to relative path ] @@ -280,7 +323,7 @@ async def reprocess_audio_cropping(audio_uuid: str, user: User): # Generate output path for cropped audio cropped_filename = f"cropped_{audio_uuid}.wav" - output_path = Path("/app/data/audio_chunks") / cropped_filename + output_path = Path("/app/audio_chunks") / cropped_filename # Get repository for database updates chunk_repo = AudioChunksRepository(chunks_col) @@ -469,11 +512,12 @@ async def delete_conversation(audio_uuid: str, user: User): logger.info(f"Deleted audio chunk {audio_uuid}") - # If this audio chunk has an associated conversation, delete it from conversations collection too + # If this audio chunk has an associated conversation, delete it using Beanie if conversation_id: try: - conversation_result = await conversations_col.delete_one({"conversation_id": conversation_id}) - if conversation_result.deleted_count > 0: + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if conversation_model: + await conversation_model.delete() conversation_deleted = True logger.info(f"Deleted conversation {conversation_id} associated with audio chunk {audio_uuid}") else: @@ -537,18 +581,17 @@ async def delete_conversation(audio_uuid: str, user: User): async def reprocess_transcript(conversation_id: str, user: User): """Reprocess transcript for a conversation. Users can only reprocess their own conversations.""" try: - # Find the conversation in conversations collection - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: + # Find the conversation using Beanie + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: return JSONResponse(status_code=404, content={"error": "Conversation not found"}) # Check ownership for non-admin users - if not user.is_superuser and conversation["user_id"] != str(user.user_id): + if not user.is_superuser and conversation_model.user_id != str(user.user_id): return JSONResponse(status_code=403, content={"error": "Access forbidden. You can only reprocess your own conversations."}) # Get audio_uuid for file access - audio_uuid = conversation["audio_uuid"] + audio_uuid = conversation_model.audio_uuid # Get audio file path from audio_chunks collection chunk = await chunks_col.find_one({"audio_uuid": audio_uuid}) @@ -563,7 +606,6 @@ async def reprocess_transcript(conversation_id: str, user: User): # Check if file exists - try multiple possible locations possible_paths = [ - Path("/app/data/audio_chunks") / audio_path, Path("/app/audio_chunks") / audio_path, Path(audio_path), # fallback to relative path ] @@ -584,47 +626,74 @@ async def reprocess_transcript(conversation_id: str, user: User): } ) - # Generate configuration hash for duplicate detection - config_data = { - "audio_path": str(full_audio_path), - "transcription_provider": "deepgram", # This would come from settings - "trigger": "manual_reprocess" - } - config_hash = hashlib.sha256(str(config_data).encode()).hexdigest()[:16] - - # Create processing run - run_id = await processing_runs_repo.create_run( - conversation_id=conversation_id, - audio_uuid=audio_uuid, - run_type="transcript", - user_id=user.user_id, - trigger="manual_reprocess", - config_hash=config_hash + # Create new transcript version ID + import uuid + version_id = str(uuid.uuid4()) + + # Enqueue job chain with RQ (transcription -> speaker recognition -> memory) + from advanced_omi_backend.workers.transcription_jobs import transcribe_full_audio_job, recognise_speakers_job + from advanced_omi_backend.workers.memory_jobs import process_memory_job + from advanced_omi_backend.controllers.queue_controller import transcription_queue, memory_queue, JOB_RESULT_TTL + + # Job 1: Transcribe audio to text + transcript_job = transcription_queue.enqueue( + transcribe_full_audio_job, + conversation_id, + audio_uuid, + str(full_audio_path), + version_id, + str(user.user_id), + "reprocess", + job_timeout=600, + result_ttl=JOB_RESULT_TTL, + job_id=f"reprocess_{conversation_id[:8]}", + description=f"Transcribe audio for {conversation_id[:8]}", + meta={'audio_uuid': audio_uuid} ) - - # Create new transcript version in conversations collection - version_id = await conversations_repo.create_transcript_version( - conversation_id=conversation_id, - processing_run_id=run_id + logger.info(f"πŸ“₯ RQ: Enqueued transcription job {transcript_job.id}") + + # Job 2: Recognize speakers (depends on transcription) + speaker_job = transcription_queue.enqueue( + recognise_speakers_job, + conversation_id, + version_id, + str(full_audio_path), + str(user.user_id), + "", # transcript_text - will be read from DB + [], # words - will be read from DB + depends_on=transcript_job, + job_timeout=600, + result_ttl=JOB_RESULT_TTL, + job_id=f"speaker_{conversation_id[:8]}", + description=f"Recognize speakers for {conversation_id[:8]}", + meta={'audio_uuid': audio_uuid} ) + logger.info(f"πŸ“₯ RQ: Enqueued speaker recognition job {speaker_job.id} (depends on {transcript_job.id})") + + # Job 3: Extract memories (depends on speaker recognition) + memory_job = memory_queue.enqueue( + process_memory_job, + None, # client_id - will be read from conversation in DB + str(user.user_id), + "", # user_email - will be read from user in DB + conversation_id, + depends_on=speaker_job, + job_timeout=1800, + result_ttl=JOB_RESULT_TTL, + job_id=f"memory_{conversation_id[:8]}", + description=f"Extract memories for {conversation_id[:8]}", + meta={'audio_uuid': audio_uuid} + ) + logger.info(f"πŸ“₯ RQ: Enqueued memory job {memory_job.id} (depends on {speaker_job.id})") - if not version_id: - return JSONResponse( - status_code=500, content={"error": "Failed to create transcript version"} - ) - - # TODO: Queue audio for reprocessing with ProcessorManager - # This is where we would integrate with the existing processor - # For now, we'll return the version ID for the caller to handle - - logger.info(f"Created transcript reprocessing job {run_id} (version {version_id}) for conversation {conversation_id}") + job = transcript_job # For backward compatibility with return value + logger.info(f"Created transcript reprocessing job {job.id} (version: {version_id}) for conversation {conversation_id}") return JSONResponse(content={ "message": f"Transcript reprocessing started for conversation {conversation_id}", - "run_id": run_id, + "job_id": job.id, "version_id": version_id, - "config_hash": config_hash, - "status": "PENDING" + "status": "queued" }) except Exception as e: @@ -635,25 +704,22 @@ async def reprocess_transcript(conversation_id: str, user: User): async def reprocess_memory(conversation_id: str, transcript_version_id: str, user: User): """Reprocess memory extraction for a specific transcript version. Users can only reprocess their own conversations.""" try: - # Find the conversation in conversations collection - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: + # Find the conversation using Beanie + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: return JSONResponse(status_code=404, content={"error": "Conversation not found"}) # Check ownership for non-admin users - if not user.is_superuser and conversation["user_id"] != str(user.user_id): + if not user.is_superuser and conversation_model.user_id != str(user.user_id): return JSONResponse(status_code=403, content={"error": "Access forbidden. You can only reprocess your own conversations."}) # Get audio_uuid for processing run tracking - audio_uuid = conversation["audio_uuid"] + audio_uuid = conversation_model.audio_uuid # Resolve transcript version ID - transcript_versions = conversation.get("transcript_versions", []) - # Handle special "active" version ID if transcript_version_id == "active": - active_version_id = conversation.get("active_transcript_version") + active_version_id = conversation_model.active_transcript_version if not active_version_id: return JSONResponse( status_code=404, content={"error": "No active transcript version found"} @@ -662,8 +728,8 @@ async def reprocess_memory(conversation_id: str, transcript_version_id: str, use # Find the specific transcript version transcript_version = None - for version in transcript_versions: - if version["version_id"] == transcript_version_id: + for version in conversation_model.transcript_versions: + if version.version_id == transcript_version_id: transcript_version = version break @@ -672,48 +738,30 @@ async def reprocess_memory(conversation_id: str, transcript_version_id: str, use status_code=404, content={"error": f"Transcript version '{transcript_version_id}' not found"} ) - # Generate configuration hash for duplicate detection - config_data = { - "transcript_version_id": transcript_version_id, - "memory_provider": "friend_lite", # This would come from settings - "trigger": "manual_reprocess" - } - config_hash = hashlib.sha256(str(config_data).encode()).hexdigest()[:16] + # Create new memory version ID + import uuid + version_id = str(uuid.uuid4()) - # Create processing run - run_id = await processing_runs_repo.create_run( - conversation_id=conversation_id, - audio_uuid=audio_uuid, - run_type="memory", - user_id=user.user_id, - trigger="manual_reprocess", - config_hash=config_hash - ) + # Enqueue memory processing job with RQ (RQ handles job tracking) + from advanced_omi_backend.workers.memory_jobs import enqueue_memory_processing + from advanced_omi_backend.models.job import JobPriority - # Create new memory version in conversations collection - version_id = await conversations_repo.create_memory_version( + job = enqueue_memory_processing( + client_id=conversation_model.client_id, + user_id=str(user.user_id), + user_email=user.email, conversation_id=conversation_id, - transcript_version_id=transcript_version_id, - processing_run_id=run_id + priority=JobPriority.NORMAL ) - if not version_id: - return JSONResponse( - status_code=500, content={"error": "Failed to create memory version"} - ) - - # TODO: Queue memory extraction for processing - # This is where we would integrate with the existing memory processor - - logger.info(f"Created memory reprocessing job {run_id} (version {version_id}) for conversation {conversation_id}") + logger.info(f"Created memory reprocessing job {job.id} (version {version_id}) for conversation {conversation_id}") return JSONResponse(content={ "message": f"Memory reprocessing started for conversation {conversation_id}", - "run_id": run_id, + "job_id": job.id, "version_id": version_id, "transcript_version_id": transcript_version_id, - "config_hash": config_hash, - "status": "PENDING" + "status": "queued" }) except Exception as e: @@ -724,23 +772,24 @@ async def reprocess_memory(conversation_id: str, transcript_version_id: str, use async def activate_transcript_version(conversation_id: str, version_id: str, user: User): """Activate a specific transcript version. Users can only modify their own conversations.""" try: - # Find the conversation in conversations collection - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: + # Find the conversation using Beanie + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: return JSONResponse(status_code=404, content={"error": "Conversation not found"}) # Check ownership for non-admin users - if not user.is_superuser and conversation["user_id"] != str(user.user_id): + if not user.is_superuser and conversation_model.user_id != str(user.user_id): return JSONResponse(status_code=403, content={"error": "Access forbidden. You can only modify your own conversations."}) - # Activate the transcript version - success = await conversations_repo.activate_transcript_version(conversation_id, version_id) + # Activate the transcript version using Beanie model method + success = conversation_model.set_active_transcript_version(version_id) if not success: return JSONResponse( status_code=400, content={"error": "Failed to activate transcript version"} ) + await conversation_model.save() + # TODO: Trigger speaker recognition if configured # This would integrate with existing speaker recognition logic @@ -759,23 +808,24 @@ async def activate_transcript_version(conversation_id: str, version_id: str, use async def activate_memory_version(conversation_id: str, version_id: str, user: User): """Activate a specific memory version. Users can only modify their own conversations.""" try: - # Find the conversation in conversations collection - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: + # Find the conversation using Beanie + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: return JSONResponse(status_code=404, content={"error": "Conversation not found"}) # Check ownership for non-admin users - if not user.is_superuser and conversation["user_id"] != str(user.user_id): + if not user.is_superuser and conversation_model.user_id != str(user.user_id): return JSONResponse(status_code=403, content={"error": "Access forbidden. You can only modify your own conversations."}) - # Activate the memory version - success = await conversations_repo.activate_memory_version(conversation_id, version_id) + # Activate the memory version using Beanie model method + success = conversation_model.set_active_memory_version(version_id) if not success: return JSONResponse( status_code=400, content={"error": "Failed to activate memory version"} ) + await conversation_model.save() + logger.info(f"Activated memory version {version_id} for conversation {conversation_id} by user {user.user_id}") return JSONResponse(content={ @@ -791,18 +841,38 @@ async def activate_memory_version(conversation_id: str, version_id: str, user: U async def get_conversation_version_history(conversation_id: str, user: User): """Get version history for a conversation. Users can only access their own conversations.""" try: - # Find the conversation in conversations collection to check ownership - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: + # Find the conversation using Beanie to check ownership + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: return JSONResponse(status_code=404, content={"error": "Conversation not found"}) # Check ownership for non-admin users - if not user.is_superuser and conversation["user_id"] != str(user.user_id): + if not user.is_superuser and conversation_model.user_id != str(user.user_id): return JSONResponse(status_code=403, content={"error": "Access forbidden. You can only access your own conversations."}) - # Get version history - history = await conversations_repo.get_version_history(conversation_id) + # Get version history from model + # Convert datetime objects to ISO strings for JSON serialization + transcript_versions = [] + for v in conversation_model.transcript_versions: + version_dict = v.model_dump() + if version_dict.get('created_at'): + version_dict['created_at'] = version_dict['created_at'].isoformat() + transcript_versions.append(version_dict) + + memory_versions = [] + for v in conversation_model.memory_versions: + version_dict = v.model_dump() + if version_dict.get('created_at'): + version_dict['created_at'] = version_dict['created_at'].isoformat() + memory_versions.append(version_dict) + + history = { + "conversation_id": conversation_id, + "active_transcript_version": conversation_model.active_transcript_version, + "active_memory_version": conversation_model.active_memory_version, + "transcript_versions": transcript_versions, + "memory_versions": memory_versions + } return JSONResponse(content=history) diff --git a/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py new file mode 100644 index 00000000..dcd657dc --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py @@ -0,0 +1,540 @@ +""" +Queue Controller - RQ queue configuration, management and monitoring. + +This module provides: +- Queue setup and configuration +- Job statistics and monitoring +- Queue health checks +- Beanie initialization for workers +""" + +import os +import logging +from typing import Dict, Any, Optional + +import redis +from rq import Queue, Worker +from rq.job import Job + +from advanced_omi_backend.models.job import JobPriority + +logger = logging.getLogger(__name__) + +# Global flag to track if Beanie is initialized in this process +_beanie_initialized = False + +# Redis connection configuration +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") +redis_conn = redis.from_url(REDIS_URL) + +# Queue name constants +TRANSCRIPTION_QUEUE = "transcription" +MEMORY_QUEUE = "memory" +DEFAULT_QUEUE = "default" + +# Job retention configuration +JOB_RESULT_TTL = int(os.getenv("RQ_RESULT_TTL", 3600)) # 1 hour default + +# Create queues with custom result TTL +transcription_queue = Queue(TRANSCRIPTION_QUEUE, connection=redis_conn, default_timeout=300) +memory_queue = Queue(MEMORY_QUEUE, connection=redis_conn, default_timeout=300) +default_queue = Queue(DEFAULT_QUEUE, connection=redis_conn, default_timeout=300) + + +def get_queue(queue_name: str = DEFAULT_QUEUE) -> Queue: + """Get an RQ queue by name.""" + queues = { + TRANSCRIPTION_QUEUE: transcription_queue, + MEMORY_QUEUE: memory_queue, + DEFAULT_QUEUE: default_queue, + } + return queues.get(queue_name, default_queue) + + +async def _ensure_beanie_initialized(): + """Ensure Beanie is initialized in the current process (for RQ workers).""" + global _beanie_initialized + + if _beanie_initialized: + return + + try: + from motor.motor_asyncio import AsyncIOMotorClient + from beanie import init_beanie + from advanced_omi_backend.models.conversation import Conversation + from advanced_omi_backend.models.audio_file import AudioFile + from advanced_omi_backend.models.user import User + + # Get MongoDB URI from environment + mongodb_uri = os.getenv("MONGODB_URI", "mongodb://localhost:27017") + + # Create MongoDB client + client = AsyncIOMotorClient(mongodb_uri) + database = client.get_default_database("friend-lite") + + # Initialize Beanie + await init_beanie( + database=database, + document_models=[User, Conversation, AudioFile], + ) + + _beanie_initialized = True + logger.info("βœ… Beanie initialized in RQ worker process") + + except Exception as e: + logger.error(f"❌ Failed to initialize Beanie in RQ worker: {e}") + raise + + +def get_job_stats() -> Dict[str, Any]: + """Get statistics about jobs in all queues matching frontend expectations.""" + from datetime import datetime + + total_jobs = 0 + queued_jobs = 0 + processing_jobs = 0 + completed_jobs = 0 + failed_jobs = 0 + cancelled_jobs = 0 + deferred_jobs = 0 # Jobs waiting for dependencies (depends_on) + + for queue_name in [TRANSCRIPTION_QUEUE, MEMORY_QUEUE, DEFAULT_QUEUE]: + queue = get_queue(queue_name) + + queued_jobs += len(queue) + processing_jobs += len(queue.started_job_registry) + completed_jobs += len(queue.finished_job_registry) + failed_jobs += len(queue.failed_job_registry) + cancelled_jobs += len(queue.canceled_job_registry) + deferred_jobs += len(queue.deferred_job_registry) + + total_jobs = queued_jobs + processing_jobs + completed_jobs + failed_jobs + cancelled_jobs + deferred_jobs + + return { + "total_jobs": total_jobs, + "queued_jobs": queued_jobs, + "processing_jobs": processing_jobs, + "completed_jobs": completed_jobs, + "failed_jobs": failed_jobs, + "cancelled_jobs": cancelled_jobs, + "deferred_jobs": deferred_jobs, + "timestamp": datetime.utcnow().isoformat() + } + + +def get_jobs(limit: int = 20, offset: int = 0, queue_name: str = None) -> Dict[str, Any]: + """ + Get jobs from a specific queue or all queues. + + Args: + limit: Maximum number of jobs to return + offset: Number of jobs to skip + queue_name: Specific queue name or None for all queues + + Returns: + Dict with jobs list and pagination metadata matching frontend expectations + """ + all_jobs = [] + + queues_to_check = [queue_name] if queue_name else [TRANSCRIPTION_QUEUE, MEMORY_QUEUE, DEFAULT_QUEUE] + + for qname in queues_to_check: + queue = get_queue(qname) + + # Collect jobs from all registries + registries = [ + (queue.job_ids, "queued"), + (queue.started_job_registry.get_job_ids(), "processing"), + (queue.finished_job_registry.get_job_ids(), "completed"), + (queue.failed_job_registry.get_job_ids(), "failed"), + (queue.deferred_job_registry.get_job_ids(), "deferred"), # Jobs waiting for dependencies + ] + + for job_ids, status in registries: + for job_id in job_ids: + try: + job = Job.fetch(job_id, connection=redis_conn) + + # Extract user_id from kwargs if present + user_id = job.kwargs.get("user_id", "") if job.kwargs else "" + + # Extract just the function name (e.g., "listen_for_speech_job" from "module.listen_for_speech_job") + job_type = job.func_name.split('.')[-1] if job.func_name else "unknown" + + all_jobs.append({ + "job_id": job.id, + "job_type": job_type, + "user_id": user_id, + "status": status, + "priority": "normal", # RQ doesn't track priority in metadata + "data": { + "description": job.description or "", + "queue": qname, + }, + "result": job.result if hasattr(job, 'result') else None, + "error_message": str(job.exc_info) if job.exc_info else None, + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.ended_at.isoformat() if job.ended_at else None, + "retry_count": job.retries_left if hasattr(job, 'retries_left') else 0, + "max_retries": 3, # Default max retries + "progress_percent": 0, # RQ doesn't track progress by default + "progress_message": "", + }) + except Exception as e: + logger.error(f"Error fetching job {job_id}: {e}") + + # Sort by created_at (most recent first) + all_jobs.sort(key=lambda x: x.get("created_at") or "", reverse=True) + + # Paginate + total_jobs = len(all_jobs) + paginated_jobs = all_jobs[offset:offset + limit] + has_more = (offset + limit) < total_jobs + + return { + "jobs": paginated_jobs, + "pagination": { + "total": total_jobs, + "limit": limit, + "offset": offset, + "has_more": has_more, + } + } + + +def all_jobs_complete_for_session(session_id: str) -> bool: + """ + Check if all jobs associated with a session are in terminal states. + + A session is considered complete only when all its jobs are in terminal states + (completed, failed, or cancelled). Jobs that are queued or processing keep the + session in active state. + + This function now traverses dependency chains to find dependent jobs that may + not be in any registry yet (they're stored via job.dependent_ids). + + Args: + session_id: The audio_uuid (session ID) to check jobs for + + Returns: + True if all jobs are complete (or no jobs found), False if any job is still processing + """ + from rq.registry import ScheduledJobRegistry, DeferredJobRegistry + from advanced_omi_backend.models.conversation import Conversation + import asyncio + + # First, get conversation_id(s) for this session (for memory jobs) + conversation_ids = set() + try: + # Run async query in sync context + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + conversations = loop.run_until_complete( + Conversation.find(Conversation.audio_uuid == session_id).to_list() + ) + conversation_ids = {conv.conversation_id for conv in conversations} + loop.close() + except Exception as e: + logger.debug(f"Error fetching conversations for session {session_id}: {e}") + + processed_job_ids = set() # Track which jobs we've already checked + session_jobs_found = [] # Track all jobs found for this session + + def check_job_and_dependents(job): + """ + Recursively check a job and all its dependents. + Returns True if all are terminal, False if any are non-terminal. + """ + if job.id in processed_job_ids: + return True + + processed_job_ids.add(job.id) + + # Check if this job is in a terminal state + is_terminal = job.is_finished or job.is_failed or job.is_canceled + + if not is_terminal: + # Job is still queued, processing, or scheduled - session not complete + logger.debug(f"Job {job.id} ({job.func_name}) is not terminal (queued/processing/scheduled)") + return False + + # Check dependent jobs (jobs that depend on this one) + try: + dependent_ids = job.dependent_ids + if dependent_ids: + logger.debug(f"Job {job.id} has {len(dependent_ids)} dependents") + for dep_id in dependent_ids: + try: + dep_job = Job.fetch(dep_id, connection=redis_conn) + # Recursively check dependent job + if not check_job_and_dependents(dep_job): + return False + except Exception as e: + logger.debug(f"Error fetching dependent job {dep_id}: {e}") + except Exception as e: + logger.debug(f"Error checking dependents for job {job.id}: {e}") + + return True + + # Check all queues and registries + for queue in [transcription_queue, memory_queue, default_queue]: + # Check all job registries for this queue (including scheduled/deferred) + registries = [ + queue.job_ids, # Queued jobs + queue.started_job_registry.get_job_ids(), # Processing jobs + queue.finished_job_registry.get_job_ids(), # Completed + queue.failed_job_registry.get_job_ids(), # Failed + queue.canceled_job_registry.get_job_ids(), # Cancelled + ScheduledJobRegistry(queue=queue).get_job_ids(), # Scheduled (dependent jobs) + DeferredJobRegistry(queue=queue).get_job_ids(), # Deferred (retrying) + ] + + for job_ids in registries: + for job_id in job_ids: + try: + job = Job.fetch(job_id, connection=redis_conn) + matches_session = False + + # Check job.meta first (preferred method for all new jobs) + if job.meta and 'audio_uuid' in job.meta: + if job.meta['audio_uuid'] == session_id: + matches_session = True + # FALLBACK: Check args for backward compatibility + elif job.args and len(job.args) > 0: + # Check args[0] first (most common for streaming jobs) + if job.args[0] == session_id: + matches_session = True + # Check args[1] for transcription jobs + elif len(job.args) > 1 and job.args[1] == session_id: + matches_session = True + # Check args[3] for memory jobs (conversation_id) + elif len(job.args) > 3 and job.args[3] in conversation_ids: + matches_session = True + + if matches_session: + session_jobs_found.append(job.id) + # Check this job and all its dependents + if not check_job_and_dependents(job): + logger.debug(f"Session {session_id} has incomplete jobs (found {len(session_jobs_found)} jobs)") + return False + + except Exception as e: + logger.debug(f"Error checking job {job_id}: {e}") + continue + + # All jobs are in terminal states (or no jobs found) + logger.debug(f"Session {session_id} all jobs complete ({len(session_jobs_found)} jobs checked)") + return True + + +def start_streaming_jobs( + session_id: str, + user_id: str, + user_email: str, + client_id: str +) -> Dict[str, str]: + """ + Enqueue jobs for streaming audio session. + + This starts the parallel job processing for a streaming session: + 1. Speech detection job - monitors transcription results for speech + 2. Audio persistence job - writes audio chunks to WAV file + + Args: + session_id: Stream session ID (audio_uuid) + user_id: User identifier + user_email: User email + client_id: Client identifier + + Returns: + Dict with job IDs: {'speech_detection': job_id, 'audio_persistence': job_id} + """ + from advanced_omi_backend.workers.transcription_jobs import stream_speech_detection_job + from advanced_omi_backend.workers.audio_jobs import audio_streaming_persistence_job + + # Enqueue speech detection job + speech_job = transcription_queue.enqueue( + stream_speech_detection_job, + session_id, + user_id, + user_email, + client_id, + job_timeout=3600, # 1 hour for long recordings + result_ttl=JOB_RESULT_TTL, + job_id=f"speech-detect_{session_id[:12]}", + description=f"Stream speech detection for {session_id[:12]}", + meta={'audio_uuid': session_id} + ) + logger.info(f"πŸ“₯ RQ: Enqueued speech detection job {speech_job.id}") + + # Enqueue audio persistence job in parallel + audio_job = transcription_queue.enqueue( + audio_streaming_persistence_job, + session_id, + user_id, + user_email, + client_id, + job_timeout=3600, # 1 hour for long recordings + result_ttl=JOB_RESULT_TTL, + job_id=f"audio-persist_{session_id[:12]}", + description=f"Audio persistence for {session_id[:12]}", + meta={'audio_uuid': session_id} + ) + logger.info(f"πŸ“₯ RQ: Enqueued audio persistence job {audio_job.id}") + + return { + 'speech_detection': speech_job.id, + 'audio_persistence': audio_job.id + } + + +def start_batch_processing_jobs( + conversation_id: str, + audio_uuid: str, + user_id: str, + user_email: str, + audio_file_path: str +) -> Dict[str, str]: + """ + Enqueue complete batch processing job chain with dependencies. + + This creates the full processing pipeline: + 1. Transcription job (transcribe audio file) + 2. Speaker recognition job (depends on transcription) + 3. Memory extraction job (depends on speaker recognition) + + Args: + conversation_id: Conversation identifier + audio_uuid: Audio file UUID + user_id: User identifier + user_email: User email + audio_file_path: Path to audio file + + Returns: + Dict with job IDs: { + 'transcription': job_id, + 'speaker_recognition': job_id, + 'memory': job_id + } + """ + import uuid + from advanced_omi_backend.workers.transcription_jobs import transcribe_full_audio_job + from advanced_omi_backend.workers.transcription_jobs import recognise_speakers_job + from advanced_omi_backend.workers.memory_jobs import process_memory_job + + # Generate version IDs for transcript and speaker processing + transcript_version_id = str(uuid.uuid4()) + + # Step 1: Transcription job (no dependencies) + # Signature: transcribe_full_audio_job(conversation_id, audio_uuid, audio_path, version_id, user_id, trigger, redis_client) + transcription_job = transcription_queue.enqueue( + transcribe_full_audio_job, + conversation_id, + audio_uuid, + audio_file_path, + transcript_version_id, + user_id, + "batch", # trigger + job_timeout=getattr(transcribe_full_audio_job, 'job_timeout', 1800), # Use decorator default or 30 min + result_ttl=getattr(transcribe_full_audio_job, 'result_ttl', JOB_RESULT_TTL), + job_id=f"transcribe_{audio_uuid[:12]}", + description=f"Transcribe audio {audio_uuid[:12]}", + meta={'audio_uuid': audio_uuid, 'conversation_id': conversation_id} + ) + logger.info(f"πŸ“₯ RQ: Enqueued transcription job {transcription_job.id}") + + # Step 2: Speaker recognition job (depends on transcription) + # Signature: recognise_speakers_job(conversation_id, version_id, audio_path, user_id, transcript_text, words, redis_client) + speaker_job = transcription_queue.enqueue( + recognise_speakers_job, + conversation_id, + transcript_version_id, + audio_file_path, + user_id, + "", # transcript_text - will be read from DB + [], # words - will be read from DB + job_timeout=getattr(recognise_speakers_job, 'job_timeout', 1200), # Use decorator default or 20 min + result_ttl=getattr(recognise_speakers_job, 'result_ttl', JOB_RESULT_TTL), + depends_on=transcription_job, + job_id=f"speaker_{audio_uuid[:12]}", + description=f"Speaker recognition for {audio_uuid[:12]}", + meta={'audio_uuid': audio_uuid, 'conversation_id': conversation_id} + ) + logger.info(f"πŸ“₯ RQ: Enqueued speaker recognition job {speaker_job.id} (depends on {transcription_job.id})") + + # Step 3: Memory extraction job (depends on speaker recognition) + # Signature: process_memory_job(client_id, user_id, user_email, conversation_id, redis_client) + memory_job = memory_queue.enqueue( + process_memory_job, + None, # client_id - will be read from conversation in DB + user_id, + user_email, + conversation_id, + job_timeout=getattr(process_memory_job, 'job_timeout', 900), # Use decorator default or 15 min + result_ttl=getattr(process_memory_job, 'result_ttl', JOB_RESULT_TTL), + depends_on=speaker_job, + job_id=f"memory_{audio_uuid[:12]}", + description=f"Memory extraction for {audio_uuid[:12]}", + meta={'audio_uuid': audio_uuid, 'conversation_id': conversation_id} + ) + logger.info(f"πŸ“₯ RQ: Enqueued memory extraction job {memory_job.id} (depends on {speaker_job.id})") + + return { + 'transcription': transcription_job.id, + 'speaker_recognition': speaker_job.id, + 'memory': memory_job.id + } + + +def get_queue_health() -> Dict[str, Any]: + """Get health status of all queues and workers.""" + health = { + "queues": {}, + "workers": [], + "redis_connection": "unknown", + "total_workers": 0, + "active_workers": 0, + "idle_workers": 0, + } + + # Check Redis connection + try: + redis_conn.ping() + health["redis_connection"] = "healthy" + except Exception as e: + health["redis_connection"] = f"unhealthy: {e}" + return health + + # Check each queue + for queue_name in [TRANSCRIPTION_QUEUE, MEMORY_QUEUE, DEFAULT_QUEUE]: + queue = get_queue(queue_name) + health["queues"][queue_name] = { + "count": len(queue), + "failed_count": len(queue.failed_job_registry), + "finished_count": len(queue.finished_job_registry), + "started_count": len(queue.started_job_registry), + } + + # Check workers + workers = Worker.all(connection=redis_conn) + health["total_workers"] = len(workers) + + for worker in workers: + state = worker.get_state() + current_job = worker.get_current_job_id() + + # Count active vs idle workers + if current_job or state == "busy": + health["active_workers"] += 1 + else: + health["idle_workers"] += 1 + + health["workers"].append({ + "name": worker.name, + "state": state, + "queues": [q.name for q in worker.queues], + "current_job": current_job, + }) + + return health diff --git a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py index 9fc7efe6..045a7007 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py @@ -2,32 +2,24 @@ System controller for handling system-related business logic. """ -import asyncio -import io -import json import logging import os import shutil import time -import wave from datetime import UTC, datetime -from pathlib import Path -import numpy as np -from advanced_omi_backend.client_manager import generate_client_id from advanced_omi_backend.config import ( load_diarization_settings_from_file, save_diarization_settings_to_file, ) -from advanced_omi_backend.database import chunks_col -from advanced_omi_backend.job_tracker import FileStatus, JobStatus, get_job_tracker -from advanced_omi_backend.processors import AudioProcessingItem, get_processor_manager -from advanced_omi_backend.audio_utils import process_audio_chunk +from advanced_omi_backend.models.user import User +# TODO: Remove old processor architecture +# from advanced_omi_backend.processors import get_processor_manager +def get_processor_manager(): + """Stub - processors being removed.""" + return None from advanced_omi_backend.task_manager import get_task_manager -from advanced_omi_backend.users import User -from fastapi import BackgroundTasks, File, Query, UploadFile from fastapi.responses import JSONResponse -from wyoming.audio import AudioChunk logger = logging.getLogger(__name__) audio_logger = logging.getLogger("audio_processing") @@ -118,28 +110,26 @@ async def get_processing_task_status(client_id: str): async def get_processor_status(): - """Get processor queue status and health.""" + """Get RQ worker and queue status.""" try: - processor_manager = get_processor_manager() + # Get RQ queue health (new architecture) + from advanced_omi_backend.controllers.queue_controller import get_queue_health + queue_health = get_queue_health() - # Get queue sizes status = { - "queues": { - "audio_queue": processor_manager.audio_queue.qsize(), - "transcription_queue": processor_manager.transcription_queue.qsize(), - "memory_queue": processor_manager.memory_queue.qsize(), - "cropping_queue": processor_manager.cropping_queue.qsize(), - }, - "processors": { - "audio_processor": "running", - "transcription_processor": "running", - "memory_processor": "running", - "cropping_processor": "running", - }, - "active_clients": len(processor_manager.active_file_sinks), - "active_audio_uuids": len(processor_manager.active_audio_uuids), - "processing_tasks": len(processor_manager.processing_tasks), + "architecture": "rq_workers", # New RQ-based architecture "timestamp": int(time.time()), + "workers": { + "total": queue_health.get("total_workers", 0), + "active": queue_health.get("active_workers", 0), + "idle": queue_health.get("idle_workers", 0), + "details": queue_health.get("workers", []) + }, + "queues": { + "transcription": queue_health.get("queues", {}).get("transcription", {}), + "memory": queue_health.get("queues", {}).get("memory", {}), + "default": queue_health.get("queues", {}).get("default", {}) + } } # Get task manager status if available @@ -154,604 +144,13 @@ async def get_processor_status(): return status except Exception as e: - logger.error(f"Error getting processor status: {e}") + logger.error(f"Error getting processor status: {e}", exc_info=True) return JSONResponse( status_code=500, content={"error": f"Failed to get processor status: {str(e)}"} ) -async def process_audio_files( - user: User, files: list[UploadFile], device_name: str, auto_generate_client: bool -): - """Process uploaded audio files through the transcription pipeline.""" - # Need to import here because we import the routes into main, causing circular imports - from advanced_omi_backend.main import cleanup_client_state, create_client_state - - # Process files through complete transcription pipeline like WebSocket clients - try: - if not files: - return JSONResponse(status_code=400, content={"error": "No files provided"}) - - processed_files = [] - processed_conversations = [] - - for file_index, file in enumerate(files): - client_id = None - client_state = None - - try: - # Validate file type (only WAV for now) - if not file.filename or not file.filename.lower().endswith(".wav"): - processed_files.append( - { - "filename": file.filename or "unknown", - "status": "error", - "error": "Only WAV files are currently supported", - } - ) - continue - - # Generate unique client ID for each file to create separate conversations - file_device_name = f"{device_name}-{file_index + 1:03d}" - client_id = generate_client_id(user, file_device_name) - - # Create separate client state for this file - client_state = await create_client_state(client_id, user, file_device_name) - - audio_logger.info( - f"πŸ“ Processing file {file_index + 1}/{len(files)}: {file.filename} with client_id: {client_id}" - ) - - processor_manager = get_processor_manager() - - # Read file content - content = await file.read() - - # Process WAV file - with wave.open(io.BytesIO(content), "rb") as wav_file: - # Get audio parameters - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - channels = wav_file.getnchannels() - - # Read all audio data - audio_data = wav_file.readframes(wav_file.getnframes()) - - # Convert to mono if stereo - if channels == 2: - # Convert stereo to mono by averaging channels - if sample_width == 2: - audio_array = np.frombuffer(audio_data, dtype=np.int16) - else: - audio_array = np.frombuffer(audio_data, dtype=np.int32) - - # Reshape to separate channels and average - audio_array = audio_array.reshape(-1, 2) - audio_data = ( - np.mean(audio_array, axis=1).astype(audio_array.dtype).tobytes() - ) - channels = 1 - - # Ensure sample rate is 16kHz (resample if needed) - if sample_rate != 16000: - audio_logger.warning( - f"File {file.filename} has sample rate {sample_rate}Hz, expected 16kHz." - ) - raise JSONResponse(status_code=400, content={"error": f"File {file.filename} has sample rate {sample_rate}Hz, expected 16kHz. I'll implement this at some point sorry"}) - - # Process audio in larger chunks for faster file processing - # Use larger chunks (32KB) for optimal performance - chunk_size = 32 * 1024 # 32KB chunks - base_timestamp = int(time.time()) - - for i in range(0, len(audio_data), chunk_size): - chunk_data = audio_data[i : i + chunk_size] - - # Calculate relative timestamp for this chunk - chunk_offset_bytes = i - chunk_offset_seconds = chunk_offset_bytes / ( - sample_rate * sample_width * channels - ) - chunk_timestamp = base_timestamp + int(chunk_offset_seconds) - - # Process audio chunk through unified pipeline - await process_audio_chunk( - audio_data=chunk_data, - client_id=client_id, - user_id=user.user_id, - user_email=user.email, - audio_format={ - "rate": sample_rate, - "width": sample_width, - "channels": channels, - "timestamp": chunk_timestamp, - }, - client_state=None, # No client state needed for file upload - ) - - # Yield control occasionally to prevent blocking the event loop - if i % (chunk_size * 10) == 0: # Every 10 chunks (~320KB) - await asyncio.sleep(0) - - processed_files.append( - { - "filename": file.filename, - "sample_rate": sample_rate, - "channels": channels, - "duration_seconds": len(audio_data) - / (sample_rate * sample_width * channels), - "size_bytes": len(audio_data), - "client_id": client_id, - "status": "processed", - } - ) - - audio_logger.info( - f"βœ… Processed audio file: {file.filename} ({len(audio_data)} bytes)" - ) - - # Wait briefly for transcription manager to be created by background processor - audio_logger.info( - f"⏳ Waiting for transcription manager to be created for client {client_id}" - ) - await asyncio.sleep(2.0) # Give transcription processor time to create manager - - # Close client audio to trigger transcription completion (flush_final_transcript) - audio_logger.info( - f"πŸ“ž About to call close_client_audio for upload client {client_id}" - ) - processor_manager = get_processor_manager() - audio_logger.info(f"πŸ“ž Got processor manager, calling close_client_audio now...") - await processor_manager.close_client_audio(client_id) - audio_logger.info( - f"πŸ”š Successfully called close_client_audio for upload client {client_id}" - ) - - # Wait for this file's transcription processing to complete - audio_logger.info(f"πŸ“ Waiting for transcription to process file: {file.filename}") - - # Wait for chunks to be processed by the audio saver - await asyncio.sleep(1.0) - - # Wait for file processing to complete using task tracking - # Increase timeout based on file duration (3x duration + 60s buffer) - audio_duration = len(audio_data) / (sample_rate * sample_width * channels) - max_wait_time = max( - 120, int(audio_duration * 3) + 60 - ) # At least 2 minutes, or 3x duration + 60s - wait_interval = 2.0 # Reduced from 0.5s to 2s to reduce polling spam - elapsed_time = 0 - - audio_logger.info( - f"πŸ“ Audio duration: {audio_duration:.1f}s, max wait time: {max_wait_time}s" - ) - - # Use concrete task tracking instead of database polling - while elapsed_time < max_wait_time: - try: - # Check processing status using task tracking - processing_status = processor_manager.get_processing_status(client_id) - - # Check if transcription stage is complete - stages = processing_status.get("stages", {}) - transcription_stage = stages.get("transcription", {}) - - # If transcription is marked as started but not completed, check database - if transcription_stage.get( - "status" - ) == "started" and not transcription_stage.get("completed", False): - # Check if transcription is actually complete by checking the database - try: - chunk = await chunks_col.find_one({"client_id": client_id}) - if ( - chunk - and chunk.get("transcript") - and len(chunk.get("transcript", [])) > 0 - ): - # Transcription is complete! Update the processor state - processor_manager.track_processing_stage( - client_id, - "transcription", - "completed", - { - "audio_uuid": chunk.get("audio_uuid"), - "segments": len(chunk.get("transcript", [])), - }, - ) - audio_logger.info( - f"πŸ“ Transcription completed for file: {file.filename} ({len(chunk.get('transcript', []))} segments)" - ) - break - except Exception as e: - audio_logger.debug(f"Error checking transcription completion: {e}") - - if transcription_stage.get("completed", False): - audio_logger.info( - f"πŸ“ Transcription completed for file: {file.filename}" - ) - break - - # Check for errors - if transcription_stage.get("error"): - audio_logger.warning( - f"πŸ“ Transcription error for file: {file.filename}: {transcription_stage.get('error')}" - ) - break - - except Exception as e: - audio_logger.debug(f"Error checking processing status: {e}") - - await asyncio.sleep(wait_interval) - elapsed_time += wait_interval - - if elapsed_time >= max_wait_time: - audio_logger.warning(f"πŸ“ Transcription timed out for file: {file.filename}") - - # Signal end of conversation - trigger memory processing - await client_state.close_current_conversation() - - # Give cleanup time to complete - await asyncio.sleep(0.5) - - # Track conversation created - conversation_info = { - "client_id": client_id, - "filename": file.filename, - "status": "completed" if elapsed_time < max_wait_time else "timed_out", - } - processed_conversations.append(conversation_info) - - except Exception as e: - audio_logger.error(f"Error processing file {file.filename}: {e}") - processed_files.append( - {"filename": file.filename or "unknown", "status": "error", "error": str(e)} - ) - finally: - # Always clean up client state to prevent accumulation - if client_id and client_state: - try: - await cleanup_client_state(client_id) - audio_logger.info(f"🧹 Cleaned up client state for {client_id}") - except Exception as cleanup_error: - audio_logger.error( - f"❌ Error cleaning up client state for {client_id}: {cleanup_error}" - ) - - return { - "message": f"Processed {len(files)} files", - "files": processed_files, - "conversations": processed_conversations, - "successful": len([f for f in processed_files if f.get("status") != "error"]), - "failed": len([f for f in processed_files if f.get("status") == "error"]), - } - - except Exception as e: - audio_logger.error(f"Error in process_audio_files: {e}") - return JSONResponse(status_code=500, content={"error": f"File processing failed: {str(e)}"}) - - -def get_audio_duration(file_content: bytes) -> float: - """Get duration of WAV file in seconds using wave library.""" - try: - with wave.open(io.BytesIO(file_content), "rb") as wav_file: - frames = wav_file.getnframes() - sample_rate = wav_file.getframerate() - duration = frames / sample_rate - return duration - except Exception as e: - logger.warning(f"Could not determine audio duration: {e}") - return 0.0 - - -async def process_audio_files_async( - background_tasks: BackgroundTasks, user: User, files: list[UploadFile], device_name: str -): - """Start async processing of uploaded audio files. Returns job ID immediately.""" - try: - if not files: - return JSONResponse(status_code=400, content={"error": "No files provided"}) - - # Read all file contents immediately to avoid file handle issues - file_data = [] - for file in files: - try: - content = await file.read() - file_data.append((file.filename, content)) - audio_logger.info(f"πŸ“₯ Read file: {file.filename} ({len(content)} bytes)") - except Exception as e: - audio_logger.error(f"❌ Failed to read file {file.filename}: {e}") - return JSONResponse( - status_code=500, - content={"error": f"Failed to read file {file.filename}: {str(e)}"}, - ) - - # Create job - job_tracker = get_job_tracker() - filenames = [filename for filename, _ in file_data] - job_id = await job_tracker.create_job(user.user_id, device_name, filenames) - - # Start background processing with file contents - background_tasks.add_task(process_files_with_content, job_id, file_data, user, device_name) - - audio_logger.info(f"πŸš€ Started async processing job {job_id} with {len(files)} files") - - return { - "job_id": job_id, - "message": f"Started processing {len(files)} files", - "status_url": f"/api/process-audio-files/jobs/{job_id}", - "total_files": len(files), - } - - except Exception as e: - audio_logger.error(f"Error starting async file processing: {e}") - return JSONResponse( - status_code=500, content={"error": f"Failed to start processing: {str(e)}"} - ) - - -async def get_processing_job_status(job_id: str): - """Get status of an async file processing job.""" - try: - job_tracker = get_job_tracker() - job = await job_tracker.get_job(job_id) - - if not job: - return JSONResponse(status_code=404, content={"error": "Job not found"}) - - return job.to_dict() - - except Exception as e: - logger.error(f"Error getting job status for {job_id}: {e}") - return JSONResponse( - status_code=500, content={"error": f"Failed to get job status: {str(e)}"} - ) - - -async def list_processing_jobs(): - """List all active processing jobs.""" - try: - job_tracker = get_job_tracker() - active_jobs = await job_tracker.get_active_jobs() - - return {"active_jobs": len(active_jobs), "jobs": [job.to_dict() for job in active_jobs]} - - except Exception as e: - logger.error(f"Error listing jobs: {e}") - return JSONResponse(status_code=500, content={"error": f"Failed to list jobs: {str(e)}"}) - - -async def process_files_with_content( - job_id: str, file_data: list[tuple[str, bytes]], user: User, device_name: str -): - """Background task to process uploaded files using pre-read content.""" - # Import here to avoid circular imports - from advanced_omi_backend.main import cleanup_client_state, create_client_state - - audio_logger.info( - f"πŸš€ process_files_with_content called for job {job_id} with {len(file_data)} files" - ) - job_tracker = get_job_tracker() - - try: - # Update job status to processing - await job_tracker.update_job_status(job_id, JobStatus.PROCESSING) - - for file_index, (filename, content) in enumerate(file_data): - client_id = None - client_state = None - - try: - audio_logger.info( - f"πŸ”§ [Job {job_id}] Processing file {file_index + 1}/{len(file_data)}: {filename}, content type: {type(content)}, size: {len(content)}" - ) - # Set current file - await job_tracker.set_current_file(job_id, filename) - await job_tracker.update_file_status(job_id, filename, FileStatus.PROCESSING) - - audio_logger.info( - f"πŸš€ [Job {job_id}] Processing file {file_index + 1}/{len(file_data)}: {filename}" - ) - - # Check duration and skip if too long - audio_logger.info( - f"πŸ” [Job {job_id}] About to check duration for {filename}, content size: {len(content)} bytes" - ) - try: - duration = get_audio_duration(content) - audio_logger.info( - f"πŸ” [Job {job_id}] Duration check successful: {duration:.2f}s for {filename}" - ) - except Exception as duration_error: - audio_logger.error( - f"❌ [Job {job_id}] Duration check failed for {filename}: {duration_error}" - ) - raise - # Duration limit removed - process files of any reasonable length - audio_logger.info(f"πŸ“Š File duration: {duration/60:.1f} minutes") - - # Validate file type - if not filename or not filename.lower().endswith(".wav"): - error_msg = "Only WAV files are currently supported" - await job_tracker.update_file_status( - job_id, filename, FileStatus.FAILED, error_message=error_msg - ) - continue - - # Generate unique client ID for each file - file_device_name = f"{device_name}-{file_index + 1:03d}" - client_id = generate_client_id(user, file_device_name) - - # Update job tracker with client ID - await job_tracker.update_file_status( - job_id, filename, FileStatus.PROCESSING, client_id=client_id - ) - - # Create client state - client_state = await create_client_state(client_id, user, file_device_name) - - # Process WAV file - with wave.open(io.BytesIO(content), "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - channels = wav_file.getnchannels() - audio_data = wav_file.readframes(wav_file.getnframes()) - - # Convert to mono if stereo - if channels == 2: - if sample_width == 2: - audio_array = np.frombuffer(audio_data, dtype=np.int16) - else: - audio_array = np.frombuffer(audio_data, dtype=np.int32) - audio_array = audio_array.reshape(-1, 2) - audio_data = ( - np.mean(audio_array, axis=1).astype(audio_array.dtype).tobytes() - ) - channels = 1 - - # Process audio in chunks - processor_manager = get_processor_manager() - chunk_size = 32 * 1024 - base_timestamp = int(time.time()) - - for i in range(0, len(audio_data), chunk_size): - chunk_data = audio_data[i : i + chunk_size] - chunk_offset_bytes = i - chunk_offset_seconds = chunk_offset_bytes / ( - sample_rate * sample_width * channels - ) - chunk_timestamp = base_timestamp + int(chunk_offset_seconds) - - # Process audio chunk through unified pipeline - await process_audio_chunk( - audio_data=chunk_data, - client_id=client_id, - user_id=user.user_id, - user_email=user.email, - audio_format={ - "rate": sample_rate, - "width": sample_width, - "channels": channels, - "timestamp": chunk_timestamp, - }, - client_state=None, # No client state needed for file upload - ) - - if i % (chunk_size * 10) == 0: # Yield control occasionally - await asyncio.sleep(0) - - # Wait briefly for transcription manager to be created - await asyncio.sleep(2.0) - - # Close client audio to trigger transcription completion - await processor_manager.close_client_audio(client_id) - - # Wait for processing to complete with dynamic timeout - max_wait_time = max(120, int(duration * 2) + 60) # 2x duration + 60s buffer - wait_interval = 2.0 - elapsed_time = 0 - - audio_logger.info( - f"⏳ [Job {job_id}] Waiting for transcription (max {max_wait_time}s)" - ) - - # Track whether memory processing has been triggered to avoid duplicate calls - memory_triggered = False - - while elapsed_time < max_wait_time: - try: - # Check database for completion status - chunk = await chunks_col.find_one({"client_id": client_id}) - if chunk: - transcription_status = chunk.get("transcription_status", "PENDING") - memory_status = chunk.get("memory_processing_status", "PENDING") - - # Update job tracker with current status - await job_tracker.update_file_status( - job_id, - filename, - FileStatus.PROCESSING, - audio_uuid=chunk.get("audio_uuid"), - transcription_status=transcription_status, - memory_status=memory_status, - ) - - # Check if transcription failed - immediately fail the job - if transcription_status == "FAILED": - audio_logger.error( - f"❌ [Job {job_id}] Transcription failed, marking file as failed: {filename}" - ) - await job_tracker.update_file_status( - job_id, filename, FileStatus.FAILED, - error_message="Transcription failed" - ) - break # Exit monitoring loop for this file - - # Check if transcription is complete to trigger memory processing - elif transcription_status in ["COMPLETED", "EMPTY"]: - # Trigger memory processing if not already done - if memory_status == "PENDING" and not memory_triggered: - audio_logger.info( - f"πŸš€ [Job {job_id}] Transcription complete, triggering memory processing: {filename}" - ) - await client_state.close_current_conversation() - memory_triggered = True - # Continue to next iteration to check memory status - continue - - # Check if memory processing is also complete - if memory_status in ["COMPLETED", "FAILED", "SKIPPED"]: - audio_logger.info( - f"βœ… [Job {job_id}] File processing completed: {filename}" - ) - await job_tracker.update_file_status( - job_id, filename, FileStatus.COMPLETED - ) - break - - except Exception as e: - audio_logger.debug(f"Error checking processing status: {e}") - - await asyncio.sleep(wait_interval) - elapsed_time += wait_interval - - if elapsed_time >= max_wait_time: - error_msg = f"Processing timed out after {max_wait_time}s" - audio_logger.warning(f"⏰ [Job {job_id}] {error_msg}: {filename}") - await job_tracker.update_file_status( - job_id, filename, FileStatus.FAILED, error_message=error_msg - ) - - # Signal end of conversation - trigger memory processing - await client_state.close_current_conversation() - await asyncio.sleep(0.5) - - except Exception as e: - error_msg = f"Error processing file: {str(e)}" - audio_logger.error(f"❌ [Job {job_id}] {error_msg}") - await job_tracker.update_file_status( - job_id, filename, FileStatus.FAILED, error_message=error_msg - ) - finally: - # Always clean up client state to prevent accumulation - if client_id and client_state: - try: - await cleanup_client_state(client_id) - audio_logger.info( - f"🧹 [Job {job_id}] Cleaned up client state for {client_id}" - ) - except Exception as cleanup_error: - audio_logger.error( - f"❌ [Job {job_id}] Error cleaning up client state for {client_id}: {cleanup_error}" - ) - - # Mark job as completed - await job_tracker.update_job_status(job_id, JobStatus.COMPLETED) - audio_logger.info(f"πŸŽ‰ [Job {job_id}] All files processed") - - except Exception as e: - error_msg = f"Job processing failed: {str(e)}" - audio_logger.error(f"πŸ’₯ [Job {job_id}] {error_msg}") - await job_tracker.update_job_status(job_id, JobStatus.FAILED, error_msg) +# Audio file processing functions moved to audio_controller.py # Configuration functions moved to config.py to avoid circular imports @@ -1159,3 +558,645 @@ async def delete_all_user_memories(user: User): return JSONResponse( status_code=500, content={"error": f"Failed to delete memories: {str(e)}"} ) + + +async def get_streaming_status(request): + """Get status of active streaming sessions and Redis Streams health.""" + import time + from advanced_omi_backend.controllers.queue_controller import ( + transcription_queue, + memory_queue, + default_queue, + all_jobs_complete_for_session + ) + + try: + # Get Redis client from request.app.state (initialized during startup) + redis_client = request.app.state.redis_audio_stream + + if not redis_client: + return JSONResponse( + status_code=503, + content={"error": "Redis client for audio streaming not initialized"} + ) + + # Get all sessions (both active and completed) + session_keys = await redis_client.keys("audio:session:*") + active_sessions = [] + completed_sessions_from_redis = [] + + for key in session_keys: + session_data = await redis_client.hgetall(key) + if not session_data: + continue + + session_id = key.decode().split(":")[-1] + started_at = float(session_data.get(b"started_at", b"0")) + last_chunk_at = float(session_data.get(b"last_chunk_at", b"0")) + status = session_data.get(b"status", b"").decode() + + session_obj = { + "session_id": session_id, + "user_id": session_data.get(b"user_id", b"").decode(), + "client_id": session_data.get(b"client_id", b"").decode(), + "provider": session_data.get(b"provider", b"").decode(), + "mode": session_data.get(b"mode", b"").decode(), + "status": status, + "chunks_published": int(session_data.get(b"chunks_published", b"0")), + "started_at": started_at, + "last_chunk_at": last_chunk_at, + "age_seconds": time.time() - started_at, + "idle_seconds": time.time() - last_chunk_at + } + + # Separate active and completed sessions + # Check if all jobs are complete (including failed jobs) + all_jobs_done = all_jobs_complete_for_session(session_id) + + # Session is completed if: + # 1. Redis status says complete/finalized AND all jobs done, OR + # 2. All jobs are done (even if status isn't complete yet) + # This ensures sessions with failed jobs move to completed + if status in ["complete", "completed", "finalized"] or all_jobs_done: + if all_jobs_done: + # All jobs complete - this is truly a completed session + # Update Redis status if it wasn't already marked complete + if status not in ["complete", "completed", "finalized"]: + await redis_client.hset(key, "status", "complete") + logger.info(f"βœ… Marked session {session_id} as complete (all jobs terminal)") + + completed_sessions_from_redis.append({ + "session_id": session_id, + "client_id": session_data.get(b"client_id", b"").decode(), + "conversation_id": session_data.get(b"conversation_id", b"").decode() if b"conversation_id" in session_data else None, + "has_conversation": bool(session_data.get(b"conversation_id", b"")), + "action": session_data.get(b"action", b"complete").decode(), + "reason": session_data.get(b"reason", b"").decode() if b"reason" in session_data else "", + "completed_at": last_chunk_at, + "audio_file": session_data.get(b"audio_file", b"").decode() if b"audio_file" in session_data else "" + }) + else: + # Status says complete but jobs still processing - keep in active + active_sessions.append(session_obj) + else: + # This is an active session + active_sessions.append(session_obj) + + # Get stream health for all streams (per-client streams) + # Categorize as active or completed based on consumer activity + active_streams = {} + completed_streams = {} + + # Discover all audio streams + stream_keys = await redis_client.keys("audio:stream:*") + current_time = time.time() + + for stream_key in stream_keys: + stream_name = stream_key.decode() if isinstance(stream_key, bytes) else stream_key + try: + # Check if stream exists + stream_info = await redis_client.execute_command('XINFO', 'STREAM', stream_name) + + # Parse stream info (returns flat list of key-value pairs) + info_dict = {} + for i in range(0, len(stream_info), 2): + key = stream_info[i].decode() if isinstance(stream_info[i], bytes) else str(stream_info[i]) + value = stream_info[i+1] + + # Skip complex binary structures like first-entry and last-entry + # which contain message data that can't be JSON serialized + if key in ["first-entry", "last-entry"]: + # Just extract the message ID (first element) + if isinstance(value, list) and len(value) > 0: + msg_id = value[0] + if isinstance(msg_id, bytes): + msg_id = msg_id.decode() + value = msg_id + else: + value = None + elif isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + # Binary data that can't be decoded, skip it + value = "" + + info_dict[key] = value + + # Calculate stream age from last entry + stream_age_seconds = 0 + last_entry_id = info_dict.get("last-entry") + if last_entry_id: + try: + # Redis Stream IDs format: "milliseconds-sequence" + last_timestamp_ms = int(last_entry_id.split('-')[0]) + last_timestamp_s = last_timestamp_ms / 1000 + stream_age_seconds = current_time - last_timestamp_s + except (ValueError, IndexError, AttributeError): + stream_age_seconds = 0 + + # Get consumer groups + groups = await redis_client.execute_command('XINFO', 'GROUPS', stream_name) + + stream_data = { + "stream_length": info_dict.get("length", 0), + "first_entry_id": info_dict.get("first-entry"), + "last_entry_id": last_entry_id, + "stream_age_seconds": stream_age_seconds, + "consumer_groups": [], + "total_pending": 0 + } + + # Track if stream has any active consumers + has_active_consumer = False + min_consumer_idle_ms = float('inf') + + # Parse consumer groups + for group in groups: + group_dict = {} + for i in range(0, len(group), 2): + key = group[i].decode() if isinstance(group[i], bytes) else str(group[i]) + value = group[i+1] + if isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + value = "" + group_dict[key] = value + + group_name = group_dict.get("name", "unknown") + if isinstance(group_name, bytes): + group_name = group_name.decode() + + # Get consumers for this group + consumers = await redis_client.execute_command('XINFO', 'CONSUMERS', stream_name, group_name) + consumer_list = [] + consumer_pending_total = 0 + + for consumer in consumers: + consumer_dict = {} + for i in range(0, len(consumer), 2): + key = consumer[i].decode() if isinstance(consumer[i], bytes) else str(consumer[i]) + value = consumer[i+1] + if isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + value = "" + consumer_dict[key] = value + + consumer_name = consumer_dict.get("name", "unknown") + if isinstance(consumer_name, bytes): + consumer_name = consumer_name.decode() + + consumer_pending = int(consumer_dict.get("pending", 0)) + consumer_idle_ms = int(consumer_dict.get("idle", 0)) + consumer_pending_total += consumer_pending + + # Track minimum idle time + min_consumer_idle_ms = min(min_consumer_idle_ms, consumer_idle_ms) + + # Consumer is active if idle < 5 minutes (300000ms) + if consumer_idle_ms < 300000: + has_active_consumer = True + + consumer_list.append({ + "name": consumer_name, + "pending": consumer_pending, + "idle_ms": consumer_idle_ms + }) + + # Get group-level pending count (may be 0 even if consumers have pending) + try: + pending = await redis_client.xpending(stream_name, group_name) + group_pending_count = int(pending[0]) if pending else 0 + except Exception: + group_pending_count = 0 + + # Use the maximum of group-level pending or sum of consumer pending + # (Sometimes group pending is 0 but consumers still have pending messages) + effective_pending = max(group_pending_count, consumer_pending_total) + + stream_data["consumer_groups"].append({ + "name": str(group_name), + "consumers": consumer_list, + "pending": int(effective_pending) + }) + + stream_data["total_pending"] += int(effective_pending) + + # Determine if stream is active or completed + # Active: has active consumers OR pending messages OR recent activity (< 5 min) + # Completed: no active consumers and idle > 5 minutes but < 1 hour + is_active = ( + has_active_consumer or + stream_data["total_pending"] > 0 or + stream_age_seconds < 300 # Less than 5 minutes old + ) + + if is_active: + active_streams[stream_name] = stream_data + else: + # Mark as completed (will be cleaned up when > 1 hour old) + stream_data["idle_seconds"] = stream_age_seconds + completed_streams[stream_name] = stream_data + + except Exception as e: + # Stream doesn't exist or error getting info + logger.debug(f"Error processing stream {stream_name}: {e}") + continue + + # Get RQ queue stats - include all registries + rq_stats = { + "transcription_queue": { + "queued": transcription_queue.count, + "processing": len(transcription_queue.started_job_registry), + "completed": len(transcription_queue.finished_job_registry), + "failed": len(transcription_queue.failed_job_registry), + "cancelled": len(transcription_queue.canceled_job_registry), + "deferred": len(transcription_queue.deferred_job_registry) + }, + "memory_queue": { + "queued": memory_queue.count, + "processing": len(memory_queue.started_job_registry), + "completed": len(memory_queue.finished_job_registry), + "failed": len(memory_queue.failed_job_registry), + "cancelled": len(memory_queue.canceled_job_registry), + "deferred": len(memory_queue.deferred_job_registry) + }, + "default_queue": { + "queued": default_queue.count, + "processing": len(default_queue.started_job_registry), + "completed": len(default_queue.finished_job_registry), + "failed": len(default_queue.failed_job_registry), + "cancelled": len(default_queue.canceled_job_registry), + "deferred": len(default_queue.deferred_job_registry) + } + } + + return { + "active_sessions": active_sessions, + "completed_sessions": completed_sessions_from_redis, + "active_streams": active_streams, + "completed_streams": completed_streams, + "stream_health": active_streams, # Backward compatibility - use active_streams + "rq_queues": rq_stats, + "timestamp": time.time() + } + + except Exception as e: + logger.error(f"Error getting streaming status: {e}", exc_info=True) + return JSONResponse( + status_code=500, + content={"error": f"Failed to get streaming status: {str(e)}"} + ) + + +async def cleanup_stuck_stream_workers(request): + """Clean up stuck Redis Stream consumers and pending messages from all active streams.""" + import time + + try: + # Get Redis client from request.app.state (initialized during startup) + redis_client = request.app.state.redis_audio_stream + + if not redis_client: + return JSONResponse( + status_code=503, + content={"error": "Redis client for audio streaming not initialized"} + ) + + cleanup_results = {} + total_cleaned = 0 + total_deleted_consumers = 0 + total_deleted_streams = 0 + current_time = time.time() + + # Discover all audio streams (per-client streams) + stream_keys = await redis_client.keys("audio:stream:*") + + for stream_key in stream_keys: + stream_name = stream_key.decode() if isinstance(stream_key, bytes) else stream_key + + try: + # First check stream age - delete old streams (>1 hour) immediately + stream_info = await redis_client.execute_command('XINFO', 'STREAM', stream_name) + + # Parse stream info + info_dict = {} + for i in range(0, len(stream_info), 2): + key_name = stream_info[i].decode() if isinstance(stream_info[i], bytes) else str(stream_info[i]) + info_dict[key_name] = stream_info[i+1] + + stream_length = int(info_dict.get("length", 0)) + last_entry = info_dict.get("last-entry") + + # Check if stream is old + should_delete_stream = False + stream_age = 0 + + if stream_length == 0: + should_delete_stream = True + stream_age = 0 + elif last_entry and isinstance(last_entry, list) and len(last_entry) > 0: + try: + last_id = last_entry[0] + if isinstance(last_id, bytes): + last_id = last_id.decode() + last_timestamp_ms = int(last_id.split('-')[0]) + last_timestamp_s = last_timestamp_ms / 1000 + stream_age = current_time - last_timestamp_s + + # Delete streams older than 1 hour (3600 seconds) + if stream_age > 3600: + should_delete_stream = True + except (ValueError, IndexError): + pass + + if should_delete_stream: + await redis_client.delete(stream_name) + total_deleted_streams += 1 + cleanup_results[stream_name] = { + "message": f"Deleted old stream (age: {stream_age:.0f}s, length: {stream_length})", + "cleaned": 0, + "deleted_consumers": 0, + "deleted_stream": True, + "stream_age": stream_age + } + continue + + # Get consumer groups + groups = await redis_client.execute_command('XINFO', 'GROUPS', stream_name) + + if not groups: + cleanup_results[stream_name] = {"message": "No consumer groups found", "cleaned": 0, "deleted_stream": False} + continue + + # Parse first group + group_dict = {} + group = groups[0] + for i in range(0, len(group), 2): + key = group[i].decode() if isinstance(group[i], bytes) else str(group[i]) + value = group[i+1] + if isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + value = str(value) + group_dict[key] = value + + group_name = group_dict.get("name", "unknown") + if isinstance(group_name, bytes): + group_name = group_name.decode() + + pending_count = int(group_dict.get("pending", 0)) + + # Get consumers for this group to check per-consumer pending + consumers = await redis_client.execute_command('XINFO', 'CONSUMERS', stream_name, group_name) + + cleaned_count = 0 + total_consumer_pending = 0 + + # Clean up pending messages for each consumer AND delete dead consumers + deleted_consumers = 0 + for consumer in consumers: + consumer_dict = {} + for i in range(0, len(consumer), 2): + key = consumer[i].decode() if isinstance(consumer[i], bytes) else str(consumer[i]) + value = consumer[i+1] + if isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + value = str(value) + consumer_dict[key] = value + + consumer_name = consumer_dict.get("name", "unknown") + if isinstance(consumer_name, bytes): + consumer_name = consumer_name.decode() + + consumer_pending = int(consumer_dict.get("pending", 0)) + consumer_idle_ms = int(consumer_dict.get("idle", 0)) + total_consumer_pending += consumer_pending + + # Check if consumer is dead (idle > 5 minutes = 300000ms) + is_dead = consumer_idle_ms > 300000 + + if consumer_pending > 0: + logger.info(f"Found {consumer_pending} pending messages for consumer {consumer_name} (idle: {consumer_idle_ms}ms)") + + # Get pending messages for this specific consumer + try: + pending_messages = await redis_client.execute_command( + 'XPENDING', stream_name, group_name, '-', '+', str(consumer_pending), consumer_name + ) + + # XPENDING returns flat list: [msg_id, consumer, idle_ms, delivery_count, msg_id, ...] + # Parse in groups of 4 + for i in range(0, len(pending_messages), 4): + if i < len(pending_messages): + msg_id = pending_messages[i] + if isinstance(msg_id, bytes): + msg_id = msg_id.decode() + + # Claim the message to a cleanup worker + try: + await redis_client.execute_command( + 'XCLAIM', stream_name, group_name, 'cleanup-worker', '0', msg_id + ) + + # Acknowledge it immediately + await redis_client.xack(stream_name, group_name, msg_id) + cleaned_count += 1 + except Exception as claim_error: + logger.warning(f"Failed to claim/ack message {msg_id}: {claim_error}") + + except Exception as consumer_error: + logger.error(f"Error processing consumer {consumer_name}: {consumer_error}") + + # Delete dead consumers (idle > 5 minutes with no pending messages) + if is_dead and consumer_pending == 0: + try: + await redis_client.execute_command( + 'XGROUP', 'DELCONSUMER', stream_name, group_name, consumer_name + ) + deleted_consumers += 1 + logger.info(f"🧹 Deleted dead consumer {consumer_name} (idle: {consumer_idle_ms}ms)") + except Exception as delete_error: + logger.warning(f"Failed to delete consumer {consumer_name}: {delete_error}") + + if total_consumer_pending == 0 and deleted_consumers == 0: + cleanup_results[stream_name] = {"message": "No pending messages or dead consumers", "cleaned": 0, "deleted_consumers": 0, "deleted_stream": False} + continue + + total_cleaned += cleaned_count + total_deleted_consumers += deleted_consumers + cleanup_results[stream_name] = { + "message": f"Cleaned {cleaned_count} pending messages, deleted {deleted_consumers} dead consumers", + "cleaned": cleaned_count, + "deleted_consumers": deleted_consumers, + "deleted_stream": False, + "original_pending": pending_count + } + + except Exception as e: + cleanup_results[stream_name] = { + "error": str(e), + "cleaned": 0 + } + + return { + "success": True, + "total_cleaned": total_cleaned, + "total_deleted_consumers": total_deleted_consumers, + "total_deleted_streams": total_deleted_streams, + "streams": cleanup_results, # New key for per-stream results + "providers": cleanup_results, # Keep for backward compatibility with frontend + "timestamp": time.time() + } + + except Exception as e: + logger.error(f"Error cleaning up stuck workers: {e}", exc_info=True) + return JSONResponse( + status_code=500, content={"error": f"Failed to cleanup stuck workers: {str(e)}"} + ) + + +async def cleanup_old_sessions(request, max_age_seconds: int = 3600): + """Clean up old session tracking metadata and old audio streams from Redis.""" + import time + + try: + # Get Redis client from request.app.state (initialized during startup) + redis_client = request.app.state.redis_audio_stream + + if not redis_client: + return JSONResponse( + status_code=503, + content={"error": "Redis client for audio streaming not initialized"} + ) + + # Get all session keys + session_keys = await redis_client.keys("audio:session:*") + cleaned_sessions = 0 + old_sessions = [] + + current_time = time.time() + + for key in session_keys: + session_data = await redis_client.hgetall(key) + if not session_data: + continue + + session_id = key.decode().split(":")[-1] + started_at = float(session_data.get(b"started_at", b"0")) + status = session_data.get(b"status", b"").decode() + + age_seconds = current_time - started_at + + # Clean up sessions older than max_age or stuck in "finalizing" + should_clean = ( + age_seconds > max_age_seconds or + (status == "finalizing" and age_seconds > 300) # Finalizing for more than 5 minutes + ) + + if should_clean: + old_sessions.append({ + "session_id": session_id, + "age_seconds": age_seconds, + "status": status + }) + await redis_client.delete(key) + cleaned_sessions += 1 + + # Also clean up old audio streams (per-client streams that are inactive) + stream_keys = await redis_client.keys("audio:stream:*") + cleaned_streams = 0 + old_streams = [] + + for stream_key in stream_keys: + stream_name = stream_key.decode() if isinstance(stream_key, bytes) else stream_key + + try: + # Check stream info to get last activity + stream_info = await redis_client.execute_command('XINFO', 'STREAM', stream_name) + + # Parse stream info + info_dict = {} + for i in range(0, len(stream_info), 2): + key_name = stream_info[i].decode() if isinstance(stream_info[i], bytes) else str(stream_info[i]) + info_dict[key_name] = stream_info[i+1] + + stream_length = int(info_dict.get("length", 0)) + last_entry = info_dict.get("last-entry") + + # Check stream age via last entry ID (Redis Stream IDs are timestamps) + should_delete = False + age_seconds = 0 + + if stream_length == 0: + # Empty stream - safe to delete + should_delete = True + reason = "empty" + elif last_entry and isinstance(last_entry, list) and len(last_entry) > 0: + # Extract timestamp from last entry ID + last_id = last_entry[0] + if isinstance(last_id, bytes): + last_id = last_id.decode() + + # Redis Stream IDs format: "milliseconds-sequence" + try: + last_timestamp_ms = int(last_id.split('-')[0]) + last_timestamp_s = last_timestamp_ms / 1000 + age_seconds = current_time - last_timestamp_s + + # Delete streams older than max_age regardless of size + if age_seconds > max_age_seconds: + should_delete = True + reason = "old" + except (ValueError, IndexError): + # If we can't parse timestamp, check if first entry is old + first_entry = info_dict.get("first-entry") + if first_entry and isinstance(first_entry, list) and len(first_entry) > 0: + try: + first_id = first_entry[0] + if isinstance(first_id, bytes): + first_id = first_id.decode() + first_timestamp_ms = int(first_id.split('-')[0]) + first_timestamp_s = first_timestamp_ms / 1000 + age_seconds = current_time - first_timestamp_s + + if age_seconds > max_age_seconds: + should_delete = True + reason = "old_unparseable" + except (ValueError, IndexError): + pass + + if should_delete: + await redis_client.delete(stream_name) + cleaned_streams += 1 + old_streams.append({ + "stream_name": stream_name, + "reason": reason, + "age_seconds": age_seconds, + "length": stream_length + }) + + except Exception as e: + logger.debug(f"Error checking stream {stream_name}: {e}") + continue + + return { + "success": True, + "cleaned_sessions": cleaned_sessions, + "cleaned_streams": cleaned_streams, + "cleaned_session_details": old_sessions, + "cleaned_stream_details": old_streams, + "timestamp": time.time() + } + + except Exception as e: + logger.error(f"Error cleaning up old sessions: {e}", exc_info=True) + return JSONResponse( + status_code=500, content={"error": f"Failed to cleanup old sessions: {str(e)}"} + ) diff --git a/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py index dd00f8a9..dfb4b752 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py @@ -17,7 +17,7 @@ from advanced_omi_backend.client_manager import get_user_clients_all from advanced_omi_backend.database import chunks_col, db, users_col from advanced_omi_backend.memory import get_memory_service -from advanced_omi_backend.users import User, UserCreate +from advanced_omi_backend.users import User, UserCreate, UserUpdate logger = logging.getLogger(__name__) @@ -78,8 +78,9 @@ async def create_user(user_data: UserCreate): ) -async def update_user(user_id: str, user_data: UserCreate): +async def update_user(user_id: str, user_data: UserUpdate): """Update an existing user.""" + print("DEBUG: New update_user function is being called!") try: # Validate ObjectId format try: @@ -106,23 +107,9 @@ async def update_user(user_id: str, user_data: UserCreate): # Convert to User object for the manager user_obj = User(**existing_user) - - # Prepare update data - only include non-None fields - update_data = {} - if user_data.email: - update_data["email"] = user_data.email - if user_data.display_name is not None: - update_data["display_name"] = user_data.display_name - if hasattr(user_data, 'is_superuser'): - update_data["is_superuser"] = user_data.is_superuser - if hasattr(user_data, 'is_active'): - update_data["is_active"] = user_data.is_active - if user_data.password: - # Hash the password if provided - update_data["hashed_password"] = user_manager.password_helper.hash(user_data.password) - - # Update the user - updated_user = await user_manager.update(user_obj, update_data) + + # Update the user using the fastapi-users manager (now with fix for missing method) + updated_user = await user_manager.update(user_obj, user_data) return JSONResponse( status_code=200, diff --git a/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py new file mode 100644 index 00000000..919daa1b --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py @@ -0,0 +1,1045 @@ + +""" +WebSocket controller for Friend-Lite backend. + +This module handles WebSocket connections for audio streaming. +""" + +import asyncio +import concurrent.futures +import json +import logging +import os +import time +import uuid +from functools import partial +from typing import Optional + +from fastapi import WebSocket, WebSocketDisconnect, Query +from friend_lite.decoder import OmiOpusDecoder + +from advanced_omi_backend.auth import websocket_auth +from advanced_omi_backend.client_manager import generate_client_id, get_client_manager +from advanced_omi_backend.constants import OMI_CHANNELS, OMI_SAMPLE_RATE, OMI_SAMPLE_WIDTH +from advanced_omi_backend.audio_utils import process_audio_chunk +from advanced_omi_backend.services.audio_stream import AudioStreamProducer +from advanced_omi_backend.services.audio_stream.producer import get_audio_stream_producer + +# Thread pool executors for audio decoding +_DEC_IO_EXECUTOR = concurrent.futures.ThreadPoolExecutor( + max_workers=os.cpu_count() or 4, + thread_name_prefix="opus_io", +) + +# Logging setup +logger = logging.getLogger(__name__) +application_logger = logging.getLogger("audio_processing") + +# Track pending WebSocket connections to prevent race conditions +pending_connections: set[str] = set() + + +async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: + """Parse Wyoming protocol: JSON header line followed by optional binary payload. + + Returns: + Tuple of (header_dict, payload_bytes or None) + """ + # Read data from WebSocket + logger.debug(f"parse_wyoming_protocol: About to call ws.receive()") + message = await ws.receive() + logger.debug(f"parse_wyoming_protocol: Received message with keys: {message.keys() if message else 'None'}") + + # Handle WebSocket close frame + if "type" in message and message["type"] == "websocket.disconnect": + # This is a normal WebSocket close event + code = message.get("code", 1000) + reason = message.get("reason", "") + logger.info(f"πŸ“΄ WebSocket disconnect received in parse_wyoming_protocol. Code: {code}, Reason: {reason}") + raise WebSocketDisconnect(code=code, reason=reason) + + # Handle text message (JSON header) + if "text" in message: + header_text = message["text"] + # Wyoming protocol uses newline-terminated JSON + if not header_text.endswith("\n"): + header_text += "\n" + + # Parse JSON header + json_line = header_text.strip() + header = json.loads(json_line) + + # If payload is expected, read binary data + payload = None + payload_length = header.get("payload_length") + if payload_length is not None and payload_length > 0: + payload_msg = await ws.receive() + if "bytes" in payload_msg: + payload = payload_msg["bytes"] + else: + logger.warning(f"Expected binary payload but got: {payload_msg.keys()}") + + return header, payload + + # Handle binary message (invalid - Wyoming protocol requires JSONL headers) + elif "bytes" in message: + raise ValueError( + "Raw binary messages not supported - Wyoming protocol requires JSONL headers" + ) + + else: + raise ValueError(f"Unexpected WebSocket message type: {message.keys()}") + + +async def create_client_state(client_id: str, user, device_name: Optional[str] = None): + """Create and register a new client state.""" + # Get client manager and repository + client_manager = get_client_manager() + from advanced_omi_backend.database import AudioChunksRepository + from motor.motor_asyncio import AsyncIOMotorClient + + # MongoDB Configuration + MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://mongo:27017") + mongo_client = AsyncIOMotorClient(MONGODB_URI) + db = mongo_client.get_default_database("friend-lite") + chunks_col = db["audio_chunks"] + + # Initialize repository + ac_repository = AudioChunksRepository(chunks_col) + + # Directory where WAV chunks are written + from pathlib import Path + CHUNK_DIR = Path("./audio_chunks") # This will be mounted to ./data/audio_chunks by Docker + + # Use ClientManager for atomic client creation and registration + client_state = client_manager.create_client( + client_id, ac_repository, CHUNK_DIR, user.user_id, user.email + ) + + # Also track in persistent mapping (for database queries) + from advanced_omi_backend.client_manager import track_client_user_relationship + track_client_user_relationship(client_id, user.user_id) + + # Register client in user model (persistent) + from advanced_omi_backend.users import register_client_to_user + await register_client_to_user(user, client_id, device_name) + + return client_state + + +async def cleanup_client_state(client_id: str): + """Clean up and remove client state.""" + # Use ClientManager for atomic client removal with cleanup + client_manager = get_client_manager() + removed = await client_manager.remove_client_with_cleanup(client_id) + + if removed: + logger.info(f"Client {client_id} cleaned up successfully") + else: + logger.warning(f"Client {client_id} was not found for cleanup") + + +# Shared helper functions for WebSocket handlers +async def _setup_websocket_connection( + ws: WebSocket, + token: Optional[str], + device_name: Optional[str], + pending_client_id: str, + connection_type: str +) -> tuple[Optional[str], Optional[object], Optional[object]]: + """ + Setup WebSocket connection: accept, authenticate, create client state. + + Args: + ws: WebSocket connection + token: JWT authentication token + device_name: Optional device name for client ID + pending_client_id: Temporary tracking ID + connection_type: "OMI" or "PCM" for logging + + Returns: + tuple: (client_id, client_state, user) or (None, None, None) on failure + """ + # Accept WebSocket first (required before any send/close operations) + await ws.accept() + + # Authenticate user after accepting connection + user = await websocket_auth(ws, token) + if not user: + # Send error message to client before closing + try: + error_msg = json.dumps({ + "type": "error", + "error": "authentication_failed", + "message": "Authentication failed. Please log in again and ensure your token is valid.", + "code": 1008 + }) + "\n" + await ws.send_text(error_msg) + application_logger.info("Sent authentication error message to client") + except Exception as send_error: + application_logger.warning(f"Failed to send error message: {send_error}") + + # Close connection with appropriate code + await ws.close(code=1008, reason="Authentication failed") + return None, None, None + + # Generate proper client_id using user and device_name + client_id = generate_client_id(user, device_name) + + # Remove from pending now that we have real client_id + pending_connections.discard(pending_client_id) + application_logger.info( + f"πŸ”Œ {connection_type} WebSocket connection accepted - User: {user.user_id} ({user.email}), Client: {client_id}" + ) + + # Send ready message for PCM clients + if connection_type == "PCM": + try: + ready_msg = json.dumps({"type": "ready", "message": "WebSocket connection established"}) + "\n" + await ws.send_text(ready_msg) + application_logger.debug(f"βœ… Sent ready message to {client_id}") + except Exception as e: + application_logger.error(f"Failed to send ready message to {client_id}: {e}") + + # Create client state + client_state = await create_client_state(client_id, user, device_name) + + return client_id, client_state, user + + +async def _initialize_streaming_session( + client_state, + audio_stream_producer, + user_id: str, + user_email: str, + client_id: str, + audio_format: dict +) -> None: + """ + Initialize streaming session with Redis and enqueue processing jobs. + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + user_id: User ID + user_email: User email + client_id: Client ID + audio_format: Audio format dict from audio-start event + """ + if hasattr(client_state, 'stream_session_id'): + application_logger.debug(f"Session already initialized for {client_id}") + return + + # Initialize stream session + client_state.stream_session_id = str(uuid.uuid4()) + client_state.stream_chunk_count = 0 + client_state.stream_audio_format = audio_format + application_logger.info(f"πŸ†” Created stream session: {client_state.stream_session_id}") + + # Initialize session tracking in Redis + await audio_stream_producer.init_session( + session_id=client_state.stream_session_id, + user_id=user_id, + client_id=client_id, + mode="streaming", + provider="deepgram" + ) + + # Enqueue streaming jobs (speech detection + audio persistence) + from advanced_omi_backend.controllers.queue_controller import start_streaming_jobs + + job_ids = start_streaming_jobs( + session_id=client_state.stream_session_id, + user_id=user_id, + user_email=user_email, + client_id=client_id + ) + + client_state.speech_detection_job_id = job_ids['speech_detection'] + client_state.audio_persistence_job_id = job_ids['audio_persistence'] + + +async def _finalize_streaming_session( + client_state, + audio_stream_producer, + user_id: str, + user_email: str, + client_id: str +) -> None: + """ + Finalize streaming session: flush buffer, signal workers, enqueue finalize job, cleanup. + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + user_id: User ID + user_email: User email + client_id: Client ID + """ + if not hasattr(client_state, 'stream_session_id'): + application_logger.debug(f"No active session to finalize for {client_id}") + return + + session_id = client_state.stream_session_id + + try: + # Flush any remaining buffered audio + audio_format = getattr(client_state, 'stream_audio_format', {}) + await audio_stream_producer.flush_session_buffer( + session_id=session_id, + sample_rate=audio_format.get("rate", 16000), + channels=audio_format.get("channels", 1), + sample_width=audio_format.get("width", 2) + ) + + # Send end-of-session signal to workers + await audio_stream_producer.send_session_end_signal(session_id) + + # Mark session as finalizing + await audio_stream_producer.finalize_session(session_id) + + # NOTE: Finalize job disabled - open_conversation_job now handles everything + # The open_conversation_job will: + # 1. Detect the "finalizing" status + # 2. Enter 5-second grace period + # 3. Get audio file path + # 4. Mark session complete + # 5. Clean up Redis streams + # 6. Enqueue batch transcription and memory processing + # + # If no speech was detected (open_conversation_job never started): + # - Audio is discarded (intentional - we only create conversations with speech) + # - Redis streams are cleaned up by TTL + # + # TODO: Consider adding cleanup for no-speech scenarios if needed + + application_logger.info( + f"βœ… Session {session_id[:12]} marked as finalizing - open_conversation_job will handle cleanup" + ) + + # Clear session state + for attr in ['stream_session_id', 'stream_chunk_count', 'stream_audio_format', + 'speech_detection_job_id', 'audio_persistence_job_id']: + if hasattr(client_state, attr): + delattr(client_state, attr) + + except Exception as finalize_error: + application_logger.error( + f"❌ Failed to finalize streaming session: {finalize_error}", + exc_info=True + ) + + +async def _publish_audio_to_stream( + client_state, + audio_stream_producer, + audio_data: bytes, + user_id: str, + client_id: str, + sample_rate: int, + channels: int, + sample_width: int +) -> None: + """ + Publish audio chunk to Redis Stream with chunk tracking. + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + audio_data: Raw PCM audio bytes + user_id: User ID + client_id: Client ID + sample_rate: Sample rate (Hz) + channels: Number of channels + sample_width: Bytes per sample + """ + if not hasattr(client_state, 'stream_session_id'): + application_logger.warning(f"⚠️ Received audio chunk before session initialized for {client_id}") + return + + # Increment chunk count and format chunk ID + client_state.stream_chunk_count += 1 + chunk_id = f"{client_state.stream_chunk_count:05d}" + + # Publish to Redis Stream using producer + await audio_stream_producer.add_audio_chunk( + audio_data=audio_data, + session_id=client_state.stream_session_id, + chunk_id=chunk_id, + user_id=user_id, + client_id=client_id, + sample_rate=sample_rate, + channels=channels, + sample_width=sample_width + ) + + +async def _handle_omi_audio_chunk( + client_state, + audio_stream_producer, + opus_payload: bytes, + decode_packet_fn, + user_id: str, + client_id: str, + packet_count: int +) -> None: + """ + Handle OMI audio chunk: decode Opus to PCM, then publish to stream. + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + opus_payload: Opus-encoded audio bytes + decode_packet_fn: Opus decoder function + user_id: User ID + client_id: Client ID + packet_count: Current packet number for logging + """ + # Decode Opus to PCM + start_time = time.time() + loop = asyncio.get_running_loop() + pcm_data = await loop.run_in_executor(_DEC_IO_EXECUTOR, decode_packet_fn, opus_payload) + decode_time = time.time() - start_time + + if pcm_data: + if packet_count <= 5 or packet_count % 1000 == 0: + application_logger.debug( + f"🎡 Decoded OMI packet #{packet_count}: {len(opus_payload)} bytes -> " + f"{len(pcm_data)} PCM bytes (took {decode_time:.3f}s)" + ) + + # Publish decoded PCM to Redis Stream + await _publish_audio_to_stream( + client_state, + audio_stream_producer, + pcm_data, + user_id, + client_id, + OMI_SAMPLE_RATE, + OMI_CHANNELS, + OMI_SAMPLE_WIDTH + ) + else: + # Log decode failures for first 5 packets + if packet_count <= 5: + application_logger.warning( + f"❌ Failed to decode OMI packet #{packet_count}: {len(opus_payload)} bytes" + ) + + +async def _handle_streaming_mode_audio( + client_state, + audio_stream_producer, + audio_data: bytes, + audio_format: dict, + user_id: str, + user_email: str, + client_id: str +) -> None: + """ + Handle audio chunk in streaming mode. + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + audio_data: Raw PCM audio bytes + audio_format: Audio format dict (rate, width, channels) + user_id: User ID + user_email: User email + client_id: Client ID + """ + # Initialize session if needed + if not hasattr(client_state, 'stream_session_id'): + await _initialize_streaming_session( + client_state, + audio_stream_producer, + user_id, + user_email, + client_id, + audio_format + ) + + # Publish to Redis Stream + await _publish_audio_to_stream( + client_state, + audio_stream_producer, + audio_data, + user_id, + client_id, + audio_format.get("rate", 16000), + audio_format.get("channels", 1), + audio_format.get("width", 2) + ) + + +async def _handle_batch_mode_audio( + client_state, + audio_data: bytes, + audio_format: dict, + client_id: str +) -> None: + """ + Handle audio chunk in batch mode - accumulate in memory. + + Args: + client_state: Client state object + audio_data: Raw PCM audio bytes + audio_format: Audio format dict + client_id: Client ID + """ + # Initialize batch accumulator if needed + if not hasattr(client_state, 'batch_audio_chunks'): + client_state.batch_audio_chunks = [] + client_state.batch_audio_format = audio_format + application_logger.info(f"πŸ“¦ Started batch audio accumulation for {client_id}") + + # Accumulate audio + client_state.batch_audio_chunks.append(audio_data) + application_logger.debug( + f"πŸ“¦ Accumulated chunk #{len(client_state.batch_audio_chunks)} ({len(audio_data)} bytes) for {client_id}" + ) + + +async def _handle_audio_chunk( + client_state, + audio_stream_producer, + audio_data: bytes, + audio_format: dict, + user_id: str, + user_email: str, + client_id: str +) -> None: + """ + Route audio chunk to appropriate mode handler (streaming or batch). + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + audio_data: Raw PCM audio bytes + audio_format: Audio format dict + user_id: User ID + user_email: User email + client_id: Client ID + """ + recording_mode = getattr(client_state, 'recording_mode', 'batch') + + if recording_mode == "streaming": + await _handle_streaming_mode_audio( + client_state, audio_stream_producer, audio_data, + audio_format, user_id, user_email, client_id + ) + else: + await _handle_batch_mode_audio( + client_state, audio_data, audio_format, client_id + ) + + +async def _handle_audio_session_start( + client_state, + audio_format: dict, + client_id: str +) -> tuple[bool, str]: + """ + Handle audio-start event - set mode and switch to audio streaming. + + Args: + client_state: Client state object + audio_format: Audio format dict with mode + client_id: Client ID + + Returns: + (audio_streaming_flag, recording_mode) + """ + recording_mode = audio_format.get("mode", "batch") + client_state.recording_mode = recording_mode + + application_logger.info( + f"πŸŽ™οΈ Audio session started for {client_id} - " + f"Format: {audio_format.get('rate')}Hz, " + f"{audio_format.get('width')}bytes, " + f"{audio_format.get('channels')}ch, " + f"Mode: {recording_mode}" + ) + + return True, recording_mode # Switch to audio streaming mode + + +async def _handle_audio_session_stop( + client_state, + audio_stream_producer, + user_id: str, + user_email: str, + client_id: str +) -> bool: + """ + Handle audio-stop event - finalize session based on mode. + + Args: + client_state: Client state object + audio_stream_producer: Audio stream producer instance + user_id: User ID + user_email: User email + client_id: Client ID + + Returns: + False to switch back to control mode + """ + recording_mode = getattr(client_state, 'recording_mode', 'batch') + application_logger.info(f"πŸ›‘ Audio session stopped for {client_id} (mode: {recording_mode})") + + if recording_mode == "streaming": + await _finalize_streaming_session( + client_state, audio_stream_producer, + user_id, user_email, client_id + ) + else: + await _process_batch_audio_complete( + client_state, user_id, user_email, client_id + ) + + return False # Switch back to control mode + + +async def _process_batch_audio_complete( + client_state, + user_id: str, + user_email: str, + client_id: str +) -> None: + """ + Process completed batch audio: write file, create conversation, enqueue jobs. + + Args: + client_state: Client state with batch_audio_chunks + user_id: User ID + user_email: User email + client_id: Client ID + """ + if not hasattr(client_state, 'batch_audio_chunks') or not client_state.batch_audio_chunks: + application_logger.warning(f"⚠️ Batch mode: No audio chunks accumulated for {client_id}") + return + + try: + from advanced_omi_backend.audio_utils import write_audio_file + from advanced_omi_backend.models.conversation import create_conversation + + # Combine all chunks + complete_audio = b''.join(client_state.batch_audio_chunks) + application_logger.info( + f"πŸ“¦ Batch mode: Combined {len(client_state.batch_audio_chunks)} chunks into {len(complete_audio)} bytes" + ) + + # Generate audio UUID and timestamp + audio_uuid = str(uuid.uuid4()) + timestamp = int(time.time() * 1000) + + # Write audio file and create AudioFile entry + wav_filename, file_path, duration = await write_audio_file( + raw_audio_data=complete_audio, + audio_uuid=audio_uuid, + client_id=client_id, + user_id=user_id, + user_email=user_email, + timestamp=timestamp, + validate=False # PCM data, not WAV + ) + + application_logger.info( + f"βœ… Batch mode: Wrote audio file {wav_filename} ({duration:.1f}s)" + ) + + # Create conversation immediately for batch audio + conversation_id = str(uuid.uuid4()) + version_id = str(uuid.uuid4()) + + conversation = create_conversation( + conversation_id=conversation_id, + audio_uuid=audio_uuid, + user_id=user_id, + client_id=client_id, + title="Batch Recording", + summary="Processing batch audio..." + ) + await conversation.insert() + + application_logger.info(f"πŸ“ Batch mode: Created conversation {conversation_id}") + + # Enqueue complete batch processing job chain + from advanced_omi_backend.controllers.queue_controller import start_batch_processing_jobs + + job_ids = start_batch_processing_jobs( + conversation_id=conversation_id, + audio_uuid=audio_uuid, + user_id=user_id, + user_email=user_email, + audio_file_path=file_path + ) + + application_logger.info( + f"βœ… Batch mode: Enqueued job chain for {conversation_id} - " + f"transcription ({job_ids['transcription']}) β†’ " + f"speaker ({job_ids['speaker_recognition']}) β†’ " + f"memory ({job_ids['memory']})" + ) + + # Clear accumulated chunks + client_state.batch_audio_chunks = [] + + except Exception as batch_error: + application_logger.error( + f"❌ Batch mode processing failed: {batch_error}", + exc_info=True + ) + + +async def handle_omi_websocket( + ws: WebSocket, + token: Optional[str] = None, + device_name: Optional[str] = None, +): + """Handle OMI WebSocket connections with Opus decoding.""" + # Generate pending client_id to track connection even if auth fails + pending_client_id = f"pending_{uuid.uuid4()}" + pending_connections.add(pending_client_id) + + client_id = None + client_state = None + + try: + # Setup connection (accept, auth, create client state) + client_id, client_state, user = await _setup_websocket_connection( + ws, token, device_name, pending_client_id, "OMI" + ) + if not user: + return + + # OMI-specific: Setup Opus decoder + decoder = OmiOpusDecoder() + _decode_packet = partial(decoder.decode_packet, strip_header=False) + + # Get singleton audio stream producer + audio_stream_producer = get_audio_stream_producer() + + packet_count = 0 + total_bytes = 0 + + while True: + # Parse Wyoming protocol + header, payload = await parse_wyoming_protocol(ws) + + if header["type"] == "audio-start": + # Handle audio session start + application_logger.info(f"πŸŽ™οΈ OMI audio session started for {client_id}") + await _initialize_streaming_session( + client_state, + audio_stream_producer, + user.user_id, + user.email, + client_id, + header.get("data", {"rate": OMI_SAMPLE_RATE, "width": OMI_SAMPLE_WIDTH, "channels": OMI_CHANNELS}) + ) + + elif header["type"] == "audio-chunk" and payload: + packet_count += 1 + total_bytes += len(payload) + + # Log progress + if packet_count <= 5 or packet_count % 1000 == 0: + application_logger.info( + f"🎡 Received OMI audio chunk #{packet_count}: {len(payload)} bytes" + ) + + # Handle OMI audio chunk (Opus decode + publish to stream) + await _handle_omi_audio_chunk( + client_state, + audio_stream_producer, + payload, + _decode_packet, + user.user_id, + client_id, + packet_count + ) + + # Log progress every 1000th packet + if packet_count % 1000 == 0: + application_logger.info( + f"πŸ“Š Processed {packet_count} OMI packets ({total_bytes} bytes total)" + ) + + elif header["type"] == "audio-stop": + # Handle audio session stop + application_logger.info( + f"πŸ›‘ OMI audio session stopped for {client_id} - " + f"Total chunks: {packet_count}, Total bytes: {total_bytes}" + ) + + # Finalize session using helper function + await _finalize_streaming_session( + client_state, + audio_stream_producer, + user.user_id, + user.email, + client_id + ) + + # Reset counters for next session + packet_count = 0 + total_bytes = 0 + + else: + # Unknown event type + application_logger.debug( + f"Ignoring Wyoming event type '{header['type']}' for OMI client {client_id}" + ) + + except WebSocketDisconnect: + application_logger.info( + f"πŸ”Œ WebSocket disconnected - Client: {client_id}, Packets: {packet_count}, Total bytes: {total_bytes}" + ) + except Exception as e: + application_logger.error(f"❌ WebSocket error for client {client_id}: {e}", exc_info=True) + finally: + # Clean up pending connection tracking + pending_connections.discard(pending_client_id) + + # Ensure cleanup happens even if client_id is None + if client_id: + try: + # Clean up client state + await cleanup_client_state(client_id) + except Exception as cleanup_error: + application_logger.error( + f"Error during cleanup for client {client_id}: {cleanup_error}", exc_info=True + ) + + +async def handle_pcm_websocket( + ws: WebSocket, + token: Optional[str] = None, + device_name: Optional[str] = None +): + """Handle PCM WebSocket connections with batch and streaming mode support.""" + # Generate pending client_id to track connection even if auth fails + pending_client_id = f"pending_{uuid.uuid4()}" + pending_connections.add(pending_client_id) + + client_id = None + client_state = None + + try: + # Setup connection (accept, auth, create client state) + client_id, client_state, user = await _setup_websocket_connection( + ws, token, device_name, pending_client_id, "PCM" + ) + if not user: + return + + # Get singleton audio stream producer + audio_stream_producer = get_audio_stream_producer() + + packet_count = 0 + total_bytes = 0 + audio_streaming = False # Track if audio session is active + + while True: + try: + if not audio_streaming: + # Control message mode - parse Wyoming protocol + application_logger.debug(f"πŸ”„ Control mode for {client_id}, WebSocket state: {ws.client_state if hasattr(ws, 'client_state') else 'unknown'}") + application_logger.debug(f"πŸ“¨ About to receive control message for {client_id}") + header, payload = await parse_wyoming_protocol(ws) + application_logger.debug(f"βœ… Received message type: {header.get('type')} for {client_id}") + + if header["type"] == "audio-start": + application_logger.debug(f"πŸŽ™οΈ Processing audio-start for {client_id}") + # Handle audio session start using helper function + audio_streaming, recording_mode = await _handle_audio_session_start( + client_state, + header.get("data", {}), + client_id + ) + continue # Continue to audio streaming mode + + elif header["type"] == "ping": + # Handle keepalive ping from frontend + application_logger.debug(f"πŸ“ Received ping from {client_id}") + continue + + else: + # Unknown control message type + application_logger.debug( + f"Ignoring Wyoming control event type '{header['type']}' for {client_id}" + ) + continue + + else: + # Audio streaming mode - receive raw bytes (like speaker recognition) + application_logger.debug(f"🎡 Audio streaming mode for {client_id} - waiting for audio data") + + try: + # Receive raw audio bytes or check for control messages + message = await ws.receive() + + + # Check if it's a disconnect + if "type" in message and message["type"] == "websocket.disconnect": + code = message.get("code", 1000) + reason = message.get("reason", "") + application_logger.info(f"πŸ”Œ WebSocket disconnect during audio streaming for {client_id}. Code: {code}, Reason: {reason}") + break + + # Check if it's a text message (control message like audio-stop) + if "text" in message: + try: + control_header = json.loads(message["text"].strip()) + if control_header.get("type") == "audio-stop": + # Handle audio session stop using helper function + audio_streaming = await _handle_audio_session_stop( + client_state, + audio_stream_producer, + user.user_id, + user.email, + client_id + ) + # Reset counters for next session + packet_count = 0 + total_bytes = 0 + continue + elif control_header.get("type") == "ping": + application_logger.debug(f"πŸ“ Received ping during streaming from {client_id}") + continue + elif control_header.get("type") == "audio-start": + # Handle duplicate audio-start messages gracefully (idempotent behavior) + application_logger.info(f"πŸ”„ Ignoring duplicate audio-start message during streaming for {client_id}") + continue + elif control_header.get("type") == "audio-chunk": + # Handle Wyoming protocol audio-chunk with binary payload + payload_length = control_header.get("payload_length") + if payload_length and payload_length > 0: + # Receive the binary audio data + payload_msg = await ws.receive() + if "bytes" in payload_msg: + audio_data = payload_msg["bytes"] + packet_count += 1 + total_bytes += len(audio_data) + + application_logger.debug(f"🎡 Received audio chunk #{packet_count}: {len(audio_data)} bytes") + + # Route to appropriate mode handler + audio_format = control_header.get("data", {}) + await _handle_audio_chunk( + client_state, + audio_stream_producer, + audio_data, + audio_format, + user.user_id, + user.email, + client_id + ) + else: + application_logger.warning(f"Expected binary payload for audio-chunk, got: {payload_msg.keys()}") + else: + application_logger.warning(f"audio-chunk missing payload_length: {payload_length}") + continue + else: + application_logger.warning(f"Unknown control message during streaming: {control_header.get('type')}") + continue + + except json.JSONDecodeError: + application_logger.warning(f"Invalid control message during streaming for {client_id}") + continue + + # Check if it's binary data (raw audio without Wyoming protocol) + elif "bytes" in message: + # Raw binary audio data (legacy support) + audio_data = message["bytes"] + packet_count += 1 + total_bytes += len(audio_data) + + application_logger.debug(f"🎡 Received raw audio chunk #{packet_count}: {len(audio_data)} bytes") + + # Route to appropriate mode handler with default format + default_format = {"rate": 16000, "width": 2, "channels": 1} + await _handle_audio_chunk( + client_state, + audio_stream_producer, + audio_data, + default_format, + user.user_id, + user.email, + client_id + ) + + else: + application_logger.warning(f"Unexpected message format in streaming mode: {message.keys()}") + continue + + except Exception as streaming_error: + application_logger.error(f"Error in audio streaming mode: {streaming_error}") + if "disconnect" in str(streaming_error).lower(): + break + continue + + except WebSocketDisconnect as e: + application_logger.info( + f"πŸ”Œ WebSocket disconnected during message processing for {client_id}. " + f"Code: {e.code}, Reason: {e.reason}" + ) + break # Exit the loop on disconnect + except json.JSONDecodeError as e: + application_logger.error( + f"❌ JSON decode error in Wyoming protocol for {client_id}: {e}" + ) + continue # Skip this message but don't disconnect + except ValueError as e: + application_logger.error( + f"❌ Protocol error for {client_id}: {e}" + ) + continue # Skip this message but don't disconnect + except RuntimeError as e: + # Handle "Cannot call receive once a disconnect message has been received" + if "disconnect" in str(e).lower(): + application_logger.info( + f"πŸ”Œ WebSocket already disconnected for {client_id}: {e}" + ) + break # Exit the loop on disconnect + else: + application_logger.error( + f"❌ Runtime error for {client_id}: {e}", exc_info=True + ) + continue + except Exception as e: + application_logger.error( + f"❌ Unexpected error processing message for {client_id}: {e}", exc_info=True + ) + # Check if it's a connection-related error + error_msg = str(e).lower() + if "disconnect" in error_msg or "closed" in error_msg or "receive" in error_msg: + application_logger.info( + f"πŸ”Œ Connection issue detected for {client_id}, exiting loop" + ) + break + else: + continue # Skip this message for other errors + + except WebSocketDisconnect: + application_logger.info( + f"πŸ”Œ PCM WebSocket disconnected - Client: {client_id}, Packets: {packet_count}, Total bytes: {total_bytes}" + ) + except Exception as e: + application_logger.error( + f"❌ PCM WebSocket error for client {client_id}: {e}", exc_info=True + ) + finally: + # Clean up pending connection tracking + pending_connections.discard(pending_client_id) + + # Ensure cleanup happens even if client_id is None + if client_id: + try: + # Clean up client state + await cleanup_client_state(client_id) + except Exception as cleanup_error: + application_logger.error( + f"Error during cleanup for client {client_id}: {cleanup_error}", exc_info=True + ) diff --git a/backends/advanced/src/advanced_omi_backend/conversation_manager.py b/backends/advanced/src/advanced_omi_backend/conversation_manager.py index 92b1ee0b..a240dd99 100644 --- a/backends/advanced/src/advanced_omi_backend/conversation_manager.py +++ b/backends/advanced/src/advanced_omi_backend/conversation_manager.py @@ -8,11 +8,6 @@ import logging from typing import Optional -from advanced_omi_backend.processors import ( - get_processor_manager, -) -from advanced_omi_backend.transcript_coordinator import get_transcript_coordinator - audio_logger = logging.getLogger("audio") @@ -21,10 +16,11 @@ class ConversationManager: This class handles the responsibilities previously mixed into ClientState, providing a clean separation of concerns for conversation management. + + V2 Architecture: Uses RQ jobs for all transcription and memory processing. """ def __init__(self): - self.coordinator = get_transcript_coordinator() audio_logger.info("ConversationManager initialized") async def close_conversation( @@ -55,27 +51,39 @@ async def close_conversation( audio_logger.info(f"πŸ”’ Closing conversation {audio_uuid} for client {client_id}") try: - # Get processor manager - processor_manager = get_processor_manager() - - # Step 1: Close audio file in processor (only if transcription not already completed) - # Check if transcription is already completed to avoid double-flushing - processing_status = processor_manager.get_processing_status(client_id) - transcription_completed = processing_status.get("stages", {}).get("transcription", {}).get("completed", False) - - if not transcription_completed: - audio_logger.info(f"πŸ”„ Transcription not completed, calling close_client_audio for {client_id}") - await processor_manager.close_client_audio(client_id) + # V2 Architecture: All processing handled by RQ jobs + # Step 1: Enqueue final high-quality transcription via RQ + # This will add a new transcript version and trigger memory processing + from advanced_omi_backend.database import AudioChunksRepository + + repo = AudioChunksRepository() + audio_session = await repo.get_chunk(audio_uuid) + + if audio_session and audio_session.get("conversation_id"): + # Only enqueue if conversation was created (speech detected) + import uuid + from advanced_omi_backend.workers.transcription_jobs import transcribe_full_audio_job + from advanced_omi_backend.controllers.queue_controller import transcription_queue, JOB_RESULT_TTL + + conversation_id = audio_session["conversation_id"] + version_id = str(uuid.uuid4()) # Generate new version ID for final transcription + audio_logger.info(f"πŸ“€ Enqueuing final transcription job for conversation {conversation_id}") + + job = transcription_queue.enqueue( + transcribe_full_audio_job, + conversation_id, + audio_uuid, + audio_session["audio_file_path"], + version_id, + user_id, + job_timeout=300, + result_ttl=JOB_RESULT_TTL, + job_id=f"transcript-reprocess_{conversation_id[:12]}", + description=f"Final transcription for conversation {conversation_id[:12]} (conversation close)" + ) + audio_logger.info(f"βœ… Enqueued final transcription job {job.id} for conversation {conversation_id}") else: - audio_logger.info(f"βœ… Transcription already completed, skipping close_client_audio for {client_id}") - - # Step 2: Memory processing is now handled by transcription completion - # This eliminates race conditions and event coordination issues - audio_logger.info(f"πŸ’­ Memory processing will be triggered by transcription completion for {audio_uuid}") - - # Step 3: Audio cropping is now handled at processor level after transcription - # This ensures cropping happens with diarization segments when available - # See transcription.py _queue_diarization_based_cropping() method + audio_logger.info(f"⏭️ No conversation created for {audio_uuid} (no speech detected), skipping final transcription") audio_logger.info(f"βœ… Successfully closed conversation {audio_uuid}") return True diff --git a/backends/advanced/src/advanced_omi_backend/database.py b/backends/advanced/src/advanced_omi_backend/database.py index e93c1d5c..1b85bf21 100644 --- a/backends/advanced/src/advanced_omi_backend/database.py +++ b/backends/advanced/src/advanced_omi_backend/database.py @@ -18,15 +18,23 @@ # MongoDB Configuration MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://mongo:27017") -mongo_client = AsyncIOMotorClient(MONGODB_URI) +mongo_client = AsyncIOMotorClient( + MONGODB_URI, + maxPoolSize=50, # Increased pool size for concurrent operations + minPoolSize=10, # Keep minimum connections ready + maxIdleTimeMS=45000, # Keep idle connections for 45 seconds + serverSelectionTimeoutMS=5000, # Fail fast if server unavailable + socketTimeoutMS=20000, # 20 second timeout for operations +) db = mongo_client.get_default_database("friend-lite") -# Collection references -chunks_col = db["audio_chunks"] -processing_runs_col = db["processing_runs"] +# Collection references (for non-Beanie collections) users_col = db["users"] -speakers_col = db["speakers"] -conversations_col = db["conversations"] +chunks_col = db["audio_chunks"] # Still used by AudioChunksRepository + +# Note: conversations collection managed by Beanie +# Note: processing_runs replaced by RQ job tracking +# Beanie initialization happens in main.py during application startup def get_database(): @@ -37,11 +45,8 @@ def get_database(): def get_collections(): """Get commonly used collection references.""" return { - "chunks_col": chunks_col, - "processing_runs_col": processing_runs_col, "users_col": users_col, - "speakers_col": speakers_col, - "conversations_col": conversations_col, + "chunks_col": chunks_col, } @@ -520,335 +525,38 @@ async def get_sessions_with_speech(self, user_id: str, limit: int = 100): return await cursor.to_list(length=None) - -class ConversationsRepository: - """Repository for user-facing conversations (speech-driven architecture).""" - - def __init__(self, collection): - self.col = collection - - async def create_conversation(self, conversation_data: dict) -> str: - """Create new user-facing conversation.""" - result = await self.col.insert_one(conversation_data) - return conversation_data["conversation_id"] - - def _populate_legacy_fields(self, conversation): - """Auto-populate legacy fields from active versions for backward compatibility.""" - if not conversation: - return conversation - - # Auto-populate transcript from active transcript version - active_transcript_version_id = conversation.get("active_transcript_version") - if active_transcript_version_id: - for version in conversation.get("transcript_versions", []): - if version.get("version_id") == active_transcript_version_id: - conversation["transcript"] = version.get("segments", []) - conversation["speakers_identified"] = version.get("speakers_identified", []) - break - else: - # No active version - ensure empty transcript - conversation["transcript"] = [] - - # Auto-populate memories from active memory version - active_memory_version_id = conversation.get("active_memory_version") - if active_memory_version_id: - for version in conversation.get("memory_versions", []): - if version.get("version_id") == active_memory_version_id: - conversation["memories"] = version.get("memories", []) - conversation["memory_processing_status"] = version.get("status", "pending") - break - else: - # No active version - ensure empty memories - conversation["memories"] = [] - conversation["memory_processing_status"] = "pending" - - return conversation - - async def get_conversation(self, conversation_id: str): - """Get conversation by conversation_id with auto-populated legacy fields.""" - conversation = await self.col.find_one({"conversation_id": conversation_id}) - return self._populate_legacy_fields(conversation) - - async def get_user_conversations(self, user_id: str, limit=100): - """Get all conversations for a user (only shows conversations with speech).""" - cursor = self.col.find({"user_id": user_id}) - conversations = await cursor.sort("created_at", -1).limit(limit).to_list() - # Auto-populate legacy fields for all conversations - return [self._populate_legacy_fields(conv) for conv in conversations] - - async def update_conversation(self, conversation_id: str, update_data: dict): - """Update conversation data.""" - result = await self.col.update_one( - {"conversation_id": conversation_id}, - {"$set": {**update_data, "updated_at": datetime.now(UTC)}} - ) - return result.modified_count > 0 - - async def add_memories(self, conversation_id: str, memories: list): - """Add memories to conversation.""" - result = await self.col.update_one( - {"conversation_id": conversation_id}, - { - "$push": {"memories": {"$each": memories}}, - "$set": {"updated_at": datetime.now(UTC)} - } - ) - return result.modified_count > 0 - - async def update_memory_processing_status(self, conversation_id: str, status: str): - """Update memory processing status for conversation.""" - result = await self.col.update_one( - {"conversation_id": conversation_id}, - { - "$set": { - "memory_processing_status": status, - "memory_processing_updated_at": datetime.now(UTC) - } - } - ) - return result.modified_count > 0 - - # ======================================== - # NEW: VERSIONING METHODS FOR REPROCESSING - # ======================================== - - async def create_transcript_version( - self, - conversation_id: str, - segments: list = None, - processing_run_id: str = None, - provider: str = None, - raw_data: dict = None - ) -> Optional[str]: - """Create a new transcript version in conversation.""" - version_id = str(uuid.uuid4()) - version_data = { - "version_id": version_id, - "segments": segments or [], - "status": "PENDING", - "provider": provider, - "created_at": datetime.now(UTC).isoformat(), - "processing_run_id": processing_run_id, - "raw_data": raw_data or {}, - "speakers_identified": [] - } - - result = await self.col.update_one( - {"conversation_id": conversation_id}, - {"$push": {"transcript_versions": version_data}} - ) - - if result.modified_count > 0: - logger.info(f"Created new transcript version {version_id} for conversation {conversation_id}") - return version_id - return None - - async def create_memory_version( - self, - conversation_id: str, - transcript_version_id: str, - memories: list = None, - processing_run_id: str = None - ) -> Optional[str]: - """Create a new memory version in conversation.""" - version_id = str(uuid.uuid4()) - version_data = { - "version_id": version_id, - "memories": memories or [], - "status": "PENDING", - "created_at": datetime.now(UTC).isoformat(), - "processing_run_id": processing_run_id, - "transcript_version_id": transcript_version_id - } - - result = await self.col.update_one( - {"conversation_id": conversation_id}, - {"$push": {"memory_versions": version_data}} - ) - - if result.modified_count > 0: - logger.info(f"Created new memory version {version_id} for conversation {conversation_id}") - return version_id - return None - - async def activate_transcript_version(self, conversation_id: str, version_id: str) -> bool: - """Activate a specific transcript version in conversation.""" - # First verify the version exists - conversation = await self.col.find_one( - {"conversation_id": conversation_id, "transcript_versions.version_id": version_id} - ) - if not conversation: - return False - - # Find the version and update active fields - version_data = None - for version in conversation.get("transcript_versions", []): - if version["version_id"] == version_id: - version_data = version - break - - if not version_data: - return False - - result = await self.col.update_one( - {"conversation_id": conversation_id}, - { - "$set": { - "active_transcript_version": version_id, - "transcript": version_data["segments"], - "speakers_identified": version_data["speakers_identified"], - "updated_at": datetime.now(UTC) - } - } - ) - - if result.modified_count > 0: - logger.info(f"Activated transcript version {version_id} for conversation {conversation_id}") - return result.modified_count > 0 - - async def activate_memory_version(self, conversation_id: str, version_id: str) -> bool: - """Activate a specific memory version in conversation.""" - # First verify the version exists - conversation = await self.col.find_one( - {"conversation_id": conversation_id, "memory_versions.version_id": version_id} - ) - if not conversation: - return False - - # Find the version and update active fields - version_data = None - for version in conversation.get("memory_versions", []): - if version["version_id"] == version_id: - version_data = version - break - - if not version_data: - return False - - result = await self.col.update_one( - {"conversation_id": conversation_id}, - { - "$set": { - "active_memory_version": version_id, - "memories": version_data["memories"], - "memory_processing_status": version_data["status"], - "updated_at": datetime.now(UTC) - } - } - ) - - if result.modified_count > 0: - logger.info(f"Activated memory version {version_id} for conversation {conversation_id}") - return result.modified_count > 0 - - async def get_version_history(self, conversation_id: str) -> dict: - """Get all version history for a conversation.""" - conversation = await self.col.find_one({"conversation_id": conversation_id}) - if not conversation: - return {} - - return { - "conversation_id": conversation_id, - "active_transcript_version": conversation.get("active_transcript_version"), - "active_memory_version": conversation.get("active_memory_version"), - "transcript_versions": conversation.get("transcript_versions", []), - "memory_versions": conversation.get("memory_versions", []) - } - - async def update_transcript_processing_status( - self, - conversation_id: str, - status: str, - provider: str = None, - error_message: str = None + async def update_transcription_status( + self, audio_uuid: str, status: str, error_message: str = None, provider: str = None ): - """Update transcript processing status for conversation.""" - update_doc = { - "transcript_processing_status": status, - "transcript_processing_updated_at": datetime.now(UTC), - "updated_at": datetime.now(UTC) - } - if provider: - update_doc["transcript_provider"] = provider - if error_message: - update_doc["transcript_processing_error"] = error_message - - result = await self.col.update_one( - {"conversation_id": conversation_id}, - {"$set": update_doc} - ) - return result.modified_count > 0 - - -class ProcessingRunsRepository: - """Repository for processing run tracking (updated for conversation_id).""" - - def __init__(self, collection): - self.col = collection - - async def create_run( - self, - *, - conversation_id: str, - audio_uuid: str, # Keep for audio file access - run_type: str, # 'transcript' or 'memory' - user_id: str, - trigger: str, # 'manual_reprocess', 'initial_processing', etc. - config_hash: str = None - ) -> str: - """Create a new processing run for conversation.""" - run_id = str(uuid.uuid4()) - doc = { - "run_id": run_id, - "conversation_id": conversation_id, - "audio_uuid": audio_uuid, # Keep for file access - "run_type": run_type, - "user_id": user_id, - "trigger": trigger, - "config_hash": config_hash, - "status": "PENDING", - "started_at": datetime.now(UTC), - "completed_at": None, - "error_message": None, - "result_version_id": None - } - await self.col.insert_one(doc) - logger.info(f"Created processing run {run_id} for conversation {conversation_id}") - return run_id - - async def update_run_status( - self, - run_id: str, - status: str, - error_message: str = None, - result_version_id: str = None - ) -> bool: - """Update processing run status.""" + """Update transcription status and completion timestamp. + + Args: + audio_uuid: UUID of the audio chunk + status: New status ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'EMPTY') + error_message: Optional error message if status is 'FAILED' + provider: Optional provider name for successful transcriptions + """ update_doc = { - "status": status, - "updated_at": datetime.now(UTC) + "transcription_status": status, + "updated_at": datetime.now(UTC).isoformat() } - if status in ["COMPLETED", "FAILED"]: - update_doc["completed_at"] = datetime.now(UTC) - if error_message: - update_doc["error_message"] = error_message - if result_version_id: - update_doc["result_version_id"] = result_version_id - + + if status == "COMPLETED": + update_doc["transcription_completed_at"] = datetime.now(UTC).isoformat() + if provider: + update_doc["transcription_provider"] = provider + elif status == "FAILED" and error_message: + update_doc["transcription_error"] = error_message + elif status == "EMPTY": + update_doc["transcription_completed_at"] = datetime.now(UTC).isoformat() + if provider: + update_doc["transcription_provider"] = provider + result = await self.col.update_one( - {"run_id": run_id}, - {"$set": update_doc} + {"audio_uuid": audio_uuid}, {"$set": update_doc} ) - - if result.modified_count > 0: - logger.info(f"Updated processing run {run_id} status to {status}") return result.modified_count > 0 - async def get_run(self, run_id: str): - """Get a processing run by ID.""" - return await self.col.find_one({"run_id": run_id}) - async def get_runs_for_conversation(self, conversation_id: str): - """Get all processing runs for a conversation.""" - cursor = self.col.find({"conversation_id": conversation_id}).sort("started_at", -1) - return await cursor.to_list(length=None) +# ConversationsRepository removed - use Beanie Conversation model directly +# ProcessingRunsRepository removed - use RQ job tracking instead diff --git a/backends/advanced/src/advanced_omi_backend/job_tracker.py b/backends/advanced/src/advanced_omi_backend/job_tracker.py deleted file mode 100644 index f16b1c2b..00000000 --- a/backends/advanced/src/advanced_omi_backend/job_tracker.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Job tracking system for async file processing operations. - -Provides in-memory job tracking for file upload and processing operations. -""" - -import asyncio -import logging -import time -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum -from typing import Dict, List, Optional - -logger = logging.getLogger(__name__) - - -class JobStatus(str, Enum): - QUEUED = "queued" - PROCESSING = "processing" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - -class FileStatus(str, Enum): - PENDING = "pending" - PROCESSING = "processing" - COMPLETED = "completed" - FAILED = "failed" - SKIPPED = "skipped" - - -@dataclass -class FileProcessingInfo: - filename: str - duration_seconds: Optional[float] = None - size_bytes: Optional[int] = None - status: FileStatus = FileStatus.PENDING - client_id: Optional[str] = None - audio_uuid: Optional[str] = None - transcription_status: Optional[str] = None - memory_status: Optional[str] = None - error_message: Optional[str] = None - started_at: Optional[datetime] = None - completed_at: Optional[datetime] = None - - -@dataclass -class ProcessingJob: - job_id: str - user_id: str - device_name: str - status: JobStatus = JobStatus.QUEUED - files: List[FileProcessingInfo] = field(default_factory=list) - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - started_at: Optional[datetime] = None - completed_at: Optional[datetime] = None - error_message: Optional[str] = None - current_file_index: int = 0 - - @property - def total_files(self) -> int: - return len(self.files) - - @property - def processed_files(self) -> int: - return len( - [ - f - for f in self.files - if f.status in [FileStatus.COMPLETED, FileStatus.FAILED, FileStatus.SKIPPED] - ] - ) - - @property - def progress_percent(self) -> float: - if self.total_files == 0: - return 0.0 - return (self.processed_files / self.total_files) * 100 - - @property - def current_file(self) -> Optional[FileProcessingInfo]: - if 0 <= self.current_file_index < len(self.files): - return self.files[self.current_file_index] - return None - - def to_dict(self) -> dict: - return { - "job_id": self.job_id, - "status": self.status.value, - "total_files": self.total_files, - "processed_files": self.processed_files, - "current_file": self.current_file.filename if self.current_file else None, - "progress_percent": round(self.progress_percent, 1), - "created_at": self.created_at.isoformat(), - "started_at": self.started_at.isoformat() if self.started_at else None, - "completed_at": self.completed_at.isoformat() if self.completed_at else None, - "error_message": self.error_message, - "files": [ - { - "filename": f.filename, - "duration_seconds": f.duration_seconds, - "size_bytes": f.size_bytes, - "status": f.status.value, - "client_id": f.client_id, - "audio_uuid": f.audio_uuid, - "transcription_status": f.transcription_status, - "memory_status": f.memory_status, - "error_message": f.error_message, - "started_at": f.started_at.isoformat() if f.started_at else None, - "completed_at": f.completed_at.isoformat() if f.completed_at else None, - } - for f in self.files - ], - } - - -class JobTracker: - """In-memory job tracking system.""" - - def __init__(self): - self.jobs: Dict[str, ProcessingJob] = {} - self._lock = asyncio.Lock() - - # Start cleanup task - self._cleanup_task = None - self._start_cleanup_task() - - def _start_cleanup_task(self): - """Start background task to clean up old jobs.""" - if self._cleanup_task is None or self._cleanup_task.done(): - self._cleanup_task = asyncio.create_task(self._cleanup_old_jobs()) - - async def _cleanup_old_jobs(self): - """Remove jobs older than 1 hour to prevent memory leaks.""" - while True: - try: - await asyncio.sleep(3600) # Check every hour - cutoff_time = datetime.now(timezone.utc).timestamp() - 3600 # 1 hour ago - - async with self._lock: - jobs_to_remove = [] - for job_id, job in self.jobs.items(): - job_age = job.created_at.timestamp() - if job_age < cutoff_time and job.status in [ - JobStatus.COMPLETED, - JobStatus.FAILED, - JobStatus.CANCELLED, - ]: - jobs_to_remove.append(job_id) - - for job_id in jobs_to_remove: - del self.jobs[job_id] - logger.info(f"Cleaned up old job: {job_id}") - - except Exception as e: - logger.error(f"Error in job cleanup task: {e}") - - async def create_job(self, user_id: str, device_name: str, files: List[str]) -> str: - """Create a new processing job.""" - job_id = str(uuid.uuid4()) - - file_infos = [] - for filename in files: - file_infos.append(FileProcessingInfo(filename=filename)) - - job = ProcessingJob( - job_id=job_id, user_id=user_id, device_name=device_name, files=file_infos - ) - - async with self._lock: - self.jobs[job_id] = job - - logger.info(f"Created job {job_id} with {len(files)} files for user {user_id}") - return job_id - - async def get_job(self, job_id: str) -> Optional[ProcessingJob]: - """Get job by ID.""" - async with self._lock: - return self.jobs.get(job_id) - - async def update_job_status(self, job_id: str, status: JobStatus, error_message: str = None): - """Update job status.""" - async with self._lock: - if job_id in self.jobs: - job = self.jobs[job_id] - job.status = status - if error_message: - job.error_message = error_message - - if status == JobStatus.PROCESSING and job.started_at is None: - job.started_at = datetime.now(timezone.utc) - elif status in [JobStatus.COMPLETED, JobStatus.FAILED]: - job.completed_at = datetime.now(timezone.utc) - - async def update_file_status( - self, - job_id: str, - filename: str, - status: FileStatus, - client_id: str = None, - audio_uuid: str = None, - transcription_status: str = None, - memory_status: str = None, - error_message: str = None, - ): - """Update status of a specific file in the job.""" - async with self._lock: - if job_id in self.jobs: - job = self.jobs[job_id] - for file_info in job.files: - if file_info.filename == filename: - file_info.status = status - if client_id: - file_info.client_id = client_id - if audio_uuid: - file_info.audio_uuid = audio_uuid - if transcription_status: - file_info.transcription_status = transcription_status - if memory_status: - file_info.memory_status = memory_status - if error_message: - file_info.error_message = error_message - - if status == FileStatus.PROCESSING and file_info.started_at is None: - file_info.started_at = datetime.now(timezone.utc) - elif status in [ - FileStatus.COMPLETED, - FileStatus.FAILED, - FileStatus.SKIPPED, - ]: - file_info.completed_at = datetime.now(timezone.utc) - break - - async def set_current_file(self, job_id: str, filename: str): - """Set the currently processing file.""" - async with self._lock: - if job_id in self.jobs: - job = self.jobs[job_id] - for i, file_info in enumerate(job.files): - if file_info.filename == filename: - job.current_file_index = i - break - - async def get_active_jobs(self) -> List[ProcessingJob]: - """Get all active (non-completed) jobs.""" - async with self._lock: - return [ - job - for job in self.jobs.values() - if job.status in [JobStatus.QUEUED, JobStatus.PROCESSING] - ] - - -# Global job tracker instance -_job_tracker: Optional[JobTracker] = None - - -def get_job_tracker() -> JobTracker: - """Get the global job tracker instance.""" - global _job_tracker - if _job_tracker is None: - _job_tracker = JobTracker() - return _job_tracker diff --git a/backends/advanced/src/advanced_omi_backend/main.py b/backends/advanced/src/advanced_omi_backend/main.py index 1eaafabe..a9d9d47c 100644 --- a/backends/advanced/src/advanced_omi_backend/main.py +++ b/backends/advanced/src/advanced_omi_backend/main.py @@ -1,1285 +1,49 @@ #!/usr/bin/env python3 -"""Unified Omi-audio service - -* Accepts Opus packets over a WebSocket (`/ws`) or PCM over a WebSocket (`/ws_pcm`). -* Uses a central queue to decouple audio ingestion from processing. -* A saver consumer buffers PCM and writes 30-second WAV chunks to `./data/audio_chunks/`. -* A transcription consumer sends each chunk to a Wyoming ASR service. -* The transcript is stored in **mem0** and MongoDB. - """ -import logging - -logging.basicConfig(level=logging.INFO) - -import asyncio -import concurrent.futures -import json -import os -import time -import uuid -from contextlib import asynccontextmanager -from functools import partial -from pathlib import Path -from typing import Optional - -import aiohttp - -# Import authentication components -from advanced_omi_backend.auth import ( - bearer_backend, - cookie_backend, - create_admin_user_if_needed, - fastapi_users, - websocket_auth, -) -from advanced_omi_backend.client import ClientState -from advanced_omi_backend.client_manager import generate_client_id -from advanced_omi_backend.constants import ( - OMI_CHANNELS, - OMI_SAMPLE_RATE, - OMI_SAMPLE_WIDTH, -) -from advanced_omi_backend.database import AudioChunksRepository -from advanced_omi_backend.llm_client import async_health_check -from advanced_omi_backend.memory import get_memory_service, shutdown_memory_service -from advanced_omi_backend.processors import ( - AudioProcessingItem, - get_processor_manager, - init_processor_manager, -) -from advanced_omi_backend.audio_utils import process_audio_chunk -from advanced_omi_backend.task_manager import init_task_manager, get_task_manager -from advanced_omi_backend.transcript_coordinator import get_transcript_coordinator -from advanced_omi_backend.transcription_providers import get_transcription_provider -from advanced_omi_backend.users import ( - User, - UserRead, - UserUpdate, - register_client_to_user, -) - -# Import Beanie for user management -from beanie import init_beanie -from dotenv import load_dotenv -from fastapi import ( - FastAPI, - HTTPException, - Query, - Request, - WebSocket, - WebSocketDisconnect, -) -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from fastapi.staticfiles import StaticFiles -from friend_lite.decoder import OmiOpusDecoder -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo.errors import ConnectionFailure, PyMongoError -from wyoming.audio import AudioChunk -from wyoming.client import AsyncTcpClient +Unified Omi-audio service + + * Accepts Opus packets over a WebSocket (`/ws`) or PCM over a WebSocket (`/ws_pcm`). + * Uses a central queue to decouple audio ingestion from processing. + * A saver consumer buffers PCM and writes 30-second WAV chunks to `./data/audio_chunks/`. + * A transcription consumer sends each chunk to a Wyoming ASR service. + * The transcript is stored in **mem0** and MongoDB. + +Refactored to use a modular architecture with proper separation of concerns: +- app_factory.py: FastAPI application creation and configuration +- app_config.py: Centralized configuration management +- middleware/app_middleware.py: CORS and exception handling +- routers/modules/: Organized route handlers +""" -############################################################################### -# SETUP -############################################################################### +import logging +import uvicorn -# Load environment variables first -load_dotenv() +from advanced_omi_backend.app_factory import create_app # Logging setup +logging.basicConfig(level=logging.INFO) logger = logging.getLogger("advanced-backend") -application_logger = logging.getLogger("audio_processing") - -# Conditional Deepgram import -try: - from deepgram import DeepgramClient, FileSource, PrerecordedOptions # type: ignore -except ImportError: - logger.warning("Deepgram SDK not available. Install with: uv sync --group deepgram") - -############################################################################### -# CONFIGURATION -############################################################################### - -# MongoDB Configuration -MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://mongo:27017") -mongo_client = AsyncIOMotorClient(MONGODB_URI) -db = mongo_client.get_default_database("friend-lite") -chunks_col = db["audio_chunks"] -users_col = db["users"] -speakers_col = db["speakers"] - - -# Audio Configuration -SEGMENT_SECONDS = 60 # length of each stored chunk -TARGET_SAMPLES = OMI_SAMPLE_RATE * SEGMENT_SECONDS - -# Conversation timeout configuration -NEW_CONVERSATION_TIMEOUT_MINUTES = float(os.getenv("NEW_CONVERSATION_TIMEOUT_MINUTES", "1.5")) - -# Audio cropping configuration -AUDIO_CROPPING_ENABLED = os.getenv("AUDIO_CROPPING_ENABLED", "true").lower() == "true" -MIN_SPEECH_SEGMENT_DURATION = float(os.getenv("MIN_SPEECH_SEGMENT_DURATION", "1.0")) # seconds -CROPPING_CONTEXT_PADDING = float( - os.getenv("CROPPING_CONTEXT_PADDING", "0.1") -) # seconds of padding around speech - -# Directory where WAV chunks are written -CHUNK_DIR = Path("./audio_chunks") # This will be mounted to ./data/audio_chunks by Docker -CHUNK_DIR.mkdir(parents=True, exist_ok=True) - - -# Transcription Configuration -TRANSCRIPTION_PROVIDER = os.getenv("TRANSCRIPTION_PROVIDER") -DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY") -MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY") - -# Get configured transcription provider (online or offline) -transcription_provider = get_transcription_provider(TRANSCRIPTION_PROVIDER) -if transcription_provider: - logger.info( - f"βœ… Using {transcription_provider.name} transcription provider ({transcription_provider.mode})" - ) -else: - logger.warning("⚠️ No transcription provider configured - speech-to-text will not be available") - -# Ollama & Qdrant Configuration -QDRANT_BASE_URL = os.getenv("QDRANT_BASE_URL", "qdrant") -QDRANT_PORT = os.getenv("QDRANT_PORT", "6333") - -# Speaker service configuration - -# Track pending WebSocket connections to prevent race conditions -pending_connections: set[str] = set() - -# Thread pool executors -_DEC_IO_EXECUTOR = concurrent.futures.ThreadPoolExecutor( - max_workers=os.cpu_count() or 4, - thread_name_prefix="opus_io", -) - -# Initialize memory service -memory_service = get_memory_service() - -############################################################################### -# UTILITY FUNCTIONS & HELPER CLASSES -############################################################################### - - -async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: - """Parse Wyoming protocol: JSON header line followed by optional binary payload. - - Returns: - Tuple of (header_dict, payload_bytes or None) - """ - # Read data from WebSocket - logger.debug(f"parse_wyoming_protocol: About to call ws.receive()") - message = await ws.receive() - logger.debug(f"parse_wyoming_protocol: Received message with keys: {message.keys() if message else 'None'}") - - # Handle WebSocket close frame - if "type" in message and message["type"] == "websocket.disconnect": - # This is a normal WebSocket close event - code = message.get("code", 1000) - reason = message.get("reason", "") - logger.info(f"πŸ“΄ WebSocket disconnect received in parse_wyoming_protocol. Code: {code}, Reason: {reason}") - raise WebSocketDisconnect(code=code, reason=reason) - - # Handle text message (JSON header) - if "text" in message: - header_text = message["text"] - # Wyoming protocol uses newline-terminated JSON - if not header_text.endswith("\n"): - header_text += "\n" - - # Parse JSON header - json_line = header_text.strip() - header = json.loads(json_line) - - # If payload is expected, read binary data - payload = None - payload_length = header.get("payload_length") - if payload_length is not None and payload_length > 0: - payload_msg = await ws.receive() - if "bytes" in payload_msg: - payload = payload_msg["bytes"] - else: - logger.warning(f"Expected binary payload but got: {payload_msg.keys()}") - - return header, payload - - # Handle binary message (invalid - Wyoming protocol requires JSONL headers) - elif "bytes" in message: - raise ValueError( - "Raw binary messages not supported - Wyoming protocol requires JSONL headers" - ) - - else: - raise ValueError(f"Unexpected WebSocket message type: {message.keys()}") - - -# Initialize repository and global state -ac_repository = AudioChunksRepository(chunks_col) -# Client-to-user mapping for reliable permission checking -client_to_user_mapping: dict[str, str] = {} # client_id -> user_id - -# Initialize client manager (self-initializing, no external dict needed) -from advanced_omi_backend.client_manager import get_client_manager - -client_manager = get_client_manager() - -# Initialize client utilities with the mapping dictionaries -from advanced_omi_backend.client_manager import ( - init_client_user_mapping, - register_client_user_mapping, - track_client_user_relationship, - unregister_client_user_mapping, -) - -# Client ownership tracking for database records -# Since we're in development, we'll track all client-user relationships in memory -# This will be populated when clients connect and persisted in database records -all_client_user_mappings: dict[str, str] = ( - {} -) # client_id -> user_id (includes disconnected clients) - -# Initialize client user mapping with both dictionaries -init_client_user_mapping(client_to_user_mapping, all_client_user_mappings) - - -async def create_client_state( - client_id: str, user: User, device_name: Optional[str] = None -) -> ClientState: - """Create and register a new client state.""" - # Use ClientManager for atomic client creation and registration - client_state = client_manager.create_client( - client_id, ac_repository, CHUNK_DIR, user.user_id, user.email - ) - - # Also track in persistent mapping (for database queries) - track_client_user_relationship(client_id, user.user_id) - - # Register client in user model (persistent) - await register_client_to_user(user, client_id, device_name) - - # Note: No need to start processing - it's handled at application level now - - return client_state - - -async def cleanup_client_state(client_id: str): - """Clean up and remove client state.""" - # Use ClientManager for atomic client removal with cleanup - removed = await client_manager.remove_client_with_cleanup(client_id) - - if removed: - # Clean up any orphaned transcript events for this client - coordinator = get_transcript_coordinator() - coordinator.cleanup_transcript_events_for_client(client_id) - - logger.info(f"Client {client_id} cleaned up successfully") - else: - logger.warning(f"Client {client_id} was not found for cleanup") - - -############################################################################### -# CORE APPLICATION LOGIC -############################################################################### - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Manage application lifespan events.""" - # Startup - application_logger.info("Starting application...") - - # Initialize Beanie for user management - try: - await init_beanie( - database=mongo_client.get_default_database("friend-lite"), - document_models=[User], - ) - application_logger.info("Beanie initialized for user management") - except Exception as e: - application_logger.error(f"Failed to initialize Beanie: {e}") - raise - - # Create admin user if needed - try: - await create_admin_user_if_needed() - except Exception as e: - application_logger.error(f"Failed to create admin user: {e}") - # Don't raise here as this is not critical for startup - - # Initialize task manager - task_manager = init_task_manager() - await task_manager.start() - application_logger.info("Task manager started") - - # Initialize processor manager - processor_manager = init_processor_manager(CHUNK_DIR, ac_repository) - await processor_manager.start() - - logger.info("App ready") - try: - yield - finally: - # Shutdown - application_logger.info("Shutting down application...") - - # Clean up all active clients - for client_id in client_manager.get_all_client_ids(): - await cleanup_client_state(client_id) - - # Shutdown processor manager - processor_manager = get_processor_manager() - await processor_manager.shutdown() - application_logger.info("Processor manager shut down") - - # Shutdown task manager - task_manager = get_task_manager() - await task_manager.shutdown() - application_logger.info("Task manager shut down") - - # Stop metrics collection and save final report - application_logger.info("Metrics collection stopped") - - # Shutdown memory service and speaker service - shutdown_memory_service() - application_logger.info("Memory and speaker services shut down.") - - application_logger.info("Shutdown complete.") - - -# FastAPI Application -app = FastAPI(lifespan=lifespan) - -# Configure CORS with configurable origins (includes Tailscale support by default) -default_origins = "http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,http://127.0.0.1:3002" -cors_origins = os.getenv("CORS_ORIGINS", default_origins) -allowed_origins = [origin.strip() for origin in cors_origins.split(",") if origin.strip()] - -# Support Tailscale IP range (100.x.x.x) via regex pattern -tailscale_regex = r"http://100\.\d{1,3}\.\d{1,3}\.\d{1,3}:3000" - -logger.info(f"🌐 CORS configured with origins: {allowed_origins}") -logger.info(f"🌐 CORS also allows Tailscale IPs via regex: {tailscale_regex}") - -app.add_middleware( - CORSMiddleware, - allow_origins=allowed_origins, - allow_origin_regex=tailscale_regex, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -############################################################################### -# GLOBAL EXCEPTION HANDLERS -############################################################################### - -@app.exception_handler(ConnectionFailure) -@app.exception_handler(PyMongoError) -async def database_exception_handler(request: Request, exc: Exception): - """Handle database connection failures and return structured error response.""" - logger.error(f"Database connection error: {type(exc).__name__}: {exc}") - return JSONResponse( - status_code=500, - content={ - "detail": "Unable to connect to server. Please check your connection and try again.", - "error_type": "connection_failure", - "error_category": "database" - } - ) - - -@app.exception_handler(ConnectionError) -async def connection_exception_handler(request: Request, exc: ConnectionError): - """Handle general connection errors and return structured error response.""" - logger.error(f"Connection error: {exc}") - return JSONResponse( - status_code=500, - content={ - "detail": "Unable to connect to server. Please check your connection and try again.", - "error_type": "connection_failure", - "error_category": "network" - } - ) - - -@app.exception_handler(HTTPException) -async def http_exception_handler(request: Request, exc: HTTPException): - """Handle HTTP exceptions with structured error response.""" - # For authentication failures (401), add error_type - if exc.status_code == 401: - return JSONResponse( - status_code=exc.status_code, - content={ - "detail": exc.detail, - "error_type": "authentication_failure" - }, - headers=getattr(exc, "headers", None), - ) - - # For other HTTP exceptions, return as-is - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.detail}, - headers=getattr(exc, "headers", None), - ) - - -############################################################################### -# HEALTH CHECK ENDPOINTS -############################################################################### - -@app.get("/api/auth/health") -async def auth_health_check(): - """Pre-flight health check for authentication service connectivity.""" - try: - # Test database connectivity - await mongo_client.admin.command("ping") - - # Test memory service if available - if memory_service: - try: - await asyncio.wait_for(memory_service.test_connection(), timeout=2.0) - memory_status = "ok" - except Exception as e: - logger.warning(f"Memory service health check failed: {e}") - memory_status = "degraded" - else: - memory_status = "unavailable" - - return { - "status": "ok", - "database": "ok", - "memory_service": memory_status, - "timestamp": int(time.time()) - } - except Exception as e: - logger.error(f"Auth health check failed: {e}") - return JSONResponse( - status_code=500, - content={ - "status": "error", - "detail": "Service connectivity check failed", - "error_type": "connection_failure", - "timestamp": int(time.time()) - } - ) - - -app.mount("/audio", StaticFiles(directory=CHUNK_DIR), name="audio") - -# Add authentication routers -app.include_router( - fastapi_users.get_auth_router(cookie_backend), - prefix="/auth/cookie", - tags=["auth"], -) -app.include_router( - fastapi_users.get_auth_router(bearer_backend), - prefix="/auth/jwt", - tags=["auth"], -) - -# Add users router for /users/me and other user endpoints -app.include_router( - fastapi_users.get_users_router(UserRead, UserUpdate), - prefix="/users", - tags=["users"], -) - -# API endpoints -from advanced_omi_backend.routers.api_router import router as api_router - -app.include_router(api_router) - - -@app.websocket("/ws_omi") -async def ws_endpoint_omi( - ws: WebSocket, - token: Optional[str] = Query(None), - device_name: Optional[str] = Query(None), -): - """Accepts WebSocket connections with Wyoming protocol, decodes OMI Opus audio, and processes per-client.""" - # Generate pending client_id to track connection even if auth fails - pending_client_id = f"pending_{uuid.uuid4()}" - pending_connections.add(pending_client_id) - - client_id = None - client_state = None - - try: - # Authenticate user before accepting WebSocket connection - user = await websocket_auth(ws, token) - if not user: - await ws.close(code=1008, reason="Authentication required") - return - - await ws.accept() - - # Generate proper client_id using user and device_name - client_id = generate_client_id(user, device_name) - - # Remove from pending now that we have real client_id - pending_connections.discard(pending_client_id) - application_logger.info( - f"πŸ”Œ WebSocket connection accepted - User: {user.user_id} ({user.email}), Client: {client_id}" - ) - - # Create client state - client_state = await create_client_state(client_id, user, device_name) - - # Setup decoder (only required for decoding OMI audio) - decoder = OmiOpusDecoder() - _decode_packet = partial(decoder.decode_packet, strip_header=False) - - # Get processor manager - processor_manager = get_processor_manager() - - packet_count = 0 - total_bytes = 0 - - while True: - # Parse Wyoming protocol - header, payload = await parse_wyoming_protocol(ws) - - if header["type"] == "audio-start": - # Handle audio session start (optional for OMI devices) - application_logger.info( - f"πŸŽ™οΈ OMI audio session started for {client_id} (explicit start)" - ) - - elif header["type"] == "audio-chunk" and payload: - packet_count += 1 - total_bytes += len(payload) - - # OMI devices stream continuously - always process audio chunks - if packet_count <= 5 or packet_count % 1000 == 0: # Log first 5 and every 1000th - application_logger.info( - f"🎡 Received OMI audio chunk #{packet_count}: {len(payload)} bytes" - ) - - # Decode Opus payload to PCM using OMI decoder - start_time = time.time() - loop = asyncio.get_running_loop() - pcm_data = await loop.run_in_executor(_DEC_IO_EXECUTOR, _decode_packet, payload) - decode_time = time.time() - start_time - - if pcm_data: - if packet_count <= 5 or packet_count % 1000 == 0: # Log first 5 and every 1000th - application_logger.debug( - f"🎡 Decoded OMI packet #{packet_count}: {len(payload)} bytes -> {len(pcm_data)} PCM bytes (took {decode_time:.3f}s)" - ) - - # Use timestamp from Wyoming header if provided, otherwise current time - audio_data = header.get("data", {}) - chunk_timestamp = audio_data.get("timestamp", int(time.time())) - - # Queue to application-level processor - if packet_count <= 5 or packet_count % 100 == 0: # Log first 5 and every 100th - application_logger.debug( - f"πŸš€ About to queue audio chunk #{packet_count} for client {client_id}" - ) - - # Process audio chunk through unified pipeline - await process_audio_chunk( - audio_data=pcm_data, - client_id=client_id, - user_id=user.user_id, - user_email=user.email, - audio_format={ - "rate": OMI_SAMPLE_RATE, - "width": OMI_SAMPLE_WIDTH, - "channels": OMI_CHANNELS, - "timestamp": chunk_timestamp, - }, - client_state=client_state, - ) - - # Log every 1000th packet to avoid spam - if packet_count % 1000 == 0: - application_logger.info( - f"πŸ“Š Processed {packet_count} OMI packets ({total_bytes} bytes total) for client {client_id}" - ) - else: - # Log decode failures for first 5 packets - if packet_count <= 5: - application_logger.warning( - f"❌ Failed to decode OMI packet #{packet_count}: {len(payload)} bytes" - ) - - elif header["type"] == "audio-stop": - # Handle audio session stop - application_logger.info( - f"πŸ›‘ OMI audio session stopped for {client_id} - " - f"Total chunks: {packet_count}, Total bytes: {total_bytes}" - ) - - # Signal end of audio stream to processor - await processor_manager.close_client_audio(client_id) - - # Close current conversation to trigger memory processing - if client_state: - application_logger.info( - f"πŸ“ Closing conversation for {client_id} on audio-stop" - ) - await client_state.close_current_conversation() - - # Reset counters for next session - packet_count = 0 - total_bytes = 0 - - else: - # Unknown event type - application_logger.debug( - f"Ignoring Wyoming event type '{header['type']}' for OMI client {client_id}" - ) - - except WebSocketDisconnect: - application_logger.info( - f"πŸ”Œ WebSocket disconnected - Client: {client_id}, Packets: {packet_count}, Total bytes: {total_bytes}" - ) - except Exception as e: - application_logger.error(f"❌ WebSocket error for client {client_id}: {e}", exc_info=True) - finally: - # Clean up pending connection tracking - pending_connections.discard(pending_client_id) - - # Ensure cleanup happens even if client_id is None - if client_id: - try: - # Signal end of audio stream to processor - processor_manager = get_processor_manager() - await processor_manager.close_client_audio(client_id) - - # Clean up client state - await cleanup_client_state(client_id) - except Exception as cleanup_error: - application_logger.error( - f"Error during cleanup for client {client_id}: {cleanup_error}", exc_info=True - ) - - -@app.websocket("/ws_pcm") -async def ws_endpoint_pcm( - ws: WebSocket, token: Optional[str] = Query(None), device_name: Optional[str] = Query(None) -): - """Accepts WebSocket connections, processes PCM audio per-client.""" - # Generate pending client_id to track connection even if auth fails - pending_client_id = f"pending_{uuid.uuid4()}" - pending_connections.add(pending_client_id) - - client_id = None - client_state = None - - try: - # Authenticate user before accepting WebSocket connection - user = await websocket_auth(ws, token) - if not user: - await ws.close(code=1008, reason="Authentication required") - return - - # Accept WebSocket AFTER authentication succeeds (fixes race condition) - await ws.accept() - - # Generate proper client_id using user and device_name - client_id = generate_client_id(user, device_name) - - # Remove from pending now that we have real client_id - pending_connections.discard(pending_client_id) - application_logger.info( - f"πŸ”Œ PCM WebSocket connection accepted - User: {user.user_id} ({user.email}), Client: {client_id}" - ) - - # Send ready message to client (similar to speaker recognition service) - try: - ready_msg = json.dumps({"type": "ready", "message": "WebSocket connection established"}) + "\n" - await ws.send_text(ready_msg) - application_logger.debug(f"βœ… Sent ready message to {client_id}") - except Exception as e: - application_logger.error(f"Failed to send ready message to {client_id}: {e}") - - # Create client state - client_state = await create_client_state(client_id, user, device_name) - - # Get processor manager - processor_manager = get_processor_manager() - - packet_count = 0 - total_bytes = 0 - audio_streaming = False # Track if audio session is active - - while True: - try: - if not audio_streaming: - # Control message mode - parse Wyoming protocol - application_logger.debug(f"πŸ”„ Control mode for {client_id}, WebSocket state: {ws.client_state if hasattr(ws, 'client_state') else 'unknown'}") - application_logger.debug(f"πŸ“¨ About to receive control message for {client_id}") - header, payload = await parse_wyoming_protocol(ws) - application_logger.debug(f"βœ… Received message type: {header.get('type')} for {client_id}") - - if header["type"] == "audio-start": - application_logger.debug(f"πŸŽ™οΈ Processing audio-start for {client_id}") - # Handle audio session start - audio_streaming = True - audio_format = header.get("data", {}) - application_logger.info( - f"πŸŽ™οΈ Audio session started for {client_id} - " - f"Format: {audio_format.get('rate')}Hz, " - f"{audio_format.get('width')}bytes, " - f"{audio_format.get('channels')}ch" - ) - - # Create transcription manager early for this client - processor_manager = get_processor_manager() - try: - application_logger.debug(f"πŸ“‹ Creating transcription manager for {client_id}") - await processor_manager.ensure_transcription_manager(client_id) - application_logger.info( - f"πŸ”Œ Created transcription manager for {client_id} on audio-start" - ) - except Exception as tm_error: - application_logger.error( - f"❌ Failed to create transcription manager for {client_id}: {tm_error}", exc_info=True - ) - - application_logger.info(f"🎡 Switching to audio streaming mode for {client_id}") - continue # Continue to audio streaming mode - - elif header["type"] == "ping": - # Handle keepalive ping from frontend - application_logger.debug(f"πŸ“ Received ping from {client_id}") - continue - - else: - # Unknown control message type - application_logger.debug( - f"Ignoring Wyoming control event type '{header['type']}' for {client_id}" - ) - continue - - else: - # Audio streaming mode - receive raw bytes (like speaker recognition) - application_logger.debug(f"🎡 Audio streaming mode for {client_id} - waiting for audio data") - - try: - # Receive raw audio bytes or check for control messages - message = await ws.receive() - - - # Check if it's a disconnect - if "type" in message and message["type"] == "websocket.disconnect": - code = message.get("code", 1000) - reason = message.get("reason", "") - application_logger.info(f"πŸ”Œ WebSocket disconnect during audio streaming for {client_id}. Code: {code}, Reason: {reason}") - break - - # Check if it's a text message (control message like audio-stop) - if "text" in message: - try: - control_header = json.loads(message["text"].strip()) - if control_header.get("type") == "audio-stop": - application_logger.info(f"πŸ›‘ Audio session stopped for {client_id}") - audio_streaming = False - - # Signal end of audio stream to processor - await processor_manager.close_client_audio(client_id) - - # Close current conversation to trigger memory processing - if client_state: - application_logger.info(f"πŸ“ Closing conversation for {client_id} on audio-stop") - await client_state.close_current_conversation() - - # Reset counters for next session - packet_count = 0 - total_bytes = 0 - continue - elif control_header.get("type") == "ping": - application_logger.debug(f"πŸ“ Received ping during streaming from {client_id}") - continue - elif control_header.get("type") == "audio-start": - # Handle duplicate audio-start messages gracefully (idempotent behavior) - application_logger.info(f"πŸ”„ Ignoring duplicate audio-start message during streaming for {client_id}") - continue - elif control_header.get("type") == "audio-chunk": - # Handle Wyoming protocol audio-chunk with binary payload - payload_length = control_header.get("payload_length") - if payload_length and payload_length > 0: - # Receive the binary audio data - payload_msg = await ws.receive() - if "bytes" in payload_msg: - audio_data = payload_msg["bytes"] - packet_count += 1 - total_bytes += len(audio_data) - - application_logger.debug(f"🎡 Received audio chunk #{packet_count}: {len(audio_data)} bytes") - - # Process audio chunk through unified pipeline - audio_format = control_header.get("data", {}) - await process_audio_chunk( - audio_data=audio_data, - client_id=client_id, - user_id=user.user_id, - user_email=user.email, - audio_format=audio_format, - client_state=None, # No client state update needed for Wyoming protocol - ) - else: - application_logger.warning(f"Expected binary payload for audio-chunk, got: {payload_msg.keys()}") - else: - application_logger.warning(f"audio-chunk missing payload_length: {payload_length}") - continue - else: - application_logger.warning(f"Unknown control message during streaming: {control_header.get('type')}") - continue - except json.JSONDecodeError: - application_logger.warning(f"Invalid control message during streaming for {client_id}") - continue - - # Check if it's binary data (raw audio without Wyoming protocol) - elif "bytes" in message: - # Raw binary audio data (legacy support) - audio_data = message["bytes"] - packet_count += 1 - total_bytes += len(audio_data) - - application_logger.debug(f"🎡 Received raw audio chunk #{packet_count}: {len(audio_data)} bytes") - - # Process raw audio chunk through unified pipeline (assume PCM 16kHz mono) - await process_audio_chunk( - audio_data=audio_data, - client_id=client_id, - user_id=user.user_id, - user_email=user.email, - audio_format={ - "rate": 16000, - "width": 2, - "channels": 1, - "timestamp": int(time.time()), - }, - client_state=None, # No client state update needed for raw streaming - ) - - else: - application_logger.warning(f"Unexpected message format in streaming mode: {message.keys()}") - continue - - except Exception as streaming_error: - application_logger.error(f"Error in audio streaming mode: {streaming_error}") - if "disconnect" in str(streaming_error).lower(): - break - continue - - # This section is now handled in the streaming mode above - - except WebSocketDisconnect as e: - application_logger.info( - f"πŸ”Œ WebSocket disconnected during message processing for {client_id}. " - f"Code: {e.code}, Reason: {e.reason}" - ) - break # Exit the loop on disconnect - except json.JSONDecodeError as e: - application_logger.error( - f"❌ JSON decode error in Wyoming protocol for {client_id}: {e}" - ) - continue # Skip this message but don't disconnect - except ValueError as e: - application_logger.error( - f"❌ Protocol error for {client_id}: {e}" - ) - continue # Skip this message but don't disconnect - except RuntimeError as e: - # Handle "Cannot call receive once a disconnect message has been received" - if "disconnect" in str(e).lower(): - application_logger.info( - f"πŸ”Œ WebSocket already disconnected for {client_id}: {e}" - ) - break # Exit the loop on disconnect - else: - application_logger.error( - f"❌ Runtime error for {client_id}: {e}", exc_info=True - ) - continue - except Exception as e: - application_logger.error( - f"❌ Unexpected error processing message for {client_id}: {e}", exc_info=True - ) - # Check if it's a connection-related error - error_msg = str(e).lower() - if "disconnect" in error_msg or "closed" in error_msg or "receive" in error_msg: - application_logger.info( - f"πŸ”Œ Connection issue detected for {client_id}, exiting loop" - ) - break - else: - continue # Skip this message for other errors - - except WebSocketDisconnect: - application_logger.info( - f"πŸ”Œ PCM WebSocket disconnected - Client: {client_id}, Packets: {packet_count}, Total bytes: {total_bytes}" - ) - except Exception as e: - application_logger.error( - f"❌ PCM WebSocket error for client {client_id}: {e}", exc_info=True - ) - finally: - # Clean up pending connection tracking - pending_connections.discard(pending_client_id) - - # Ensure cleanup happens even if client_id is None - if client_id: - try: - # Signal end of audio stream to processor - processor_manager = get_processor_manager() - await processor_manager.close_client_audio(client_id) - - # Clean up client state - await cleanup_client_state(client_id) - except Exception as cleanup_error: - application_logger.error( - f"Error during cleanup for client {client_id}: {cleanup_error}", exc_info=True - ) - - -@app.get("/health") -async def health_check(): - """Comprehensive health check for all services.""" - health_status = { - "status": "healthy", - "timestamp": int(time.time()), - "services": {}, - "config": { - "mongodb_uri": MONGODB_URI, - "qdrant_url": f"http://{QDRANT_BASE_URL}:{QDRANT_PORT}", - "transcription_service": ( - f"Speech to Text ({transcription_provider.name})" - if transcription_provider - else "Speech to Text (Not Configured)" - ), - "asr_uri": ( - f"{transcription_provider.mode.upper()} ({transcription_provider.name})" - if transcription_provider - else "Not configured" - ), - "transcription_provider": TRANSCRIPTION_PROVIDER or "auto-detect", - "provider_type": ( - transcription_provider.mode if transcription_provider else "none" - ), - "chunk_dir": str(CHUNK_DIR), - "active_clients": client_manager.get_client_count(), - "new_conversation_timeout_minutes": NEW_CONVERSATION_TIMEOUT_MINUTES, - "audio_cropping_enabled": AUDIO_CROPPING_ENABLED, - "llm_provider": os.getenv("LLM_PROVIDER"), - "llm_model": os.getenv("OPENAI_MODEL"), - "llm_base_url": os.getenv("OPENAI_BASE_URL"), - }, - } - - overall_healthy = True - critical_services_healthy = True - - # Get configuration once at the start - memory_provider = os.getenv("MEMORY_PROVIDER", "friend_lite") - speaker_service_url = os.getenv("SPEAKER_SERVICE_URL") - openmemory_mcp_url = os.getenv("OPENMEMORY_MCP_URL") - - # Check MongoDB (critical service) - try: - await asyncio.wait_for(mongo_client.admin.command("ping"), timeout=5.0) - health_status["services"]["mongodb"] = { - "status": "βœ… Connected", - "healthy": True, - "critical": True, - } - except asyncio.TimeoutError: - health_status["services"]["mongodb"] = { - "status": "❌ Connection Timeout (5s)", - "healthy": False, - "critical": True, - } - overall_healthy = False - critical_services_healthy = False - except Exception as e: - health_status["services"]["mongodb"] = { - "status": f"❌ Connection Failed: {str(e)}", - "healthy": False, - "critical": True, - } - overall_healthy = False - critical_services_healthy = False - - # Check LLM service (non-critical service - may not be running) - try: - - llm_health = await asyncio.wait_for(async_health_check(), timeout=8.0) - health_status["services"]["audioai"] = { - "status": llm_health.get("status", "❌ Unknown"), - "healthy": "βœ…" in llm_health.get("status", ""), - "base_url": llm_health.get("base_url", ""), - "model": llm_health.get("default_model", ""), - "provider": os.getenv("LLM_PROVIDER", "openai"), - "critical": False, - } - except asyncio.TimeoutError: - health_status["services"]["audioai"] = { - "status": "⚠️ Connection Timeout (8s) - Service may not be running", - "healthy": False, - "provider": os.getenv("LLM_PROVIDER", "openai"), - "critical": False, - } - overall_healthy = False - except Exception as e: - health_status["services"]["audioai"] = { - "status": f"⚠️ Connection Failed: {str(e)} - Service may not be running", - "healthy": False, - "provider": os.getenv("LLM_PROVIDER", "openai"), - "critical": False, - } - overall_healthy = False - - # Check memory service (provider-dependent) - if memory_provider == "friend_lite": - try: - # Test Friend-Lite memory service connection with timeout - test_success = await asyncio.wait_for(memory_service.test_connection(), timeout=8.0) - if test_success: - health_status["services"]["memory_service"] = { - "status": "βœ… Friend-Lite Memory Connected", - "healthy": True, - "provider": "friend_lite", - "critical": False, - } - else: - health_status["services"]["memory_service"] = { - "status": "⚠️ Friend-Lite Memory Test Failed", - "healthy": False, - "provider": "friend_lite", - "critical": False, - } - overall_healthy = False - except asyncio.TimeoutError: - health_status["services"]["memory_service"] = { - "status": "⚠️ Friend-Lite Memory Timeout (8s) - Check Qdrant", - "healthy": False, - "provider": "friend_lite", - "critical": False, - } - overall_healthy = False - except Exception as e: - health_status["services"]["memory_service"] = { - "status": f"⚠️ Friend-Lite Memory Failed: {str(e)}", - "healthy": False, - "provider": "friend_lite", - "critical": False, - } - overall_healthy = False - elif memory_provider == "openmemory_mcp": - # OpenMemory MCP check is handled separately above - health_status["services"]["memory_service"] = { - "status": "βœ… Using OpenMemory MCP", - "healthy": True, - "provider": "openmemory_mcp", - "critical": False, - } - else: - health_status["services"]["memory_service"] = { - "status": f"❌ Unknown memory provider: {memory_provider}", - "healthy": False, - "provider": memory_provider, - "critical": False, - } - overall_healthy = False - - # Check Speech to Text service based on configured provider - if transcription_provider: - provider_name = transcription_provider.name - provider_type = transcription_provider.mode - - # Generic provider health check - let each provider handle its own connection logic - try: - # Test provider connection - await transcription_provider.connect("health-check") - await transcription_provider.disconnect() - - health_status["services"]["speech_to_text"] = { - "status": "βœ… Provider Available", - "healthy": True, - "type": provider_type.title(), - "provider": provider_name, - "critical": False, - } - except Exception as e: - health_status["services"]["speech_to_text"] = { - "status": f"⚠️ Provider Error: {str(e)}", - "healthy": False, - "type": provider_type.title(), - "provider": provider_name, - "critical": False, - } - # Don't mark overall health as unhealthy for transcription issues - # since the service may be external or optional - else: - # No transcription service configured - health_status["services"]["speech_to_text"] = { - "status": "❌ No transcription service configured", - "healthy": False, - "type": "None", - "provider": "None", - "critical": False, - } - overall_healthy = False - - # Check Speaker Recognition service (non-critical - optional feature) - if speaker_service_url: - try: - # Make a health check request to the speaker service - async with aiohttp.ClientSession() as session: - async with session.get( - f"{speaker_service_url}/health", timeout=aiohttp.ClientTimeout(total=5) - ) as response: - if response.status == 200: - health_status["services"]["speaker_recognition"] = { - "status": "βœ… Connected", - "healthy": True, - "url": speaker_service_url, - "critical": False, - } - else: - health_status["services"]["speaker_recognition"] = { - "status": f"⚠️ Unhealthy: HTTP {response.status}", - "healthy": False, - "url": speaker_service_url, - "critical": False, - } - overall_healthy = False - except asyncio.TimeoutError: - health_status["services"]["speaker_recognition"] = { - "status": "⚠️ Connection Timeout (5s)", - "healthy": False, - "url": speaker_service_url, - "critical": False, - } - overall_healthy = False - except Exception as e: - health_status["services"]["speaker_recognition"] = { - "status": f"⚠️ Connection Failed: {str(e)}", - "healthy": False, - "url": speaker_service_url, - "critical": False, - } - overall_healthy = False - - # Check OpenMemory MCP service (if configured) - if memory_provider == "openmemory_mcp" and openmemory_mcp_url: - try: - # Make a health check request to the OpenMemory MCP service - async with aiohttp.ClientSession() as session: - async with session.get( - f"{openmemory_mcp_url}/api/v1/apps/", timeout=aiohttp.ClientTimeout(total=5) - ) as response: - if response.status == 200: - health_status["services"]["openmemory_mcp"] = { - "status": "βœ… Connected", - "healthy": True, - "url": openmemory_mcp_url, - "provider": "openmemory_mcp", - "critical": False, - } - else: - health_status["services"]["openmemory_mcp"] = { - "status": f"⚠️ Unhealthy: HTTP {response.status}", - "healthy": False, - "url": openmemory_mcp_url, - "provider": "openmemory_mcp", - "critical": False, - } - overall_healthy = False - except asyncio.TimeoutError: - health_status["services"]["openmemory_mcp"] = { - "status": "⚠️ Connection Timeout (5s)", - "healthy": False, - "url": openmemory_mcp_url, - "provider": "openmemory_mcp", - "critical": False, - } - overall_healthy = False - except Exception as e: - health_status["services"]["openmemory_mcp"] = { - "status": f"⚠️ Connection Failed: {str(e)}", - "healthy": False, - "url": openmemory_mcp_url, - "provider": "openmemory_mcp", - "critical": False, - } - overall_healthy = False - - # Track health check results in debug tracker - try: - # Can add health check tracking to debug tracker if needed - pass - except Exception as e: - application_logger.error(f"Failed to record health check metrics: {e}") - - # Set overall status - health_status["overall_healthy"] = overall_healthy - health_status["critical_services_healthy"] = critical_services_healthy - - if not critical_services_healthy: - health_status["status"] = "critical" - elif not overall_healthy: - health_status["status"] = "degraded" - else: - health_status["status"] = "healthy" - - # Add helpful messages - if not overall_healthy: - messages = [] - if not critical_services_healthy: - messages.append( - "Critical services (MongoDB) are unavailable - core functionality will not work" - ) - - unhealthy_optional = [ - name - for name, service in health_status["services"].items() - if not service["healthy"] and not service.get("critical", True) - ] - if unhealthy_optional: - messages.append(f"Optional services unavailable: {', '.join(unhealthy_optional)}") - - health_status["message"] = "; ".join(messages) - - return JSONResponse(content=health_status, status_code=200) - - -@app.get("/readiness") -async def readiness_check(): - """Simple readiness check for container orchestration.""" - # Use debug level for health check to reduce log spam - logger.debug("Readiness check requested") - - # Only check critical services for readiness - try: - # Quick MongoDB ping to ensure we can serve requests - await asyncio.wait_for(mongo_client.admin.command("ping"), timeout=2.0) - return JSONResponse(content={"status": "ready", "timestamp": int(time.time())}, status_code=200) - except Exception as e: - logger.error(f"Readiness check failed: {e}") - return JSONResponse( - content={"status": "not_ready", "error": str(e), "timestamp": int(time.time())}, - status_code=503 - ) +# Create FastAPI application using the app factory pattern +app = create_app() if __name__ == "__main__": - import uvicorn + """Main entry point for running the application.""" + import os + # Get port from environment or use default + port = int(os.getenv("PORT", 8000)) host = os.getenv("HOST", "0.0.0.0") - port = int(os.getenv("PORT", "8000")) - application_logger.info("Starting Omi unified service at ws://%s:%s/ws", host, port) - uvicorn.run("main:app", host=host, port=port, reload=False) + logger.info(f"Starting server on {host}:{port}") + + # Run the application + uvicorn.run( + "main:app", + host=host, + port=port, + reload=False, # Set to True for development + access_log=True, + log_level="info" + ) diff --git a/backends/advanced/src/advanced_omi_backend/memory/memory_service.py b/backends/advanced/src/advanced_omi_backend/memory/memory_service.py index dc5bc21e..46af0a75 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/memory_service.py +++ b/backends/advanced/src/advanced_omi_backend/memory/memory_service.py @@ -201,9 +201,9 @@ async def add_memory( memory_logger.info(f"βœ… Upserted {len(created_ids)} memories for {source_id}") return True, created_ids - error_msg = f"❌ No memories created for {source_id}: memory_entries={len(memory_entries) if memory_entries else 0}, allow_update={allow_update}" - memory_logger.error(error_msg) - raise RuntimeError(error_msg) + # No memories created - this is a valid outcome (duplicates, no extractable facts, etc.) + memory_logger.info(f"ℹ️ No new memories created for {source_id}: memory_entries={len(memory_entries) if memory_entries else 0}, allow_update={allow_update}") + return True, [] except asyncio.TimeoutError as e: memory_logger.error(f"⏰ Memory processing timed out for {source_id}") diff --git a/backends/advanced/src/advanced_omi_backend/memory/prompts.py b/backends/advanced/src/advanced_omi_backend/memory/prompts.py index 96aa4153..f655752e 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/prompts.py +++ b/backends/advanced/src/advanced_omi_backend/memory/prompts.py @@ -229,7 +229,7 @@ ## Summary of the agent's execution history **Task Objective**: Scrape blog post titles and full content from the OpenAI blog. -**Progress Status**: 10\% \complete β€” 5 out of 50 blog posts processed. +**Progress Status**: 10% complete β€” 5 out of 50 blog posts processed. 1. **Agent Action**: Opened URL "https://openai.com" **Action Result**: diff --git a/backends/advanced/src/advanced_omi_backend/memory/service_factory.py b/backends/advanced/src/advanced_omi_backend/memory/service_factory.py index 1df6ac27..df2a23c9 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/service_factory.py +++ b/backends/advanced/src/advanced_omi_backend/memory/service_factory.py @@ -34,7 +34,7 @@ def create_memory_service(config: MemoryConfig) -> MemoryServiceBase: ValueError: If unsupported memory provider is specified RuntimeError: If required dependencies are missing """ - memory_logger.info(f"Creating memory service with provider: {config.memory_provider.value}") + memory_logger.info(f"🧠 Creating memory service with provider: {config.memory_provider.value}") if config.memory_provider == MemoryProvider.FRIEND_LITE: # Use the sophisticated Friend-Lite implementation diff --git a/backends/advanced/src/advanced_omi_backend/memory/utils.py b/backends/advanced/src/advanced_omi_backend/memory/utils.py index 8db92f51..b3c231f7 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/utils.py +++ b/backends/advanced/src/advanced_omi_backend/memory/utils.py @@ -73,9 +73,9 @@ def extract_json_from_text(response_text: str) -> Optional[Dict[str, Any]]: # Try to find JSON using comprehensive regex patterns json_patterns = [ # Look for memory format: {"memory": [...]} - r'\{"memory"\s*:\s*\[.*?\]\s*\}', + r'\{"memory"\\s*:\\s*\[.*?\]\\s*\}', # Look for facts format: {"facts": [...]} - r'\{"facts"\s*:\s*\[.*?\]\s*\}', + r'\{"facts"\\s*:\\s*\[.*?\]\\s*\}', # Look for any JSON object containing memory or facts r'\{[^{}]*"(?:memory|facts)"[^{}]*\}', # Look for any balanced JSON object @@ -108,7 +108,7 @@ def extract_json_from_text(response_text: str) -> Optional[Dict[str, Any]]: # Try to extract just the facts or memory array if JSON object parsing fails for key in ["memory", "facts"]: - array_pattern = f'"{key}"\s*:\s*(\[.*?\])' + array_pattern = f'"{key}"\\s*:\\s*(\\[.*?\\])' try: match = re.search(array_pattern, response_text, re.DOTALL) if match: diff --git a/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py b/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py new file mode 100644 index 00000000..f886d31f --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py @@ -0,0 +1,91 @@ +""" +Middleware configuration for Friend-Lite backend. + +Centralizes CORS configuration and global exception handlers. +""" + +import logging +from typing import Optional + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pymongo.errors import ConnectionFailure, PyMongoError + +from advanced_omi_backend.app_config import get_app_config + +logger = logging.getLogger(__name__) + + +def setup_cors_middleware(app: FastAPI) -> None: + """Configure CORS middleware for the FastAPI application.""" + config = get_app_config() + + logger.info(f"🌐 CORS configured with origins: {config.allowed_origins}") + logger.info(f"🌐 CORS also allows Tailscale IPs via regex: {config.tailscale_regex}") + + app.add_middleware( + CORSMiddleware, + allow_origins=config.allowed_origins, + allow_origin_regex=config.tailscale_regex, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +def setup_exception_handlers(app: FastAPI) -> None: + """Configure global exception handlers for the FastAPI application.""" + + @app.exception_handler(ConnectionFailure) + @app.exception_handler(PyMongoError) + async def database_exception_handler(request: Request, exc: Exception): + """Handle database connection failures and return structured error response.""" + logger.error(f"Database connection error: {type(exc).__name__}: {exc}") + return JSONResponse( + status_code=500, + content={ + "detail": "Unable to connect to server. Please check your connection and try again.", + "error_type": "connection_failure", + "error_category": "database" + } + ) + + @app.exception_handler(ConnectionError) + async def connection_exception_handler(request: Request, exc: ConnectionError): + """Handle general connection errors and return structured error response.""" + logger.error(f"Connection error: {exc}") + return JSONResponse( + status_code=500, + content={ + "detail": "Unable to connect to server. Please check your connection and try again.", + "error_type": "connection_failure", + "error_category": "network" + } + ) + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """Handle HTTP exceptions with structured error response.""" + # For authentication failures (401), add error_type + if exc.status_code == 401: + return JSONResponse( + status_code=exc.status_code, + content={ + "detail": exc.detail, + "error_type": "authentication_failure", + "error_category": "security" + } + ) + + # For other HTTP exceptions, return as normal + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail} + ) + + +def setup_middleware(app: FastAPI) -> None: + """Set up all middleware for the FastAPI application.""" + setup_cors_middleware(app) + setup_exception_handlers(app) \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/__init__.py b/backends/advanced/src/advanced_omi_backend/models/__init__.py new file mode 100644 index 00000000..52c63c20 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/__init__.py @@ -0,0 +1,10 @@ +""" +Models package for Friend-Lite backend. + +This package contains Pydantic models that define the structure and validation +for all data entities in the Friend-Lite system. +""" + +# Models can be imported directly from their files +# e.g. from .job import TranscriptionJob +# e.g. from .conversation import Conversation, create_conversation \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/audio_file.py b/backends/advanced/src/advanced_omi_backend/models/audio_file.py new file mode 100644 index 00000000..de1c6f3f --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/audio_file.py @@ -0,0 +1,61 @@ +""" +AudioFile models for Friend-Lite backend. + +This module contains the Beanie Document model for audio_chunks collection, +which stores ALL audio files (both with and without speech). This is the +storage layer - all audio gets stored here with its metadata. + +Note: Named AudioFile (not AudioChunk) to avoid confusion with wyoming.audio.AudioChunk +which is the in-memory streaming audio data structure. +""" + +from datetime import datetime +from typing import Dict, List, Optional, Any +from pydantic import BaseModel, Field + +from beanie import Document, Indexed + + +class AudioFile(Document): + """ + Audio file model representing persisted audio files in MongoDB. + + The audio_chunks collection stores ALL raw audio files (both with and without speech). + This is just for audio file storage and metadata. If speech is detected, a + Conversation document is created which contains transcripts and memories. + + This is different from wyoming.audio.AudioChunk which is for streaming audio data. + """ + + # Core identifiers + audio_uuid: Indexed(str, unique=True) = Field(description="Unique audio identifier") + audio_path: str = Field(description="Path to raw audio file") + client_id: Indexed(str) = Field(description="Client device identifier") + timestamp: Indexed(int) = Field(description="Unix timestamp in milliseconds") + + # User information + user_id: Indexed(str) = Field(description="User who owns this audio") + user_email: Optional[str] = Field(None, description="User email") + + # Audio processing + cropped_audio_path: Optional[str] = Field(None, description="Path to cropped audio (speech only)") + + # Speech-driven conversation linking + conversation_id: Optional[str] = Field( + None, + description="Link to Conversation if speech was detected" + ) + has_speech: bool = Field(default=False, description="Whether speech was detected") + speech_analysis: Dict[str, Any] = Field( + default_factory=dict, + description="Speech detection results" + ) + + class Settings: + name = "audio_chunks" + indexes = [ + "audio_uuid", + "client_id", + "user_id", + "timestamp" + ] \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/conversation.py b/backends/advanced/src/advanced_omi_backend/models/conversation.py new file mode 100644 index 00000000..864c68e2 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/conversation.py @@ -0,0 +1,324 @@ +""" +Conversation models for Friend-Lite backend. + +This module contains Beanie Document and Pydantic models for conversations, +transcript versions, and memory versions. +""" + +from datetime import datetime +from typing import Dict, List, Optional, Any, Union +from pydantic import BaseModel, Field, model_validator +from enum import Enum + +from beanie import Document, Indexed + + +class Conversation(Document): + """Complete conversation model with versioned processing.""" + + # Nested Enums + class TranscriptProvider(str, Enum): + """Supported transcription providers.""" + DEEPGRAM = "deepgram" + MISTRAL = "mistral" + PARAKEET = "parakeet" + SPEECH_DETECTION = "speech_detection" # Legacy value + UNKNOWN = "unknown" # Fallback value + + class MemoryProvider(str, Enum): + """Supported memory providers.""" + FRIEND_LITE = "friend_lite" + OPENMEMORY_MCP = "openmemory_mcp" + + # Nested Models + class SpeakerSegment(BaseModel): + """Individual speaker segment in a transcript.""" + start: float = Field(description="Start time in seconds") + end: float = Field(description="End time in seconds") + text: str = Field(description="Transcript text for this segment") + speaker: str = Field(description="Speaker identifier") + confidence: Optional[float] = Field(None, description="Confidence score (0-1)") + + class TranscriptVersion(BaseModel): + """Version of a transcript with processing metadata.""" + version_id: str = Field(description="Unique version identifier") + transcript: Optional[str] = Field(None, description="Full transcript text") + segments: List["Conversation.SpeakerSegment"] = Field(default_factory=list, description="Speaker segments") + provider: Optional["Conversation.TranscriptProvider"] = Field(None, description="Transcription provider used") + model: Optional[str] = Field(None, description="Model used (e.g., nova-3, voxtral-mini-2507)") + created_at: datetime = Field(description="When this version was created") + processing_time_seconds: Optional[float] = Field(None, description="Time taken to process") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional provider-specific metadata") + + class MemoryVersion(BaseModel): + """Version of memory extraction with processing metadata.""" + version_id: str = Field(description="Unique version identifier") + memory_count: int = Field(description="Number of memories extracted") + transcript_version_id: str = Field(description="Which transcript version was used") + provider: "Conversation.MemoryProvider" = Field(description="Memory provider used") + model: Optional[str] = Field(None, description="Model used (e.g., gpt-4o-mini, llama3)") + created_at: datetime = Field(description="When this version was created") + processing_time_seconds: Optional[float] = Field(None, description="Time taken to process") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional provider-specific metadata") + + # Core identifiers + conversation_id: Indexed(str, unique=True) = Field(description="Unique conversation identifier") + audio_uuid: Indexed(str) = Field(description="Link to audio_chunks collection") + user_id: Indexed(str) = Field(description="User who owns this conversation") + client_id: Indexed(str) = Field(description="Client device identifier") + + # Creation metadata + created_at: Indexed(datetime) = Field(default_factory=datetime.utcnow, description="When the conversation was created") + + # Summary fields (auto-generated from transcript) + title: Optional[str] = Field(None, description="Auto-generated conversation title") + summary: Optional[str] = Field(None, description="Auto-generated conversation summary") + + # Versioned processing + transcript_versions: List["Conversation.TranscriptVersion"] = Field( + default_factory=list, + description="All transcript processing attempts" + ) + memory_versions: List["Conversation.MemoryVersion"] = Field( + default_factory=list, + description="All memory extraction attempts" + ) + + # Active version pointers + active_transcript_version: Optional[str] = Field( + None, + description="Version ID of currently active transcript" + ) + active_memory_version: Optional[str] = Field( + None, + description="Version ID of currently active memory extraction" + ) + + # Legacy fields (auto-populated from active versions) + transcript: Union[str, List[Dict[str, Any]], None] = Field(None, description="Current transcript text") + segments: List["Conversation.SpeakerSegment"] = Field(default_factory=list, description="Current transcript segments") + memories: List[Dict[str, Any]] = Field(default_factory=list, description="Current extracted memories") + memory_count: int = Field(default=0, description="Current memory count") + + @model_validator(mode='before') + @classmethod + def clean_legacy_data(cls, data: Any) -> Any: + """Clean up legacy/malformed data before Pydantic validation.""" + + #TODO Unsure that we need this, likely best to migrate database on startup, or mimic the old structure better + if not isinstance(data, dict): + return data + + # Fix legacy transcript field if it's a dict (should be string or None) + if isinstance(data.get('transcript'), dict): + data['transcript'] = None + + # Fix legacy segments field if it's a dict (should be list) + if isinstance(data.get('segments'), dict): + data['segments'] = [] + + # Fix malformed transcript_versions + if 'transcript_versions' in data and isinstance(data['transcript_versions'], list): + for version in data['transcript_versions']: + if isinstance(version, dict): + # If segments is not a list, clear it + if 'segments' in version and not isinstance(version['segments'], list): + version['segments'] = [] + # If transcript is a dict, clear it + if 'transcript' in version and isinstance(version['transcript'], dict): + version['transcript'] = None + # Normalize provider to lowercase (legacy data had "Deepgram" instead of "deepgram") + if 'provider' in version and isinstance(version['provider'], str): + version['provider'] = version['provider'].lower() + # Fix speaker IDs in segments (legacy data had integers, need strings) + if 'segments' in version and isinstance(version['segments'], list): + for segment in version['segments']: + if isinstance(segment, dict) and 'speaker' in segment: + if isinstance(segment['speaker'], int): + segment['speaker'] = f"Speaker {segment['speaker']}" + elif not isinstance(segment['speaker'], str): + segment['speaker'] = "unknown" + + # Also fix legacy segments field + if 'segments' in data and isinstance(data['segments'], list): + for segment in data['segments']: + if isinstance(segment, dict) and 'speaker' in segment: + if isinstance(segment['speaker'], int): + segment['speaker'] = f"Speaker {segment['speaker']}" + elif not isinstance(segment['speaker'], str): + segment['speaker'] = "unknown" + + return data + + @property + def active_transcript(self) -> Optional["Conversation.TranscriptVersion"]: + """Get the currently active transcript version.""" + if not self.active_transcript_version: + return None + + for version in self.transcript_versions: + if version.version_id == self.active_transcript_version: + return version + return None + + @property + def active_memory(self) -> Optional["Conversation.MemoryVersion"]: + """Get the currently active memory version.""" + if not self.active_memory_version: + return None + + for version in self.memory_versions: + if version.version_id == self.active_memory_version: + return version + return None + + def add_transcript_version( + self, + version_id: str, + transcript: str, + segments: List["Conversation.SpeakerSegment"], + provider: "Conversation.TranscriptProvider", + model: Optional[str] = None, + processing_time_seconds: Optional[float] = None, + metadata: Optional[Dict[str, Any]] = None, + set_as_active: bool = True + ) -> "Conversation.TranscriptVersion": + """Add a new transcript version and optionally set it as active.""" + new_version = Conversation.TranscriptVersion( + version_id=version_id, + transcript=transcript, + segments=segments, + provider=provider, + model=model, + created_at=datetime.now(), + processing_time_seconds=processing_time_seconds, + metadata=metadata or {} + ) + + self.transcript_versions.append(new_version) + + if set_as_active: + self.active_transcript_version = version_id + self._update_legacy_transcript_fields() + + return new_version + + def add_memory_version( + self, + version_id: str, + memory_count: int, + transcript_version_id: str, + provider: "Conversation.MemoryProvider", + model: Optional[str] = None, + processing_time_seconds: Optional[float] = None, + metadata: Optional[Dict[str, Any]] = None, + set_as_active: bool = True + ) -> "Conversation.MemoryVersion": + """Add a new memory version and optionally set it as active.""" + new_version = Conversation.MemoryVersion( + version_id=version_id, + memory_count=memory_count, + transcript_version_id=transcript_version_id, + provider=provider, + model=model, + created_at=datetime.now(), + processing_time_seconds=processing_time_seconds, + metadata=metadata or {} + ) + + self.memory_versions.append(new_version) + + if set_as_active: + self.active_memory_version = version_id + self._update_legacy_memory_fields(memory_count) + + return new_version + + def set_active_transcript_version(self, version_id: str) -> bool: + """Set a specific transcript version as active.""" + for version in self.transcript_versions: + if version.version_id == version_id: + self.active_transcript_version = version_id + self._update_legacy_transcript_fields() + return True + return False + + def set_active_memory_version(self, version_id: str) -> bool: + """Set a specific memory version as active.""" + for version in self.memory_versions: + if version.version_id == version_id: + self.active_memory_version = version_id + self._update_legacy_memory_fields(version.memory_count) + return True + return False + + def _update_legacy_transcript_fields(self): + """Update legacy transcript fields from active version.""" + active = self.active_transcript + if active: + self.transcript = active.transcript + self.segments = active.segments + else: + self.transcript = None + self.segments = [] + + def _update_legacy_memory_fields(self, memory_count: int): + """Update legacy memory fields from active version.""" + self.memory_count = memory_count + # Note: actual memories list would need to be fetched from memory storage + # This is just the count for now + + class Settings: + name = "conversations" + indexes = [ + "conversation_id", + "user_id", + "created_at", + [("user_id", 1), ("created_at", -1)] # Compound index for user queries + ] + + +# Factory function for creating conversations +def create_conversation( + conversation_id: str, + audio_uuid: str, + user_id: str, + client_id: str, + title: Optional[str] = None, + summary: Optional[str] = None, + transcript: Optional[str] = None, + segments: Optional[List["Conversation.SpeakerSegment"]] = None, +) -> Conversation: + """ + Factory function to create a new conversation. + + Args: + conversation_id: Unique conversation identifier + audio_uuid: Link to audio_chunks collection + user_id: User who owns this conversation + client_id: Client device identifier + title: Optional conversation title + summary: Optional conversation summary + transcript: Optional transcript text + segments: Optional speaker segments + + Returns: + Conversation instance + """ + return Conversation( + conversation_id=conversation_id, + audio_uuid=audio_uuid, + user_id=user_id, + client_id=client_id, + created_at=datetime.now(), + title=title, + summary=summary, + transcript=transcript or "", + segments=segments or [], + transcript_versions=[], + active_transcript_version=None, + memory_versions=[], + active_memory_version=None, + memories=[], + memory_count=0 + ) \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/job.py b/backends/advanced/src/advanced_omi_backend/models/job.py new file mode 100644 index 00000000..545b8a12 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/job.py @@ -0,0 +1,248 @@ +""" +Job models and base classes for RQ queue system. + +This module provides: +- JobPriority enum for job priority levels +- BaseRQJob abstract class for common job setup and teardown +- async_job decorator for simplified job creation +""" + +import asyncio +import logging +import time +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, Optional, Callable +from functools import wraps + +import redis.asyncio as redis_async + +logger = logging.getLogger(__name__) + + +class JobPriority(str, Enum): + """Priority levels for RQ job processing. + + Used to map priority to RQ job timeout values: + - URGENT: 10 minutes timeout + - HIGH: 8 minutes timeout + - NORMAL: 5 minutes timeout (default) + - LOW: 3 minutes timeout + """ + URGENT = "urgent" # 1 - Process immediately + HIGH = "high" # 2 - Process before normal + NORMAL = "normal" # 3 - Default priority + LOW = "low" # 4 - Process when idle + + +class BaseRQJob(ABC): + """ + Base class for RQ job implementations. + + Handles common setup and teardown: + - Event loop management + - Beanie (MongoDB ODM) initialization + - Redis client creation (optional) + - Exception handling and logging + + Subclasses must implement the `execute()` method with job-specific logic. + + Example: + class MyJob(BaseRQJob): + async def execute(self) -> Dict[str, Any]: + # Job-specific async logic here + result = await some_async_operation() + return {"success": True, "result": result} + + # RQ job function wrapper + def my_job_function(arg1, arg2, redis_url=None): + job = MyJob(redis_url=redis_url) + return job.run(arg1=arg1, arg2=arg2) + """ + + def __init__(self, redis_url: Optional[str] = None, initialize_beanie: bool = True): + """ + Initialize base job with common dependencies. + + Args: + redis_url: Redis connection URL (optional, creates client if provided) + initialize_beanie: Whether to initialize Beanie ODM (default True) + """ + self.redis_url = redis_url + self.initialize_beanie = initialize_beanie + self.redis_client: Optional[redis_async.Redis] = None + self.job_start_time = time.time() + + async def _setup(self): + """Setup common dependencies before job execution.""" + # Initialize Beanie for MongoDB access + if self.initialize_beanie: + from advanced_omi_backend.controllers.queue_controller import _ensure_beanie_initialized + await _ensure_beanie_initialized() + logger.debug("Beanie initialized") + + # Create Redis client if URL provided + if self.redis_url: + self.redis_client = redis_async.from_url(self.redis_url) + logger.debug(f"Redis client created: {self.redis_url}") + + async def _teardown(self): + """Cleanup resources after job execution.""" + if self.redis_client: + await self.redis_client.close() + logger.debug("Redis client closed") + + @abstractmethod + async def execute(self, **kwargs) -> Dict[str, Any]: + """ + Execute job-specific logic. + + This method must be implemented by subclasses. + + Args: + **kwargs: Job-specific parameters passed from RQ + + Returns: + Dict with job results + """ + pass + + def run(self, **kwargs) -> Dict[str, Any]: + """ + Run the job with common setup and teardown. + + This method: + 1. Creates a new event loop + 2. Calls _setup() for dependencies + 3. Calls execute() with job-specific logic + 4. Calls _teardown() for cleanup + 5. Handles exceptions and logging + + Args: + **kwargs: Job-specific parameters to pass to execute() + + Returns: + Dict with job results + """ + job_name = self.__class__.__name__ + logger.info(f"πŸš€ Starting {job_name}") + + try: + # Create new event loop for this job + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + async def process(): + await self._setup() + try: + result = await self.execute(**kwargs) + return result + finally: + await self._teardown() + + result = loop.run_until_complete(process()) + + elapsed = time.time() - self.job_start_time + logger.info(f"βœ… {job_name} completed in {elapsed:.2f}s") + return result + + finally: + loop.close() + + except Exception as e: + elapsed = time.time() - self.job_start_time + logger.error(f"❌ {job_name} failed after {elapsed:.2f}s: {e}", exc_info=True) + raise + + +def async_job(redis: bool = True, beanie: bool = True, timeout: int = 300, result_ttl: int = 3600): + """ + Decorator to convert async functions into RQ-compatible job functions. + + Handles common setup/teardown: + - Event loop management + - Beanie (MongoDB ODM) initialization + - Redis client creation (optional) + - Exception handling and logging + - Default job configuration (timeout, result_ttl) + + Args: + redis: If True, creates Redis client and passes as 'redis_client' kwarg + beanie: If True, initializes Beanie ODM (default True) + timeout: Default job timeout in seconds (default 300 = 5 minutes) + result_ttl: Default result TTL in seconds (default 3600 = 1 hour) + + Example: + @async_job(redis=True, beanie=True, timeout=600) + async def my_job(arg1, arg2, redis_client=None): + # Job logic with redis_client available + result = await some_async_operation() + return {"success": True, "result": result} + + # Enqueue with defaults or override + queue.enqueue(my_job, arg1_value, arg2_value) # Uses timeout=600 + queue.enqueue(my_job, arg1_value, arg2_value, job_timeout=1200) # Override + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Dict[str, Any]: + job_name = func.__name__ + start_time = time.time() + logger.info(f"πŸš€ Starting {job_name}") + + redis_client = None + + try: + # Create new event loop for this job + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + async def process(): + nonlocal redis_client + + # Initialize Beanie for MongoDB access + if beanie: + from advanced_omi_backend.controllers.queue_controller import _ensure_beanie_initialized + await _ensure_beanie_initialized() + logger.debug("Beanie initialized") + + # Create Redis client if requested + if redis: + from advanced_omi_backend.controllers.queue_controller import REDIS_URL + redis_client = redis_async.from_url(REDIS_URL) + kwargs['redis_client'] = redis_client + logger.debug(f"Redis client created") + + try: + # Call the actual job function + result = await func(*args, **kwargs) + return result + finally: + # Cleanup Redis client + if redis_client: + await redis_client.close() + logger.debug("Redis client closed") + + result = loop.run_until_complete(process()) + + elapsed = time.time() - start_time + logger.info(f"βœ… {job_name} completed in {elapsed:.2f}s") + return result + + finally: + loop.close() + + except Exception as e: + elapsed = time.time() - start_time + logger.error(f"❌ {job_name} failed after {elapsed:.2f}s: {e}", exc_info=True) + raise + + # Store default job configuration as attributes for RQ introspection + wrapper.job_timeout = timeout + wrapper.result_ttl = result_ttl + + return wrapper + return decorator \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/transcription.py b/backends/advanced/src/advanced_omi_backend/models/transcription.py new file mode 100644 index 00000000..13893a68 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/transcription.py @@ -0,0 +1,134 @@ +""" +Transcription provider abstract base classes. + +This module defines the interfaces for transcription providers. +All concrete provider implementations should inherit from these base classes. + +Provider Output Formats: +----------------------- +All providers return a standardized dictionary with the following structure: +{ + "text": str, # Full transcript text + "words": List[dict], # Word-level data (if available) + "segments": List[dict] # Speaker segments (if available) +} + +Word object format (when available): +{ + "word": str, # The word text + "start": float, # Start time in seconds + "end": float, # End time in seconds + "confidence": float, # Confidence score (0-1) + "speaker": int # Speaker ID (optional) +} + +Provider-specific behaviors: +- Deepgram: Returns rich word-level timestamps with confidence scores +- NeMo Parakeet: Returns word-level timestamps (streaming and batch modes) +""" + +import abc +from enum import Enum +from typing import Optional + + +class TranscriptionProvider(Enum): + """Available transcription providers for audio stream routing.""" + DEEPGRAM = "deepgram" + PARAKEET = "parakeet" + MISTRAL = "mistral" + + +class BaseTranscriptionProvider(abc.ABC): + """Abstract base class for all transcription providers.""" + + @abc.abstractmethod + async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: + """ + Transcribe audio data to text with word-level timestamps. + + Args: + audio_data: Raw audio bytes (PCM format) + sample_rate: Audio sample rate (Hz) + **kwargs: Additional parameters (e.g. diarize=True for speaker diarization) + + Returns: + Dictionary containing: + - text: Transcribed text string + - words: List of word-level data with timestamps (required) + - segments: List of speaker segments (empty for non-RTTM providers) + """ + pass + + @property + @abc.abstractmethod + def name(self) -> str: + """Return the provider name for logging.""" + pass + + @property + @abc.abstractmethod + def mode(self) -> str: + """Return 'streaming' or 'batch' for processing mode.""" + pass + + async def connect(self, client_id: Optional[str] = None): + """Initialize/connect the provider. Default implementation does nothing.""" + pass + + async def disconnect(self): + """Cleanup/disconnect the provider. Default implementation does nothing.""" + pass + + +class StreamingTranscriptionProvider(BaseTranscriptionProvider): + """Base class for streaming transcription providers.""" + + @property + def mode(self) -> str: + return "streaming" + + @abc.abstractmethod + async def start_stream(self, client_id: str, sample_rate: int = 16000, diarize: bool = False): + """Start a transcription stream for a client. + + Args: + client_id: Unique client identifier + sample_rate: Audio sample rate + diarize: Whether to enable speaker diarization (provider-dependent) + """ + pass + + @abc.abstractmethod + async def process_audio_chunk(self, client_id: str, audio_chunk: bytes) -> Optional[dict]: + """ + Process audio chunk and return partial/final transcription. + + Returns: + None for partial results, dict with transcription for final results + """ + pass + + @abc.abstractmethod + async def end_stream(self, client_id: str) -> dict: + """End stream and return final transcription with word-level timestamps.""" + pass + + +class BatchTranscriptionProvider(BaseTranscriptionProvider): + """Base class for batch transcription providers.""" + + @property + def mode(self) -> str: + return "batch" + + @abc.abstractmethod + async def transcribe(self, audio_data: bytes, sample_rate: int, diarize: bool = False) -> dict: + """Transcribe audio data. + + Args: + audio_data: Raw audio bytes + sample_rate: Audio sample rate + diarize: Whether to enable speaker diarization (provider-dependent) + """ + pass diff --git a/backends/advanced/src/advanced_omi_backend/models/user.py b/backends/advanced/src/advanced_omi_backend/models/user.py new file mode 100644 index 00000000..a3779021 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/user.py @@ -0,0 +1,133 @@ +"""User models for fastapi-users integration with Beanie and MongoDB.""" + +import logging +from datetime import UTC, datetime +from typing import Optional + +from beanie import Document, PydanticObjectId +from fastapi_users.db import BeanieBaseUser, BeanieUserDatabase +from fastapi_users.schemas import BaseUser, BaseUserCreate, BaseUserUpdate +from pydantic import ConfigDict, EmailStr, Field + +logger = logging.getLogger(__name__) + + +class UserCreate(BaseUserCreate): + """Schema for creating new users.""" + + display_name: Optional[str] = None + is_superuser: Optional[bool] = False + + +class UserRead(BaseUser[PydanticObjectId]): + """Schema for reading user data.""" + + display_name: Optional[str] = None + registered_clients: dict[str, dict] = Field(default_factory=dict) + primary_speakers: list[dict] = Field(default_factory=list) + + +class UserUpdate(BaseUserUpdate): + """Schema for updating user data.""" + + display_name: Optional[str] = None + is_superuser: Optional[bool] = None + + def create_update_dict_superuser(self): + """Create update dictionary for superuser operations.""" + update_dict = {} + if self.email is not None: + update_dict["email"] = self.email + if self.password is not None: + update_dict["password"] = self.password + if self.is_active is not None: + update_dict["is_active"] = self.is_active + if self.is_verified is not None: + update_dict["is_verified"] = self.is_verified + if self.is_superuser is not None: + update_dict["is_superuser"] = self.is_superuser + if self.display_name is not None: + update_dict["display_name"] = self.display_name + return update_dict + + +class User(BeanieBaseUser, Document): + """User model extending fastapi-users BeanieBaseUser with custom fields.""" + + # Pydantic v2 configuration + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + ) + + display_name: Optional[str] = None + # Client tracking for audio devices + registered_clients: dict[str, dict] = Field(default_factory=dict) + # Speaker processing filter configuration + primary_speakers: list[dict] = Field(default_factory=list) + + class Settings: + name = "users" # Collection name in MongoDB - standardized from "fastapi_users" + email_collation = {"locale": "en", "strength": 2} # Case-insensitive comparison + + @property + def user_id(self) -> str: + """Return string representation of MongoDB ObjectId for backward compatibility.""" + return str(self.id) + + def register_client(self, client_id: str, device_name: Optional[str] = None) -> None: + """Register a new client for this user.""" + # Check if client already exists + if client_id in self.registered_clients: + # Update existing client + logger.info(f"Updating existing client {client_id} for user {self.user_id}") + self.registered_clients[client_id]["last_seen"] = datetime.now(UTC) + self.registered_clients[client_id]["device_name"] = ( + device_name or self.registered_clients[client_id].get("device_name") + ) + return + + # Add new client + self.registered_clients[client_id] = { + "client_id": client_id, + "device_name": device_name, + "first_seen": datetime.now(UTC), + "last_seen": datetime.now(UTC), + "is_active": True, + } + + def get_client_ids(self) -> list[str]: + """Get all client IDs registered to this user.""" + return list(self.registered_clients.keys()) + + +# Rebuild Pydantic model to ensure inherited fields are properly accessible +User.model_rebuild() + + +async def get_user_db(): + """Get the user database instance for dependency injection.""" + yield BeanieUserDatabase(User) # type: ignore + + +async def get_user_by_id(user_id: str) -> Optional[User]: + """Get user by MongoDB ObjectId string.""" + try: + return await User.get(PydanticObjectId(user_id)) + except Exception as e: + logger.error(f"Failed to get user by ID {user_id}: {e}") + # Re-raise for proper error handling upstream + raise + + +async def get_user_by_client_id(client_id: str) -> Optional[User]: + """Find the user that owns a specific client_id.""" + return await User.find_one({"registered_clients.client_id": client_id}) + + +async def register_client_to_user( + user: User, client_id: str, device_name: Optional[str] = None +) -> None: + """Register a client to a user and save to database.""" + user.register_client(client_id, device_name) + await user.save() diff --git a/backends/advanced/src/advanced_omi_backend/processors.py b/backends/advanced/src/advanced_omi_backend/processors.py deleted file mode 100644 index e4c077ca..00000000 --- a/backends/advanced/src/advanced_omi_backend/processors.py +++ /dev/null @@ -1,1239 +0,0 @@ -"""Application-level processors for audio, transcription, memory, and cropping. - -This module implements global processing queues and processors that handle -all processing tasks at the application level, decoupled from individual -client connections. -""" - -import asyncio -import logging -import time -import uuid -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path - -# Import TranscriptionManager for type hints -from typing import TYPE_CHECKING, Any, Optional - -from advanced_omi_backend.audio_utils import ( - _process_audio_cropping_with_relative_timestamps, -) -from advanced_omi_backend.client_manager import get_client_manager -from advanced_omi_backend.database import ( - AudioChunksRepository, - ConversationsRepository, - conversations_col, -) -from advanced_omi_backend.memory import get_memory_service -from advanced_omi_backend.task_manager import get_task_manager -from advanced_omi_backend.users import get_user_by_id -from easy_audio_interfaces.filesystem.filesystem_interfaces import LocalFileSink -from wyoming.audio import AudioChunk - -# Lazy import to avoid config loading issues -# from advanced_omi_backend.memory import get_memory_service - -if TYPE_CHECKING: - from advanced_omi_backend.transcription import TranscriptionManager - -logger = logging.getLogger(__name__) -audio_logger = logging.getLogger("audio_processing") - -# Audio configuration constants -OMI_SAMPLE_RATE = 16_000 -OMI_CHANNELS = 1 -OMI_SAMPLE_WIDTH = 2 -SEGMENT_SECONDS = 60 -TARGET_SAMPLES = OMI_SAMPLE_RATE * SEGMENT_SECONDS - -if TYPE_CHECKING: - from advanced_omi_backend.transcription import TranscriptionManager - - -@dataclass -class AudioProcessingItem: - """Item for audio processing queue.""" - - client_id: str - user_id: str - user_email: str - audio_chunk: AudioChunk - audio_uuid: Optional[str] = None - timestamp: Optional[int] = None - - -@dataclass -class TranscriptionItem: - """Item for transcription processing queue.""" - - client_id: str - user_id: str - audio_uuid: str - audio_chunk: AudioChunk - - -@dataclass -class MemoryProcessingItem: - """Item for memory processing queue (speech-driven conversations architecture).""" - - client_id: str - user_id: str - user_email: str - conversation_id: str - - -@dataclass -class AudioCroppingItem: - """Item for audio cropping queue.""" - - client_id: str - user_id: str - audio_uuid: str - original_path: str - speech_segments: list[tuple[float, float]] - output_path: str - - -class ProcessorManager: - """Manages all application-level processors and queues.""" - - def __init__(self, chunk_dir: Path, audio_chunks_repository: AudioChunksRepository): - self.chunk_dir = chunk_dir - self.repository = audio_chunks_repository - - # Global processing queues - self.audio_queue: asyncio.Queue[Optional[AudioProcessingItem]] = asyncio.Queue() - self.transcription_queue: asyncio.Queue[Optional[TranscriptionItem]] = asyncio.Queue() - self.memory_queue: asyncio.Queue[Optional[MemoryProcessingItem]] = asyncio.Queue() - self.cropping_queue: asyncio.Queue[Optional[AudioCroppingItem]] = asyncio.Queue() - - # Processor tasks - self.audio_processor_task: Optional[asyncio.Task] = None - self.transcription_processor_task: Optional[asyncio.Task] = None - self.memory_processor_task: Optional[asyncio.Task] = None - self.cropping_processor_task: Optional[asyncio.Task] = None - - # Services - lazy import - self.memory_service = None - self.task_manager = get_task_manager() - self.client_manager = get_client_manager() - - # Track active file sinks per client - self.active_file_sinks: dict[str, LocalFileSink] = {} - self.active_audio_uuids: dict[str, str] = {} - - # Transcription managers pool - self.transcription_managers: dict[str, "TranscriptionManager"] = {} - - # Shutdown flag - self.shutdown_flag = False - - # Task tracking for specific processing jobs - self.processing_tasks: dict[str, dict[str, str]] = {} # client_id -> {stage: task_id} - - # Direct state tracking for synchronous operations - self.processing_state: dict[str, dict[str, Any]] = {} # client_id -> {stage: state_info} - - # Track clients currently being closed to prevent duplicate close operations - self.closing_clients: set[str] = set() - - async def _update_memory_status(self, conversation_id: str, status: str): - """Update memory processing status for conversation.""" - try: - conversations_repo = ConversationsRepository(conversations_col) - await conversations_repo.update_memory_processing_status(conversation_id, status) - - audio_logger.info(f"πŸ“ Updated memory status to {status} for conversation {conversation_id}") - except Exception as e: - audio_logger.error(f"Failed to update memory status to {status} for conversation {conversation_id}: {e}") - - async def start(self): - """Start all processors.""" - # Create processor tasks - self.audio_processor_task = asyncio.create_task( - self._audio_processor(), name="audio_processor" - ) - self.transcription_processor_task = asyncio.create_task( - self._transcription_processor(), name="transcription_processor" - ) - self.memory_processor_task = asyncio.create_task( - self._memory_processor(), name="memory_processor" - ) - self.cropping_processor_task = asyncio.create_task( - self._cropping_processor(), name="cropping_processor" - ) - - # Track processor tasks in task manager - self.task_manager.track_task( - self.audio_processor_task, "audio_processor", {"type": "processor"} - ) - self.task_manager.track_task( - self.transcription_processor_task, "transcription_processor", {"type": "processor"} - ) - self.task_manager.track_task( - self.memory_processor_task, "memory_processor", {"type": "processor"} - ) - self.task_manager.track_task( - self.cropping_processor_task, "cropping_processor", {"type": "processor"} - ) - - async def _should_process_memory(self, user_id: str, conversation_id: str) -> tuple[bool, str]: - """ - Determine if memory processing should proceed based on primary speakers configuration. - - Implements graceful degradation: - - No primary speakers configured β†’ Process all (True) - - Speaker service unavailable β†’ Process all (True) - - No speakers identified β†’ Process all (True) - - Primary speakers found β†’ Process (True) - - Only non-primary speakers β†’ Skip (False) - - Args: - user_id: User ID to check primary speakers configuration - conversation_id: Conversation ID to check transcript speakers - - Returns: - Tuple of (should_process: bool, reason: str) - """ - try: - # Get user's primary speaker configuration - user = await get_user_by_id(user_id) - if not user or not user.primary_speakers: - return True, "No primary speakers configured - processing all conversations" - - audio_logger.info(f"πŸ” Checking primary speakers filter for conversation {conversation_id} - user has {len(user.primary_speakers)} primary speakers configured") - - # Get conversation data from conversations collection - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation or not conversation.get('transcript'): - return True, "No transcript data available - processing conversation" - - # Extract speakers from transcript segments (normalized for comparison) - transcript_speakers = set() - transcript_speaker_originals = {} # Keep original names for logging - total_segments = 0 - identified_segments = 0 - - for segment in conversation['transcript']: - total_segments += 1 - if 'identified_as' in segment and segment['identified_as'] and segment['identified_as'] != 'Unknown': - original_name = segment['identified_as'] - normalized_name = original_name.strip().lower() - transcript_speakers.add(normalized_name) - transcript_speaker_originals[normalized_name] = original_name - identified_segments += 1 - - if not transcript_speakers: - return True, f"No speakers identified in transcript ({identified_segments}/{total_segments} segments) - processing conversation" - - # Check if any primary speakers are present (normalized comparison) - primary_speaker_names = {ps['name'].strip().lower() for ps in user.primary_speakers} - primary_speaker_originals = {ps['name'].strip().lower(): ps['name'] for ps in user.primary_speakers} - found_primary_speakers_normalized = transcript_speakers.intersection(primary_speaker_names) - - if found_primary_speakers_normalized: - # Convert back to original names for display - found_primary_originals = [primary_speaker_originals[name] for name in found_primary_speakers_normalized] - audio_logger.info(f"βœ… Primary speakers found in conversation: {found_primary_originals} - processing memory") - return True, f"Primary speakers detected: {', '.join(found_primary_originals)}" - else: - # Show original names in logs - transcript_originals = [transcript_speaker_originals[name] for name in transcript_speakers] - primary_originals = [primary_speaker_originals[name] for name in primary_speaker_names] - audio_logger.info(f"❌ No primary speakers found - transcript speakers: {transcript_originals}, primary speakers: {primary_originals} - skipping memory processing") - return False, f"Only non-primary speakers found: {', '.join(transcript_originals)}" - - except Exception as e: - # On any error, default to processing (fail-safe) - audio_logger.warning(f"Error checking primary speakers filter for {conversation_id}: {e} - defaulting to process conversation") - return True, f"Error in speaker filtering: {str(e)} - processing conversation as fallback" - - async def shutdown(self): - """Shutdown all processors gracefully.""" - logger.info("Shutting down processors...") - self.shutdown_flag = True - - # Signal all queues to stop - await self.audio_queue.put(None) - await self.transcription_queue.put(None) - await self.memory_queue.put(None) - await self.cropping_queue.put(None) - - # Wait for processors to complete with timeout - tasks = [ - ("audio_processor", self.audio_processor_task, 30.0), - ("transcription_processor", self.transcription_processor_task, 60.0), - ("memory_processor", self.memory_processor_task, 300.0), # 5 minutes for LLM - ("cropping_processor", self.cropping_processor_task, 60.0), - ] - - for name, task, timeout in tasks: - if task: - try: - await asyncio.wait_for(task, timeout=timeout) - logger.info(f"{name} shut down gracefully") - except asyncio.TimeoutError: - logger.warning(f"{name} did not shut down within {timeout}s, cancelling") - task.cancel() - try: - await task - except asyncio.CancelledError: - logger.info(f"{name} cancelled successfully") - - # Clean up transcription managers - for manager in self.transcription_managers.values(): - try: - await manager.disconnect() - except Exception as e: - logger.error(f"Error disconnecting transcription manager: {e}") - - # Close any remaining file sinks - for sink in self.active_file_sinks.values(): - try: - await sink.close() - except Exception as e: - logger.error(f"Error closing file sink: {e}") - - logger.info("All processors shut down") - - def _new_local_file_sink( - self, file_path: str, sample_rate: Optional[int] = None - ) -> LocalFileSink: - """Create a properly configured LocalFileSink with dynamic sample rate.""" - effective_sample_rate = sample_rate or OMI_SAMPLE_RATE - return LocalFileSink( - file_path=file_path, - sample_rate=int(effective_sample_rate), - channels=int(OMI_CHANNELS), - sample_width=int(OMI_SAMPLE_WIDTH), - ) - - async def queue_audio(self, item: AudioProcessingItem): - """Queue audio for processing.""" - audio_logger.debug( - f"πŸ“₯ queue_audio called for client {item.client_id}, audio chunk: {len(item.audio_chunk.audio)} bytes" - ) - await self.audio_queue.put(item) - queue_size = self.audio_queue.qsize() - audio_logger.debug( - f"βœ… Successfully queued audio for client {item.client_id}, queue size: {queue_size}" - ) - - async def queue_transcription(self, item: TranscriptionItem): - """Queue audio for transcription.""" - audio_logger.debug( - f"πŸ“₯ queue_transcription called for client {item.client_id}, audio_uuid: {item.audio_uuid}" - ) - await self.transcription_queue.put(item) - audio_logger.debug( - f"πŸ“€ Successfully put item in transcription_queue for client {item.client_id}, queue size: {self.transcription_queue.qsize()}" - ) - - async def queue_memory(self, item: MemoryProcessingItem): - """Queue conversation for memory processing.""" - audio_logger.info( - f"πŸ“₯ queue_memory called for conversation {item.conversation_id} (client {item.client_id})" - ) - audio_logger.info(f"πŸ“₯ Memory queue size before: {self.memory_queue.qsize()}") - await self.memory_queue.put(item) - audio_logger.info(f"πŸ“₯ Memory queue size after: {self.memory_queue.qsize()}") - audio_logger.info(f"βœ… Successfully queued memory processing item for conversation {item.conversation_id}") - - async def queue_cropping(self, item: AudioCroppingItem): - """Queue audio for cropping.""" - await self.cropping_queue.put(item) - - def track_processing_task( - self, client_id: str, stage: str, task_id: str, metadata: dict[str, Any] | None = None - ): - """Track a processing task for a specific client and stage.""" - if client_id not in self.processing_tasks: - self.processing_tasks[client_id] = {} - self.processing_tasks[client_id][stage] = task_id - logger.info(f"Tracking task {task_id} for client {client_id} stage {stage}") - - def track_processing_stage( - self, client_id: str, stage: str, status: str, metadata: dict[str, Any] | None = None - ): - """Track processing stage completion directly for synchronous operations.""" - if client_id not in self.processing_state: - self.processing_state[client_id] = {} - - self.processing_state[client_id][stage] = { - "status": status, # "started", "completed", "failed" - "completed": status == "completed", - "error": None if status != "failed" else metadata.get("error") if metadata else None, - "metadata": metadata or {}, - "timestamp": time.time(), - } - logger.info(f"Tracking stage {stage} as {status} for client {client_id}") - - def get_processing_status(self, client_id: str) -> dict[str, Any]: - """Get processing status for a specific client using both direct state and task tracking.""" - logger.debug(f"Getting processing status for client {client_id}") - logger.debug( - f"Available client_ids in processing_tasks: {list(self.processing_tasks.keys())}" - ) - logger.debug( - f"Available client_ids in processing_state: {list(self.processing_state.keys())}" - ) - - stages = {} - - # First, get task tracking (for asynchronous operations like memory/cropping) - if client_id in self.processing_tasks: - client_tasks = self.processing_tasks[client_id] - for stage, task_id in client_tasks.items(): - logger.info(f"Looking up task {task_id} for stage {stage}") - task_info = self.task_manager.get_task_info(task_id) - logger.info(f"Task info for {task_id}: {task_info}") - if task_info: - stages[stage] = { - "task_id": task_id, - "completed": task_info.completed_at is not None, - "error": task_info.error, - "created_at": task_info.created_at, - "completed_at": task_info.completed_at, - "cancelled": task_info.cancelled, - } - else: - stages[stage] = { - "task_id": task_id, - "completed": False, - "error": "Task not found", - "created_at": None, - "completed_at": None, - "cancelled": False, - } - - # Then, get direct state tracking (for synchronous operations like audio, transcription) - # Direct state takes PRECEDENCE over task tracking for the same stage - if client_id in self.processing_state: - client_state = self.processing_state[client_id] - for stage, state_info in client_state.items(): - stages[stage] = { - "completed": state_info["completed"], - "error": state_info["error"], - "status": state_info["status"], - "metadata": state_info["metadata"], - "timestamp": state_info["timestamp"], - } - logger.debug(f"Direct state - {stage}: {state_info['status']} (takes precedence)") - - # If no stages found, return no_tasks - if not stages: - return {"status": "no_tasks", "stages": {}} - - # Check if all stages are complete - all_complete = all(stage_info["completed"] for stage_info in stages.values()) - - return { - "status": "complete" if all_complete else "processing", - "stages": stages, - "client_id": client_id, - } - - def cleanup_processing_tasks(self, client_id: str): - """Clean up processing task tracking for a client.""" - if client_id in self.processing_tasks: - del self.processing_tasks[client_id] - logger.debug(f"Cleaned up processing tasks for client {client_id}") - - if client_id in self.processing_state: - del self.processing_state[client_id] - logger.debug(f"Cleaned up processing state for client {client_id}") - - def get_all_processing_status(self) -> dict[str, Any]: - """Get processing status for all clients.""" - # Get all client IDs from both tracking types - all_client_ids = set(self.processing_tasks.keys()) | set(self.processing_state.keys()) - return {client_id: self.get_processing_status(client_id) for client_id in all_client_ids} - - async def mark_transcription_failed(self, client_id: str, error: str): - """Mark transcription as failed and clean up transcription manager. - - This method handles transcription failures without closing audio files, - allowing long recordings to continue even if intermediate transcriptions fail. - - Args: - client_id: The client ID whose transcription failed - error: The error message describing the failure - """ - # Mark as failed in state tracking - self.track_processing_stage(client_id, "transcription", "failed", {"error": error}) - - # Remove transcription manager to allow fresh retry - if client_id in self.transcription_managers: - try: - manager = self.transcription_managers.pop(client_id) - await manager.disconnect() - audio_logger.info(f"🧹 Removed failed transcription manager for {client_id}") - except Exception as cleanup_error: - audio_logger.error( - f"❌ Error cleaning up transcription manager for {client_id}: {cleanup_error}" - ) - - # Do NOT close audio files - client may still be streaming - # Audio will be closed when client disconnects or sends audio-stop - audio_logger.warning( - f"❌ Transcription failed for {client_id}: {error}, keeping audio session open" - ) - - async def close_client_audio(self, client_id: str): - """Close audio file for a client when conversation ends.""" - audio_logger.info(f"πŸ”š close_client_audio called for client {client_id}") - - # Check if already closing to prevent duplicate operations - if client_id in self.closing_clients: - audio_logger.info(f"⏭️ Client {client_id} already being closed, skipping duplicate close") - return - - # Mark as being closed - self.closing_clients.add(client_id) - - # First, flush ASR to complete any pending transcription - if client_id in self.transcription_managers: - try: - manager = self.transcription_managers[client_id] - audio_logger.info( - f"πŸ”„ Found transcription manager - flushing ASR for client {client_id}" - ) - audio_logger.info( - f"πŸ“Š Transcription manager state - has manager: {manager is not None}, type: {type(manager).__name__}" - ) - - flush_start_time = time.time() - audio_logger.info( - f"πŸ“€ Calling flush_final_transcript for client {client_id} (manager: {manager})" - ) - try: - await manager.process_collected_audio() - flush_duration = time.time() - flush_start_time - audio_logger.info( - f"βœ… ASR flush completed for client {client_id} in {flush_duration:.2f}s" - ) - # Mark transcription as completed after successful flush - self.track_processing_stage( - client_id, "transcription", "completed", {"flushed": True} - ) - except Exception as flush_error: - audio_logger.error( - f"❌ Error during flush_final_transcript: {flush_error}", exc_info=True - ) - # Mark transcription as failed on flush error - self.track_processing_stage( - client_id, "transcription", "failed", {"error": str(flush_error)} - ) - raise - - # Verify that transcription was marked as completed after flush - current_status = self.get_processing_status(client_id) - transcription_stage = current_status.get("stages", {}).get("transcription", {}) - audio_logger.info( - f"πŸ” Post-flush transcription status: {transcription_stage.get('status', 'unknown')} (completed: {transcription_stage.get('completed', False)})" - ) - except Exception as e: - audio_logger.error( - f"❌ Error flushing ASR for client {client_id}: {e}", exc_info=True - ) - else: - audio_logger.warning( - f"⚠️ No transcription manager found for client {client_id} - cannot flush transcription" - ) - - # Then close the audio file - if client_id in self.active_file_sinks: - try: - sink = self.active_file_sinks[client_id] - await sink.close() - del self.active_file_sinks[client_id] - - if client_id in self.active_audio_uuids: - del self.active_audio_uuids[client_id] - - audio_logger.info(f"Closed audio file for client {client_id}") - except Exception as e: - audio_logger.error(f"Error closing audio file for client {client_id}: {e}") - - # Remove from closing set now that we're done - self.closing_clients.discard(client_id) - audio_logger.info(f"βœ… Completed close_client_audio for client {client_id}") - - async def ensure_transcription_manager(self, client_id: str): - """Ensure a transcription manager exists for the given client. - - This can be called early (e.g., on audio-start) to create the manager - before audio chunks arrive. - """ - from advanced_omi_backend.transcription import TranscriptionManager - if client_id not in self.transcription_managers: - audio_logger.info( - f"πŸ”Œ Creating transcription manager for client {client_id} (early creation)" - ) - manager = TranscriptionManager( - chunk_repo=self.repository, processor_manager=self - ) - try: - await manager.connect(client_id) - self.transcription_managers[client_id] = manager - audio_logger.info( - f"βœ… Successfully created transcription manager for {client_id}" - ) - except Exception as e: - audio_logger.error( - f"❌ Failed to create transcription manager for {client_id}: {e}" - ) - raise - else: - audio_logger.debug( - f"♻️ Transcription manager already exists for client {client_id}" - ) - - async def _audio_processor(self): - """Process audio chunks and save to files.""" - audio_logger.info("Audio processor started") - - try: - while not self.shutdown_flag: - try: - # Get item with timeout to allow periodic health checks - queue_size = self.audio_queue.qsize() - if queue_size > 0: - audio_logger.debug( - f"πŸ”„ Audio processor waiting for items, queue size: {queue_size}" - ) - item = await asyncio.wait_for(self.audio_queue.get(), timeout=30.0) - - audio_logger.debug( - f"πŸ“¦ Audio processor dequeued item for client {item.client_id if item else 'None'}" - ) - - if item is None: # Shutdown signal - audio_logger.info("πŸ›‘ Audio processor received shutdown signal") - self.audio_queue.task_done() - break - - try: - # Get or create file sink for this client - if item.client_id not in self.active_file_sinks: - audio_logger.debug( - f"πŸ†• Creating new audio file sink for client {item.client_id}" - ) - # Get client state to access/store sample rate - client_state = self.client_manager.get_client(item.client_id) - audio_logger.debug( - f"πŸ‘€ Client state lookup for {item.client_id}: {client_state is not None}" - ) - - # Store sample rate from first audio chunk - if client_state and client_state.sample_rate is None: - client_state.sample_rate = item.audio_chunk.rate - audio_logger.info( - f"πŸ“Š Set sample rate to {client_state.sample_rate}Hz for client {item.client_id}" - ) - - # Get sample rate for file sink (use client state or fallback to chunk rate) - file_sample_rate = None - if client_state and client_state.sample_rate: - file_sample_rate = client_state.sample_rate - else: - file_sample_rate = item.audio_chunk.rate - audio_logger.warning( - f"Using chunk sample rate {file_sample_rate}Hz for {item.client_id} (no client state)" - ) - - # Create new file - audio_uuid = uuid.uuid4().hex - timestamp = item.timestamp or int(time.time()) - wav_filename = f"{timestamp}_{item.client_id}_{audio_uuid}.wav" - - sink = self._new_local_file_sink( - f"{self.chunk_dir}/{wav_filename}", file_sample_rate - ) - await sink.open() - - self.active_file_sinks[item.client_id] = sink - self.active_audio_uuids[item.client_id] = audio_uuid - - # Create database entry - await self.repository.create_chunk( - audio_uuid=audio_uuid, - audio_path=wav_filename, - client_id=item.client_id, - timestamp=timestamp, - user_id=item.user_id, - user_email=item.user_email, - ) - - # Notify client state about new audio UUID - if client_state: - client_state.set_current_audio_uuid(audio_uuid) - - # Track audio processing completion directly (synchronous operation) - self.track_processing_stage( - item.client_id, - "audio", - "completed", - { - "audio_uuid": audio_uuid, - "wav_filename": wav_filename, - "file_created": True, - }, - ) - - audio_logger.info( - f"Created new audio file for client {item.client_id}: {wav_filename}" - ) - - # Write audio chunk - sink = self.active_file_sinks[item.client_id] - await sink.write(item.audio_chunk) - - # Queue for transcription - audio_uuid = self.active_audio_uuids[item.client_id] - audio_logger.debug( - f"πŸ”„ About to queue transcription for client {item.client_id}, audio_uuid: {audio_uuid}" - ) - await self.queue_transcription( - TranscriptionItem( - client_id=item.client_id, - user_id=item.user_id, - audio_uuid=audio_uuid, - audio_chunk=item.audio_chunk, - ) - ) - audio_logger.debug( - f"βœ… Successfully queued transcription for client {item.client_id}, audio_uuid: {audio_uuid}" - ) - - except Exception as e: - audio_logger.error( - f"Error processing audio for client {item.client_id}: {e}", - exc_info=True, - ) - finally: - self.audio_queue.task_done() - audio_logger.debug( - f"βœ… Completed processing audio item for client {item.client_id if item else 'None'}" - ) - - except asyncio.TimeoutError: - # Periodic health check - active_clients = len(self.active_file_sinks) - queue_size = self.audio_queue.qsize() - if queue_size > 0 or active_clients > 0: - audio_logger.info( - f"⏰ Audio processor timeout (periodic health check): {active_clients} active files, " - f"{queue_size} items in queue" - ) - - except Exception as e: - audio_logger.error(f"Fatal error in audio processor: {e}", exc_info=True) - finally: - audio_logger.info("Audio processor stopped") - - async def _transcription_processor(self): - """Process transcription requests.""" - audio_logger.info("Transcription processor started") - from advanced_omi_backend.transcription import TranscriptionManager - - try: - while not self.shutdown_flag: - try: - item = await asyncio.wait_for(self.transcription_queue.get(), timeout=30.0) - - if item is None: # Shutdown signal - self.transcription_queue.task_done() - break - - try: - # Get or create transcription manager for client - if item.client_id not in self.transcription_managers: - # Import here to avoid circular imports - - audio_logger.info( - f"πŸ”Œ Creating new transcription manager for client {item.client_id}" - ) - manager = TranscriptionManager( - chunk_repo=self.repository, processor_manager=self - ) - try: - await manager.connect(item.client_id) - self.transcription_managers[item.client_id] = manager - audio_logger.info( - f"βœ… Successfully created transcription manager for {item.client_id}" - ) - except Exception as e: - audio_logger.error( - f"❌ Failed to create transcription manager for {item.client_id}: {e}" - ) - # Mark transcription as failed when manager creation fails - self.track_processing_stage( - item.client_id, "transcription", "failed", {"error": str(e)} - ) - self.transcription_queue.task_done() - continue - else: - audio_logger.debug( - f"♻️ Reusing existing transcription manager for client {item.client_id}" - ) - - manager = self.transcription_managers[item.client_id] - - # Process transcription chunk - audio_logger.debug( - f"🎡 Processing transcribe_chunk for client {item.client_id}, audio_uuid: {item.audio_uuid}" - ) - - try: - # Add timeout for transcription processing (5 minutes) - async with asyncio.timeout(300): # 5 minute timeout - await manager.transcribe_chunk( - item.audio_uuid, item.audio_chunk, item.client_id - ) - audio_logger.debug( - f"βœ… Completed transcribe_chunk for client {item.client_id}" - ) - except asyncio.TimeoutError: - audio_logger.error( - f"❌ Transcription timeout for client {item.client_id} after 5 minutes" - ) - # Mark transcription as failed on timeout - self.track_processing_stage( - item.client_id, - "transcription", - "failed", - {"error": "Transcription timeout (5 minutes)"}, - ) - except Exception as e: - audio_logger.error( - f"❌ Error in transcribe_chunk for client {item.client_id}: {e}", - exc_info=True, - ) - # Mark transcription as failed when chunk processing fails - self.track_processing_stage( - item.client_id, "transcription", "failed", {"error": str(e)} - ) - - # Track transcription as started using direct state tracking - ONLY ONCE per audio session - # Check if we haven't already marked this transcription as started for this audio UUID - current_transcription_status = self.processing_state.get( - item.client_id, {} - ).get("transcription", {}) - current_audio_uuid = current_transcription_status.get("metadata", {}).get( - "audio_uuid" - ) - - # Only mark as started if this is a new audio UUID or no transcription status exists - if current_audio_uuid != item.audio_uuid: - audio_logger.info( - f"🎯 Starting transcription tracking for new audio UUID: {item.audio_uuid}" - ) - self.track_processing_stage( - item.client_id, - "transcription", - "started", - {"audio_uuid": item.audio_uuid, "chunk_processing": True}, - ) - else: - audio_logger.debug( - f"⏩ Skipping transcription status update - already tracking audio UUID: {item.audio_uuid}" - ) - - except Exception as e: - audio_logger.error( - f"Error processing transcription for client {item.client_id}: {e}", - exc_info=True, - ) - finally: - self.transcription_queue.task_done() - - except asyncio.TimeoutError: - # Periodic health check only (NO cleanup based on client active status) - queue_size = self.transcription_queue.qsize() - active_managers = len(self.transcription_managers) - audio_logger.debug( - f"Transcription processor health: {active_managers} managers, " - f"{queue_size} items in queue" - ) - - except Exception as e: - audio_logger.error(f"Fatal error in transcription processor: {e}", exc_info=True) - finally: - audio_logger.info("Transcription processor stopped") - - async def _memory_processor(self): - """Process memory/LLM requests.""" - audio_logger.info("Memory processor started") - - try: - while not self.shutdown_flag: - try: - item = await asyncio.wait_for(self.memory_queue.get(), timeout=30.0) - - if item is None: # Shutdown signal - self.memory_queue.task_done() - break - - try: - # Create background task for memory processing - task = asyncio.create_task(self._process_memory_item(item)) - - # Track task with 5 minute timeout - task_name = f"memory_{item.client_id}_{item.conversation_id}" - actual_task_id = self.task_manager.track_task( - task, - task_name, - { - "client_id": item.client_id, - "conversation_id": item.conversation_id, - "type": "memory", - "timeout": 3600, # 60 minutes - }, - ) - - # Register task with client for tracking (use the actual task_id from TaskManager) - self.track_processing_task( - item.client_id, - "memory", - actual_task_id, - {"conversation_id": item.conversation_id}, - ) - - except Exception as e: - audio_logger.error( - f"Error queuing memory processing for {item.conversation_id}: {e}", - exc_info=True, - ) - finally: - self.memory_queue.task_done() - - except asyncio.TimeoutError: - # Periodic health check - queue_size = self.memory_queue.qsize() - audio_logger.debug(f"Memory processor health: {queue_size} items in queue") - - except Exception as e: - audio_logger.error(f"Fatal error in memory processor: {e}", exc_info=True) - finally: - audio_logger.info("Memory processor stopped") - - async def _process_memory_item(self, item: MemoryProcessingItem): - """Process a single memory item (speech-driven conversations architecture).""" - start_time = time.time() - audio_logger.info(f"πŸš€ MEMORY PROCESSING STARTED for conversation {item.conversation_id} at {start_time}") - - # Track memory processing start - self.track_processing_stage( - item.client_id, - "memory", - "started", - {"conversation_id": item.conversation_id, "started_at": start_time}, - ) - - try: - # Get conversation data directly from conversations collection (speech-driven architecture) - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(item.conversation_id) - - if not conversation: - audio_logger.warning( - f"No conversation found for {item.conversation_id}, skipping memory processing" - ) - return None - - # Extract conversation text from transcript segments - full_conversation = "" - transcript = conversation.get("transcript", []) - if transcript: - dialogue_lines = [] - for segment in transcript: - text = segment.get("text", "").strip() - if text: - speaker = segment.get("speaker", "Unknown") - dialogue_lines.append(f"{speaker}: {text}") - full_conversation = "\n".join(dialogue_lines) - else: - audio_logger.warning( - f"No transcript found in conversation {item.conversation_id}, skipping memory processing" - ) - return None - if len(full_conversation) < 10: # Minimum length check - audio_logger.warning( - f"Conversation too short for memory processing ({len(full_conversation)} chars): conversation {item.conversation_id}" - ) - return None - - # Debug tracking removed for cleaner architecture - - # Check if memory processing should proceed based on primary speakers configuration - should_process, filter_reason = await self._should_process_memory(item.user_id, item.conversation_id) - audio_logger.info(f"🎯 Speaker filter decision for conversation {item.conversation_id}: {filter_reason}") - - if not should_process: - # Update memory processing status to skipped - await self._update_memory_status(item.conversation_id, "skipped") - - # Track completion - self.track_processing_stage( - item.client_id, - "memory", - "completed", - { - "conversation_id": item.conversation_id, - "status": "skipped", - "reason": filter_reason, - "completed_at": time.time(), - }, - ) - audio_logger.info(f"⏭️ Skipped memory processing for conversation {item.conversation_id}: {filter_reason}") - return None - - # Lazy import memory service - if self.memory_service is None: - audio_logger.info(f"πŸ”§ Initializing memory service for conversation {item.conversation_id}...") - self.memory_service = get_memory_service() - audio_logger.info(f"βœ… Memory service initialized for conversation {item.conversation_id}") - - # Process memory with timeout - memory_result = await asyncio.wait_for( - self.memory_service.add_memory( - full_conversation, - item.client_id, - item.conversation_id, # Use conversation_id instead of audio_uuid - item.user_id, - item.user_email, - allow_update=True, - ), - timeout=3600, # 60 minutes - ) - - if memory_result: - # Check if this was a successful result with actual memories created - success, created_memory_ids = memory_result - logger.info(f"Memory result: {memory_result}") - - if success and created_memory_ids: - # Memories were actually created - audio_logger.info( - f"βœ… Successfully processed memory for conversation {item.conversation_id} - created {len(created_memory_ids)} memories" - ) - - # Add memory references to conversations collection (speech-driven architecture) - try: - conversations_repo = ConversationsRepository(conversations_col) - - # Add memory references to conversation - memory_refs = [{"memory_id": mid, "created_at": datetime.now(UTC).isoformat(), "status": "created"} for mid in created_memory_ids] - await conversations_repo.add_memories(item.conversation_id, memory_refs) - - # Update memory processing status - await conversations_repo.update_memory_processing_status(item.conversation_id, "completed") - - audio_logger.info( - f"πŸ“ Added {len(created_memory_ids)} memories to conversation {item.conversation_id}" - ) - except Exception as e: - audio_logger.warning(f"Failed to add memory references: {e}") - - # Track memory processing completion - self.track_processing_stage( - item.client_id, - "memory", - "completed", - { - "conversation_id": item.conversation_id, - "memories_created": len(created_memory_ids), - "processing_time": time.time() - start_time, - }, - ) - elif success and not created_memory_ids: - # Successful processing but no memories created (likely empty transcript) - audio_logger.info( - f"βœ… Memory processing completed for conversation {item.conversation_id} but no memories created (likely empty transcript)" - ) - - # Update database memory processing status to skipped - await self._update_memory_status(item.conversation_id, "skipped") - - # Track memory processing completion (even though no memories created) - self.track_processing_stage( - item.client_id, - "memory", - "completed", - { - "conversation_id": item.conversation_id, - "memories_created": 0, - "processing_time": time.time() - start_time, - "status": "skipped", - }, - ) - else: - # This shouldn't happen, but handle it gracefully - audio_logger.warning( - f"⚠️ Unexpected memory result for conversation {item.conversation_id}: success={success}, ids={created_memory_ids}" - ) - - # Update database memory processing status to failed - await self._update_memory_status(item.conversation_id, "failed") - - # Track memory processing failure - self.track_processing_stage( - item.client_id, - "memory", - "failed", - { - "conversation_id": item.conversation_id, - "error": f"Unexpected result: success={success}, ids={created_memory_ids}", - "processing_time": time.time() - start_time, - }, - ) - - else: - audio_logger.warning(f"⚠️ Memory service returned False for conversation {item.conversation_id}") - - # Update database memory processing status to failed - await self._update_memory_status(item.conversation_id, "failed") - - # Track memory processing failure - self.track_processing_stage( - item.client_id, - "memory", - "failed", - { - "conversation_id": item.conversation_id, - "error": "Memory service returned False", - "processing_time": time.time() - start_time, - }, - ) - - except asyncio.TimeoutError: - audio_logger.error(f"Memory processing timed out for conversation {item.conversation_id}") - - # Update database memory processing status to failed - await self._update_memory_status(item.conversation_id, "failed") - - # Track memory processing timeout failure - self.track_processing_stage( - item.client_id, - "memory", - "failed", - { - "conversation_id": item.conversation_id, - "error": "Processing timeout (5 minutes)", - "processing_time": time.time() - start_time, - }, - ) - - except Exception as e: - audio_logger.error(f"Error processing memory for conversation {item.conversation_id}: {e}") - - # Update database memory processing status to failed - await self._update_memory_status(item.conversation_id, "failed") - - # Track memory processing exception failure - self.track_processing_stage( - item.client_id, - "memory", - "failed", - { - "conversation_id": item.conversation_id, - "error": f"Exception: {str(e)}", - "processing_time": time.time() - start_time, - }, - ) - - end_time = time.time() - processing_time_ms = (end_time - start_time) * 1000 - audio_logger.info( - f"🏁 MEMORY PROCESSING COMPLETED for conversation {item.conversation_id} in {processing_time_ms:.1f}ms (end time: {end_time})" - ) - - async def _cropping_processor(self): - """Process audio cropping requests.""" - audio_logger.info("Audio cropping processor started") - - try: - while not self.shutdown_flag: - try: - item = await asyncio.wait_for(self.cropping_queue.get(), timeout=30.0) - - if item is None: # Shutdown signal - self.cropping_queue.task_done() - break - - try: - # Create background task for cropping - task = asyncio.create_task( - _process_audio_cropping_with_relative_timestamps( - item.original_path, - item.speech_segments, - item.output_path, - item.audio_uuid, - self.repository, - ) - ) - - # Track task - task_name = f"cropping_{item.client_id}_{item.audio_uuid}" - actual_task_id = self.task_manager.track_task( - task, - task_name, - { - "client_id": item.client_id, - "audio_uuid": item.audio_uuid, - "type": "cropping", - "segments": len(item.speech_segments), - }, - ) - - # Register task with client for tracking (use the actual task_id from TaskManager) - self.track_processing_task( - item.client_id, - "cropping", - actual_task_id, - {"audio_uuid": item.audio_uuid, "segments": len(item.speech_segments)}, - ) - - audio_logger.info( - f"βœ‚οΈ Queued audio cropping for {item.audio_uuid} " - f"with {len(item.speech_segments)} segments" - ) - - except Exception as e: - audio_logger.error( - f"Error queuing audio cropping for {item.audio_uuid}: {e}", - exc_info=True, - ) - finally: - self.cropping_queue.task_done() - - except asyncio.TimeoutError: - # Periodic health check - queue_size = self.cropping_queue.qsize() - audio_logger.debug(f"Cropping processor health: {queue_size} items in queue") - - except Exception as e: - audio_logger.error(f"Fatal error in cropping processor: {e}", exc_info=True) - finally: - audio_logger.info("Audio cropping processor stopped") - - -# Global processor manager instance -_processor_manager: Optional[ProcessorManager] = None - - -def init_processor_manager(chunk_dir: Path, db_helper: AudioChunksRepository): - """Initialize the global processor manager.""" - global _processor_manager - _processor_manager = ProcessorManager(chunk_dir, db_helper) - return _processor_manager - - -def get_processor_manager() -> ProcessorManager: - """Get the global processor manager instance.""" - if _processor_manager is None: - raise RuntimeError("ProcessorManager not initialized. Call init_processor_manager first.") - return _processor_manager diff --git a/backends/advanced/src/advanced_omi_backend/routers/api_router.py b/backends/advanced/src/advanced_omi_backend/routers/api_router.py index 4a6ab878..a510d396 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/api_router.py +++ b/backends/advanced/src/advanced_omi_backend/routers/api_router.py @@ -10,13 +10,16 @@ from fastapi import APIRouter from .modules import ( + audio_router, chat_router, client_router, conversation_router, memory_router, + queue_router, system_router, user_router, ) +from .modules.health_routes import router as health_router logger = logging.getLogger(__name__) audio_logger = logging.getLogger("audio_processing") @@ -25,12 +28,15 @@ router = APIRouter(prefix="/api", tags=["api"]) # Include all sub-routers +router.include_router(audio_router) router.include_router(user_router) router.include_router(chat_router) router.include_router(client_router) router.include_router(conversation_router) router.include_router(memory_router) router.include_router(system_router) +router.include_router(queue_router) +router.include_router(health_router) # Also include under /api for frontend compatibility logger.info("API router initialized with all sub-modules") diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py b/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py index 54fcf543..371fd38d 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py @@ -7,14 +7,33 @@ - client_routes: Active client monitoring and management - conversation_routes: Conversation CRUD and audio processing - memory_routes: Memory management, search, and debug -- system_routes: System utilities, metrics, and file processing +- system_routes: System utilities and metrics +- queue_routes: Job queue management and monitoring +- audio_routes: Audio file uploads and processing +- health_routes: Health check endpoints +- websocket_routes: WebSocket connection handling """ +from .audio_routes import router as audio_router from .chat_routes import router as chat_router from .client_routes import router as client_router from .conversation_routes import router as conversation_router +from .health_routes import router as health_router from .memory_routes import router as memory_router +from .queue_routes import router as queue_router from .system_routes import router as system_router from .user_routes import router as user_router +from .websocket_routes import router as websocket_router -__all__ = ["user_router", "chat_router", "client_router", "conversation_router", "memory_router", "system_router"] +__all__ = [ + "audio_router", + "chat_router", + "client_router", + "conversation_router", + "health_router", + "memory_router", + "queue_router", + "system_router", + "user_router", + "websocket_router", +] diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py new file mode 100644 index 00000000..2eb67607 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py @@ -0,0 +1,35 @@ +""" +Audio file upload routes. + +Handles audio file uploads and processing job management. +""" + +from fastapi import APIRouter, Depends, File, Query, UploadFile + +from advanced_omi_backend.auth import current_superuser +from advanced_omi_backend.controllers import audio_controller +from advanced_omi_backend.models.user import User + +router = APIRouter(prefix="/audio", tags=["audio"]) + + +@router.post("/upload") +async def upload_audio_files( + current_user: User = Depends(current_superuser), + files: list[UploadFile] = File(...), + device_name: str = Query(default="upload", description="Device name for uploaded files"), + auto_generate_client: bool = Query(default=True, description="Auto-generate client ID"), +): + """ + Upload and process audio files. Admin only. + + Audio files are saved to disk and enqueued for processing via RQ jobs. + This allows for scalable processing of large files without blocking the API. + + Returns: + - List of uploaded files with their processing job IDs + - Summary of enqueued vs failed uploads + """ + return await audio_controller.upload_and_process_audio_files( + current_user, files, device_name, auto_generate_client + ) diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/conversation_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/conversation_routes.py index 02442a24..be387ff8 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/conversation_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/conversation_routes.py @@ -41,12 +41,12 @@ async def get_conversations(current_user: User = Depends(current_active_user)): @router.get("/{conversation_id}") -async def get_conversation( +async def get_conversation_detail( conversation_id: str, current_user: User = Depends(current_active_user) ): - """Get a specific conversation by conversation_id.""" - return await conversation_controller.get_conversation_by_id(conversation_id, current_user) + """Get a specific conversation with full transcript details.""" + return await conversation_controller.get_conversation(conversation_id, current_user) @router.get("/{audio_uuid}/cropped") diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py new file mode 100644 index 00000000..4981ca39 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py @@ -0,0 +1,442 @@ +""" +Health check routes for Friend-Lite backend. + +This module provides health check endpoints for monitoring the application's status. +""" + +import asyncio +import logging +import os +import time +from typing import Dict, Any + +import aiohttp +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +from motor.motor_asyncio import AsyncIOMotorClient + +from advanced_omi_backend.controllers.queue_controller import redis_conn +from advanced_omi_backend.client_manager import get_client_manager +from advanced_omi_backend.llm_client import async_health_check +from advanced_omi_backend.memory import get_memory_service +from advanced_omi_backend.services.transcription import get_transcription_provider + +# Create router +router = APIRouter(tags=["health"]) + +# Logging setup +logger = logging.getLogger(__name__) +application_logger = logging.getLogger("audio_processing") + +# MongoDB Configuration +MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://mongo:27017") +mongo_client = AsyncIOMotorClient(MONGODB_URI) + +# Memory service +memory_service = get_memory_service() + +# Transcription provider +transcription_provider = get_transcription_provider() + +# Qdrant Configuration +QDRANT_BASE_URL = os.getenv("QDRANT_BASE_URL", "qdrant") +QDRANT_PORT = os.getenv("QDRANT_PORT", "6333") + + +@router.get("/auth/health") +async def auth_health_check(): + """Pre-flight health check for authentication service connectivity.""" + try: + # Test database connectivity + await mongo_client.admin.command("ping") + + # Test memory service if available + if memory_service: + try: + await asyncio.wait_for(memory_service.test_connection(), timeout=2.0) + memory_status = "ok" + except Exception as e: + logger.warning(f"Memory service health check failed: {e}") + memory_status = "degraded" + else: + memory_status = "unavailable" + + return { + "status": "ok", + "database": "ok", + "memory_service": memory_status, + "timestamp": int(time.time()) + } + except Exception as e: + logger.error(f"Auth health check failed: {e}") + return JSONResponse( + status_code=500, + content={ + "status": "error", + "detail": "Service connectivity check failed", + "error_type": "connection_failure", + "timestamp": int(time.time()) + } + ) + + +@router.get("/health") +async def health_check(): + """Comprehensive health check for all services.""" + health_status = { + "status": "healthy", + "timestamp": int(time.time()), + "services": {}, + "config": { + "mongodb_uri": MONGODB_URI, + "qdrant_url": f"http://{QDRANT_BASE_URL}:{QDRANT_PORT}", + "transcription_service": ( + f"Speech to Text ({transcription_provider.name})" + if transcription_provider + else "Speech to Text (Not Configured)" + ), + "asr_uri": ( + f"{transcription_provider.mode.upper()} ({transcription_provider.name})" + if transcription_provider + else "Not configured" + ), + "transcription_provider": os.getenv("TRANSCRIPTION_PROVIDER", "auto-detect"), + "provider_type": ( + transcription_provider.mode if transcription_provider else "none" + ), + "chunk_dir": str(os.getenv("CHUNK_DIR", "./audio_chunks")), + "active_clients": get_client_manager().get_client_count(), + "new_conversation_timeout_minutes": float(os.getenv("NEW_CONVERSATION_TIMEOUT_MINUTES", "1.5")), + "audio_cropping_enabled": os.getenv("AUDIO_CROPPING_ENABLED", "true").lower() == "true", + "llm_provider": os.getenv("LLM_PROVIDER"), + "llm_model": os.getenv("OPENAI_MODEL"), + "llm_base_url": os.getenv("OPENAI_BASE_URL"), + }, + } + + overall_healthy = True + critical_services_healthy = True + + # Get configuration once at the start + memory_provider = os.getenv("MEMORY_PROVIDER", "friend_lite") + speaker_service_url = os.getenv("SPEAKER_SERVICE_URL") + openmemory_mcp_url = os.getenv("OPENMEMORY_MCP_URL") + + # Check MongoDB (critical service) + try: + await asyncio.wait_for(mongo_client.admin.command("ping"), timeout=5.0) + health_status["services"]["mongodb"] = { + "status": "βœ… Connected", + "healthy": True, + "critical": True, + } + except asyncio.TimeoutError: + health_status["services"]["mongodb"] = { + "status": "❌ Connection Timeout (5s)", + "healthy": False, + "critical": True, + } + overall_healthy = False + critical_services_healthy = False + except Exception as e: + health_status["services"]["mongodb"] = { + "status": f"❌ Connection Failed: {str(e)}", + "healthy": False, + "critical": True, + } + overall_healthy = False + critical_services_healthy = False + + # Check Redis and RQ Workers (critical for queue processing) + try: + from rq import Worker + + # Test Redis connection + await asyncio.wait_for(asyncio.to_thread(redis_conn.ping), timeout=5.0) + + # Count active workers + workers = Worker.all(connection=redis_conn) + worker_count = len(workers) + active_workers = len([w for w in workers if w.state == 'busy']) + idle_workers = worker_count - active_workers + + health_status["services"]["redis"] = { + "status": "βœ… Connected", + "healthy": True, + "critical": True, + "worker_count": worker_count, + "active_workers": active_workers, + "idle_workers": idle_workers + } + except asyncio.TimeoutError: + health_status["services"]["redis"] = { + "status": "❌ Connection Timeout (5s)", + "healthy": False, + "critical": True, + "worker_count": 0 + } + overall_healthy = False + critical_services_healthy = False + except Exception as e: + health_status["services"]["redis"] = { + "status": f"❌ Connection Failed: {str(e)}", + "healthy": False, + "critical": True, + "worker_count": 0 + } + overall_healthy = False + critical_services_healthy = False + + # Check LLM service (non-critical service - may not be running) + try: + llm_health = await asyncio.wait_for(async_health_check(), timeout=8.0) + health_status["services"]["audioai"] = { + "status": llm_health.get("status", "❌ Unknown"), + "healthy": "βœ…" in llm_health.get("status", ""), + "base_url": llm_health.get("base_url", ""), + "model": llm_health.get("default_model", ""), + "provider": os.getenv("LLM_PROVIDER", "openai"), + "critical": False, + } + except asyncio.TimeoutError: + health_status["services"]["audioai"] = { + "status": "⚠️ Connection Timeout (8s) - Service may not be running", + "healthy": False, + "provider": os.getenv("LLM_PROVIDER", "openai"), + "critical": False, + } + overall_healthy = False + except Exception as e: + health_status["services"]["audioai"] = { + "status": f"⚠️ Connection Failed: {str(e)} - Service may not be running", + "healthy": False, + "provider": os.getenv("LLM_PROVIDER", "openai"), + "critical": False, + } + overall_healthy = False + + # Check memory service (provider-dependent) + if memory_provider == "friend_lite": + try: + # Test Friend-Lite memory service connection with timeout + test_success = await asyncio.wait_for(memory_service.test_connection(), timeout=8.0) + if test_success: + health_status["services"]["memory_service"] = { + "status": "βœ… Friend-Lite Memory Connected", + "healthy": True, + "provider": "friend_lite", + "critical": False, + } + else: + health_status["services"]["memory_service"] = { + "status": "⚠️ Friend-Lite Memory Test Failed", + "healthy": False, + "provider": "friend_lite", + "critical": False, + } + overall_healthy = False + except asyncio.TimeoutError: + health_status["services"]["memory_service"] = { + "status": "⚠️ Friend-Lite Memory Timeout (8s) - Check Qdrant", + "healthy": False, + "provider": "friend_lite", + "critical": False, + } + overall_healthy = False + except Exception as e: + health_status["services"]["memory_service"] = { + "status": f"⚠️ Friend-Lite Memory Failed: {str(e)}", + "healthy": False, + "provider": "friend_lite", + "critical": False, + } + overall_healthy = False + elif memory_provider == "openmemory_mcp": + # OpenMemory MCP check is handled separately above + health_status["services"]["memory_service"] = { + "status": "βœ… Using OpenMemory MCP", + "healthy": True, + "provider": "openmemory_mcp", + "critical": False, + } + else: + health_status["services"]["memory_service"] = { + "status": f"❌ Unknown memory provider: {memory_provider}", + "healthy": False, + "provider": memory_provider, + "critical": False, + } + overall_healthy = False + + # Check Speech to Text service based on configured provider + if transcription_provider: + provider_name = transcription_provider.name + provider_type = transcription_provider.mode + + # Generic provider health check - let each provider handle its own connection logic + try: + # Test provider connection + await transcription_provider.connect("health-check") + await transcription_provider.disconnect() + + health_status["services"]["speech_to_text"] = { + "status": "βœ… Provider Available", + "healthy": True, + "type": provider_type.title(), + "provider": provider_name, + "critical": False, + } + except Exception as e: + health_status["services"]["speech_to_text"] = { + "status": f"⚠️ Provider Error: {str(e)}", + "healthy": False, + "type": provider_type.title(), + "provider": provider_name, + "critical": False, + } + # Don't mark overall health as unhealthy for transcription issues + # since the service may be external or optional + else: + # No transcription service configured + health_status["services"]["speech_to_text"] = { + "status": "❌ No transcription service configured", + "healthy": False, + "type": "None", + "provider": "None", + "critical": False, + } + overall_healthy = False + + # Check Speaker Recognition service (non-critical - optional feature) + if speaker_service_url: + try: + # Make a health check request to the speaker service + async with aiohttp.ClientSession() as session: + async with session.get( + f"{speaker_service_url}/health", timeout=aiohttp.ClientTimeout(total=5) + ) as response: + if response.status == 200: + health_status["services"]["speaker_recognition"] = { + "status": "βœ… Connected", + "healthy": True, + "url": speaker_service_url, + "critical": False, + } + else: + health_status["services"]["speaker_recognition"] = { + "status": f"⚠️ Unhealthy: HTTP {response.status}", + "healthy": False, + "url": speaker_service_url, + "critical": False, + } + overall_healthy = False + except asyncio.TimeoutError: + health_status["services"]["speaker_recognition"] = { + "status": "⚠️ Connection Timeout (5s)", + "healthy": False, + "url": speaker_service_url, + "critical": False, + } + overall_healthy = False + except Exception as e: + health_status["services"]["speaker_recognition"] = { + "status": f"⚠️ Connection Failed: {str(e)}", + "healthy": False, + "url": speaker_service_url, + "critical": False, + } + overall_healthy = False + + # Check OpenMemory MCP service (if configured) + if memory_provider == "openmemory_mcp" and openmemory_mcp_url: + try: + # Make a health check request to the OpenMemory MCP service + async with aiohttp.ClientSession() as session: + async with session.get( + f"{openmemory_mcp_url}/api/v1/apps/", timeout=aiohttp.ClientTimeout(total=5) + ) as response: + if response.status == 200: + health_status["services"]["openmemory_mcp"] = { + "status": "βœ… Connected", + "healthy": True, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + else: + health_status["services"]["openmemory_mcp"] = { + "status": f"⚠️ Unhealthy: HTTP {response.status}", + "healthy": False, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + overall_healthy = False + except asyncio.TimeoutError: + health_status["services"]["openmemory_mcp"] = { + "status": "⚠️ Connection Timeout (5s)", + "healthy": False, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + overall_healthy = False + except Exception as e: + health_status["services"]["openmemory_mcp"] = { + "status": f"⚠️ Connection Failed: {str(e)}", + "healthy": False, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + overall_healthy = False + + # Set overall status + health_status["overall_healthy"] = overall_healthy + health_status["critical_services_healthy"] = critical_services_healthy + + if not critical_services_healthy: + health_status["status"] = "critical" + elif not overall_healthy: + health_status["status"] = "degraded" + else: + health_status["status"] = "healthy" + + # Add helpful messages + if not overall_healthy: + messages = [] + if not critical_services_healthy: + messages.append( + "Critical services (MongoDB) are unavailable - core functionality will not work" + ) + + unhealthy_optional = [ + name + for name, service in health_status["services"].items() + if not service["healthy"] and not service.get("critical", True) + ] + if unhealthy_optional: + messages.append(f"Optional services unavailable: {', '.join(unhealthy_optional)}") + + health_status["message"] = "; ".join(messages) + + return JSONResponse(content=health_status, status_code=200) + + +@router.get("/readiness") +async def readiness_check(): + """Simple readiness check for container orchestration.""" + # Use debug level for health check to reduce log spam + logger.debug("Readiness check requested") + + # Only check critical services for readiness + try: + # Quick MongoDB ping to ensure we can serve requests + await asyncio.wait_for(mongo_client.admin.command("ping"), timeout=2.0) + return JSONResponse(content={"status": "ready", "timestamp": int(time.time())}, status_code=200) + except Exception as e: + logger.error(f"Readiness check failed: {e}") + return JSONResponse( + content={"status": "not_ready", "error": str(e), "timestamp": int(time.time())}, + status_code=503 + ) \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/queue_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/queue_routes.py new file mode 100644 index 00000000..89679dba --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/queue_routes.py @@ -0,0 +1,629 @@ +""" +Simple queue API routes for job monitoring. +Provides basic endpoints for viewing job status and statistics. +""" + +import logging +from fastapi import APIRouter, Depends, Query, HTTPException +from pydantic import BaseModel +from typing import List, Optional + +from advanced_omi_backend.auth import current_active_user +from advanced_omi_backend.controllers.queue_controller import get_jobs, get_job_stats, get_queue_health, redis_conn +from advanced_omi_backend.users import User +from rq.job import Job +import redis.asyncio as aioredis + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/queue", tags=["queue"]) + + +@router.get("/jobs") +async def list_jobs( + limit: int = Query(20, ge=1, le=100, description="Number of jobs to return"), + offset: int = Query(0, ge=0, description="Number of jobs to skip"), + queue_name: str = Query(None, description="Filter by queue name"), + current_user: User = Depends(current_active_user) +): + """List jobs with pagination and filtering.""" + try: + result = get_jobs(limit=limit, offset=offset, queue_name=queue_name) + + # Filter jobs by user if not admin + if not current_user.is_superuser: + # Filter based on user_id in job kwargs (where RQ stores job parameters) + user_jobs = [] + for job in result["jobs"]: + job_kwargs = job.get("kwargs", {}) + if job_kwargs.get("user_id") == str(current_user.user_id): + user_jobs.append(job) + + result["jobs"] = user_jobs + result["pagination"]["total"] = len(user_jobs) + + return result + + except Exception as e: + logger.error(f"Failed to list jobs: {e}") + return {"error": "Failed to list jobs", "jobs": [], "pagination": {"total": 0, "limit": limit, "offset": offset, "has_more": False}} + + +@router.get("/jobs/{job_id}") +async def get_job( + job_id: str, + current_user: User = Depends(current_active_user) +): + """Get detailed job information including result.""" + try: + job = Job.fetch(job_id, connection=redis_conn) + + # Check user permission (non-admins can only see their own jobs) + if not current_user.is_superuser: + job_user_id = job.kwargs.get("user_id") if job.kwargs else None + if job_user_id != str(current_user.user_id): + raise HTTPException(status_code=403, detail="Access forbidden") + + # Determine status from registries + status = "unknown" + if job.is_queued: + status = "queued" + elif job.is_started: + status = "processing" + elif job.is_finished: + status = "completed" + elif job.is_failed: + status = "failed" + elif job.is_deferred: + status = "deferred" + + return { + "job_id": job.id, + "status": status, + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "ended_at": job.ended_at.isoformat() if job.ended_at else None, + "description": job.description or "", + "func_name": job.func_name if hasattr(job, 'func_name') else "", + "args": job.args, + "kwargs": job.kwargs, + "result": job.result, + "error_message": str(job.exc_info) if job.exc_info else None, + } + + except Exception as e: + logger.error(f"Failed to get job {job_id}: {e}") + raise HTTPException(status_code=404, detail="Job not found") + + +@router.get("/jobs/by-session/{session_id}") +async def get_jobs_by_session( + session_id: str, + current_user: User = Depends(current_active_user) +): + """Get all jobs associated with a specific streaming session.""" + try: + from rq.registry import FinishedJobRegistry, FailedJobRegistry, StartedJobRegistry, CanceledJobRegistry, DeferredJobRegistry, ScheduledJobRegistry + from advanced_omi_backend.controllers.queue_controller import get_queue + from advanced_omi_backend.models.conversation import Conversation + + # First, get conversation_id(s) for this session (for memory jobs) + conversation_ids = set() + conversations = await Conversation.find(Conversation.audio_uuid == session_id).to_list() + conversation_ids = {conv.conversation_id for conv in conversations} + + all_jobs = [] + processed_job_ids = set() # Track which jobs we've already processed + queues = ["default", "transcription", "memory"] + + def get_job_status(job, registries_map): + """Determine job status from registries.""" + if job.is_queued: + return "queued" + elif job.is_started: + return "processing" + elif job.is_finished: + return "completed" + elif job.is_failed: + return "failed" + elif job.is_deferred: + return "deferred" + elif job.is_scheduled: + return "waiting" + else: + return "unknown" + + def process_job_and_dependents(job, queue_name, base_status): + """Process a job and recursively find all its dependents.""" + if job.id in processed_job_ids: + return + + processed_job_ids.add(job.id) + + # Check user permission (non-admins can only see their own jobs) + if not current_user.is_superuser: + job_user_id = job.kwargs.get("user_id") if job.kwargs else None + if job_user_id != str(current_user.user_id): + return + + # Get accurate status + status = get_job_status(job, {}) + + # Add this job to results + all_jobs.append({ + "job_id": job.id, + "job_type": job.func_name.split('.')[-1] if job.func_name else "unknown", + "queue": queue_name, + "status": status, + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "ended_at": job.ended_at.isoformat() if job.ended_at else None, + "description": job.description or "", + "result": job.result, + "error_message": str(job.exc_info) if job.exc_info else None, + }) + + # Check for dependent jobs (jobs that depend on this one) + try: + dependent_ids = job.dependent_ids + if dependent_ids: + logger.debug(f"Job {job.id} has {len(dependent_ids)} dependents: {dependent_ids}") + + for dep_id in dependent_ids: + try: + dep_job = Job.fetch(dep_id, connection=redis_conn) + # Recursively process dependent job + process_job_and_dependents(dep_job, queue_name, "waiting") + except Exception as e: + logger.debug(f"Error fetching dependent job {dep_id}: {e}") + except Exception as e: + logger.debug(f"Error checking dependents for job {job.id}: {e}") + + # Find all jobs that match the session + for queue_name in queues: + queue = get_queue(queue_name) + + # Check all registries + registries = [ + ("queued", queue.job_ids), + ("processing", StartedJobRegistry(queue=queue).get_job_ids()), + ("completed", FinishedJobRegistry(queue=queue).get_job_ids()), + ("failed", FailedJobRegistry(queue=queue).get_job_ids()), + ("cancelled", CanceledJobRegistry(queue=queue).get_job_ids()), + ("waiting", DeferredJobRegistry(queue=queue).get_job_ids()), + ("waiting", ScheduledJobRegistry(queue=queue).get_job_ids()) + ] + + for status_name, job_ids in registries: + for job_id in job_ids: + try: + job = Job.fetch(job_id, connection=redis_conn) + + # Check if this job belongs to the requested session + matches_session = False + + # NEW: Check job.meta first (preferred method for all new jobs) + if job.meta and 'audio_uuid' in job.meta: + if job.meta['audio_uuid'] == session_id: + matches_session = True + # FALLBACK: Check args for backward compatibility with existing queued jobs + elif job.args and len(job.args) > 0: + # Check args[0] first (most common for streaming jobs) + if job.args[0] == session_id: + matches_session = True + # Check args[1] for transcription jobs + elif len(job.args) > 1 and job.args[1] == session_id: + matches_session = True + # Check args[3] for memory jobs (conversation_id) + elif len(job.args) > 3 and job.args[3] in conversation_ids: + matches_session = True + + if matches_session: + # Process this job and all its dependents + process_job_and_dependents(job, queue_name, status_name) + + except Exception as e: + logger.debug(f"Error fetching job {job_id}: {e}") + continue + + # Sort by created_at + all_jobs.sort(key=lambda x: x["created_at"] or "", reverse=False) + + logger.info(f"Found {len(all_jobs)} jobs for session {session_id} (including dependents)") + + return { + "session_id": session_id, + "jobs": all_jobs, + "total": len(all_jobs) + } + + except Exception as e: + logger.error(f"Failed to get jobs for session {session_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get jobs for session: {str(e)}") + + +@router.get("/stats") +async def get_queue_stats_endpoint( + current_user: User = Depends(current_active_user) +): + """Get queue statistics.""" + try: + stats = get_job_stats() + return stats + + except Exception as e: + logger.error(f"Failed to get queue stats: {e}") + return {"total_jobs": 0, "queued_jobs": 0, "processing_jobs": 0, "completed_jobs": 0, "failed_jobs": 0, "cancelled_jobs": 0, "deferred_jobs": 0} + + +@router.get("/health") +async def get_queue_health_endpoint(): + """Get queue system health status.""" + try: + health = get_queue_health() + return health + + except Exception as e: + logger.error(f"Failed to get queue health: {e}") + return { + "status": "unhealthy", + "message": f"Health check failed: {str(e)}" + } + + +@router.get("/streams") +async def get_stream_stats( + limit: int = Query(default=10, ge=1, le=100), # Max 100 streams to prevent timeouts + current_user: User = Depends(current_active_user) +): + """Get Redis Streams statistics with consumer group information.""" + try: + from advanced_omi_backend.services.audio_service import get_audio_stream_service + audio_service = get_audio_stream_service() + + if not audio_service.redis: + return { + "error": "Audio stream service not connected", + "streams": [] + } + + # Get audio streams with limit + stream_keys = [] + cursor = b"0" + while cursor and len(stream_keys) < limit: + cursor, keys = await audio_service.redis.scan( + cursor, match=f"{audio_service.audio_stream_prefix}*", count=limit + ) + stream_keys.extend(keys[:limit - len(stream_keys)]) + + # Use asyncio.gather to fetch stream info in parallel + import asyncio + + async def get_stream_info(stream_key): + try: + stream_name = stream_key.decode() if isinstance(stream_key, bytes) else stream_key + + # Get basic stream info + info = await audio_service.redis.xinfo_stream(stream_name) + + # Get consumer groups info + groups_info = [] + try: + groups = await audio_service.redis.xinfo_groups(stream_name) + for group in groups: + group_dict = {} + # Parse group info (alternating key-value pairs) + for i in range(0, len(group), 2): + if i+1 < len(group): + key = group[i].decode() if isinstance(group[i], bytes) else str(group[i]) + value = group[i+1] + if isinstance(value, bytes): + try: + value = value.decode() + except: + value = str(value) + group_dict[key] = value + + # Get consumers for this group + consumers = [] + try: + consumers_raw = await audio_service.redis.xinfo_consumers(stream_name, group_dict.get('name', '')) + for consumer in consumers_raw: + consumer_dict = {} + for i in range(0, len(consumer), 2): + if i+1 < len(consumer): + key = consumer[i].decode() if isinstance(consumer[i], bytes) else str(consumer[i]) + value = consumer[i+1] + if isinstance(value, bytes): + try: + value = value.decode() + except: + value = str(value) + consumer_dict[key] = value + consumers.append(consumer_dict) + except Exception as ce: + logger.debug(f"Could not fetch consumers for group {group_dict.get('name')}: {ce}") + + groups_info.append({ + "name": group_dict.get('name', 'unknown'), + "consumers": group_dict.get('consumers', 0), + "pending": group_dict.get('pending', 0), + "last_delivered_id": group_dict.get('last-delivered-id', 'N/A'), + "consumer_details": consumers + }) + except Exception as ge: + logger.debug(f"No consumer groups for stream {stream_name}: {ge}") + + return { + "stream_name": stream_name, + "length": info[b"length"], + "first_entry_id": info[b"first-entry"][0].decode() if info[b"first-entry"] else None, + "last_entry_id": info[b"last-entry"][0].decode() if info[b"last-entry"] else None, + "groups": groups_info + } + except Exception as e: + logger.error(f"Error getting info for stream {stream_key}: {e}") + return None + + # Fetch all stream info in parallel + streams_info_results = await asyncio.gather(*[get_stream_info(key) for key in stream_keys]) + streams_info = [info for info in streams_info_results if info is not None] + + return { + "total_streams": len(streams_info), + "streams": streams_info, + "limited": len(stream_keys) >= limit + } + + except Exception as e: + logger.error(f"Failed to get stream stats: {e}", exc_info=True) + return { + "error": str(e), + "total_streams": 0, + "streams": [] + } + + +class FlushJobsRequest(BaseModel): + older_than_hours: int = 24 + statuses: List[str] = ["completed", "failed", "cancelled"] + + +class FlushAllJobsRequest(BaseModel): + confirm: bool + + +@router.post("/flush") +async def flush_jobs( + request: FlushJobsRequest, + current_user: User = Depends(current_active_user) +): + """Flush old inactive jobs based on age and status.""" + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Admin access required") + + try: + from datetime import datetime, timedelta + from rq.registry import FinishedJobRegistry, FailedJobRegistry, CanceledJobRegistry + from advanced_omi_backend.controllers.queue_controller import get_queue + + cutoff_time = datetime.utcnow() - timedelta(hours=request.older_than_hours) + total_removed = 0 + + # Get all queues + queues = ["default", "transcription", "memory"] + + for queue_name in queues: + queue = get_queue(queue_name) + + # Flush from appropriate registries based on requested statuses + if "completed" in request.statuses: + registry = FinishedJobRegistry(queue=queue) + for job_id in registry.get_job_ids(): + try: + job = Job.fetch(job_id, connection=redis_conn) + if job.ended_at and job.ended_at < cutoff_time: + job.delete() + total_removed += 1 + except Exception as e: + logger.error(f"Error deleting job {job_id}: {e}") + + if "failed" in request.statuses: + registry = FailedJobRegistry(queue=queue) + for job_id in registry.get_job_ids(): + try: + job = Job.fetch(job_id, connection=redis_conn) + if job.ended_at and job.ended_at < cutoff_time: + job.delete() + total_removed += 1 + except Exception as e: + logger.error(f"Error deleting job {job_id}: {e}") + + if "cancelled" in request.statuses: + registry = CanceledJobRegistry(queue=queue) + for job_id in registry.get_job_ids(): + try: + job = Job.fetch(job_id, connection=redis_conn) + if job.ended_at and job.ended_at < cutoff_time: + job.delete() + total_removed += 1 + except Exception as e: + logger.error(f"Error deleting job {job_id}: {e}") + + return { + "total_removed": total_removed, + "cutoff_time": cutoff_time.isoformat(), + "statuses": request.statuses + } + + except Exception as e: + logger.error(f"Failed to flush jobs: {e}") + raise HTTPException(status_code=500, detail=f"Failed to flush jobs: {str(e)}") + + +@router.post("/flush-all") +async def flush_all_jobs( + request: FlushAllJobsRequest, + current_user: User = Depends(current_active_user) +): + """Flush ALL jobs (DANGER - requires confirmation).""" + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Admin access required") + + if not request.confirm: + raise HTTPException(status_code=400, detail="Confirmation required") + + try: + from rq.registry import ( + FinishedJobRegistry, + FailedJobRegistry, + CanceledJobRegistry, + StartedJobRegistry, + DeferredJobRegistry, + ScheduledJobRegistry + ) + from advanced_omi_backend.controllers.queue_controller import get_queue + + total_removed = 0 + queues = ["default", "transcription", "memory"] + + for queue_name in queues: + queue = get_queue(queue_name) + + # Remove from all registries + registries = [ + FinishedJobRegistry(queue=queue), + FailedJobRegistry(queue=queue), + CanceledJobRegistry(queue=queue), + StartedJobRegistry(queue=queue), + DeferredJobRegistry(queue=queue), + ScheduledJobRegistry(queue=queue) + ] + + for registry in registries: + for job_id in registry.get_job_ids(): + try: + job = Job.fetch(job_id, connection=redis_conn) + job.delete() + total_removed += 1 + except Exception as e: + logger.error(f"Error deleting job {job_id}: {e}") + + # Also empty the queue itself + queue.empty() + + return { + "total_removed": total_removed, + "message": "All jobs have been flushed" + } + + except Exception as e: + logger.error(f"Failed to flush all jobs: {e}") + raise HTTPException(status_code=500, detail=f"Failed to flush all jobs: {str(e)}") + + +@router.get("/sessions") +async def get_redis_sessions( + limit: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(current_active_user) +): + """Get Redis session tracking information.""" + try: + import redis.asyncio as aioredis + from advanced_omi_backend.controllers.queue_controller import REDIS_URL + + redis_client = aioredis.from_url(REDIS_URL) + + # Get session keys + session_keys = [] + cursor = b"0" + while cursor and len(session_keys) < limit: + cursor, keys = await redis_client.scan( + cursor, match="audio:session:*", count=limit + ) + session_keys.extend(keys[:limit - len(session_keys)]) + + # Get session info + sessions = [] + for key in session_keys: + try: + session_data = await redis_client.hgetall(key) + if session_data: + session_id = key.decode().replace("audio:session:", "") + sessions.append({ + "session_id": session_id, + "user_id": session_data.get(b"user_id", b"").decode(), + "client_id": session_data.get(b"client_id", b"").decode(), + "stream_name": session_data.get(b"stream_name", b"").decode(), + "provider": session_data.get(b"provider", b"").decode(), + "mode": session_data.get(b"mode", b"").decode(), + "status": session_data.get(b"status", b"").decode(), + "started_at": session_data.get(b"started_at", b"").decode(), + "chunks_published": int(session_data.get(b"chunks_published", b"0").decode() or 0), + "last_chunk_at": session_data.get(b"last_chunk_at", b"").decode() + }) + except Exception as e: + logger.error(f"Error getting session info for {key}: {e}") + + await redis_client.close() + + return { + "total_sessions": len(sessions), + "sessions": sessions + } + + except Exception as e: + logger.error(f"Failed to get sessions: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get sessions: {str(e)}") + + +@router.post("/sessions/clear") +async def clear_old_sessions( + older_than_seconds: int = Query(default=3600, description="Clear sessions older than N seconds"), + current_user: User = Depends(current_active_user) +): + """Clear old Redis sessions that are stuck or inactive.""" + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Admin access required") + + try: + import redis.asyncio as aioredis + import time + from advanced_omi_backend.controllers.queue_controller import REDIS_URL + + redis_client = aioredis.from_url(REDIS_URL) + current_time = time.time() + cutoff_time = current_time - older_than_seconds + + # Get all session keys + session_keys = [] + cursor = b"0" + while cursor: + cursor, keys = await redis_client.scan(cursor, match="audio:session:*", count=100) + session_keys.extend(keys) + + # Check each session and delete if old + deleted_count = 0 + for key in session_keys: + try: + session_data = await redis_client.hgetall(key) + if session_data: + last_chunk_at = session_data.get(b"last_chunk_at", b"").decode() + if last_chunk_at: + last_chunk_time = float(last_chunk_at) + if last_chunk_time < cutoff_time: + await redis_client.delete(key) + deleted_count += 1 + logger.info(f"Deleted old session: {key.decode()}") + except Exception as e: + logger.error(f"Error processing session {key}: {e}") + + await redis_client.close() + + return { + "deleted_count": deleted_count, + "cutoff_seconds": older_than_seconds + } + + except Exception as e: + logger.error(f"Failed to clear sessions: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to clear sessions: {str(e)}") \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py index 5e5d34d6..b3d886e5 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py @@ -1,17 +1,17 @@ """ System and utility routes for Friend-Lite API. -Handles metrics, auth config, file processing, and other system utilities. +Handles metrics, auth config, and other system utilities. """ import logging from typing import Optional -from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile +from fastapi import APIRouter, Depends, Request from advanced_omi_backend.auth import current_active_user, current_superuser from advanced_omi_backend.controllers import system_controller -from advanced_omi_backend.users import User +from advanced_omi_backend.models.user import User logger = logging.getLogger(__name__) @@ -50,44 +50,6 @@ async def get_processor_status(current_user: User = Depends(current_superuser)): return await system_controller.get_processor_status() -@router.post("/process-audio-files") -async def process_audio_files( - current_user: User = Depends(current_superuser), - files: list[UploadFile] = File(...), - device_name: str = Query(default="upload"), - auto_generate_client: bool = Query(default=True), -): - """Process uploaded audio files through the transcription pipeline. Admin only.""" - return await system_controller.process_audio_files( - current_user, files, device_name, auto_generate_client - ) - - -@router.post("/process-audio-files-async") -async def process_audio_files_async( - background_tasks: BackgroundTasks, - current_user: User = Depends(current_superuser), - files: list[UploadFile] = File(...), - device_name: str = Query(default="upload"), -): - """Start async processing of uploaded audio files. Returns job ID immediately. Admin only.""" - return await system_controller.process_audio_files_async( - background_tasks, current_user, files, device_name - ) - - -@router.get("/process-audio-files/jobs/{job_id}") -async def get_processing_job_status(job_id: str, current_user: User = Depends(current_superuser)): - """Get status of an async file processing job. Admin only.""" - return await system_controller.get_processing_job_status(job_id) - - -@router.get("/process-audio-files/jobs") -async def list_processing_jobs(current_user: User = Depends(current_superuser)): - """List all active processing jobs. Admin only.""" - return await system_controller.list_processing_jobs() - - @router.get("/diarization-settings") async def get_diarization_settings(current_user: User = Depends(current_superuser)): """Get current diarization settings. Admin only.""" @@ -166,3 +128,21 @@ async def reload_memory_config(current_user: User = Depends(current_superuser)): async def delete_all_user_memories(current_user: User = Depends(current_active_user)): """Delete all memories for the current user.""" return await system_controller.delete_all_user_memories(current_user) + + +@router.get("/streaming/status") +async def get_streaming_status(request: Request, current_user: User = Depends(current_superuser)): + """Get status of active streaming sessions and Redis Streams health. Admin only.""" + return await system_controller.get_streaming_status(request) + + +@router.post("/streaming/cleanup") +async def cleanup_stuck_stream_workers(request: Request, current_user: User = Depends(current_superuser)): + """Clean up stuck Redis Stream workers and pending messages. Admin only.""" + return await system_controller.cleanup_stuck_stream_workers(request) + + +@router.post("/streaming/cleanup-sessions") +async def cleanup_old_sessions(request: Request, max_age_seconds: int = 3600, current_user: User = Depends(current_superuser)): + """Clean up old session tracking metadata. Admin only.""" + return await system_controller.cleanup_old_sessions(request, max_age_seconds) diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py index 8cf70117..808b8185 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py @@ -10,7 +10,7 @@ from advanced_omi_backend.auth import current_superuser from advanced_omi_backend.controllers import user_controller -from advanced_omi_backend.users import User, UserCreate +from advanced_omi_backend.users import User, UserCreate, UserUpdate logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ async def create_user(user_data: UserCreate, current_user: User = Depends(curren @router.put("/{user_id}") -async def update_user(user_id: str, user_data: UserCreate, current_user: User = Depends(current_superuser)): +async def update_user(user_id: str, user_data: UserUpdate, current_user: User = Depends(current_superuser)): """Update a user. Admin only.""" return await user_controller.update_user(user_id, user_data) diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/websocket_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/websocket_routes.py new file mode 100644 index 00000000..454cabb9 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/websocket_routes.py @@ -0,0 +1,38 @@ +""" +WebSocket routes for Friend-Lite backend. + +This module handles WebSocket connections for audio streaming. +""" + +import logging +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query +from typing import Optional + +from advanced_omi_backend.controllers.websocket_controller import ( + handle_omi_websocket, + handle_pcm_websocket +) + +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(tags=["websocket"]) + +@router.websocket("/ws_omi") +async def ws_endpoint_omi( + ws: WebSocket, + token: Optional[str] = Query(None), + device_name: Optional[str] = Query(None), +): + """Accepts WebSocket connections with Wyoming protocol, decodes OMI Opus audio, and processes per-client.""" + await handle_omi_websocket(ws, token, device_name) + + +@router.websocket("/ws_pcm") +async def ws_endpoint_pcm( + ws: WebSocket, + token: Optional[str] = Query(None), + device_name: Optional[str] = Query(None) +): + """Accepts WebSocket connections, processes PCM audio per-client.""" + await handle_pcm_websocket(ws, token, device_name) \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/services/__init__.py b/backends/advanced/src/advanced_omi_backend/services/__init__.py new file mode 100644 index 00000000..81d3c535 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/__init__.py @@ -0,0 +1,5 @@ +""" +Services module for Friend-Lite backend. + +This module contains business logic services and their provider implementations. +""" diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_service.py b/backends/advanced/src/advanced_omi_backend/services/audio_service.py new file mode 100644 index 00000000..094f5526 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/audio_service.py @@ -0,0 +1,375 @@ +""" +Audio processing service using Redis Streams. + +This service handles audio chunk streaming, processing, and coordination +using Redis Streams for event-driven architecture. +""" + +import asyncio +import json +import logging +import os +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +import redis.asyncio as aioredis +from wyoming.audio import AudioChunk + +logger = logging.getLogger(__name__) +audio_logger = logging.getLogger("audio_processing") + + +@dataclass +class AudioStreamMessage: + """Message format for audio stream.""" + client_id: str + user_id: str + user_email: str + audio_data: bytes + audio_rate: int + audio_width: int + audio_channels: int + audio_uuid: Optional[str] = None + timestamp: Optional[int] = None + + +class AudioStreamService: + """ + Audio service using Redis Streams for event-driven processing. + + Architecture: + - WebSocket publishes audio chunks to Redis Stream: audio:{client_id} + - RQ workers consume from stream and process audio + - Events published to transcript:events stream when transcription completes + """ + + def __init__(self, redis_url: Optional[str] = None): + """Initialize audio stream service. + + Args: + redis_url: Redis connection URL (defaults to REDIS_URL env var) + """ + self.redis_url = redis_url or os.getenv("REDIS_URL", "redis://localhost:6379/0") + self.redis: Optional[aioredis.Redis] = None + + # Stream configuration + self.audio_stream_prefix = "audio:" # audio:{client_id} + self.transcript_events_stream = "transcript:events" + self.memory_events_stream = "memory:events" + + # Consumer group names (action verbs - what they DO) + self.audio_writer = "audio-file-writer" # Writes audio chunks to WAV files + self.memory_enqueuer = "memory-job-enqueuer" # Enqueues memory extraction jobs + self.event_listener = "event-listener" # Listens for completion events + + async def connect(self): + """Connect to Redis with connection pooling.""" + # Use connection pooling for better concurrency handling + self.redis = await aioredis.from_url( + self.redis_url, + decode_responses=False, + max_connections=20, # Allow multiple concurrent operations + socket_keepalive=True, + socket_connect_timeout=5, + retry_on_timeout=True + ) + logger.info(f"Audio stream service connected to Redis at {self.redis_url}") + + # Create consumer groups if they don't exist + await self._ensure_consumer_groups() + + async def disconnect(self): + """Disconnect from Redis.""" + if self.redis: + await self.redis.close() + logger.info("Audio stream service disconnected from Redis") + + async def _ensure_consumer_groups(self): + """Ensure consumer groups exist for all streams.""" + try: + # Note: Consumer groups are created per stream when first audio arrives + # We'll create them dynamically in publish_audio_chunk + pass + except Exception as e: + logger.error(f"Error ensuring consumer groups: {e}") + + async def publish_audio_chunk( + self, + client_id: str, + user_id: str, + user_email: str, + audio_chunk: AudioChunk, + audio_uuid: Optional[str] = None, + timestamp: Optional[int] = None + ) -> str: + """ + Publish audio chunk to Redis Stream. + + Args: + client_id: Client identifier + user_id: User ID + user_email: User email + audio_chunk: Wyoming AudioChunk object + audio_uuid: Optional audio UUID + timestamp: Optional timestamp (session start time in ms) + + Returns: + Message ID from Redis Stream + """ + if not self.redis: + raise RuntimeError("Redis not connected. Call connect() first.") + + stream_name = f"{self.audio_stream_prefix}{client_id}" + + # Use Redis Stream message ID as sequence - it's guaranteed to be unique and ordered + # The timestamp parameter is for session start time tracking + session_timestamp = timestamp or int(time.time() * 1000) + + # Prepare message data + message_data = { + b"client_id": client_id.encode(), + b"user_id": user_id.encode(), + b"user_email": user_email.encode(), + b"audio_data": audio_chunk.audio, + b"audio_rate": str(audio_chunk.rate).encode(), + b"audio_width": str(audio_chunk.width).encode(), + b"audio_channels": str(audio_chunk.channels).encode(), + b"session_timestamp": str(session_timestamp).encode(), + } + + if audio_uuid: + message_data[b"audio_uuid"] = audio_uuid.encode() + + # Publish to stream - Redis generates unique message_id automatically + message_id = await self.redis.xadd(stream_name, message_data) + + audio_logger.debug( + f"Published audio chunk to {stream_name}: {len(audio_chunk.audio)} bytes, " + f"message_id={message_id.decode()}" + ) + + # Ensure consumer group exists for this stream + try: + await self.redis.xgroup_create( + stream_name, + self.audio_writer, + id="0", + mkstream=True + ) + audio_logger.debug(f"Created consumer group {self.audio_writer} for {stream_name}") + except aioredis.ResponseError as e: + if "BUSYGROUP" not in str(e): + raise + + return message_id.decode() + + async def publish_transcript_event( + self, + audio_uuid: str, + conversation_id: str, + status: str, + error: Optional[str] = None + ): + """ + Publish transcript completion event. + + Args: + audio_uuid: Audio UUID + conversation_id: Conversation ID + status: Status (completed, failed) + error: Error message if failed + """ + if not self.redis: + raise RuntimeError("Redis not connected") + + event_data = { + b"audio_uuid": audio_uuid.encode(), + b"conversation_id": conversation_id.encode(), + b"status": status.encode(), + b"timestamp": str(int(time.time() * 1000)).encode(), + } + + if error: + event_data[b"error"] = error.encode() + + message_id = await self.redis.xadd(self.transcript_events_stream, event_data) + + logger.info( + f"Published transcript event: {status} for {audio_uuid}, " + f"message_id={message_id.decode()}" + ) + + # Ensure consumer group exists + try: + await self.redis.xgroup_create( + self.transcript_events_stream, + self.memory_enqueuer, + id="0", + mkstream=True + ) + except aioredis.ResponseError as e: + if "BUSYGROUP" not in str(e): + raise + + async def publish_memory_event( + self, + conversation_id: str, + status: str, + memory_count: int = 0, + error: Optional[str] = None + ): + """ + Publish memory processing event. + + Args: + conversation_id: Conversation ID + status: Status (completed, failed) + memory_count: Number of memories extracted + error: Error message if failed + """ + if not self.redis: + raise RuntimeError("Redis not connected") + + event_data = { + b"conversation_id": conversation_id.encode(), + b"status": status.encode(), + b"memory_count": str(memory_count).encode(), + b"timestamp": str(int(time.time() * 1000)).encode(), + } + + if error: + event_data[b"error"] = error.encode() + + message_id = await self.redis.xadd(self.memory_events_stream, event_data) + + logger.info( + f"Published memory event: {status} for {conversation_id}, " + f"memories={memory_count}, message_id={message_id.decode()}" + ) + + # Ensure consumer group exists + try: + await self.redis.xgroup_create( + self.memory_events_stream, + self.event_listener, + id="0", + mkstream=True + ) + except aioredis.ResponseError as e: + if "BUSYGROUP" not in str(e): + raise + + async def consume_audio_stream( + self, + consumer_name: str, + callback, + block_ms: int = 5000, + count: int = 10 + ): + """ + Consume audio chunks from all client streams. + + This is intended to be run in RQ workers. + + Args: + consumer_name: Unique consumer name (e.g., worker ID) + callback: Async function to process each audio message + block_ms: Block time in milliseconds + count: Max messages to read per call + """ + if not self.redis: + raise RuntimeError("Redis not connected") + + logger.info(f"Audio stream consumer {consumer_name} starting...") + + # Get all audio streams + stream_keys = [] + cursor = b"0" + while cursor: + cursor, keys = await self.redis.scan( + cursor, match=f"{self.audio_stream_prefix}*" + ) + stream_keys.extend(keys) + + if not stream_keys: + logger.debug("No audio streams found") + return + + # Read from all streams + streams_dict = {key: b">" for key in stream_keys} + + try: + messages = await self.redis.xreadgroup( + self.audio_writer, + consumer_name, + streams_dict, + count=count, + block=block_ms + ) + + for stream_name, stream_messages in messages: + for message_id, message_data in stream_messages: + try: + # Process message + await callback(stream_name, message_id, message_data) + + # Acknowledge message + await self.redis.xack( + stream_name, + self.audio_writer, + message_id + ) + + except Exception as e: + logger.error( + f"Error processing audio message {message_id.decode()}: {e}", + exc_info=True + ) + + except Exception as e: + logger.error(f"Error consuming audio stream: {e}", exc_info=True) + + async def get_stream_info(self, stream_name: str) -> Dict[str, Any]: + """Get information about a stream.""" + if not self.redis: + raise RuntimeError("Redis not connected") + + try: + info = await self.redis.xinfo_stream(stream_name) + return info + except aioredis.ResponseError: + return {} + + async def cleanup_old_messages(self, stream_name: str, max_age_ms: int = 3600000): + """ + Trim old messages from stream (older than max_age_ms). + + Args: + stream_name: Stream name + max_age_ms: Maximum age in milliseconds (default 1 hour) + """ + if not self.redis: + raise RuntimeError("Redis not connected") + + # Calculate cutoff timestamp + cutoff_ts = int((time.time() * 1000) - max_age_ms) + + # Trim stream + await self.redis.xtrim(stream_name, minid=f"{cutoff_ts}-0", approximate=True) + + logger.debug(f"Trimmed old messages from {stream_name} (cutoff: {cutoff_ts})") + + +# Global singleton +_audio_stream_service: Optional[AudioStreamService] = None + + +def get_audio_stream_service() -> AudioStreamService: + """Get the global audio stream service instance.""" + global _audio_stream_service + if _audio_stream_service is None: + _audio_stream_service = AudioStreamService() + return _audio_stream_service diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/__init__.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/__init__.py new file mode 100644 index 00000000..b8b78ee8 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/__init__.py @@ -0,0 +1,14 @@ +""" +Audio stream service - Redis Streams-based audio transcription. +""" + +from .aggregator import TranscriptionResultsAggregator +from .consumer import BaseAudioStreamConsumer +from .producer import AudioStreamProducer, get_audio_stream_producer + +__all__ = [ + "AudioStreamProducer", + "get_audio_stream_producer", + "TranscriptionResultsAggregator", + "BaseAudioStreamConsumer", +] diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/aggregator.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/aggregator.py new file mode 100644 index 00000000..9b82aabf --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/aggregator.py @@ -0,0 +1,207 @@ +""" +Transcription results aggregator - reads results from Redis Streams. +""" + +import json +import logging + +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +class TranscriptionResultsAggregator: + """ + Reads transcription results from Redis Streams. + + Results are in: transcription:results:{session_id} + """ + + def __init__(self, redis_client: redis.Redis): + """ + Initialize aggregator. + + Args: + redis_client: Connected Redis client + """ + self.redis_client = redis_client + + async def get_session_results(self, session_id: str) -> list[dict]: + """ + Get all transcription results for a session. + + Args: + session_id: Session identifier + + Returns: + List of result dictionaries with text, confidence, provider, etc. + """ + stream_name = f"transcription:results:{session_id}" + + try: + # Read all messages from stream + messages = await self.redis_client.xrange(stream_name) + + results = [] + for message_id, fields in messages: + result = { + "message_id": message_id.decode(), + "text": fields[b"text"].decode(), + "confidence": float(fields[b"confidence"].decode()), + "provider": fields[b"provider"].decode(), + "chunk_id": fields[b"chunk_id"].decode(), + "processing_time": float(fields[b"processing_time"].decode()), + "timestamp": float(fields[b"timestamp"].decode()), + } + + # Optional fields + if b"words" in fields: + result["words"] = json.loads(fields[b"words"].decode()) + if b"segments" in fields: + result["segments"] = json.loads(fields[b"segments"].decode()) + + results.append(result) + + # Sort by timestamp + results.sort(key=lambda x: x["timestamp"]) + + # Log detailed result info + chunk_ids = [r["chunk_id"] for r in results] + total_text_length = sum(len(r["text"]) for r in results) + logger.info( + f"πŸ”„ Retrieved {len(results)} results for session {session_id}: " + f"chunks={chunk_ids}, total_text={total_text_length} chars" + ) + return results + + except Exception as e: + logger.error(f"πŸ”„ Error getting results for session {session_id}: {e}") + return [] + + async def get_combined_results(self, session_id: str) -> dict: + """ + Get all transcription results combined into a single aggregated result. + + This is what an aggregator should do - combine multiple chunks into one. + + Args: + session_id: Session identifier + + Returns: + Combined result dict with: + - text: Full transcript (all chunks joined) + - words: All words combined + - segments: All segments combined and sorted + - chunk_count: Number of chunks combined + - total_confidence: Average confidence + - provider: Provider name + """ + # Get raw chunks + results = await self.get_session_results(session_id) + + if not results: + return { + "text": "", + "words": [], + "segments": [], + "chunk_count": 0, + "total_confidence": 0.0, + "provider": None + } + + # Combine text + full_text = " ".join([r.get("text", "") for r in results if r.get("text")]) + + # Combine words + all_words = [] + for r in results: + if "words" in r and r["words"]: + all_words.extend(r["words"]) + + # Combine segments + all_segments = [] + for r in results: + if "segments" in r and r["segments"]: + all_segments.extend(r["segments"]) + + # Sort segments by start time + all_segments.sort(key=lambda s: s.get("start", 0.0)) + + # Calculate average confidence + confidences = [r.get("confidence", 0.0) for r in results] + avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0 + + # Get provider (assume all chunks from same provider) + provider = results[0].get("provider") if results else None + + combined = { + "text": full_text, + "words": all_words, + "segments": all_segments, + "chunk_count": len(results), + "total_confidence": avg_confidence, + "provider": provider + } + + logger.info( + f"πŸ“¦ Combined {len(results)} chunks for session {session_id}: " + f"{len(full_text)} chars, {len(all_words)} words, {len(all_segments)} segments" + ) + + return combined + + async def get_realtime_results( + self, + session_id: str, + last_id: str = "0", + timeout_ms: int = 1000 + ) -> tuple[list[dict], str]: + """ + Get new results since last_id (for real-time streaming). + + Args: + session_id: Session identifier + last_id: Last message ID received (use "0" to start from beginning) + timeout_ms: Block timeout in milliseconds + + Returns: + Tuple of (results list, new_last_id) + """ + stream_name = f"transcription:results:{session_id}" + + try: + # Read new messages since last_id + messages = await self.redis_client.xread( + {stream_name: last_id}, + count=10, + block=timeout_ms + ) + + results = [] + new_last_id = last_id + + if messages: + for _, msgs in messages: + for message_id, fields in msgs: + result = { + "message_id": message_id.decode(), + "text": fields[b"text"].decode(), + "confidence": float(fields[b"confidence"].decode()), + "provider": fields[b"provider"].decode(), + "chunk_id": fields[b"chunk_id"].decode(), + } + + # Optional fields + if b"words" in fields: + result["words"] = json.loads(fields[b"words"].decode()) + if b"segments" in fields: + result["segments"] = json.loads(fields[b"segments"].decode()) + + results.append(result) + new_last_id = message_id.decode() + + return results, new_last_id + + except Exception as e: + logger.error(f"πŸ”„ Error getting realtime results for session {session_id}: {e}") + return [], last_id diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/consumer.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/consumer.py new file mode 100644 index 00000000..ea770253 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/consumer.py @@ -0,0 +1,588 @@ +""" +Base audio stream consumer - reads from Redis Streams and transcribes. +""" + +import asyncio +import json +import logging +import os +import time +from abc import ABC, abstractmethod + +import redis.asyncio as redis +from redis import exceptions as redis_exceptions +from redis.asyncio.lock import Lock + +logger = logging.getLogger(__name__) + + +class BaseAudioStreamConsumer(ABC): + """ + Base class for audio stream consumers. + + Reads from specified stream (client-specific or provider-specific) and transcribes using the provider. + Writes results to transcription:results:{session_id}. + """ + + def __init__(self, provider_name: str, redis_client: redis.Redis, buffer_chunks: int = 30): + """ + Initialize consumer. + + Dynamically discovers all audio:stream:* streams and claims them using Redis locks + to ensure exclusive processing (one consumer per stream). + + Args: + provider_name: Provider name (e.g., "deepgram", "parakeet") + redis_client: Connected Redis client + buffer_chunks: Number of chunks to accumulate before transcribing (default: 30 = ~7.5 seconds) + """ + self.provider_name = provider_name + self.redis_client = redis_client + self.buffer_chunks = buffer_chunks + + # Stream configuration + self.stream_pattern = "audio:stream:*" + self.group_name = f"{provider_name}_workers" + self.consumer_name = f"{provider_name}-worker-{os.getpid()}" + + self.running = False + + # Dynamic stream discovery with exclusive locks + self.active_streams = {} # {stream_name: True} + self.stream_locks = {} # {stream_name: Lock object} + + # Buffering: accumulate chunks per session + self.session_buffers = {} # {session_id: {"chunks": [], "chunk_ids": [], "sample_rate": int}} + + async def discover_streams(self) -> list[str]: + """ + Discover all audio streams matching the pattern. + + Returns: + List of stream names + """ + streams = [] + cursor = b"0" + + while cursor: + cursor, keys = await self.redis_client.scan( + cursor, match=self.stream_pattern, count=100 + ) + if keys: + streams.extend([k.decode() if isinstance(k, bytes) else k for k in keys]) + + return streams + + async def try_claim_stream(self, stream_name: str) -> bool: + """ + Try to claim exclusive ownership of a stream using Redis lock. + + Args: + stream_name: Stream to claim + + Returns: + True if lock acquired, False otherwise + """ + lock_key = f"consumer:lock:{stream_name}" + + # Create lock with 30 second timeout (will be renewed) + lock = Lock( + self.redis_client, + lock_key, + timeout=30, + blocking=False # Non-blocking + ) + + acquired = await lock.acquire(blocking=False) + + if acquired: + self.stream_locks[stream_name] = lock + logger.info(f"πŸ”’ Claimed stream: {stream_name}") + return True + else: + logger.debug(f"⏭️ Stream already claimed by another consumer: {stream_name}") + return False + + async def release_stream(self, stream_name: str): + """Release lock on a stream.""" + if stream_name in self.stream_locks: + try: + await self.stream_locks[stream_name].release() + logger.info(f"πŸ”“ Released stream: {stream_name}") + except Exception as e: + logger.warning(f"Failed to release lock for {stream_name}: {e}") + finally: + del self.stream_locks[stream_name] + + async def renew_stream_locks(self): + """Renew locks on all claimed streams.""" + for stream_name, lock in list(self.stream_locks.items()): + try: + await lock.reacquire() + except Exception as e: + logger.warning(f"Failed to renew lock for {stream_name}: {e}") + # Lock expired, remove from our list + del self.stream_locks[stream_name] + if stream_name in self.active_streams: + del self.active_streams[stream_name] + + async def setup_consumer_group(self, stream_name: str): + """Create consumer group if it doesn't exist.""" + # Create consumer group (ignore error if already exists) + try: + await self.redis_client.xgroup_create( + stream_name, + self.group_name, + "0", + mkstream=True + ) + logger.debug(f"➑️ Created consumer group {self.group_name} for {stream_name}") + except redis_exceptions.ResponseError as e: + if "BUSYGROUP" not in str(e): + raise + logger.debug(f"➑️ Consumer group {self.group_name} already exists for {stream_name}") + + async def cleanup_dead_consumers(self, idle_threshold_ms: int = 30000): + """ + Clean up dead consumers from the consumer group. + + Removes consumers that are idle > threshold (default 30 seconds) and have no pending messages. + Claims and ACKs any pending messages from dead consumers first. + + Args: + idle_threshold_ms: Idle time threshold in milliseconds (default 30 seconds) + """ + try: + # Get all consumers in the group + consumers = await self.redis_client.execute_command( + 'XINFO', 'CONSUMERS', self.input_stream, self.group_name + ) + + if not consumers: + return + + deleted_count = 0 + claimed_count = 0 + + # Parse consumer info - each consumer is a nested list + for consumer_info in consumers: + consumer_dict = {} + + # Parse consumer fields (flat key-value pairs within each consumer) + for j in range(0, len(consumer_info), 2): + if j+1 < len(consumer_info): + key = consumer_info[j].decode() if isinstance(consumer_info[j], bytes) else str(consumer_info[j]) + value = consumer_info[j+1] + if isinstance(value, bytes): + try: + value = value.decode() + except UnicodeDecodeError: + value = str(value) + consumer_dict[key] = value + + consumer_name = consumer_dict.get("name", "") + if isinstance(consumer_name, bytes): + consumer_name = consumer_name.decode() + + consumer_pending = int(consumer_dict.get("pending", 0)) + consumer_idle_ms = int(consumer_dict.get("idle", 0)) + + # Skip our own consumer + if consumer_name == self.consumer_name: + continue + + # Check if consumer is dead + is_dead = consumer_idle_ms > idle_threshold_ms + + if is_dead: + # If consumer has pending messages, claim and ACK them first + if consumer_pending > 0: + logger.info(f"πŸ”„ Claiming {consumer_pending} pending messages from dead consumer {consumer_name}") + + try: + pending_messages = await self.redis_client.execute_command( + 'XPENDING', self.input_stream, self.group_name, '-', '+', str(consumer_pending), consumer_name + ) + + # Parse pending messages (groups of 4: msg_id, consumer, idle_ms, delivery_count) + for k in range(0, len(pending_messages), 4): + if k < len(pending_messages): + msg_id = pending_messages[k] + if isinstance(msg_id, bytes): + msg_id = msg_id.decode() + + # Claim to ourselves and ACK immediately + try: + await self.redis_client.execute_command( + 'XCLAIM', self.input_stream, self.group_name, self.consumer_name, '0', msg_id + ) + await self.redis_client.xack(self.input_stream, self.group_name, msg_id) + claimed_count += 1 + except Exception as claim_error: + logger.warning(f"Failed to claim/ack message {msg_id}: {claim_error}") + + except Exception as pending_error: + logger.warning(f"Failed to process pending messages for {consumer_name}: {pending_error}") + + # Delete the dead consumer + try: + await self.redis_client.execute_command( + 'XGROUP', 'DELCONSUMER', self.input_stream, self.group_name, consumer_name + ) + deleted_count += 1 + logger.info(f"🧹 Deleted dead consumer {consumer_name} (idle: {consumer_idle_ms}ms)") + except Exception as delete_error: + logger.warning(f"Failed to delete consumer {consumer_name}: {delete_error}") + + if deleted_count > 0 or claimed_count > 0: + logger.info(f"βœ… Cleanup complete: deleted {deleted_count} dead consumers, claimed {claimed_count} pending messages") + + except Exception as e: + logger.error(f"❌ Failed to cleanup dead consumers: {e}", exc_info=True) + + @abstractmethod + async def transcribe_audio(self, audio_data: bytes, sample_rate: int) -> dict: + """ + Transcribe audio using the provider. + + Must be implemented by subclasses. + + Args: + audio_data: Raw PCM audio bytes + sample_rate: Audio sample rate (Hz) + + Returns: + Dict with "text", "words", "segments", "confidence" + """ + pass + + async def start_consuming(self): + """Discover and consume from multiple streams with exclusive locking.""" + self.running = True + logger.info(f"➑️ Starting dynamic stream consumer: {self.consumer_name}") + + last_discovery = 0 + last_lock_renewal = 0 + discovery_interval = 10 # Discover new streams every 10 seconds + lock_renewal_interval = 15 # Renew locks every 15 seconds + + while self.running: + try: + current_time = time.time() + + # Periodically discover new streams + if current_time - last_discovery > discovery_interval: + discovered = await self.discover_streams() + logger.debug(f"πŸ” Discovered {len(discovered)} streams") + + for stream_name in discovered: + if stream_name not in self.active_streams: + # Try to claim this stream + if await self.try_claim_stream(stream_name): + # Setup consumer group for this stream + await self.setup_consumer_group(stream_name) + self.active_streams[stream_name] = True + logger.info(f"βœ… Now consuming from {stream_name}") + + last_discovery = current_time + + # Periodically renew locks + if current_time - last_lock_renewal > lock_renewal_interval: + await self.renew_stream_locks() + last_lock_renewal = current_time + + # Read from all active streams + if not self.active_streams: + # No streams claimed yet, wait and retry + await asyncio.sleep(1) + continue + + # Build streams dict for XREADGROUP + streams_dict = {stream: ">" for stream in self.active_streams.keys()} + + messages = await self.redis_client.xreadgroup( + self.group_name, + self.consumer_name, + streams_dict, + count=1, + block=1000 # Block for 1 second + ) + + if not messages: + continue + + for stream_name, msgs in messages: + stream_name_str = stream_name.decode() if isinstance(stream_name, bytes) else stream_name + for message_id, fields in msgs: + await self.process_message(message_id, fields, stream_name_str) + + except redis_exceptions.ResponseError as e: + error_msg = str(e) + + # Handle NOGROUP errors (stream was deleted or consumer group doesn't exist) + if "NOGROUP" in error_msg or "no such key" in error_msg.lower(): + # Extract stream name from error message + for stream_name in list(self.active_streams.keys()): + if stream_name in error_msg: + logger.warning(f"➑️ [{self.consumer_name}] Stream {stream_name} was deleted, removing from active streams") + + # Release the lock + lock_key = f"stream:lock:{stream_name}" + try: + await self.redis_client.delete(lock_key) + logger.info(f"πŸ”“ Released lock for deleted stream: {stream_name}") + except: + pass + + # Remove from active streams + del self.active_streams[stream_name] + logger.info(f"➑️ [{self.consumer_name}] Removed {stream_name}, {len(self.active_streams)} streams remaining") + break + else: + # Other ResponseError - log and continue + logger.error(f"➑️ [{self.consumer_name}] Redis ResponseError: {e}") + + await asyncio.sleep(1) + + except Exception as e: + logger.error(f"➑️ [{self.consumer_name}] Error in dynamic consume loop: {e}", exc_info=True) + await asyncio.sleep(1) + + async def process_message(self, message_id: bytes, fields: dict, stream_name: str): + """ + Process a single message from the stream. + Accumulates chunks and transcribes when buffer is full. + + Args: + message_id: Redis message ID + fields: Message fields + stream_name: Stream name this message came from + """ + try: + # Extract message data + audio_data = fields[b"audio_data"] + session_id = fields[b"session_id"].decode() + chunk_id = fields[b"chunk_id"].decode() + sample_rate = int(fields[b"sample_rate"].decode()) + + # Check for end-of-session signal + if chunk_id == "END": + logger.info(f"➑️ [{self.consumer_name}] {self.provider_name}: Received END signal for session {session_id}") + + # Flush buffer for this session if it has any chunks + if session_id in self.session_buffers: + buffer = self.session_buffers[session_id] + + if len(buffer["chunks"]) > 0: + start_time = time.time() + + # Combine buffered chunks + combined_audio = b"".join(buffer["chunks"]) + combined_chunk_id = f"{buffer['chunk_ids'][0]}-{buffer['chunk_ids'][-1]}" + + logger.info( + f"➑️ [{self.consumer_name}] {self.provider_name}: Flushing {len(buffer['chunks'])} remaining chunks " + f"({len(combined_audio)} bytes, ~{len(combined_audio)/32000:.1f}s) as {combined_chunk_id}" + ) + + # Transcribe remaining audio + result = await self.transcribe_audio(combined_audio, buffer["sample_rate"]) + + # Store result + processing_time = time.time() - start_time + await self.store_result( + session_id=session_id, + chunk_id=combined_chunk_id, + text=result.get("text", ""), + confidence=result.get("confidence", 0.0), + words=result.get("words", []), + segments=result.get("segments", []), + processing_time=processing_time + ) + + # ACK all buffered messages + for msg_id in buffer["message_ids"]: + await self.redis_client.xack(stream_name, self.group_name, msg_id) + + # Trim stream to remove ACKed messages (keep only last 1000 for safety) + try: + await self.redis_client.xtrim(stream_name, maxlen=1000, approximate=True) + logger.debug(f"🧹 Trimmed audio stream {stream_name} to max 1000 entries") + except Exception as trim_error: + logger.warning(f"Failed to trim stream {stream_name}: {trim_error}") + + logger.info( + f"➑️ [{self.consumer_name}] {self.provider_name}: Flushed buffer for session {session_id} " + f"in {processing_time:.2f}s (transcript: {len(result.get('text', ''))} chars)" + ) + + # Clean up session buffer + del self.session_buffers[session_id] + + # ACK the END message + await self.redis_client.xack(stream_name, self.group_name, message_id) + return + + # Initialize buffer for this session if needed + if session_id not in self.session_buffers: + self.session_buffers[session_id] = { + "chunks": [], + "chunk_ids": [], + "sample_rate": sample_rate, + "message_ids": [], + "audio_offset_seconds": 0.0 # Track cumulative audio duration + } + + # Add to buffer (skip empty audio data from END signals) + if len(audio_data) > 0: + buffer = self.session_buffers[session_id] + buffer["chunks"].append(audio_data) + buffer["chunk_ids"].append(chunk_id) + buffer["message_ids"].append(message_id) + else: + # ACK and skip empty chunks + await self.redis_client.xack(stream_name, self.group_name, message_id) + return + + logger.debug( + f"➑️ [{self.consumer_name}] {self.provider_name}: Buffered chunk {chunk_id} ({len(buffer['chunks'])}/{self.buffer_chunks})" + ) + + # Transcribe when buffer is full + if len(buffer["chunks"]) >= self.buffer_chunks: + start_time = time.time() + + # Combine buffered chunks + combined_audio = b"".join(buffer["chunks"]) + combined_chunk_id = f"{buffer['chunk_ids'][0]}-{buffer['chunk_ids'][-1]}" + + # Calculate audio duration for this chunk (16-bit PCM, 1 channel) + audio_duration_seconds = len(combined_audio) / (sample_rate * 2) # 2 bytes per sample + audio_offset = buffer["audio_offset_seconds"] + + # Log individual chunk IDs to detect duplicates + chunk_list = ", ".join(buffer['chunk_ids'][:5] + ['...'] + buffer['chunk_ids'][-5:]) if len(buffer['chunk_ids']) > 10 else ", ".join(buffer['chunk_ids']) + + logger.info( + f"➑️ [{self.consumer_name}] {self.provider_name}: Transcribing {len(buffer['chunks'])} chunks " + f"({len(combined_audio)} bytes, {audio_duration_seconds:.1f}s, offset={audio_offset:.1f}s) as {combined_chunk_id} [{chunk_list}]" + ) + + # Transcribe combined audio + result = await self.transcribe_audio(combined_audio, sample_rate) + + # Adjust segment timestamps to be relative to session start + adjusted_segments = [] + for seg in result.get("segments", []): + adjusted_seg = seg.copy() + adjusted_seg["start"] = seg.get("start", 0.0) + audio_offset + adjusted_seg["end"] = seg.get("end", 0.0) + audio_offset + adjusted_segments.append(adjusted_seg) + + # Adjust word timestamps too + adjusted_words = [] + for word in result.get("words", []): + adjusted_word = word.copy() + adjusted_word["start"] = word.get("start", 0.0) + audio_offset + adjusted_word["end"] = word.get("end", 0.0) + audio_offset + adjusted_words.append(adjusted_word) + + logger.debug(f"➑️ [{self.consumer_name}] Adjusted {len(adjusted_segments)} segments by +{audio_offset:.1f}s") + + # Store result with adjusted timestamps + processing_time = time.time() - start_time + await self.store_result( + session_id=session_id, + chunk_id=combined_chunk_id, + text=result.get("text", ""), + confidence=result.get("confidence", 0.0), + words=adjusted_words, + segments=adjusted_segments, + processing_time=processing_time + ) + + # Update audio offset for next chunk + buffer["audio_offset_seconds"] += audio_duration_seconds + + # ACK all buffered messages + for msg_id in buffer["message_ids"]: + await self.redis_client.xack(stream_name, self.group_name, msg_id) + + # Trim stream to remove ACKed messages (keep only last 1000 for safety) + try: + await self.redis_client.xtrim(stream_name, maxlen=1000, approximate=True) + logger.debug(f"🧹 Trimmed audio stream {stream_name} to max 1000 entries") + except Exception as trim_error: + logger.warning(f"Failed to trim stream {stream_name}: {trim_error}") + + logger.info( + f"➑️ [{self.consumer_name}] {self.provider_name}: Completed {combined_chunk_id} in {processing_time:.2f}s " + f"(transcript: {len(result.get('text', ''))} chars, next_offset={buffer['audio_offset_seconds']:.1f}s)" + ) + + # Clear buffer + buffer["chunks"] = [] + buffer["chunk_ids"] = [] + buffer["message_ids"] = [] + + except Exception as e: + logger.error( + f"➑️ [{self.consumer_name}] {self.provider_name}: Failed to process chunk {fields.get(b'chunk_id', b'unknown').decode()}: {e}", + exc_info=True + ) + + async def store_result( + self, + session_id: str, + chunk_id: str, + text: str, + confidence: float, + words: list, + segments: list, + processing_time: float + ): + """ + Store transcription result in Redis Stream. + + Args: + session_id: Session identifier + chunk_id: Chunk identifier + text: Transcribed text + confidence: Confidence score + words: Word-level data + segments: Speaker segments + processing_time: Processing time in seconds + """ + result_data = { + b"text": text.encode(), + b"chunk_id": chunk_id.encode(), + b"provider": self.provider_name.encode(), + b"confidence": str(confidence).encode(), + b"processing_time": str(processing_time).encode(), + b"timestamp": str(time.time()).encode(), + } + + # Add optional JSON fields + if words: + result_data[b"words"] = json.dumps(words).encode() + if segments: + result_data[b"segments"] = json.dumps(segments).encode() + + # Write to session results stream with MAXLEN limit + session_results_stream = f"transcription:results:{session_id}" + message_id = await self.redis_client.xadd( + session_results_stream, + result_data, + maxlen=1000, # Keep max 1k results per session + approximate=True + ) + + logger.info( + f"➑️ Stored result {chunk_id} in {session_results_stream}: " + f"text_len={len(text)}, msg_id={message_id.decode()}" + ) + + async def stop(self): + """Stop consuming messages.""" + self.running = False + logger.info(f"➑️ Stopping consumer: {self.consumer_name}") diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py new file mode 100644 index 00000000..98e93cfc --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py @@ -0,0 +1,369 @@ +""" +Audio stream producer - publishes audio chunks to Redis Streams. +""" + +import logging +import time + +import redis.asyncio as redis + +from advanced_omi_backend.models.transcription import TranscriptionProvider + +logger = logging.getLogger(__name__) + + +class AudioStreamProducer: + """ + Publishes audio chunks to provider-specific Redis Streams. + + Routes audio to: audio:stream:{provider} (e.g., "audio:stream:deepgram") + + Multiple workers can consume from the same stream using consumer groups for horizontal scaling. + Buffers incoming audio and creates fixed-size chunks aligned to sample boundaries. + This prevents cutting audio mid-word and improves transcription accuracy. + """ + + def __init__(self, redis_client: redis.Redis): + """ + Initialize producer. + + Args: + redis_client: Connected Redis client + """ + self.redis_client = redis_client + + # Per-session audio buffers for sample-aligned chunking + # {session_id: {"buffer": bytes, "chunk_count": int, "stream_name": str, ...}} + self.session_buffers = {} + + async def init_session( + self, + session_id: str, + user_id: str, + client_id: str, + mode: str = "streaming", + provider: str = "deepgram" + ): + """ + Initialize session tracking metadata. + + Args: + session_id: Session identifier + user_id: User identifier + client_id: Client identifier + mode: Processing mode (streaming/batch) + provider: Transcription provider ("deepgram", "mistral", etc.) + """ + # Client-specific stream naming (one stream per client for isolation) + stream_name = f"audio:stream:{client_id}" + session_key = f"audio:session:{session_id}" + + await self.redis_client.hset(session_key, mapping={ + "user_id": user_id, + "client_id": client_id, + "stream_name": stream_name, + "provider": provider, + "mode": mode, + "started_at": str(time.time()), + "chunks_published": "0", + "last_chunk_at": str(time.time()), + "status": "active" + }) + + # Set TTL of 1 hour + await self.redis_client.expire(session_key, 3600) + + # Initialize audio buffer for this session + self.session_buffers[session_id] = { + "buffer": b"", + "chunk_count": 0, + "user_id": user_id, + "client_id": client_id, + "stream_name": stream_name, + "provider": provider + } + + logger.info(f"πŸ“Š Initialized session {session_id} β†’ stream {stream_name} (provider: {provider})") + + async def update_session_chunk_count(self, session_id: str): + """ + Increment chunk counter and update last activity time. + + Args: + session_id: Session identifier + """ + session_key = f"audio:session:{session_id}" + + # Increment chunk count + await self.redis_client.hincrby(session_key, "chunks_published", 1) + + # Update last chunk time + await self.redis_client.hset(session_key, "last_chunk_at", str(time.time())) + + async def send_session_end_signal(self, session_id: str): + """ + Send end-of-session signal to workers to flush their buffers. + + Args: + session_id: Session identifier + """ + if session_id not in self.session_buffers: + return + + buffer = self.session_buffers[session_id] + stream_name = buffer["stream_name"] + + # Send special "end" message to signal workers to flush + end_signal = { + b"audio_data": b"", # Empty audio data + b"session_id": session_id.encode(), + b"chunk_id": b"END", # Special marker + b"user_id": buffer["user_id"].encode(), + b"client_id": buffer["client_id"].encode(), + b"timestamp": str(time.time()).encode(), + b"sample_rate": b"16000", + b"channels": b"1", + b"sample_width": b"2", + } + + await self.redis_client.xadd( + stream_name, + end_signal, + maxlen=25000, + approximate=True + ) + logger.info(f"πŸ“‘ Sent end-of-session signal for {session_id} to {stream_name}") + + async def finalize_session(self, session_id: str): + """ + Mark session as finalizing and clean up buffer. + + Args: + session_id: Session identifier + """ + session_key = f"audio:session:{session_id}" + + await self.redis_client.hset(session_key, mapping={ + "status": "finalizing", + "finalized_at": str(time.time()) + }) + + # Clean up session buffer + if session_id in self.session_buffers: + del self.session_buffers[session_id] + logger.debug(f"🧹 Cleaned up buffer for session {session_id}") + + logger.info(f"πŸ“Š Marked session {session_id} as finalizing") + + async def add_audio_chunk( + self, + audio_data: bytes, + session_id: str, + chunk_id: str, + user_id: str, + client_id: str, + sample_rate: int = 16000, + channels: int = 1, + sample_width: int = 2 + ) -> list[str]: + """ + Add audio data to session buffer and publish fixed-size chunks. + + Buffers incoming audio and creates sample-aligned chunks of fixed duration + (0.25 seconds = 8000 bytes for 16kHz 16-bit mono) to prevent cutting mid-word. + + Args: + audio_data: Raw PCM audio bytes (arbitrary size from WebSocket) + session_id: Session identifier + chunk_id: Base chunk identifier (will increment for multiple chunks) + user_id: User identifier + client_id: Client identifier (used for stream naming) + sample_rate: Audio sample rate (Hz) + channels: Number of audio channels + sample_width: Bytes per sample + + Returns: + List of Redis message IDs (may send multiple chunks per call) + """ + # Initialize buffer if needed (in case init_session wasn't called) + if session_id not in self.session_buffers: + stream_name = f"audio:stream:{client_id}" # Client-specific stream + self.session_buffers[session_id] = { + "buffer": b"", + "chunk_count": 0, + "user_id": user_id, + "client_id": client_id, + "stream_name": stream_name, + "provider": "deepgram" + } + + session_buffer = self.session_buffers[session_id] + + # Add incoming audio to buffer + session_buffer["buffer"] += audio_data + + # Calculate target chunk size (0.25 seconds of audio) + # bytes_per_second = sample_rate * channels * sample_width + # target_chunk_duration = 0.25 seconds + bytes_per_second = sample_rate * channels * sample_width + target_chunk_size = int(bytes_per_second * 0.25) + + # Publish fixed-size chunks from buffer + message_ids = [] + stream_name = session_buffer["stream_name"] + + while len(session_buffer["buffer"]) >= target_chunk_size: + # Extract exactly target_chunk_size bytes + chunk_audio = session_buffer["buffer"][:target_chunk_size] + session_buffer["buffer"] = session_buffer["buffer"][target_chunk_size:] + + # Increment chunk count + session_buffer["chunk_count"] += 1 + chunk_id_formatted = f"{session_buffer['chunk_count']:05d}" + + # Prepare chunk data + chunk_data = { + b"audio_data": chunk_audio, + b"session_id": session_id.encode(), + b"chunk_id": chunk_id_formatted.encode(), + b"user_id": user_id.encode(), + b"client_id": client_id.encode(), + b"timestamp": str(time.time()).encode(), + b"sample_rate": str(sample_rate).encode(), + b"channels": str(channels).encode(), + b"sample_width": str(sample_width).encode(), + } + + # Add to stream with MAXLEN limit (safety net to prevent unbounded growth) + message_id = await self.redis_client.xadd( + stream_name, + chunk_data, + maxlen=25000, # Keep max 25k chunks (~104 minutes at 250ms/chunk) + approximate=True + ) + message_ids.append(message_id.decode()) + + # Update session tracking + await self.update_session_chunk_count(session_id) + + # Log every 10th chunk to avoid spam + if session_buffer["chunk_count"] % 10 == 0 or session_buffer["chunk_count"] <= 5: + logger.info( + f"πŸ“€ Added fixed-size chunk {chunk_id_formatted} to {stream_name} " + f"({len(chunk_audio)} bytes = {len(chunk_audio)/bytes_per_second:.3f}s, " + f"buffer remaining: {len(session_buffer['buffer'])} bytes)" + ) + + # Log buffer accumulation if no chunks were sent + if not message_ids: + logger.debug( + f"πŸ“¦ Buffering audio for {session_id}: " + f"{len(session_buffer['buffer'])}/{target_chunk_size} bytes " + f"(need {target_chunk_size - len(session_buffer['buffer'])} more)" + ) + + return message_ids + + async def flush_session_buffer( + self, + session_id: str, + sample_rate: int = 16000, + channels: int = 1, + sample_width: int = 2 + ) -> str | None: + """ + Flush any remaining audio in session buffer. + + Called at session end to send the last partial chunk. + + Args: + session_id: Session identifier + sample_rate: Audio sample rate (Hz) + channels: Number of audio channels + sample_width: Bytes per sample + + Returns: + Redis message ID if chunk was sent, None if buffer was empty + """ + if session_id not in self.session_buffers: + return None + + session_buffer = self.session_buffers[session_id] + + # Send any remaining buffered audio + if len(session_buffer["buffer"]) > 0: + chunk_audio = session_buffer["buffer"] + session_buffer["buffer"] = b"" + + # Increment chunk count + session_buffer["chunk_count"] += 1 + chunk_id_formatted = f"{session_buffer['chunk_count']:05d}" + + stream_name = session_buffer["stream_name"] + + # Prepare chunk data + chunk_data = { + b"audio_data": chunk_audio, + b"session_id": session_id.encode(), + b"chunk_id": chunk_id_formatted.encode(), + b"user_id": session_buffer["user_id"].encode(), + b"client_id": session_buffer["client_id"].encode(), + b"timestamp": str(time.time()).encode(), + b"sample_rate": str(sample_rate).encode(), + b"channels": str(channels).encode(), + b"sample_width": str(sample_width).encode(), + } + + # Add to stream with MAXLEN limit + message_id = await self.redis_client.xadd( + stream_name, + chunk_data, + maxlen=25000, + approximate=True + ) + + # Update session tracking + await self.update_session_chunk_count(session_id) + + bytes_per_second = sample_rate * channels * sample_width + logger.info( + f"πŸ“€ Flushed final chunk {chunk_id_formatted} to {stream_name} " + f"({len(chunk_audio)} bytes = {len(chunk_audio)/bytes_per_second:.3f}s)" + ) + + return message_id.decode() + + return None + + + +# Singleton instance +_producer_instance = None + + +def get_audio_stream_producer() -> AudioStreamProducer: + """ + Get or create singleton AudioStreamProducer instance. + + Returns: + Singleton AudioStreamProducer instance + """ + global _producer_instance + + if _producer_instance is None: + import os + import redis.asyncio as redis_async + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + # Create async Redis client (synchronous call, connection happens on first use) + redis_client = redis_async.from_url( + redis_url, + encoding="utf-8", + decode_responses=False + ) + + _producer_instance = AudioStreamProducer(redis_client) + logger.info(f"Created AudioStreamProducer singleton with Redis URL: {redis_url}") + + return _producer_instance diff --git a/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py b/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py new file mode 100644 index 00000000..9036aa61 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py @@ -0,0 +1,127 @@ +""" +Transcription provider implementations and factory. + +This module contains concrete implementations of transcription providers +for different ASR services (Deepgram, Parakeet, etc.) and a factory function +to instantiate the appropriate provider based on configuration. +""" + +import logging +import os +from typing import Optional + +from advanced_omi_backend.models.transcription import BaseTranscriptionProvider +from advanced_omi_backend.services.transcription.deepgram import ( + DeepgramProvider, + DeepgramStreamingProvider, + DeepgramStreamConsumer, +) +from advanced_omi_backend.services.transcription.parakeet import ( + ParakeetProvider, + ParakeetStreamingProvider, +) + +logger = logging.getLogger(__name__) + + +def get_transcription_provider( + provider_name: Optional[str] = None, + mode: Optional[str] = None, +) -> Optional[BaseTranscriptionProvider]: + """ + Factory function to get the appropriate transcription provider. + + Args: + provider_name: Name of the provider ('deepgram', 'parakeet'). + If None, will auto-select based on available configuration. + mode: Processing mode ('streaming', 'batch'). If None, defaults to 'batch'. + + Returns: + An instance of BaseTranscriptionProvider, or None if no provider is configured. + + Raises: + RuntimeError: If a specific provider is requested but not properly configured. + """ + deepgram_key = os.getenv("DEEPGRAM_API_KEY") + parakeet_url = os.getenv("PARAKEET_ASR_URL") + + if provider_name: + provider_name = provider_name.lower() + + if mode is None: + mode = "batch" + mode = mode.lower() + + # Handle specific provider requests + if provider_name == "deepgram": + if not deepgram_key: + raise RuntimeError( + "Deepgram transcription provider requested but DEEPGRAM_API_KEY not configured" + ) + logger.info(f"Using Deepgram transcription provider in {mode} mode") + if mode == "streaming": + return DeepgramStreamingProvider(deepgram_key) + else: + return DeepgramProvider(deepgram_key) + + elif provider_name == "parakeet": + if not parakeet_url: + raise RuntimeError( + "Parakeet ASR provider requested but PARAKEET_ASR_URL not configured" + ) + logger.info(f"Using Parakeet transcription provider in {mode} mode") + if mode == "streaming": + return ParakeetStreamingProvider(parakeet_url) + else: + return ParakeetProvider(parakeet_url) + + elif provider_name == "offline": + # "offline" is an alias for Parakeet ASR + if not parakeet_url: + raise RuntimeError( + "Offline transcription provider requested but PARAKEET_ASR_URL not configured" + ) + logger.info(f"Using offline Parakeet transcription provider in {mode} mode") + if mode == "streaming": + return ParakeetStreamingProvider(parakeet_url) + else: + return ParakeetProvider(parakeet_url) + + # Auto-select provider based on available configuration (when provider_name is None) + if provider_name is None: + # Check TRANSCRIPTION_PROVIDER environment variable first + env_provider = os.getenv("TRANSCRIPTION_PROVIDER") + if env_provider: + # Recursively call with the specified provider + return get_transcription_provider(env_provider, mode) + + # Auto-select: prefer Deepgram if available, fallback to Parakeet + if deepgram_key: + logger.info(f"Auto-selected Deepgram transcription provider in {mode} mode") + if mode == "streaming": + return DeepgramStreamingProvider(deepgram_key) + else: + return DeepgramProvider(deepgram_key) + elif parakeet_url: + logger.info(f"Auto-selected Parakeet transcription provider in {mode} mode") + if mode == "streaming": + return ParakeetStreamingProvider(parakeet_url) + else: + return ParakeetProvider(parakeet_url) + else: + logger.warning( + "No transcription provider configured (DEEPGRAM_API_KEY or PARAKEET_ASR_URL required)" + ) + return None + else: + return None + + +__all__ = [ + "get_transcription_provider", + "DeepgramProvider", + "DeepgramStreamingProvider", + "DeepgramStreamConsumer", + "ParakeetProvider", + "ParakeetStreamingProvider", +] diff --git a/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py b/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py new file mode 100644 index 00000000..89b80de1 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py @@ -0,0 +1,481 @@ +""" +Deepgram transcription provider implementations. + +Provides both batch and streaming transcription using Deepgram's Nova-3 model. +""" + +import asyncio +import json +import logging +import uuid +from typing import Dict, Optional + +import httpx +import websockets + +from advanced_omi_backend.models.transcription import ( + BatchTranscriptionProvider, + StreamingTranscriptionProvider, +) + +logger = logging.getLogger(__name__) + +class DeepgramProvider(BatchTranscriptionProvider): + """Deepgram batch transcription provider using Nova-3 model.""" + + def __init__(self, api_key: str): + self.api_key = api_key + self.url = "https://api.deepgram.com/v1/listen" + + @property + def name(self) -> str: + return "deepgram" + + async def transcribe(self, audio_data: bytes, sample_rate: int, diarize: bool = False) -> dict: + """Transcribe audio using Deepgram's REST API. + + Args: + audio_data: Raw audio bytes + sample_rate: Audio sample rate + diarize: Whether to enable speaker diarization + """ + try: + params = { + "model": "nova-3", + "language": "multi", + "smart_format": "true", + "punctuate": "true", + "diarize": "true" if diarize else "false", + "encoding": "linear16", + "sample_rate": str(sample_rate), + "channels": "1", + } + + headers = {"Authorization": f"Token {self.api_key}", "Content-Type": "audio/raw"} + + logger.info(f"Sending {len(audio_data)} bytes to Deepgram API") + + # Calculate dynamic timeout based on audio file size + estimated_duration = len(audio_data) / (sample_rate * 2 * 1) # 16-bit mono + processing_timeout = max( + 120, int(estimated_duration * 3) + ) # Min 2 minutes, 3x audio duration + + timeout_config = httpx.Timeout( + connect=30.0, + read=processing_timeout, + write=max( + 180.0, int(len(audio_data) / (sample_rate * 2)) + ), # bytes per second for 16-bit PCM + pool=10.0, + ) + + logger.info( + f"Estimated audio duration: {estimated_duration:.1f}s, timeout: {processing_timeout}s" + ) + + async with httpx.AsyncClient(timeout=timeout_config) as client: + response = await client.post( + self.url, params=params, headers=headers, content=audio_data + ) + + if response.status_code == 200: + result = response.json() + logger.debug(f"Deepgram response: {result}") + + # Extract transcript from response + if result.get("results", {}).get("channels", []) and result["results"][ + "channels" + ][0].get("alternatives", []): + + alternative = result["results"]["channels"][0]["alternatives"][0] + + # Extract segments from diarized utterances if available + segments = [] + if "paragraphs" in alternative and alternative["paragraphs"].get("paragraphs"): + transcript = alternative["paragraphs"]["transcript"].strip() + logger.info( + f"Deepgram diarized transcription successful: {len(transcript)} characters" + ) + + # Extract speaker segments, grouping consecutive sentences from same speaker + current_speaker = None + current_segment = None + + for paragraph in alternative["paragraphs"]["paragraphs"]: + speaker = f"Speaker {paragraph.get('speaker', 'unknown')}" + + for sentence in paragraph.get("sentences", []): + if speaker == current_speaker and current_segment: + # Extend current segment with same speaker + current_segment["text"] += " " + sentence.get("text", "").strip() + current_segment["end"] = sentence.get("end", 0) + else: + # Save previous segment and start new one + if current_segment: + segments.append(current_segment) + current_segment = { + "text": sentence.get("text", "").strip(), + "speaker": speaker, + "start": sentence.get("start", 0), + "end": sentence.get("end", 0), + "confidence": None # Deepgram doesn't provide segment-level confidence + } + current_speaker = speaker + + # Don't forget the last segment + if current_segment: + segments.append(current_segment) + else: + transcript = alternative.get("transcript", "").strip() + logger.info( + f"Deepgram basic transcription successful: {len(transcript)} characters" + ) + + if transcript: + # Extract speech timing information for logging + words = alternative.get("words", []) + if words: + first_word_start = words[0].get("start", 0) + last_word_end = words[-1].get("end", 0) + speech_duration = last_word_end - first_word_start + + # Calculate audio duration from data size + audio_duration = len(audio_data) / ( + sample_rate * 2 * 1 + ) # 16-bit mono + speech_percentage = ( + (speech_duration / audio_duration) * 100 + if audio_duration > 0 + else 0 + ) + + logger.info( + f"Deepgram speech analysis: {speech_duration:.1f}s speech detected in {audio_duration:.1f}s audio ({speech_percentage:.1f}%)" + ) + + # Check confidence levels + confidences = [ + w.get("confidence", 0) for w in words if "confidence" in w + ] + if confidences: + avg_confidence = sum(confidences) / len(confidences) + low_confidence_count = sum(1 for c in confidences if c < 0.5) + logger.info( + f"Deepgram confidence: avg={avg_confidence:.2f}, {low_confidence_count}/{len(words)} words <0.5 confidence" + ) + + # Keep raw transcript and word data without formatting + logger.info( + f"Keeping raw transcript with word-level data: {len(transcript)} characters, {len(segments)} segments" + ) + return { + "text": transcript, + "words": words, + "segments": segments, + } + else: + # No word-level data, return basic transcript + logger.info( + "No word-level data available, returning basic transcript" + ) + return {"text": transcript, "words": [], "segments": []} + else: + logger.warning("Deepgram returned empty transcript") + return {"text": "", "words": [], "segments": []} + else: + logger.warning("Deepgram response missing expected transcript structure") + return {"text": "", "words": [], "segments": []} + else: + logger.error(f"Deepgram API error: {response.status_code} - {response.text}") + return {"text": "", "words": [], "segments": []} + + except httpx.TimeoutException as e: + timeout_type = "unknown" + if "connect" in str(e).lower(): + timeout_type = "connection" + elif "read" in str(e).lower(): + timeout_type = "read" + elif "write" in str(e).lower(): + timeout_type = "write (upload)" + elif "pool" in str(e).lower(): + timeout_type = "connection pool" + logger.error( + f"HTTP {timeout_type} timeout during Deepgram API call for {len(audio_data)} bytes: {e}" + ) + return {"text": "", "words": [], "segments": []} + except Exception as e: + logger.error(f"Error calling Deepgram API: {e}") + return {"text": "", "words": [], "segments": []} + + +class DeepgramStreamingProvider(StreamingTranscriptionProvider): + """Deepgram streaming transcription provider using WebSocket connection.""" + + def __init__(self, api_key: str): + self.api_key = api_key + self.ws_url = "wss://api.deepgram.com/v1/listen" + self._streams: Dict[str, Dict] = {} # client_id -> stream data + + @property + def name(self) -> str: + return "deepgram" + + async def start_stream(self, client_id: str, sample_rate: int = 16000, diarize: bool = False): + """Start a WebSocket connection for streaming transcription. + + Args: + client_id: Unique client identifier + sample_rate: Audio sample rate + diarize: Whether to enable speaker diarization + """ + try: + logger.info(f"Starting Deepgram streaming for client {client_id} (diarize={diarize})") + + # WebSocket connection parameters + params = { + "model": "nova-3", + "language": "multi", + "smart_format": "true", + "punctuate": "true", + "diarize": "true" if diarize else "false", + "encoding": "linear16", + "sample_rate": str(sample_rate), + "channels": "1", + "interim_results": "true", + "endpointing": "300", # 300ms silence for endpoint detection + } + + # Build WebSocket URL with parameters + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + ws_url = f"{self.ws_url}?{query_string}" + + # Connect to WebSocket + websocket = await websockets.connect( + ws_url, + extra_headers={"Authorization": f"Token {self.api_key}"} + ) + + # Store stream data + self._streams[client_id] = { + "websocket": websocket, + "final_transcript": "", + "words": [], + "stream_id": str(uuid.uuid4()) + } + + logger.info(f"Deepgram WebSocket connected for client {client_id}") + + except Exception as e: + logger.error(f"Failed to start Deepgram streaming for {client_id}: {e}") + raise + + async def process_audio_chunk(self, client_id: str, audio_chunk: bytes) -> Optional[dict]: + """Send audio chunk to WebSocket and process responses.""" + if client_id not in self._streams: + logger.error(f"No active stream for client {client_id}") + return None + + try: + stream_data = self._streams[client_id] + websocket = stream_data["websocket"] + + # Send audio chunk + await websocket.send(audio_chunk) + + # Check for responses (non-blocking) + try: + while True: + response = await asyncio.wait_for(websocket.recv(), timeout=0.01) + result = json.loads(response) + + if result.get("type") == "Results": + channel = result.get("channel", {}) + alternatives = channel.get("alternatives", []) + + if alternatives: + alt = alternatives[0] + is_final = channel.get("is_final", False) + + if is_final: + # Accumulate final transcript and words + transcript = alt.get("transcript", "") + words = alt.get("words", []) + + if transcript.strip(): + stream_data["final_transcript"] += transcript + " " + stream_data["words"].extend(words) + + logger.debug(f"Final transcript chunk: {transcript}") + + except asyncio.TimeoutError: + # No response available, continue + pass + + return None # Streaming, no final result yet + + except Exception as e: + logger.error(f"Error processing audio chunk for {client_id}: {e}") + return None + + async def end_stream(self, client_id: str) -> dict: + """Close WebSocket connection and return final transcription.""" + if client_id not in self._streams: + logger.error(f"No active stream for client {client_id}") + return {"text": "", "words": [], "segments": []} + + try: + stream_data = self._streams[client_id] + websocket = stream_data["websocket"] + + # Send close message + close_msg = json.dumps({"type": "CloseStream"}) + await websocket.send(close_msg) + + # Wait a bit for final responses + try: + end_time = asyncio.get_event_loop().time() + 2.0 # 2 second timeout + while asyncio.get_event_loop().time() < end_time: + response = await asyncio.wait_for(websocket.recv(), timeout=0.5) + result = json.loads(response) + + if result.get("type") == "Results": + channel = result.get("channel", {}) + alternatives = channel.get("alternatives", []) + + if alternatives and channel.get("is_final", False): + alt = alternatives[0] + transcript = alt.get("transcript", "") + words = alt.get("words", []) + + if transcript.strip(): + stream_data["final_transcript"] += transcript + stream_data["words"].extend(words) + + except asyncio.TimeoutError: + pass + + # Close WebSocket + await websocket.close() + + # Prepare final result + final_transcript = stream_data["final_transcript"].strip() + final_words = stream_data["words"] + + logger.info(f"Deepgram streaming completed for {client_id}: {len(final_transcript)} chars, {len(final_words)} words") + + # Clean up + del self._streams[client_id] + + return { + "text": final_transcript, + "words": final_words, + "segments": [] + } + + except Exception as e: + logger.error(f"Error ending stream for {client_id}: {e}") + # Clean up on error + if client_id in self._streams: + del self._streams[client_id] + return {"text": "", "words": [], "segments": []} + + async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: + """For streaming provider, this method is not typically used.""" + logger.warning("transcribe() called on streaming provider - use streaming methods instead") + return {"text": "", "words": [], "segments": []} + + async def disconnect(self): + """Close all active WebSocket connections.""" + for client_id in list(self._streams.keys()): + try: + websocket = self._streams[client_id]["websocket"] + await websocket.close() + except Exception as e: + logger.error(f"Error closing WebSocket for {client_id}: {e}") + finally: + del self._streams[client_id] + + logger.info("All Deepgram streaming connections closed") + + +class DeepgramStreamConsumer: + """ + Deepgram consumer for Redis Streams architecture. + + Reads from: specified stream (client-specific or provider-specific) + Writes to: transcription:results:{session_id} + + This inherits from BaseAudioStreamConsumer and implements transcribe_audio(). + """ + + def __init__(self, redis_client, api_key: str = None, buffer_chunks: int = 30): + """ + Initialize Deepgram consumer. + + Dynamically discovers all audio:stream:* streams and claims them using Redis locks. + + Args: + redis_client: Connected Redis client + api_key: Deepgram API key (defaults to DEEPGRAM_API_KEY env var) + buffer_chunks: Number of chunks to buffer before transcribing (default: 30 = ~7.5s) + """ + import os + from advanced_omi_backend.services.audio_stream.consumer import BaseAudioStreamConsumer + + self.api_key = api_key or os.getenv("DEEPGRAM_API_KEY") + if not self.api_key: + raise ValueError("DEEPGRAM_API_KEY is required") + + # Initialize Deepgram provider + self.provider = DeepgramProvider(api_key=self.api_key) + + # Create a concrete subclass that implements transcribe_audio + class _ConcreteConsumer(BaseAudioStreamConsumer): + def __init__(inner_self, provider_name: str, redis_client, buffer_chunks: int): + super().__init__(provider_name, redis_client, buffer_chunks) + inner_self._deepgram_provider = self.provider + + async def transcribe_audio(inner_self, audio_data: bytes, sample_rate: int) -> dict: + """Transcribe using DeepgramProvider.""" + try: + result = await inner_self._deepgram_provider.transcribe( + audio_data=audio_data, + sample_rate=sample_rate, + diarize=True + ) + + # Calculate confidence + confidence = 0.0 + if result.get("words"): + confidences = [ + w.get("confidence", 0) + for w in result["words"] + if "confidence" in w + ] + if confidences: + confidence = sum(confidences) / len(confidences) + + return { + "text": result.get("text", ""), + "words": result.get("words", []), + "segments": result.get("segments", []), + "confidence": confidence + } + + except Exception as e: + logger.error(f"Deepgram transcription failed: {e}", exc_info=True) + raise + + # Instantiate the concrete consumer + self._consumer = _ConcreteConsumer("deepgram", redis_client, buffer_chunks) + + async def start_consuming(self): + """Delegate to base consumer.""" + return await self._consumer.start_consuming() + + async def stop(self): + """Delegate to base consumer.""" + return await self._consumer.stop() + + diff --git a/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py b/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py new file mode 100644 index 00000000..10da0058 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py @@ -0,0 +1,302 @@ +""" +Parakeet (NeMo) transcription provider implementations. + +Provides both batch and streaming transcription using NeMo's Parakeet ASR models. +""" + +import asyncio +import json +import logging +import tempfile +from typing import Dict, Optional + +import httpx +import numpy as np +import websockets +from easy_audio_interfaces.audio_interfaces import AudioChunk +from easy_audio_interfaces.filesystem import LocalFileSink + +from advanced_omi_backend.models.transcription import ( + BatchTranscriptionProvider, + StreamingTranscriptionProvider, +) + +logger = logging.getLogger(__name__) + +class ParakeetProvider(BatchTranscriptionProvider): + """Parakeet HTTP batch transcription provider.""" + + def __init__(self, service_url: str): + self.service_url = service_url.rstrip('/') + self.transcribe_url = f"{self.service_url}/transcribe" + + @property + def name(self) -> str: + return "parakeet" + + async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: + """Transcribe audio using Parakeet HTTP service.""" + try: + + logger.info(f"Sending {len(audio_data)} bytes to Parakeet service at {self.transcribe_url}") + + # Convert PCM bytes to audio file for upload + if sample_rate != 16000: + logger.warning(f"Sample rate {sample_rate} != 16000, audio may not be optimal") + + # Assume 16-bit PCM + audio_array = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) + audio_array = audio_array / np.iinfo(np.int16).max # Normalize to [-1, 1] + + # Create temporary WAV file + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file: + # sf.write(tmp_file.name, audio_array, 16000) # Force 16kHz + async with LocalFileSink(tmp_file.name, sample_rate, 1) as sink: + await sink.write(AudioChunk( + rate=sample_rate, + width=2, + channels=1, + audio=audio_data, + )) + + tmp_filename = tmp_file.name + + try: + # Upload file to Parakeet service + async with httpx.AsyncClient(timeout=180.0) as client: + with open(tmp_filename, "rb") as f: + files = {"file": ("audio.wav", f, "audio/wav")} + response = await client.post(self.transcribe_url, files=files) + + if response.status_code == 200: + result = response.json() + logger.info(f"Parakeet transcription successful: {len(result.get('text', ''))} chars, {len(result.get('words', []))} words") + return result + else: + error_msg = f"Parakeet service error: {response.status_code} - {response.text}" + logger.error(error_msg) + + # For 5xx errors, raise exception to trigger retry/failure handling + if response.status_code >= 500: + raise RuntimeError(f"Parakeet service unavailable: HTTP {response.status_code}") + + # For 4xx errors, return empty result (client error, won't retry) + return {"text": "", "words": [], "segments": []} + + finally: + # Clean up temporary file + if os.path.exists(tmp_filename): + os.unlink(tmp_filename) + + except Exception as e: + logger.error(f"Error calling Parakeet service: {e}") + raise e + + +class ParakeetStreamingProvider(StreamingTranscriptionProvider): + """Parakeet WebSocket streaming transcription provider.""" + + def __init__(self, service_url: str): + self.service_url = service_url.rstrip('/') + self.ws_url = service_url.replace("http://", "ws://").replace("https://", "wss://") + "/stream" + self._streams: Dict[str, Dict] = {} # client_id -> stream data + + @property + def name(self) -> str: + return "parakeet" + + async def start_stream(self, client_id: str, sample_rate: int = 16000, diarize: bool = False): + """Start a WebSocket connection for streaming transcription. + + Args: + client_id: Unique client identifier + sample_rate: Audio sample rate + diarize: Whether to enable speaker diarization (ignored - Parakeet doesn't support diarization) + """ + if diarize: + logger.warning(f"Parakeet streaming provider does not support diarization, ignoring diarize=True for client {client_id}") + try: + logger.info(f"Starting Parakeet streaming for client {client_id}") + + # Connect to WebSocket + websocket = await websockets.connect(self.ws_url) + + # Send transcribe event to start session + session_config = { + "vad_enabled": True, + "vad_silence_ms": 1000, + "time_interval_seconds": 30, + "return_interim_results": True, + "min_audio_seconds": 0.5 + } + + start_message = { + "type": "transcribe", + "session_id": client_id, + "config": session_config + } + + await websocket.send(json.dumps(start_message)) + + # Wait for session_started confirmation + response = await websocket.recv() + response_data = json.loads(response) + + if response_data.get("type") != "session_started": + raise RuntimeError(f"Failed to start session: {response_data}") + + # Store stream data + self._streams[client_id] = { + "websocket": websocket, + "sample_rate": sample_rate, + "session_id": client_id, + "interim_results": [], + "final_result": None + } + + logger.info(f"Parakeet WebSocket connected for client {client_id}") + + except Exception as e: + logger.error(f"Failed to start Parakeet streaming for {client_id}: {e}") + raise + + async def process_audio_chunk(self, client_id: str, audio_chunk: bytes) -> Optional[dict]: + """Send audio chunk to WebSocket and process responses.""" + if client_id not in self._streams: + logger.error(f"No active stream for client {client_id}") + return None + + try: + stream_data = self._streams[client_id] + websocket = stream_data["websocket"] + sample_rate = stream_data["sample_rate"] + + # Send audio_chunk event + chunk_message = { + "type": "audio_chunk", + "session_id": client_id, + "rate": sample_rate, + "width": 2, # 16-bit + "channels": 1 + } + + await websocket.send(json.dumps(chunk_message)) + await websocket.send(audio_chunk) + + # Check for responses (non-blocking) + try: + while True: + response = await asyncio.wait_for(websocket.recv(), timeout=0.01) + result = json.loads(response) + + if result.get("type") == "interim_result": + # Store interim result but don't return it (handled by backend differently) + stream_data["interim_results"].append(result) + logger.debug(f"Received interim result: {result.get('text', '')[:50]}...") + elif result.get("type") == "final_result": + # This shouldn't happen during chunk processing, but store it + stream_data["final_result"] = result + logger.debug(f"Received final result during chunk processing: {result.get('text', '')[:50]}...") + + except asyncio.TimeoutError: + # No response available, continue + pass + + return None # Streaming, no final result yet + + except Exception as e: + logger.error(f"Error processing audio chunk for {client_id}: {e}") + return None + + async def end_stream(self, client_id: str) -> dict: + """Close WebSocket connection and return final transcription.""" + if client_id not in self._streams: + logger.error(f"No active stream for client {client_id}") + return {"text": "", "words": [], "segments": []} + + try: + stream_data = self._streams[client_id] + websocket = stream_data["websocket"] + + # Send finalize event + finalize_message = { + "type": "finalize", + "session_id": client_id + } + await websocket.send(json.dumps(finalize_message)) + + # Wait for final result + try: + end_time = asyncio.get_event_loop().time() + 5.0 # 5 second timeout + while asyncio.get_event_loop().time() < end_time: + response = await asyncio.wait_for(websocket.recv(), timeout=1.0) + result = json.loads(response) + + if result.get("type") == "final_result": + stream_data["final_result"] = result + break + + except asyncio.TimeoutError: + logger.warning(f"Timeout waiting for final result from {client_id}") + + # Close WebSocket + await websocket.close() + + # Prepare final result + final_result = stream_data.get("final_result") + if final_result: + result_data = { + "text": final_result.get("text", ""), + "words": final_result.get("words", []), + "segments": final_result.get("segments", []) + } + else: + # Fallback: aggregate interim results if no final result received + interim_texts = [r.get("text", "") for r in stream_data["interim_results"]] + all_words = [] + for r in stream_data["interim_results"]: + all_words.extend(r.get("words", [])) + + result_data = { + "text": " ".join(interim_texts), + "words": all_words, + "segments": [] + } + + logger.info(f"Parakeet streaming completed for {client_id}: {len(result_data.get('text', ''))} chars") + + # Clean up + del self._streams[client_id] + + return result_data + + except Exception as e: + logger.error(f"Error ending stream for {client_id}: {e}") + # Clean up on error + if client_id in self._streams: + try: + await self._streams[client_id]["websocket"].close() + except: + pass + del self._streams[client_id] + return {"text": "", "words": [], "segments": []} + + async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: + """For streaming provider, this method is not typically used.""" + logger.warning("transcribe() called on streaming provider - use streaming methods instead") + return {"text": "", "words": [], "segments": []} + + async def disconnect(self): + """Close all active WebSocket connections.""" + for client_id in list(self._streams.keys()): + try: + websocket = self._streams[client_id]["websocket"] + await websocket.close() + except Exception as e: + logger.error(f"Error closing WebSocket for {client_id}: {e}") + finally: + del self._streams[client_id] + + logger.info("All Parakeet streaming connections closed") + + diff --git a/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py b/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py index 0445b394..b66b6f08 100644 --- a/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py +++ b/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py @@ -12,6 +12,7 @@ from typing import Dict, List, Optional import aiohttp +from aiohttp import ClientConnectorError, ClientTimeout logger = logging.getLogger(__name__) @@ -57,17 +58,16 @@ async def diarize_identify_match( Dictionary containing segments with matched text and speaker identification """ if not self.enabled: + logger.info(f"🎀 Speaker recognition disabled, returning empty result") return {} try: - logger.info(f"Diarizing, identifying, and matching words for {audio_path}") + logger.info(f"🎀 Identifying speakers for {audio_path}") # Read diarization source from existing config system from advanced_omi_backend.config import load_diarization_settings_from_file config = load_diarization_settings_from_file() diarization_source = config.get("diarization_source", "pyannote") - - logger.info(f"Using diarization source: {diarization_source}") async with aiohttp.ClientSession() as session: # Prepare the audio file for upload @@ -116,28 +116,42 @@ async def diarize_identify_match( endpoint = "/v1/diarize-identify-match" # Make the request to the consolidated endpoint + request_url = f"{self.service_url}{endpoint}" + logger.info(f"🎀 DEBUG: Making request to speaker service URL: {request_url}") + async with session.post( - f"{self.service_url}{endpoint}", + request_url, data=form_data, timeout=aiohttp.ClientTimeout(total=120), ) as response: + logger.info(f"🎀 Speaker service response status: {response.status}") + if response.status != 200: - logger.warning( - f"Speaker service returned status {response.status}: {await response.text()}" + response_text = await response.text() + logger.error( + f"🎀 ❌ Speaker service returned status {response.status}: {response_text}" ) return {} result = await response.json() - logger.info( - f"Speaker service ({diarization_source}) returned response with enhancement data" - ) + + # Log basic result info + num_segments = len(result.get("segments", [])) + logger.info(f"🎀 Speaker recognition returned {num_segments} segments") + return result + except ClientConnectorError as e: + logger.error(f"🎀 Failed to connect to speaker recognition service: {e}") + return {} + except ClientTimeout as e: + logger.error(f"🎀 Timeout connecting to speaker recognition service: {e}") + return {} except aiohttp.ClientError as e: - logger.warning(f"Failed to connect to speaker recognition service: {e}") + logger.warning(f"🎀 Client error during speaker recognition: {e}") return {} except Exception as e: - logger.error(f"Error during diarize-identify-match: {e}") + logger.error(f"🎀 Error during speaker recognition: {e}") return {} async def diarize_and_identify( @@ -158,10 +172,19 @@ async def diarize_and_identify( logger.warning("Words parameter is not implemented yet") if not self.enabled: + logger.warning("🎀 [DIARIZE] Speaker recognition is disabled") return {} try: - logger.info(f"Diarizing and identifying speakers in {audio_path}") + logger.info(f"🎀 [DIARIZE] Starting diarization and identification for {audio_path}") + + # Verify file exists and get info + if not os.path.exists(audio_path): + logger.error(f"🎀 [DIARIZE] ❌ Audio file does not exist: {audio_path}") + return {} + + file_size = os.path.getsize(audio_path) + logger.info(f"🎀 [DIARIZE] Audio file size: {file_size} bytes") # Call the speaker recognition service async with aiohttp.ClientSession() as session: @@ -171,46 +194,87 @@ async def diarize_and_identify( form_data.add_field( "file", audio_file, filename=Path(audio_path).name, content_type="audio/wav" ) - # Get current diarization settings - from advanced_omi_backend.controllers.system_controller import _diarization_settings - + # Get current diarization settings from config + from advanced_omi_backend.config import load_diarization_settings_from_file + + diarization_settings = load_diarization_settings_from_file() + # Add all diarization parameters for the diarize-and-identify endpoint - form_data.add_field("min_duration", str(_diarization_settings["min_duration"])) - form_data.add_field("similarity_threshold", str(_diarization_settings.get("similarity_threshold", 0.15))) - form_data.add_field("collar", str(_diarization_settings.get("collar", 2.0))) - form_data.add_field("min_duration_off", str(_diarization_settings.get("min_duration_off", 1.5))) - if _diarization_settings.get("min_speakers"): - form_data.add_field("min_speakers", str(_diarization_settings["min_speakers"])) - if _diarization_settings.get("max_speakers"): - form_data.add_field("max_speakers", str(_diarization_settings["max_speakers"])) + min_duration = diarization_settings.get("min_duration", 0.5) + similarity_threshold = diarization_settings.get("similarity_threshold", 0.15) + collar = diarization_settings.get("collar", 2.0) + min_duration_off = diarization_settings.get("min_duration_off", 1.5) + + form_data.add_field("min_duration", str(min_duration)) + form_data.add_field("similarity_threshold", str(similarity_threshold)) + form_data.add_field("collar", str(collar)) + form_data.add_field("min_duration_off", str(min_duration_off)) + + if diarization_settings.get("min_speakers"): + form_data.add_field("min_speakers", str(diarization_settings["min_speakers"])) + if diarization_settings.get("max_speakers"): + form_data.add_field("max_speakers", str(diarization_settings["max_speakers"])) + form_data.add_field("identify_only_enrolled", "false") # TODO: Implement proper user mapping between MongoDB ObjectIds and speaker service integer IDs # For now, hardcode to admin user (ID=1) since speaker service expects integer user_id form_data.add_field("user_id", "1") + endpoint_url = f"{self.service_url}/diarize-and-identify" + logger.info(f"🎀 [DIARIZE] Calling speaker service: {endpoint_url}") + logger.info( + f"🎀 [DIARIZE] Parameters: min_duration={min_duration}, " + f"similarity_threshold={similarity_threshold}, collar={collar}, " + f"min_duration_off={min_duration_off}, user_id=1" + ) + # Make the request async with session.post( - f"{self.service_url}/diarize-and-identify", + endpoint_url, data=form_data, timeout=aiohttp.ClientTimeout(total=120), ) as response: + logger.info(f"🎀 [DIARIZE] Response status: {response.status}") + if response.status != 200: + response_text = await response.text() logger.warning( - f"Speaker recognition service returned status {response.status}: {await response.text()}" + f"🎀 [DIARIZE] ❌ Speaker recognition service returned status {response.status}: {response_text}" ) return {} result = await response.json() - logger.info( - f"Speaker service returned {len(result.get('segments', []))} segments" - ) + segments_count = len(result.get('segments', [])) + logger.info(f"🎀 [DIARIZE] βœ… Speaker service returned {segments_count} segments") + + # Log details about identified speakers + if segments_count > 0: + identified_names = set() + for seg in result.get('segments', []): + identified_as = seg.get('identified_as') + if identified_as and identified_as != 'Unknown': + identified_names.add(identified_as) + + if identified_names: + logger.info(f"🎀 [DIARIZE] Identified speakers in segments: {identified_names}") + else: + logger.warning(f"🎀 [DIARIZE] No identified speakers found in {segments_count} segments") + return result + except ClientConnectorError as e: + logger.error(f"🎀 [DIARIZE] ❌ Failed to connect to speaker recognition service at {self.service_url}: {e}") + return {} + except asyncio.TimeoutError as e: + logger.error(f"🎀 [DIARIZE] ❌ Timeout connecting to speaker recognition service: {e}") + return {} except aiohttp.ClientError as e: - logger.warning(f"Failed to connect to speaker recognition service: {e}") + logger.warning(f"🎀 [DIARIZE] ❌ Client error during speaker recognition: {e}") return {} except Exception as e: - logger.error(f"Error during speaker diarization and identification: {e}") + logger.error(f"🎀 [DIARIZE] ❌ Error during speaker diarization and identification: {e}") + import traceback + logger.debug(traceback.format_exc()) return {} async def identify_speakers(self, audio_path: str, segments: List[Dict]) -> Dict[str, str]: @@ -333,18 +397,17 @@ def _process_diarization_result( # Assign the most common identified name, or unknown if none found if name_counts: - # Get the name with the highest count best_name = max(name_counts.items(), key=lambda x: x[1])[0] speaker_mapping[generic_speaker] = best_name else: - # Assign unknown speaker label speaker_mapping[generic_speaker] = f"unknown_speaker_{unknown_counter}" unknown_counter += 1 + logger.info(f"🎀 Speaker mapping: {speaker_mapping}") return speaker_mapping except Exception as e: - logger.error(f"Error processing diarization result: {e}") + logger.error(f"🎀 Error processing diarization result: {e}") return {} async def get_enrolled_speakers(self, user_id: Optional[str] = None) -> Dict: @@ -361,32 +424,167 @@ async def get_enrolled_speakers(self, user_id: Optional[str] = None) -> Dict: return {"speakers": []} try: - logger.info(f"Getting enrolled speakers from service: {self.service_url}") - async with aiohttp.ClientSession() as session: - # Use the /speakers endpoint - currently no user filtering in speaker service async with session.get( f"{self.service_url}/speakers", timeout=aiohttp.ClientTimeout(total=10), ) as response: if response.status != 200: - logger.warning( - f"Speaker service returned status {response.status}: {await response.text()}" - ) + logger.warning(f"🎀 Failed to get enrolled speakers: status {response.status}") return {"speakers": []} result = await response.json() speakers = result.get("speakers", []) - logger.info(f"Retrieved {len(speakers)} enrolled speakers from service") + logger.info(f"🎀 Retrieved {len(speakers)} enrolled speakers") return result except aiohttp.ClientError as e: - logger.warning(f"Failed to connect to speaker recognition service: {e}") + logger.warning(f"🎀 Failed to connect to speaker recognition service: {e}") return {"speakers": []} except Exception as e: - logger.error(f"Error getting enrolled speakers: {e}") + logger.error(f"🎀 Error getting enrolled speakers: {e}") return {"speakers": []} + async def check_if_enrolled_speaker_present( + self, + redis_client, + client_id: str, + session_id: str, + user_id: str, + transcription_results: List[dict] + ) -> tuple[bool, dict]: + """ + Check if any enrolled speakers are present in the transcription results. + + This extracts audio from Redis, runs speaker recognition, and checks if + any identified speakers match the user's enrolled speakers. + + Args: + redis_client: Redis client + client_id: Client identifier + session_id: Session identifier + user_id: User ID + transcription_results: List of transcription results from aggregator + + Returns: + Tuple of (enrolled_present: bool, speaker_result: dict) + - enrolled_present: True if enrolled speaker detected, False otherwise + - speaker_result: Full speaker recognition result dict with segments + """ + import tempfile + import uuid + from pathlib import Path + from advanced_omi_backend.utils.audio_extraction import extract_audio_for_results + from advanced_omi_backend.audio_utils import write_pcm_to_wav + + logger.info(f"🎀 [SPEAKER CHECK] Starting speaker check for session {session_id}") + logger.info(f"🎀 [SPEAKER CHECK] Client: {client_id}, User: {user_id}") + logger.info(f"🎀 [SPEAKER CHECK] Transcription results count: {len(transcription_results)}") + + # Get enrolled speakers for this user + logger.info(f"🎀 [SPEAKER CHECK] Fetching enrolled speakers for user {user_id}...") + enrolled_result = await self.get_enrolled_speakers(user_id) + enrolled_speakers = set(speaker["name"] for speaker in enrolled_result.get("speakers", [])) + + logger.info(f"🎀 [SPEAKER CHECK] Enrolled speakers: {enrolled_speakers}") + + if not enrolled_speakers: + logger.warning("🎀 [SPEAKER CHECK] No enrolled speakers found, allowing conversation") + return (True, {}) # If no enrolled speakers, allow all conversations + + # Extract audio chunks + logger.info(f"🎀 [SPEAKER CHECK] Extracting audio chunks from Redis...") + audio_data = await extract_audio_for_results( + redis_client=redis_client, + client_id=client_id, + session_id=session_id, + transcription_results=transcription_results + ) + + if not audio_data: + logger.warning("🎀 [SPEAKER CHECK] No audio data extracted, skipping speaker check") + return (False, {}) + + audio_size_kb = len(audio_data) / 1024 + audio_duration_sec = len(audio_data) / (16000 * 2) # 16kHz, 16-bit + logger.info( + f"🎀 [SPEAKER CHECK] Extracted audio: {audio_size_kb:.1f} KB, ~{audio_duration_sec:.1f}s" + ) + + # Write to temporary WAV file + temp_path = Path(tempfile.gettempdir()) / f"speech_check_{uuid.uuid4()}.wav" + logger.info(f"🎀 [SPEAKER CHECK] Writing audio to temp file: {temp_path}") + + try: + write_pcm_to_wav(audio_data, str(temp_path), sample_rate=16000, channels=1, sample_width=2) + + # Verify file was created + if temp_path.exists(): + file_size = temp_path.stat().st_size + logger.info(f"🎀 [SPEAKER CHECK] Temp WAV file created: {file_size} bytes") + else: + logger.error(f"🎀 [SPEAKER CHECK] ❌ Temp WAV file was not created!") + return (False, {}) + + # Run speaker recognition (diarize and identify) + logger.info(f"🎀 [SPEAKER CHECK] Calling diarize_and_identify with audio file...") + result = await self.diarize_and_identify( + audio_path=str(temp_path), + words=None, + user_id=user_id + ) + + logger.info(f"🎀 [SPEAKER CHECK] Speaker recognition result: {result}") + + # Check if any identified speakers are enrolled + identified_speakers = set() + segments_count = len(result.get("segments", [])) + logger.info(f"🎀 [SPEAKER CHECK] Processing {segments_count} segments from speaker recognition") + + for idx, segment in enumerate(result.get("segments", [])): + identified_name = segment.get("identified_as") + speaker_label = segment.get("speaker", "unknown") + segment_start = segment.get("start", 0) + segment_end = segment.get("end", 0) + + logger.debug( + f"🎀 [SPEAKER CHECK] Segment {idx+1}/{segments_count}: " + f"speaker={speaker_label}, identified_as={identified_name}, " + f"time=[{segment_start:.2f}s - {segment_end:.2f}s]" + ) + + if identified_name and identified_name != "Unknown": + identified_speakers.add(identified_name) + logger.info(f"🎀 [SPEAKER CHECK] Found identified speaker: {identified_name}") + + logger.info(f"🎀 [SPEAKER CHECK] All identified speakers: {identified_speakers}") + logger.info(f"🎀 [SPEAKER CHECK] Enrolled speakers: {enrolled_speakers}") + + matches = enrolled_speakers & identified_speakers + + if matches: + logger.info(f"🎀 [SPEAKER CHECK] βœ… MATCH! Enrolled speaker(s) detected: {matches}") + return (True, result) # Return both boolean and speaker recognition results + else: + logger.info( + f"🎀 [SPEAKER CHECK] ❌ NO MATCH. " + f"Identified: {identified_speakers}, Enrolled: {enrolled_speakers}" + ) + return (False, result) # Return both boolean and speaker recognition results + + except Exception as e: + logger.error(f"🎀 [SPEAKER CHECK] ❌ Speaker recognition check failed: {e}", exc_info=True) + return (False, {}) # Fail closed - don't create conversation on error + + finally: + # Clean up temp file + try: + if temp_path.exists(): + temp_path.unlink() + logger.debug(f"🎀 [SPEAKER CHECK] Cleaned up temp file: {temp_path}") + except Exception as cleanup_error: + logger.warning(f"🎀 [SPEAKER CHECK] Failed to remove temp file {temp_path}: {cleanup_error}") + async def health_check(self) -> bool: """ Check if the speaker recognition service is healthy and responding. @@ -403,7 +601,7 @@ async def health_check(self) -> bool: async with aiohttp.ClientSession() as session: # Use the /health endpoint if available, otherwise try a simple endpoint health_endpoints = ["/health", "/speakers"] - + for endpoint in health_endpoints: try: async with session.get( diff --git a/backends/advanced/src/advanced_omi_backend/transcript_coordinator.py b/backends/advanced/src/advanced_omi_backend/transcript_coordinator.py deleted file mode 100644 index 696a7087..00000000 --- a/backends/advanced/src/advanced_omi_backend/transcript_coordinator.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Transcript Coordinator for Event-Driven Memory Processing. - -This module provides proper async coordination between transcript completion and memory processing, -eliminating polling/retry mechanisms in favor of asyncio events. -""" - -import asyncio -import logging -from typing import Dict, Optional - -logger = logging.getLogger(__name__) - - -class TranscriptionFailed(Exception): - """Exception raised when transcription fails.""" - pass - - -class TranscriptCoordinator: - """Coordinates transcript completion events across the system. - - This replaces polling/retry mechanisms with proper asyncio event coordination. - When transcription is saved to the database, it signals waiting memory processors. - """ - - def __init__(self): - self.transcript_events: Dict[str, asyncio.Event] = {} - self.transcript_failures: Dict[str, str] = {} # audio_uuid -> error_message - self._lock = asyncio.Lock() - logger.info("TranscriptCoordinator initialized") - - async def wait_for_transcript_completion(self, audio_uuid: str, timeout: float = 30.0) -> bool: - """Wait for transcript completion for the given audio_uuid. - - Args: - audio_uuid: The audio UUID to wait for - timeout: Maximum time to wait in seconds - - Returns: - True if transcript was completed successfully, False if timeout or failed - - Raises: - TranscriptionFailed: If transcription failed with an error - """ - async with self._lock: - # Check if there's already a failure recorded before creating/waiting on event - if audio_uuid in self.transcript_failures: - error_msg = self.transcript_failures.pop(audio_uuid) - logger.error(f"Transcript already failed for {audio_uuid}: {error_msg}") - raise TranscriptionFailed(f"Transcription failed: {error_msg}") - - # Create event for this audio_uuid if it doesn't exist - if audio_uuid not in self.transcript_events: - self.transcript_events[audio_uuid] = asyncio.Event() - logger.info(f"Created transcript wait event for {audio_uuid}") - - event = self.transcript_events[audio_uuid] - - try: - # Wait for the transcript to be ready - await asyncio.wait_for(event.wait(), timeout=timeout) - - # Check if this was a failure (covers failures signaled during the wait) - if audio_uuid in self.transcript_failures: - error_msg = self.transcript_failures[audio_uuid] - logger.error(f"Transcript failed for {audio_uuid}: {error_msg}") - # Clean up failure tracking - self.transcript_failures.pop(audio_uuid, None) - raise TranscriptionFailed(f"Transcription failed: {error_msg}") - - logger.info(f"Transcript ready event received for {audio_uuid}") - return True - except asyncio.TimeoutError: - logger.warning(f"Transcript wait timeout ({timeout}s) for {audio_uuid}") - return False - finally: - # Clean up the event - async with self._lock: - self.transcript_events.pop(audio_uuid, None) - self.transcript_failures.pop(audio_uuid, None) - logger.debug(f"Cleaned up transcript event for {audio_uuid}") - - def signal_transcript_ready(self, audio_uuid: str): - """Signal that transcript is ready for the given audio_uuid. - - This should be called by TranscriptionManager after successfully saving - transcript segments to the database. - - Args: - audio_uuid: The audio UUID that has completed transcription - """ - if audio_uuid in self.transcript_events: - self.transcript_events[audio_uuid].set() - logger.info(f"Signaled transcript ready for {audio_uuid}") - else: - logger.debug(f"No waiting processors for transcript {audio_uuid}") - - def signal_transcript_failed(self, audio_uuid: str, error_message: str): - """Signal that transcript processing failed for the given audio_uuid. - - This should be called by TranscriptionManager when transcription fails. - Waiting processes will be unblocked and can check for failure status. - - Args: - audio_uuid: The audio UUID that failed transcription - error_message: Description of the failure - """ - # Store the failure message - self.transcript_failures[audio_uuid] = error_message - - # Always create an Event for the audio_uuid if missing so future waiters see the failure immediately - if audio_uuid not in self.transcript_events: - self.transcript_events[audio_uuid] = asyncio.Event() - logger.debug(f"Created transcript event for failed {audio_uuid}") - - # Set the event to unblock waiting processes (current and future) - self.transcript_events[audio_uuid].set() - logger.error(f"Signaled transcript failed for {audio_uuid}: {error_message}") - - def cleanup_transcript_events_for_client(self, client_id: str): - """Clean up any transcript events associated with a disconnected client. - - This prevents memory leaks and orphaned events when clients disconnect - before transcription completes. - - Args: - client_id: The client ID that disconnected - """ - # Since we don't track client_id -> audio_uuid mapping here, - # this is a safety method that can be called but currently has limited scope - # In the future, we could enhance this by tracking client associations - events_cleaned = 0 - for audio_uuid in list(self.transcript_events.keys()): - # For now, we'll rely on the timeout mechanism in wait_for_transcript_completion - # Future enhancement: track client_id associations to enable targeted cleanup - pass - - if events_cleaned > 0: - logger.info(f"Cleaned up {events_cleaned} transcript events for disconnected client {client_id}") - else: - logger.debug(f"No transcript events to clean up for client {client_id}") - - async def cleanup_stale_events(self, max_age_seconds: float = 300.0): - """Clean up any stale events that might be left over. - - This is a safety mechanism to prevent memory leaks if events are not - properly cleaned up during normal operation. - - Args: - max_age_seconds: Maximum age for events before cleanup - """ - async with self._lock: - # For now, just log the count - in a real implementation you'd track creation times - stale_count = len(self.transcript_events) - if stale_count > 0: - logger.warning(f"Found {stale_count} potentially stale transcript events") - - def get_waiting_count(self) -> int: - """Get the number of currently waiting transcript events.""" - return len(self.transcript_events) - - -# Global singleton instance -_transcript_coordinator: Optional[TranscriptCoordinator] = None - - -def get_transcript_coordinator() -> TranscriptCoordinator: - """Get the global TranscriptCoordinator instance.""" - global _transcript_coordinator - if _transcript_coordinator is None: - _transcript_coordinator = TranscriptCoordinator() - return _transcript_coordinator diff --git a/backends/advanced/src/advanced_omi_backend/transcription.py b/backends/advanced/src/advanced_omi_backend/transcription.py deleted file mode 100644 index f6ac919f..00000000 --- a/backends/advanced/src/advanced_omi_backend/transcription.py +++ /dev/null @@ -1,1150 +0,0 @@ -import asyncio -import logging -import os -import time -import uuid -from datetime import UTC, datetime -from typing import Optional - -from advanced_omi_backend.client_manager import get_client_manager -from advanced_omi_backend.config import ( - get_conversation_stop_settings, - get_speech_detection_settings, - load_diarization_settings_from_file, -) -from advanced_omi_backend.database import ConversationsRepository, conversations_col -from advanced_omi_backend.llm_client import async_generate -from advanced_omi_backend.processors import ( - AudioCroppingItem, - MemoryProcessingItem, - get_processor_manager, -) -from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient -from advanced_omi_backend.transcript_coordinator import get_transcript_coordinator -from advanced_omi_backend.transcription_providers import ( - BaseTranscriptionProvider, - get_transcription_provider, -) -from wyoming.audio import AudioChunk - -# ASR Configuration -TRANSCRIPTION_PROVIDER = os.getenv("TRANSCRIPTION_PROVIDER") # Optional: 'deepgram' or 'parakeet' - -logger = logging.getLogger(__name__) - - -class AudioTimeline: - """Track audio timeline for proper speech gap detection.""" - - def __init__(self): - self.start_time = time.time() - self.total_samples = 0 - self.sample_rate = None - - def add_chunk(self, chunk: AudioChunk): - """Add audio chunk and update timeline.""" - if chunk.audio and len(chunk.audio) > 0: - # Assuming 16-bit PCM audio (2 bytes per sample) - self.total_samples += len(chunk.audio) // 2 - self.sample_rate = chunk.rate - - @property - def current_position(self) -> float: - """Get current position in audio stream (seconds).""" - if not self.sample_rate: - return 0.0 - return self.total_samples / self.sample_rate - - def reset(self): - """Reset timeline for new audio session.""" - self.start_time = time.time() - self.total_samples = 0 - - -class SpeechActivityAnalyzer: - """Analyze transcripts for speech activity after transcription.""" - - def __init__(self, audio_timeline: AudioTimeline): - config = get_conversation_stop_settings() - self.speech_inactivity_threshold = config["speech_inactivity_threshold"] - self.min_word_confidence = config["min_word_confidence"] - self.audio_timeline = audio_timeline - - def analyze_transcript_activity(self, transcript_data: dict) -> dict: - """ - Analyze transcript for speech activity. - - Returns: - dict: { - "has_speech": bool, - "last_word_time": float or None, - "speech_gap_seconds": float or None, - "word_count": int - } - """ - words = transcript_data.get("words", []) - - # Filter by confidence - valid_words = [ - w for w in words - if w.get("confidence", 0) >= self.min_word_confidence - ] - - if not valid_words: - return { - "has_speech": False, - "last_word_time": None, - "speech_gap_seconds": None, - "word_count": 0 - } - - # Find last word timestamp - last_word = valid_words[-1] - last_word_end = last_word.get("end", 0) - - # Calculate speech gap using audio timeline - current_audio_position = self.audio_timeline.current_position - speech_gap = current_audio_position - last_word_end if current_audio_position else None - - return { - "has_speech": True, - "last_word_time": last_word_end, - "speech_gap_seconds": speech_gap, - "word_count": len(valid_words) - } - - -class TranscriptionManager: - """Manages transcription using any configured transcription provider.""" - - def __init__(self, chunk_repo=None, processor_manager=None): - self.provider: Optional[BaseTranscriptionProvider] = get_transcription_provider( - TRANSCRIPTION_PROVIDER - ) - self._current_audio_uuid = None - self._audio_buffer = [] # Buffer for collecting audio chunks - self._audio_start_time = None # Track when audio collection started - self._max_collection_time = 600.0 # 10 minutes timeout - allow longer conversations - self._current_transaction_id = None # Track current debug transaction - self.chunk_repo = chunk_repo # Database repository for chunks - self.client_manager = get_client_manager() # Cached client manager instance - self.processor_manager = ( - processor_manager # Reference to processor manager for completion tracking - ) - self._client_id = None - - # Collection state tracking - self._collecting = False - self._collection_task = None - - # Audio timeline for speech gap detection - self._audio_timeline = AudioTimeline() - - # Buffer monitoring for periodic transcription (batch providers) - self._buffer_start_time = None - config = get_conversation_stop_settings() - self._max_buffer_duration = config["transcription_buffer_seconds"] - self._transcribing = False # Track transcription state - self._last_word_time = None # Track last word for conversation closure - - # Optional speaker recognition - self.speaker_client = SpeakerRecognitionClient() - if self.speaker_client.enabled: - logger.info("Speaker recognition integration enabled") - - def _get_current_client(self): - """Get the current client state using ClientManager.""" - if not self._client_id: - return None - return self.client_manager.get_client(self._client_id) - - async def connect(self, client_id: str | None = None): - """Initialize transcription service for the client.""" - self._client_id = client_id - - if not self.provider: - raise Exception("No transcription provider configured") - - try: - await self.provider.connect(client_id) - logger.info( - f"{self.provider.name} transcription initialized for client {self._client_id}" - ) - except Exception as e: - logger.error(f"Failed to connect to {self.provider.name} transcription service: {e}") - raise - - async def process_collected_audio(self): - """Unified processing for all transcription providers.""" - logger.info(f"πŸš€ process_collected_audio called for client {self._client_id}") - logger.info( - f"πŸ“Š Current state - buffer size: {len(self._audio_buffer) if self._audio_buffer else 0}, collecting: {self._collecting}" - ) - - if not self.provider: - logger.error("No transcription provider available") - return - - # Cancel collection timeout task first to prevent interference - if self._collection_task and not self._collection_task.done(): - logger.info(f"πŸ›‘ Cancelling collection timeout task before processing") - self._collection_task.cancel() - try: - await self._collection_task - except asyncio.CancelledError: - logger.info(f"βœ… Collection task cancelled successfully") - except Exception as e: - logger.error(f"❌ Error cancelling collection task: {e}") - - # Get transcript from provider - try: - transcript_result = await self._get_transcript() - # Process the result uniformly - await self._process_transcript_result(transcript_result) - except asyncio.CancelledError: - raise - except Exception as e: - # Signal transcription failure - logger.exception(f"Transcription failed for {self._current_audio_uuid}: {e}") - if self._current_audio_uuid: - # Update database status to FAILED - if self.chunk_repo: - await self.chunk_repo.update_transcription_status( - self._current_audio_uuid, "FAILED", error_message=str(e) - ) - # Signal coordinator about failure - coordinator = get_transcript_coordinator() - coordinator.signal_transcript_failed(self._current_audio_uuid, str(e)) - - async def _get_transcript(self): - """Get transcript from any provider using unified interface.""" - if not self.provider: - logger.error("No transcription provider available") - return None - - try: - # For all providers, combine collected audio and call transcribe - combined_audio = b"".join(chunk.audio for chunk in self._audio_buffer if chunk.audio) - sample_rate = self._get_sample_rate() - - if not combined_audio or not sample_rate: - logger.warning("No audio data or sample rate available for transcription") - return None - - # Check if we should request diarization based on configuration - config = load_diarization_settings_from_file() - diarization_source = config.get("diarization_source", "pyannote") - - # Request diarization if using Deepgram as diarization source - should_diarize = (diarization_source == "deepgram" and - self.provider.name in ["Deepgram", "Deepgram-Streaming"]) - - if should_diarize: - logger.info(f"Requesting diarization from {self.provider.name} (diarization_source=deepgram)") - - return await self.provider.transcribe(combined_audio, sample_rate, diarize=should_diarize) - - except Exception as e: - logger.error(f"Error getting transcript from {self.provider.name}: {e}") - # Clean up buffer before re-raising - self._audio_buffer.clear() - self._audio_start_time = None - self._collecting = False - raise e - finally: - # Clear the buffer for all provider types (in case of success) - self._audio_buffer.clear() - self._audio_start_time = None - self._collecting = False - - def _get_sample_rate(self): - """Get sample rate from client state or audio buffer.""" - current_client = self._get_current_client() - if current_client and current_client.sample_rate: - return current_client.sample_rate - elif self._audio_buffer: - return self._audio_buffer[0].rate - return None - - async def _process_transcript_result(self, transcript_result): - """Process transcript result uniformly for all providers.""" - if not transcript_result or not self._current_audio_uuid: - logger.info(f"⚠️ No transcript result to process for {self._current_audio_uuid}") - # Even with no transcript, signal completion to unblock memory processing - if self._current_audio_uuid: - coordinator = get_transcript_coordinator() - coordinator.signal_transcript_ready(self._current_audio_uuid) - logger.info( - f"⚠️ Signaled transcript completion (no data) for {self._current_audio_uuid}" - ) - return - - start_time = time.time() - - try: - # Store raw transcript data - provider_name = self.provider.name if self.provider else "unknown" - logger.info(f"transcript_result type={type(transcript_result)}, content preview: {str(transcript_result)[:200]}") - if self.chunk_repo: - await self.chunk_repo.store_raw_transcript_data( - self._current_audio_uuid, transcript_result, provider_name - ) - logger.info(f"Successfully stored raw transcript data for {self._current_audio_uuid}") - - # Normalize transcript result - normalized_result = self._normalize_transcript_result(transcript_result) - if not normalized_result.get("text"): - logger.warning( - f"No text in normalized transcript result for {self._current_audio_uuid}" - ) - # Signal completion even with empty text to unblock memory processing - coordinator = get_transcript_coordinator() - coordinator.signal_transcript_ready(self._current_audio_uuid) - logger.warning( - f"⚠️ Signaled transcript completion (empty text) for {self._current_audio_uuid}" - ) - return - - # Get speaker diarization with word matching (if available) - final_segments = [] - # Prepare transcript data for speaker service (define early so it's available for fallback) - transcript_data = { - "words": normalized_result.get("words", []), - "text": normalized_result.get("text", ""), - } - # SPEECH DETECTION: Analyze transcript for meaningful speech - speech_analysis = self._analyze_speech(transcript_data) - logger.info(f"🎯 Speech analysis for {self._current_audio_uuid}: {speech_analysis}") - - # Mark audio_chunks with speech detection results - if self.chunk_repo: - await self.chunk_repo.update_speech_detection( - self._current_audio_uuid, - has_speech=speech_analysis["has_speech"], - **{k: v for k, v in speech_analysis.items() if k != "has_speech"} - ) - - # Create conversation only if speech is detected - conversation_id = None - if speech_analysis["has_speech"]: - conversation_id = await self._create_conversation( - self._current_audio_uuid, transcript_data, speech_analysis - ) - if conversation_id: - logger.info(f"βœ… Created conversation {conversation_id} for detected speech in {self._current_audio_uuid}") - else: - logger.error(f"❌ Failed to create conversation for {self._current_audio_uuid}") - else: - logger.info(f"⏭️ No speech detected in {self._current_audio_uuid}: {speech_analysis.get('reason', 'Unknown reason')}") - # Update transcript status to EMPTY for silent audio - if self.chunk_repo: - await self.chunk_repo.update_transcription_status( - self._current_audio_uuid, "EMPTY", provider=provider_name - ) - # Signal completion but don't queue memory processing - coordinator = get_transcript_coordinator() - coordinator.signal_transcript_ready(self._current_audio_uuid) - return - - # SPEECH GAP ANALYSIS: Check for conversation closure (only if conversation exists) - if conversation_id: - analyzer = SpeechActivityAnalyzer(self._audio_timeline) - activity = analyzer.analyze_transcript_activity(transcript_data) - - last_word_str = f"{activity['last_word_time']:.1f}s" if activity['last_word_time'] else 'N/A' - gap_str = f"{activity['speech_gap_seconds']:.1f}s" if activity['speech_gap_seconds'] else 'N/A' - logger.info( - f"πŸ“Š Speech activity analysis for {self._client_id}: " - f"words={activity['word_count']}, " - f"last_word={last_word_str}, " - f"gap={gap_str}" - ) - - # Check if we should close due to inactivity - if activity['speech_gap_seconds'] and \ - activity['speech_gap_seconds'] > analyzer.speech_inactivity_threshold: - logger.info( - f"πŸ’€ No speech for {activity['speech_gap_seconds']:.1f}s, " - f"closing conversation for {self._client_id}" - ) - await self._trigger_conversation_close() - # Signal completion and return (conversation closed) - coordinator = get_transcript_coordinator() - coordinator.signal_transcript_ready(self._current_audio_uuid) - return - else: - # Update last word time for next analysis - if activity['last_word_time']: - self._last_word_time = activity['last_word_time'] - logger.debug(f"Speech detected, continuing collection for {self._client_id}") - - # ONLY process speaker diarization if speech was detected - final_segments = [] - if self.speaker_client.enabled and self._current_audio_uuid and self.chunk_repo: - try: - # Get audio file path from database - chunk_data = await self.chunk_repo.get_chunk(self._current_audio_uuid) - if chunk_data and "audio_path" in chunk_data: - audio_path = chunk_data["audio_path"] - full_audio_path = f"/app/audio_chunks/{audio_path}" - - logger.info( - f"🎀 Getting speaker diarization with word matching for: {full_audio_path}" - ) - - # Get user_id from client state - current_client = self._get_current_client() - user_id = current_client.user_id if current_client else None - - # Call new speaker service endpoint - speaker_result = await self.speaker_client.diarize_identify_match( - full_audio_path, transcript_data, user_id=user_id - ) - - if speaker_result and speaker_result.get("segments"): - final_segments = speaker_result["segments"] - logger.info( - f"🎀 Speaker service returned {len(final_segments)} segments with matched text" - ) - # Debug: Log first few segments to see text content - for i, seg in enumerate(final_segments[:3]): - logger.info(f"πŸ” DEBUG: Segment {i}: text='{seg.get('text', 'MISSING')}', speaker={seg.get('speaker', 'UNKNOWN')}") - else: - logger.info("🎀 Speaker service returned no segments") - else: - logger.warning("No audio path found for speaker diarization") - - except Exception as e: - logger.warning(f"Speaker diarization with matching failed: {e}") - - # Only store segments if we got them from speaker service - if not final_segments: - logger.info( - f"πŸ“ No diarization available - creating single segment from raw transcript for {self._current_audio_uuid}" - ) - # Create a single segment from the raw transcript when speaker recognition is disabled - if transcript_data and transcript_data.get("text"): - final_segments = [{ - "text": transcript_data["text"], - "start": 0.0, - "end": 0.0, # Duration unknown without audio analysis - "speaker": "", - "speaker_id": "", - "confidence": 1.0, - }] - - # Store final segments with required fields - if self.chunk_repo and final_segments: - # Process segments for storage - segments_to_store = [] - speakers_found = set() - speaker_names = {} - - for segment in final_segments: - # Add required fields for database storage - segment_to_store = { - "text": segment.get("text", ""), - "start": segment.get("start", 0.0), - "end": segment.get("end", 0.0), - "speaker": segment.get("identified_as") or segment.get("speaker", ""), - "speaker_id": segment.get("speaker_id", ""), - "confidence": segment.get("confidence", 0.0), - "chunk_sequence": 0, - "absolute_timestamp": time.time() + segment.get("start", 0.0), - } - segments_to_store.append(segment_to_store) - - # Store segments in audio_chunks (legacy support) - await self.chunk_repo.add_transcript_segment( - self._current_audio_uuid, segment_to_store - ) - - # Collect speaker information - speaker_name = segment.get("identified_as") or segment.get("speaker") - if speaker_name: - speakers_found.add(speaker_name) - # Map speaker_id to name if available - speaker_id = segment.get("speaker_id", "") - if speaker_id: - speaker_names[speaker_id] = speaker_name - - # Add speakers to audio_chunks (legacy support) - for speaker in speakers_found: - await self.chunk_repo.add_speaker(self._current_audio_uuid, speaker) - - # CRITICAL: Update conversation with transcript data - if conversation_id: - try: - conversations_repo = ConversationsRepository(conversations_col) - - # Check if this is the first transcript for this conversation - conversation = await conversations_repo.get_conversation(conversation_id) - if conversation and not conversation.get("active_transcript_version"): - # This is the first transcript - create initial version - version_id = await conversations_repo.create_transcript_version( - conversation_id=conversation_id, - segments=segments_to_store, - provider="speech_detection", - raw_data={} - ) - if version_id: - # Activate this version - await conversations_repo.activate_transcript_version(conversation_id, version_id) - logger.info(f"βœ… Created and activated initial transcript version {version_id} for conversation {conversation_id}") - - # Generate title and summary with speaker information - title = await self._generate_title_with_speakers(segments_to_store) - summary = await self._generate_summary_with_speakers(segments_to_store) - - # Update conversation with speaker info, title, summary and metadata - update_data = { - "title": title, - "summary": summary, - "speaker_names": speaker_names, - "updated_at": datetime.now(UTC) - } - await conversations_repo.update_conversation(conversation_id, update_data) - - logger.info(f"βœ… Updated conversation {conversation_id} with {len(segments_to_store)} transcript segments, {len(speakers_found)} speakers, and speaker-aware title/summary") - except Exception as e: - logger.error(f"Failed to update conversation {conversation_id} with transcript data: {e}") - - # Update client state - current_client = self._get_current_client() - if current_client: - current_client.update_transcript_received() - - # Signal transcript coordinator - coordinator = get_transcript_coordinator() - coordinator.signal_transcript_ready(self._current_audio_uuid) - - # Queue memory processing now that transcription is complete (only for conversations with speech) - if conversation_id: - await self._queue_memory_processing(conversation_id) - - # Queue audio cropping if we have diarization segments and cropping is enabled - if final_segments and os.getenv("AUDIO_CROPPING_ENABLED", "true").lower() == "true": - await self._queue_diarization_based_cropping(final_segments) - - # Update database transcription status - if self.chunk_repo: - status = "EMPTY" if not normalized_result.get("text").strip() else "COMPLETED" - await self.chunk_repo.update_transcription_status( - self._current_audio_uuid, status, provider=provider_name - ) - - # Mark transcription as completed - if self.processor_manager and self._client_id: - self.processor_manager.track_processing_stage( - self._client_id, - "transcription", - "completed", - { - "audio_uuid": self._current_audio_uuid, - "segments": len(final_segments), - "provider": provider_name, - }, - ) - - except Exception as e: - logger.error(f"Error processing transcript result: {e}") - # Update database transcription status to failed - if self.chunk_repo and self._current_audio_uuid: - await self.chunk_repo.update_transcription_status( - self._current_audio_uuid, "FAILED", error_message=str(e) - ) - finally: - # Log total processing time - total_duration = time.time() - start_time - logger.info( - f"⏱️ Total transcript processing time: {total_duration:.2f}s for client {self._client_id}" - ) - - def _normalize_transcript_result(self, transcript_result): - """Normalize transcript result to consistent format.""" - if isinstance(transcript_result, str): - # Handle string response (legacy offline ASR) - return {"text": transcript_result, "words": [], "segments": []} - elif isinstance(transcript_result, dict): - # Handle dict response (modern providers) - return { - "text": transcript_result.get("text", ""), - "words": transcript_result.get("words", []), - "segments": transcript_result.get("segments", []), - } - else: - # Invalid format - return {"text": "", "words": [], "segments": []} - - # REMOVED: All segment creation methods have been removed. - # Segments are now only created by the speaker service endpoint /v1/diarize-identify-match - # which handles diarization, speaker identification, and word-to-speaker matching. - # This keeps all the segment creation logic in one place (speaker service). - - def _analyze_speech(self, transcript_data: dict): - """Analyze transcript for meaningful speech to determine if conversation should be created.""" - - settings = get_speech_detection_settings() - words = transcript_data.get("words", []) - - # Filter by confidence - valid_words = [ - w for w in words - if w.get("confidence", 0) >= settings["min_confidence"] - ] - - if len(valid_words) < settings["min_words"]: - return {"has_speech": False, "reason": f"Not enough valid words ({len(valid_words)} < {settings['min_words']})"} - - # Calculate speech duration - if valid_words: - speech_duration = valid_words[-1].get("end", 0) - valid_words[0].get("start", 0) - - return { - "has_speech": True, - "word_count": len(valid_words), - "speech_start": valid_words[0].get("start", 0), - "speech_end": valid_words[-1].get("end", 0), - "duration": speech_duration - } - - # Fallback for transcripts without detailed word timing - text = transcript_data.get("text", "").strip() - if text: - word_count = len(text.split()) - if word_count >= settings["min_words"]: - return { - "has_speech": True, - "word_count": word_count, - "speech_start": 0.0, - "speech_end": 0.0, # Duration unknown - "duration": 0.0, - "fallback": True - } - - return {"has_speech": False, "reason": "No meaningful speech content detected"} - - async def _create_conversation(self, audio_uuid: str, transcript_data: dict, speech_analysis: dict): - """Create conversation entry for detected speech.""" - try: - # Get audio session info from audio_chunks - audio_session = await self.chunk_repo.get_chunk(audio_uuid) - if not audio_session: - logger.error(f"No audio session found for {audio_uuid}") - return None - - # Create conversation data (title and summary will be generated after speaker recognition) - conversation_id = str(uuid.uuid4()) - conversation_data = { - "conversation_id": conversation_id, - "audio_uuid": audio_uuid, - "user_id": audio_session["user_id"], - "client_id": audio_session["client_id"], - "title": "Processing...", # Placeholder - will be updated after speaker recognition - "summary": "Processing...", # Placeholder - will be updated after speaker recognition - - # Versioned system (source of truth) - "transcript_versions": [], - "active_transcript_version": None, - "memory_versions": [], - "active_memory_version": None, - - # Legacy compatibility fields (auto-populated on read) - # Note: These will be auto-populated from active versions when retrieved - - "duration_seconds": speech_analysis.get("duration", 0.0), - "speech_start_time": speech_analysis.get("speech_start", 0.0), - "speech_end_time": speech_analysis.get("speech_end", 0.0), - "speaker_names": {}, - "action_items": [], - "created_at": datetime.now(UTC), - "updated_at": datetime.now(UTC), - "session_start": datetime.fromtimestamp(audio_session.get("timestamp", 0), tz=UTC), - "session_end": datetime.now(UTC), - } - - # Create conversation in conversations collection - conversations_repo = ConversationsRepository(conversations_col) - await conversations_repo.create_conversation(conversation_data) - - # Mark audio_chunks as having speech and link to conversation - await self.chunk_repo.mark_conversation_created(audio_uuid, conversation_id) - - logger.info(f"βœ… Created conversation {conversation_id} for audio {audio_uuid} (speech detected)") - return conversation_id - - except Exception as e: - logger.error(f"Failed to create conversation for {audio_uuid}: {e}", exc_info=True) - return None - - async def _generate_title(self, text: str) -> str: - """Generate an LLM-powered title from conversation text.""" - if not text or len(text.strip()) < 10: - return "Conversation" - - try: - prompt = f"""Generate a concise, descriptive title (3-6 words) for this conversation transcript: - -"{text[:500]}" - -Rules: -- Maximum 6 words -- Capture the main topic or theme -- No quotes or special characters -- Examples: "Planning Weekend Trip", "Work Project Discussion", "Medical Appointment" - -Title:""" - - title = await async_generate(prompt, temperature=0.3) - return title.strip().strip('"').strip("'") or "Conversation" - - except Exception as e: - logger.warning(f"Failed to generate LLM title: {e}") - # Fallback to simple title generation - words = text.split()[:6] - title = " ".join(words) - return title[:40] + "..." if len(title) > 40 else title or "Conversation" - - async def _generate_summary(self, text: str) -> str: - """Generate an LLM-powered summary from conversation text.""" - if not text or len(text.strip()) < 10: - return "No content" - - try: - prompt = f"""Generate a brief, informative summary (1-2 sentences, max 120 characters) for this conversation: - -"{text[:1000]}" - -Rules: -- Maximum 120 characters -- 1-2 complete sentences -- Capture key topics and outcomes -- Use present tense -- Be specific and informative - -Summary:""" - - summary = await async_generate(prompt, temperature=0.3) - return summary.strip().strip('"').strip("'") or "No content" - - except Exception as e: - logger.warning(f"Failed to generate LLM summary: {e}") - # Fallback to simple summary generation - return text[:120] + "..." if len(text) > 120 else text or "No content" - - async def _generate_title_with_speakers(self, segments: list) -> str: - """Generate an LLM-powered title from conversation segments with speaker information.""" - if not segments: - return "Conversation" - - # Format conversation with speaker names - conversation_text = "" - for segment in segments[:10]: # Use first 10 segments for title generation - speaker = segment.get("speaker", "") - text = segment.get("text", "").strip() - if text: - if speaker: - conversation_text += f"{speaker}: {text}\n" - else: - conversation_text += f"{text}\n" - - if not conversation_text.strip(): - return "Conversation" - - try: - prompt = f"""Generate a concise title (max 40 characters) for this conversation: - -"{conversation_text[:500]}" - -Rules: -- Maximum 40 characters -- Include speaker names if relevant -- Capture the main topic -- Be specific and informative - -Title:""" - - title = await async_generate(prompt, temperature=0.3) - title = title.strip().strip('"').strip("'") - return title[:40] + "..." if len(title) > 40 else title or "Conversation" - - except Exception as e: - logger.warning(f"Failed to generate LLM title with speakers: {e}") - # Fallback to simple title generation - words = conversation_text.split()[:6] - title = " ".join(words) - return title[:40] + "..." if len(title) > 40 else title or "Conversation" - - async def _generate_summary_with_speakers(self, segments: list) -> str: - """Generate an LLM-powered summary from conversation segments with speaker information.""" - if not segments: - return "No content" - - # Format conversation with speaker names - conversation_text = "" - speakers_in_conv = set() - for segment in segments: - speaker = segment.get("speaker", "") - text = segment.get("text", "").strip() - if text: - if speaker: - conversation_text += f"{speaker}: {text}\n" - speakers_in_conv.add(speaker) - else: - conversation_text += f"{text}\n" - - if not conversation_text.strip(): - return "No content" - - try: - prompt = f"""Generate a brief, informative summary (1-2 sentences, max 120 characters) for this conversation with speakers: - -"{conversation_text[:1000]}" - -Rules: -- Maximum 120 characters -- 1-2 complete sentences -- Include speaker names when relevant (e.g., "John discusses X with Sarah") -- Capture key topics and outcomes -- Use present tense -- Be specific and informative - -Summary:""" - - summary = await async_generate(prompt, temperature=0.3) - return summary.strip().strip('"').strip("'") or "No content" - - except Exception as e: - logger.warning(f"Failed to generate LLM summary with speakers: {e}") - # Fallback to simple summary generation - return conversation_text[:120] + "..." if len(conversation_text) > 120 else conversation_text or "No content" - - async def _queue_memory_processing(self, conversation_id: str): - """Queue memory processing for a speech-detected conversation. - - Args: - conversation_id: The conversation ID to process (not audio_uuid) - """ - try: - # Get conversation data from conversations collection - conversations_repo = ConversationsRepository(conversations_col) - conversation = await conversations_repo.get_conversation(conversation_id) - if not conversation: - logger.warning( - f"No conversation found for memory processing {conversation_id}" - ) - return - - # Get audio session data to get user info - audio_session = await self.chunk_repo.get_chunk(conversation["audio_uuid"]) - if not audio_session: - logger.warning( - f"No audio session found for conversation {conversation_id}" - ) - return - - # Check if we have required data - if not all( - [conversation_id, conversation.get("user_id"), audio_session.get("user_email")] - ): - logger.warning( - f"Memory processing skipped - missing required data for conversation {conversation_id}" - ) - logger.warning(f" - conversation_id: {bool(conversation_id)}") - logger.warning( - f" - user_id: {bool(conversation.get('user_id'))}" - ) - logger.warning( - f" - user_email: {bool(audio_session.get('user_email'))}" - ) - return - - logger.info( - f"πŸ’­ Queuing memory processing for conversation {conversation_id} (audio: {conversation['audio_uuid']})" - ) - - # Import here to avoid circular imports - - # Queue memory processing for conversation - processor_manager = get_processor_manager() - await processor_manager.queue_memory( - MemoryProcessingItem( - client_id=self._client_id, - user_id=conversation["user_id"], - user_email=audio_session["user_email"], - conversation_id=conversation_id, - ) - ) - - except Exception as e: - logger.error(f"Error queuing memory processing for conversation {conversation_id}: {e}") - - async def _queue_diarization_based_cropping(self, segments): - """Queue audio cropping based on diarization segments.""" - try: - # Import here to avoid circular imports - - # Get current client for user info - current_client = self._get_current_client() - if not current_client: - logger.warning(f"No client state available for cropping {self._current_audio_uuid}") - return - - # Get audio file path from database - if not self.chunk_repo: - logger.warning( - f"No chunk repository available for cropping {self._current_audio_uuid}" - ) - return - - chunk_data = await self.chunk_repo.get_chunk(self._current_audio_uuid) - if not chunk_data or "audio_path" not in chunk_data: - logger.warning(f"No audio path found for cropping {self._current_audio_uuid}") - return - - # Build file paths - audio_filename = chunk_data["audio_path"] - original_path = f"/app/audio_chunks/{audio_filename}" - cropped_path = original_path.replace(".wav", "_cropped.wav") - - # Convert segments to cropping format (start, end tuples) - cropping_segments = [] - for seg in segments: - start = seg.get("start", 0.0) - end = seg.get("end", 0.0) - if end > start: # Only include valid segments - cropping_segments.append((start, end)) - - if not cropping_segments: - logger.debug( - f"No valid cropping segments from diarization for {self._current_audio_uuid}" - ) - return - - logger.info( - f"βœ‚οΈ Queuing diarization-based cropping for {self._current_audio_uuid} " - f"with {len(cropping_segments)} segments" - ) - - # Queue cropping with processor manager - processor_manager = get_processor_manager() - await processor_manager.queue_cropping( - AudioCroppingItem( - client_id=self._client_id, - user_id=current_client.user_id, - audio_uuid=self._current_audio_uuid, - original_path=original_path, - speech_segments=cropping_segments, - output_path=cropped_path, - ) - ) - - except Exception as e: - logger.error( - f"Error queuing diarization-based cropping for {self._current_audio_uuid}: {e}" - ) - - async def disconnect(self): - """Cleanly disconnect from transcription service.""" - logger.info( - f"πŸ”Œ disconnect called for client {self._client_id} - provider: {self.provider.name if self.provider else 'None'}" - ) - - if not self.provider: - logger.warning("No provider to disconnect") - return - - # Cancel collection timeout task first to prevent interference - if self._collection_task and not self._collection_task.done(): - logger.info(f"πŸ›‘ Cancelling collection timeout task for client {self._client_id}") - self._collection_task.cancel() - try: - await self._collection_task - except asyncio.CancelledError: - logger.info(f"βœ… Collection task cancelled successfully") - except Exception as e: - logger.error(f"❌ Error cancelling collection task: {e}") - - # Process any remaining audio before disconnect - if self._collecting or self._audio_buffer: - logger.info( - f"πŸ“Š Processing remaining audio on disconnect - buffer size: {len(self._audio_buffer)}" - ) - await self.process_collected_audio() - - # Disconnect the provider - try: - await self.provider.disconnect() - logger.info( - f"{self.provider.name} transcription disconnected for client {self._client_id}" - ) - except Exception as e: - logger.error(f"Error disconnecting from {self.provider.name}: {e}") - - async def _collection_timeout_handler(self): - """Handle collection timeout - process audio after timeout period.""" - logger.info( - f"⏰ Collection timeout handler started for client {self._client_id} ({self._max_collection_time}s)" - ) - try: - await asyncio.sleep(self._max_collection_time) - if self._collecting and self._audio_buffer: - logger.info( - f"⏰ Collection timeout reached for client {self._client_id}, processing audio (buffer: {len(self._audio_buffer)} chunks)" - ) - await self.process_collected_audio() - else: - logger.info( - f"⏰ Collection timeout reached but no audio to process (collecting: {self._collecting}, buffer: {len(self._audio_buffer) if self._audio_buffer else 0})" - ) - except asyncio.CancelledError: - logger.info(f"⏰ Collection timeout cancelled for client {self._client_id}") - except Exception as e: - logger.error(f"❌ Error in collection timeout handler: {e}", exc_info=True) - - async def transcribe_chunk(self, audio_uuid: str, chunk: AudioChunk, client_id: str): - """Process audio chunk using the configured transcription provider.""" - if not self.provider: - logger.error("No transcription provider available") - return - - # Clean mode-based dispatch - no exception handling for control flow - if self.provider.mode == "streaming": - # Streaming providers process chunks immediately - try: - await self.provider.process_streaming_chunk(audio_uuid, chunk, client_id) - except Exception as e: - logger.error(f"Error in streaming processing for {audio_uuid}: {e}") - await self._reconnect() - else: - # Batch providers collect chunks for later processing - await self._collect_audio_chunk(audio_uuid, chunk, client_id) - - async def _collect_audio_chunk(self, audio_uuid: str, chunk: AudioChunk, client_id: str): - """Collect audio chunk for batch processing.""" - logger.debug( - f"πŸ“₯ _collect_audio_chunk called for client {client_id}, audio_uuid: {audio_uuid}" - ) - try: - # Update current audio UUID - if self._current_audio_uuid != audio_uuid: - self._current_audio_uuid = audio_uuid - logger.info( - f"πŸ†• New audio_uuid for {self.provider.name if self.provider else 'online'} batch: {audio_uuid}" - ) - - # Reset collection state for new audio session - self._audio_buffer.clear() - self._audio_start_time = time.time() - self._collecting = True - - # Reset audio timeline for new session - self._audio_timeline.reset() - self._buffer_start_time = time.time() - self._last_word_time = None - - # Start collection timeout task - if self._collection_task and not self._collection_task.done(): - self._collection_task.cancel() - self._collection_task = asyncio.create_task(self._collection_timeout_handler()) - - # Add chunk to buffer if we have audio data - if chunk.audio and len(chunk.audio) > 0: - # Get sample rate from client state (set by audio processor) - current_client = self._get_current_client() - if current_client and current_client.sample_rate: - # Use sample rate from client state - expected_rate = current_client.sample_rate - if chunk.rate != expected_rate: - logger.warning( - f"⚠️ Sample rate mismatch for {client_id}: expected {expected_rate}Hz, got {chunk.rate}Hz" - ) - else: - # Fallback: no client state available, just log chunk rate - logger.info( - f"πŸ“Š Processing chunk with sample rate {chunk.rate}Hz for client {client_id} (no client state)" - ) - - self._audio_buffer.append(chunk) - - # Update audio timeline - self._audio_timeline.add_chunk(chunk) - - logger.debug( - f"πŸ“¦ Collected {len(chunk.audio)} bytes for {audio_uuid} (total chunks: {len(self._audio_buffer)})" - ) - - # Track buffer start time for periodic transcription - if not self._buffer_start_time: - self._buffer_start_time = time.time() - - # Check if buffer duration exceeded and safe to transcribe - buffer_duration = time.time() - self._buffer_start_time - if buffer_duration >= self._max_buffer_duration and not self._transcribing: - logger.info( - f"πŸ“Š Buffer duration limit reached ({buffer_duration:.1f}s), " - f"triggering transcription for {client_id}" - ) - await self._trigger_periodic_transcription() - else: - logger.warning(f"⚠️ Empty audio chunk received for {audio_uuid}") - - except Exception as e: - logger.error(f"Error collecting audio chunk for {audio_uuid}: {e}") - - async def _trigger_periodic_transcription(self): - """Safely trigger periodic transcription with state management.""" - # Check if already transcribing or not collecting - if self._transcribing or not self._collecting: - logger.debug("Skipping periodic trigger - transcribing or not collecting") - return - - # Mark as transcribing to prevent concurrent triggers - self._transcribing = True - try: - await self.process_collected_audio() - finally: - self._transcribing = False - self._buffer_start_time = time.time() # Reset for next period - - async def _trigger_conversation_close(self): - """Trigger conversation close due to inactivity but continue audio collection.""" - if not self._client_id: - logger.warning("Cannot close conversation - missing client_id") - return - - logger.info(f"πŸ”š Closing conversation for {self._client_id} due to speech inactivity") - - try: - # Reset conversation-specific state but continue audio collection - # Setting _current_audio_uuid to None will trigger "new session" logic - # on next audio chunk, which will reset buffer and timeline - self._current_audio_uuid = None - self._last_word_time = None - - # Keep audio collection active: - # - _collecting=True (audio stream continues) - # - _audio_buffer (will be cleared on next chunk due to _current_audio_uuid=None) - # - _audio_timeline (will be reset on next chunk) - # - _buffer_start_time (will be reset on next chunk) - - logger.info(f"βœ… Conversation closed for {self._client_id}, audio collection continues for new conversations") - - except Exception as e: - logger.error(f"❌ Error closing conversation for {self._client_id}: {e}") - - async def _reconnect(self): - """Attempt to reconnect to transcription service.""" - if not self.provider: - logger.warning("No provider to reconnect") - return - - logger.info("Attempting to reconnect to transcription service...") - - try: - await self.provider.disconnect() - await asyncio.sleep(2) # Brief delay before reconnecting - await self.provider.connect(self._client_id) - logger.info(f"Successfully reconnected to {self.provider.name}") - except Exception as e: - logger.error(f"Reconnection to {self.provider.name} failed: {e}") diff --git a/backends/advanced/src/advanced_omi_backend/transcription_providers.py b/backends/advanced/src/advanced_omi_backend/transcription_providers.py deleted file mode 100644 index 28147912..00000000 --- a/backends/advanced/src/advanced_omi_backend/transcription_providers.py +++ /dev/null @@ -1,824 +0,0 @@ -""" -Transcription provider abstraction for multiple ASR services (online and offline). - -Provider Output Formats (2025): --------------------------------- -All providers return a standardized dictionary with the following structure: -{ - "text": str, # Full transcript text - "words": List[dict], # Word-level data (if available) - "segments": List[dict] # Speaker segments (if available) -} - -Word object format (when available): -{ - "word": str, # The word text - "start": float, # Start time in seconds - "end": float, # End time in seconds - "confidence": float, # Confidence score (0-1) - "speaker": int # Speaker ID (optional) -} - -Provider-specific behaviors: -- Deepgram: Returns rich word-level timestamps with confidence scores -- NeMo Parakeet: Returns word-level timestamps (streaming and batch modes) -""" - -import abc -import asyncio -import json -import logging -import os -import tempfile -import uuid -from typing import Dict, Optional - -import httpx -import numpy as np -import websockets -from easy_audio_interfaces.audio_interfaces import AudioChunk -from easy_audio_interfaces.filesystem import LocalFileSink - -logger = logging.getLogger(__name__) - -class BaseTranscriptionProvider(abc.ABC): - """Abstract base class for all transcription providers.""" - - @abc.abstractmethod - async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: - """ - Transcribe audio data to text with word-level timestamps. - - Args: - audio_data: Raw audio bytes (PCM format) - sample_rate: Audio sample rate (Hz) - **kwargs: Additional parameters (e.g. diarize=True for speaker diarization) - - Returns: - Dictionary containing: - - text: Transcribed text string - - words: List of word-level data with timestamps (required) - - segments: List of speaker segments (empty for non-RTTM providers) - """ - pass - - @property - @abc.abstractmethod - def name(self) -> str: - """Return the provider name for logging.""" - pass - - @property - @abc.abstractmethod - def mode(self) -> str: - """Return 'streaming' or 'batch' for processing mode.""" - pass - - async def connect(self, client_id: Optional[str] = None): - """Initialize/connect the provider. Default implementation does nothing.""" - pass - - async def disconnect(self): - """Cleanup/disconnect the provider. Default implementation does nothing.""" - pass - - -class StreamingTranscriptionProvider(BaseTranscriptionProvider): - """Base class for streaming transcription providers.""" - - @property - def mode(self) -> str: - return "streaming" - - @abc.abstractmethod - async def start_stream(self, client_id: str, sample_rate: int = 16000, diarize: bool = False): - """Start a transcription stream for a client. - - Args: - client_id: Unique client identifier - sample_rate: Audio sample rate - diarize: Whether to enable speaker diarization (provider-dependent) - """ - pass - - @abc.abstractmethod - async def process_audio_chunk(self, client_id: str, audio_chunk: bytes) -> Optional[dict]: - """ - Process audio chunk and return partial/final transcription. - - Returns: - None for partial results, dict with transcription for final results - """ - pass - - @abc.abstractmethod - async def end_stream(self, client_id: str) -> dict: - """End stream and return final transcription with word-level timestamps.""" - pass - - -class BatchTranscriptionProvider(BaseTranscriptionProvider): - """Base class for batch transcription providers.""" - - @property - def mode(self) -> str: - return "batch" - - @abc.abstractmethod - async def transcribe(self, audio_data: bytes, sample_rate: int, diarize: bool = False) -> dict: - """Transcribe audio data. - - Args: - audio_data: Raw audio bytes - sample_rate: Audio sample rate - diarize: Whether to enable speaker diarization (provider-dependent) - """ - pass - - -class DeepgramProvider(BatchTranscriptionProvider): - """Deepgram batch transcription provider using Nova-3 model.""" - - def __init__(self, api_key: str): - self.api_key = api_key - self.url = "https://api.deepgram.com/v1/listen" - - @property - def name(self) -> str: - return "Deepgram" - - async def transcribe(self, audio_data: bytes, sample_rate: int, diarize: bool = False) -> dict: - """Transcribe audio using Deepgram's REST API. - - Args: - audio_data: Raw audio bytes - sample_rate: Audio sample rate - diarize: Whether to enable speaker diarization - """ - try: - params = { - "model": "nova-3", - "language": "multi", - "smart_format": "true", - "punctuate": "true", - "diarize": "true" if diarize else "false", - "encoding": "linear16", - "sample_rate": str(sample_rate), - "channels": "1", - } - - headers = {"Authorization": f"Token {self.api_key}", "Content-Type": "audio/raw"} - - logger.info(f"Sending {len(audio_data)} bytes to Deepgram API") - - # Calculate dynamic timeout based on audio file size - estimated_duration = len(audio_data) / (sample_rate * 2 * 1) # 16-bit mono - processing_timeout = max( - 120, int(estimated_duration * 3) - ) # Min 2 minutes, 3x audio duration - - timeout_config = httpx.Timeout( - connect=30.0, - read=processing_timeout, - write=max( - 180.0, int(len(audio_data) / (sample_rate * 2)) - ), # bytes per second for 16-bit PCM - pool=10.0, - ) - - logger.info( - f"Estimated audio duration: {estimated_duration:.1f}s, timeout: {processing_timeout}s" - ) - - async with httpx.AsyncClient(timeout=timeout_config) as client: - response = await client.post( - self.url, params=params, headers=headers, content=audio_data - ) - - if response.status_code == 200: - result = response.json() - logger.debug(f"Deepgram response: {result}") - - # Extract transcript from response - if result.get("results", {}).get("channels", []) and result["results"][ - "channels" - ][0].get("alternatives", []): - - alternative = result["results"]["channels"][0]["alternatives"][0] - - # Use diarized transcript if available - if "paragraphs" in alternative and alternative["paragraphs"].get( - "transcript" - ): - transcript = alternative["paragraphs"]["transcript"].strip() - logger.info( - f"Deepgram diarized transcription successful: {len(transcript)} characters" - ) - else: - transcript = alternative.get("transcript", "").strip() - logger.info( - f"Deepgram basic transcription successful: {len(transcript)} characters" - ) - - if transcript: - # Extract speech timing information for logging - words = alternative.get("words", []) - if words: - first_word_start = words[0].get("start", 0) - last_word_end = words[-1].get("end", 0) - speech_duration = last_word_end - first_word_start - - # Calculate audio duration from data size - audio_duration = len(audio_data) / ( - sample_rate * 2 * 1 - ) # 16-bit mono - speech_percentage = ( - (speech_duration / audio_duration) * 100 - if audio_duration > 0 - else 0 - ) - - logger.info( - f"Deepgram speech analysis: {speech_duration:.1f}s speech detected in {audio_duration:.1f}s audio ({speech_percentage:.1f}%)" - ) - - # Check confidence levels - confidences = [ - w.get("confidence", 0) for w in words if "confidence" in w - ] - if confidences: - avg_confidence = sum(confidences) / len(confidences) - low_confidence_count = sum(1 for c in confidences if c < 0.5) - logger.info( - f"Deepgram confidence: avg={avg_confidence:.2f}, {low_confidence_count}/{len(words)} words <0.5 confidence" - ) - - # Keep raw transcript and word data without formatting - logger.info( - f"Keeping raw transcript with word-level data: {len(transcript)} characters" - ) - return { - "text": transcript, - "words": words, - "segments": [], - } - else: - # No word-level data, return basic transcript - logger.info( - "No word-level data available, returning basic transcript" - ) - return {"text": transcript, "words": [], "segments": []} - else: - logger.warning("Deepgram returned empty transcript") - return {"text": "", "words": [], "segments": []} - else: - logger.warning("Deepgram response missing expected transcript structure") - return {"text": "", "words": [], "segments": []} - else: - logger.error(f"Deepgram API error: {response.status_code} - {response.text}") - return {"text": "", "words": [], "segments": []} - - except httpx.TimeoutException as e: - timeout_type = "unknown" - if "connect" in str(e).lower(): - timeout_type = "connection" - elif "read" in str(e).lower(): - timeout_type = "read" - elif "write" in str(e).lower(): - timeout_type = "write (upload)" - elif "pool" in str(e).lower(): - timeout_type = "connection pool" - logger.error( - f"HTTP {timeout_type} timeout during Deepgram API call for {len(audio_data)} bytes: {e}" - ) - return {"text": "", "words": [], "segments": []} - except Exception as e: - logger.error(f"Error calling Deepgram API: {e}") - return {"text": "", "words": [], "segments": []} - - -class DeepgramStreamingProvider(StreamingTranscriptionProvider): - """Deepgram streaming transcription provider using WebSocket connection.""" - - def __init__(self, api_key: str): - self.api_key = api_key - self.ws_url = "wss://api.deepgram.com/v1/listen" - self._streams: Dict[str, Dict] = {} # client_id -> stream data - - @property - def name(self) -> str: - return "Deepgram-Streaming" - - async def start_stream(self, client_id: str, sample_rate: int = 16000, diarize: bool = False): - """Start a WebSocket connection for streaming transcription. - - Args: - client_id: Unique client identifier - sample_rate: Audio sample rate - diarize: Whether to enable speaker diarization - """ - try: - logger.info(f"Starting Deepgram streaming for client {client_id} (diarize={diarize})") - - # WebSocket connection parameters - params = { - "model": "nova-3", - "language": "multi", - "smart_format": "true", - "punctuate": "true", - "diarize": "true" if diarize else "false", - "encoding": "linear16", - "sample_rate": str(sample_rate), - "channels": "1", - "interim_results": "true", - "endpointing": "300", # 300ms silence for endpoint detection - } - - # Build WebSocket URL with parameters - query_string = "&".join([f"{k}={v}" for k, v in params.items()]) - ws_url = f"{self.ws_url}?{query_string}" - - # Connect to WebSocket - websocket = await websockets.connect( - ws_url, - extra_headers={"Authorization": f"Token {self.api_key}"} - ) - - # Store stream data - self._streams[client_id] = { - "websocket": websocket, - "final_transcript": "", - "words": [], - "stream_id": str(uuid.uuid4()) - } - - logger.info(f"Deepgram WebSocket connected for client {client_id}") - - except Exception as e: - logger.error(f"Failed to start Deepgram streaming for {client_id}: {e}") - raise - - async def process_audio_chunk(self, client_id: str, audio_chunk: bytes) -> Optional[dict]: - """Send audio chunk to WebSocket and process responses.""" - if client_id not in self._streams: - logger.error(f"No active stream for client {client_id}") - return None - - try: - stream_data = self._streams[client_id] - websocket = stream_data["websocket"] - - # Send audio chunk - await websocket.send(audio_chunk) - - # Check for responses (non-blocking) - try: - while True: - response = await asyncio.wait_for(websocket.recv(), timeout=0.01) - result = json.loads(response) - - if result.get("type") == "Results": - channel = result.get("channel", {}) - alternatives = channel.get("alternatives", []) - - if alternatives: - alt = alternatives[0] - is_final = channel.get("is_final", False) - - if is_final: - # Accumulate final transcript and words - transcript = alt.get("transcript", "") - words = alt.get("words", []) - - if transcript.strip(): - stream_data["final_transcript"] += transcript + " " - stream_data["words"].extend(words) - - logger.debug(f"Final transcript chunk: {transcript}") - - except asyncio.TimeoutError: - # No response available, continue - pass - - return None # Streaming, no final result yet - - except Exception as e: - logger.error(f"Error processing audio chunk for {client_id}: {e}") - return None - - async def end_stream(self, client_id: str) -> dict: - """Close WebSocket connection and return final transcription.""" - if client_id not in self._streams: - logger.error(f"No active stream for client {client_id}") - return {"text": "", "words": [], "segments": []} - - try: - stream_data = self._streams[client_id] - websocket = stream_data["websocket"] - - # Send close message - close_msg = json.dumps({"type": "CloseStream"}) - await websocket.send(close_msg) - - # Wait a bit for final responses - try: - end_time = asyncio.get_event_loop().time() + 2.0 # 2 second timeout - while asyncio.get_event_loop().time() < end_time: - response = await asyncio.wait_for(websocket.recv(), timeout=0.5) - result = json.loads(response) - - if result.get("type") == "Results": - channel = result.get("channel", {}) - alternatives = channel.get("alternatives", []) - - if alternatives and channel.get("is_final", False): - alt = alternatives[0] - transcript = alt.get("transcript", "") - words = alt.get("words", []) - - if transcript.strip(): - stream_data["final_transcript"] += transcript - stream_data["words"].extend(words) - - except asyncio.TimeoutError: - pass - - # Close WebSocket - await websocket.close() - - # Prepare final result - final_transcript = stream_data["final_transcript"].strip() - final_words = stream_data["words"] - - logger.info(f"Deepgram streaming completed for {client_id}: {len(final_transcript)} chars, {len(final_words)} words") - - # Clean up - del self._streams[client_id] - - return { - "text": final_transcript, - "words": final_words, - "segments": [] - } - - except Exception as e: - logger.error(f"Error ending stream for {client_id}: {e}") - # Clean up on error - if client_id in self._streams: - del self._streams[client_id] - return {"text": "", "words": [], "segments": []} - - async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: - """For streaming provider, this method is not typically used.""" - logger.warning("transcribe() called on streaming provider - use streaming methods instead") - return {"text": "", "words": [], "segments": []} - - async def disconnect(self): - """Close all active WebSocket connections.""" - for client_id in list(self._streams.keys()): - try: - websocket = self._streams[client_id]["websocket"] - await websocket.close() - except Exception as e: - logger.error(f"Error closing WebSocket for {client_id}: {e}") - finally: - del self._streams[client_id] - - logger.info("All Deepgram streaming connections closed") - - -class ParakeetProvider(BatchTranscriptionProvider): - """Parakeet HTTP batch transcription provider.""" - - def __init__(self, service_url: str): - self.service_url = service_url.rstrip('/') - self.transcribe_url = f"{self.service_url}/transcribe" - - @property - def name(self) -> str: - return "Parakeet" - - async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: - """Transcribe audio using Parakeet HTTP service.""" - try: - - logger.info(f"Sending {len(audio_data)} bytes to Parakeet service at {self.transcribe_url}") - - # Convert PCM bytes to audio file for upload - if sample_rate != 16000: - logger.warning(f"Sample rate {sample_rate} != 16000, audio may not be optimal") - - # Assume 16-bit PCM - audio_array = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) - audio_array = audio_array / np.iinfo(np.int16).max # Normalize to [-1, 1] - - # Create temporary WAV file - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file: - # sf.write(tmp_file.name, audio_array, 16000) # Force 16kHz - async with LocalFileSink(tmp_file.name, sample_rate, 1) as sink: - await sink.write(AudioChunk( - rate=sample_rate, - width=2, - channels=1, - audio=audio_data, - )) - - tmp_filename = tmp_file.name - - try: - # Upload file to Parakeet service - async with httpx.AsyncClient(timeout=180.0) as client: - with open(tmp_filename, "rb") as f: - files = {"file": ("audio.wav", f, "audio/wav")} - response = await client.post(self.transcribe_url, files=files) - - if response.status_code == 200: - result = response.json() - logger.info(f"Parakeet transcription successful: {len(result.get('text', ''))} chars, {len(result.get('words', []))} words") - return result - else: - error_msg = f"Parakeet service error: {response.status_code} - {response.text}" - logger.error(error_msg) - - # For 5xx errors, raise exception to trigger retry/failure handling - if response.status_code >= 500: - raise RuntimeError(f"Parakeet service unavailable: HTTP {response.status_code}") - - # For 4xx errors, return empty result (client error, won't retry) - return {"text": "", "words": [], "segments": []} - - finally: - # Clean up temporary file - if os.path.exists(tmp_filename): - os.unlink(tmp_filename) - - except Exception as e: - logger.error(f"Error calling Parakeet service: {e}") - raise e - - -class ParakeetStreamingProvider(StreamingTranscriptionProvider): - """Parakeet WebSocket streaming transcription provider.""" - - def __init__(self, service_url: str): - self.service_url = service_url.rstrip('/') - self.ws_url = service_url.replace("http://", "ws://").replace("https://", "wss://") + "/stream" - self._streams: Dict[str, Dict] = {} # client_id -> stream data - - @property - def name(self) -> str: - return "Parakeet-Streaming" - - async def start_stream(self, client_id: str, sample_rate: int = 16000, diarize: bool = False): - """Start a WebSocket connection for streaming transcription. - - Args: - client_id: Unique client identifier - sample_rate: Audio sample rate - diarize: Whether to enable speaker diarization (ignored - Parakeet doesn't support diarization) - """ - if diarize: - logger.warning(f"Parakeet streaming provider does not support diarization, ignoring diarize=True for client {client_id}") - try: - logger.info(f"Starting Parakeet streaming for client {client_id}") - - # Connect to WebSocket - websocket = await websockets.connect(self.ws_url) - - # Send transcribe event to start session - session_config = { - "vad_enabled": True, - "vad_silence_ms": 1000, - "time_interval_seconds": 30, - "return_interim_results": True, - "min_audio_seconds": 0.5 - } - - start_message = { - "type": "transcribe", - "session_id": client_id, - "config": session_config - } - - await websocket.send(json.dumps(start_message)) - - # Wait for session_started confirmation - response = await websocket.recv() - response_data = json.loads(response) - - if response_data.get("type") != "session_started": - raise RuntimeError(f"Failed to start session: {response_data}") - - # Store stream data - self._streams[client_id] = { - "websocket": websocket, - "sample_rate": sample_rate, - "session_id": client_id, - "interim_results": [], - "final_result": None - } - - logger.info(f"Parakeet WebSocket connected for client {client_id}") - - except Exception as e: - logger.error(f"Failed to start Parakeet streaming for {client_id}: {e}") - raise - - async def process_audio_chunk(self, client_id: str, audio_chunk: bytes) -> Optional[dict]: - """Send audio chunk to WebSocket and process responses.""" - if client_id not in self._streams: - logger.error(f"No active stream for client {client_id}") - return None - - try: - stream_data = self._streams[client_id] - websocket = stream_data["websocket"] - sample_rate = stream_data["sample_rate"] - - # Send audio_chunk event - chunk_message = { - "type": "audio_chunk", - "session_id": client_id, - "rate": sample_rate, - "width": 2, # 16-bit - "channels": 1 - } - - await websocket.send(json.dumps(chunk_message)) - await websocket.send(audio_chunk) - - # Check for responses (non-blocking) - try: - while True: - response = await asyncio.wait_for(websocket.recv(), timeout=0.01) - result = json.loads(response) - - if result.get("type") == "interim_result": - # Store interim result but don't return it (handled by backend differently) - stream_data["interim_results"].append(result) - logger.debug(f"Received interim result: {result.get('text', '')[:50]}...") - elif result.get("type") == "final_result": - # This shouldn't happen during chunk processing, but store it - stream_data["final_result"] = result - logger.debug(f"Received final result during chunk processing: {result.get('text', '')[:50]}...") - - except asyncio.TimeoutError: - # No response available, continue - pass - - return None # Streaming, no final result yet - - except Exception as e: - logger.error(f"Error processing audio chunk for {client_id}: {e}") - return None - - async def end_stream(self, client_id: str) -> dict: - """Close WebSocket connection and return final transcription.""" - if client_id not in self._streams: - logger.error(f"No active stream for client {client_id}") - return {"text": "", "words": [], "segments": []} - - try: - stream_data = self._streams[client_id] - websocket = stream_data["websocket"] - - # Send finalize event - finalize_message = { - "type": "finalize", - "session_id": client_id - } - await websocket.send(json.dumps(finalize_message)) - - # Wait for final result - try: - end_time = asyncio.get_event_loop().time() + 5.0 # 5 second timeout - while asyncio.get_event_loop().time() < end_time: - response = await asyncio.wait_for(websocket.recv(), timeout=1.0) - result = json.loads(response) - - if result.get("type") == "final_result": - stream_data["final_result"] = result - break - - except asyncio.TimeoutError: - logger.warning(f"Timeout waiting for final result from {client_id}") - - # Close WebSocket - await websocket.close() - - # Prepare final result - final_result = stream_data.get("final_result") - if final_result: - result_data = { - "text": final_result.get("text", ""), - "words": final_result.get("words", []), - "segments": final_result.get("segments", []) - } - else: - # Fallback: aggregate interim results if no final result received - interim_texts = [r.get("text", "") for r in stream_data["interim_results"]] - all_words = [] - for r in stream_data["interim_results"]: - all_words.extend(r.get("words", [])) - - result_data = { - "text": " ".join(interim_texts), - "words": all_words, - "segments": [] - } - - logger.info(f"Parakeet streaming completed for {client_id}: {len(result_data.get('text', ''))} chars") - - # Clean up - del self._streams[client_id] - - return result_data - - except Exception as e: - logger.error(f"Error ending stream for {client_id}: {e}") - # Clean up on error - if client_id in self._streams: - try: - await self._streams[client_id]["websocket"].close() - except: - pass - del self._streams[client_id] - return {"text": "", "words": [], "segments": []} - - async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dict: - """For streaming provider, this method is not typically used.""" - logger.warning("transcribe() called on streaming provider - use streaming methods instead") - return {"text": "", "words": [], "segments": []} - - async def disconnect(self): - """Close all active WebSocket connections.""" - for client_id in list(self._streams.keys()): - try: - websocket = self._streams[client_id]["websocket"] - await websocket.close() - except Exception as e: - logger.error(f"Error closing WebSocket for {client_id}: {e}") - finally: - del self._streams[client_id] - - logger.info("All Parakeet streaming connections closed") - - -def get_transcription_provider( - provider_name: Optional[str] = None, - mode: Optional[str] = None, -) -> Optional[BaseTranscriptionProvider]: - """ - Factory function to get the appropriate transcription provider. - - Args: - provider_name: Name of the provider ('deepgram', 'parakeet'). - If None, will auto-select based on available configuration. - mode: Processing mode ('streaming', 'batch'). If None, defaults to 'batch'. - - Returns: - An instance of BaseTranscriptionProvider, or None if no provider is configured. - - Raises: - RuntimeError: If a specific provider is requested but not properly configured. - """ - deepgram_key = os.getenv("DEEPGRAM_API_KEY") - parakeet_url = os.getenv("PARAKEET_ASR_URL") - - if provider_name: - provider_name = provider_name.lower() - - if mode is None: - mode = "batch" - mode = mode.lower() - - # Handle specific provider requests - if provider_name == "deepgram": - if not deepgram_key: - raise RuntimeError( - "Deepgram transcription provider requested but DEEPGRAM_API_KEY not configured" - ) - logger.info(f"Using Deepgram transcription provider in {mode} mode") - if mode == "streaming": - return DeepgramStreamingProvider(deepgram_key) - else: - return DeepgramProvider(deepgram_key) - - elif provider_name == "parakeet": - if not parakeet_url: - raise RuntimeError( - "Parakeet ASR provider requested but PARAKEET_ASR_URL not configured" - ) - logger.info(f"Using Parakeet transcription provider in {mode} mode") - return ParakeetProvider(parakeet_url) - - elif provider_name == "offline": - # "offline" is an alias for Parakeet ASR - if not parakeet_url: - raise RuntimeError( - "Offline transcription provider requested but PARAKEET_ASR_URL not configured" - ) - logger.info(f"Using offline Parakeet transcription provider in {mode} mode") - return ParakeetProvider(parakeet_url) - else: - return None diff --git a/backends/advanced/src/advanced_omi_backend/users.py b/backends/advanced/src/advanced_omi_backend/users.py index b016c68c..73c6535b 100644 --- a/backends/advanced/src/advanced_omi_backend/users.py +++ b/backends/advanced/src/advanced_omi_backend/users.py @@ -1,108 +1,28 @@ -"""User models for fastapi-users integration with Beanie and MongoDB.""" - -import logging -from datetime import UTC, datetime -from typing import Optional - -from beanie import Document, PydanticObjectId -from fastapi_users.db import BeanieBaseUser, BeanieUserDatabase -from fastapi_users.schemas import BaseUser, BaseUserCreate, BaseUserUpdate -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - - -class UserCreate(BaseUserCreate): - """Schema for creating new users.""" - - display_name: Optional[str] = None - - -class UserRead(BaseUser[PydanticObjectId]): - """Schema for reading user data.""" - - display_name: Optional[str] = None - registered_clients: dict[str, dict] = Field(default_factory=dict) - primary_speakers: list[dict] = Field(default_factory=list) - - -class UserUpdate(BaseUserUpdate): - """Schema for updating user data.""" - - display_name: Optional[str] = None - - -class User(BeanieBaseUser, Document): - """User model extending fastapi-users BeanieBaseUser with custom fields.""" - - display_name: Optional[str] = None - # Client tracking for audio devices - registered_clients: dict[str, dict] = Field(default_factory=dict) - # Speaker processing filter configuration - primary_speakers: list[dict] = Field(default_factory=list) - - @property - def user_id(self) -> str: - """Return string representation of MongoDB ObjectId for backward compatibility.""" - return str(self.id) - - def register_client(self, client_id: str, device_name: Optional[str] = None) -> None: - """Register a new client for this user.""" - # Check if client already exists - if client_id in self.registered_clients: - # Update existing client - logger.info(f"Updating existing client {client_id} for user {self.user_id}") - self.registered_clients[client_id]["last_seen"] = datetime.now(UTC) - self.registered_clients[client_id]["device_name"] = ( - device_name or self.registered_clients[client_id].get("device_name") - ) - return - - # Add new client - self.registered_clients[client_id] = { - "client_id": client_id, - "device_name": device_name, - "first_seen": datetime.now(UTC), - "last_seen": datetime.now(UTC), - "is_active": True, - } - - def get_client_ids(self) -> list[str]: - """Get all client IDs registered to this user.""" - return list(self.registered_clients.keys()) - - # def has_client(self, client_id: str) -> bool: - # """Check if a client is registered to this user.""" - # return client_id in self.registered_clients - - class Settings: - name = "users" # Collection name in MongoDB - standardized from "fastapi_users" - email_collation = {"locale": "en", "strength": 2} # Case-insensitive comparison - - -async def get_user_db(): - """Get the user database instance for dependency injection.""" - yield BeanieUserDatabase(User) # type: ignore - - -async def get_user_by_id(user_id: str) -> Optional[User]: - """Get user by MongoDB ObjectId string.""" - try: - return await User.get(PydanticObjectId(user_id)) - except Exception as e: - logger.error(f"Failed to get user by ID {user_id}: {e}") - # Re-raise for proper error handling upstream - raise - - -async def get_user_by_client_id(client_id: str) -> Optional[User]: - """Find the user that owns a specific client_id.""" - return await User.find_one({"registered_clients.client_id": client_id}) - - -async def register_client_to_user( - user: User, client_id: str, device_name: Optional[str] = None -) -> None: - """Register a client to a user and save to database.""" - user.register_client(client_id, device_name) - await user.save() +""" +Backward compatibility module - re-exports User models from models.user. + +This module maintains the original import location for existing code. +New code should import from advanced_omi_backend.models.user instead. +""" + +from advanced_omi_backend.models.user import ( + User, + UserCreate, + UserRead, + UserUpdate, + get_user_by_client_id, + get_user_by_id, + get_user_db, + register_client_to_user, +) + +__all__ = [ + "User", + "UserCreate", + "UserRead", + "UserUpdate", + "get_user_db", + "get_user_by_id", + "get_user_by_client_id", + "register_client_to_user", +] diff --git a/backends/advanced/src/advanced_omi_backend/utils/audio_extraction.py b/backends/advanced/src/advanced_omi_backend/utils/audio_extraction.py new file mode 100644 index 00000000..df999a10 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/utils/audio_extraction.py @@ -0,0 +1,129 @@ +""" +Audio extraction utilities for getting audio chunks from Redis. +""" + +import logging +from typing import List, Tuple + +logger = logging.getLogger(__name__) + + +def parse_chunk_range(chunk_id: str) -> Tuple[int, int]: + """ + Parse chunk ID range like "00001-00030" into (1, 30). + + Args: + chunk_id: Chunk ID string (e.g., "00001-00030" or "00005") + + Returns: + Tuple of (start_chunk, end_chunk) + """ + if "-" in chunk_id: + start, end = chunk_id.split("-") + return int(start), int(end) + else: + # Single chunk + chunk_num = int(chunk_id) + return chunk_num, chunk_num + + +async def extract_audio_for_results( + redis_client, + client_id: str, + session_id: str, + transcription_results: List[dict] +) -> bytes: + """ + Extract audio chunks for transcription results. + + Reads the chunk_id from each result to determine which audio chunks to fetch. + + Args: + redis_client: Redis client + client_id: Client identifier + session_id: Session identifier + transcription_results: List of transcription results from aggregator + + Returns: + Combined audio bytes for all chunks in results + """ + logger.info(f"🎡 [AUDIO EXTRACT] Starting audio extraction for session {session_id}") + logger.info(f"🎡 [AUDIO EXTRACT] Client: {client_id}, Results count: {len(transcription_results)}") + + if not transcription_results: + logger.warning(f"🎡 [AUDIO EXTRACT] No transcription results provided") + return b"" + + # Parse chunk ranges from all results + chunk_ranges = [] + for idx, result in enumerate(transcription_results): + chunk_id = result.get("chunk_id", "") + logger.debug(f"🎡 [AUDIO EXTRACT] Result {idx+1}: chunk_id={chunk_id}") + if chunk_id: + start, end = parse_chunk_range(chunk_id) + chunk_ranges.append((start, end)) + + if not chunk_ranges: + logger.warning("🎡 [AUDIO EXTRACT] No chunk ranges found in transcription results") + return b"" + + # Find overall range + min_chunk = min(start for start, _ in chunk_ranges) + max_chunk = max(end for _, end in chunk_ranges) + + logger.info( + f"🎡 [AUDIO EXTRACT] Extracting audio chunks {min_chunk:05d}-{max_chunk:05d} " + f"for session {session_id} ({max_chunk - min_chunk + 1} chunks)" + ) + + # Read from audio stream + stream_name = f"audio:stream:{client_id}" + logger.info(f"🎡 [AUDIO EXTRACT] Reading from Redis stream: {stream_name}") + + # Get all messages (we'll filter by session and chunk) + messages = await redis_client.xrange(stream_name) + logger.info(f"🎡 [AUDIO EXTRACT] Total messages in stream: {len(messages)}") + + # Collect audio chunks + audio_chunks = {} # {chunk_num: audio_data} + + for msg_id, fields in messages: + # Check if this message belongs to our session + msg_session_id = fields.get(b"session_id", b"").decode() + if msg_session_id != session_id: + continue + + # Get chunk number + msg_chunk_id = fields.get(b"chunk_id", b"").decode() + if not msg_chunk_id or msg_chunk_id == "END": + continue + + try: + chunk_num = int(msg_chunk_id) + except ValueError: + logger.debug(f"🎡 [AUDIO EXTRACT] Invalid chunk_id format: {msg_chunk_id}") + continue + + # Check if this chunk is in our range + if min_chunk <= chunk_num <= max_chunk: + audio_data = fields.get(b"audio_data", b"") + audio_chunks[chunk_num] = audio_data + logger.debug(f"🎡 [AUDIO EXTRACT] Collected chunk {chunk_num}: {len(audio_data)} bytes") + + # Combine chunks in order + sorted_chunks = sorted(audio_chunks.items()) + combined_audio = b"".join(data for _, data in sorted_chunks) + + logger.info( + f"🎡 [AUDIO EXTRACT] βœ… Extracted {len(sorted_chunks)} audio chunks " + f"({len(combined_audio)} bytes, ~{len(combined_audio)/32000:.1f}s)" + ) + + if len(combined_audio) == 0: + logger.warning(f"🎡 [AUDIO EXTRACT] ⚠️ No audio data collected!") + elif len(sorted_chunks) < (max_chunk - min_chunk + 1): + missing_chunks = (max_chunk - min_chunk + 1) - len(sorted_chunks) + logger.warning(f"🎡 [AUDIO EXTRACT] ⚠️ Missing {missing_chunks} chunks from expected range") + + return combined_audio + diff --git a/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py b/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py new file mode 100644 index 00000000..c6cfa06e --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py @@ -0,0 +1,281 @@ +""" +Conversation utilities - speech detection, title/summary generation. + +Extracted from legacy TranscriptionService to be reusable across V2 architecture. +""" + +import logging +from typing import Optional + +from advanced_omi_backend.config import get_speech_detection_settings +from advanced_omi_backend.llm_client import async_generate + +logger = logging.getLogger(__name__) + + +def analyze_speech(transcript_data: dict) -> dict: + """ + Analyze transcript for meaningful speech to determine if conversation should be created. + + Uses configurable thresholds from environment: + - SPEECH_DETECTION_MIN_WORDS (default: 5) + - SPEECH_DETECTION_MIN_CONFIDENCE (default: 0.5) + + Args: + transcript_data: Dictionary with: + - "text": str - Full transcript text + - "words": list - Word-level data with confidence and timing (optional) + [{"text": str, "confidence": float, "start": float, "end": float}, ...] + + Returns: + dict: { + "has_speech": bool, + "reason": str, + "word_count": int, + "duration": float (seconds, 0.0 if no timing data), + "speech_start": float (optional), + "speech_end": float (optional), + "fallback": bool (optional, true if text-only analysis) + } + + Example: + >>> result = analyze_speech({"text": "Hello world", "words": [...]}) + >>> if result["has_speech"]: + >>> print(f"Speech detected: {result['word_count']} words, {result['duration']}s") + """ + settings = get_speech_detection_settings() + words = transcript_data.get("words", []) + + # Method 1: Word-level analysis (preferred - has confidence scores and timing) + if words: + # Filter by confidence threshold + valid_words = [ + w for w in words + if w.get("confidence", 0) >= settings["min_confidence"] + ] + + if len(valid_words) < settings["min_words"]: + return { + "has_speech": False, + "reason": f"Not enough valid words ({len(valid_words)} < {settings['min_words']})", + "word_count": len(valid_words), + "duration": 0.0 + } + + # Calculate speech duration from word timing + if valid_words: + speech_start = valid_words[0].get("start", 0) + speech_end = valid_words[-1].get("end", 0) + speech_duration = speech_end - speech_start + + return { + "has_speech": True, + "word_count": len(valid_words), + "speech_start": speech_start, + "speech_end": speech_end, + "duration": speech_duration, + "reason": f"Valid speech detected ({len(valid_words)} words, {speech_duration:.1f}s)" + } + + # Method 2: Text-only fallback (when no word-level data available) + text = transcript_data.get("text", "").strip() + if text: + word_count = len(text.split()) + if word_count >= settings["min_words"]: + return { + "has_speech": True, + "word_count": word_count, + "speech_start": 0.0, + "speech_end": 0.0, + "duration": 0.0, + "reason": f"Valid speech detected ({word_count} words, no timing data)", + "fallback": True + } + + # No speech detected + return { + "has_speech": False, + "reason": "No meaningful speech content detected", + "word_count": 0, + "duration": 0.0 + } + + +async def generate_title(text: str) -> str: + """ + Generate an LLM-powered title from conversation text. + + Args: + text: Conversation transcript + + Returns: + str: Generated title (3-6 words) or fallback + """ + if not text or len(text.strip()) < 10: + return "Conversation" + + try: + prompt = f"""Generate a concise, descriptive title (3-6 words) for this conversation transcript: + +"{text[:500]}" + +Rules: +- Maximum 6 words +- Capture the main topic or theme +- No quotes or special characters +- Examples: "Planning Weekend Trip", "Work Project Discussion", "Medical Appointment" + +Title:""" + + title = await async_generate(prompt, temperature=0.3) + return title.strip().strip('"').strip("'") or "Conversation" + + except Exception as e: + logger.warning(f"Failed to generate LLM title: {e}") + # Fallback to simple title generation + words = text.split()[:6] + title = " ".join(words) + return title[:40] + "..." if len(title) > 40 else title or "Conversation" + + +async def generate_summary(text: str) -> str: + """ + Generate an LLM-powered summary from conversation text. + + Args: + text: Conversation transcript + + Returns: + str: Generated summary (1-2 sentences, max 120 chars) or fallback + """ + if not text or len(text.strip()) < 10: + return "No content" + + try: + prompt = f"""Generate a brief, informative summary (1-2 sentences, max 120 characters) for this conversation: + +"{text[:1000]}" + +Rules: +- Maximum 120 characters +- 1-2 complete sentences +- Capture key topics and outcomes +- Use present tense +- Be specific and informative + +Summary:""" + + summary = await async_generate(prompt, temperature=0.3) + return summary.strip().strip('"').strip("'") or "No content" + + except Exception as e: + logger.warning(f"Failed to generate LLM summary: {e}") + # Fallback to simple summary generation + return text[:120] + "..." if len(text) > 120 else text or "No content" + + +async def generate_title_with_speakers(segments: list) -> str: + """ + Generate an LLM-powered title from conversation segments with speaker information. + + Args: + segments: List of dicts with: + [{"speaker": str, "text": str, "start": float, "end": float}, ...] + + Returns: + str: Generated title (max 40 chars) or fallback + """ + if not segments: + return "Conversation" + + # Format conversation with speaker names + conversation_text = "" + for segment in segments[:10]: # Use first 10 segments for title generation + speaker = segment.get("speaker", "") + text = segment.get("text", "").strip() + if text: + if speaker: + conversation_text += f"{speaker}: {text}\n" + else: + conversation_text += f"{text}\n" + + if not conversation_text.strip(): + return "Conversation" + + try: + prompt = f"""Generate a concise title (max 40 characters) for this conversation: + +"{conversation_text[:500]}" + +Rules: +- Maximum 40 characters +- Include speaker names if relevant +- Capture the main topic +- Be specific and informative + +Title:""" + + title = await async_generate(prompt, temperature=0.3) + title = title.strip().strip('"').strip("'") + return title[:40] + "..." if len(title) > 40 else title or "Conversation" + + except Exception as e: + logger.warning(f"Failed to generate LLM title with speakers: {e}") + # Fallback to simple title generation + words = conversation_text.split()[:6] + title = " ".join(words) + return title[:40] + "..." if len(title) > 40 else title or "Conversation" + + +async def generate_summary_with_speakers(segments: list) -> str: + """ + Generate an LLM-powered summary from conversation segments with speaker information. + + Args: + segments: List of dicts with: + [{"speaker": str, "text": str, "start": float, "end": float}, ...] + + Returns: + str: Generated summary (1-2 sentences, max 120 chars) or fallback + """ + if not segments: + return "No content" + + # Format conversation with speaker names + conversation_text = "" + speakers_in_conv = set() + for segment in segments: + speaker = segment.get("speaker", "") + text = segment.get("text", "").strip() + if text: + if speaker: + conversation_text += f"{speaker}: {text}\n" + speakers_in_conv.add(speaker) + else: + conversation_text += f"{text}\n" + + if not conversation_text.strip(): + return "No content" + + try: + prompt = f"""Generate a brief, informative summary (1-2 sentences, max 120 characters) for this conversation with speakers: + +"{conversation_text[:1000]}" + +Rules: +- Maximum 120 characters +- 1-2 complete sentences +- Include speaker names when relevant (e.g., "John discusses X with Sarah") +- Capture key topics and outcomes +- Use present tense +- Be specific and informative + +Summary:""" + + summary = await async_generate(prompt, temperature=0.3) + return summary.strip().strip('"').strip("'") or "No content" + + except Exception as e: + logger.warning(f"Failed to generate LLM summary with speakers: {e}") + # Fallback to simple summary generation + return conversation_text[:120] + "..." if len(conversation_text) > 120 else conversation_text or "No content" diff --git a/backends/advanced/src/advanced_omi_backend/workers/__init__.py b/backends/advanced/src/advanced_omi_backend/workers/__init__.py new file mode 100644 index 00000000..5b9b1044 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/__init__.py @@ -0,0 +1,93 @@ +""" +Workers package - RQ job definitions and queue utilities. + +This package provides modular RQ job functions organized by domain: +- transcription_jobs: Speech-to-text processing +- conversation_jobs: Conversation management and updates +- memory_jobs: Memory extraction and processing +- audio_jobs: Audio file processing and cropping + +Queue configuration and utilities are in controllers/queue_controller.py +""" + +# Import from transcription_jobs +from .transcription_jobs import ( + transcribe_full_audio_job, + recognise_speakers_job, + stream_speech_detection_job, +) + +# Import from conversation_jobs +from .conversation_jobs import ( + open_conversation_job, +) + +# Import from memory_jobs +from .memory_jobs import ( + process_memory_job, + enqueue_memory_processing, +) + +# Import from audio_jobs +from .audio_jobs import ( + process_audio_job, + process_cropping_job, + audio_streaming_persistence_job, + enqueue_audio_processing, + enqueue_cropping, +) + +# Import from queue_controller +from advanced_omi_backend.controllers.queue_controller import ( + get_queue, + get_job_stats, + get_jobs, + get_queue_health, + transcription_queue, + memory_queue, + default_queue, + redis_conn, + REDIS_URL, + JOB_RESULT_TTL, + _ensure_beanie_initialized, + TRANSCRIPTION_QUEUE, + MEMORY_QUEUE, + DEFAULT_QUEUE, +) + +__all__ = [ + # Transcription jobs + "transcribe_full_audio_job", + "recognise_speakers_job", + "stream_speech_detection_job", + + # Conversation jobs + "open_conversation_job", + "audio_streaming_persistence_job", + + # Memory jobs + "process_memory_job", + "enqueue_memory_processing", + + # Audio jobs + "process_audio_job", + "process_cropping_job", + "enqueue_audio_processing", + "enqueue_cropping", + + # Queue utils + "get_queue", + "get_job_stats", + "get_jobs", + "get_queue_health", + "transcription_queue", + "memory_queue", + "default_queue", + "redis_conn", + "REDIS_URL", + "JOB_RESULT_TTL", + "_ensure_beanie_initialized", + "TRANSCRIPTION_QUEUE", + "MEMORY_QUEUE", + "DEFAULT_QUEUE", +] diff --git a/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py new file mode 100644 index 00000000..0cd84a63 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py @@ -0,0 +1,520 @@ +""" +Audio-related RQ job functions. + +This module contains jobs related to audio file processing and cropping. +""" + +import asyncio +import os +import logging +import time +from typing import Dict, Any, Optional + +from advanced_omi_backend.models.job import JobPriority, async_job + +from advanced_omi_backend.controllers.queue_controller import ( + default_queue, + _ensure_beanie_initialized, + JOB_RESULT_TTL, +) + +logger = logging.getLogger(__name__) + + +def process_audio_job( + client_id: str, + user_id: str, + user_email: str, + audio_data: bytes, + audio_rate: int, + audio_width: int, + audio_channels: int, + audio_uuid: Optional[str] = None, + timestamp: Optional[int] = None +) -> Dict[str, Any]: + """ + RQ job function for audio file writing and database entry creation. + + This function is executed by RQ workers and can survive server restarts. + """ + import asyncio + import time + import uuid + from pathlib import Path + from wyoming.audio import AudioChunk + from easy_audio_interfaces.filesystem.filesystem_interfaces import LocalFileSink + from advanced_omi_backend.database import get_collections + + try: + logger.info(f"πŸ”„ RQ: Starting audio processing for client {client_id}") + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + async def process(): + # Get repository + collections = get_collections() + from advanced_omi_backend.database import AudioChunksRepository + from advanced_omi_backend.config import CHUNK_DIR + repository = AudioChunksRepository(collections["chunks_col"]) + + # Use CHUNK_DIR from config + chunk_dir = CHUNK_DIR + + # Ensure directory exists + chunk_dir.mkdir(parents=True, exist_ok=True) + + # Create audio UUID if not provided + final_audio_uuid = audio_uuid or uuid.uuid4().hex + final_timestamp = timestamp or int(time.time()) + + # Create filename and file sink + wav_filename = f"{final_timestamp}_{client_id}_{final_audio_uuid}.wav" + file_path = chunk_dir / wav_filename + + # Create file sink + sink = LocalFileSink( + file_path=str(file_path), + sample_rate=int(audio_rate), + channels=int(audio_channels), + sample_width=int(audio_width) + ) + + # Open sink and write audio + await sink.open() + audio_chunk = AudioChunk( + rate=audio_rate, + width=audio_width, + channels=audio_channels, + audio=audio_data + ) + await sink.write(audio_chunk) + await sink.close() + + # Create database entry + await repository.create_chunk( + audio_uuid=final_audio_uuid, + audio_path=wav_filename, + client_id=client_id, + timestamp=final_timestamp, + user_id=user_id, + user_email=user_email, + ) + + logger.info(f"βœ… RQ: Completed audio processing for client {client_id}, file: {wav_filename}") + + # Enqueue transcript processing for this audio file + # First ensure Beanie is initialized for this worker process + await _ensure_beanie_initialized() + + # Create a conversation entry + from advanced_omi_backend.models.conversation import create_conversation + import uuid as uuid_lib + + conversation_id = str(uuid_lib.uuid4()) + conversation = create_conversation( + conversation_id=conversation_id, + audio_uuid=final_audio_uuid, + user_id=user_id, + client_id=client_id + ) + # Set placeholder title/summary + conversation.title = "Processing..." + conversation.summary = "Transcript processing in progress" + await conversation.insert() + + logger.info(f"πŸ“ RQ: Created conversation {conversation_id} for audio {final_audio_uuid}") + + # Now enqueue transcript processing (runs outside async context) + version_id = str(uuid_lib.uuid4()) + + return { + "success": True, + "audio_uuid": final_audio_uuid, + "conversation_id": conversation_id, + "wav_filename": wav_filename, + "client_id": client_id, + "version_id": version_id, + "file_path": str(file_path) + } + + result = loop.run_until_complete(process()) + + # Enqueue transcript processing job chain (outside async context) + if result.get("success") and result.get("conversation_id"): + from .transcription_jobs import transcribe_full_audio_job, recognise_speakers_job + from .memory_jobs import process_memory_job + from advanced_omi_backend.controllers.queue_controller import transcription_queue, memory_queue, JOB_RESULT_TTL + + conversation_id = result["conversation_id"] + + # Job 1: Transcribe audio to text + transcript_job = transcription_queue.enqueue( + transcribe_full_audio_job, + conversation_id, + result["audio_uuid"], + result["file_path"], + result["version_id"], + user_id, + "upload", + job_timeout=600, + result_ttl=JOB_RESULT_TTL, + job_id=f"upload_{conversation_id[:8]}", + description=f"Transcribe audio for {conversation_id[:8]}", + meta={'audio_uuid': result["audio_uuid"]} + ) + logger.info(f"πŸ“₯ RQ: Enqueued transcription job {transcript_job.id}") + + # Job 2: Recognize speakers (depends on transcription) + speaker_job = transcription_queue.enqueue( + recognise_speakers_job, + conversation_id, + result["version_id"], + result["file_path"], + user_id, + "", # transcript_text - will be read from DB + [], # words - will be read from DB + depends_on=transcript_job, + job_timeout=600, + result_ttl=JOB_RESULT_TTL, + job_id=f"speaker_{conversation_id[:8]}", + description=f"Recognize speakers for {conversation_id[:8]}", + meta={'audio_uuid': result["audio_uuid"]} + ) + logger.info(f"πŸ“₯ RQ: Enqueued speaker recognition job {speaker_job.id} (depends on {transcript_job.id})") + + # Job 3: Extract memories (depends on speaker recognition) + memory_job = memory_queue.enqueue( + process_memory_job, + None, # client_id - will be read from conversation in DB + user_id, + "", # user_email - will be read from user in DB + conversation_id, + depends_on=speaker_job, + job_timeout=1800, + result_ttl=JOB_RESULT_TTL, + job_id=f"memory_{conversation_id[:8]}", + description=f"Extract memories for {conversation_id[:8]}", + meta={'audio_uuid': result["audio_uuid"]} + ) + logger.info(f"πŸ“₯ RQ: Enqueued memory job {memory_job.id} (depends on {speaker_job.id})") + + result["transcript_job_id"] = transcript_job.id + result["speaker_job_id"] = speaker_job.id + result["memory_job_id"] = memory_job.id + + return result + + finally: + loop.close() + + except Exception as e: + logger.error(f"❌ RQ: Audio processing failed for client {client_id}: {e}") + raise + + +def process_cropping_job( + client_id: str, + user_id: str, + audio_uuid: str, + original_path: str, + speech_segments: list, + output_path: str +) -> Dict[str, Any]: + """ + RQ job function for audio cropping. + + This function is executed by RQ workers and can survive server restarts. + """ + import asyncio + from advanced_omi_backend.audio_utils import _process_audio_cropping_with_relative_timestamps + from advanced_omi_backend.database import get_collections, AudioChunksRepository + + try: + logger.info(f"πŸ”„ RQ: Starting audio cropping for audio {audio_uuid}") + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + async def process(): + # Get repository + collections = get_collections() + repository = AudioChunksRepository(collections["chunks_col"]) + + # Convert list of lists to list of tuples + segments_tuples = [tuple(seg) for seg in speech_segments] + + # Process cropping + await _process_audio_cropping_with_relative_timestamps( + original_path, + segments_tuples, + output_path, + audio_uuid, + repository + ) + + logger.info(f"βœ… RQ: Completed audio cropping for audio {audio_uuid}") + + return { + "success": True, + "audio_uuid": audio_uuid, + "output_path": output_path, + "segments": len(speech_segments) + } + + result = loop.run_until_complete(process()) + return result + + finally: + loop.close() + + except Exception as e: + logger.error(f"❌ RQ: Audio cropping failed for audio {audio_uuid}: {e}") + raise + + +@async_job(redis=True, beanie=True) +async def audio_streaming_persistence_job( + session_id: str, + user_id: str, + user_email: str, + client_id: str, + redis_client=None +) -> Dict[str, Any]: + """ + Long-running RQ job that collects audio chunks from Redis stream and writes to disk progressively. + + Runs in parallel with transcription processing to reduce memory pressure on WebSocket. + + Args: + session_id: Stream session ID + user_id: User ID + user_email: User email + client_id: Client ID + redis_client: Redis client (injected by decorator) + + Returns: + Dict with audio_file_path, chunk_count, total_bytes, duration_seconds + """ + logger.info(f"🎡 Starting audio persistence for session {session_id}") + + # Setup audio persistence consumer group (separate from transcription consumer) + audio_stream_name = f"audio:stream:{client_id}" + audio_group_name = "audio_persistence" + audio_consumer_name = f"persistence-{session_id[:8]}" + + try: + await redis_client.xgroup_create( + audio_stream_name, + audio_group_name, + "0", + mkstream=True + ) + logger.info(f"πŸ“¦ Created audio persistence consumer group for {audio_stream_name}") + except Exception as e: + if "BUSYGROUP" not in str(e): + logger.warning(f"Failed to create audio consumer group: {e}") + logger.debug(f"Audio consumer group already exists for {audio_stream_name}") + + # Job control + session_key = f"audio:session:{session_id}" + max_runtime = 3540 # 59 minutes + start_time = time.time() + + # Audio collection + audio_chunks = [] + chunk_count = 0 + total_bytes = 0 + end_signal_received = False + consecutive_empty_reads = 0 + max_empty_reads = 3 # Exit after 3 consecutive empty reads (deterministic check) + + while True: + # Check timeout + if time.time() - start_time > max_runtime: + logger.warning(f"⏱️ Timeout reached for audio persistence {session_id}") + break + + # Read audio chunks from stream (non-blocking) + try: + audio_messages = await redis_client.xreadgroup( + audio_group_name, + audio_consumer_name, + {audio_stream_name: ">"}, + count=20, # Read up to 20 chunks at a time for efficiency + block=500 # 500ms timeout + ) + + if audio_messages: + # Reset empty read counter - we got messages + consecutive_empty_reads = 0 + + for stream_name, msgs in audio_messages: + for message_id, fields in msgs: + # Extract audio data + audio_data = fields.get(b"audio_data", b"") + chunk_id = fields.get(b"chunk_id", b"").decode() + + # Check for END signal + if chunk_id == "END": + logger.info(f"πŸ“‘ Received END signal in audio persistence") + end_signal_received = True + elif len(audio_data) > 0: + audio_chunks.append(audio_data) + chunk_count += 1 + total_bytes += len(audio_data) + + # Log every 40 chunks to avoid spam + if chunk_count % 40 == 0: + logger.info(f"πŸ“¦ Collected {chunk_count} audio chunks ({total_bytes / 1024 / 1024:.2f} MB)") + + # ACK the message + await redis_client.xack(audio_stream_name, audio_group_name, message_id) + else: + # No new messages - stream might be empty + if end_signal_received: + consecutive_empty_reads += 1 + logger.info(f"πŸ“­ No new messages ({consecutive_empty_reads}/{max_empty_reads} empty reads after END signal)") + + if consecutive_empty_reads >= max_empty_reads: + logger.info(f"βœ… Stream empty after END signal - stopping audio collection") + break + + except Exception as audio_error: + # Stream might not exist yet or other transient errors + logger.debug(f"Audio stream read error (non-fatal): {audio_error}") + + await asyncio.sleep(0.1) # Check every 100ms for responsiveness + + # Write complete audio file + if audio_chunks: + from advanced_omi_backend.audio_utils import write_audio_file + + complete_audio = b''.join(audio_chunks) + timestamp = int(time.time() * 1000) + + logger.info(f"πŸ’Ύ Writing {len(audio_chunks)} chunks ({total_bytes / 1024 / 1024:.2f} MB) to disk") + + wav_filename, file_path, duration = await write_audio_file( + raw_audio_data=complete_audio, + audio_uuid=session_id, + client_id=client_id, + user_id=user_id, + user_email=user_email, + timestamp=timestamp, + validate=False + ) + logger.info(f"βœ… Wrote audio file: {wav_filename} ({duration:.1f}s, {chunk_count} chunks)") + + # Store file path in Redis for finalize job to find + audio_file_key = f"audio:file:{session_id}" + await redis_client.set(audio_file_key, file_path, ex=3600) + logger.info(f"πŸ’Ύ Stored audio file path in Redis: {audio_file_key}") + else: + logger.warning(f"⚠️ No audio chunks collected for session {session_id}") + file_path = None + duration = 0.0 + + # Clean up Redis tracking key + audio_job_key = f"audio_persistence:session:{session_id}" + await redis_client.delete(audio_job_key) + logger.info(f"🧹 Cleaned up tracking key {audio_job_key}") + + return { + "session_id": session_id, + "audio_file_path": file_path, + "chunk_count": chunk_count, + "total_bytes": total_bytes, + "duration_seconds": duration, + "runtime_seconds": time.time() - start_time + } + + +# Enqueue wrapper functions + +def enqueue_audio_processing( + client_id: str, + user_id: str, + user_email: str, + audio_data: bytes, + audio_rate: int, + audio_width: int, + audio_channels: int, + audio_uuid: Optional[str] = None, + timestamp: Optional[int] = None, + priority: JobPriority = JobPriority.NORMAL +): + """ + Enqueue an audio processing job (file writing + DB entry). + + Returns RQ Job object for tracking. + """ + timeout_mapping = { + JobPriority.URGENT: 120, # 2 minutes + JobPriority.HIGH: 90, # 1.5 minutes + JobPriority.NORMAL: 60, # 1 minute + JobPriority.LOW: 30 # 30 seconds + } + + job = default_queue.enqueue( + process_audio_job, + client_id, + user_id, + user_email, + audio_data, + audio_rate, + audio_width, + audio_channels, + audio_uuid, + timestamp, + job_timeout=timeout_mapping.get(priority, 60), + result_ttl=JOB_RESULT_TTL, + job_id=f"audio_{client_id}_{audio_uuid or 'new'}", + description=f"Process audio for client {client_id}", + meta={'audio_uuid': audio_uuid} if audio_uuid else {} + ) + + logger.info(f"πŸ“₯ RQ: Enqueued audio job {job.id} for client {client_id}") + return job + + +def enqueue_cropping( + client_id: str, + user_id: str, + audio_uuid: str, + original_path: str, + speech_segments: list, + output_path: str, + priority: JobPriority = JobPriority.NORMAL +): + """ + Enqueue an audio cropping job. + + Returns RQ Job object for tracking. + """ + timeout_mapping = { + JobPriority.URGENT: 300, # 5 minutes + JobPriority.HIGH: 240, # 4 minutes + JobPriority.NORMAL: 180, # 3 minutes + JobPriority.LOW: 120 # 2 minutes + } + + job = default_queue.enqueue( + process_cropping_job, + client_id, + user_id, + audio_uuid, + original_path, + speech_segments, + output_path, + job_timeout=timeout_mapping.get(priority, 180), + result_ttl=JOB_RESULT_TTL, + job_id=f"cropping_{audio_uuid[:8]}", + description=f"Crop audio for {audio_uuid[:8]}", + meta={'audio_uuid': audio_uuid} + ) + + logger.info(f"πŸ“₯ RQ: Enqueued cropping job {job.id} for audio {audio_uuid}") + return job diff --git a/backends/advanced/src/advanced_omi_backend/workers/audio_stream_deepgram_worker.py b/backends/advanced/src/advanced_omi_backend/workers/audio_stream_deepgram_worker.py new file mode 100644 index 00000000..80203677 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/audio_stream_deepgram_worker.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Deepgram audio stream worker. + +Starts a consumer that reads from audio:stream:deepgram and transcribes audio. +""" + +import asyncio +import logging +import os +import signal +import sys + +import redis.asyncio as redis + +from advanced_omi_backend.services.transcription.deepgram import DeepgramStreamConsumer + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) + +logger = logging.getLogger(__name__) + + +async def main(): + """Main worker entry point.""" + logger.info("πŸš€ Starting Deepgram audio stream worker") + + # Get configuration from environment + api_key = os.getenv("DEEPGRAM_API_KEY") + if not api_key: + logger.error("DEEPGRAM_API_KEY environment variable is required") + sys.exit(1) + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + # Create Redis client + redis_client = await redis.from_url( + redis_url, + encoding="utf-8", + decode_responses=False + ) + logger.info("Connected to Redis") + + # Create consumer with balanced buffer size + # 20 chunks = ~5 seconds of audio + # Balance between transcription accuracy and latency + consumer = DeepgramStreamConsumer( + redis_client=redis_client, + api_key=api_key, + buffer_chunks=20 # 5 seconds - good context without excessive delay + ) + + # Setup signal handlers for graceful shutdown + def signal_handler(signum, frame): + logger.info(f"Received signal {signum}, shutting down...") + asyncio.create_task(consumer.stop()) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + logger.info("βœ… Deepgram worker ready") + + # This blocks until consumer is stopped + await consumer.start_consuming() + + except Exception as e: + logger.error(f"Worker error: {e}", exc_info=True) + sys.exit(1) + finally: + await redis_client.aclose() + logger.info("πŸ‘‹ Deepgram worker stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py new file mode 100644 index 00000000..2e44e034 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py @@ -0,0 +1,206 @@ +""" +Conversation-related RQ job functions. + +This module contains jobs related to conversation management and updates. +""" + +import asyncio +import logging +import time +from typing import Dict, Any + +from advanced_omi_backend.models.job import async_job +from advanced_omi_backend.controllers.queue_controller import ( + transcription_queue, + REDIS_URL, +) + +logger = logging.getLogger(__name__) + + +@async_job(redis=True, beanie=True) +async def open_conversation_job( + session_id: str, + user_id: str, + user_email: str, + client_id: str, + speech_detected_at: float, + redis_client=None +) -> Dict[str, Any]: + """ + Long-running RQ job that creates and continuously updates conversation with transcription results. + + Creates conversation when speech is detected, then monitors and updates until session ends. + + Args: + session_id: Stream session ID + user_id: User ID + user_email: User email + client_id: Client ID + speech_detected_at: Timestamp when speech was first detected + redis_client: Redis client (injected by decorator) + + Returns: + Dict with conversation_id, final_result_count, runtime_seconds + """ + from advanced_omi_backend.services.audio_stream import TranscriptionResultsAggregator + from advanced_omi_backend.models.conversation import Conversation + + import uuid + from advanced_omi_backend.models.conversation import create_conversation + + logger.info(f"πŸ“ Creating and opening conversation for session {session_id} (speech detected at {speech_detected_at})") + + # Create minimal streaming conversation + conversation_id = str(uuid.uuid4()) + conversation = create_conversation( + conversation_id=conversation_id, + audio_uuid=session_id, + user_id=user_id, + client_id=client_id, + title="Recording...", + summary="Transcribing audio..." + ) + + # Save to database + await conversation.insert() + logger.info(f"βœ… Created streaming conversation {conversation_id} for session {session_id}") + + # Store conversation_id in Redis for finalize job to find + conversation_key = f"conversation:session:{session_id}" + await redis_client.set(conversation_key, conversation_id, ex=3600) + logger.info(f"πŸ’Ύ Stored conversation ID in Redis: {conversation_key}") + + # Use redis_client parameter + aggregator = TranscriptionResultsAggregator(redis_client) + + # Job control + session_key = f"audio:session:{session_id}" + max_runtime = 3540 # 59 minutes (graceful exit before RQ timeout at 60 min) + start_time = time.time() + + last_result_count = 0 + finalize_received = False + + while True: + # Check if session is finalizing (set by producer when recording stops) + if not finalize_received: + status = await redis_client.hget(session_key, "status") + if status and status.decode() in ["finalizing", "complete"]: + finalize_received = True + logger.info(f"πŸ›‘ Session finalizing, waiting for audio persistence job to complete...") + break # Exit immediately when finalize signal received + + # Check timeout + if time.time() - start_time > max_runtime: + logger.warning(f"⏱️ Timeout reached for {conversation_id}") + break + + # Get combined results from aggregator + combined = await aggregator.get_combined_results(session_id) + current_count = combined["chunk_count"] + + # Update conversation if new results arrived + if current_count > last_result_count: + # Update conversation in MongoDB + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + + if conversation: + conversation.transcript = combined["text"] + conversation.segments = combined["segments"] + await conversation.save() + + logger.info( + f"πŸ“Š Updated conversation {conversation_id}: " + f"{current_count} results, {len(combined['text'])} chars, {len(combined['segments'])} segments" + ) + else: + logger.warning(f"⚠️ Conversation {conversation_id} not found") + + last_result_count = current_count + + await asyncio.sleep(1) # Check every second for responsiveness + + logger.info(f"βœ… Conversation {conversation_id} updates complete, waiting for audio file to be ready...") + + # Wait for audio_streaming_persistence_job to complete and write the file path + # Poll for the audio file key - this is deterministic, not a timeout-based grace period + audio_file_key = f"audio:file:{session_id}" + file_path_bytes = None + max_wait_audio = 30 # Maximum 30 seconds to wait for audio file + wait_start = time.time() + + while time.time() - wait_start < max_wait_audio: + file_path_bytes = await redis_client.get(audio_file_key) + if file_path_bytes: + wait_duration = time.time() - wait_start + logger.info(f"βœ… Audio file ready after {wait_duration:.1f}s") + break + + # Check if still within reasonable time + elapsed = time.time() - wait_start + if elapsed % 5 == 0: # Log every 5 seconds + logger.info(f"⏳ Waiting for audio file... ({elapsed:.0f}s elapsed)") + + await asyncio.sleep(0.5) # Check every 500ms + + if not file_path_bytes: + logger.error(f"❌ Audio file path not found in Redis after {max_wait_audio}s") + logger.warning(f"⚠️ Audio persistence job may have failed or is still running - cannot enqueue batch transcription") + else: + file_path = file_path_bytes.decode() + logger.info(f"πŸ“ Retrieved audio file path: {file_path}") + + # Enqueue complete batch processing job chain + from advanced_omi_backend.controllers.queue_controller import start_batch_processing_jobs + + job_ids = start_batch_processing_jobs( + conversation_id=conversation_id, + audio_uuid=session_id, + user_id=user_id, + user_email=user_email, + audio_file_path=file_path + ) + + logger.info( + f"πŸ“₯ RQ: Enqueued batch processing chain: " + f"{job_ids['transcription']} β†’ {job_ids['speaker_recognition']} β†’ {job_ids['memory']}" + ) + + # Wait a moment to ensure jobs are registered in RQ + await asyncio.sleep(0.5) + + # DON'T mark session as complete yet - dependent jobs are still processing + # Session remains in "finalizing" status until process_memory_job completes + logger.info(f"⏳ Session {session_id} remains in 'finalizing' status while batch jobs process") + + # Clean up Redis streams to prevent memory leaks + try: + # Delete the audio input stream + audio_stream_key = f"audio:stream:{client_id}" + await redis_client.delete(audio_stream_key) + logger.info(f"🧹 Deleted audio stream: {audio_stream_key}") + + # Delete the transcription results stream + results_stream_key = f"transcription:results:{session_id}" + await redis_client.delete(results_stream_key) + logger.info(f"🧹 Deleted results stream: {results_stream_key}") + + # Set TTL on session key (expire after 1 hour) + await redis_client.expire(session_key, 3600) + logger.info(f"⏰ Set TTL on session key: {session_key}") + except Exception as cleanup_error: + logger.warning(f"⚠️ Error during stream cleanup: {cleanup_error}") + + # Clean up Redis tracking key so new speech detection jobs can start + open_job_key = f"open_conversation:session:{session_id}" + await redis_client.delete(open_job_key) + logger.info(f"🧹 Cleaned up tracking key {open_job_key}") + + return { + "conversation_id": conversation_id, + "final_result_count": last_result_count, + "runtime_seconds": time.time() - start_time + } diff --git a/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py new file mode 100644 index 00000000..a838ee67 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py @@ -0,0 +1,210 @@ +""" +Memory-related RQ job functions. + +This module contains jobs related to memory extraction and processing. +""" + +import logging +import time +from datetime import UTC, datetime +from typing import Dict, Any + +from advanced_omi_backend.models.job import JobPriority, BaseRQJob, async_job +from advanced_omi_backend.controllers.queue_controller import ( + memory_queue, + JOB_RESULT_TTL, +) + +logger = logging.getLogger(__name__) + + +@async_job(redis=True, beanie=True) +async def process_memory_job( + client_id: str, + user_id: str, + user_email: str, + conversation_id: str, + redis_client=None +) -> Dict[str, Any]: + """ + RQ job function for memory extraction and processing from conversations. + + Args: + client_id: Client identifier + user_id: User ID + user_email: User email + conversation_id: Conversation ID to process + redis_client: Redis client (injected by decorator) + + Returns: + Dict with processing results + """ + from advanced_omi_backend.models.conversation import Conversation + from advanced_omi_backend.memory import get_memory_service + from advanced_omi_backend.users import get_user_by_id + + start_time = time.time() + logger.info(f"πŸ”„ Starting memory processing for conversation {conversation_id}") + + # Get conversation data + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation_model: + logger.warning(f"No conversation found for {conversation_id}") + return {"success": False, "error": "Conversation not found"} + + # Read client_id and user_email from conversation/user if not provided + # (Parameters may be empty if called via job dependency) + actual_client_id = client_id or conversation_model.client_id + actual_user_email = user_email + + if not actual_user_email: + user = await get_user_by_id(user_id) + if user: + actual_user_email = user.email + else: + logger.warning(f"Could not find user {user_id}") + actual_user_email = "" + + logger.info(f"πŸ”„ Processing memory for conversation {conversation_id}, client={actual_client_id}, user={user_id}") + + # Extract conversation text from transcript segments + full_conversation = "" + segments = conversation_model.segments + if segments: + dialogue_lines = [] + for segment in segments: + # Handle both dict and object segments + if isinstance(segment, dict): + text = segment.get("text", "").strip() + speaker = segment.get("speaker", "Unknown") + else: + text = getattr(segment, "text", "").strip() + speaker = getattr(segment, "speaker", "Unknown") + + if text: + dialogue_lines.append(f"{speaker}: {text}") + full_conversation = "\n".join(dialogue_lines) + elif conversation_model.transcript and isinstance(conversation_model.transcript, str): + # Fallback: if segments are empty but transcript text exists + full_conversation = conversation_model.transcript + + if len(full_conversation) < 10: + logger.warning(f"Conversation too short for memory processing: {conversation_id}") + return {"success": False, "error": "Conversation too short"} + + # Check primary speakers filter + user = await get_user_by_id(user_id) + if user and user.primary_speakers: + transcript_speakers = set() + for segment in conversation_model.segments: + # Handle both dict and object segments + if isinstance(segment, dict): + identified_as = segment.get('identified_as') + else: + identified_as = getattr(segment, 'identified_as', None) + + if identified_as and identified_as != 'Unknown': + transcript_speakers.add(identified_as.strip().lower()) + + primary_speaker_names = {ps['name'].strip().lower() for ps in user.primary_speakers} + + if transcript_speakers and not transcript_speakers.intersection(primary_speaker_names): + logger.info(f"Skipping memory - no primary speakers found in conversation {conversation_id}") + return {"success": True, "skipped": True, "reason": "No primary speakers"} + + # Process memory + memory_service = get_memory_service() + memory_result = await memory_service.add_memory( + full_conversation, + actual_client_id, + conversation_id, + user_id, + actual_user_email, + allow_update=True, + ) + + if memory_result: + success, created_memory_ids = memory_result + + if success and created_memory_ids: + # Add memory references to conversation + conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if conversation_model: + memory_refs = [ + {"memory_id": mid, "created_at": datetime.now(UTC).isoformat(), "status": "created"} + for mid in created_memory_ids + ] + conversation_model.memories.extend(memory_refs) + await conversation_model.save() + + processing_time = time.time() - start_time + logger.info(f"βœ… Completed memory processing for conversation {conversation_id} - created {len(created_memory_ids)} memories in {processing_time:.2f}s") + + # Mark session as complete in Redis (this is the last job in the chain) + if conversation_model and conversation_model.audio_uuid: + session_key = f"audio:session:{conversation_model.audio_uuid}" + try: + await redis_client.hset(session_key, mapping={ + "status": "complete", + "completed_at": str(time.time()) + }) + logger.info(f"βœ… Marked session {conversation_model.audio_uuid} as complete (all jobs finished)") + except Exception as e: + logger.warning(f"⚠️ Could not mark session as complete: {e}") + + return { + "success": True, + "memories_created": len(created_memory_ids), + "processing_time": processing_time + } + else: + # Mark session as complete even if no memories created + if conversation_model and conversation_model.audio_uuid: + session_key = f"audio:session:{conversation_model.audio_uuid}" + try: + await redis_client.hset(session_key, mapping={ + "status": "complete", + "completed_at": str(time.time()) + }) + logger.info(f"βœ… Marked session {conversation_model.audio_uuid} as complete (no memories)") + except Exception as e: + logger.warning(f"⚠️ Could not mark session as complete: {e}") + + return {"success": True, "memories_created": 0, "skipped": True} + else: + return {"success": False, "error": "Memory service returned False"} + + +def enqueue_memory_processing( + client_id: str, + user_id: str, + user_email: str, + conversation_id: str, + priority: JobPriority = JobPriority.NORMAL +): + """ + Enqueue a memory processing job. + + Returns RQ Job object for tracking. + """ + timeout_mapping = { + JobPriority.URGENT: 3600, # 60 minutes + JobPriority.HIGH: 2400, # 40 minutes + JobPriority.NORMAL: 1800, # 30 minutes + JobPriority.LOW: 900 # 15 minutes + } + + job = memory_queue.enqueue( + process_memory_job, + client_id, + user_id, + user_email, + conversation_id, + job_timeout=timeout_mapping.get(priority, 1800), + result_ttl=JOB_RESULT_TTL, + job_id=f"memory_{conversation_id[:8]}", + description=f"Process memory for conversation {conversation_id[:8]}" + ) + + logger.info(f"πŸ“₯ RQ: Enqueued memory job {job.id} for conversation {conversation_id}") + return job diff --git a/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py new file mode 100644 index 00000000..c69bc3fa --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py @@ -0,0 +1,701 @@ +""" +Transcription-related RQ job functions. + +This module contains all jobs related to speech-to-text transcription processing. +""" + +import asyncio +import os +import logging +import time +from typing import Dict, Any + +from advanced_omi_backend.models.job import JobPriority, BaseRQJob, async_job + +from advanced_omi_backend.controllers.queue_controller import ( + transcription_queue, + redis_conn, + _ensure_beanie_initialized, + JOB_RESULT_TTL, + REDIS_URL, +) + +logger = logging.getLogger(__name__) + + +async def apply_speaker_recognition( + audio_path: str, + transcript_text: str, + words: list, + segments: list, + user_id: str, + conversation_id: str = None +) -> list: + """ + Apply speaker recognition to segments using the speaker recognition service. + + This is a reusable helper function that can be called from any job. + + Args: + audio_path: Path to the audio file + transcript_text: Full transcript text + words: Word-level timing data + segments: List of Conversation.SpeakerSegment objects + user_id: User ID + conversation_id: Optional conversation ID for logging + + Returns: + Updated list of segments with identified speakers + """ + try: + from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient + + speaker_client = SpeakerRecognitionClient() + if not speaker_client.enabled: + logger.info(f"🎀 Speaker recognition disabled, using original speaker labels") + return segments + + logger.info(f"🎀 Speaker recognition enabled, identifying speakers{f' for {conversation_id}' if conversation_id else ''}...") + + # Prepare transcript data with word-level timings + transcript_data = { + "text": transcript_text, + "words": words + } + + # Call speaker recognition service to match and identify speakers + speaker_result = await speaker_client.diarize_identify_match( + audio_path=audio_path, + transcript_data=transcript_data, + user_id=user_id + ) + + if not speaker_result or "segments" not in speaker_result: + logger.info(f"🎀 Speaker recognition returned no segments, keeping original transcription segments") + return segments + + speaker_identified_segments = speaker_result["segments"] + logger.info(f"🎀 Speaker recognition returned {len(speaker_identified_segments)} identified segments") + logger.info(f"🎀 Original segments: {len(segments)}") + + # Create time-based speaker mapping + def get_speaker_at_time(timestamp: float, speaker_segments: list) -> str: + """Get the identified speaker active at a given timestamp.""" + for seg in speaker_segments: + seg_start = seg.get("start", 0.0) + seg_end = seg.get("end", 0.0) + if seg_start <= timestamp <= seg_end: + return seg.get("identified_as") or seg.get("speaker", "Unknown") + return None + + # Update each segment's speaker based on its timestamp + updated_count = 0 + for seg in segments: + seg_mid = (seg.start + seg.end) / 2.0 + identified_speaker = get_speaker_at_time(seg_mid, speaker_identified_segments) + + if identified_speaker and identified_speaker != "Unknown": + original_speaker = seg.speaker + seg.speaker = identified_speaker + updated_count += 1 + logger.debug(f"🎀 Segment [{seg.start:.1f}-{seg.end:.1f}] '{original_speaker}' -> '{identified_speaker}'") + + # Ensure segments remain sorted by start time + segments.sort(key=lambda s: s.start) + logger.info(f"🎀 Updated {updated_count}/{len(segments)} segments with speaker identifications") + + return segments + + except Exception as speaker_error: + logger.warning(f"⚠️ Speaker recognition failed: {speaker_error}") + logger.warning(f"Continuing with original transcription speaker labels") + import traceback + logger.debug(traceback.format_exc()) + return segments + + +@async_job(redis=True, beanie=True) +async def transcribe_full_audio_job( + conversation_id: str, + audio_uuid: str, + audio_path: str, + version_id: str, + user_id: str, + trigger: str = "reprocess", + redis_client=None +) -> Dict[str, Any]: + """ + RQ job function for transcribing full audio to text (transcription only, no speaker recognition). + + This job: + 1. Transcribes audio to text with generic speaker labels (Speaker 0, Speaker 1, etc.) + 2. Generates title and summary + 3. Saves transcript version to conversation + 4. Returns results for downstream jobs (speaker recognition, memory) + + Speaker recognition is handled by a separate job (recognise_speakers_job). + + Args: + conversation_id: Conversation ID + audio_uuid: Audio UUID (unused but kept for compatibility) + audio_path: Path to audio file + version_id: Version ID for new transcript + user_id: User ID + trigger: Trigger source + redis_client: Redis client (injected by decorator) + + Returns: + Dict with processing results including transcript data for next job + """ + from pathlib import Path + from advanced_omi_backend.services.transcription import get_transcription_provider + from advanced_omi_backend.models.conversation import Conversation + + logger.info(f"πŸ”„ RQ: Starting transcript processing for conversation {conversation_id} (trigger: {trigger})") + + start_time = time.time() + + # Get the transcription provider + provider = get_transcription_provider(mode="batch") + if not provider: + raise ValueError("No transcription provider available") + + provider_name = provider.name + logger.info(f"Using transcription provider: {provider_name}") + + # Read the audio file + audio_file_path = Path(audio_path) + if not audio_file_path.exists(): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + + # Load audio data + with open(audio_file_path, 'rb') as f: + audio_data = f.read() + + # Transcribe the audio (assume 16kHz sample rate) + transcription_result = await provider.transcribe( + audio_data=audio_data, + sample_rate=16000, + diarize=True + ) + + # Extract results + transcript_text = transcription_result.get("text", "") + segments = transcription_result.get("segments", []) + words = transcription_result.get("words", []) + + logger.info(f"πŸ“Š Transcription complete: {len(transcript_text)} chars, {len(segments)} segments, {len(words)} words") + + # Calculate processing time (transcription only) + processing_time = time.time() - start_time + + # Get the conversation using Beanie + conversation = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation: + logger.error(f"Conversation {conversation_id} not found") + return {"success": False, "error": "Conversation not found"} + + # Convert segments to SpeakerSegment objects + speaker_segments = [] + for seg in segments: + # Use identified_as if available (from speaker recognition), otherwise use speaker label + speaker_name = seg.get("identified_as") or seg.get("speaker", "Unknown") + + speaker_segments.append( + Conversation.SpeakerSegment( + start=seg.get("start", 0), + end=seg.get("end", 0), + text=seg.get("text", ""), + speaker=speaker_name, + confidence=seg.get("confidence") + ) + ) + + logger.info(f"πŸ“Š Created {len(speaker_segments)} speaker segments") + + # Add new transcript version + provider_normalized = provider_name.lower() if provider_name else "unknown" + + # Prepare metadata (transcription only - speaker recognition will add its own metadata) + metadata = { + "trigger": trigger, + "audio_file_size": len(audio_data), + "segment_count": len(segments), + "word_count": len(words), + "words": words, # Store words for speaker recognition job to read + "speaker_recognition": { + "enabled": False, + "reason": "handled_by_separate_job" + } + } + + conversation.add_transcript_version( + version_id=version_id, + transcript=transcript_text, + segments=speaker_segments, + provider=Conversation.TranscriptProvider(provider_normalized), + model=getattr(provider, 'model', 'unknown'), + processing_time_seconds=processing_time, + metadata=metadata, + set_as_active=True + ) + + # Generate title and summary from transcript using LLM + if transcript_text and len(transcript_text.strip()) > 0: + try: + from advanced_omi_backend.llm_client import async_generate + + # Prepare prompt for LLM + prompt = f"""Based on this conversation transcript, generate a concise title and summary. + +Transcript: +{transcript_text[:2000]} + +Respond in this exact format: +Title: +Summary: """ + + logger.info(f"πŸ€– Generating title/summary using LLM for conversation {conversation_id}") + llm_response = await async_generate(prompt, temperature=0.7) + + # Parse LLM response + lines = llm_response.strip().split('\n') + title = None + summary = None + + for line in lines: + if line.startswith('Title:'): + title = line.replace('Title:', '').strip() + elif line.startswith('Summary:'): + summary = line.replace('Summary:', '').strip() + + # Use LLM-generated title/summary if valid, otherwise fallback + if title and len(title) > 0: + conversation.title = title[:50] + "..." if len(title) > 50 else title + else: + # Fallback to first sentence if LLM didn't provide title + first_sentence = transcript_text.split('.')[0].strip() + conversation.title = first_sentence[:50] + "..." if len(first_sentence) > 50 else first_sentence + + if summary and len(summary) > 0: + conversation.summary = summary[:150] + "..." if len(summary) > 150 else summary + else: + # Fallback to truncated transcript if LLM didn't provide summary + conversation.summary = transcript_text[:150] + "..." if len(transcript_text) > 150 else transcript_text + + logger.info(f"βœ… Generated title: '{conversation.title}', summary: '{conversation.summary}'") + + except Exception as llm_error: + logger.warning(f"⚠️ LLM title/summary generation failed: {llm_error}") + # Fallback to simple truncation + first_sentence = transcript_text.split('.')[0].strip() + conversation.title = first_sentence[:50] + "..." if len(first_sentence) > 50 else first_sentence + conversation.summary = transcript_text[:150] + "..." if len(transcript_text) > 150 else transcript_text + else: + conversation.title = "Empty Conversation" + conversation.summary = "No speech detected" + + # Save the updated conversation + await conversation.save() + + logger.info(f"βœ… Transcript processing completed for {conversation_id} in {processing_time:.2f}s") + + return { + "success": True, + "conversation_id": conversation_id, + "version_id": version_id, + "audio_path": str(audio_file_path), + "user_id": user_id, + "transcript": transcript_text, + "segments": [seg.model_dump() for seg in speaker_segments], + "words": words, # Needed by speaker recognition + "provider": provider_name, + "processing_time_seconds": processing_time, + "trigger": trigger + } + + +@async_job(redis=True, beanie=True) +async def recognise_speakers_job( + conversation_id: str, + version_id: str, + audio_path: str, + user_id: str, + transcript_text: str, + words: list, + redis_client=None +) -> Dict[str, Any]: + """ + RQ job function for identifying speakers in a transcribed conversation. + + This job runs after transcription and: + 1. Calls speaker recognition service to identify speakers + 2. Updates the transcript version with identified speaker labels + 3. Returns results for downstream jobs (memory) + + Args: + conversation_id: Conversation ID + version_id: Transcript version ID to update + audio_path: Path to audio file + user_id: User ID + transcript_text: Transcript text from transcription job + words: Word-level timing data from transcription job + redis_client: Redis client (injected by decorator) + + Returns: + Dict with processing results + """ + from advanced_omi_backend.models.conversation import Conversation + from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient + + logger.info(f"🎀 RQ: Starting speaker recognition for conversation {conversation_id}") + + start_time = time.time() + + # Get the conversation + conversation = await Conversation.find_one(Conversation.conversation_id == conversation_id) + if not conversation: + logger.error(f"Conversation {conversation_id} not found") + return {"success": False, "error": "Conversation not found"} + + # Find the transcript version to update + transcript_version = None + for version in conversation.transcript_versions: + if version.version_id == version_id: + transcript_version = version + break + + if not transcript_version: + logger.error(f"Transcript version {version_id} not found") + return {"success": False, "error": "Transcript version not found"} + + # Check if speaker recognition is enabled + speaker_client = SpeakerRecognitionClient() + if not speaker_client.enabled: + logger.info(f"🎀 Speaker recognition disabled, skipping") + return { + "success": True, + "conversation_id": conversation_id, + "version_id": version_id, + "speaker_recognition_enabled": False, + "processing_time_seconds": 0 + } + + # Call speaker recognition service + try: + logger.info(f"🎀 Calling speaker recognition service...") + + # Read transcript text and words from the transcript version + # (Parameters may be empty if called via job dependency) + actual_transcript_text = transcript_text or transcript_version.transcript or "" + actual_words = words if words else [] + + # If words not provided, we need to get them from metadata + if not actual_words and transcript_version.metadata: + actual_words = transcript_version.metadata.get("words", []) + + if not actual_transcript_text: + logger.warning(f"🎀 No transcript text found in version {version_id}") + return { + "success": False, + "conversation_id": conversation_id, + "version_id": version_id, + "error": "No transcript text available", + "processing_time_seconds": 0 + } + + transcript_data = { + "text": actual_transcript_text, + "words": actual_words + } + + speaker_result = await speaker_client.diarize_identify_match( + audio_path=audio_path, + transcript_data=transcript_data, + user_id=user_id + ) + + if not speaker_result or "segments" not in speaker_result: + logger.warning(f"🎀 Speaker recognition returned no segments") + return { + "success": True, + "conversation_id": conversation_id, + "version_id": version_id, + "speaker_recognition_enabled": True, + "identified_speakers": [], + "processing_time_seconds": time.time() - start_time + } + + speaker_segments = speaker_result["segments"] + logger.info(f"🎀 Speaker recognition returned {len(speaker_segments)} segments") + + # Update the transcript version segments with identified speakers + updated_segments = [] + for seg in speaker_segments: + speaker_name = seg.get("identified_as") or seg.get("speaker", "Unknown") + updated_segments.append( + Conversation.SpeakerSegment( + start=seg.get("start", 0), + end=seg.get("end", 0), + text=seg.get("text", ""), + speaker=speaker_name, + confidence=seg.get("confidence") + ) + ) + + # Update the transcript version + transcript_version.segments = updated_segments + + # Extract unique identified speakers for metadata + identified_speakers = set() + for seg in speaker_segments: + identified_as = seg.get("identified_as", "Unknown") + if identified_as != "Unknown": + identified_speakers.add(identified_as) + + # Update metadata + if not transcript_version.metadata: + transcript_version.metadata = {} + + transcript_version.metadata["speaker_recognition"] = { + "enabled": True, + "identified_speakers": list(identified_speakers), + "speaker_count": len(identified_speakers), + "total_segments": len(speaker_segments), + "processing_time_seconds": time.time() - start_time + } + + # Update legacy fields if this is the active version + if conversation.active_transcript_version == version_id: + conversation.segments = updated_segments + + await conversation.save() + + processing_time = time.time() - start_time + logger.info(f"βœ… Speaker recognition completed for {conversation_id} in {processing_time:.2f}s") + + return { + "success": True, + "conversation_id": conversation_id, + "version_id": version_id, + "user_id": user_id, + "speaker_recognition_enabled": True, + "identified_speakers": list(identified_speakers), + "segment_count": len(updated_segments), + "processing_time_seconds": processing_time + } + + except Exception as speaker_error: + logger.error(f"❌ Speaker recognition failed: {speaker_error}") + import traceback + logger.debug(traceback.format_exc()) + + return { + "success": False, + "conversation_id": conversation_id, + "version_id": version_id, + "error": str(speaker_error), + "processing_time_seconds": time.time() - start_time + } + + +@async_job(redis=True, beanie=True) +async def stream_speech_detection_job( + session_id: str, + user_id: str, + user_email: str, + client_id: str, + redis_client=None +) -> Dict[str, Any]: + """ + Job that monitors transcription stream for speech (STREAMING MODE ONLY). + + Decorated with @async_job to handle setup/teardown automatically. + + Job lifecycle: + 1. Monitors transcription stream for speech + 2. When speech detected: + - Checks if conversation already open (prevents duplicates) + - If no open conversation: creates conversation + starts open_conversation_job + - Exits (job completes) + 3. New stream_speech_detection_job can be started when conversation closes + + This architecture alternates between "listening for speech" and "actively recording conversation". + + This is part of the V2 architecture using RQ jobs as orchestrators. + + For batch/upload mode, conversations are created upfront and transcribe_full_audio_job is used. + + Args: + session_id: Stream session ID + user_id: User ID + user_email: User email + client_id: Client ID + + Returns: + Dict with session_id, conversation_id, open_conversation_job_id, detected_speakers, runtime_seconds + """ + from advanced_omi_backend.services.audio_stream import TranscriptionResultsAggregator + from .conversation_jobs import open_conversation_job + + logger.info(f"πŸ” RQ: Starting stream speech detection for session {session_id}") + + # Use redis_client from decorator + aggregator = TranscriptionResultsAggregator(redis_client) + + # Job control + session_key = f"audio:session:{session_id}" + max_runtime = 3540 # 59 minutes (graceful exit before RQ timeout at 60 min) + start_time = time.time() + + conversation_id = None + open_conversation_job_id = None + detected_speakers = [] # Track enrolled speakers detected during speech detection + + while True: + # Check if session has ended (status = "finalizing" or "complete") + # session_status = await redis_client.hget(session_key, "status") + # if session_status: + # status_str = session_status.decode() if isinstance(session_status, bytes) else session_status + # if status_str in ["finalizing", "complete"]: + # logger.info(f"πŸ›‘ Session {status_str}, stopping speech detection") + # break + + # # Check timeout + # if time.time() - start_time > max_runtime: + # logger.warning(f"⏱️ Timeout reached for {session_id}") + # break + + # Get combined transcription results (aggregator does the combining) + combined = await aggregator.get_combined_results(session_id) + + if not combined["text"]: + await asyncio.sleep(2) # Check every 2 seconds + continue + + # Analyze for speech using centralized detection from utils + from advanced_omi_backend.utils.conversation_utils import analyze_speech + transcript_data = { + "text": combined["text"], + "words": combined["words"] + } + speech_analysis = analyze_speech(transcript_data) + has_speech = speech_analysis["has_speech"] + + print(f"πŸ” SPEECH ANALYSIS: session={session_id}, has_speech={has_speech}, conv_id={conversation_id}, words={speech_analysis.get('word_count', 0)}") + logger.info( + f"πŸ” Speech analysis for {session_id}: has_speech={has_speech}, " + f"conversation_id={conversation_id}, word_count={speech_analysis.get('word_count', 0)}" + ) + + if has_speech and not conversation_id: + print(f"πŸ’¬ SPEECH DETECTED! Checking if enrolled speakers present...") + logger.info(f"πŸ’¬ Speech detected in {session_id}!") + + # Check if we should filter by enrolled speakers (two-stage filter: text first, then speaker) + record_only_enrolled = os.getenv("RECORD_ONLY_ENROLLED_SPEAKERS", "false").lower() == "true" + + if record_only_enrolled: + logger.info(f"🎀 Checking if enrolled speakers are present...") + + from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient + + # Get raw transcription results (with chunk IDs) + raw_results = await aggregator.get_session_results(session_id) + + # Check if enrolled speaker is speaking (also returns speaker recognition results) + speaker_client = SpeakerRecognitionClient() + enrolled_speaker_present, speaker_recognition_result = await speaker_client.check_if_enrolled_speaker_present( + redis_client=redis_client, + client_id=client_id, + session_id=session_id, + user_id=user_id, + transcription_results=raw_results + ) + + if not enrolled_speaker_present: + logger.info(f"⏭️ Meaningful speech detected but not from enrolled speakers, continuing to listen...") + await asyncio.sleep(2) + continue + + # Extract identified speakers from the result + identified_speakers = [] + if speaker_recognition_result and "segments" in speaker_recognition_result: + for seg in speaker_recognition_result["segments"]: + identified_as = seg.get("identified_as") + # Filter out None and "Unknown" values + if identified_as and identified_as != "Unknown" and identified_as not in identified_speakers: + identified_speakers.append(identified_as) + + num_segments = len(speaker_recognition_result["segments"]) + + if identified_speakers: + speakers_str = ", ".join(identified_speakers) + logger.info(f"βœ… Enrolled speaker(s) detected: {speakers_str}") + logger.info(f"🎀 Speaker recognition returned {num_segments} segments with {len(identified_speakers)} enrolled speaker(s)") + print(f"βœ… ENROLLED SPEAKERS DETECTED: {speakers_str} ({num_segments} segments)") + detected_speakers = identified_speakers # Store for return value + else: + logger.info(f"βœ… Enrolled speaker detected! (no identified_as field in segments)") + logger.info(f"🎀 Speaker recognition returned {num_segments} segments during enrollment check") + else: + logger.info(f"βœ… Enrolled speaker detected! Proceeding to create conversation...") + + # Check if conversation job already running for this session + open_job_key = f"open_conversation:session:{session_id}" + existing_job = await redis_client.get(open_job_key) + + if existing_job: + # Already have an open conversation job running + open_conversation_job_id = existing_job.decode() + logger.info(f"βœ… Conversation job already running: {open_conversation_job_id}") + else: + # No conversation job running - enqueue one + speech_detected_at = time.time() + logger.info(f"πŸ“ Enqueueing open_conversation_job (speech detected at {speech_detected_at})") + + # Start open_conversation_job to create and monitor conversation + open_job = transcription_queue.enqueue( + open_conversation_job, + session_id, + user_id, + user_email, + client_id, + speech_detected_at, + job_timeout=3600, + result_ttl=600, + job_id=f"open-conv_{session_id[:12]}", + description=f"Open conversation for session {session_id[:12]}" + ) + open_conversation_job_id = open_job.id + + # Store job tracking (TTL handles cleanup automatically) + await redis_client.set( + open_job_key, + open_job.id, + ex=3600 # Expire after 1 hour + ) + + logger.info(f"βœ… Enqueued conversation job {open_job.id}") + + # Exit this job now that conversation job is running + logger.info(f"🏁 Exiting speech detection job - conversation job is now managing session") + break + else: + if not has_speech: + logger.debug(f"⏭️ No speech detected yet (words: {speech_analysis.get('word_count', 0)})") + else: + logger.debug(f"ℹ️ Speech detected but conversation already exists: {conversation_id}") + + await asyncio.sleep(2) # Check every 2 seconds + + logger.info(f"βœ… Stream speech detection complete for {session_id}") + + return { + "session_id": session_id, + "open_conversation_job_id": open_conversation_job_id, + "detected_speakers": detected_speakers, + "runtime_seconds": time.time() - start_time + } + + diff --git a/backends/advanced/start-k8s.sh b/backends/advanced/start-k8s.sh new file mode 100755 index 00000000..963ff533 --- /dev/null +++ b/backends/advanced/start-k8s.sh @@ -0,0 +1,197 @@ +#!/bin/bash + +# Friend-Lite Backend Kubernetes Startup Script +# Starts both the FastAPI backend and RQ workers for K8s deployment + +set -e + +echo "πŸš€ Starting Friend-Lite Backend (Kubernetes)..." + +# Debug environment variables +echo "πŸ” Environment check:" +echo " REDIS_URL: ${REDIS_URL:-NOT_SET}" +echo " MONGODB_URI: ${MONGODB_URI:-NOT_SET}" + +# Function to handle shutdown +shutdown() { + echo "πŸ›‘ Shutting down services..." + kill $AUDIO_WORKER_1_PID 2>/dev/null || true + kill $RQ_WORKER_1_PID 2>/dev/null || true + kill $RQ_WORKER_2_PID 2>/dev/null || true + kill $RQ_WORKER_3_PID 2>/dev/null || true + kill $BACKEND_PID 2>/dev/null || true + wait + echo "βœ… All services stopped" + exit 0 +} + +# Set up signal handlers +trap shutdown SIGTERM SIGINT + +# Test Redis connectivity first +echo "πŸ” Testing Redis connectivity..." +if [ -n "${REDIS_URL}" ]; then + echo " Using Redis URL: ${REDIS_URL}" + # Try to ping Redis to verify connectivity + timeout 5 python3 -c " +import redis +import sys +import os +try: + r = redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0')) + r.ping() + print(' βœ… Redis connection successful') +except Exception as e: + print(f' ❌ Redis connection failed: {e}') + sys.exit(1) +" || { + echo " ❌ Redis connectivity test failed, continuing anyway..." + } +else + echo " ⚠️ REDIS_URL not set, using default" +fi + +# Clean up stale worker registrations from previous runs +echo "🧹 Cleaning up stale worker registrations from Redis..." +uv run --no-sync python3 -c " +from rq import Worker +from redis import Redis +import os + +redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0') +redis_conn = Redis.from_url(redis_url) + +# Get all workers and clean up dead ones +workers = Worker.all(connection=redis_conn) +for worker in workers: + # Force cleanup of all registered workers from previous runs + worker.register_death() +print(f'Cleaned up {len(workers)} stale workers') +" 2>/dev/null || echo "No stale workers to clean" + +sleep 1 + +# OLD WORKERS - Disabled for testing new Redis Streams architecture +# These have been renamed to old_audio_stream_worker.py and old_transcription_stream_worker.py +# echo "🎡 Starting Redis Streams audio workers (2 workers)..." +# if uv run --no-sync python3 -m advanced_omi_backend.workers.old_audio_stream_worker & +# then +# AUDIO_WORKER_1_PID=$! +# echo " βœ… Audio stream worker 1 started with PID: $AUDIO_WORKER_1_PID" +# else +# echo " ❌ Failed to start audio stream worker 1" +# exit 1 +# fi + +# if uv run --no-sync python3 -m advanced_omi_backend.workers.old_audio_stream_worker & +# then +# AUDIO_WORKER_2_PID=$! +# echo " βœ… Audio stream worker 2 started with PID: $AUDIO_WORKER_2_PID" +# else +# echo " ❌ Failed to start audio stream worker 2" +# kill $AUDIO_WORKER_1_PID 2>/dev/null || true +# exit 1 +# fi + +# echo "πŸ“ Starting transcription stream workers (2 workers)..." +# if uv run --no-sync python3 -m advanced_omi_backend.workers.old_transcription_stream_worker & +# then +# TRANSCRIPTION_WORKER_1_PID=$! +# echo " βœ… Transcription stream worker 1 started with PID: $TRANSCRIPTION_WORKER_1_PID" +# else +# echo " ❌ Failed to start transcription stream worker 1" +# kill $AUDIO_WORKER_1_PID $AUDIO_WORKER_2_PID 2>/dev/null || true +# exit 1 +# fi + +# if uv run --no-sync python3 -m advanced_omi_backend.workers.old_transcription_stream_worker & +# then +# TRANSCRIPTION_WORKER_2_PID=$! +# echo " βœ… Transcription stream worker 2 started with PID: $TRANSCRIPTION_WORKER_2_PID" +# else +# echo " ❌ Failed to start transcription stream worker 2" +# kill $AUDIO_WORKER_1_PID $AUDIO_WORKER_2_PID $TRANSCRIPTION_WORKER_1_PID 2>/dev/null || true +# exit 1 +# fi + +# NEW WORKERS - Redis Streams multi-provider architecture +# Single worker ensures sequential processing of audio chunks (matching start-workers.sh) +echo "🎡 Starting audio stream Deepgram worker (1 worker for sequential processing)..." +if uv run --no-sync python3 -m advanced_omi_backend.workers.audio_stream_deepgram_worker & +then + AUDIO_WORKER_1_PID=$! + echo " βœ… Deepgram stream worker started with PID: $AUDIO_WORKER_1_PID" +else + echo " ❌ Failed to start Deepgram stream worker" + exit 1 +fi + +# Start 3 RQ workers listening to ALL queues (matching start-workers.sh) +echo "πŸ”§ Starting RQ workers (3 workers, all queues: transcription, memory, default)..." +if uv run --no-sync rq worker transcription memory default --url "${REDIS_URL:-redis://localhost:6379/0}" --verbose --logging_level INFO & +then + RQ_WORKER_1_PID=$! + echo " βœ… RQ worker 1 started with PID: $RQ_WORKER_1_PID" +else + echo " ❌ Failed to start RQ worker 1" + kill $AUDIO_WORKER_1_PID 2>/dev/null || true + exit 1 +fi + +if uv run --no-sync rq worker transcription memory default --url "${REDIS_URL:-redis://localhost:6379/0}" --verbose --logging_level INFO & +then + RQ_WORKER_2_PID=$! + echo " βœ… RQ worker 2 started with PID: $RQ_WORKER_2_PID" +else + echo " ❌ Failed to start RQ worker 2" + kill $AUDIO_WORKER_1_PID $RQ_WORKER_1_PID 2>/dev/null || true + exit 1 +fi + +if uv run --no-sync rq worker transcription memory default --url "${REDIS_URL:-redis://localhost:6379/0}" --verbose --logging_level INFO & +then + RQ_WORKER_3_PID=$! + echo " βœ… RQ worker 3 started with PID: $RQ_WORKER_3_PID" +else + echo " ❌ Failed to start RQ worker 3" + kill $AUDIO_WORKER_1_PID $RQ_WORKER_1_PID $RQ_WORKER_2_PID 2>/dev/null || true + exit 1 +fi + +# Give workers a moment to start +sleep 3 + +# Start the main FastAPI application +echo "🌐 Starting FastAPI backend..." +if uv run --no-sync python3 src/advanced_omi_backend/main.py & +then + BACKEND_PID=$! + echo " βœ… FastAPI backend started with PID: $BACKEND_PID" +else + echo " ❌ Failed to start FastAPI backend" + kill $AUDIO_WORKER_1_PID $RQ_WORKER_1_PID $RQ_WORKER_2_PID $RQ_WORKER_3_PID 2>/dev/null || true + exit 1 +fi + +echo "πŸŽ‰ All services started successfully!" +echo " - Audio stream worker: $AUDIO_WORKER_1_PID (Redis Streams consumer - sequential processing)" +echo " - RQ worker 1: $RQ_WORKER_1_PID (transcription, memory, default)" +echo " - RQ worker 2: $RQ_WORKER_2_PID (transcription, memory, default)" +echo " - RQ worker 3: $RQ_WORKER_3_PID (transcription, memory, default)" +echo " - FastAPI Backend: $BACKEND_PID" + +# Wait for any process to exit +wait -n + +# If we get here, one process has exited - kill the others +echo "⚠️ One service exited, stopping all services..." +# Kill only non-empty PIDs +[ -n "$AUDIO_WORKER_1_PID" ] && kill $AUDIO_WORKER_1_PID 2>/dev/null || true +[ -n "$RQ_WORKER_1_PID" ] && kill $RQ_WORKER_1_PID 2>/dev/null || true +[ -n "$RQ_WORKER_2_PID" ] && kill $RQ_WORKER_2_PID 2>/dev/null || true +[ -n "$RQ_WORKER_3_PID" ] && kill $RQ_WORKER_3_PID 2>/dev/null || true +[ -n "$BACKEND_PID" ] && kill $BACKEND_PID 2>/dev/null || true +wait + +echo "πŸ”„ All services stopped" +exit 1 \ No newline at end of file diff --git a/backends/advanced/start-workers.sh b/backends/advanced/start-workers.sh new file mode 100644 index 00000000..173f986c --- /dev/null +++ b/backends/advanced/start-workers.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Unified worker startup script +# Starts all workers in a single container for efficiency + +set -e + +echo "πŸš€ Starting Friend-Lite Workers..." + +# Clean up any stale worker registrations from previous runs +echo "🧹 Cleaning up stale worker registrations from Redis..." +# Use RQ's cleanup command to remove dead workers +uv run python -c " +from rq import Worker +from redis import Redis +import os + +redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0') +redis_conn = Redis.from_url(redis_url) + +# Get all workers and clean up dead ones +workers = Worker.all(connection=redis_conn) +for worker in workers: + # Force cleanup of all registered workers from previous runs + worker.register_death() +print(f'Cleaned up {len(workers)} stale workers') +" 2>/dev/null || echo "No stale workers to clean" + +sleep 1 + +# Function to handle shutdown +shutdown() { + echo "πŸ›‘ Shutting down workers..." + kill $RQ_WORKER_1_PID 2>/dev/null || true + kill $RQ_WORKER_2_PID 2>/dev/null || true + kill $RQ_WORKER_3_PID 2>/dev/null || true + kill $AUDIO_WORKER_1_PID 2>/dev/null || true + wait + echo "βœ… All workers stopped" + exit 0 +} + +# Set up signal handlers +trap shutdown SIGTERM SIGINT + +# Configure Python logging for RQ workers +export PYTHONUNBUFFERED=1 + +# Start 3 RQ workers listening to ALL queues +echo "πŸ”§ Starting RQ workers (3 workers, all queues: transcription, memory, default)..." +uv run rq worker transcription memory default --url "${REDIS_URL:-redis://localhost:6379/0}" --verbose --logging_level INFO & +RQ_WORKER_1_PID=$! +uv run rq worker transcription memory default --url "${REDIS_URL:-redis://localhost:6379/0}" --verbose --logging_level INFO & +RQ_WORKER_2_PID=$! +uv run rq worker transcription memory default --url "${REDIS_URL:-redis://localhost:6379/0}" --verbose --logging_level INFO & +RQ_WORKER_3_PID=$! + +# Start 1 audio stream worker for Deepgram +# Single worker ensures sequential processing of audio chunks +echo "🎡 Starting audio stream Deepgram worker (1 worker for sequential processing)..." +uv run python -m advanced_omi_backend.workers.audio_stream_deepgram_worker & +AUDIO_WORKER_1_PID=$! + +echo "βœ… All workers started:" +echo " - RQ worker 1: PID $RQ_WORKER_1_PID (transcription, memory, default)" +echo " - RQ worker 2: PID $RQ_WORKER_2_PID (transcription, memory, default)" +echo " - RQ worker 3: PID $RQ_WORKER_3_PID (transcription, memory, default)" +echo " - Audio stream worker: PID $AUDIO_WORKER_1_PID (Redis Streams consumer - sequential processing)" + +# Wait for any process to exit +wait -n + +# If we get here, one process has exited - kill the others +echo "⚠️ One worker exited, stopping all workers..." +kill $RQ_WORKER_1_PID 2>/dev/null || true +kill $RQ_WORKER_2_PID 2>/dev/null || true +kill $RQ_WORKER_3_PID 2>/dev/null || true +kill $AUDIO_WORKER_1_PID 2>/dev/null || true +wait + +echo "πŸ”„ All workers stopped" +exit 1 diff --git a/backends/advanced/start.sh b/backends/advanced/start.sh new file mode 100755 index 00000000..51946672 --- /dev/null +++ b/backends/advanced/start.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +# Friend-Lite Backend Startup Script +# Starts both the FastAPI backend and RQ workers + +set -e + +echo "πŸš€ Starting Friend-Lite Backend..." + +# Function to handle shutdown +shutdown() { + echo "πŸ›‘ Shutting down services..." + pkill -TERM -P $$ + wait + echo "βœ… All services stopped" + exit 0 +} + +# Set up signal handlers +trap shutdown SIGTERM SIGINT + +# OLD WORKERS - Disabled for testing new Redis Streams architecture +# These have been renamed to old_audio_stream_worker.py and old_transcription_stream_worker.py +# echo "🎡 Starting Redis Streams audio workers (2 workers)..." +# uv run --extra deepgram python3 -m advanced_omi_backend.workers.old_audio_stream_worker & +# AUDIO_WORKER_1_PID=$! +# uv run --extra deepgram python3 -m advanced_omi_backend.workers.old_audio_stream_worker & +# AUDIO_WORKER_2_PID=$! + +# echo "πŸ“ Starting transcription stream workers (2 workers)..." +# uv run --extra deepgram python3 -m advanced_omi_backend.workers.old_transcription_stream_worker & +# TRANSCRIPTION_WORKER_1_PID=$! +# uv run --extra deepgram python3 -m advanced_omi_backend.workers.old_transcription_stream_worker & +# TRANSCRIPTION_WORKER_2_PID=$! + +# NEW WORKERS - Redis Streams multi-provider architecture +# Note: Workers are now started via docker-compose as dedicated services +# See: audio-stream-worker-1 and audio-stream-worker-2 in docker-compose.yml +echo "ℹ️ Audio stream workers run as dedicated docker-compose services" +AUDIO_WORKER_1_PID="" +AUDIO_WORKER_2_PID="" +TRANSCRIPTION_WORKER_1_PID="" +TRANSCRIPTION_WORKER_2_PID="" + +# RQ workers are now started via docker-compose as a dedicated service +# See: workers service in docker-compose.yml +echo "ℹ️ RQ workers run as dedicated docker-compose service" +RQ_WORKER_PID="" + +# Give workers a moment to start +sleep 2 + +# Start the main FastAPI application +echo "🌐 Starting FastAPI backend..." +uv run --extra deepgram python3 src/advanced_omi_backend/main.py & +BACKEND_PID=$! + +# Wait for any process to exit +wait -n + +# If we get here, one process has exited - kill the others +echo "⚠️ One service exited, stopping all services..." +# Kill only non-empty PIDs +[ -n "$AUDIO_WORKER_1_PID" ] && kill $AUDIO_WORKER_1_PID 2>/dev/null || true +[ -n "$AUDIO_WORKER_2_PID" ] && kill $AUDIO_WORKER_2_PID 2>/dev/null || true +[ -n "$RQ_WORKER_PID" ] && kill $RQ_WORKER_PID 2>/dev/null || true +[ -n "$BACKEND_PID" ] && kill $BACKEND_PID 2>/dev/null || true +wait + +echo "πŸ”„ All services stopped" +exit 1 \ No newline at end of file diff --git a/backends/advanced/tests/test_conversation_models.py b/backends/advanced/tests/test_conversation_models.py new file mode 100644 index 00000000..197fddee --- /dev/null +++ b/backends/advanced/tests/test_conversation_models.py @@ -0,0 +1,269 @@ +""" +Test suite for conversation models. +""" + +import pytest +from datetime import datetime +from advanced_omi_backend.models.conversation import ( + Conversation, + TranscriptVersion, + MemoryVersion, + SpeakerSegment, + TranscriptProvider, + MemoryProvider, + create_conversation +) + + +class TestConversationModel: + """Test Conversation Pydantic model.""" + + def test_create_conversation_factory(self): + """Test the create_conversation factory function.""" + conversation = create_conversation( + conversation_id="test-conv-123", + audio_uuid="test-audio-456", + user_id="test-user-789", + client_id="test-client-abc" + ) + + # Verify basic properties + assert conversation.conversation_id == "test-conv-123" + assert conversation.audio_uuid == "test-audio-456" + assert conversation.user_id == "test-user-789" + assert conversation.client_id == "test-client-abc" + assert isinstance(conversation.created_at, datetime) + + # Verify defaults + assert len(conversation.transcript_versions) == 0 + assert len(conversation.memory_versions) == 0 + assert conversation.active_transcript_version is None + assert conversation.active_memory_version is None + assert conversation.transcript is None + assert len(conversation.segments) == 0 + assert len(conversation.memories) == 0 + assert conversation.memory_count == 0 + + def test_speaker_segment_model(self): + """Test SpeakerSegment model.""" + segment = SpeakerSegment( + start=10.5, + end=15.8, + text="Hello, how are you today?", + speaker="Speaker A", + confidence=0.95 + ) + + assert segment.start == 10.5 + assert segment.end == 15.8 + assert segment.text == "Hello, how are you today?" + assert segment.speaker == "Speaker A" + assert segment.confidence == 0.95 + + def test_transcript_version_model(self): + """Test TranscriptVersion model.""" + segments = [ + SpeakerSegment(start=0.0, end=5.0, text="Hello", speaker="Speaker A"), + SpeakerSegment(start=5.1, end=10.0, text="Hi there", speaker="Speaker B") + ] + + version = TranscriptVersion( + version_id="trans-v1", + transcript="Hello Hi there", + segments=segments, + provider=TranscriptProvider.DEEPGRAM, + model="nova-3", + created_at=datetime.now(), + processing_time_seconds=12.5, + metadata={"confidence": 0.9} + ) + + assert version.version_id == "trans-v1" + assert version.transcript == "Hello Hi there" + assert len(version.segments) == 2 + assert version.provider == TranscriptProvider.DEEPGRAM + assert version.model == "nova-3" + assert version.processing_time_seconds == 12.5 + assert version.metadata["confidence"] == 0.9 + + def test_memory_version_model(self): + """Test MemoryVersion model.""" + version = MemoryVersion( + version_id="mem-v1", + memory_count=5, + transcript_version_id="trans-v1", + provider=MemoryProvider.FRIEND_LITE, + model="gpt-4o-mini", + created_at=datetime.now(), + processing_time_seconds=45.2, + metadata={"extraction_quality": "high"} + ) + + assert version.version_id == "mem-v1" + assert version.memory_count == 5 + assert version.transcript_version_id == "trans-v1" + assert version.provider == MemoryProvider.FRIEND_LITE + assert version.model == "gpt-4o-mini" + assert version.processing_time_seconds == 45.2 + assert version.metadata["extraction_quality"] == "high" + + def test_add_transcript_version(self): + """Test adding transcript versions to conversation.""" + conversation = create_conversation("conv-1", "audio-1", "user-1", "client-1") + + segments = [SpeakerSegment(start=0.0, end=5.0, text="Test", speaker="Speaker A")] + + # Add first transcript version + version1 = conversation.add_transcript_version( + version_id="v1", + transcript="Test transcript", + segments=segments, + provider=TranscriptProvider.DEEPGRAM, + model="nova-3", + processing_time_seconds=10.0 + ) + + assert len(conversation.transcript_versions) == 1 + assert conversation.active_transcript_version == "v1" + assert conversation.transcript == "Test transcript" + assert len(conversation.segments) == 1 + assert version1.version_id == "v1" + + # Add second transcript version without setting as active + version2 = conversation.add_transcript_version( + version_id="v2", + transcript="Updated transcript", + segments=segments, + provider=TranscriptProvider.MISTRAL, + set_as_active=False + ) + + assert len(conversation.transcript_versions) == 2 + assert conversation.active_transcript_version == "v1" # Still v1 + assert conversation.transcript == "Test transcript" # Still v1 content + + def test_add_memory_version(self): + """Test adding memory versions to conversation.""" + conversation = create_conversation("conv-1", "audio-1", "user-1", "client-1") + + # Add memory version + version1 = conversation.add_memory_version( + version_id="m1", + memory_count=3, + transcript_version_id="v1", + provider=MemoryProvider.FRIEND_LITE, + model="gpt-4o-mini", + processing_time_seconds=30.0 + ) + + assert len(conversation.memory_versions) == 1 + assert conversation.active_memory_version == "m1" + assert conversation.memory_count == 3 + assert version1.version_id == "m1" + + def test_set_active_versions(self): + """Test switching between active versions.""" + conversation = create_conversation("conv-1", "audio-1", "user-1", "client-1") + + # Add two transcript versions + segments1 = [SpeakerSegment(start=0.0, end=5.0, text="Version 1", speaker="Speaker A")] + segments2 = [SpeakerSegment(start=0.0, end=5.0, text="Version 2", speaker="Speaker A")] + + conversation.add_transcript_version("v1", "Transcript 1", segments1, TranscriptProvider.DEEPGRAM) + conversation.add_transcript_version("v2", "Transcript 2", segments2, TranscriptProvider.MISTRAL, set_as_active=False) + + # Should be v1 active + assert conversation.active_transcript_version == "v1" + assert conversation.transcript == "Transcript 1" + + # Switch to v2 + success = conversation.set_active_transcript_version("v2") + assert success is True + assert conversation.active_transcript_version == "v2" + assert conversation.transcript == "Transcript 2" + + # Try to switch to non-existent version + success = conversation.set_active_transcript_version("v999") + assert success is False + assert conversation.active_transcript_version == "v2" # Unchanged + + def test_active_version_properties(self): + """Test active version property methods.""" + conversation = create_conversation("conv-1", "audio-1", "user-1", "client-1") + + # No active versions initially + assert conversation.active_transcript is None + assert conversation.active_memory is None + + # Add versions + segments = [SpeakerSegment(start=0.0, end=5.0, text="Test", speaker="Speaker A")] + conversation.add_transcript_version("v1", "Test", segments, TranscriptProvider.DEEPGRAM) + conversation.add_memory_version("m1", 2, "v1", MemoryProvider.FRIEND_LITE) + + # Should return active versions + active_transcript = conversation.active_transcript + active_memory = conversation.active_memory + + assert active_transcript is not None + assert active_transcript.version_id == "v1" + assert active_memory is not None + assert active_memory.version_id == "m1" + + def test_provider_enums(self): + """Test that provider enums work correctly.""" + # Test TranscriptProvider enum + assert TranscriptProvider.DEEPGRAM == "deepgram" + assert TranscriptProvider.MISTRAL == "mistral" + assert TranscriptProvider.PARAKEET == "parakeet" + + # Test MemoryProvider enum + assert MemoryProvider.FRIEND_LITE == "friend_lite" + assert MemoryProvider.OPENMEMORY_MCP == "openmemory_mcp" + + def test_conversation_model_dump(self): + """Test that Conversation can be serialized for MongoDB storage.""" + conversation = create_conversation("conv-1", "audio-1", "user-1", "client-1") + + # Add some versions + segments = [SpeakerSegment(start=0.0, end=5.0, text="Test", speaker="Speaker A")] + conversation.add_transcript_version("v1", "Test", segments, TranscriptProvider.DEEPGRAM) + conversation.add_memory_version("m1", 2, "v1", MemoryProvider.FRIEND_LITE) + + # Test model_dump() works + conv_dict = conversation.model_dump() + + # Verify essential fields are present + assert "conversation_id" in conv_dict + assert "audio_uuid" in conv_dict + assert "user_id" in conv_dict + assert "client_id" in conv_dict + assert "created_at" in conv_dict + assert "transcript_versions" in conv_dict + assert "memory_versions" in conv_dict + assert "active_transcript_version" in conv_dict + assert "active_memory_version" in conv_dict + + # Verify nested structures + assert len(conv_dict["transcript_versions"]) == 1 + assert len(conv_dict["memory_versions"]) == 1 + assert conv_dict["active_transcript_version"] == "v1" + assert conv_dict["active_memory_version"] == "m1" + + def test_conversation_recreation_from_dict(self): + """Test that Conversation can be recreated from a dict.""" + # Create original conversation + original = create_conversation("conv-1", "audio-1", "user-1", "client-1") + segments = [SpeakerSegment(start=0.0, end=5.0, text="Test", speaker="Speaker A")] + original.add_transcript_version("v1", "Test", segments, TranscriptProvider.DEEPGRAM) + + # Convert to dict and back + conv_dict = original.model_dump() + recreated = Conversation(**conv_dict) + + # Verify they match + assert recreated.conversation_id == original.conversation_id + assert recreated.audio_uuid == original.audio_uuid + assert recreated.user_id == original.user_id + assert recreated.active_transcript_version == original.active_transcript_version + assert len(recreated.transcript_versions) == len(original.transcript_versions) + assert recreated.transcript == original.transcript \ No newline at end of file diff --git a/backends/advanced/webui/Dockerfile b/backends/advanced/webui/Dockerfile index c16bfc37..3b2f28d8 100644 --- a/backends/advanced/webui/Dockerfile +++ b/backends/advanced/webui/Dockerfile @@ -17,6 +17,9 @@ COPY . . ARG VITE_ALLOWED_HOSTS ENV VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS} +ARG VITE_BACKEND_URL +ENV VITE_BACKEND_URL=${VITE_BACKEND_URL} + # Build the application RUN npm run build diff --git a/backends/advanced/webui/src/App.tsx b/backends/advanced/webui/src/App.tsx index 16b723a8..39605087 100644 --- a/backends/advanced/webui/src/App.tsx +++ b/backends/advanced/webui/src/App.tsx @@ -9,6 +9,7 @@ import Memories from './pages/Memories' import Users from './pages/Users' import System from './pages/System' import Upload from './pages/Upload' +import Queue from './pages/Queue' import LiveRecord from './pages/LiveRecord' import ProtectedRoute from './components/auth/ProtectedRoute' import { ErrorBoundary, PageErrorBoundary } from './components/ErrorBoundary' @@ -68,6 +69,11 @@ function App() { } /> + + + + } /> diff --git a/backends/advanced/webui/src/components/ConversationVersionDropdown.tsx b/backends/advanced/webui/src/components/ConversationVersionDropdown.tsx new file mode 100644 index 00000000..30ea4f1f --- /dev/null +++ b/backends/advanced/webui/src/components/ConversationVersionDropdown.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect } from 'react' +import { ChevronDown, CheckCircle, Loader2 } from 'lucide-react' +import { conversationsApi } from '../services/api' + +interface TranscriptVersion { + version_id: string + transcript: string + segments: any[] + provider: string + model?: string + created_at: string + processing_time_seconds?: number + metadata?: any +} + +interface MemoryVersion { + version_id: string + memory_count: number + transcript_version_id: string + provider: string + model?: string + created_at: string + processing_time_seconds?: number + metadata?: any +} + +interface VersionHistory { + transcript_versions: TranscriptVersion[] + memory_versions: MemoryVersion[] + active_transcript_version: string + active_memory_version: string +} + +interface ConversationVersionDropdownProps { + conversationId: string + versionInfo?: { + transcript_count: number + memory_count: number + active_transcript_version?: string + active_memory_version?: string + } + onVersionChange: () => void +} + +export default function ConversationVersionDropdown({ + conversationId, + versionInfo, + onVersionChange +}: ConversationVersionDropdownProps) { + const [versionHistory, setVersionHistory] = useState(null) + const [loading, setLoading] = useState(false) + const [activating, setActivating] = useState<{ type: 'transcript' | 'memory', versionId: string } | null>(null) + const [showTranscriptDropdown, setShowTranscriptDropdown] = useState(false) + const [showMemoryDropdown, setShowMemoryDropdown] = useState(false) + + // Close dropdowns when clicking outside + useEffect(() => { + const handleClickOutside = () => { + setShowTranscriptDropdown(false) + setShowMemoryDropdown(false) + } + document.addEventListener('click', handleClickOutside) + return () => document.removeEventListener('click', handleClickOutside) + }, []) + + const loadVersionHistory = async () => { + try { + setLoading(true) + const response = await conversationsApi.getVersionHistory(conversationId) + setVersionHistory(response.data) + } catch (err: any) { + console.error('Failed to load version history:', err) + } finally { + setLoading(false) + } + } + + // Don't auto-load version history - only load when dropdown is opened + // This prevents API spam when rendering many conversations in a list + + const handleActivateVersion = async (type: 'transcript' | 'memory', versionId: string) => { + try { + setActivating({ type, versionId }) + + if (type === 'transcript') { + await conversationsApi.activateTranscriptVersion(conversationId, versionId) + setShowTranscriptDropdown(false) + } else { + await conversationsApi.activateMemoryVersion(conversationId, versionId) + setShowMemoryDropdown(false) + } + + // Reload version history to update active version + await loadVersionHistory() + + // Notify parent component to refresh conversation data + onVersionChange() + + } catch (err: any) { + console.error(`Failed to activate ${type} version:`, err) + } finally { + setActivating(null) + } + } + + const formatVersionLabel = (version: TranscriptVersion | MemoryVersion, index: number) => { + return `v${index + 1} (${version.provider}${version.model ? ` ${version.model}` : ''})` + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString() + } + + // Don't show anything if there are no multiple versions + if (!versionInfo || ((versionInfo.transcript_count || 0) <= 1 && (versionInfo.memory_count || 0) <= 1)) { + return null + } + + return ( +
+ {/* Transcript Version Dropdown */} + {(versionInfo.transcript_count || 0) > 1 && ( +
+ + + {showTranscriptDropdown && versionHistory && ( +
e.stopPropagation()} + > +
+ {versionHistory.transcript_versions.map((version, index) => ( + + ))} +
+
+ )} +
+ )} + + {/* Memory Version Dropdown */} + {(versionInfo.memory_count || 0) > 1 && ( +
+ + + {showMemoryDropdown && versionHistory && ( +
e.stopPropagation()} + > +
+ {versionHistory.memory_versions.map((version, index) => ( + + ))} +
+
+ )} +
+ )} + + {loading && ( +
+ + Loading versions... +
+ )} +
+ ) +} \ No newline at end of file diff --git a/backends/advanced/webui/src/components/ConversationVersionHeader.tsx b/backends/advanced/webui/src/components/ConversationVersionHeader.tsx new file mode 100644 index 00000000..9e7c5e09 --- /dev/null +++ b/backends/advanced/webui/src/components/ConversationVersionHeader.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { conversationsApi } from '../services/api'; +import ConversationVersionDropdown from './ConversationVersionDropdown'; + +interface ConversationVersionHeaderProps { + conversationId: string; + versionInfo?: { + transcript_count: number; + memory_count: number; + active_transcript_version?: string; + active_memory_version?: string; + }; + onVersionChange?: () => void; +} + +export default function ConversationVersionHeader({ conversationId, versionInfo, onVersionChange }: ConversationVersionHeaderProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleReprocessTranscript = async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + try { + setLoading(true); + await conversationsApi.reprocessTranscript(conversationId); + onVersionChange?.(); + } catch (err) { + console.error('Failed to reprocess transcript:', err); + setError('Failed to reprocess transcript'); + } finally { + setLoading(false); + } + }; + + // If no version info provided, don't show anything + if (!versionInfo) return null; + + // Only show if there are multiple versions or reprocessing capability + if (versionInfo.transcript_count <= 1 && versionInfo.memory_count <= 1) { + return ( +
+
+
+ {versionInfo.transcript_count} transcript version, {versionInfo.memory_count} memory version +
+ +
+
+ ); + } + + // Show multiple version info with reprocess option and version selector + return ( +
+
+
+
+ {versionInfo.transcript_count} transcript versions, + {versionInfo.memory_count} memory versions + {error &&
{error}
} +
+ + {/* Version Selector Dropdowns */} + {})} + /> +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/backends/advanced/webui/src/components/layout/Layout.tsx b/backends/advanced/webui/src/components/layout/Layout.tsx index 13f2fa13..0243d00f 100644 --- a/backends/advanced/webui/src/components/layout/Layout.tsx +++ b/backends/advanced/webui/src/components/layout/Layout.tsx @@ -1,5 +1,5 @@ import { Link, useLocation, Outlet } from 'react-router-dom' -import { Music, MessageSquare, MessageCircle, Brain, Users, Upload, Settings, LogOut, Sun, Moon, Shield, Radio } from 'lucide-react' +import { Music, MessageSquare, MessageCircle, Brain, Users, Upload, Settings, LogOut, Sun, Moon, Shield, Radio, Layers } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useTheme } from '../../contexts/ThemeContext' @@ -16,6 +16,7 @@ export default function Layout() { { path: '/users', label: 'User Management', icon: Users }, ...(isAdmin ? [ { path: '/upload', label: 'Upload Audio', icon: Upload }, + { path: '/queue', label: 'Queue Management', icon: Layers }, { path: '/system', label: 'System State', icon: Settings }, ] : []), ] @@ -62,7 +63,7 @@ export default function Layout() { -
+
{/* Sidebar Navigation */} {/* Main Content */} -
+
diff --git a/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts b/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts index 740001a3..268544c7 100644 --- a/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts +++ b/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts @@ -1,6 +1,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' export type RecordingStep = 'idle' | 'mic' | 'websocket' | 'audio-start' | 'streaming' | 'stopping' | 'error' +export type RecordingMode = 'batch' | 'streaming' export interface DebugStats { chunksSent: number @@ -17,15 +18,17 @@ export interface SimpleAudioRecordingReturn { isRecording: boolean recordingDuration: number error: string | null - + mode: RecordingMode + // Actions startRecording: () => Promise stopRecording: () => void - + setMode: (mode: RecordingMode) => void + // For components analyser: AnalyserNode | null debugStats: DebugStats - + // Utilities formatDuration: (seconds: number) => string canAccessMicrophone: boolean @@ -37,6 +40,7 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { const [isRecording, setIsRecording] = useState(false) const [recordingDuration, setRecordingDuration] = useState(0) const [error, setError] = useState(null) + const [mode, setMode] = useState('streaming') // Debug stats const [debugStats, setDebugStats] = useState({ @@ -64,7 +68,14 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { // Check if we're on localhost or using HTTPS const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' const isHttps = window.location.protocol === 'https:' - const canAccessMicrophone = isLocalhost || isHttps + + // DEVELOPMENT ONLY: Allow specific IP addresses (remove in production!) + const devAllowedHosts = import.meta.env.MODE === 'development' + ? ['192.168.1.100', '10.0.0.100'] // Add your Docker host IPs here + : [] + const isDevelopmentHost = devAllowedHosts.includes(window.location.hostname) + + const canAccessMicrophone = isLocalhost || isHttps || isDevelopmentHost // Format duration helper const formatDuration = useCallback((seconds: number) => { @@ -236,40 +247,52 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { // Step 3: Send audio-start message const sendAudioStartMessage = useCallback(async (ws: WebSocket): Promise => { console.log('πŸ“€ Step 3: Sending audio-start message') - + if (ws.readyState !== WebSocket.OPEN) { throw new Error('WebSocket not connected') } - + const startMessage = { type: 'audio-start', data: { rate: 16000, width: 2, - channels: 1 + channels: 1, + mode: mode // Pass recording mode to backend }, payload_length: null } - + ws.send(JSON.stringify(startMessage) + '\n') - console.log('βœ… Audio-start message sent') - }, []) + console.log('βœ… Audio-start message sent with mode:', mode) + }, [mode]) // Step 4: Start audio streaming const startAudioStreaming = useCallback(async (stream: MediaStream, ws: WebSocket): Promise => { console.log('🎡 Step 4: Starting audio streaming') - + // Set up audio context and analyser for visualization const audioContext = new AudioContext({ sampleRate: 16000 }) const analyser = audioContext.createAnalyser() const source = audioContext.createMediaStreamSource(stream) - + analyser.fftSize = 256 source.connect(analyser) - + + console.log('🎧 Audio context state:', audioContext.state) + console.log('🎧 Analyser created:', analyser) + console.log('🎧 Sample rate:', audioContext.sampleRate) + + // Resume audio context if suspended (required by some browsers) + if (audioContext.state === 'suspended') { + console.log('🎧 Resuming suspended audio context...') + await audioContext.resume() + console.log('🎧 Audio context resumed, new state:', audioContext.state) + } + audioContextRef.current = audioContext analyserRef.current = analyser - + // Wait brief moment for backend to process audio-start await new Promise(resolve => setTimeout(resolve, 100)) @@ -277,20 +300,44 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { const processor = audioContext.createScriptProcessor(4096, 1, 1) source.connect(processor) processor.connect(audioContext.destination) - + + let processCallCount = 0 processor.onaudioprocess = (event) => { + processCallCount++ + + // Calculate audio level for first few chunks + const inputData = event.inputBuffer.getChannelData(0) + let sum = 0 + for (let i = 0; i < inputData.length; i++) { + sum += Math.abs(inputData[i]) + } + const avgLevel = sum / inputData.length + + // Log first few calls to debug + if (processCallCount <= 3) { + console.log(`🎡 Audio process callback #${processCallCount}`, { + wsState: ws?.readyState, + wsOpen: ws?.readyState === WebSocket.OPEN, + audioProcessingStarted: audioProcessingStartedRef.current, + audioLevel: avgLevel.toFixed(6), + hasAudio: avgLevel > 0.001 + }) + } + if (!ws || ws.readyState !== WebSocket.OPEN) { + if (processCallCount === 1) { + console.warn('⚠️ WebSocket not open in audio callback') + } return } - + if (!audioProcessingStartedRef.current) { console.log('🚫 Audio processing not started yet, skipping chunk') return } - - const inputBuffer = event.inputBuffer - const inputData = inputBuffer.getChannelData(0) - + + // inputData already declared above for audio level calculation + // Convert float32 to int16 PCM const pcmBuffer = new Int16Array(inputData.length) for (let i = 0; i < inputData.length; i++) { @@ -317,10 +364,15 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { ws.send(JSON.stringify(chunkHeader) + '\n') ws.send(new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength)) - + // Update debug stats chunkCountRef.current++ setDebugStats(prev => ({ ...prev, chunksSent: chunkCountRef.current })) + + // Log first few chunks + if (chunkCountRef.current <= 3) { + console.log(`βœ… Sent audio chunk #${chunkCountRef.current}, size: ${pcmBuffer.byteLength} bytes`) + } } catch (error) { console.error('Failed to send audio chunk:', error) setDebugStats(prev => ({ @@ -430,8 +482,10 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { isRecording, recordingDuration, error, + mode, startRecording, stopRecording, + setMode, analyser: analyserRef.current, debugStats, formatDuration, diff --git a/backends/advanced/webui/src/pages/Conversations.tsx b/backends/advanced/webui/src/pages/Conversations.tsx index 79698be3..85df4008 100644 --- a/backends/advanced/webui/src/pages/Conversations.tsx +++ b/backends/advanced/webui/src/pages/Conversations.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { MessageSquare, RefreshCw, Calendar, User, Play, Pause, MoreVertical, RotateCcw, Zap, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { conversationsApi, BACKEND_URL } from '../services/api' +import ConversationVersionHeader from '../components/ConversationVersionHeader' interface Conversation { conversation_id?: string @@ -10,7 +11,9 @@ interface Conversation { timestamp: number created_at?: string client_id: string - transcript: Array<{ + segment_count?: number // From list endpoint + transcript?: string // Full text transcript (for LLM parsing) + segments?: Array<{ // Optional - only populated after fetching details text: string speaker: string start: number @@ -28,6 +31,12 @@ interface Conversation { memory_processing_status?: string transcription_status?: string action_items?: any[] + version_info?: { + transcript_count: number + memory_count: number + active_transcript_version?: string + active_memory_version?: string + } } // Speaker color palette for consistent colors across conversations @@ -67,11 +76,8 @@ export default function Conversations() { try { setLoading(true) const response = await conversationsApi.getAll() - // Convert the conversations object to an array - const conversationsData = response.data.conversations || {} - const conversationsList = Object.entries(conversationsData).flatMap(([clientId, convs]: [string, any]) => - convs.map((conv: any) => ({ ...conv, client_id: clientId })) - ) + // API now returns a flat list with client_id as a field + const conversationsList = response.data.conversations || [] setConversations(conversationsList) setError(null) } catch (err: any) { @@ -92,7 +98,15 @@ export default function Conversations() { return () => document.removeEventListener('click', handleClickOutside) }, []) - const formatDate = (timestamp: number) => { + const formatDate = (timestamp: number | string) => { + // Handle both Unix timestamp (number) and ISO string + if (typeof timestamp === 'string') { + return new Date(timestamp).toLocaleString() + } + // If timestamp is 0, return placeholder + if (timestamp === 0) { + return 'Unknown date' + } return new Date(timestamp * 1000).toLocaleString() } @@ -193,16 +207,47 @@ export default function Conversations() { } } - const toggleTranscriptExpansion = (audioUuid: string) => { - setExpandedTranscripts(prev => { - const newSet = new Set(prev) - if (newSet.has(audioUuid)) { + const toggleTranscriptExpansion = async (audioUuid: string) => { + // If already expanded, just collapse + if (expandedTranscripts.has(audioUuid)) { + setExpandedTranscripts(prev => { + const newSet = new Set(prev) newSet.delete(audioUuid) - } else { - newSet.add(audioUuid) + return newSet + }) + return + } + + // Find the conversation by audio_uuid + const conversation = conversations.find(c => c.audio_uuid === audioUuid) + if (!conversation || !conversation.conversation_id) { + console.error('Cannot expand transcript: conversation_id missing') + return + } + + // If segments are already loaded, just expand + if (conversation.segments && conversation.segments.length > 0) { + setExpandedTranscripts(prev => new Set(prev).add(audioUuid)) + return + } + + // Fetch full conversation details including segments + try { + const response = await conversationsApi.getById(conversation.conversation_id) + if (response.status === 200 && response.data.conversation) { + // Update the conversation in state with full segments and transcript + setConversations(prev => prev.map(c => + c.audio_uuid === audioUuid + ? { ...c, segments: response.data.conversation.segments, transcript: response.data.conversation.transcript } + : c + )) + // Expand the transcript + setExpandedTranscripts(prev => new Set(prev).add(audioUuid)) } - return newSet - }) + } catch (err: any) { + console.error('Failed to fetch conversation details:', err) + setError(`Failed to load transcript: ${err.message || 'Unknown error'}`) + } } const handleSegmentPlayPause = (audioUuid: string, segmentIndex: number, segment: any, audioPath: string) => { @@ -345,6 +390,32 @@ export default function Conversations() { key={conversation.audio_uuid} className="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 border border-gray-200 dark:border-gray-600" > + {/* Version Selector Header - Only show for conversations with conversation_id */} + {conversation.conversation_id && ( + { + // Update only this specific conversation without reloading all conversations + // This prevents page scroll jump + try { + const response = await conversationsApi.getById(conversation.conversation_id!) + if (response.status === 200 && response.data.conversation) { + setConversations(prev => prev.map(c => + c.conversation_id === conversation.conversation_id + ? { ...c, ...response.data.conversation } + : c + )) + } + } catch (err: any) { + console.error('Failed to refresh conversation:', err) + // Fallback to full reload on error + loadConversations() + } + }} + /> + )} + {/* Conversation Header */}
@@ -364,7 +435,7 @@ export default function Conversations() {
- {formatDate(conversation.timestamp)} + {formatDate(conversation.created_at || conversation.timestamp)}
@@ -489,9 +560,9 @@ export default function Conversations() { onClick={() => toggleTranscriptExpansion(conversation.audio_uuid)} >

- Transcript {conversation.transcript && conversation.transcript.length > 0 && ( + Transcript {((conversation.segments && conversation.segments.length > 0) || conversation.segment_count) && ( - ({conversation.transcript.length} segments) + ({conversation.segments?.length || conversation.segment_count || 0} segments) )}

@@ -506,26 +577,26 @@ export default function Conversations() { {/* Transcript Content - Conditionally Rendered */} {expandedTranscripts.has(conversation.audio_uuid) && ( -
- {conversation.transcript && conversation.transcript.length > 0 ? ( +
+ {conversation.segments && conversation.segments.length > 0 ? (
{(() => { // Build a speaker-to-color map for this conversation const speakerColorMap: { [key: string]: string } = {}; let colorIndex = 0; - + // First pass: assign colors to unique speakers - conversation.transcript.forEach(segment => { + conversation.segments.forEach(segment => { const speaker = segment.speaker || 'Unknown'; if (!speakerColorMap[speaker]) { speakerColorMap[speaker] = SPEAKER_COLOR_PALETTE[colorIndex % SPEAKER_COLOR_PALETTE.length]; colorIndex++; } }); - + // Render the transcript - return conversation.transcript.map((segment, index) => { + return conversation.segments.map((segment, index) => { const speaker = segment.speaker || 'Unknown'; const speakerColor = speakerColorMap[speaker]; const segmentId = `${conversation.audio_uuid}-${index}`; @@ -584,6 +655,7 @@ export default function Conversations() { No transcript available
)} +
)}
@@ -616,7 +688,7 @@ export default function Conversations() {
Cropped Audio: {conversation.cropped_audio_path || 'N/A'}
Transcription Status: {conversation.transcription_status || 'N/A'}
Memory Processing Status: {conversation.memory_processing_status || 'N/A'}
-
Transcript Segments: {conversation.transcript?.length || 0}
+
Transcript Segments: {conversation.segments?.length || 0}
Client ID: {conversation.client_id}
diff --git a/backends/advanced/webui/src/pages/LiveRecord.tsx b/backends/advanced/webui/src/pages/LiveRecord.tsx index fb1cc48c..4b763746 100644 --- a/backends/advanced/webui/src/pages/LiveRecord.tsx +++ b/backends/advanced/webui/src/pages/LiveRecord.tsx @@ -1,4 +1,4 @@ -import { Radio } from 'lucide-react' +import { Radio, Zap, Archive } from 'lucide-react' import { useSimpleAudioRecording } from '../hooks/useSimpleAudioRecording' import SimplifiedControls from '../components/audio/SimplifiedControls' import StatusDisplay from '../components/audio/StatusDisplay' @@ -11,11 +11,64 @@ export default function LiveRecord() { return (
{/* Header */} -
- -

- Live Audio Recording -

+
+
+ +

+ Live Audio Recording +

+
+ + {/* Mode Toggle */} +
+ + +
+
+ + {/* Mode Description */} +
+

+ {recording.mode === 'streaming' ? ( + <> + Streaming Mode: Audio is sent in real-time chunks and processed immediately. + Transcription starts while you're still speaking. + + ) : ( + <> + Batch Mode: Audio is accumulated and sent as a complete file when you stop recording. + Transcription begins after recording ends. + + )} +

{/* Main Controls - Single START button */} @@ -36,9 +89,15 @@ export default function LiveRecord() { πŸ“ How it Works
    +
  • β€’ Choose your mode: Streaming for real-time or Batch for complete file processing
  • β€’ One-click recording: Single button handles complete setup automatically
  • -
  • β€’ Sequential process: Mic access β†’ WebSocket connection β†’ Audio session β†’ Streaming
  • -
  • β€’ Real-time processing: Audio streams to backend for transcription and memory extraction
  • +
  • β€’ Sequential process: Mic access β†’ WebSocket connection β†’ Audio session β†’ Recording
  • +
  • β€’ Mode-based processing: + {recording.mode === 'streaming' + ? 'Real-time chunks sent as you speak' + : 'Complete audio sent after you stop' + } +
  • β€’ Wyoming protocol: Structured communication ensures reliable data transmission
  • β€’ High quality audio: 16kHz mono with noise suppression and echo cancellation
  • β€’ View results: Check Conversations page for transcribed content and memories
  • diff --git a/backends/advanced/webui/src/pages/Queue.tsx b/backends/advanced/webui/src/pages/Queue.tsx new file mode 100644 index 00000000..4b7f4ed0 --- /dev/null +++ b/backends/advanced/webui/src/pages/Queue.tsx @@ -0,0 +1,1701 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Clock, + Play, + Pause, + CheckCircle, + XCircle, + RotateCcw, + StopCircle, + Eye, + Filter, + X, + RefreshCw, + Layers, + Trash2, + AlertTriangle, + ChevronDown, + ChevronRight, + FileAudio, + FileText, + Brain, + Repeat, + Zap +} from 'lucide-react'; +import { queueApi } from '../services/api'; + +interface QueueJob { + job_id: string; + job_type: string; + user_id: string; + status: 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'deferred' | 'waiting'; + priority: 'low' | 'normal' | 'high'; + data: { + description?: string; + [key: string]: any; + }; + result?: any; + error_message?: string; + created_at: string; + started_at?: string; + completed_at?: string; + ended_at?: string; // API returns this field instead of completed_at + retry_count: number; + max_retries: number; + progress_percent: number; + progress_message: string; +} + +interface QueueStats { + total_jobs: number; + queued_jobs: number; + processing_jobs: number; + completed_jobs: number; + failed_jobs: number; + cancelled_jobs: number; + deferred_jobs: number; + timestamp: string; +} + +interface Filters { + status: string; + job_type: string; + priority: string; +} + +interface StreamingSession { + session_id: string; + user_id: string; + client_id: string; + provider: string; + mode: string; + status: string; + chunks_published: number; + started_at: number; + last_chunk_at: number; + age_seconds: number; + idle_seconds: number; +} + +interface StreamConsumer { + name: string; + pending: number; + idle_ms: number; +} + +interface StreamConsumerGroup { + name: string; + consumers: StreamConsumer[]; + pending: number; +} + +interface StreamHealth { + stream_length?: number; + consumer_groups?: StreamConsumerGroup[]; + total_pending?: number; + error?: string; + exists?: boolean; +} + +interface CompletedSession { + session_id: string; + client_id: string; + conversation_id: string | null; + has_conversation: boolean; + action: string; + reason: string; + completed_at: number; + audio_file: string; +} + +interface StreamingStatus { + active_sessions: StreamingSession[]; + completed_sessions: CompletedSession[]; + stream_health: { + [provider: string]: StreamHealth; + }; + rq_queues: { + [queue: string]: { + count: number; + failed_count: number; + }; + }; + timestamp: number; +} + +const Queue: React.FC = () => { + const [jobs, setJobs] = useState([]); + const [stats, setStats] = useState(null); + const [streamingStatus, setStreamingStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedJob, setSelectedJob] = useState(null); + const [loadingJobDetails, setLoadingJobDetails] = useState(false); + const [filters, setFilters] = useState({ + status: '', + job_type: '', + priority: '' + }); + const [pagination, setPagination] = useState({ + offset: 0, + limit: 20, + total: 0, + has_more: false + }); + const [refreshing, setRefreshing] = useState(false); + const [showFlushModal, setShowFlushModal] = useState(false); + const [flushSettings, setFlushSettings] = useState({ + older_than_hours: 24, + statuses: ['completed', 'failed'], + flush_all: false + }); + const [flushing, setFlushing] = useState(false); + const [expandedSessions, setExpandedSessions] = useState>(new Set()); + const [sessionJobs, setSessionJobs] = useState<{[sessionId: string]: any[]}>({}); + const [lastUpdate, setLastUpdate] = useState(Date.now()); + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(() => { + // Load from localStorage, default to true + const saved = localStorage.getItem('queue_auto_refresh'); + return saved !== null ? saved === 'true' : true; + }); + + // Use refs to track current state in interval + const expandedSessionsRef = useRef>(new Set()); + const streamingStatusRef = useRef(null); + const refreshingRef = useRef(false); + + // Update refs when state changes + useEffect(() => { + expandedSessionsRef.current = expandedSessions; + }, [expandedSessions]); + + useEffect(() => { + streamingStatusRef.current = streamingStatus; + }, [streamingStatus]); + + useEffect(() => { + refreshingRef.current = refreshing; + }, [refreshing]); + + // Refresh jobs for all expanded, active, and completed sessions + const refreshSessionJobs = useCallback(async () => { + const currentExpanded = expandedSessionsRef.current; + const currentStreamingStatus = streamingStatusRef.current; + + // Get all active session IDs + const activeSessionIds = currentStreamingStatus?.active_sessions + ?.filter(s => s.status !== 'complete') + .map(s => s.session_id) || []; + + // Get all completed session IDs + const completedSessionIds = currentStreamingStatus?.completed_sessions + ?.map(s => s.session_id) || []; + + // Get all session IDs that should have jobs loaded (expanded, active, or completed) + const sessionIdsToRefresh = new Set([...currentExpanded, ...activeSessionIds, ...completedSessionIds]); + + if (sessionIdsToRefresh.size === 0) return; + + // Fetch jobs for all sessions in parallel + const fetchPromises = Array.from(sessionIdsToRefresh).map(async (sessionId) => { + try { + const response = await queueApi.getJobsBySession(sessionId); + return { sessionId, jobs: response.data.jobs }; + } catch (error) { + console.error(`❌ Failed to refresh jobs for session ${sessionId}:`, error); + return { sessionId, jobs: [] }; + } + }); + + const results = await Promise.all(fetchPromises); + + // Update session jobs state with all results + setSessionJobs(prev => { + const updated = { ...prev }; + results.forEach(({ sessionId, jobs }) => { + updated[sessionId] = jobs; + }); + return updated; + }); + }, []); + + // Main data fetch function + const fetchData = useCallback(async () => { + if (refreshingRef.current) { + return; + } + + setRefreshing(true); + + try { + // Fetch all main data in parallel + await Promise.all([fetchJobs(), fetchStats(), fetchStreamingStatus()]); + + // Then refresh session jobs + await refreshSessionJobs(); + + setLastUpdate(Date.now()); + } catch (error) { + console.error('❌ Error fetching queue data:', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [refreshSessionJobs]); + + // Save auto-refresh preference to localStorage + useEffect(() => { + localStorage.setItem('queue_auto_refresh', autoRefreshEnabled.toString()); + }, [autoRefreshEnabled]); + + // Auto-refresh interval using useRef + useEffect(() => { + if (!autoRefreshEnabled) { + return; + } + + const intervalId = setInterval(() => { + fetchData(); + }, 2000); // Refresh every 2 seconds + + return () => { + clearInterval(intervalId); + }; + }, [fetchData, autoRefreshEnabled]); + + // Initial data fetch + useEffect(() => { + fetchData(); + }, [filters, pagination.offset, fetchData]); + + const fetchJobs = async () => { + try { + const params = new URLSearchParams({ + limit: pagination.limit.toString(), + offset: pagination.offset.toString(), + sort: 'created_at', + order: 'desc' + }); + + if (filters.status) params.append('status', filters.status); + if (filters.job_type) params.append('job_type', filters.job_type); + if (filters.priority) params.append('priority', filters.priority); + + const response = await queueApi.getJobs(params); + const data = response.data; + setJobs(data.jobs); + setPagination(prev => ({ + ...prev, + total: data.pagination.total, + has_more: data.pagination.has_more + })); + } catch (error) { + console.error('❌ Error fetching jobs:', error); + } + }; + + const fetchStats = async () => { + try { + const response = await queueApi.getStats(); + const data = response.data; + setStats(data); + } catch (error) { + console.error('❌ Error fetching stats:', error); + } + }; + + const fetchStreamingStatus = async () => { + try { + const response = await queueApi.getStreamingStatus(); + const data = response.data; + setStreamingStatus(data); + + // Auto-expand active sessions + if (data.active_sessions && data.active_sessions.length > 0) { + setExpandedSessions(prev => { + const newExpanded = new Set(prev); + let hasChanges = false; + + data.active_sessions.filter((s: StreamingSession) => s.status !== 'complete').forEach((session: StreamingSession) => { + if (!newExpanded.has(session.session_id)) { + newExpanded.add(session.session_id); + hasChanges = true; + } + }); + + return hasChanges ? newExpanded : prev; + }); + } + } catch (error) { + console.error('❌ Error fetching streaming status:', error); + // Don't fail the whole page if streaming status fails + setStreamingStatus(null); + } + }; + + const viewJobDetails = async (jobId: string) => { + setLoadingJobDetails(true); + try { + const response = await queueApi.getJob(jobId); + setSelectedJob(response.data); + } catch (error) { + console.error('Error fetching job details:', error); + alert('Failed to fetch job details'); + } finally { + setLoadingJobDetails(false); + } + }; + + const retryJob = async (jobId: string) => { + try { + await queueApi.retryJob(jobId, false); + fetchJobs(); + } catch (error) { + console.error('Error retrying job:', error); + } + }; + + const cancelJob = async (jobId: string) => { + if (!confirm('Are you sure you want to cancel this job?')) return; + + try { + await queueApi.cancelJob(jobId); + fetchJobs(); + } catch (error) { + console.error('Error cancelling job:', error); + } + }; + + const cleanupStuckWorkers = async () => { + if (!confirm('This will clean up all stuck workers and pending messages. Continue?')) return; + + try { + console.log('🧹 Starting cleanup of stuck workers...'); + const response = await queueApi.cleanupStuckWorkers(); + const data = response.data; + console.log('βœ… Cleanup complete:', data); + + alert(`βœ… Cleanup complete!\n\nTotal cleaned: ${data.total_cleaned} messages\n\n${ + Object.entries(data.providers).map(([provider, result]: [string, any]) => + `${provider}: ${result.message || result.error || 'Unknown'}` + ).join('\n') + }`); + + // Refresh streaming status to show updated counts + fetchStreamingStatus(); + } catch (error: any) { + console.error('❌ Error during cleanup:', error); + alert(`Failed to cleanup workers: ${error.response?.data?.error || error.message}`); + } + }; + + const cleanupOldSessions = async () => { + if (!confirm('This will remove old and stuck "finalizing" sessions from the dashboard. Continue?')) return; + + try { + console.log('🧹 Starting cleanup of old sessions...'); + const response = await queueApi.cleanupOldSessions(3600); // 1 hour + const data = response.data; + console.log('βœ… Cleanup complete:', data); + + alert(`βœ… Cleanup complete!\n\nRemoved ${data.cleaned_count} old session(s)`); + + // Refresh streaming status to show updated counts + fetchStreamingStatus(); + } catch (error: any) { + console.error('❌ Error during cleanup:', error); + alert(`Failed to cleanup sessions: ${error.response?.data?.error || error.message}`); + } + }; + + const applyFilters = () => { + setPagination(prev => ({ ...prev, offset: 0 })); + fetchJobs(); + }; + + const clearFilters = () => { + setFilters({ status: '', job_type: '', priority: '' }); + setPagination(prev => ({ ...prev, offset: 0 })); + }; + + const nextPage = () => { + if (pagination.has_more) { + setPagination(prev => ({ ...prev, offset: prev.offset + prev.limit })); + } + }; + + const prevPage = () => { + if (pagination.offset > 0) { + setPagination(prev => ({ + ...prev, + offset: Math.max(0, prev.offset - prev.limit) + })); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'queued': return ; + case 'processing': return ; + case 'completed': return ; + case 'failed': return ; + case 'cancelled': return ; + case 'deferred': return ; + case 'waiting': return ; + default: return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'queued': return 'text-yellow-600 bg-yellow-100'; + case 'processing': return 'text-blue-600 bg-blue-100'; + case 'completed': return 'text-green-600 bg-green-100'; + case 'failed': return 'text-red-600 bg-red-100'; + case 'cancelled': return 'text-gray-600 bg-gray-100'; + case 'deferred': return 'text-blue-600 bg-blue-100'; + case 'waiting': return 'text-blue-600 bg-blue-100'; + default: return 'text-gray-600 bg-gray-100'; + } + }; + + const getJobTypeShort = (type: string) => { + const typeMap: { [key: string]: string } = { + 'process_audio_files': 'Process', + 'process_single_audio_file': 'Process', + 'reprocess_transcript': 'Reprocess', + 'reprocess_memory': 'Memory' + }; + return typeMap[type] || type; + }; + + const getJobTypeIcon = (type: string) => { + const iconClass = "w-3.5 h-3.5"; + switch (type) { + case 'audio_transcription': + case 'process_audio_chunk': + return ; + case 'transcript_processing': + case 'reprocess_transcript': + return ; + case 'memory_extraction': + case 'reprocess_memory': + return ; + case 'process_audio_files': + case 'process_single_audio_file': + return ; + default: + return ; + } + }; + + const getJobTypeColor = (type: string, status: string) => { + // Base colors by job type + let bgColor = 'bg-gray-400'; + let borderColor = 'border-gray-500'; + + // Transcription jobs - blue shades + if (type.includes('transcribe') || type === 'transcribe_full_audio_job') { + bgColor = 'bg-blue-500'; + borderColor = 'border-blue-600'; + } + // Speaker recognition - purple shades + else if (type.includes('speaker') || type.includes('recognise') || type === 'recognise_speakers_job') { + bgColor = 'bg-purple-500'; + borderColor = 'border-purple-600'; + } + // Memory jobs - pink shades + else if (type.includes('memory') || type === 'process_memory_job') { + bgColor = 'bg-pink-500'; + borderColor = 'border-pink-600'; + } + // Conversation/open jobs - cyan shades (check this AFTER memory to avoid confusion) + else if (type.includes('conversation') || type.includes('open_conversation') || type === 'open_conversation_job') { + bgColor = 'bg-cyan-500'; + borderColor = 'border-cyan-600'; + } + // Speech detection jobs - green shades + else if (type.includes('speech') || type.includes('detect')) { + bgColor = 'bg-green-500'; + borderColor = 'border-green-600'; + } + // Audio processing - orange shades + else if (type.includes('audio') || type.includes('persist') || type.includes('cropping')) { + bgColor = 'bg-orange-500'; + borderColor = 'border-orange-600'; + } + // Default - gray + else { + bgColor = 'bg-gray-400'; + borderColor = 'border-gray-500'; + } + + // Failed jobs - always red + if (status === 'failed') { + bgColor = 'bg-red-500'; + borderColor = 'border-red-600'; + } + // Processing jobs - add pulse animation + else if (status === 'processing') { + bgColor = bgColor + ' animate-pulse'; + } + + return { bgColor, borderColor }; + }; + + const renderJobTimeline = (jobs: any[], session: StreamingSession | CompletedSession) => { + if (!jobs || jobs.length === 0) return null; + + // Sort jobs by created_at first + const sortedJobs = [...jobs].sort((a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + + // Calculate timeline boundaries + // For active sessions, use session timestamps + // For completed sessions without started_at, use earliest job timestamp + let sessionStart: number; + let sessionEnd: number; + + if ('started_at' in session) { + // Active session - use session.started_at + sessionStart = session.started_at * 1000; + } else { + // Completed session - calculate from jobs + // Use the earliest job timestamp (created_at or started_at) + const earliestTime = Math.min(...sortedJobs.map(j => { + const created = new Date(j.created_at).getTime(); + const started = j.started_at ? new Date(j.started_at).getTime() : created; + return Math.min(created, started); + })); + sessionStart = earliestTime; + } + + if ('completed_at' in session) { + // Completed session - use the latest job end time (not session.completed_at) + // This handles batch jobs that run after the session is marked complete + const latestJobEnd = Math.max(...sortedJobs.map(j => { + const completed = j.completed_at ? new Date(j.completed_at).getTime() : 0; + const ended = j.ended_at ? new Date(j.ended_at).getTime() : 0; + const started = j.started_at ? new Date(j.started_at).getTime() : 0; + return Math.max(completed, ended, started); + })); + // Use the later of: session completion or latest job end + sessionEnd = Math.max(session.completed_at * 1000, latestJobEnd); + } else { + // Active session - use current time + sessionEnd = Date.now(); + } + + const totalDuration = sessionEnd - sessionStart; + + if (totalDuration <= 0) return null; + + // Smart row assignment - place jobs in rows to avoid overlaps + const rows: any[][] = []; + sortedJobs.forEach(job => { + const jobStart = job.started_at ? new Date(job.started_at).getTime() : new Date(job.created_at).getTime(); + + // Find first row where this job doesn't overlap + let assignedRow = -1; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const row = rows[rowIndex]; + const lastJobInRow = row[row.length - 1]; + // Calculate when the last job in this row ends (use Date.now() for active jobs) + const lastJobEnd = lastJobInRow.completed_at || lastJobInRow.ended_at + ? new Date((lastJobInRow.completed_at || lastJobInRow.ended_at)!).getTime() + : (lastJobInRow.status === 'processing' ? Date.now() : new Date(lastJobInRow.started_at || lastJobInRow.created_at).getTime()); + + // If this job starts after the last job in this row ends, we can use this row + if (jobStart >= lastJobEnd) { + assignedRow = rowIndex; + break; + } + } + + // If no suitable row found, create a new one + if (assignedRow === -1) { + assignedRow = rows.length; + rows.push([]); + } + + rows[assignedRow].push(job); + job._assignedRow = assignedRow; + }); + + // Calculate height based on number of rows needed + const rowCount = rows.length; + const timelineHeight = Math.max(4, rowCount * 2); // At least 4rem, 2rem per row + + return ( +
    +
    Timeline:
    +
    + {/* Timeline grid lines */} +
    + {[0, 25, 50, 75, 100].map(percent => ( +
    + ))} +
    + + {/* Job bars */} + {sortedJobs.map((job) => { + const jobStart = job.started_at ? new Date(job.started_at).getTime() : new Date(job.created_at).getTime(); + const jobEnd = job.completed_at || job.ended_at + ? new Date((job.completed_at || job.ended_at)!).getTime() + : (job.status === 'processing' ? Date.now() : jobStart); + + const startPercent = Math.max(0, ((jobStart - sessionStart) / totalDuration) * 100); + const duration = jobEnd - jobStart; + const widthPercent = Math.max(1, (duration / totalDuration) * 100); + + // Color based on job type + const { bgColor, borderColor } = getJobTypeColor(job.job_type, job.status); + + // Calculate position in assigned row + const rowIndex = job._assignedRow; + const rowHeight = 100 / rowCount; + const barHeight = Math.min(25, rowHeight * 0.6); // 60% of row height, max 25% + const topPercent = (rowIndex * rowHeight) + (rowHeight - barHeight) / 2; + + // Format duration for display + const durationMs = jobEnd - jobStart; + let durationStr = ''; + if (durationMs < 1000) durationStr = `${durationMs}ms`; + else if (durationMs < 60000) durationStr = `${(durationMs / 1000).toFixed(1)}s`; + else durationStr = `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`; + + return ( +
    +
    +
    + {getJobTypeIcon(job.job_type)} +
    +
    + + {/* Tooltip on hover - smart positioning to avoid viewport overflow */} +
    80 ? 'auto' : '50%', + right: startPercent > 80 ? '0' : 'auto', + transform: startPercent >= 20 && startPercent <= 80 ? 'translateX(-50%)' : 'none' + }} + > +
    {job.job_type}
    +
    {job.status} β€’ {durationStr}
    +
    +
    + ); + })} +
    + + {/* Timeline labels */} +
    + 0s + {(totalDuration / 1000).toFixed(0)}s +
    +
    + ); + }; + + const getJobResult = (job: QueueJob) => { + if (job.status !== 'completed' || !job.result) { + return -; + } + + const result = job.result; + + // Show different results based on job type + if (job.job_type === 'reprocess_transcript') { + const segments = result.transcript_segments || 0; + const speakers = result.speakers_identified || 0; + + return ( +
    +
    {segments} segments
    + {speakers > 0 && ( +
    {speakers} speakers identified
    + )} +
    + ); + } + + if (job.job_type === 'reprocess_memory') { + const memories = result.memory_count || 0; + return ( +
    + {memories} memories +
    + ); + } + + return ( +
    + βœ“ Success +
    + ); + }; + + const flushJobs = async () => { + setFlushing(true); + try { + const endpoint = flushSettings.flush_all ? '/api/queue/flush-all' : '/api/queue/flush'; + const body = flushSettings.flush_all + ? { confirm: true } + : { + older_than_hours: flushSettings.older_than_hours, + statuses: flushSettings.statuses + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + if (response.ok) { + const result = await response.json(); + alert(`Successfully flushed ${result.total_removed} jobs!`); + setShowFlushModal(false); + fetchData(); // Refresh the data + } else if (response.status === 403) { + alert('Admin access required to flush jobs'); + } else if (response.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } else { + const error = await response.json(); + alert(`Error: ${error.detail || 'Failed to flush jobs'}`); + } + } catch (error) { + console.error('Error flushing jobs:', error); + alert('Failed to flush jobs'); + } finally { + setFlushing(false); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const formatDuration = (job: any) => { + if (!job.started_at) return '-'; + + const start = new Date(job.started_at).getTime(); + // For failed/finished jobs, use completed_at or ended_at. For running jobs, use current time. + const end = job.completed_at || job.ended_at + ? new Date((job.completed_at || job.ended_at)!).getTime() + : (job.status === 'processing' ? Date.now() : start); // Don't show increasing time for failed jobs + const durationMs = end - start; + + if (durationMs < 1000) return `${durationMs}ms`; + if (durationMs < 60000) return `${(durationMs / 1000).toFixed(1)}s`; + if (durationMs < 3600000) return `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`; + return `${Math.floor(durationMs / 3600000)}h ${Math.floor((durationMs % 3600000) / 60000)}m`; + }; + + const toggleSessionExpansion = async (sessionId: string) => { + const newExpanded = new Set(expandedSessions); + + if (newExpanded.has(sessionId)) { + // Collapse + newExpanded.delete(sessionId); + setExpandedSessions(newExpanded); + } else { + // Expand and fetch jobs if not already loaded + newExpanded.add(sessionId); + setExpandedSessions(newExpanded); + + if (!sessionJobs[sessionId]) { + try { + const response = await queueApi.getJobsBySession(sessionId); + const data = response.data; + setSessionJobs(prev => ({ ...prev, [sessionId]: data.jobs })); + } catch (error) { + console.error(`❌ Failed to fetch jobs for session ${sessionId}:`, error); + setSessionJobs(prev => ({ ...prev, [sessionId]: [] })); + } + } + } + }; + + if (loading) { + return ( +
    +
    +
    + ); + } + + return ( +
    + {/* Header */} +
    +
    + +
    +

    Queue Management

    +

    + Last updated: {new Date(lastUpdate).toLocaleTimeString()} β€’ Auto-refresh every 2s +

    +
    +
    +
    + + + +
    +
    + + {/* Stats Cards */} + {stats && ( +
    +
    +
    + +
    +

    Total

    +

    {stats.total_jobs}

    +
    +
    +
    + +
    +
    + +
    +

    Queued

    +

    {stats.queued_jobs}

    +
    +
    +
    + +
    +
    + 0 ? 'animate-pulse' : ''}`} /> +
    +

    Processing

    +

    {stats.processing_jobs}

    +
    +
    +
    + +
    +
    + +
    +

    Completed

    +

    {stats.completed_jobs}

    +
    +
    +
    + +
    +
    + +
    +

    Failed

    +

    {stats.failed_jobs}

    +
    +
    +
    + +
    +
    + +
    +

    Cancelled

    +

    {stats.cancelled_jobs}

    +
    +
    +
    + +
    +
    + +
    +

    Deferred

    +

    {stats.deferred_jobs}

    +
    +
    +
    +
    + )} + + {/* Streaming Status */} + {streamingStatus && ( +
    +
    +

    Audio Streaming Status

    +
    + + {streamingStatus?.active_sessions && streamingStatus.active_sessions.length > 0 && ( + + )} + +
    +
    + +
    + {/* Active and Completed Sessions Grid */} +
    + {/* Active Sessions */} +
    +

    Active Streaming Sessions

    + {streamingStatus?.active_sessions && streamingStatus.active_sessions.filter(s => s.status !== 'complete').length > 0 ? ( +
    + {streamingStatus.active_sessions.filter(s => s.status !== 'complete').map((session) => { + const isExpanded = expandedSessions.has(session.session_id); + const jobs = sessionJobs[session.session_id] || []; + + return ( +
    +
    toggleSessionExpansion(session.session_id)} + > +
    +
    + {isExpanded ? ( + + ) : ( + + )} + + {session.client_id} + {session.provider} + {session.status} +
    +
    + Session: {session.session_id.substring(0, 8)}... β€’ + Chunks: {session.chunks_published} β€’ + Duration: {Math.floor(session.age_seconds)}s β€’ + Idle: {session.idle_seconds.toFixed(1)}s +
    +
    +
    + + {/* Expanded Jobs Section */} + {isExpanded && ( +
    + {/* Timeline Visualization */} + {renderJobTimeline(jobs, session)} + +
    Jobs for this session:
    + {jobs.length > 0 ? ( +
    + {jobs.map((job, index) => ( +
    +
    +
    + #{index + 1} + {getJobTypeIcon(job.job_type)} + {getStatusIcon(job.status)} + {job.job_type} + + {job.status} + + {job.queue} +
    +
    + {job.started_at && ( + Started: {new Date(job.started_at).toLocaleTimeString()} + )} + {job.started_at && ( + β€’ Duration: {formatDuration(job)} + )} +
    +
    + +
    + ))} +
    + ) : ( +
    No jobs found for this session
    + )} +
    + )} +
    + ); + })} +
    + ) : ( +
    + No active sessions +
    + )} +
    + + {/* Completed Sessions */} +
    +

    Completed Sessions (Last Hour)

    + {streamingStatus?.completed_sessions && streamingStatus.completed_sessions.length > 0 ? ( +
    + {streamingStatus.completed_sessions.map((session) => { + const isExpanded = expandedSessions.has(session.session_id); + const jobs = sessionJobs[session.session_id] || []; + + return ( +
    +
    toggleSessionExpansion(session.session_id)} + > +
    +
    + {isExpanded ? ( + + ) : ( + + )} + {session.has_conversation ? ( + + ) : ( + + )} + {session.client_id} + {session.has_conversation ? ( + Conversation + ) : ( + {session.reason || 'No speech'} + )} +
    +
    + Session: {session.session_id.substring(0, 8)}... β€’ + {new Date(session.completed_at * 1000).toLocaleTimeString()} +
    +
    +
    + + {/* Expanded Jobs Section */} + {isExpanded && ( +
    + {/* Timeline Visualization */} + {renderJobTimeline(jobs, session)} + +
    Jobs for this session:
    + {jobs.length > 0 ? ( +
    + {jobs.map((job, index) => ( +
    +
    +
    + #{index + 1} + {getJobTypeIcon(job.job_type)} + {getStatusIcon(job.status)} + {job.job_type} + + {job.status} + + {job.queue} +
    +
    + {job.started_at && ( + Started: {new Date(job.started_at).toLocaleTimeString()} + )} + {job.started_at && ( + β€’ Duration: {formatDuration(job)} + )} +
    +
    + +
    + ))} +
    + ) : ( +
    No jobs found for this session
    + )} +
    + )} +
    + ); + })} +
    + ) : ( +
    + No completed sessions +
    + )} +
    +
    + + {/* Stream Health */} +
    +

    Stream Workers

    +
    + {streamingStatus?.stream_health && Object.entries(streamingStatus.stream_health).map(([provider, health]) => ( +
    +
    + {provider} + {health.error ? ( + Inactive + ) : ( + Active + )} +
    + + {health.error ? ( +

    {health.error}

    + ) : ( +
    +
    + Stream Length: + {health.stream_length} +
    +
    + Pending: + 0 ? 'text-yellow-600' : 'text-green-600'}`}> + {health.total_pending} + +
    + {health.consumer_groups && health.consumer_groups.map((group) => ( +
    +
    Consumers:
    + {group.consumers.map((consumer) => ( +
    + {consumer.name} + 0 ? 'text-yellow-600' : 'text-green-600'}> + {consumer.pending} pending + +
    + ))} +
    + ))} +
    + )} +
    + ))} +
    +
    +
    +
    + )} + + {/* Filters */} +
    +

    Filters

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + + {/* Jobs Table */} +
    +
    +

    Jobs

    +
    + +
    + + + + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + + + ))} + +
    DateJob IDTypeStatusDurationResultActions
    + {new Date(job.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + +
    + {job.job_id} +
    +
    +
    {getJobTypeShort(job.job_type)}
    +
    + + {getStatusIcon(job.status)} + {job.status.charAt(0).toUpperCase() + job.status.slice(1)} + + +
    + {formatDuration(job)} +
    +
    + {getJobResult(job)} + + {job.status === 'failed' && ( + + )} + + {(job.status === 'queued' || job.status === 'processing') && ( + + )} +
    +
    + + {/* Pagination */} + {pagination.total > pagination.limit && ( +
    +
    + Showing {pagination.offset + 1} to {Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} results +
    +
    + + +
    +
    + )} +
    + + {/* Job Details Modal */} + {selectedJob && ( +
    +
    +
    +

    Job Details

    + +
    + + {loadingJobDetails ? ( +
    +
    +
    + ) : ( +
    +
    +
    + +

    {selectedJob.job_id}

    +
    +
    + + + {getStatusIcon(selectedJob.status)} + {selectedJob.status.charAt(0).toUpperCase() + selectedJob.status.slice(1)} + +
    + {selectedJob.description && ( +
    + +

    {selectedJob.description}

    +
    + )} + {selectedJob.func_name && ( +
    + +

    {selectedJob.func_name}

    +
    + )} +
    + +

    {selectedJob.created_at ? formatDate(selectedJob.created_at) : '-'}

    +
    +
    + +

    {selectedJob.started_at ? formatDate(selectedJob.started_at) : '-'}

    +
    +
    + +

    {selectedJob.ended_at ? formatDate(selectedJob.ended_at) : '-'}

    +
    +
    + + {selectedJob.args && selectedJob.args.length > 0 && ( +
    + +
    +                      {JSON.stringify(selectedJob.args, null, 2)}
    +                    
    +
    + )} + + {selectedJob.kwargs && Object.keys(selectedJob.kwargs).length > 0 && ( +
    + +
    +                      {JSON.stringify(selectedJob.kwargs, null, 2)}
    +                    
    +
    + )} + + {selectedJob.error_message && ( +
    + +
    +                      {selectedJob.error_message}
    +                    
    +
    + )} + + {selectedJob.result && ( +
    + +
    +                      {JSON.stringify(selectedJob.result, null, 2)}
    +                    
    +
    + )} +
    + )} +
    +
    + )} + + {/* Flush Jobs Modal */} + {showFlushModal && ( +
    +
    +
    +

    + + Flush Jobs +

    + +
    + +
    +
    +
    + + This will permanently remove jobs from the database +
    +
    + +
    +
    + + + {!flushSettings.flush_all && ( +
    +
    + + +
    + +
    + +
    + {['completed', 'failed', 'cancelled'].map(status => ( + + ))} +
    +
    +
    + )} +
    + +
    + + + {flushSettings.flush_all && ( +
    +
    +

    + ⚠️ This will remove ALL jobs including queued and processing ones, and reset the job counter! +

    +
    +
    + )} +
    +
    + +
    + + +
    +
    +
    +
    + )} +
    + ); +}; + +export default Queue; \ No newline at end of file diff --git a/backends/advanced/webui/src/pages/System.tsx b/backends/advanced/webui/src/pages/System.tsx index 8a7e5e0e..3ca54a59 100644 --- a/backends/advanced/webui/src/pages/System.tsx +++ b/backends/advanced/webui/src/pages/System.tsx @@ -158,7 +158,8 @@ export default function System() { const getServiceDisplayName = (service: string) => { const displayNames: Record = { 'mongodb': 'MONGODB', - 'audioai': 'AUDIOAI', + 'redis': 'REDIS & RQ WORKERS', + 'audioai': 'AUDIOAI', 'mem0': 'MEM0', 'memory_service': 'MEMORY SERVICE', 'speech_to_text': 'SPEECH TO TEXT', @@ -275,6 +276,12 @@ export default function System() { ({(status as any).provider}) )} + {service === 'redis' && (status as any).worker_count !== undefined && ( +
    + Workers: {(status as any).worker_count} total + ({(status as any).active_workers || 0} active, {(status as any).idle_workers || 0} idle) +
    + )}
))} @@ -289,7 +296,7 @@ export default function System() { Processor Status -
+
Audio Queue
@@ -315,6 +322,43 @@ export default function System() {
+ + {/* Worker Information */} + {(processorStatus as any).workers && ( +
+
+

+ RQ Workers +

+ + {(processorStatus as any).workers.active} / {(processorStatus as any).workers.total} active + +
+
+ {(processorStatus as any).workers.details?.map((worker: any, idx: number) => ( +
+
+ +
+ RQ Worker #{idx + 1} + {worker.name.substring(0, 8)}... +
+
+
+ {worker.queues?.join(', ')} + + {worker.state} + +
+
+ ))} +
+
+ )}
)} diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts index 5c9d82f0..41a1810d 100644 --- a/backends/advanced/webui/src/services/api.ts +++ b/backends/advanced/webui/src/services/api.ts @@ -3,7 +3,7 @@ import axios from 'axios' // Get backend URL from environment or auto-detect based on current location const getBackendUrl = () => { // If explicitly set in environment, use that - if (import.meta.env.VITE_BACKEND_URL !== undefined) { + if (import.meta.env.VITE_BACKEND_URL !== undefined && import.meta.env.VITE_BACKEND_URL !== '') { return import.meta.env.VITE_BACKEND_URL } @@ -34,7 +34,7 @@ export { BACKEND_URL } export const api = axios.create({ baseURL: BACKEND_URL, - timeout: 30000, + timeout: 60000, // Increased to 60 seconds for heavy processing scenarios }) // Add request interceptor to include auth token @@ -50,10 +50,18 @@ api.interceptors.request.use((config) => { api.interceptors.response.use( (response) => response, (error) => { + // Only clear token and redirect on actual 401 responses, not on timeouts if (error.response?.status === 401) { // Token expired or invalid, redirect to login + console.warn('πŸ” API: 401 Unauthorized - clearing token and redirecting to login') localStorage.removeItem('token') window.location.href = '/login' + } else if (error.code === 'ECONNABORTED') { + // Request timeout - don't logout, just log it + console.warn('⏱️ API: Request timeout - server may be busy') + } else if (!error.response) { + // Network error - don't logout + console.warn('🌐 API: Network error - server may be unreachable') } return Promise.reject(error) } @@ -133,9 +141,22 @@ export const systemApi = { reloadMemoryConfig: () => api.post('/api/admin/memory/config/reload'), } +export const queueApi = { + getJobs: (params: URLSearchParams) => api.get(`/api/queue/jobs?${params}`), + getJob: (jobId: string) => api.get(`/api/queue/jobs/${jobId}`), + getJobsBySession: (sessionId: string) => api.get(`/api/queue/jobs/by-session/${sessionId}`), + getStats: () => api.get('/api/queue/stats'), + getStreamingStatus: () => api.get('/api/streaming/status'), + cleanupStuckWorkers: () => api.post('/api/streaming/cleanup'), + cleanupOldSessions: (maxAgeSeconds: number = 3600) => api.post(`/api/streaming/cleanup-sessions?max_age_seconds=${maxAgeSeconds}`), + retryJob: (jobId: string, force: boolean = false) => + api.post(`/api/queue/jobs/${jobId}/retry`, { force }), + cancelJob: (jobId: string) => api.delete(`/api/queue/jobs/${jobId}`), +} + export const uploadApi = { uploadAudioFiles: (files: FormData, onProgress?: (progress: number) => void) => - api.post('/api/process-audio-files', files, { + api.post('/api/audio/upload', files, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 300000, // 5 minutes onUploadProgress: (progressEvent) => { diff --git a/backends/charts/advanced-backend/values.yaml b/backends/charts/advanced-backend/values.yaml index ef758385..5598c142 100644 --- a/backends/charts/advanced-backend/values.yaml +++ b/backends/charts/advanced-backend/values.yaml @@ -20,13 +20,12 @@ ingress: annotations: nginx.ingress.kubernetes.io/ssl-redirect: "false" nginx.ingress.kubernetes.io/proxy-body-size: "50m" - nginx.ingress.kubernetes.io/proxy-read-timeout: "300" - nginx.ingress.kubernetes.io/proxy-send-timeout: "300" # cert-manager.io/cluster-issuer: "selfsigned-issuer" nginx.ingress.kubernetes.io/cors-allow-origin: "*" nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS" nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" nginx.ingress.kubernetes.io/enable-cors: "true" + # WebSocket support - long timeouts for persistent connections nginx.ingress.kubernetes.io/proxy-read-timeout: "86400" nginx.ingress.kubernetes.io/proxy-send-timeout: "86400" nginx.ingress.kubernetes.io/proxy-connect-timeout: "60s" @@ -55,11 +54,12 @@ env: # These are set by Skaffold setValueTemplates for Kubernetes-specific URLs # MONGODB_URI: set by Skaffold # QDRANT_BASE_URL: set by Skaffold - + # REDIS_URL: set by Skaffold in templates + # Override only specific values if needed ENVIRONMENT: "dev" AUTH_ENABLED: "false" - CORS_ORIGINS: "" # Set by ConfigMap generated from Makefile + # CORS_ORIGINS is set by ConfigMap generated from Makefile - don't override here # Don't override OPENAI_API_KEY - it comes from ConfigMap # Volume mounts for persistent data diff --git a/backends/charts/qdrant/templates/deployment.yaml b/backends/charts/qdrant/templates/deployment.yaml index 7e668ecd..a182b914 100644 --- a/backends/charts/qdrant/templates/deployment.yaml +++ b/backends/charts/qdrant/templates/deployment.yaml @@ -6,6 +6,11 @@ metadata: {{- include "qdrant.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 0 selector: matchLabels: {{- include "qdrant.selectorLabels" . | nindent 6 }} @@ -14,10 +19,43 @@ spec: labels: {{- include "qdrant.selectorLabels" . | nindent 8 }} spec: + terminationGracePeriodSeconds: 30 + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.persistence.enabled }} + initContainers: + - name: fix-permissions + image: busybox:latest + command: + - sh + - -c + - | + chown -R 1000:1000 /qdrant/storage && \ + chmod -R 755 /qdrant/storage && \ + mkdir -p /qdrant/storage/snapshots/tmp && \ + mkdir -p /qdrant/snapshots/tmp && \ + chown -R 1000:1000 /qdrant/storage/snapshots && \ + chown -R 1000:1000 /qdrant/snapshots && \ + chmod -R 755 /qdrant/storage/snapshots && \ + chmod -R 755 /qdrant/snapshots + volumeMounts: + - name: data-volume + mountPath: /qdrant/storage + - name: snapshots-volume + mountPath: /qdrant/snapshots + securityContext: + runAsUser: 0 + {{- end }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} ports: - name: http containerPort: {{ .Values.service.port }} @@ -37,19 +75,27 @@ spec: port: http initialDelaySeconds: 5 periodSeconds: 5 + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep 15"] resources: {{- toYaml .Values.resources | nindent 12 }} - {{- if .Values.persistence.enabled }} volumeMounts: + {{- if .Values.persistence.enabled }} - name: data-volume mountPath: {{ .Values.persistence.mountPath }} - {{- end }} - {{- if .Values.persistence.enabled }} + {{- end }} + - name: snapshots-volume + mountPath: /qdrant/snapshots volumes: + {{- if .Values.persistence.enabled }} - name: data-volume persistentVolumeClaim: claimName: {{ include "qdrant.fullname" . }}-data - {{- end }} + {{- end }} + - name: snapshots-volume + emptyDir: {} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/backends/charts/qdrant/values.yaml b/backends/charts/qdrant/values.yaml index fdd613c3..3f929708 100644 --- a/backends/charts/qdrant/values.yaml +++ b/backends/charts/qdrant/values.yaml @@ -29,6 +29,16 @@ persistence: size: 10Gi mountPath: /qdrant/storage +# Pod-level security context +podSecurityContext: + fsGroup: 1000 + +# Container-level security context +containerSecurityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + nodeSelector: {} tolerations: [] affinity: {} diff --git a/extras/speaker-recognition/docker-compose-test.yml b/extras/speaker-recognition/docker-compose-test.yml index 30d360a9..0bf5d87b 100644 --- a/extras/speaker-recognition/docker-compose-test.yml +++ b/extras/speaker-recognition/docker-compose-test.yml @@ -19,6 +19,8 @@ services: - ./audio_chunks_test:/app/audio_chunks - ./debug_test:/app/debug - ./speaker_data_test:/app/data + env_file: + - .env environment: - HF_HOME=/models - HF_TOKEN=${HF_TOKEN} @@ -26,6 +28,7 @@ services: - SPEAKER_SERVICE_HOST=0.0.0.0 - SPEAKER_SERVICE_PORT=8085 - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} + - CORS_ORIGINS=http://localhost:3001,http://localhost:8001,https://localhost:3001,https://localhost:8001 restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8085/health"] diff --git a/extras/speaker-recognition/pyproject.toml b/extras/speaker-recognition/pyproject.toml index 51bb5e1f..068be94d 100644 --- a/extras/speaker-recognition/pyproject.toml +++ b/extras/speaker-recognition/pyproject.toml @@ -7,7 +7,6 @@ requires-python = ">=3.10" dependencies = [ "fastapi>=0.115.12", "uvicorn>=0.34.2", - "numpy>=1.26,<2", "scipy>=1.10.0", "pyannote.audio>=3.3.2", "aiohttp>=3.8.0", @@ -51,12 +50,13 @@ simple-speaker-web = "simple_speaker_recognition.web.app:main" [dependency-groups] cpu = [ - "faiss-cpu>=1.8", + "faiss-cpu>=1.9", "torch>=2.0.0", "torchaudio>=2.0.0", + "numpy==1.23.5", # Ensure numpy compatibility with faiss-cpu ] gpu = [ - "faiss-cpu>=1.8", # Use CPU FAISS for compatibility, GPU PyTorch for performance + "faiss-cpu>=1.9", # Use CPU FAISS for compatibility, GPU PyTorch for performance "torch>=2.0.0", "torchaudio>=2.0.0", ] diff --git a/extras/speaker-recognition/uv.lock b/extras/speaker-recognition/uv.lock deleted file mode 100644 index 2420caf5..00000000 --- a/extras/speaker-recognition/uv.lock +++ /dev/null @@ -1,3702 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] -conflicts = [[ - { package = "simple-speaker-recognition", group = "cpu" }, - { package = "simple-speaker-recognition", group = "gpu" }, -]] - -[[package]] -name = "aenum" -version = "3.1.16" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload_time = "2025-04-25T03:17:58.89Z" }, -] - -[[package]] -name = "aiofiles" -version = "24.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload_time = "2024-06-24T11:02:03.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload_time = "2024-06-24T11:02:01.529Z" }, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload_time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload_time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.12.13" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160, upload_time = "2025-06-14T15:15:41.354Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/2d/27e4347660723738b01daa3f5769d56170f232bf4695dd4613340da135bb/aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29", size = 702090, upload_time = "2025-06-14T15:12:58.938Z" }, - { url = "https://files.pythonhosted.org/packages/10/0b/4a8e0468ee8f2b9aff3c05f2c3a6be1dfc40b03f68a91b31041d798a9510/aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0", size = 478440, upload_time = "2025-06-14T15:13:02.981Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/2086df2f9a842b13feb92d071edf756be89250f404f10966b7bc28317f17/aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d", size = 466215, upload_time = "2025-06-14T15:13:04.817Z" }, - { url = "https://files.pythonhosted.org/packages/a7/3d/d23e5bd978bc8012a65853959b13bd3b55c6e5afc172d89c26ad6624c52b/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa", size = 1648271, upload_time = "2025-06-14T15:13:06.532Z" }, - { url = "https://files.pythonhosted.org/packages/31/31/e00122447bb137591c202786062f26dd383574c9f5157144127077d5733e/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294", size = 1622329, upload_time = "2025-06-14T15:13:08.394Z" }, - { url = "https://files.pythonhosted.org/packages/04/01/caef70be3ac38986969045f21f5fb802ce517b3f371f0615206bf8aa6423/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce", size = 1694734, upload_time = "2025-06-14T15:13:09.979Z" }, - { url = "https://files.pythonhosted.org/packages/3f/15/328b71fedecf69a9fd2306549b11c8966e420648a3938d75d3ed5bcb47f6/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe", size = 1737049, upload_time = "2025-06-14T15:13:11.672Z" }, - { url = "https://files.pythonhosted.org/packages/e6/7a/d85866a642158e1147c7da5f93ad66b07e5452a84ec4258e5f06b9071e92/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5", size = 1641715, upload_time = "2025-06-14T15:13:13.548Z" }, - { url = "https://files.pythonhosted.org/packages/14/57/3588800d5d2f5f3e1cb6e7a72747d1abc1e67ba5048e8b845183259c2e9b/aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073", size = 1581836, upload_time = "2025-06-14T15:13:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/c913332899a916d85781aa74572f60fd98127449b156ad9c19e23135b0e4/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6", size = 1625685, upload_time = "2025-06-14T15:13:17.163Z" }, - { url = "https://files.pythonhosted.org/packages/4c/34/26cded195f3bff128d6a6d58d7a0be2ae7d001ea029e0fe9008dcdc6a009/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795", size = 1636471, upload_time = "2025-06-14T15:13:19.086Z" }, - { url = "https://files.pythonhosted.org/packages/19/21/70629ca006820fccbcec07f3cd5966cbd966e2d853d6da55339af85555b9/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0", size = 1611923, upload_time = "2025-06-14T15:13:20.997Z" }, - { url = "https://files.pythonhosted.org/packages/31/80/7fa3f3bebf533aa6ae6508b51ac0de9965e88f9654fa679cc1a29d335a79/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a", size = 1691511, upload_time = "2025-06-14T15:13:22.54Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7a/359974653a3cdd3e9cee8ca10072a662c3c0eb46a359c6a1f667b0296e2f/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40", size = 1714751, upload_time = "2025-06-14T15:13:24.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/24/0aa03d522171ce19064347afeefadb008be31ace0bbb7d44ceb055700a14/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6", size = 1643090, upload_time = "2025-06-14T15:13:26.231Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/7d4b0026a41e4b467e143221c51b279083b7044a4b104054f5c6464082ff/aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad", size = 427526, upload_time = "2025-06-14T15:13:27.988Z" }, - { url = "https://files.pythonhosted.org/packages/17/de/34d998da1e7f0de86382160d039131e9b0af1962eebfe53dda2b61d250e7/aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178", size = 450734, upload_time = "2025-06-14T15:13:29.394Z" }, - { url = "https://files.pythonhosted.org/packages/6a/65/5566b49553bf20ffed6041c665a5504fb047cefdef1b701407b8ce1a47c4/aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c", size = 709401, upload_time = "2025-06-14T15:13:30.774Z" }, - { url = "https://files.pythonhosted.org/packages/14/b5/48e4cc61b54850bdfafa8fe0b641ab35ad53d8e5a65ab22b310e0902fa42/aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358", size = 481669, upload_time = "2025-06-14T15:13:32.316Z" }, - { url = "https://files.pythonhosted.org/packages/04/4f/e3f95c8b2a20a0437d51d41d5ccc4a02970d8ad59352efb43ea2841bd08e/aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014", size = 469933, upload_time = "2025-06-14T15:13:34.104Z" }, - { url = "https://files.pythonhosted.org/packages/41/c9/c5269f3b6453b1cfbd2cfbb6a777d718c5f086a3727f576c51a468b03ae2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7", size = 1740128, upload_time = "2025-06-14T15:13:35.604Z" }, - { url = "https://files.pythonhosted.org/packages/6f/49/a3f76caa62773d33d0cfaa842bdf5789a78749dbfe697df38ab1badff369/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013", size = 1688796, upload_time = "2025-06-14T15:13:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e4/556fccc4576dc22bf18554b64cc873b1a3e5429a5bdb7bbef7f5d0bc7664/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47", size = 1787589, upload_time = "2025-06-14T15:13:38.745Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3d/d81b13ed48e1a46734f848e26d55a7391708421a80336e341d2aef3b6db2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a", size = 1826635, upload_time = "2025-06-14T15:13:40.733Z" }, - { url = "https://files.pythonhosted.org/packages/75/a5/472e25f347da88459188cdaadd1f108f6292f8a25e62d226e63f860486d1/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc", size = 1729095, upload_time = "2025-06-14T15:13:42.312Z" }, - { url = "https://files.pythonhosted.org/packages/b9/fe/322a78b9ac1725bfc59dfc301a5342e73d817592828e4445bd8f4ff83489/aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7", size = 1666170, upload_time = "2025-06-14T15:13:44.884Z" }, - { url = "https://files.pythonhosted.org/packages/7a/77/ec80912270e231d5e3839dbd6c065472b9920a159ec8a1895cf868c2708e/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b", size = 1714444, upload_time = "2025-06-14T15:13:46.401Z" }, - { url = "https://files.pythonhosted.org/packages/21/b2/fb5aedbcb2b58d4180e58500e7c23ff8593258c27c089abfbcc7db65bd40/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9", size = 1709604, upload_time = "2025-06-14T15:13:48.377Z" }, - { url = "https://files.pythonhosted.org/packages/e3/15/a94c05f7c4dc8904f80b6001ad6e07e035c58a8ebfcc15e6b5d58500c858/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a", size = 1689786, upload_time = "2025-06-14T15:13:50.401Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/0d2e618388f7a7a4441eed578b626bda9ec6b5361cd2954cfc5ab39aa170/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d", size = 1783389, upload_time = "2025-06-14T15:13:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6b/6986d0c75996ef7e64ff7619b9b7449b1d1cbbe05c6755e65d92f1784fe9/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2", size = 1803853, upload_time = "2025-06-14T15:13:53.533Z" }, - { url = "https://files.pythonhosted.org/packages/21/65/cd37b38f6655d95dd07d496b6d2f3924f579c43fd64b0e32b547b9c24df5/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3", size = 1716909, upload_time = "2025-06-14T15:13:55.148Z" }, - { url = "https://files.pythonhosted.org/packages/fd/20/2de7012427dc116714c38ca564467f6143aec3d5eca3768848d62aa43e62/aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd", size = 427036, upload_time = "2025-06-14T15:13:57.076Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b6/98518bcc615ef998a64bef371178b9afc98ee25895b4f476c428fade2220/aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9", size = 451427, upload_time = "2025-06-14T15:13:58.505Z" }, - { url = "https://files.pythonhosted.org/packages/b4/6a/ce40e329788013cd190b1d62bbabb2b6a9673ecb6d836298635b939562ef/aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73", size = 700491, upload_time = "2025-06-14T15:14:00.048Z" }, - { url = "https://files.pythonhosted.org/packages/28/d9/7150d5cf9163e05081f1c5c64a0cdf3c32d2f56e2ac95db2a28fe90eca69/aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347", size = 475104, upload_time = "2025-06-14T15:14:01.691Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/d42ba4aed039ce6e449b3e2db694328756c152a79804e64e3da5bc19dffc/aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f", size = 467948, upload_time = "2025-06-14T15:14:03.561Z" }, - { url = "https://files.pythonhosted.org/packages/99/3b/06f0a632775946981d7c4e5a865cddb6e8dfdbaed2f56f9ade7bb4a1039b/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6", size = 1714742, upload_time = "2025-06-14T15:14:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/92/a6/2552eebad9ec5e3581a89256276009e6a974dc0793632796af144df8b740/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5", size = 1697393, upload_time = "2025-06-14T15:14:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/d8/9f/bd08fdde114b3fec7a021381b537b21920cdd2aa29ad48c5dffd8ee314f1/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b", size = 1752486, upload_time = "2025-06-14T15:14:08.808Z" }, - { url = "https://files.pythonhosted.org/packages/f7/e1/affdea8723aec5bd0959171b5490dccd9a91fcc505c8c26c9f1dca73474d/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75", size = 1798643, upload_time = "2025-06-14T15:14:10.767Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9d/666d856cc3af3a62ae86393baa3074cc1d591a47d89dc3bf16f6eb2c8d32/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6", size = 1718082, upload_time = "2025-06-14T15:14:12.38Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ce/3c185293843d17be063dada45efd2712bb6bf6370b37104b4eda908ffdbd/aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8", size = 1633884, upload_time = "2025-06-14T15:14:14.415Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5b/f3413f4b238113be35dfd6794e65029250d4b93caa0974ca572217745bdb/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710", size = 1694943, upload_time = "2025-06-14T15:14:16.48Z" }, - { url = "https://files.pythonhosted.org/packages/82/c8/0e56e8bf12081faca85d14a6929ad5c1263c146149cd66caa7bc12255b6d/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462", size = 1716398, upload_time = "2025-06-14T15:14:18.589Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/33192b4761f7f9b2f7f4281365d925d663629cfaea093a64b658b94fc8e1/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae", size = 1657051, upload_time = "2025-06-14T15:14:20.223Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0b/26ddd91ca8f84c48452431cb4c5dd9523b13bc0c9766bda468e072ac9e29/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e", size = 1736611, upload_time = "2025-06-14T15:14:21.988Z" }, - { url = "https://files.pythonhosted.org/packages/c3/8d/e04569aae853302648e2c138a680a6a2f02e374c5b6711732b29f1e129cc/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a", size = 1764586, upload_time = "2025-06-14T15:14:23.979Z" }, - { url = "https://files.pythonhosted.org/packages/ac/98/c193c1d1198571d988454e4ed75adc21c55af247a9fda08236602921c8c8/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5", size = 1724197, upload_time = "2025-06-14T15:14:25.692Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9e/07bb8aa11eec762c6b1ff61575eeeb2657df11ab3d3abfa528d95f3e9337/aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf", size = 421771, upload_time = "2025-06-14T15:14:27.364Z" }, - { url = "https://files.pythonhosted.org/packages/52/66/3ce877e56ec0813069cdc9607cd979575859c597b6fb9b4182c6d5f31886/aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e", size = 447869, upload_time = "2025-06-14T15:14:29.05Z" }, - { url = "https://files.pythonhosted.org/packages/11/0f/db19abdf2d86aa1deec3c1e0e5ea46a587b97c07a16516b6438428b3a3f8/aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938", size = 694910, upload_time = "2025-06-14T15:14:30.604Z" }, - { url = "https://files.pythonhosted.org/packages/d5/81/0ab551e1b5d7f1339e2d6eb482456ccbe9025605b28eed2b1c0203aaaade/aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace", size = 472566, upload_time = "2025-06-14T15:14:32.275Z" }, - { url = "https://files.pythonhosted.org/packages/34/3f/6b7d336663337672d29b1f82d1f252ec1a040fe2d548f709d3f90fa2218a/aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb", size = 464856, upload_time = "2025-06-14T15:14:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/26/7f/32ca0f170496aa2ab9b812630fac0c2372c531b797e1deb3deb4cea904bd/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7", size = 1703683, upload_time = "2025-06-14T15:14:36.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/53/d5513624b33a811c0abea8461e30a732294112318276ce3dbf047dbd9d8b/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b", size = 1684946, upload_time = "2025-06-14T15:14:38Z" }, - { url = "https://files.pythonhosted.org/packages/37/72/4c237dd127827b0247dc138d3ebd49c2ded6114c6991bbe969058575f25f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177", size = 1737017, upload_time = "2025-06-14T15:14:39.951Z" }, - { url = "https://files.pythonhosted.org/packages/0d/67/8a7eb3afa01e9d0acc26e1ef847c1a9111f8b42b82955fcd9faeb84edeb4/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef", size = 1786390, upload_time = "2025-06-14T15:14:42.151Z" }, - { url = "https://files.pythonhosted.org/packages/48/19/0377df97dd0176ad23cd8cad4fd4232cfeadcec6c1b7f036315305c98e3f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103", size = 1708719, upload_time = "2025-06-14T15:14:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/61/97/ade1982a5c642b45f3622255173e40c3eed289c169f89d00eeac29a89906/aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da", size = 1622424, upload_time = "2025-06-14T15:14:45.945Z" }, - { url = "https://files.pythonhosted.org/packages/99/ab/00ad3eea004e1d07ccc406e44cfe2b8da5acb72f8c66aeeb11a096798868/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d", size = 1675447, upload_time = "2025-06-14T15:14:47.911Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fe/74e5ce8b2ccaba445fe0087abc201bfd7259431d92ae608f684fcac5d143/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041", size = 1707110, upload_time = "2025-06-14T15:14:50.334Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c4/39af17807f694f7a267bd8ab1fbacf16ad66740862192a6c8abac2bff813/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1", size = 1649706, upload_time = "2025-06-14T15:14:52.378Z" }, - { url = "https://files.pythonhosted.org/packages/38/e8/f5a0a5f44f19f171d8477059aa5f28a158d7d57fe1a46c553e231f698435/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1", size = 1725839, upload_time = "2025-06-14T15:14:54.617Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ac/81acc594c7f529ef4419d3866913f628cd4fa9cab17f7bf410a5c3c04c53/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911", size = 1759311, upload_time = "2025-06-14T15:14:56.597Z" }, - { url = "https://files.pythonhosted.org/packages/38/0d/aabe636bd25c6ab7b18825e5a97d40024da75152bec39aa6ac8b7a677630/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3", size = 1708202, upload_time = "2025-06-14T15:14:58.598Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ab/561ef2d8a223261683fb95a6283ad0d36cb66c87503f3a7dde7afe208bb2/aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd", size = 420794, upload_time = "2025-06-14T15:15:00.939Z" }, - { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735, upload_time = "2025-06-14T15:15:02.858Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload_time = "2024-12-13T17:10:40.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload_time = "2024-12-13T17:10:38.469Z" }, -] - -[[package]] -name = "alembic" -version = "1.16.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563, upload_time = "2025-06-16T18:05:08.566Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717, upload_time = "2025-06-16T18:05:10.27Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "antlr4-python3-runtime" -version = "4.9.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload_time = "2021-11-06T17:52:23.524Z" } - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload_time = "2025-03-17T00:02:54.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload_time = "2025-03-17T00:02:52.713Z" }, -] - -[[package]] -name = "asteroid-filterbanks" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "torch" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/fa/5c2be1f96dc179f83cdd3bb267edbd1f47d08f756785c016d5c2163901a7/asteroid-filterbanks-0.4.0.tar.gz", hash = "sha256:415f89d1dcf2b13b35f03f7a9370968ac4e6fa6800633c522dac992b283409b9", size = 24599, upload_time = "2021-04-09T20:03:07.456Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/7c/83ff6046176a675e6a1e8aeefed8892cd97fe7c46af93cc540d1b24b8323/asteroid_filterbanks-0.4.0-py3-none-any.whl", hash = "sha256:4932ac8b6acc6e08fb87cbe8ece84215b5a74eee284fe83acf3540a72a02eaf5", size = 29912, upload_time = "2021-04-09T20:03:05.817Z" }, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload_time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload_time = "2024-11-06T16:41:37.9Z" }, -] - -[[package]] -name = "attrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload_time = "2025-03-13T11:10:22.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload_time = "2025-03-13T11:10:21.14Z" }, -] - -[[package]] -name = "audioop-lts" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload_time = "2024-08-04T21:14:43.957Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload_time = "2024-08-04T21:13:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload_time = "2024-08-04T21:13:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload_time = "2024-08-04T21:14:00.846Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload_time = "2024-08-04T21:14:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload_time = "2024-08-04T21:14:03.509Z" }, - { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload_time = "2024-08-04T21:14:04.679Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload_time = "2024-08-04T21:14:09.038Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload_time = "2024-08-04T21:14:09.99Z" }, - { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload_time = "2024-08-04T21:14:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload_time = "2024-08-04T21:14:12.394Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload_time = "2024-08-04T21:14:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload_time = "2024-08-04T21:14:14.74Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload_time = "2024-08-04T21:14:19.155Z" }, - { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload_time = "2024-08-04T21:14:20.438Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload_time = "2024-08-04T21:14:21.342Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload_time = "2024-08-04T21:14:22.193Z" }, - { url = "https://files.pythonhosted.org/packages/7a/99/bb664a99561fd4266687e5cb8965e6ec31ba4ff7002c3fce3dc5ef2709db/audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3827e3fce6fee4d69d96a3d00cd2ab07f3c0d844cb1e44e26f719b34a5b15455", size = 46827, upload_time = "2024-08-04T21:14:23.034Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e3/f664171e867e0768ab982715e744430cf323f1282eb2e11ebfb6ee4c4551/audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:161249db9343b3c9780ca92c0be0d1ccbfecdbccac6844f3d0d44b9c4a00a17f", size = 27479, upload_time = "2024-08-04T21:14:23.922Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0d/2a79231ff54eb20e83b47e7610462ad6a2bea4e113fae5aa91c6547e7764/audioop_lts-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5b7b4ff9de7a44e0ad2618afdc2ac920b91f4a6d3509520ee65339d4acde5abf", size = 27056, upload_time = "2024-08-04T21:14:28.061Z" }, - { url = "https://files.pythonhosted.org/packages/86/46/342471398283bb0634f5a6df947806a423ba74b2e29e250c7ec0e3720e4f/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e37f416adb43b0ced93419de0122b42753ee74e87070777b53c5d2241e7fab", size = 87802, upload_time = "2024-08-04T21:14:29.586Z" }, - { url = "https://files.pythonhosted.org/packages/56/44/7a85b08d4ed55517634ff19ddfbd0af05bf8bfd39a204e4445cd0e6f0cc9/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534ce808e6bab6adb65548723c8cbe189a3379245db89b9d555c4210b4aaa9b6", size = 95016, upload_time = "2024-08-04T21:14:30.481Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2a/45edbca97ea9ee9e6bbbdb8d25613a36e16a4d1e14ae01557392f15cc8d3/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2de9b6fb8b1cf9f03990b299a9112bfdf8b86b6987003ca9e8a6c4f56d39543", size = 87394, upload_time = "2024-08-04T21:14:31.883Z" }, - { url = "https://files.pythonhosted.org/packages/14/ae/832bcbbef2c510629593bf46739374174606e25ac7d106b08d396b74c964/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f24865991b5ed4b038add5edbf424639d1358144f4e2a3e7a84bc6ba23e35074", size = 84874, upload_time = "2024-08-04T21:14:32.751Z" }, - { url = "https://files.pythonhosted.org/packages/26/1c/8023c3490798ed2f90dfe58ec3b26d7520a243ae9c0fc751ed3c9d8dbb69/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bdb3b7912ccd57ea53197943f1bbc67262dcf29802c4a6df79ec1c715d45a78", size = 88698, upload_time = "2024-08-04T21:14:34.147Z" }, - { url = "https://files.pythonhosted.org/packages/2c/db/5379d953d4918278b1f04a5a64b2c112bd7aae8f81021009da0dcb77173c/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:120678b208cca1158f0a12d667af592e067f7a50df9adc4dc8f6ad8d065a93fb", size = 90401, upload_time = "2024-08-04T21:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/99/6e/3c45d316705ab1aec2e69543a5b5e458d0d112a93d08994347fafef03d50/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:54cd4520fc830b23c7d223693ed3e1b4d464997dd3abc7c15dce9a1f9bd76ab2", size = 91864, upload_time = "2024-08-04T21:14:36.158Z" }, - { url = "https://files.pythonhosted.org/packages/08/58/6a371d8fed4f34debdb532c0b00942a84ebf3e7ad368e5edc26931d0e251/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:d6bd20c7a10abcb0fb3d8aaa7508c0bf3d40dfad7515c572014da4b979d3310a", size = 98796, upload_time = "2024-08-04T21:14:37.185Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/d637aa35497e0034ff846fd3330d1db26bc6fd9dd79c406e1341188b06a2/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:f0ed1ad9bd862539ea875fb339ecb18fcc4148f8d9908f4502df28f94d23491a", size = 94116, upload_time = "2024-08-04T21:14:38.145Z" }, - { url = "https://files.pythonhosted.org/packages/1a/60/7afc2abf46bbcf525a6ebc0305d85ab08dc2d1e2da72c48dbb35eee5b62c/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e1af3ff32b8c38a7d900382646e91f2fc515fd19dea37e9392275a5cbfdbff63", size = 91520, upload_time = "2024-08-04T21:14:39.128Z" }, - { url = "https://files.pythonhosted.org/packages/65/6d/42d40da100be1afb661fd77c2b1c0dfab08af1540df57533621aea3db52a/audioop_lts-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:f51bb55122a89f7a0817d7ac2319744b4640b5b446c4c3efcea5764ea99ae509", size = 26482, upload_time = "2024-08-04T21:14:40.269Z" }, - { url = "https://files.pythonhosted.org/packages/01/09/f08494dca79f65212f5b273aecc5a2f96691bf3307cac29acfcf84300c01/audioop_lts-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f0f2f336aa2aee2bce0b0dcc32bbba9178995454c7b979cf6ce086a8801e14c7", size = 30780, upload_time = "2024-08-04T21:14:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload_time = "2024-08-04T21:14:42.803Z" }, -] - -[[package]] -name = "audioread" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/d2/87016ca9f083acadffb2d8da59bfa3253e4da7eeb9f71fb8e7708dc97ecd/audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d", size = 116513, upload_time = "2023-09-27T19:27:53.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/8d/30aa32745af16af0a9a650115fbe81bde7c610ed5c21b381fca0196f3a7f/audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33", size = 23492, upload_time = "2023-09-27T19:27:51.334Z" }, -] - -[[package]] -name = "black" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload_time = "2025-01-29T04:15:40.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload_time = "2025-01-29T05:37:06.642Z" }, - { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload_time = "2025-01-29T05:37:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload_time = "2025-01-29T04:18:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload_time = "2025-01-29T04:19:04.296Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload_time = "2025-01-29T05:37:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload_time = "2025-01-29T05:37:14.309Z" }, - { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload_time = "2025-01-29T04:18:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload_time = "2025-01-29T04:18:51.711Z" }, - { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload_time = "2025-01-29T05:37:16.707Z" }, - { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload_time = "2025-01-29T05:37:18.273Z" }, - { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload_time = "2025-01-29T04:18:33.823Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload_time = "2025-01-29T04:19:12.944Z" }, - { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload_time = "2025-01-29T05:37:20.574Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload_time = "2025-01-29T05:37:22.106Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload_time = "2025-01-29T04:18:58.564Z" }, - { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload_time = "2025-01-29T04:19:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload_time = "2025-01-29T04:15:38.082Z" }, -] - -[[package]] -name = "certifi" -version = "2025.6.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload_time = "2025-06-15T02:45:51.329Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload_time = "2025-06-15T02:45:49.977Z" }, -] - -[[package]] -name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload_time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload_time = "2024-09-04T20:43:30.027Z" }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload_time = "2024-09-04T20:43:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload_time = "2024-09-04T20:43:34.186Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload_time = "2024-09-04T20:43:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload_time = "2024-09-04T20:43:38.586Z" }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload_time = "2024-09-04T20:43:40.084Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload_time = "2024-09-04T20:43:41.526Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload_time = "2024-09-04T20:43:43.117Z" }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload_time = "2024-09-04T20:43:45.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload_time = "2024-09-04T20:43:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload_time = "2024-09-04T20:43:48.186Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload_time = "2024-09-04T20:43:49.812Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload_time = "2024-09-04T20:43:51.124Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload_time = "2024-09-04T20:43:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload_time = "2024-09-04T20:43:56.123Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload_time = "2024-09-04T20:43:57.891Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload_time = "2024-09-04T20:44:00.18Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload_time = "2024-09-04T20:44:01.585Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload_time = "2024-09-04T20:44:03.467Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload_time = "2024-09-04T20:44:05.023Z" }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload_time = "2024-09-04T20:44:06.444Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload_time = "2024-09-04T20:44:08.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload_time = "2024-09-04T20:44:09.481Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload_time = "2024-09-04T20:44:10.873Z" }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload_time = "2024-09-04T20:44:12.232Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload_time = "2024-09-04T20:44:13.739Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload_time = "2024-09-04T20:44:15.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload_time = "2024-09-04T20:44:17.188Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload_time = "2024-09-04T20:44:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload_time = "2024-09-04T20:44:20.248Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload_time = "2024-09-04T20:44:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload_time = "2024-09-04T20:44:23.245Z" }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload_time = "2024-09-04T20:44:24.757Z" }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload_time = "2024-09-04T20:44:26.208Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload_time = "2024-09-04T20:44:27.578Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload_time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload_time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload_time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload_time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload_time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload_time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload_time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload_time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload_time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload_time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload_time = "2025-05-02T08:34:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload_time = "2025-05-02T08:31:46.725Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload_time = "2025-05-02T08:31:48.889Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload_time = "2025-05-02T08:31:50.757Z" }, - { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload_time = "2025-05-02T08:31:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload_time = "2025-05-02T08:31:56.207Z" }, - { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload_time = "2025-05-02T08:31:57.613Z" }, - { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload_time = "2025-05-02T08:31:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload_time = "2025-05-02T08:32:01.219Z" }, - { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload_time = "2025-05-02T08:32:03.045Z" }, - { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload_time = "2025-05-02T08:32:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload_time = "2025-05-02T08:32:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload_time = "2025-05-02T08:32:08.66Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload_time = "2025-05-02T08:32:10.46Z" }, - { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload_time = "2025-05-02T08:32:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload_time = "2025-05-02T08:32:13.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload_time = "2025-05-02T08:32:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload_time = "2025-05-02T08:32:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload_time = "2025-05-02T08:32:18.807Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload_time = "2025-05-02T08:32:20.333Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload_time = "2025-05-02T08:32:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload_time = "2025-05-02T08:32:23.434Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload_time = "2025-05-02T08:32:24.993Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload_time = "2025-05-02T08:32:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload_time = "2025-05-02T08:32:28.376Z" }, - { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload_time = "2025-05-02T08:32:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload_time = "2025-05-02T08:32:32.191Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload_time = "2025-05-02T08:32:33.712Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload_time = "2025-05-02T08:32:35.768Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload_time = "2025-05-02T08:32:37.284Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload_time = "2025-05-02T08:32:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload_time = "2025-05-02T08:32:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload_time = "2025-05-02T08:32:41.705Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload_time = "2025-05-02T08:32:43.709Z" }, - { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload_time = "2025-05-02T08:32:46.197Z" }, - { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload_time = "2025-05-02T08:32:48.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload_time = "2025-05-02T08:32:49.719Z" }, - { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload_time = "2025-05-02T08:32:51.404Z" }, - { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload_time = "2025-05-02T08:32:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload_time = "2025-05-02T08:32:54.573Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload_time = "2025-05-02T08:32:56.363Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload_time = "2025-05-02T08:32:58.551Z" }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload_time = "2025-05-02T08:33:00.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload_time = "2025-05-02T08:33:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload_time = "2025-05-02T08:33:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload_time = "2025-05-02T08:33:06.418Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload_time = "2025-05-02T08:33:08.183Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload_time = "2025-05-02T08:33:09.986Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload_time = "2025-05-02T08:33:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload_time = "2025-05-02T08:33:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload_time = "2025-05-02T08:33:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload_time = "2025-05-02T08:33:17.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload_time = "2025-05-02T08:33:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload_time = "2025-05-02T08:34:40.053Z" }, -] - -[[package]] -name = "click" -version = "8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload_time = "2025-05-20T23:19:49.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload_time = "2025-05-20T23:19:47.796Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "colorlog" -version = "6.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload_time = "2024-10-29T18:34:51.011Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload_time = "2024-10-29T18:34:49.815Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload_time = "2025-04-15T17:47:53.79Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload_time = "2025-04-15T17:34:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload_time = "2025-04-15T17:34:51.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload_time = "2025-04-15T17:34:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload_time = "2025-04-15T17:35:00.992Z" }, - { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload_time = "2025-04-15T17:35:06.177Z" }, - { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload_time = "2025-04-15T17:35:11.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload_time = "2025-04-15T17:35:26.701Z" }, - { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload_time = "2025-04-15T17:35:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload_time = "2025-04-15T17:35:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload_time = "2025-04-15T17:35:50.064Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload_time = "2025-04-15T17:35:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload_time = "2025-04-15T17:35:58.283Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload_time = "2025-04-15T17:36:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload_time = "2025-04-15T17:36:08.275Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload_time = "2025-04-15T17:36:13.29Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload_time = "2025-04-15T17:36:18.329Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload_time = "2025-04-15T17:36:33.878Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload_time = "2025-04-15T17:36:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload_time = "2025-04-15T17:36:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload_time = "2025-04-15T17:36:58.576Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload_time = "2025-04-15T17:37:03.105Z" }, - { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload_time = "2025-04-15T17:37:07.026Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload_time = "2025-04-15T17:37:11.481Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload_time = "2025-04-15T17:37:18.212Z" }, - { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload_time = "2025-04-15T17:37:22.76Z" }, - { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload_time = "2025-04-15T17:37:33.001Z" }, - { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload_time = "2025-04-15T17:37:48.64Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload_time = "2025-04-15T17:38:06.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload_time = "2025-04-15T17:38:10.338Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload_time = "2025-04-15T17:38:14.239Z" }, - { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload_time = "2025-04-15T17:38:19.142Z" }, - { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload_time = "2025-04-15T17:38:23.688Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload_time = "2025-04-15T17:38:28.238Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload_time = "2025-04-15T17:38:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload_time = "2025-04-15T17:38:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload_time = "2025-04-15T17:38:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload_time = "2025-04-15T17:39:00.224Z" }, - { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload_time = "2025-04-15T17:43:29.649Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload_time = "2025-04-15T17:44:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload_time = "2025-04-15T17:44:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload_time = "2025-04-15T17:43:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload_time = "2025-04-15T17:43:38.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload_time = "2025-04-15T17:43:44.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload_time = "2025-04-15T17:43:49.545Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload_time = "2025-04-15T17:43:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload_time = "2025-04-15T17:44:01.025Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload_time = "2025-04-15T17:44:17.322Z" }, - { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload_time = "2025-04-15T17:44:33.43Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload_time = "2025-04-15T17:44:37.092Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload_time = "2025-04-15T17:44:40.827Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload_time = "2025-04-15T17:44:59.314Z" }, - { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload_time = "2025-04-15T17:45:04.165Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload_time = "2025-04-15T17:45:08.456Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload_time = "2025-04-15T17:45:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload_time = "2025-04-15T17:45:20.166Z" }, - { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload_time = "2025-04-15T17:45:24.794Z" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload_time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload_time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "dataclasses-json" -version = "0.6.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "typing-inspect" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload_time = "2024-06-09T16:20:19.103Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload_time = "2024-06-09T16:20:16.715Z" }, -] - -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload_time = "2025-02-24T04:41:34.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload_time = "2025-02-24T04:41:32.565Z" }, -] - -[[package]] -name = "deepgram-sdk" -version = "4.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aenum" }, - { name = "aiofiles" }, - { name = "aiohttp" }, - { name = "dataclasses-json" }, - { name = "deprecation" }, - { name = "httpx" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/c7/3c5918c2c74e3d56cf3d738aa174bc688c73069dc9682fc1bfaeb2058cc6/deepgram_sdk-4.7.0.tar.gz", hash = "sha256:e371396d8835d449782df472c3bd501f6cad41b3c925f66771933ff3fc4b1a13", size = 100128, upload_time = "2025-07-21T15:43:56.705Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/63/43a6e46b35eae9739e22b5cace4a22ece76d4aff74b563563b9507411484/deepgram_sdk-4.7.0-py3-none-any.whl", hash = "sha256:1a2a0890aa43cbc510e07b0f911f6841770ca0222e6fcc069bd3e2afcde1c061", size = 157911, upload_time = "2025-07-21T15:43:55.695Z" }, -] - -[[package]] -name = "deprecation" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload_time = "2020-04-20T14:23:38.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload_time = "2020-04-20T14:23:36.581Z" }, -] - -[[package]] -name = "docopt" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload_time = "2014-06-16T11:18:57.406Z" } - -[[package]] -name = "easy-audio-interfaces" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fire" }, - { name = "opuslib" }, - { name = "rich" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "soxr" }, - { name = "websockets" }, - { name = "wyoming" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/e6/9e3ff12be5b4a3e8579d7504c3f4a8981561ca75339eada4a56452092f98/easy_audio_interfaces-0.7.1.tar.gz", hash = "sha256:04cccc20cf342a89efcf079ab05a4343b57a0be8491f9519cdaf92cd421a8a7f", size = 36620, upload_time = "2025-07-13T21:27:40.669Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/6c/18de57f237cf90dd32a299365707a31a6b42b7b7fff4593f3867818e6afd/easy_audio_interfaces-0.7.1-py3-none-any.whl", hash = "sha256:6ee94d9636da35a3bd0cafb41498c2d0e5b8d16d746ba8f46392891e956fb199", size = 43112, upload_time = "2025-07-13T21:27:39.289Z" }, -] - -[package.optional-dependencies] -local-audio = [ - { name = "pyaudio" }, -] - -[[package]] -name = "einops" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/81/df4fbe24dff8ba3934af99044188e20a98ed441ad17a274539b74e82e126/einops-0.8.1.tar.gz", hash = "sha256:de5d960a7a761225532e0f1959e5315ebeafc0cd43394732f103ca44b9837e84", size = 54805, upload_time = "2025-02-09T03:17:00.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload_time = "2025-02-09T03:17:01.998Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload_time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload_time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "faiss-cpu" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e5/7490368ec421e44efd60a21aa88d244653c674d8d6ee6bc455d8ee3d02ed/faiss_cpu-1.11.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1995119152928c68096b0c1e5816e3ee5b1eebcf615b80370874523be009d0f6", size = 3307996, upload_time = "2025-04-28T07:47:29.126Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ac/a94fbbbf4f38c2ad11862af92c071ff346630ebf33f3d36fe75c3817c2f0/faiss_cpu-1.11.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:788d7bf24293fdecc1b93f1414ca5cc62ebd5f2fecfcbb1d77f0e0530621c95d", size = 7886309, upload_time = "2025-04-28T07:47:31.668Z" }, - { url = "https://files.pythonhosted.org/packages/63/48/ad79f34f1b9eba58c32399ad4fbedec3f2a717d72fb03648e906aab48a52/faiss_cpu-1.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:73408d52429558f67889581c0c6d206eedcf6fabe308908f2bdcd28fd5e8be4a", size = 3778443, upload_time = "2025-04-28T07:47:33.685Z" }, - { url = "https://files.pythonhosted.org/packages/95/67/3c6b94dd3223a8ecaff1c10c11b4ac6f3f13f1ba8ab6b6109c24b6e9b23d/faiss_cpu-1.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1f53513682ca94c76472544fa5f071553e428a1453e0b9755c9673f68de45f12", size = 31295174, upload_time = "2025-04-28T07:47:36.309Z" }, - { url = "https://files.pythonhosted.org/packages/a4/2c/d843256aabdb7f20f0f87f61efe3fb7c2c8e7487915f560ba523cfcbab57/faiss_cpu-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:30489de0356d3afa0b492ca55da164d02453db2f7323c682b69334fde9e8d48e", size = 15003860, upload_time = "2025-04-28T07:47:39.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/83/8aefc4d07624a868e046cc23ede8a59bebda57f09f72aee2150ef0855a82/faiss_cpu-1.11.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a90d1c81d0ecf2157e1d2576c482d734d10760652a5b2fcfa269916611e41f1c", size = 3307997, upload_time = "2025-04-28T07:47:41.905Z" }, - { url = "https://files.pythonhosted.org/packages/2b/64/f97e91d89dc6327e08f619fe387d7d9945bc4be3b0f1ca1e494a41c92ebe/faiss_cpu-1.11.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2c39a388b059fb82cd97fbaa7310c3580ced63bf285be531453bfffbe89ea3dd", size = 7886308, upload_time = "2025-04-28T07:47:44.677Z" }, - { url = "https://files.pythonhosted.org/packages/44/0a/7c17b6df017b0bc127c6aa4066b028281e67ab83d134c7433c4e75cd6bb6/faiss_cpu-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a4e3433ffc7f9b8707a7963db04f8676a5756868d325644db2db9d67a618b7a0", size = 3778441, upload_time = "2025-04-28T07:47:46.914Z" }, - { url = "https://files.pythonhosted.org/packages/53/45/7c85551025d9f0237d891b5cffdc5d4a366011d53b4b0a423b972cc52cea/faiss_cpu-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:926645f1b6829623bc88e93bc8ca872504d604718ada3262e505177939aaee0a", size = 31295136, upload_time = "2025-04-28T07:47:49.299Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9a/accade34b8668b21206c0c4cf0b96cd0b750b693ba5b255c1c10cfee460f/faiss_cpu-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:931db6ed2197c03a7fdf833b057c13529afa2cec8a827aa081b7f0543e4e671b", size = 15003710, upload_time = "2025-04-28T07:47:52.226Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d3/7178fa07047fd770964a83543329bb5e3fc1447004cfd85186ccf65ec3ee/faiss_cpu-1.11.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:356437b9a46f98c25831cdae70ca484bd6c05065af6256d87f6505005e9135b9", size = 3313807, upload_time = "2025-04-28T07:47:54.533Z" }, - { url = "https://files.pythonhosted.org/packages/9e/71/25f5f7b70a9f22a3efe19e7288278da460b043a3b60ad98e4e47401ed5aa/faiss_cpu-1.11.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c4a3d35993e614847f3221c6931529c0bac637a00eff0d55293e1db5cb98c85f", size = 7913537, upload_time = "2025-04-28T07:47:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c8/a5cb8466c981ad47750e1d5fda3d4223c82f9da947538749a582b3a2d35c/faiss_cpu-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8f9af33e0b8324e8199b93eb70ac4a951df02802a9dcff88e9afc183b11666f0", size = 3785180, upload_time = "2025-04-28T07:47:59.004Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eaf15a7d80e1aad74f56cf737b31b4547a1a664ad3c6e4cfaf90e82454a8/faiss_cpu-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:48b7e7876829e6bdf7333041800fa3c1753bb0c47e07662e3ef55aca86981430", size = 31287630, upload_time = "2025-04-28T07:48:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5c/902a78347e9c47baaf133e47863134e564c39f9afe105795b16ee986b0df/faiss_cpu-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:bdc199311266d2be9d299da52361cad981393327b2b8aa55af31a1b75eaaf522", size = 15005398, upload_time = "2025-04-28T07:48:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/92/90/d2329ce56423cc61f4c20ae6b4db001c6f88f28bf5a7ef7f8bbc246fd485/faiss_cpu-1.11.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0c98e5feff83b87348e44eac4d578d6f201780dae6f27f08a11d55536a20b3a8", size = 3313807, upload_time = "2025-04-28T07:48:06.486Z" }, - { url = "https://files.pythonhosted.org/packages/24/14/8af8f996d54e6097a86e6048b1a2c958c52dc985eb4f935027615079939e/faiss_cpu-1.11.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:796e90389427b1c1fb06abdb0427bb343b6350f80112a2e6090ac8f176ff7416", size = 7913539, upload_time = "2025-04-28T07:48:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2b/437c2f36c3aa3cffe041479fced1c76420d3e92e1f434f1da3be3e6f32b1/faiss_cpu-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b6e355dda72b3050991bc32031b558b8f83a2b3537a2b9e905a84f28585b47e", size = 3785181, upload_time = "2025-04-28T07:48:10.594Z" }, - { url = "https://files.pythonhosted.org/packages/66/75/955527414371843f558234df66fa0b62c6e86e71e4022b1be9333ac6004c/faiss_cpu-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6c482d07194638c169b4422774366e7472877d09181ea86835e782e6304d4185", size = 31287635, upload_time = "2025-04-28T07:48:12.93Z" }, - { url = "https://files.pythonhosted.org/packages/50/51/35b7a3f47f7859363a367c344ae5d415ea9eda65db0a7d497c7ea2c0b576/faiss_cpu-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:13eac45299532b10e911bff1abbb19d1bf5211aa9e72afeade653c3f1e50e042", size = 15005455, upload_time = "2025-04-28T07:48:16.173Z" }, -] - -[[package]] -name = "fastapi" -version = "0.115.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload_time = "2025-06-26T15:29:08.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload_time = "2025-06-26T15:29:06.49Z" }, -] - -[[package]] -name = "filelock" -version = "3.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload_time = "2025-03-14T07:11:40.47Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload_time = "2025-03-14T07:11:39.145Z" }, -] - -[[package]] -name = "fire" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, - { name = "termcolor" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/ed/3b9a10605163f48517931083aee8364d4d6d3bb1aa9b75eb0a4a5e9fbfc1/fire-0.5.0.tar.gz", hash = "sha256:a6b0d49e98c8963910021f92bba66f65ab440da2982b78eb1bbf95a0a34aacc6", size = 88282, upload_time = "2022-12-12T20:36:31.024Z" } - -[[package]] -name = "fonttools" -version = "4.58.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/5a/1124b2c8cb3a8015faf552e92714040bcdbc145dfa29928891b02d147a18/fonttools-4.58.4.tar.gz", hash = "sha256:928a8009b9884ed3aae17724b960987575155ca23c6f0b8146e400cc9e0d44ba", size = 3525026, upload_time = "2025-06-13T17:25:15.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/86/d22c24caa574449b56e994ed1a96d23b23af85557fb62a92df96439d3f6c/fonttools-4.58.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:834542f13fee7625ad753b2db035edb674b07522fcbdd0ed9e9a9e2a1034467f", size = 2748349, upload_time = "2025-06-13T17:23:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b8/384aca93856def00e7de30341f1e27f439694857d82c35d74a809c705ed0/fonttools-4.58.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2e6c61ce330142525296170cd65666e46121fc0d44383cbbcfa39cf8f58383df", size = 2318565, upload_time = "2025-06-13T17:23:52.144Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f2/273edfdc8d9db89ecfbbf659bd894f7e07b6d53448b19837a4bdba148d17/fonttools-4.58.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9c75f8faa29579c0fbf29b56ae6a3660c6c025f3b671803cb6a9caa7e4e3a98", size = 4838855, upload_time = "2025-06-13T17:23:54.039Z" }, - { url = "https://files.pythonhosted.org/packages/13/fa/403703548c093c30b52ab37e109b369558afa221130e67f06bef7513f28a/fonttools-4.58.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88dedcedbd5549e35b2ea3db3de02579c27e62e51af56779c021e7b33caadd0e", size = 4767637, upload_time = "2025-06-13T17:23:56.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a8/3380e1e0bff6defb0f81c9abf274a5b4a0f30bc8cab4fd4e346c6f923b4c/fonttools-4.58.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae80a895adab43586f4da1521d58fd4f4377cef322ee0cc205abcefa3a5effc3", size = 4819397, upload_time = "2025-06-13T17:23:58.263Z" }, - { url = "https://files.pythonhosted.org/packages/cd/1b/99e47eb17a8ca51d808622a4658584fa8f340857438a4e9d7ac326d4a041/fonttools-4.58.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0d3acc7f0d151da116e87a182aefb569cf0a3c8e0fd4c9cd0a7c1e7d3e7adb26", size = 4926641, upload_time = "2025-06-13T17:24:00.368Z" }, - { url = "https://files.pythonhosted.org/packages/31/75/415254408f038e35b36c8525fc31feb8561f98445688dd2267c23eafd7a2/fonttools-4.58.4-cp310-cp310-win32.whl", hash = "sha256:1244f69686008e7e8d2581d9f37eef330a73fee3843f1107993eb82c9d306577", size = 2201917, upload_time = "2025-06-13T17:24:02.587Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/f019a15ed2946317c5318e1bcc8876f8a54a313848604ad1d4cfc4c07916/fonttools-4.58.4-cp310-cp310-win_amd64.whl", hash = "sha256:2a66c0af8a01eb2b78645af60f3b787de5fe5eb1fd8348163715b80bdbfbde1f", size = 2246327, upload_time = "2025-06-13T17:24:04.087Z" }, - { url = "https://files.pythonhosted.org/packages/17/7b/cc6e9bb41bab223bd2dc70ba0b21386b85f604e27f4c3206b4205085a2ab/fonttools-4.58.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3841991c9ee2dc0562eb7f23d333d34ce81e8e27c903846f0487da21e0028eb", size = 2768901, upload_time = "2025-06-13T17:24:05.901Z" }, - { url = "https://files.pythonhosted.org/packages/3d/15/98d75df9f2b4e7605f3260359ad6e18e027c11fa549f74fce567270ac891/fonttools-4.58.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c98f91b6a9604e7ffb5ece6ea346fa617f967c2c0944228801246ed56084664", size = 2328696, upload_time = "2025-06-13T17:24:09.18Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c8/dc92b80f5452c9c40164e01b3f78f04b835a00e673bd9355ca257008ff61/fonttools-4.58.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab9f891eb687ddf6a4e5f82901e00f992e18012ca97ab7acd15f13632acd14c1", size = 5018830, upload_time = "2025-06-13T17:24:11.282Z" }, - { url = "https://files.pythonhosted.org/packages/19/48/8322cf177680505d6b0b6062e204f01860cb573466a88077a9b795cb70e8/fonttools-4.58.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:891c5771e8f0094b7c0dc90eda8fc75e72930b32581418f2c285a9feedfd9a68", size = 4960922, upload_time = "2025-06-13T17:24:14.9Z" }, - { url = "https://files.pythonhosted.org/packages/14/e0/2aff149ed7eb0916de36da513d473c6fff574a7146891ce42de914899395/fonttools-4.58.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:43ba4d9646045c375d22e3473b7d82b18b31ee2ac715cd94220ffab7bc2d5c1d", size = 4997135, upload_time = "2025-06-13T17:24:16.959Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6f/4d9829b29a64a2e63a121cb11ecb1b6a9524086eef3e35470949837a1692/fonttools-4.58.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33d19f16e6d2ffd6669bda574a6589941f6c99a8d5cfb9f464038244c71555de", size = 5108701, upload_time = "2025-06-13T17:24:18.849Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1e/2d656ddd1b0cd0d222f44b2d008052c2689e66b702b9af1cd8903ddce319/fonttools-4.58.4-cp311-cp311-win32.whl", hash = "sha256:b59e5109b907da19dc9df1287454821a34a75f2632a491dd406e46ff432c2a24", size = 2200177, upload_time = "2025-06-13T17:24:20.823Z" }, - { url = "https://files.pythonhosted.org/packages/fb/83/ba71ad053fddf4157cb0697c8da8eff6718d059f2a22986fa5f312b49c92/fonttools-4.58.4-cp311-cp311-win_amd64.whl", hash = "sha256:3d471a5b567a0d1648f2e148c9a8bcf00d9ac76eb89e976d9976582044cc2509", size = 2247892, upload_time = "2025-06-13T17:24:22.927Z" }, - { url = "https://files.pythonhosted.org/packages/04/3c/1d1792bfe91ef46f22a3d23b4deb514c325e73c17d4f196b385b5e2faf1c/fonttools-4.58.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:462211c0f37a278494e74267a994f6be9a2023d0557aaa9ecbcbfce0f403b5a6", size = 2754082, upload_time = "2025-06-13T17:24:24.862Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1f/2b261689c901a1c3bc57a6690b0b9fc21a9a93a8b0c83aae911d3149f34e/fonttools-4.58.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c7a12fb6f769165547f00fcaa8d0df9517603ae7e04b625e5acb8639809b82d", size = 2321677, upload_time = "2025-06-13T17:24:26.815Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6b/4607add1755a1e6581ae1fc0c9a640648e0d9cdd6591cc2d581c2e07b8c3/fonttools-4.58.4-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d42c63020a922154add0a326388a60a55504629edc3274bc273cd3806b4659f", size = 4896354, upload_time = "2025-06-13T17:24:28.428Z" }, - { url = "https://files.pythonhosted.org/packages/cd/95/34b4f483643d0cb11a1f830b72c03fdd18dbd3792d77a2eb2e130a96fada/fonttools-4.58.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f2b4e6fd45edc6805f5f2c355590b092ffc7e10a945bd6a569fc66c1d2ae7aa", size = 4941633, upload_time = "2025-06-13T17:24:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/81/ac/9bafbdb7694059c960de523e643fa5a61dd2f698f3f72c0ca18ae99257c7/fonttools-4.58.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f155b927f6efb1213a79334e4cb9904d1e18973376ffc17a0d7cd43d31981f1e", size = 4886170, upload_time = "2025-06-13T17:24:32.724Z" }, - { url = "https://files.pythonhosted.org/packages/ae/44/a3a3b70d5709405f7525bb7cb497b4e46151e0c02e3c8a0e40e5e9fe030b/fonttools-4.58.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e38f687d5de97c7fb7da3e58169fb5ba349e464e141f83c3c2e2beb91d317816", size = 5037851, upload_time = "2025-06-13T17:24:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/21/cb/e8923d197c78969454eb876a4a55a07b59c9c4c46598f02b02411dc3b45c/fonttools-4.58.4-cp312-cp312-win32.whl", hash = "sha256:636c073b4da9db053aa683db99580cac0f7c213a953b678f69acbca3443c12cc", size = 2187428, upload_time = "2025-06-13T17:24:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/46/e6/fe50183b1a0e1018e7487ee740fa8bb127b9f5075a41e20d017201e8ab14/fonttools-4.58.4-cp312-cp312-win_amd64.whl", hash = "sha256:82e8470535743409b30913ba2822e20077acf9ea70acec40b10fcf5671dceb58", size = 2236649, upload_time = "2025-06-13T17:24:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4f/c05cab5fc1a4293e6bc535c6cb272607155a0517700f5418a4165b7f9ec8/fonttools-4.58.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5f4a64846495c543796fa59b90b7a7a9dff6839bd852741ab35a71994d685c6d", size = 2745197, upload_time = "2025-06-13T17:24:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d3/49211b1f96ae49308f4f78ca7664742377a6867f00f704cdb31b57e4b432/fonttools-4.58.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e80661793a5d4d7ad132a2aa1eae2e160fbdbb50831a0edf37c7c63b2ed36574", size = 2317272, upload_time = "2025-06-13T17:24:43.428Z" }, - { url = "https://files.pythonhosted.org/packages/b2/11/c9972e46a6abd752a40a46960e431c795ad1f306775fc1f9e8c3081a1274/fonttools-4.58.4-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe5807fc64e4ba5130f1974c045a6e8d795f3b7fb6debfa511d1773290dbb76b", size = 4877184, upload_time = "2025-06-13T17:24:45.527Z" }, - { url = "https://files.pythonhosted.org/packages/ea/24/5017c01c9ef8df572cc9eaf9f12be83ad8ed722ff6dc67991d3d752956e4/fonttools-4.58.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b610b9bef841cb8f4b50472494158b1e347d15cad56eac414c722eda695a6cfd", size = 4939445, upload_time = "2025-06-13T17:24:47.647Z" }, - { url = "https://files.pythonhosted.org/packages/79/b0/538cc4d0284b5a8826b4abed93a69db52e358525d4b55c47c8cef3669767/fonttools-4.58.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2daa7f0e213c38f05f054eb5e1730bd0424aebddbeac094489ea1585807dd187", size = 4878800, upload_time = "2025-06-13T17:24:49.766Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9b/a891446b7a8250e65bffceb248508587958a94db467ffd33972723ab86c9/fonttools-4.58.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66cccb6c0b944496b7f26450e9a66e997739c513ffaac728d24930df2fd9d35b", size = 5021259, upload_time = "2025-06-13T17:24:51.754Z" }, - { url = "https://files.pythonhosted.org/packages/17/b2/c4d2872cff3ace3ddd1388bf15b76a1d8d5313f0a61f234e9aed287e674d/fonttools-4.58.4-cp313-cp313-win32.whl", hash = "sha256:94d2aebb5ca59a5107825520fde596e344652c1f18170ef01dacbe48fa60c889", size = 2185824, upload_time = "2025-06-13T17:24:54.324Z" }, - { url = "https://files.pythonhosted.org/packages/98/57/cddf8bcc911d4f47dfca1956c1e3aeeb9f7c9b8e88b2a312fe8c22714e0b/fonttools-4.58.4-cp313-cp313-win_amd64.whl", hash = "sha256:b554bd6e80bba582fd326ddab296e563c20c64dca816d5e30489760e0c41529f", size = 2236382, upload_time = "2025-06-13T17:24:56.291Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2f/c536b5b9bb3c071e91d536a4d11f969e911dbb6b227939f4c5b0bca090df/fonttools-4.58.4-py3-none-any.whl", hash = "sha256:a10ce13a13f26cbb9f37512a4346bb437ad7e002ff6fa966a7ce7ff5ac3528bd", size = 1114660, upload_time = "2025-06-13T17:25:13.321Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload_time = "2025-06-09T23:02:35.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload_time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload_time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload_time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload_time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload_time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload_time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload_time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload_time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload_time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload_time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload_time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload_time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload_time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload_time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload_time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload_time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload_time = "2025-06-09T23:00:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload_time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload_time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload_time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload_time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload_time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload_time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload_time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload_time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload_time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload_time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload_time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload_time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload_time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload_time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload_time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload_time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload_time = "2025-06-09T23:00:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload_time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload_time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload_time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload_time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload_time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload_time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload_time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload_time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload_time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload_time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload_time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload_time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload_time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload_time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload_time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload_time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload_time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload_time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload_time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload_time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload_time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload_time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload_time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload_time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload_time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload_time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload_time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload_time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload_time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload_time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload_time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload_time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload_time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload_time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload_time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload_time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload_time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload_time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload_time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload_time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload_time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload_time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload_time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload_time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload_time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload_time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload_time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload_time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload_time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload_time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload_time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload_time = "2025-06-09T23:02:34.204Z" }, -] - -[[package]] -name = "fsspec" -version = "2025.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/f7/27f15d41f0ed38e8fcc488584b57e902b331da7f7c6dcda53721b15838fc/fsspec-2025.5.1.tar.gz", hash = "sha256:2e55e47a540b91843b755e83ded97c6e897fa0942b11490113f09e9c443c2475", size = 303033, upload_time = "2025-05-24T12:03:23.792Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/61/78c7b3851add1481b048b5fdc29067397a1784e2910592bc81bb3f608635/fsspec-2025.5.1-py3-none-any.whl", hash = "sha256:24d3a2e663d5fc735ab256263c4075f374a174c3410c0b25e5bd1970bceaa462", size = 199052, upload_time = "2025-05-24T12:03:21.66Z" }, -] - -[package.optional-dependencies] -http = [ - { name = "aiohttp" }, -] - -[[package]] -name = "greenlet" -version = "3.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload_time = "2025-06-05T16:16:09.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/db/b4c12cff13ebac2786f4f217f06588bccd8b53d260453404ef22b121fc3a/greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be", size = 268977, upload_time = "2025-06-05T16:10:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/52/61/75b4abd8147f13f70986df2801bf93735c1bd87ea780d70e3b3ecda8c165/greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac", size = 627351, upload_time = "2025-06-05T16:38:50.685Z" }, - { url = "https://files.pythonhosted.org/packages/35/aa/6894ae299d059d26254779a5088632874b80ee8cf89a88bca00b0709d22f/greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392", size = 638599, upload_time = "2025-06-05T16:41:34.057Z" }, - { url = "https://files.pythonhosted.org/packages/30/64/e01a8261d13c47f3c082519a5e9dbf9e143cc0498ed20c911d04e54d526c/greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c", size = 634482, upload_time = "2025-06-05T16:48:16.26Z" }, - { url = "https://files.pythonhosted.org/packages/47/48/ff9ca8ba9772d083a4f5221f7b4f0ebe8978131a9ae0909cf202f94cd879/greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db", size = 633284, upload_time = "2025-06-05T16:13:01.599Z" }, - { url = "https://files.pythonhosted.org/packages/e9/45/626e974948713bc15775b696adb3eb0bd708bec267d6d2d5c47bb47a6119/greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b", size = 582206, upload_time = "2025-06-05T16:12:48.51Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8e/8b6f42c67d5df7db35b8c55c9a850ea045219741bb14416255616808c690/greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712", size = 1111412, upload_time = "2025-06-05T16:36:45.479Z" }, - { url = "https://files.pythonhosted.org/packages/05/46/ab58828217349500a7ebb81159d52ca357da747ff1797c29c6023d79d798/greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00", size = 1135054, upload_time = "2025-06-05T16:12:36.478Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/d1b537be5080721c0f0089a8447d4ef72839039cdb743bdd8ffd23046e9a/greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302", size = 296573, upload_time = "2025-06-05T16:34:26.521Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219, upload_time = "2025-06-05T16:10:10.414Z" }, - { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383, upload_time = "2025-06-05T16:38:51.785Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422, upload_time = "2025-06-05T16:41:35.259Z" }, - { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375, upload_time = "2025-06-05T16:48:18.235Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627, upload_time = "2025-06-05T16:13:02.858Z" }, - { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502, upload_time = "2025-06-05T16:12:49.642Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498, upload_time = "2025-06-05T16:36:46.598Z" }, - { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977, upload_time = "2025-06-05T16:12:38.262Z" }, - { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017, upload_time = "2025-06-05T16:25:05.225Z" }, - { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload_time = "2025-06-05T16:11:23.467Z" }, - { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload_time = "2025-06-05T16:38:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload_time = "2025-06-05T16:41:36.343Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701, upload_time = "2025-06-05T16:48:19.604Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload_time = "2025-06-05T16:13:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload_time = "2025-06-05T16:12:50.792Z" }, - { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload_time = "2025-06-05T16:36:48.59Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055, upload_time = "2025-06-05T16:12:40.457Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817, upload_time = "2025-06-05T16:29:49.244Z" }, - { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload_time = "2025-06-05T16:10:08.26Z" }, - { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload_time = "2025-06-05T16:38:53.983Z" }, - { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload_time = "2025-06-05T16:41:37.89Z" }, - { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload_time = "2025-06-05T16:48:21.467Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload_time = "2025-06-05T16:13:06.402Z" }, - { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload_time = "2025-06-05T16:12:51.91Z" }, - { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload_time = "2025-06-05T16:36:49.787Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload_time = "2025-06-05T16:12:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload_time = "2025-06-05T16:20:12.651Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload_time = "2025-06-05T16:10:47.525Z" }, - { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload_time = "2025-06-05T16:38:55.125Z" }, - { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload_time = "2025-06-05T16:41:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload_time = "2025-06-05T16:48:23.113Z" }, - { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload_time = "2025-06-05T16:13:07.972Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload_time = "2025-06-05T16:12:53.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload_time = "2025-06-05T16:15:20.111Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "hf-xet" -version = "1.1.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/d4/7685999e85945ed0d7f0762b686ae7015035390de1161dcea9d5276c134c/hf_xet-1.1.5.tar.gz", hash = "sha256:69ebbcfd9ec44fdc2af73441619eeb06b94ee34511bbcf57cd423820090f5694", size = 495969, upload_time = "2025-06-20T21:48:38.007Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/89/a1119eebe2836cb25758e7661d6410d3eae982e2b5e974bcc4d250be9012/hf_xet-1.1.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f52c2fa3635b8c37c7764d8796dfa72706cc4eded19d638331161e82b0792e23", size = 2687929, upload_time = "2025-06-20T21:48:32.284Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/2c78e28f309396e71ec8e4e9304a6483dcbc36172b5cea8f291994163425/hf_xet-1.1.5-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9fa6e3ee5d61912c4a113e0708eaaef987047616465ac7aa30f7121a48fc1af8", size = 2556338, upload_time = "2025-06-20T21:48:30.079Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2f/6cad7b5fe86b7652579346cb7f85156c11761df26435651cbba89376cd2c/hf_xet-1.1.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc874b5c843e642f45fd85cda1ce599e123308ad2901ead23d3510a47ff506d1", size = 3102894, upload_time = "2025-06-20T21:48:28.114Z" }, - { url = "https://files.pythonhosted.org/packages/d0/54/0fcf2b619720a26fbb6cc941e89f2472a522cd963a776c089b189559447f/hf_xet-1.1.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dbba1660e5d810bd0ea77c511a99e9242d920790d0e63c0e4673ed36c4022d18", size = 3002134, upload_time = "2025-06-20T21:48:25.906Z" }, - { url = "https://files.pythonhosted.org/packages/f3/92/1d351ac6cef7c4ba8c85744d37ffbfac2d53d0a6c04d2cabeba614640a78/hf_xet-1.1.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab34c4c3104133c495785d5d8bba3b1efc99de52c02e759cf711a91fd39d3a14", size = 3171009, upload_time = "2025-06-20T21:48:33.987Z" }, - { url = "https://files.pythonhosted.org/packages/c9/65/4b2ddb0e3e983f2508528eb4501288ae2f84963586fbdfae596836d5e57a/hf_xet-1.1.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:83088ecea236d5113de478acb2339f92c95b4fb0462acaa30621fac02f5a534a", size = 3279245, upload_time = "2025-06-20T21:48:36.051Z" }, - { url = "https://files.pythonhosted.org/packages/f0/55/ef77a85ee443ae05a9e9cba1c9f0dd9241eb42da2aeba1dc50f51154c81a/hf_xet-1.1.5-cp37-abi3-win_amd64.whl", hash = "sha256:73e167d9807d166596b4b2f0b585c6d5bd84a26dea32843665a8b58f6edba245", size = 2738931, upload_time = "2025-06-20T21:48:39.482Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "huggingface-hub" -version = "0.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/01/bfe0534a63ce7a2285e90dbb33e8a5b815ff096d8f7743b135c256916589/huggingface_hub-0.33.1.tar.gz", hash = "sha256:589b634f979da3ea4b8bdb3d79f97f547840dc83715918daf0b64209c0844c7b", size = 426728, upload_time = "2025-06-25T12:02:57.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/fb/5307bd3612eb0f0e62c3a916ae531d3a31e58fb5c82b58e3ebf7fd6f47a1/huggingface_hub-0.33.1-py3-none-any.whl", hash = "sha256:ec8d7444628210c0ba27e968e3c4c973032d44dcea59ca0d78ef3f612196f095", size = 515377, upload_time = "2025-06-25T12:02:55.611Z" }, -] - -[[package]] -name = "hyperpyyaml" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, - { name = "ruamel-yaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/e3/3ac46d9a662b037f699a6948b39c8d03bfcff0b592335d5953ba0c55d453/HyperPyYAML-1.2.2.tar.gz", hash = "sha256:bdb734210d18770a262f500fe5755c7a44a5d3b91521b06e24f7a00a36ee0f87", size = 17085, upload_time = "2023-09-21T14:45:27.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/c9/751b6401887f4b50f9307cc1e53d287b3dc77c375c126aeb6335aff73ccb/HyperPyYAML-1.2.2-py3-none-any.whl", hash = "sha256:3c5864bdc8864b2f0fbd7bc495e7e8fdf2dfd5dd80116f72da27ca96a128bdeb", size = 16118, upload_time = "2023-09-21T14:45:25.101Z" }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "isort" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload_time = "2025-02-26T21:13:16.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload_time = "2025-02-26T21:13:14.911Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "joblib" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/fe/0f5a938c54105553436dbff7a61dc4fed4b1b2c98852f8833beaf4d5968f/joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444", size = 330475, upload_time = "2025-05-23T12:04:37.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload_time = "2025-05-23T12:04:35.124Z" }, -] - -[[package]] -name = "julius" -version = "0.2.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "torch" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/19/c9e1596b5572c786b93428d0904280e964c930fae7e6c9368ed9e1b63922/julius-0.2.7.tar.gz", hash = "sha256:3c0f5f5306d7d6016fcc95196b274cae6f07e2c9596eed314e4e7641554fbb08", size = 59640, upload_time = "2022-09-19T16:13:34.2Z" } - -[[package]] -name = "kiwisolver" -version = "1.4.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538, upload_time = "2024-12-24T18:30:51.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/5f/4d8e9e852d98ecd26cdf8eaf7ed8bc33174033bba5e07001b289f07308fd/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db", size = 124623, upload_time = "2024-12-24T18:28:17.687Z" }, - { url = "https://files.pythonhosted.org/packages/1d/70/7f5af2a18a76fe92ea14675f8bd88ce53ee79e37900fa5f1a1d8e0b42998/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b", size = 66720, upload_time = "2024-12-24T18:28:19.158Z" }, - { url = "https://files.pythonhosted.org/packages/c6/13/e15f804a142353aefd089fadc8f1d985561a15358c97aca27b0979cb0785/kiwisolver-1.4.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce2cf1e5688edcb727fdf7cd1bbd0b6416758996826a8be1d958f91880d0809d", size = 65413, upload_time = "2024-12-24T18:28:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/ce/6d/67d36c4d2054e83fb875c6b59d0809d5c530de8148846b1370475eeeece9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c8bf637892dc6e6aad2bc6d4d69d08764166e5e3f69d469e55427b6ac001b19d", size = 1650826, upload_time = "2024-12-24T18:28:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/7b9bb8044e150d4d1558423a1568e4f227193662a02231064e3824f37e0a/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:034d2c891f76bd3edbdb3ea11140d8510dca675443da7304205a2eaa45d8334c", size = 1628231, upload_time = "2024-12-24T18:28:23.851Z" }, - { url = "https://files.pythonhosted.org/packages/b6/38/ad10d437563063eaaedbe2c3540a71101fc7fb07a7e71f855e93ea4de605/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47b28d1dfe0793d5e96bce90835e17edf9a499b53969b03c6c47ea5985844c3", size = 1408938, upload_time = "2024-12-24T18:28:26.687Z" }, - { url = "https://files.pythonhosted.org/packages/52/ce/c0106b3bd7f9e665c5f5bc1e07cc95b5dabd4e08e3dad42dbe2faad467e7/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb158fe28ca0c29f2260cca8c43005329ad58452c36f0edf298204de32a9a3ed", size = 1422799, upload_time = "2024-12-24T18:28:30.538Z" }, - { url = "https://files.pythonhosted.org/packages/d0/87/efb704b1d75dc9758087ba374c0f23d3254505edaedd09cf9d247f7878b9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5536185fce131780ebd809f8e623bf4030ce1b161353166c49a3c74c287897f", size = 1354362, upload_time = "2024-12-24T18:28:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b3/fd760dc214ec9a8f208b99e42e8f0130ff4b384eca8b29dd0efc62052176/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:369b75d40abedc1da2c1f4de13f3482cb99e3237b38726710f4a793432b1c5ff", size = 2222695, upload_time = "2024-12-24T18:28:35.641Z" }, - { url = "https://files.pythonhosted.org/packages/a2/09/a27fb36cca3fc01700687cc45dae7a6a5f8eeb5f657b9f710f788748e10d/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:641f2ddf9358c80faa22e22eb4c9f54bd3f0e442e038728f500e3b978d00aa7d", size = 2370802, upload_time = "2024-12-24T18:28:38.357Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c3/ba0a0346db35fe4dc1f2f2cf8b99362fbb922d7562e5f911f7ce7a7b60fa/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d561d2d8883e0819445cfe58d7ddd673e4015c3c57261d7bdcd3710d0d14005c", size = 2334646, upload_time = "2024-12-24T18:28:40.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/942cf69e562f5ed253ac67d5c92a693745f0bed3c81f49fc0cbebe4d6b00/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1732e065704b47c9afca7ffa272f845300a4eb959276bf6970dc07265e73b605", size = 2467260, upload_time = "2024-12-24T18:28:42.273Z" }, - { url = "https://files.pythonhosted.org/packages/32/26/2d9668f30d8a494b0411d4d7d4ea1345ba12deb6a75274d58dd6ea01e951/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bcb1ebc3547619c3b58a39e2448af089ea2ef44b37988caf432447374941574e", size = 2288633, upload_time = "2024-12-24T18:28:44.87Z" }, - { url = "https://files.pythonhosted.org/packages/98/99/0dd05071654aa44fe5d5e350729961e7bb535372935a45ac89a8924316e6/kiwisolver-1.4.8-cp310-cp310-win_amd64.whl", hash = "sha256:89c107041f7b27844179ea9c85d6da275aa55ecf28413e87624d033cf1f6b751", size = 71885, upload_time = "2024-12-24T18:28:47.346Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fc/822e532262a97442989335394d441cd1d0448c2e46d26d3e04efca84df22/kiwisolver-1.4.8-cp310-cp310-win_arm64.whl", hash = "sha256:b5773efa2be9eb9fcf5415ea3ab70fc785d598729fd6057bea38d539ead28271", size = 65175, upload_time = "2024-12-24T18:28:49.651Z" }, - { url = "https://files.pythonhosted.org/packages/da/ed/c913ee28936c371418cb167b128066ffb20bbf37771eecc2c97edf8a6e4c/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a4d3601908c560bdf880f07d94f31d734afd1bb71e96585cace0e38ef44c6d84", size = 124635, upload_time = "2024-12-24T18:28:51.826Z" }, - { url = "https://files.pythonhosted.org/packages/4c/45/4a7f896f7467aaf5f56ef093d1f329346f3b594e77c6a3c327b2d415f521/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856b269c4d28a5c0d5e6c1955ec36ebfd1651ac00e1ce0afa3e28da95293b561", size = 66717, upload_time = "2024-12-24T18:28:54.256Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b4/c12b3ac0852a3a68f94598d4c8d569f55361beef6159dce4e7b624160da2/kiwisolver-1.4.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2b9a96e0f326205af81a15718a9073328df1173a2619a68553decb7097fd5d7", size = 65413, upload_time = "2024-12-24T18:28:55.184Z" }, - { url = "https://files.pythonhosted.org/packages/a9/98/1df4089b1ed23d83d410adfdc5947245c753bddfbe06541c4aae330e9e70/kiwisolver-1.4.8-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5020c83e8553f770cb3b5fc13faac40f17e0b205bd237aebd21d53d733adb03", size = 1343994, upload_time = "2024-12-24T18:28:57.493Z" }, - { url = "https://files.pythonhosted.org/packages/8d/bf/b4b169b050c8421a7c53ea1ea74e4ef9c335ee9013216c558a047f162d20/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dace81d28c787956bfbfbbfd72fdcef014f37d9b48830829e488fdb32b49d954", size = 1434804, upload_time = "2024-12-24T18:29:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/66/5a/e13bd341fbcf73325ea60fdc8af752addf75c5079867af2e04cc41f34434/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e1022b524bd48ae56c9b4f9296bce77e15a2e42a502cceba602f804b32bb79", size = 1450690, upload_time = "2024-12-24T18:29:01.401Z" }, - { url = "https://files.pythonhosted.org/packages/9b/4f/5955dcb376ba4a830384cc6fab7d7547bd6759fe75a09564910e9e3bb8ea/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b9b4d2892fefc886f30301cdd80debd8bb01ecdf165a449eb6e78f79f0fabd6", size = 1376839, upload_time = "2024-12-24T18:29:02.685Z" }, - { url = "https://files.pythonhosted.org/packages/3a/97/5edbed69a9d0caa2e4aa616ae7df8127e10f6586940aa683a496c2c280b9/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a96c0e790ee875d65e340ab383700e2b4891677b7fcd30a699146f9384a2bb0", size = 1435109, upload_time = "2024-12-24T18:29:04.113Z" }, - { url = "https://files.pythonhosted.org/packages/13/fc/e756382cb64e556af6c1809a1bbb22c141bbc2445049f2da06b420fe52bf/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23454ff084b07ac54ca8be535f4174170c1094a4cff78fbae4f73a4bcc0d4dab", size = 2245269, upload_time = "2024-12-24T18:29:05.488Z" }, - { url = "https://files.pythonhosted.org/packages/76/15/e59e45829d7f41c776d138245cabae6515cb4eb44b418f6d4109c478b481/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:87b287251ad6488e95b4f0b4a79a6d04d3ea35fde6340eb38fbd1ca9cd35bbbc", size = 2393468, upload_time = "2024-12-24T18:29:06.79Z" }, - { url = "https://files.pythonhosted.org/packages/e9/39/483558c2a913ab8384d6e4b66a932406f87c95a6080112433da5ed668559/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b21dbe165081142b1232a240fc6383fd32cdd877ca6cc89eab93e5f5883e1c25", size = 2355394, upload_time = "2024-12-24T18:29:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/efad1fbca6570a161d29224f14b082960c7e08268a133fe5dc0f6906820e/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:768cade2c2df13db52475bd28d3a3fac8c9eff04b0e9e2fda0f3760f20b3f7fc", size = 2490901, upload_time = "2024-12-24T18:29:09.653Z" }, - { url = "https://files.pythonhosted.org/packages/c9/4f/15988966ba46bcd5ab9d0c8296914436720dd67fca689ae1a75b4ec1c72f/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d47cfb2650f0e103d4bf68b0b5804c68da97272c84bb12850d877a95c056bd67", size = 2312306, upload_time = "2024-12-24T18:29:12.644Z" }, - { url = "https://files.pythonhosted.org/packages/2d/27/bdf1c769c83f74d98cbc34483a972f221440703054894a37d174fba8aa68/kiwisolver-1.4.8-cp311-cp311-win_amd64.whl", hash = "sha256:ed33ca2002a779a2e20eeb06aea7721b6e47f2d4b8a8ece979d8ba9e2a167e34", size = 71966, upload_time = "2024-12-24T18:29:14.089Z" }, - { url = "https://files.pythonhosted.org/packages/4a/c9/9642ea855604aeb2968a8e145fc662edf61db7632ad2e4fb92424be6b6c0/kiwisolver-1.4.8-cp311-cp311-win_arm64.whl", hash = "sha256:16523b40aab60426ffdebe33ac374457cf62863e330a90a0383639ce14bf44b2", size = 65311, upload_time = "2024-12-24T18:29:15.892Z" }, - { url = "https://files.pythonhosted.org/packages/fc/aa/cea685c4ab647f349c3bc92d2daf7ae34c8e8cf405a6dcd3a497f58a2ac3/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502", size = 124152, upload_time = "2024-12-24T18:29:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/c5/0b/8db6d2e2452d60d5ebc4ce4b204feeb16176a851fd42462f66ade6808084/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31", size = 66555, upload_time = "2024-12-24T18:29:19.146Z" }, - { url = "https://files.pythonhosted.org/packages/60/26/d6a0db6785dd35d3ba5bf2b2df0aedc5af089962c6eb2cbf67a15b81369e/kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb", size = 65067, upload_time = "2024-12-24T18:29:20.096Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ed/1d97f7e3561e09757a196231edccc1bcf59d55ddccefa2afc9c615abd8e0/kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f", size = 1378443, upload_time = "2024-12-24T18:29:22.843Z" }, - { url = "https://files.pythonhosted.org/packages/29/61/39d30b99954e6b46f760e6289c12fede2ab96a254c443639052d1b573fbc/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc", size = 1472728, upload_time = "2024-12-24T18:29:24.463Z" }, - { url = "https://files.pythonhosted.org/packages/0c/3e/804163b932f7603ef256e4a715e5843a9600802bb23a68b4e08c8c0ff61d/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a", size = 1478388, upload_time = "2024-12-24T18:29:25.776Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9e/60eaa75169a154700be74f875a4d9961b11ba048bef315fbe89cb6999056/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a", size = 1413849, upload_time = "2024-12-24T18:29:27.202Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b3/9458adb9472e61a998c8c4d95cfdfec91c73c53a375b30b1428310f923e4/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a", size = 1475533, upload_time = "2024-12-24T18:29:28.638Z" }, - { url = "https://files.pythonhosted.org/packages/e4/7a/0a42d9571e35798de80aef4bb43a9b672aa7f8e58643d7bd1950398ffb0a/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3", size = 2268898, upload_time = "2024-12-24T18:29:30.368Z" }, - { url = "https://files.pythonhosted.org/packages/d9/07/1255dc8d80271400126ed8db35a1795b1a2c098ac3a72645075d06fe5c5d/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b", size = 2425605, upload_time = "2024-12-24T18:29:33.151Z" }, - { url = "https://files.pythonhosted.org/packages/84/df/5a3b4cf13780ef6f6942df67b138b03b7e79e9f1f08f57c49957d5867f6e/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4", size = 2375801, upload_time = "2024-12-24T18:29:34.584Z" }, - { url = "https://files.pythonhosted.org/packages/8f/10/2348d068e8b0f635c8c86892788dac7a6b5c0cb12356620ab575775aad89/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d", size = 2520077, upload_time = "2024-12-24T18:29:36.138Z" }, - { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410, upload_time = "2024-12-24T18:29:39.991Z" }, - { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853, upload_time = "2024-12-24T18:29:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424, upload_time = "2024-12-24T18:29:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156, upload_time = "2024-12-24T18:29:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555, upload_time = "2024-12-24T18:29:46.37Z" }, - { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071, upload_time = "2024-12-24T18:29:47.333Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053, upload_time = "2024-12-24T18:29:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278, upload_time = "2024-12-24T18:29:51.164Z" }, - { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139, upload_time = "2024-12-24T18:29:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517, upload_time = "2024-12-24T18:29:53.941Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952, upload_time = "2024-12-24T18:29:56.523Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132, upload_time = "2024-12-24T18:29:57.989Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997, upload_time = "2024-12-24T18:29:59.393Z" }, - { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060, upload_time = "2024-12-24T18:30:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471, upload_time = "2024-12-24T18:30:04.574Z" }, - { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793, upload_time = "2024-12-24T18:30:06.25Z" }, - { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855, upload_time = "2024-12-24T18:30:07.535Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430, upload_time = "2024-12-24T18:30:08.504Z" }, - { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294, upload_time = "2024-12-24T18:30:09.508Z" }, - { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736, upload_time = "2024-12-24T18:30:11.039Z" }, - { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194, upload_time = "2024-12-24T18:30:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942, upload_time = "2024-12-24T18:30:18.927Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341, upload_time = "2024-12-24T18:30:22.102Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455, upload_time = "2024-12-24T18:30:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138, upload_time = "2024-12-24T18:30:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857, upload_time = "2024-12-24T18:30:28.86Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129, upload_time = "2024-12-24T18:30:30.34Z" }, - { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538, upload_time = "2024-12-24T18:30:33.334Z" }, - { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661, upload_time = "2024-12-24T18:30:34.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710, upload_time = "2024-12-24T18:30:37.281Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload_time = "2024-12-24T18:30:40.019Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f9/ae81c47a43e33b93b0a9819cac6723257f5da2a5a60daf46aa5c7226ea85/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a", size = 60403, upload_time = "2024-12-24T18:30:41.372Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/f92b5cb6f4ce0c1ebfcfe3e2e42b96917e16f7090e45b21102941924f18f/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8", size = 58657, upload_time = "2024-12-24T18:30:42.392Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/ae0240f732f0484d3a4dc885d055653c47144bdf59b670aae0ec3c65a7c8/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0", size = 84948, upload_time = "2024-12-24T18:30:44.703Z" }, - { url = "https://files.pythonhosted.org/packages/5d/eb/78d50346c51db22c7203c1611f9b513075f35c4e0e4877c5dde378d66043/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd2785b9391f2873ad46088ed7599a6a71e762e1ea33e87514b1a441ed1da1c", size = 81186, upload_time = "2024-12-24T18:30:45.654Z" }, - { url = "https://files.pythonhosted.org/packages/43/f8/7259f18c77adca88d5f64f9a522792e178b2691f3748817a8750c2d216ef/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c07b29089b7ba090b6f1a669f1411f27221c3662b3a1b7010e67b59bb5a6f10b", size = 80279, upload_time = "2024-12-24T18:30:47.951Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/50ad811d1c5dae091e4cf046beba925bcae0a610e79ae4c538f996f63ed5/kiwisolver-1.4.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b", size = 71762, upload_time = "2024-12-24T18:30:48.903Z" }, -] - -[[package]] -name = "lazy-loader" -version = "0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload_time = "2024-04-05T13:03:12.261Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload_time = "2024-04-05T13:03:10.514Z" }, -] - -[[package]] -name = "librosa" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "audioread" }, - { name = "decorator" }, - { name = "joblib" }, - { name = "lazy-loader" }, - { name = "msgpack" }, - { name = "numba" }, - { name = "numpy" }, - { name = "pooch" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "soundfile" }, - { name = "soxr" }, - { name = "standard-aifc", marker = "python_full_version >= '3.13' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "standard-sunau", marker = "python_full_version >= '3.13' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/36/360b5aafa0238e29758729e9486c6ed92a6f37fa403b7875e06c115cdf4a/librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908", size = 327001, upload_time = "2025-03-11T15:09:54.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload_time = "2025-03-11T15:09:52.982Z" }, -] - -[[package]] -name = "lightning" -version = "2.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fsspec", extra = ["http"] }, - { name = "lightning-utilities" }, - { name = "packaging" }, - { name = "pytorch-lightning" }, - { name = "pyyaml" }, - { name = "torch" }, - { name = "torchmetrics" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/3c/6a930ac7c64fb896adbe560a9141570732d9ca890a11e6d158edd5aece76/lightning-2.5.2.tar.gz", hash = "sha256:9550df613cfb22358ebf77b4a8ad45f3767cd7d26ba2d52b7f036bd3cdd701c4", size = 633391, upload_time = "2025-06-20T15:58:22.065Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/a9/5d39280e55dc5df9e98be074029f6b48f86fe3db4929cb9ada6401234b47/lightning-2.5.2-py3-none-any.whl", hash = "sha256:7e7f23245e214c8ec14d5d8119d3856c25cfe96f9856296fd5df4e29c2ff88a7", size = 821145, upload_time = "2025-06-20T15:58:18.609Z" }, -] - -[[package]] -name = "lightning-utilities" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "setuptools" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/bb/63a6a8c9e7a96b6ba92647fa5b1595c2dbee29f8178705adb4704d82ecba/lightning_utilities-0.14.3.tar.gz", hash = "sha256:37e2f83f273890052955a44054382c211a303012ee577619efbaa5df9e65e9f5", size = 30346, upload_time = "2025-04-03T15:59:56.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/c1/31b3184cba7b257a4a3b5ca5b88b9204ccb7aa02fe3c992280899293ed54/lightning_utilities-0.14.3-py3-none-any.whl", hash = "sha256:4ab9066aa36cd7b93a05713808901909e96cc3f187ea6fd3052b2fd91313b468", size = 28894, upload_time = "2025-04-03T15:59:55.658Z" }, -] - -[[package]] -name = "llvmlite" -version = "0.44.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload_time = "2025-01-20T11:14:41.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/75/d4863ddfd8ab5f6e70f4504cf8cc37f4e986ec6910f4ef8502bb7d3c1c71/llvmlite-0.44.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614", size = 28132306, upload_time = "2025-01-20T11:12:18.634Z" }, - { url = "https://files.pythonhosted.org/packages/37/d9/6e8943e1515d2f1003e8278819ec03e4e653e2eeb71e4d00de6cfe59424e/llvmlite-0.44.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791", size = 26201096, upload_time = "2025-01-20T11:12:24.544Z" }, - { url = "https://files.pythonhosted.org/packages/aa/46/8ffbc114def88cc698906bf5acab54ca9fdf9214fe04aed0e71731fb3688/llvmlite-0.44.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8", size = 42361859, upload_time = "2025-01-20T11:12:31.839Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/9366b29ab050a726af13ebaae8d0dff00c3c58562261c79c635ad4f5eb71/llvmlite-0.44.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408", size = 41184199, upload_time = "2025-01-20T11:12:40.049Z" }, - { url = "https://files.pythonhosted.org/packages/69/07/35e7c594b021ecb1938540f5bce543ddd8713cff97f71d81f021221edc1b/llvmlite-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2", size = 30332381, upload_time = "2025-01-20T11:12:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e2/86b245397052386595ad726f9742e5223d7aea999b18c518a50e96c3aca4/llvmlite-0.44.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:eed7d5f29136bda63b6d7804c279e2b72e08c952b7c5df61f45db408e0ee52f3", size = 28132305, upload_time = "2025-01-20T11:12:53.936Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ec/506902dc6870249fbe2466d9cf66d531265d0f3a1157213c8f986250c033/llvmlite-0.44.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ace564d9fa44bb91eb6e6d8e7754977783c68e90a471ea7ce913bff30bd62427", size = 26201090, upload_time = "2025-01-20T11:12:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/99/fe/d030f1849ebb1f394bb3f7adad5e729b634fb100515594aca25c354ffc62/llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5d22c3bfc842668168a786af4205ec8e3ad29fb1bc03fd11fd48460d0df64c1", size = 42361858, upload_time = "2025-01-20T11:13:07.623Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7a/ce6174664b9077fc673d172e4c888cb0b128e707e306bc33fff8c2035f0d/llvmlite-0.44.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f01a394e9c9b7b1d4e63c327b096d10f6f0ed149ef53d38a09b3749dcf8c9610", size = 41184200, upload_time = "2025-01-20T11:13:20.058Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c6/258801143975a6d09a373f2641237992496e15567b907a4d401839d671b8/llvmlite-0.44.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8489634d43c20cd0ad71330dde1d5bc7b9966937a263ff1ec1cebb90dc50955", size = 30331193, upload_time = "2025-01-20T11:13:26.976Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload_time = "2025-01-20T11:13:32.57Z" }, - { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload_time = "2025-01-20T11:13:38.744Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload_time = "2025-01-20T11:13:46.711Z" }, - { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload_time = "2025-01-20T11:13:56.159Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload_time = "2025-01-20T11:14:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload_time = "2025-01-20T11:14:09.035Z" }, - { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload_time = "2025-01-20T11:14:15.401Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload_time = "2025-01-20T11:14:22.949Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload_time = "2025-01-20T11:14:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload_time = "2025-01-20T11:14:38.578Z" }, -] - -[[package]] -name = "mako" -version = "1.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload_time = "2025-04-10T12:44:31.16Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload_time = "2025-04-10T12:50:53.297Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload_time = "2024-10-18T15:20:51.44Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload_time = "2024-10-18T15:20:52.426Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload_time = "2024-10-18T15:20:53.578Z" }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload_time = "2024-10-18T15:20:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload_time = "2024-10-18T15:20:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload_time = "2024-10-18T15:20:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload_time = "2024-10-18T15:20:58.235Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload_time = "2024-10-18T15:20:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload_time = "2024-10-18T15:21:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload_time = "2024-10-18T15:21:01.122Z" }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload_time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload_time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload_time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload_time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload_time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload_time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload_time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload_time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload_time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload_time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload_time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload_time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload_time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload_time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload_time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload_time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload_time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload_time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload_time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload_time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "marshmallow" -version = "3.26.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload_time = "2025-02-03T15:32:25.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload_time = "2025-02-03T15:32:22.295Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.10.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/91/d49359a21893183ed2a5b6c76bec40e0b1dcbf8ca148f864d134897cfc75/matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0", size = 34799811, upload_time = "2025-05-08T19:10:54.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ea/2bba25d289d389c7451f331ecd593944b3705f06ddf593fa7be75037d308/matplotlib-3.10.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:213fadd6348d106ca7db99e113f1bea1e65e383c3ba76e8556ba4a3054b65ae7", size = 8167862, upload_time = "2025-05-08T19:09:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/41/81/cc70b5138c926604e8c9ed810ed4c79e8116ba72e02230852f5c12c87ba2/matplotlib-3.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3bec61cb8221f0ca6313889308326e7bb303d0d302c5cc9e523b2f2e6c73deb", size = 8042149, upload_time = "2025-05-08T19:09:42.413Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/0ff45b6bfa42bb16de597e6058edf2361c298ad5ef93b327728145161bbf/matplotlib-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c21ae75651c0231b3ba014b6d5e08fb969c40cdb5a011e33e99ed0c9ea86ecb", size = 8453719, upload_time = "2025-05-08T19:09:44.901Z" }, - { url = "https://files.pythonhosted.org/packages/85/c7/1866e972fed6d71ef136efbc980d4d1854ab7ef1ea8152bbd995ca231c81/matplotlib-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e39755580b08e30e3620efc659330eac5d6534ab7eae50fa5e31f53ee4e30", size = 8590801, upload_time = "2025-05-08T19:09:47.404Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b9/748f6626d534ab7e255bdc39dc22634d337cf3ce200f261b5d65742044a1/matplotlib-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf4636203e1190871d3a73664dea03d26fb019b66692cbfd642faafdad6208e8", size = 9402111, upload_time = "2025-05-08T19:09:49.474Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/8bf07bd8fb67ea5665a6af188e70b57fcb2ab67057daa06b85a08e59160a/matplotlib-3.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:fd5641a9bb9d55f4dd2afe897a53b537c834b9012684c8444cc105895c8c16fd", size = 8057213, upload_time = "2025-05-08T19:09:51.489Z" }, - { url = "https://files.pythonhosted.org/packages/f5/bd/af9f655456f60fe1d575f54fb14704ee299b16e999704817a7645dfce6b0/matplotlib-3.10.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0ef061f74cd488586f552d0c336b2f078d43bc00dc473d2c3e7bfee2272f3fa8", size = 8178873, upload_time = "2025-05-08T19:09:53.857Z" }, - { url = "https://files.pythonhosted.org/packages/c2/86/e1c86690610661cd716eda5f9d0b35eaf606ae6c9b6736687cfc8f2d0cd8/matplotlib-3.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96985d14dc5f4a736bbea4b9de9afaa735f8a0fc2ca75be2fa9e96b2097369d", size = 8052205, upload_time = "2025-05-08T19:09:55.684Z" }, - { url = "https://files.pythonhosted.org/packages/54/51/a9f8e49af3883dacddb2da1af5fca1f7468677f1188936452dd9aaaeb9ed/matplotlib-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5f0283da91e9522bdba4d6583ed9d5521566f63729ffb68334f86d0bb98049", size = 8465823, upload_time = "2025-05-08T19:09:57.442Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e3/c82963a3b86d6e6d5874cbeaa390166458a7f1961bab9feb14d3d1a10f02/matplotlib-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdfa07c0ec58035242bc8b2c8aae37037c9a886370eef6850703d7583e19964b", size = 8606464, upload_time = "2025-05-08T19:09:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/0e/34/24da1027e7fcdd9e82da3194c470143c551852757a4b473a09a012f5b945/matplotlib-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c0b9849a17bce080a16ebcb80a7b714b5677d0ec32161a2cc0a8e5a6030ae220", size = 9413103, upload_time = "2025-05-08T19:10:03.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/da/948a017c3ea13fd4a97afad5fdebe2f5bbc4d28c0654510ce6fd6b06b7bd/matplotlib-3.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:eef6ed6c03717083bc6d69c2d7ee8624205c29a8e6ea5a31cd3492ecdbaee1e1", size = 8065492, upload_time = "2025-05-08T19:10:05.271Z" }, - { url = "https://files.pythonhosted.org/packages/eb/43/6b80eb47d1071f234ef0c96ca370c2ca621f91c12045f1401b5c9b28a639/matplotlib-3.10.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ab1affc11d1f495ab9e6362b8174a25afc19c081ba5b0775ef00533a4236eea", size = 8179689, upload_time = "2025-05-08T19:10:07.602Z" }, - { url = "https://files.pythonhosted.org/packages/0f/70/d61a591958325c357204870b5e7b164f93f2a8cca1dc6ce940f563909a13/matplotlib-3.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a818d8bdcafa7ed2eed74487fdb071c09c1ae24152d403952adad11fa3c65b4", size = 8050466, upload_time = "2025-05-08T19:10:09.383Z" }, - { url = "https://files.pythonhosted.org/packages/e7/75/70c9d2306203148cc7902a961240c5927dd8728afedf35e6a77e105a2985/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748ebc3470c253e770b17d8b0557f0aa85cf8c63fd52f1a61af5b27ec0b7ffee", size = 8456252, upload_time = "2025-05-08T19:10:11.958Z" }, - { url = "https://files.pythonhosted.org/packages/c4/91/ba0ae1ff4b3f30972ad01cd4a8029e70a0ec3b8ea5be04764b128b66f763/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed70453fd99733293ace1aec568255bc51c6361cb0da94fa5ebf0649fdb2150a", size = 8601321, upload_time = "2025-05-08T19:10:14.47Z" }, - { url = "https://files.pythonhosted.org/packages/d2/88/d636041eb54a84b889e11872d91f7cbf036b3b0e194a70fa064eb8b04f7a/matplotlib-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbed9917b44070e55640bd13419de83b4c918e52d97561544814ba463811cbc7", size = 9406972, upload_time = "2025-05-08T19:10:16.569Z" }, - { url = "https://files.pythonhosted.org/packages/b1/79/0d1c165eac44405a86478082e225fce87874f7198300bbebc55faaf6d28d/matplotlib-3.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:cf37d8c6ef1a48829443e8ba5227b44236d7fcaf7647caa3178a4ff9f7a5be05", size = 8067954, upload_time = "2025-05-08T19:10:18.663Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c1/23cfb566a74c696a3b338d8955c549900d18fe2b898b6e94d682ca21e7c2/matplotlib-3.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f2efccc8dcf2b86fc4ee849eea5dcaecedd0773b30f47980dc0cbeabf26ec84", size = 8180318, upload_time = "2025-05-08T19:10:20.426Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0c/02f1c3b66b30da9ee343c343acbb6251bef5b01d34fad732446eaadcd108/matplotlib-3.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddbba06a6c126e3301c3d272a99dcbe7f6c24c14024e80307ff03791a5f294e", size = 8051132, upload_time = "2025-05-08T19:10:22.569Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ab/8db1a5ac9b3a7352fb914133001dae889f9fcecb3146541be46bed41339c/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748302b33ae9326995b238f606e9ed840bf5886ebafcb233775d946aa8107a15", size = 8457633, upload_time = "2025-05-08T19:10:24.749Z" }, - { url = "https://files.pythonhosted.org/packages/f5/64/41c4367bcaecbc03ef0d2a3ecee58a7065d0a36ae1aa817fe573a2da66d4/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80fcccbef63302c0efd78042ea3c2436104c5b1a4d3ae20f864593696364ac7", size = 8601031, upload_time = "2025-05-08T19:10:27.03Z" }, - { url = "https://files.pythonhosted.org/packages/12/6f/6cc79e9e5ab89d13ed64da28898e40fe5b105a9ab9c98f83abd24e46d7d7/matplotlib-3.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55e46cbfe1f8586adb34f7587c3e4f7dedc59d5226719faf6cb54fc24f2fd52d", size = 9406988, upload_time = "2025-05-08T19:10:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/b1/0f/eed564407bd4d935ffabf561ed31099ed609e19287409a27b6d336848653/matplotlib-3.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:151d89cb8d33cb23345cd12490c76fd5d18a56581a16d950b48c6ff19bb2ab93", size = 8068034, upload_time = "2025-05-08T19:10:31.221Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e5/2f14791ff69b12b09e9975e1d116d9578ac684460860ce542c2588cb7a1c/matplotlib-3.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c26dd9834e74d164d06433dc7be5d75a1e9890b926b3e57e74fa446e1a62c3e2", size = 8218223, upload_time = "2025-05-08T19:10:33.114Z" }, - { url = "https://files.pythonhosted.org/packages/5c/08/30a94afd828b6e02d0a52cae4a29d6e9ccfcf4c8b56cc28b021d3588873e/matplotlib-3.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:24853dad5b8c84c8c2390fc31ce4858b6df504156893292ce8092d190ef8151d", size = 8094985, upload_time = "2025-05-08T19:10:35.337Z" }, - { url = "https://files.pythonhosted.org/packages/89/44/f3bc6b53066c889d7a1a3ea8094c13af6a667c5ca6220ec60ecceec2dabe/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f7878214d369d7d4215e2a9075fef743be38fa401d32e6020bab2dfabaa566", size = 8483109, upload_time = "2025-05-08T19:10:37.611Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c7/473bc559beec08ebee9f86ca77a844b65747e1a6c2691e8c92e40b9f42a8/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6929fc618cb6db9cb75086f73b3219bbb25920cb24cee2ea7a12b04971a4158", size = 8618082, upload_time = "2025-05-08T19:10:39.892Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e9/6ce8edd264c8819e37bbed8172e0ccdc7107fe86999b76ab5752276357a4/matplotlib-3.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7818292a5cc372a2dc4c795e5c356942eb8350b98ef913f7fda51fe175ac5d", size = 9413699, upload_time = "2025-05-08T19:10:42.376Z" }, - { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044, upload_time = "2025-05-08T19:10:44.551Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d1/f54d43e95384b312ffa4a74a4326c722f3b8187aaaa12e9a84cdf3037131/matplotlib-3.10.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:86ab63d66bbc83fdb6733471d3bff40897c1e9921cba112accd748eee4bce5e4", size = 8162896, upload_time = "2025-05-08T19:10:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/fbfc00c2346177c95b353dcf9b5a004106abe8730a62cb6f27e79df0a698/matplotlib-3.10.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a48f9c08bf7444b5d2391a83e75edb464ccda3c380384b36532a0962593a1751", size = 8039702, upload_time = "2025-05-08T19:10:49.634Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b9/59e120d24a2ec5fc2d30646adb2efb4621aab3c6d83d66fb2a7a182db032/matplotlib-3.10.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb73d8aa75a237457988f9765e4dfe1c0d2453c5ca4eabc897d4309672c8e014", size = 8594298, upload_time = "2025-05-08T19:10:51.738Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload_time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload_time = "2023-03-07T16:47:09.197Z" }, -] - -[[package]] -name = "msgpack" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload_time = "2025-06-13T06:52:51.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload_time = "2025-06-13T06:51:37.228Z" }, - { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload_time = "2025-06-13T06:51:38.534Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload_time = "2025-06-13T06:51:39.538Z" }, - { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload_time = "2025-06-13T06:51:41.092Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload_time = "2025-06-13T06:51:42.575Z" }, - { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload_time = "2025-06-13T06:51:43.807Z" }, - { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload_time = "2025-06-13T06:51:45.534Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload_time = "2025-06-13T06:51:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload_time = "2025-06-13T06:51:48.582Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload_time = "2025-06-13T06:51:49.558Z" }, - { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload_time = "2025-06-13T06:51:50.68Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload_time = "2025-06-13T06:51:51.72Z" }, - { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload_time = "2025-06-13T06:51:52.749Z" }, - { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload_time = "2025-06-13T06:51:53.97Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload_time = "2025-06-13T06:51:55.507Z" }, - { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload_time = "2025-06-13T06:51:57.023Z" }, - { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload_time = "2025-06-13T06:51:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload_time = "2025-06-13T06:51:59.969Z" }, - { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload_time = "2025-06-13T06:52:01.294Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload_time = "2025-06-13T06:52:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload_time = "2025-06-13T06:52:03.909Z" }, - { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload_time = "2025-06-13T06:52:05.246Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload_time = "2025-06-13T06:52:06.341Z" }, - { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload_time = "2025-06-13T06:52:07.501Z" }, - { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload_time = "2025-06-13T06:52:09.047Z" }, - { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload_time = "2025-06-13T06:52:10.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload_time = "2025-06-13T06:52:11.644Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload_time = "2025-06-13T06:52:12.806Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload_time = "2025-06-13T06:52:14.271Z" }, - { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload_time = "2025-06-13T06:52:15.252Z" }, - { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload_time = "2025-06-13T06:52:16.64Z" }, - { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload_time = "2025-06-13T06:52:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload_time = "2025-06-13T06:52:18.982Z" }, - { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload_time = "2025-06-13T06:52:20.211Z" }, - { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload_time = "2025-06-13T06:52:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload_time = "2025-06-13T06:52:22.995Z" }, - { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload_time = "2025-06-13T06:52:24.152Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload_time = "2025-06-13T06:52:25.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload_time = "2025-06-13T06:52:26.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload_time = "2025-06-13T06:52:27.835Z" }, -] - -[[package]] -name = "multidict" -version = "6.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/6d/84d6dbf9a855c09504bdffd4a2c82c6b82cc7b4d69101b64491873967d88/multidict-6.6.0.tar.gz", hash = "sha256:460b213769cb8691b5ba2f12e53522acd95eb5b2602497d4d7e64069a61e5941", size = 99841, upload_time = "2025-06-27T09:51:54.73Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/fb/3821993b4027c5acf8449789318614ff67da71f4de9d386911eeaf6ba945/multidict-6.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d7913e6d0953b6d65c74290da65bc33d60d32a48bbe0bf2398ea1c5a2626e0b2", size = 76908, upload_time = "2025-06-27T09:49:23.988Z" }, - { url = "https://files.pythonhosted.org/packages/64/e8/641eb9fd4e6691d3c74deae9bb5d1569c722d772c3183f89d4b24f0a1378/multidict-6.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8552e89a546408d3f78f1efd1c48e46077b68e59b6d5607498dd0a44df60b87c", size = 44831, upload_time = "2025-06-27T09:49:26.151Z" }, - { url = "https://files.pythonhosted.org/packages/80/c9/6b87a1562506364145a7ab321c24c48a85e6d584b0abbb5a607480e8f449/multidict-6.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54318d7991887e3e557e71e97fee3fc152db235a26edbbc62079a75e263d8fef", size = 44494, upload_time = "2025-06-27T09:49:27.645Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/4dee445e0987255e5a8318f2c8dd4e42ebfcc28be6ff1b8ad2939c372939/multidict-6.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2cdd2a2b1d35debdc367aca97709d20fc6cfc18e88f5b85a47b478e19b990b54", size = 244954, upload_time = "2025-06-27T09:49:29.245Z" }, - { url = "https://files.pythonhosted.org/packages/8a/bd/4966863765fdd213253eff0555d24a75de49dbe44ab0fbab5b5761cab2ce/multidict-6.6.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0d60aeb062bf15d8ec5ec2547b2f5a06090692b79414c0b26fcc94709e64d650", size = 221809, upload_time = "2025-06-27T09:49:31.017Z" }, - { url = "https://files.pythonhosted.org/packages/46/77/6b76605fe4d102d71c8b1b98f6c3106344459f55dc3538e34073d0b654f1/multidict-6.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6e24583ab8e2b66370edd1a3b6cb2979b4866aff1e73b10bf61e46033c2dc1b", size = 254622, upload_time = "2025-06-27T09:49:32.362Z" }, - { url = "https://files.pythonhosted.org/packages/c0/37/d0e9dd5fb3cfd3db4062817e50ec6ecd017dbc6614cb5c09e49bf469e767/multidict-6.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d710b49cdf38e158ba9ba6819ea9bf1041e87e3d36abcd577d2836b51a7eb373", size = 250788, upload_time = "2025-06-27T09:49:34.376Z" }, - { url = "https://files.pythonhosted.org/packages/fe/19/c1603c63be00df967093b86cabb5cdc264e1f162eedae7731c2db1142309/multidict-6.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f812ed66bfd06b7d67a1f3d46b1644b88bdfe8aea6b290a1411ab08bcd93f08a", size = 243439, upload_time = "2025-06-27T09:49:36.375Z" }, - { url = "https://files.pythonhosted.org/packages/8c/99/23c6d819905ab01a4a37590c98a6ea04d65515f04ed76b9bc2c179553163/multidict-6.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7b62dc87d3a55d0e9753f5afdd7df67a5fb8ef1b43e449b9a8a2c4b8f71ecf1f", size = 240733, upload_time = "2025-06-27T09:49:37.721Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1f/53d45fa11f9d1ea2bb02143b6615adbf142615517e7a99dd6097dc4ac7ee/multidict-6.6.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b524f005fc749bec8fd0997aff1de72be136d7fe8a528062f779f659765071fc", size = 233989, upload_time = "2025-06-27T09:49:39.494Z" }, - { url = "https://files.pythonhosted.org/packages/35/20/ea9e4ccd734fae5484cf23834efc9e5fa52c6fdc9b98e31d9e9018d1d01a/multidict-6.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3f153e2cb8a5a9b34c95ffcdcc3eed0d62ea4b48a5c668b818c3d03c58061296", size = 250909, upload_time = "2025-06-27T09:49:41.189Z" }, - { url = "https://files.pythonhosted.org/packages/67/80/e02b26ef75703ecc65cc1ccd09e08a605024da24ffe0d6e1a28b38a70f7a/multidict-6.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1b23869a750e9cb32b2c4a95edf081adc45cc684d4f8ebe0c15f830d5cb0e878", size = 242628, upload_time = "2025-06-27T09:49:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/9d/99/6e58d1795ac200b31bada5d2c98fcebb7cb0ef76468a9def74d522c96c25/multidict-6.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2fe3aa2280cd573eb26afa6c9030e66a6394c763f5325399d4cae76fca24c758", size = 239149, upload_time = "2025-06-27T09:49:43.964Z" }, - { url = "https://files.pythonhosted.org/packages/19/3e/220977ae6007b0a953b550148d696eb2a03f98501b3f7242d0240999d7cd/multidict-6.6.0-cp310-cp310-win32.whl", hash = "sha256:3234b25ccf0d90666f10fceb2a8ae9d9a47b5d4e1e94eb32924d42e2ae369e74", size = 41454, upload_time = "2025-06-27T09:49:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/4f48a9aa8fbfba2414b9b47e509191d20fed9d8246bf311d9e106474d9db/multidict-6.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:bd58e43381f943f9d613c87bf0f1cf7340964dd2bea86e3f7a21c81c50bbc9fb", size = 45369, upload_time = "2025-06-27T09:49:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/f4/24/b116b5a78fc247c7005d54f809f695042c3ff8a98a4d9cc8cdcceabb9106/multidict-6.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7ee8eed2ba1e46d7f60a2ec5d9866285daec3c7e0685dcfa5dbfd0ed6a173d0", size = 43080, upload_time = "2025-06-27T09:49:47.937Z" }, - { url = "https://files.pythonhosted.org/packages/8b/8e/2a652624dae24b4e94e17794a2fd3d3f0cb0e6276829052b4c5b1a4a7226/multidict-6.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5eb5444dd0dc4c2e0f180d7e216fe2a713d45b5648fec2832ff4a78100270d6a", size = 76355, upload_time = "2025-06-27T09:49:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/56/9a/9b1ce7353c8a0da1ff682740c58273daa42a748c7757f41e61e824305656/multidict-6.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:522cafe660896c471fc667c53d5081416c435a7ab88e183d8bcd75c6f993fb27", size = 44561, upload_time = "2025-06-27T09:49:50.256Z" }, - { url = "https://files.pythonhosted.org/packages/00/6d/99f8b848b8b1297692b22f56de50fb79c7d3efabfae042a4efef5b956325/multidict-6.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b4898814f97d28c2a6a5989cb605840ad0545a8f2bad38a5d3a75071b673ec6", size = 44222, upload_time = "2025-06-27T09:49:51.403Z" }, - { url = "https://files.pythonhosted.org/packages/71/61/8cd3c9cb51641ef2a2aa69cd5e724fdab1c6d5c7ad6919399d44faada723/multidict-6.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec93a0f75742ffcb14a0c15dedcafb37e69860a76fc009d0463c534701443f2f", size = 248242, upload_time = "2025-06-27T09:49:52.731Z" }, - { url = "https://files.pythonhosted.org/packages/ae/5c/c1e469a4c7d700d4ddbfbf50dfc8bdd61626ca67f95180074cc93ac354b2/multidict-6.6.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db158941bbed55f980a30125cc9d027f272af76e11f4c7204e3c458c277a5098", size = 224761, upload_time = "2025-06-27T09:49:54.055Z" }, - { url = "https://files.pythonhosted.org/packages/27/76/04cd7fa6df2bec67aed1e920250af99bef637a17c35d7011a8e08cc9a088/multidict-6.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:561164b6e0998a49b72b17dd9f484ef785bcf836a5ce525b58a0970c563cbb6e", size = 257772, upload_time = "2025-06-27T09:49:55.845Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/3612caeb061645b83871b82d4eaa3025898443e94952309ca373e4a3ee99/multidict-6.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aed62dc3bf5bba3c64f123e15d05005e22a18b3d95b990996b1c3a9aa12c4611", size = 255327, upload_time = "2025-06-27T09:49:57.271Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f1/dee9537a66a85b793f17c24bea64d2d0eecc160a8867ffdb27a9de779e9e/multidict-6.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c38f0b501487246b1ac68cd6159459789af9f95ac6b35eb14f7f74e41b3f8eb5", size = 247179, upload_time = "2025-06-27T09:49:58.743Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f6/a7f650c14963ed642383e218ae5f91503810367e095c1090e6b583dc3326/multidict-6.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5737e9abbde209f7f9805fed605f9623d65b7822bfa9e18cb0f94b6f8fa6c0fd", size = 244077, upload_time = "2025-06-27T09:50:00.109Z" }, - { url = "https://files.pythonhosted.org/packages/83/fc/4cab751b313354fa3c061aad91576f8ab4d265c33491e46156de85951dbd/multidict-6.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8fad001e4fbda4a14f6f6466e78c73f51dad18da0a831378a564050b9790b7de", size = 238920, upload_time = "2025-06-27T09:50:01.876Z" }, - { url = "https://files.pythonhosted.org/packages/37/fb/bc11bf8c12c62df7a5616d79e443322c6d29eb7d487af37c697a16a8ade1/multidict-6.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0c9e7ce1fff62bd094b5adb349336fc965e29ae401e0db413986a85cfbfeb11d", size = 254293, upload_time = "2025-06-27T09:50:03.336Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/ce6ab86c41d48f38370fadebf7ba5ff1ea5a6c4fa1cc765b4688c3872ffc/multidict-6.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1f9fb3a923d84843807a24f0250028f5802e97469c496a6ed0eee9ef7ed455a2", size = 247190, upload_time = "2025-06-27T09:50:04.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/cb/1c35255028b3aeda8c2876ff8b8b4f8b04d1f28a6a5fcccb0c9a02886792/multidict-6.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50f62cd84cf042a7d586759bc83059d1c2b1c00ae3f2481d112cdf711e6cb15c", size = 242926, upload_time = "2025-06-27T09:50:06.112Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/503da6e5a176a6b2b14c228716f1b080214d7a1239d7a8fbfb871e437767/multidict-6.6.0-cp311-cp311-win32.whl", hash = "sha256:855fc84169a98ee9dde3805716c3a18959a8803069866e48512edd6a5a59fffc", size = 41352, upload_time = "2025-06-27T09:50:07.416Z" }, - { url = "https://files.pythonhosted.org/packages/8a/31/10955118cbc4dcf0c8579f1c9b7c212780651e8de628b66d61654fe784cc/multidict-6.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:e86d6f67647159f6b96df10504b7f00c17f12370588ea7202b78fc3867d1c900", size = 45379, upload_time = "2025-06-27T09:50:08.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/eb/f69ee7bdd3e26c66711d208f7becad87c7f75d364b47efd040f5e8b9757e/multidict-6.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:afbb6d962c355863a6f39a1558db875fcaa0cc1116acbb7086e8fa0e86a642ed", size = 43004, upload_time = "2025-06-27T09:50:09.687Z" }, - { url = "https://files.pythonhosted.org/packages/32/7b/767bd6b1b0565ac04070222e42c66dbfe7d1c3215a218db3e0e5ca878b41/multidict-6.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0b95809f30d197efa899b5e08a38cf8d6658f3acfa5f5c984e0fe6bc21245aeb", size = 76514, upload_time = "2025-06-27T09:50:10.915Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8f/2bd636957abb149b55c42baf96cb6be06c884fae7729bf27280cf1005d8a/multidict-6.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c146b37f0a719df5f82e0dccc2ecbcbcccae75e762d696b5b26990aef65e6ac4", size = 45355, upload_time = "2025-06-27T09:50:12.431Z" }, - { url = "https://files.pythonhosted.org/packages/80/54/6fa0de18d4da8011cb00def260b0f7632900d7549f59b55228c9c9be26ef/multidict-6.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d36d3cd27eba1f7aa209839ccce79b9601abbd82e9b503f33f16652072e347da", size = 43613, upload_time = "2025-06-27T09:50:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/2f/73/ee599e249ccad06f2dcfdcdb87d4f30a7386128ccb601e6f39609f31949a/multidict-6.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2e1676ed48d42e3db21a18030a149bff12ed564185453777814722ec8c67f26", size = 256970, upload_time = "2025-06-27T09:50:14.942Z" }, - { url = "https://files.pythonhosted.org/packages/ee/96/f36dd4b3ff52e52befda68bc5c46c15e93c0f11edc60b184cbe72e6aff56/multidict-6.6.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1201db24a4b55921cf5db90cbd9a31a44c0bb2eba8ee5f50e330c0b2080fa00", size = 241875, upload_time = "2025-06-27T09:50:16.33Z" }, - { url = "https://files.pythonhosted.org/packages/4a/77/63d7057fab7b5a0b3d50d21b24b17ea8b66d5b06b2cfd0d8e83befc45f9e/multidict-6.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a2a7242da589b5879857646847e806dad51b6de6fab8de3c0330ea60656d915", size = 267398, upload_time = "2025-06-27T09:50:17.792Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/39d3b8769b0e72f30b62e7b5f0c38d4ce98d7da485517ed8aae50ea57e6b/multidict-6.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8175c3ec6a7ed880ccf576a80a95f2b559a97158662698db6c8fbeffdf982123", size = 268908, upload_time = "2025-06-27T09:50:19.191Z" }, - { url = "https://files.pythonhosted.org/packages/d3/15/bea3b7376dbb70e8c2fa413655890a5062988746cc42501f01f194adfa8d/multidict-6.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a5e7c0e6ef7e98ea7601c672f067e491bd286531293c62930b10ade50120af2", size = 256905, upload_time = "2025-06-27T09:50:20.575Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9e/e989430e46877ca9cf9ab6224b3616250b4aacb129d27f91f9347fbe0bfa/multidict-6.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cfb725d2379d7c54958cce23a0fd8ff5b3d8dd1f4e2741a44a548eddefad6eae", size = 252221, upload_time = "2025-06-27T09:50:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/2ac4c1ad6ccc6e8227fdc993d494a2a8f2d379dc6c2d5dc0a3b4330a2cd4/multidict-6.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6dbff377ce9e67a5cae6c5989a4963816d70d52a9f6bf01dd04aadaa9ca31dba", size = 249186, upload_time = "2025-06-27T09:50:24.574Z" }, - { url = "https://files.pythonhosted.org/packages/22/3f/3f21091cbb14fc333949bed0a481a3f9061199ef2a3f7b341a6d48bf1bc7/multidict-6.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b04670b6d3251dfc1761e8a8c58cd1ccb28c1fc8041ed7dc0b1e741bd7753b02", size = 262862, upload_time = "2025-06-27T09:50:26.066Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ab/384b7afc28869dbd34bea5c97ecd6cbfe467a928fe189f7018cc67db2ebc/multidict-6.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:20da2c7faa1bddc3fda31258debcbcc7033f33094f4d89b3b6269570bd7b132d", size = 258965, upload_time = "2025-06-27T09:50:27.589Z" }, - { url = "https://files.pythonhosted.org/packages/16/2f/ed01b63b4da243f76ca69157d9ed708598914306883330c8d18fa853425a/multidict-6.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7a848558168b6c39bca54c57dacc27eac708b479b1ff92469a7465ead6619334", size = 252138, upload_time = "2025-06-27T09:50:29.04Z" }, - { url = "https://files.pythonhosted.org/packages/bd/d1/ca152a9b8cd23811e316effe4e9bf74606ac45b50bb6e435ed4ac782637c/multidict-6.6.0-cp312-cp312-win32.whl", hash = "sha256:a066dc45b29ce247a2ddbccc2cf20ce99f95e849a7624cf3cdfd7d50b1261098", size = 41966, upload_time = "2025-06-27T09:50:30.684Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c8/df3e38a1d9e4ce125ebf2f025e8db4032d0f1a534c4f8179ac51e5b3cced/multidict-6.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:74fa779e729bb20dd7ce9bbc2b4b704f4134b6763ea8f4a13d259aed044812fd", size = 45586, upload_time = "2025-06-27T09:50:31.846Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3a/bccfbbaed68aec312e6c187c570943a63a7fad328198b5cd608718884108/multidict-6.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:860ddc224123efb788812f16329d629722c68ca687c0d4410f4ad26a9197cc73", size = 43279, upload_time = "2025-06-27T09:50:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/8a/10/5d58c3739adc1b1322df7300ec0b40fba13a138b292fa350b59ab8329783/multidict-6.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e26114b8e3da8137bb39e2820eef09005c0ab468b2cca384f429a2104c48f6d1", size = 75827, upload_time = "2025-06-27T09:50:34.37Z" }, - { url = "https://files.pythonhosted.org/packages/14/11/713fd1b5cff3ae3a3d458073460d1efe33b469da079daca1cc2706a25e96/multidict-6.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bf72082eba16b22f63ef8553e1d245c56bf92868976f089ae3f572e91e2dd197", size = 45012, upload_time = "2025-06-27T09:50:35.607Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/9518933da0bdec068ed16ea9bead13a9d5e1bc8584af329f242ba4886395/multidict-6.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57afe4cdc5ee0c001af224f259a20b906df8ddbb9b9af932817a374bf98cd857", size = 43279, upload_time = "2025-06-27T09:50:37.183Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/28f3bb3c8ad6c74f78cba89e5ace84c026b331647dde7f1f32dc6ad018c5/multidict-6.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d18cde7f12df1f9d42bafbe01ed0af48e8f6605ee632aaf3788ada861193175", size = 255396, upload_time = "2025-06-27T09:50:38.524Z" }, - { url = "https://files.pythonhosted.org/packages/77/ef/13f4031ba9d4407e3042bf4d19b89a4c27d3e381a8b122b48a3755fcd43d/multidict-6.6.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:11ccf3fa5cdf0475706307be90ab60bb1865cd8814c7cac6f3c9e54dda094a57", size = 239929, upload_time = "2025-06-27T09:50:39.919Z" }, - { url = "https://files.pythonhosted.org/packages/3a/0d/7b5c3deeb4bdb44b91b56b4a317af54bafa1d697eaff30a6eb16e3d81f06/multidict-6.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:690e7fd86c1def94f080ce514922fb6b62b6327ab10b229e1a8a7ecfc4e88200", size = 266139, upload_time = "2025-06-27T09:50:41.466Z" }, - { url = "https://files.pythonhosted.org/packages/82/b7/8a64535737ed19211fa7cbc76635bd1fea50665a9d6d293b63791ec2e746/multidict-6.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c92cb8bc15c3152ccdb53093c12eb56e661bf404f5674c055007dc979c869f7", size = 267222, upload_time = "2025-06-27T09:50:43.081Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d2/05a85c85f3be3f3130d6d029c280d61965a96d019f42adbb03eb95bbbe6f/multidict-6.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:760a4970d6ce435b0c71a68c4a86fcd0fad50c760c948891d60af4d3486401f6", size = 254095, upload_time = "2025-06-27T09:50:44.502Z" }, - { url = "https://files.pythonhosted.org/packages/76/cd/1b667e7f56e0970310f646d29a02657db5105eb33b1de5509aa543da5216/multidict-6.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:606b94703e1949fd0044ea72aab11a7b9d92492e86fd5886c099d1a7655961ca", size = 250780, upload_time = "2025-06-27T09:50:46.094Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/72d7fc97b88a594bfb3d5415829833dd77bce6ae505c94e3ca21d358a7b3/multidict-6.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9c73131cd1f46669c9b28632be3ee3be611aef38c0fe5ee9f8d5632e9722229f", size = 249031, upload_time = "2025-06-27T09:50:47.668Z" }, - { url = "https://files.pythonhosted.org/packages/05/49/a892295218fc986884df7b99ec53411086d6c5137bc221f5791d7190b744/multidict-6.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3f76f25eea979b6e39993380acb56422eb8a10c44e13ef4f5d3c82c797cb157d", size = 261192, upload_time = "2025-06-27T09:50:49.195Z" }, - { url = "https://files.pythonhosted.org/packages/ec/68/0ecea658316bd826e666eb309c27f4b9d6635ff41e7d1426ba4c709b2c78/multidict-6.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b9a1135f8a0bf7959fb03bca6b98308521cecc6883e4a334a9ae4edecf3d90c", size = 257521, upload_time = "2025-06-27T09:50:50.802Z" }, - { url = "https://files.pythonhosted.org/packages/bb/98/e465b36fdd2bd80781ad98303f9a804f5c916d592aa055210dca3f16a519/multidict-6.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ff8f1043a727649ce698642065b279ee18b36e0d7cbdb7583d7edac6ae804392", size = 249403, upload_time = "2025-06-27T09:50:52.437Z" }, - { url = "https://files.pythonhosted.org/packages/b7/9e/0a2063333cd39287fb8497713b186b6d86bfbb3a64a67defbf849d7871a3/multidict-6.6.0-cp313-cp313-win32.whl", hash = "sha256:e53dcb79923cc0c7ef0ac41aac6e4ea4cf8aa1c7bc7f354c014cf386e9c28639", size = 41776, upload_time = "2025-06-27T09:50:53.887Z" }, - { url = "https://files.pythonhosted.org/packages/1e/67/8d029a8577e29181da4d7504c2d4be43a15ca8179c1e0e27f008645b0232/multidict-6.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:c0ac2049db3dca5fade0390817f94e1945e248297c90bf0b7596127105f3f54f", size = 45401, upload_time = "2025-06-27T09:50:55.563Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e1/b1b921846eb50c76cca9bb4b1e05438e71c5bbfd1be5240c2e98bc44d98b/multidict-6.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:fe16f2823f50a10f13cf094cc09c9e76c3b483064975c482eda0d830175746bc", size = 43097, upload_time = "2025-06-27T09:50:56.99Z" }, - { url = "https://files.pythonhosted.org/packages/01/96/11dec4734a699357b9f1f5217047011e22c3c04ef8c0daafbdb4914fbd9b/multidict-6.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:55243ada517cd453ede3be68ab65260af5389adcb8be5f4c1c7cdec63bbeef5d", size = 82775, upload_time = "2025-06-27T09:50:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0b/4128fb611bcd0045d29cd51e214f475529d425ac0c316d22e52090ff7860/multidict-6.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d614de950f7dd9d295590a5b3017dd1f0a5278a97d15a10d037a2f24e7f6d65b", size = 48329, upload_time = "2025-06-27T09:50:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c2/460deaf50a11df6fadf10b88739f58c8443b30b7ae7c650b83a0741379a1/multidict-6.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d12ce09473c3f497d8944c210899043686f88b811970edc5eb6486f413caa267", size = 46695, upload_time = "2025-06-27T09:51:00.916Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fe/8c84812a9d42f86722dc421df906f427d6ee7a670267e5c53e63ef4dc284/multidict-6.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5a2c6f673c0b5f8bd1049208a313d7e038972aa2ab898bd486f1d29a8c62130", size = 249833, upload_time = "2025-06-27T09:51:02.39Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/3435951b9f940a3e574f2b514e938811aa41fd696a10a9d0ea69db4986a7/multidict-6.6.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ff27fc5526b8740735612ea32d8fab2f79e83824b8f9e7f2b88c9e1db28d6f79", size = 228800, upload_time = "2025-06-27T09:51:03.97Z" }, - { url = "https://files.pythonhosted.org/packages/e6/17/a1f2fe66ee547152d6bfefb3654b2df3730fabdfea8c0d9f30459e6dc8c0/multidict-6.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:279bfd45fecc0d9cdb6926b2a58381cae0514689d6fab67e39a88304301da90a", size = 256563, upload_time = "2025-06-27T09:51:05.773Z" }, - { url = "https://files.pythonhosted.org/packages/57/f1/4ec89ff9d74bbd8e4ab8c7808e630773dd91151e1f08ec88d052e870319f/multidict-6.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b28f421e6f8b444f636bbf4b99e01db5adeb673691ebb764eb39c17dc64179cd", size = 256001, upload_time = "2025-06-27T09:51:07.324Z" }, - { url = "https://files.pythonhosted.org/packages/5c/3e/7b69b5a51db23f5a6464801982ea98c3d9ad1dc855c5fc5cc481d43bc3fe/multidict-6.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11537e9e25241a98746f265230569d7230ad2d8f0d26e863f974e1c991ff5a45", size = 246732, upload_time = "2025-06-27T09:51:09.198Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/a9f4ab7806cc7252c6b177daa426091497fbdf4f043564de19cedbcd4689/multidict-6.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e5b1647506370075513fb19424141853f5cc68dbba38559655dcaafce4d99f27", size = 244897, upload_time = "2025-06-27T09:51:10.793Z" }, - { url = "https://files.pythonhosted.org/packages/e2/93/14c7500f717958a2a6af78f94326a4792495af51ec7c65d0f7e0bad35d99/multidict-6.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fe2bab539a912c3aa24dd3f96e4f6a45b9fac819184fa1d09aec8f289bd7f3ab", size = 234065, upload_time = "2025-06-27T09:51:12.625Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/2eb2ceeaf0fc91b8edaa2aa4f2b76d82f8d41705b76b4d47b4b002e0da88/multidict-6.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:9d30a1ef323867e71e96c62434cc52b072160e4f9be0169ec2fea516d61003dd", size = 251228, upload_time = "2025-06-27T09:51:14.175Z" }, - { url = "https://files.pythonhosted.org/packages/5e/05/f8984acea1a76929cc84a9c8a927f8c756e23be1d11da725b56c2d249f8d/multidict-6.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0b9cc871bc3e580224f9f3c0cd172a1d91e5f4e6c1164d039e3e6f9542f09bf3", size = 245416, upload_time = "2025-06-27T09:51:17.252Z" }, - { url = "https://files.pythonhosted.org/packages/10/7b/1f8fb6487bb5e7cb1e824cc54e93dabda7bf8aadd87a6d7e1c7f82e114b5/multidict-6.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aa98b25a25eaefd8728cffab14066bdc10b30168d4dd32039c5191d2dc863631", size = 241841, upload_time = "2025-06-27T09:51:19.075Z" }, - { url = "https://files.pythonhosted.org/packages/59/30/5f1b87484a85e2a1e245e49b8533016164852f69a68d00d538a9c4ec5a62/multidict-6.6.0-cp313-cp313t-win32.whl", hash = "sha256:b62d2907e8014c3e65b7725271029085aaf8885d34f5bab526cd960bcf40905f", size = 47755, upload_time = "2025-06-27T09:51:20.497Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a3/a21a783d10ec1132e81ea808fd2977838ae01e06377991e3d1308e86e47a/multidict-6.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:954591356227721d7557a9f9ea0f80235608f2dc99c5bb1869f654e890528358", size = 52897, upload_time = "2025-06-27T09:51:21.79Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c7/103af64747f755681e7ee6077a558f8aeaa689504d191fca4b12df75e8c7/multidict-6.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:14b3d44838170996d217b168de2c9dd1cefbb9de6a18c8cfd07cec141b489e41", size = 45329, upload_time = "2025-06-27T09:51:23.935Z" }, - { url = "https://files.pythonhosted.org/packages/b8/8a/35b72900b432516674bef955c2b41100a45a735f0ac5085eb2acbfcd5465/multidict-6.6.0-py3-none-any.whl", hash = "sha256:447df643754e273681fda37764a89880d32c86cab102bfc05c1e8359ebcf0980", size = 12297, upload_time = "2025-06-27T09:51:53.07Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "narwhals" -version = "1.48.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/cd/7395d6c247e821cba6243e9f7ed202fae3fefef643c96581b5ecab927bad/narwhals-1.48.0.tar.gz", hash = "sha256:7243b456cbdb60edb148731a8f9b203f473a373a249ad66c699362508730e63f", size = 515112, upload_time = "2025-07-21T10:06:08.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/72/5406044d4c251f3d8f78cec05b74839d0332d34c9e94b59120f3697ecf48/narwhals-1.48.0-py3-none-any.whl", hash = "sha256:2bbddc3adeed0c5b15ead8fe61f1d5e459f00c1d2fa60921e52a0f9bdc06077d", size = 376866, upload_time = "2025-07-21T10:06:06.561Z" }, -] - -[[package]] -name = "networkx" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload_time = "2024-10-21T12:39:38.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload_time = "2024-10-21T12:39:36.247Z" }, -] - -[[package]] -name = "networkx" -version = "3.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload_time = "2025-05-29T11:35:07.804Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload_time = "2025-05-29T11:35:04.961Z" }, -] - -[[package]] -name = "numba" -version = "0.61.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload_time = "2025-04-09T02:58:07.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/ca/f470be59552ccbf9531d2d383b67ae0b9b524d435fb4a0d229fef135116e/numba-0.61.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a", size = 2775663, upload_time = "2025-04-09T02:57:34.143Z" }, - { url = "https://files.pythonhosted.org/packages/f5/13/3bdf52609c80d460a3b4acfb9fdb3817e392875c0d6270cf3fd9546f138b/numba-0.61.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd", size = 2778344, upload_time = "2025-04-09T02:57:36.609Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/bfb2805bcfbd479f04f835241ecf28519f6e3609912e3a985aed45e21370/numba-0.61.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642", size = 3824054, upload_time = "2025-04-09T02:57:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/e3/27/797b2004745c92955470c73c82f0e300cf033c791f45bdecb4b33b12bdea/numba-0.61.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2", size = 3518531, upload_time = "2025-04-09T02:57:39.709Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c6/c2fb11e50482cb310afae87a997707f6c7d8a48967b9696271347441f650/numba-0.61.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9", size = 2831612, upload_time = "2025-04-09T02:57:41.559Z" }, - { url = "https://files.pythonhosted.org/packages/3f/97/c99d1056aed767503c228f7099dc11c402906b42a4757fec2819329abb98/numba-0.61.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:efd3db391df53aaa5cfbee189b6c910a5b471488749fd6606c3f33fc984c2ae2", size = 2775825, upload_time = "2025-04-09T02:57:43.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/9e/63c549f37136e892f006260c3e2613d09d5120672378191f2dc387ba65a2/numba-0.61.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49c980e4171948ffebf6b9a2520ea81feed113c1f4890747ba7f59e74be84b1b", size = 2778695, upload_time = "2025-04-09T02:57:44.968Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/8740616c8436c86c1b9a62e72cb891177d2c34c2d24ddcde4c390371bf4c/numba-0.61.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3945615cd73c2c7eba2a85ccc9c1730c21cd3958bfcf5a44302abae0fb07bb60", size = 3829227, upload_time = "2025-04-09T02:57:46.63Z" }, - { url = "https://files.pythonhosted.org/packages/fc/06/66e99ae06507c31d15ff3ecd1f108f2f59e18b6e08662cd5f8a5853fbd18/numba-0.61.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbfdf4eca202cebade0b7d43896978e146f39398909a42941c9303f82f403a18", size = 3523422, upload_time = "2025-04-09T02:57:48.222Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a4/2b309a6a9f6d4d8cfba583401c7c2f9ff887adb5d54d8e2e130274c0973f/numba-0.61.2-cp311-cp311-win_amd64.whl", hash = "sha256:76bcec9f46259cedf888041b9886e257ae101c6268261b19fda8cfbc52bec9d1", size = 2831505, upload_time = "2025-04-09T02:57:50.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload_time = "2025-04-09T02:57:51.857Z" }, - { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload_time = "2025-04-09T02:57:53.658Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload_time = "2025-04-09T02:57:55.206Z" }, - { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload_time = "2025-04-09T02:57:56.818Z" }, - { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload_time = "2025-04-09T02:57:58.45Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload_time = "2025-04-09T02:57:59.96Z" }, - { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload_time = "2025-04-09T02:58:01.435Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload_time = "2025-04-09T02:58:02.933Z" }, - { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload_time = "2025-04-09T02:58:04.538Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload_time = "2025-04-09T02:58:06.125Z" }, -] - -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload_time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload_time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload_time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload_time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload_time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload_time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload_time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload_time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload_time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload_time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload_time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload_time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload_time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload_time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload_time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload_time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload_time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload_time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload_time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload_time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload_time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload_time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload_time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload_time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload_time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload_time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload_time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload_time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload_time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload_time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload_time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload_time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload_time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload_time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload_time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload_time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload_time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload_time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload_time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload_time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload_time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload_time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload_time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload_time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload_time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload_time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload_time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload_time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload_time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload_time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload_time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload_time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload_time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload_time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload_time = "2025-05-17T21:45:31.426Z" }, -] - -[[package]] -name = "nvidia-cublas-cu12" -version = "12.6.4.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload_time = "2024-11-20T17:40:25.65Z" }, - { url = "https://files.pythonhosted.org/packages/97/0d/f1f0cadbf69d5b9ef2e4f744c9466cb0a850741d08350736dfdb4aa89569/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668", size = 390794615, upload_time = "2024-11-20T17:39:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/84/f7/985e9bdbe3e0ac9298fcc8cfa51a392862a46a0ffaccbbd56939b62a9c83/nvidia_cublas_cu12-12.6.4.1-py3-none-win_amd64.whl", hash = "sha256:9e4fa264f4d8a4eb0cdbd34beadc029f453b3bafae02401e999cf3d5a5af75f8", size = 434535301, upload_time = "2024-11-20T17:50:41.681Z" }, -] - -[[package]] -name = "nvidia-cuda-cupti-cu12" -version = "12.6.80" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8b/2f6230cb715646c3a9425636e513227ce5c93c4d65823a734f4bb86d43c3/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:166ee35a3ff1587f2490364f90eeeb8da06cd867bd5b701bf7f9a02b78bc63fc", size = 8236764, upload_time = "2024-11-20T17:35:41.03Z" }, - { url = "https://files.pythonhosted.org/packages/25/0f/acb326ac8fd26e13c799e0b4f3b2751543e1834f04d62e729485872198d4/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.whl", hash = "sha256:358b4a1d35370353d52e12f0a7d1769fc01ff74a191689d3870b2123156184c4", size = 8236756, upload_time = "2024-10-01T16:57:45.507Z" }, - { url = "https://files.pythonhosted.org/packages/49/60/7b6497946d74bcf1de852a21824d63baad12cd417db4195fc1bfe59db953/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6768bad6cab4f19e8292125e5f1ac8aa7d1718704012a0e3272a6f61c4bce132", size = 8917980, upload_time = "2024-11-20T17:36:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/120ee57b218d9952c379d1e026c4479c9ece9997a4fb46303611ee48f038/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a3eff6cdfcc6a4c35db968a06fcadb061cbc7d6dde548609a941ff8701b98b73", size = 8917972, upload_time = "2024-10-01T16:58:06.036Z" }, - { url = "https://files.pythonhosted.org/packages/1c/81/7796f096afaf726796b1b648f3bc80cafc61fe7f77f44a483c89e6c5ef34/nvidia_cuda_cupti_cu12-12.6.80-py3-none-win_amd64.whl", hash = "sha256:bbe6ae76e83ce5251b56e8c8e61a964f757175682bbad058b170b136266ab00a", size = 5724175, upload_time = "2024-10-01T17:09:47.955Z" }, -] - -[[package]] -name = "nvidia-cuda-nvrtc-cu12" -version = "12.6.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/2f/72df534873235983cc0a5371c3661bebef7c4682760c275590b972c7b0f9/nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5847f1d6e5b757f1d2b3991a01082a44aad6f10ab3c5c0213fa3e25bddc25a13", size = 23162955, upload_time = "2024-10-01T16:59:50.922Z" }, - { url = "https://files.pythonhosted.org/packages/75/2e/46030320b5a80661e88039f59060d1790298b4718944a65a7f2aeda3d9e9/nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53", size = 23650380, upload_time = "2024-10-01T17:00:14.643Z" }, - { url = "https://files.pythonhosted.org/packages/f5/46/d3a1cdda8bb113c80f43a0a6f3a853356d487b830f3483f92d49ce87fa55/nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:f7007dbd914c56bd80ea31bc43e8e149da38f68158f423ba845fc3292684e45a", size = 39026742, upload_time = "2024-10-01T17:10:49.058Z" }, -] - -[[package]] -name = "nvidia-cuda-runtime-cu12" -version = "12.6.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ea/590b2ac00d772a8abd1c387a92b46486d2679ca6622fd25c18ff76265663/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6116fad3e049e04791c0256a9778c16237837c08b27ed8c8401e2e45de8d60cd", size = 908052, upload_time = "2024-11-20T17:35:19.905Z" }, - { url = "https://files.pythonhosted.org/packages/b7/3d/159023799677126e20c8fd580cca09eeb28d5c5a624adc7f793b9aa8bbfa/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d461264ecb429c84c8879a7153499ddc7b19b5f8d84c204307491989a365588e", size = 908040, upload_time = "2024-10-01T16:57:22.221Z" }, - { url = "https://files.pythonhosted.org/packages/e1/23/e717c5ac26d26cf39a27fbc076240fad2e3b817e5889d671b67f4f9f49c5/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba3b56a4f896141e25e19ab287cd71e52a6a0f4b29d0d31609f60e3b4d5219b7", size = 897690, upload_time = "2024-11-20T17:35:30.697Z" }, - { url = "https://files.pythonhosted.org/packages/f0/62/65c05e161eeddbafeca24dc461f47de550d9fa8a7e04eb213e32b55cfd99/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a84d15d5e1da416dd4774cb42edf5e954a3e60cc945698dc1d5be02321c44dc8", size = 897678, upload_time = "2024-10-01T16:57:33.821Z" }, - { url = "https://files.pythonhosted.org/packages/fa/76/4c80fa138333cc975743fd0687a745fccb30d167f906f13c1c7f9a85e5ea/nvidia_cuda_runtime_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:86c58044c824bf3c173c49a2dbc7a6c8b53cb4e4dca50068be0bf64e9dab3f7f", size = 891773, upload_time = "2024-10-01T17:09:26.362Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu12" -version = "9.5.1.17" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/93/a201a12d3ec1caa8c6ac34c1c2f9eeb696b886f0c36ff23c638b46603bd0/nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9fd4584468533c61873e5fda8ca41bac3a38bcb2d12350830c69b0a96a7e4def", size = 570523509, upload_time = "2024-10-25T19:53:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/4535c9c7f859a64781e43c969a3a7e84c54634e319a996d43ef32ce46f83/nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2", size = 570988386, upload_time = "2024-10-25T19:54:26.39Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b2/3f60d15f037fa5419d9d7f788b100ef33ea913ae5315c87ca6d6fa606c35/nvidia_cudnn_cu12-9.5.1.17-py3-none-win_amd64.whl", hash = "sha256:d7af0f8a4f3b4b9dbb3122f2ef553b45694ed9c384d5a75bab197b8eefb79ab8", size = 565440743, upload_time = "2024-10-25T19:55:49.74Z" }, -] - -[[package]] -name = "nvidia-cufft-cu12" -version = "11.3.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/37/c50d2b2f2c07e146776389e3080f4faf70bcc4fa6e19d65bb54ca174ebc3/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d16079550df460376455cba121db6564089176d9bac9e4f360493ca4741b22a6", size = 200164144, upload_time = "2024-11-20T17:40:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f5/188566814b7339e893f8d210d3a5332352b1409815908dad6a363dcceac1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8510990de9f96c803a051822618d42bf6cb8f069ff3f48d93a8486efdacb48fb", size = 200164135, upload_time = "2024-10-01T17:03:24.212Z" }, - { url = "https://files.pythonhosted.org/packages/8f/16/73727675941ab8e6ffd86ca3a4b7b47065edcca7a997920b831f8147c99d/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ccba62eb9cef5559abd5e0d54ceed2d9934030f51163df018532142a8ec533e5", size = 200221632, upload_time = "2024-11-20T17:41:32.357Z" }, - { url = "https://files.pythonhosted.org/packages/60/de/99ec247a07ea40c969d904fc14f3a356b3e2a704121675b75c366b694ee1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.whl", hash = "sha256:768160ac89f6f7b459bee747e8d175dbf53619cfe74b2a5636264163138013ca", size = 200221622, upload_time = "2024-10-01T17:03:58.79Z" }, - { url = "https://files.pythonhosted.org/packages/b4/38/36fd800cec8f6e89b7c1576edaaf8076e69ec631644cdbc1b5f2e2b5a9df/nvidia_cufft_cu12-11.3.0.4-py3-none-win_amd64.whl", hash = "sha256:6048ebddfb90d09d2707efb1fd78d4e3a77cb3ae4dc60e19aab6be0ece2ae464", size = 199356881, upload_time = "2024-10-01T17:13:01.861Z" }, -] - -[[package]] -name = "nvidia-cufile-cu12" -version = "1.11.1.6" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/66/cc9876340ac68ae71b15c743ddb13f8b30d5244af344ec8322b449e35426/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159", size = 1142103, upload_time = "2024-11-20T17:42:11.83Z" }, - { url = "https://files.pythonhosted.org/packages/17/bf/cc834147263b929229ce4aadd62869f0b195e98569d4c28b23edc72b85d9/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8f57a0051dcf2543f6dc2b98a98cb2719c37d3cee1baba8965d57f3bbc90d4db", size = 1066155, upload_time = "2024-11-20T17:41:49.376Z" }, -] - -[[package]] -name = "nvidia-curand-cu12" -version = "10.3.7.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/ac/36543605358a355632f1a6faa3e2d5dfb91eab1e4bc7d552040e0383c335/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6e82df077060ea28e37f48a3ec442a8f47690c7499bff392a5938614b56c98d8", size = 56289881, upload_time = "2024-10-01T17:04:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/73/1b/44a01c4e70933637c93e6e1a8063d1e998b50213a6b65ac5a9169c47e98e/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf", size = 56279010, upload_time = "2024-11-20T17:42:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/4a/aa/2c7ff0b5ee02eaef890c0ce7d4f74bc30901871c5e45dee1ae6d0083cd80/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:99f1a32f1ac2bd134897fc7a203f779303261268a65762a623bf30cc9fe79117", size = 56279000, upload_time = "2024-10-01T17:04:45.274Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/5362a9396f23f7de1dd8a64369e87c85ffff8216fc8194ace0fa45ba27a5/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7b2ed8e95595c3591d984ea3603dd66fe6ce6812b886d59049988a712ed06b6e", size = 56289882, upload_time = "2024-11-20T17:42:25.222Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a8/0cd0cec757bd4b4b4ef150fca62ec064db7d08a291dced835a0be7d2c147/nvidia_curand_cu12-10.3.7.77-py3-none-win_amd64.whl", hash = "sha256:6d6d935ffba0f3d439b7cd968192ff068fafd9018dbf1b85b37261b13cfc9905", size = 55783873, upload_time = "2024-10-01T17:13:30.377Z" }, -] - -[[package]] -name = "nvidia-cusolver-cu12" -version = "11.7.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/17/dbe1aa865e4fdc7b6d4d0dd308fdd5aaab60f939abfc0ea1954eac4fb113/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0ce237ef60acde1efc457335a2ddadfd7610b892d94efee7b776c64bb1cac9e0", size = 157833628, upload_time = "2024-10-01T17:05:05.591Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6e/c2cf12c9ff8b872e92b4a5740701e51ff17689c4d726fca91875b07f655d/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c", size = 158229790, upload_time = "2024-11-20T17:43:43.211Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/baba53585da791d043c10084cf9553e074548408e04ae884cfe9193bd484/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6cf28f17f64107a0c4d7802be5ff5537b2130bfc112f25d5a30df227058ca0e6", size = 158229780, upload_time = "2024-10-01T17:05:39.875Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5f/07d0ba3b7f19be5a5ec32a8679fc9384cfd9fc6c869825e93be9f28d6690/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dbbe4fc38ec1289c7e5230e16248365e375c3673c9c8bac5796e2e20db07f56e", size = 157833630, upload_time = "2024-11-20T17:43:16.77Z" }, - { url = "https://files.pythonhosted.org/packages/d4/53/fff50a0808df7113d77e3bbc7c2b7eaed6f57d5eb80fbe93ead2aea1e09a/nvidia_cusolver_cu12-11.7.1.2-py3-none-win_amd64.whl", hash = "sha256:6813f9d8073f555444a8705f3ab0296d3e1cb37a16d694c5fc8b862a0d8706d7", size = 149287877, upload_time = "2024-10-01T17:13:49.804Z" }, -] - -[[package]] -name = "nvidia-cusparse-cu12" -version = "12.5.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/eb/6681efd0aa7df96b4f8067b3ce7246833dd36830bb4cec8896182773db7d/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d25b62fb18751758fe3c93a4a08eff08effedfe4edf1c6bb5afd0890fe88f887", size = 216451147, upload_time = "2024-11-20T17:44:18.055Z" }, - { url = "https://files.pythonhosted.org/packages/d3/56/3af21e43014eb40134dea004e8d0f1ef19d9596a39e4d497d5a7de01669f/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7aa32fa5470cf754f72d1116c7cbc300b4e638d3ae5304cfa4a638a5b87161b1", size = 216451135, upload_time = "2024-10-01T17:06:03.826Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/b8b7c2f4099a37b96af5c9bb158632ea9e5d9d27d7391d7eb8fc45236674/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7556d9eca156e18184b94947ade0fba5bb47d69cec46bf8660fd2c71a4b48b73", size = 216561367, upload_time = "2024-11-20T17:44:54.824Z" }, - { url = "https://files.pythonhosted.org/packages/43/ac/64c4316ba163e8217a99680c7605f779accffc6a4bcd0c778c12948d3707/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:23749a6571191a215cb74d1cdbff4a86e7b19f1200c071b3fcf844a5bea23a2f", size = 216561357, upload_time = "2024-10-01T17:06:29.861Z" }, - { url = "https://files.pythonhosted.org/packages/45/ef/876ad8e4260e1128e6d4aac803d9d51baf3791ebdb4a9b8d9b8db032b4b0/nvidia_cusparse_cu12-12.5.4.2-py3-none-win_amd64.whl", hash = "sha256:4acb8c08855a26d737398cba8fb6f8f5045d93f82612b4cfd84645a2332ccf20", size = 213712630, upload_time = "2024-10-01T17:14:23.779Z" }, -] - -[[package]] -name = "nvidia-cusparselt-cu12" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/da/4de092c61c6dea1fc9c936e69308a02531d122e12f1f649825934ad651b5/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8371549623ba601a06322af2133c4a44350575f5a3108fb75f3ef20b822ad5f1", size = 156402859, upload_time = "2024-10-16T02:23:17.184Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9a/72ef35b399b0e183bc2e8f6f558036922d453c4d8237dab26c666a04244b/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46", size = 156785796, upload_time = "2024-10-15T21:29:17.709Z" }, - { url = "https://files.pythonhosted.org/packages/46/3e/9e1e394a02a06f694be2c97bbe47288bb7c90ea84c7e9cf88f7b28afe165/nvidia_cusparselt_cu12-0.6.3-py3-none-win_amd64.whl", hash = "sha256:3b325bcbd9b754ba43df5a311488fca11a6b5dc3d11df4d190c000cf1a0765c7", size = 155595972, upload_time = "2024-10-15T22:58:35.426Z" }, -] - -[[package]] -name = "nvidia-nccl-cu12" -version = "2.26.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/5b/ca2f213f637305633814ae8c36b153220e40a07ea001966dcd87391f3acb/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c196e95e832ad30fbbb50381eb3cbd1fadd5675e587a548563993609af19522", size = 291671495, upload_time = "2025-03-13T00:30:07.805Z" }, - { url = "https://files.pythonhosted.org/packages/67/ca/f42388aed0fddd64ade7493dbba36e1f534d4e6fdbdd355c6a90030ae028/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6", size = 201319755, upload_time = "2025-03-13T00:29:55.296Z" }, -] - -[[package]] -name = "nvidia-nvjitlink-cu12" -version = "12.6.85" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/d7/c5383e47c7e9bf1c99d5bd2a8c935af2b6d705ad831a7ec5c97db4d82f4f/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a", size = 19744971, upload_time = "2024-11-20T17:46:53.366Z" }, - { url = "https://files.pythonhosted.org/packages/31/db/dc71113d441f208cdfe7ae10d4983884e13f464a6252450693365e166dcf/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf4eaa7d4b6b543ffd69d6abfb11efdeb2db48270d94dfd3a452c24150829e41", size = 19270338, upload_time = "2024-11-20T17:46:29.758Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/93c1467b1387387440a4d25102d86b7794535449b689f8e2dc22c1c8ff7f/nvidia_nvjitlink_cu12-12.6.85-py3-none-win_amd64.whl", hash = "sha256:e61120e52ed675747825cdd16febc6a0730537451d867ee58bee3853b1b13d1c", size = 161908572, upload_time = "2024-11-20T17:52:40.124Z" }, -] - -[[package]] -name = "nvidia-nvtx-cu12" -version = "12.6.77" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/93/80f8a520375af9d7ee44571a6544653a176e53c2b8ccce85b97b83c2491b/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f44f8d86bb7d5629988d61c8d3ae61dddb2015dee142740536bc7481b022fe4b", size = 90549, upload_time = "2024-11-20T17:38:17.387Z" }, - { url = "https://files.pythonhosted.org/packages/2b/53/36e2fd6c7068997169b49ffc8c12d5af5e5ff209df6e1a2c4d373b3a638f/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:adcaabb9d436c9761fca2b13959a2d237c5f9fd406c8e4b723c695409ff88059", size = 90539, upload_time = "2024-10-01T17:00:27.179Z" }, - { url = "https://files.pythonhosted.org/packages/56/9a/fff8376f8e3d084cd1530e1ef7b879bb7d6d265620c95c1b322725c694f4/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b90bed3df379fa79afbd21be8e04a0314336b8ae16768b58f2d34cb1d04cd7d2", size = 89276, upload_time = "2024-11-20T17:38:27.621Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/0d0c945463719429b7bd21dece907ad0bde437a2ff12b9b12fee94722ab0/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1", size = 89265, upload_time = "2024-10-01T17:00:38.172Z" }, - { url = "https://files.pythonhosted.org/packages/f7/cd/98a447919d4ed14d407ac82b14b0a0c9c1dbfe81099934b1fc3bfd1e6316/nvidia_nvtx_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:2fb11a4af04a5e6c84073e6404d26588a34afd35379f0855a99797897efa75c0", size = 56434, upload_time = "2024-10-01T17:11:13.124Z" }, -] - -[[package]] -name = "omegaconf" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "antlr4-python3-runtime" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload_time = "2022-12-08T20:59:22.753Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload_time = "2022-12-08T20:59:19.686Z" }, -] - -[[package]] -name = "optuna" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alembic" }, - { name = "colorlog" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "sqlalchemy" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/e0/b303190ae8032d12f320a24c42af04038bacb1f3b17ede354dd1044a5642/optuna-4.4.0.tar.gz", hash = "sha256:a9029f6a92a1d6c8494a94e45abd8057823b535c2570819072dbcdc06f1c1da4", size = 467708, upload_time = "2025-06-16T05:13:00.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/5e/068798a8c7087863e7772e9363a880ab13fe55a5a7ede8ec42fab8a1acbb/optuna-4.4.0-py3-none-any.whl", hash = "sha256:fad8d9c5d5af993ae1280d6ce140aecc031c514a44c3b639d8c8658a8b7920ea", size = 395949, upload_time = "2025-06-16T05:12:58.37Z" }, -] - -[[package]] -name = "opuslib" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/55/826befabb29fd3902bad6d6d7308790894c7ad4d73f051728a0c53d37cd7/opuslib-3.0.1.tar.gz", hash = "sha256:2cb045e5b03e7fc50dfefe431e3404dddddbd8f5961c10c51e32dfb69a044c97", size = 8550, upload_time = "2018-01-16T06:04:42.184Z" } - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pandas" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/51/48f713c4c728d7c55ef7444ba5ea027c26998d96d1a40953b346438602fc/pandas-2.3.0.tar.gz", hash = "sha256:34600ab34ebf1131a7613a260a61dbe8b62c188ec0ea4c296da7c9a06b004133", size = 4484490, upload_time = "2025-06-05T03:27:54.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/2d/df6b98c736ba51b8eaa71229e8fcd91233a831ec00ab520e1e23090cc072/pandas-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:625466edd01d43b75b1883a64d859168e4556261a5035b32f9d743b67ef44634", size = 11527531, upload_time = "2025-06-05T03:25:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/77/1c/3f8c331d223f86ba1d0ed7d3ed7fcf1501c6f250882489cc820d2567ddbf/pandas-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6872d695c896f00df46b71648eea332279ef4077a409e2fe94220208b6bb675", size = 10774764, upload_time = "2025-06-05T03:25:53.228Z" }, - { url = "https://files.pythonhosted.org/packages/1b/45/d2599400fad7fe06b849bd40b52c65684bc88fbe5f0a474d0513d057a377/pandas-2.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4dd97c19bd06bc557ad787a15b6489d2614ddaab5d104a0310eb314c724b2d2", size = 11711963, upload_time = "2025-06-05T03:25:56.855Z" }, - { url = "https://files.pythonhosted.org/packages/66/f8/5508bc45e994e698dbc93607ee6b9b6eb67df978dc10ee2b09df80103d9e/pandas-2.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:034abd6f3db8b9880aaee98f4f5d4dbec7c4829938463ec046517220b2f8574e", size = 12349446, upload_time = "2025-06-05T03:26:01.292Z" }, - { url = "https://files.pythonhosted.org/packages/f7/fc/17851e1b1ea0c8456ba90a2f514c35134dd56d981cf30ccdc501a0adeac4/pandas-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23c2b2dc5213810208ca0b80b8666670eb4660bbfd9d45f58592cc4ddcfd62e1", size = 12920002, upload_time = "2025-06-06T00:00:07.925Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9b/8743be105989c81fa33f8e2a4e9822ac0ad4aaf812c00fee6bb09fc814f9/pandas-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:39ff73ec07be5e90330cc6ff5705c651ace83374189dcdcb46e6ff54b4a72cd6", size = 13651218, upload_time = "2025-06-05T03:26:09.731Z" }, - { url = "https://files.pythonhosted.org/packages/26/fa/8eeb2353f6d40974a6a9fd4081ad1700e2386cf4264a8f28542fd10b3e38/pandas-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:40cecc4ea5abd2921682b57532baea5588cc5f80f0231c624056b146887274d2", size = 11082485, upload_time = "2025-06-05T03:26:17.572Z" }, - { url = "https://files.pythonhosted.org/packages/96/1e/ba313812a699fe37bf62e6194265a4621be11833f5fce46d9eae22acb5d7/pandas-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8adff9f138fc614347ff33812046787f7d43b3cef7c0f0171b3340cae333f6ca", size = 11551836, upload_time = "2025-06-05T03:26:22.784Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cc/0af9c07f8d714ea563b12383a7e5bde9479cf32413ee2f346a9c5a801f22/pandas-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e5f08eb9a445d07720776df6e641975665c9ea12c9d8a331e0f6890f2dcd76ef", size = 10807977, upload_time = "2025-06-05T16:50:11.109Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3e/8c0fb7e2cf4a55198466ced1ca6a9054ae3b7e7630df7757031df10001fd/pandas-2.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa35c266c8cd1a67d75971a1912b185b492d257092bdd2709bbdebe574ed228d", size = 11788230, upload_time = "2025-06-05T03:26:27.417Z" }, - { url = "https://files.pythonhosted.org/packages/14/22/b493ec614582307faf3f94989be0f7f0a71932ed6f56c9a80c0bb4a3b51e/pandas-2.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a0cc77b0f089d2d2ffe3007db58f170dae9b9f54e569b299db871a3ab5bf46", size = 12370423, upload_time = "2025-06-05T03:26:34.142Z" }, - { url = "https://files.pythonhosted.org/packages/9f/74/b012addb34cda5ce855218a37b258c4e056a0b9b334d116e518d72638737/pandas-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c06f6f144ad0a1bf84699aeea7eff6068ca5c63ceb404798198af7eb86082e33", size = 12990594, upload_time = "2025-06-06T00:00:13.934Z" }, - { url = "https://files.pythonhosted.org/packages/95/81/b310e60d033ab64b08e66c635b94076488f0b6ce6a674379dd5b224fc51c/pandas-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ed16339bc354a73e0a609df36d256672c7d296f3f767ac07257801aa064ff73c", size = 13745952, upload_time = "2025-06-05T03:26:39.475Z" }, - { url = "https://files.pythonhosted.org/packages/25/ac/f6ee5250a8881b55bd3aecde9b8cfddea2f2b43e3588bca68a4e9aaf46c8/pandas-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:fa07e138b3f6c04addfeaf56cc7fdb96c3b68a3fe5e5401251f231fce40a0d7a", size = 11094534, upload_time = "2025-06-05T03:26:43.23Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/24192607058dd607dbfacdd060a2370f6afb19c2ccb617406469b9aeb8e7/pandas-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2eb4728a18dcd2908c7fccf74a982e241b467d178724545a48d0caf534b38ebf", size = 11573865, upload_time = "2025-06-05T03:26:46.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/ae8ea3b800757a70c9fdccc68b67dc0280a6e814efcf74e4211fd5dea1ca/pandas-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9d8c3187be7479ea5c3d30c32a5d73d62a621166675063b2edd21bc47614027", size = 10702154, upload_time = "2025-06-05T16:50:14.439Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ba/a7883d7aab3d24c6540a2768f679e7414582cc389876d469b40ec749d78b/pandas-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ff730713d4c4f2f1c860e36c005c7cefc1c7c80c21c0688fd605aa43c9fcf09", size = 11262180, upload_time = "2025-06-05T16:50:17.453Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/931fc3ad333d9d87b10107d948d757d67ebcfc33b1988d5faccc39c6845c/pandas-2.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba24af48643b12ffe49b27065d3babd52702d95ab70f50e1b34f71ca703e2c0d", size = 11991493, upload_time = "2025-06-05T03:26:51.813Z" }, - { url = "https://files.pythonhosted.org/packages/d7/bf/0213986830a92d44d55153c1d69b509431a972eb73f204242988c4e66e86/pandas-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:404d681c698e3c8a40a61d0cd9412cc7364ab9a9cc6e144ae2992e11a2e77a20", size = 12470733, upload_time = "2025-06-06T00:00:18.651Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0e/21eb48a3a34a7d4bac982afc2c4eb5ab09f2d988bdf29d92ba9ae8e90a79/pandas-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6021910b086b3ca756755e86ddc64e0ddafd5e58e076c72cb1585162e5ad259b", size = 13212406, upload_time = "2025-06-05T03:26:55.992Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d9/74017c4eec7a28892d8d6e31ae9de3baef71f5a5286e74e6b7aad7f8c837/pandas-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:094e271a15b579650ebf4c5155c05dcd2a14fd4fdd72cf4854b2f7ad31ea30be", size = 10976199, upload_time = "2025-06-05T03:26:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/5cb75a56a4842bbd0511c3d1c79186d8315b82dac802118322b2de1194fe/pandas-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c7e2fc25f89a49a11599ec1e76821322439d90820108309bf42130d2f36c983", size = 11518913, upload_time = "2025-06-05T03:27:02.757Z" }, - { url = "https://files.pythonhosted.org/packages/05/01/0c8785610e465e4948a01a059562176e4c8088aa257e2e074db868f86d4e/pandas-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6da97aeb6a6d233fb6b17986234cc723b396b50a3c6804776351994f2a658fd", size = 10655249, upload_time = "2025-06-05T16:50:20.17Z" }, - { url = "https://files.pythonhosted.org/packages/e8/6a/47fd7517cd8abe72a58706aab2b99e9438360d36dcdb052cf917b7bf3bdc/pandas-2.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb32dc743b52467d488e7a7c8039b821da2826a9ba4f85b89ea95274f863280f", size = 11328359, upload_time = "2025-06-05T03:27:06.431Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b3/463bfe819ed60fb7e7ddffb4ae2ee04b887b3444feee6c19437b8f834837/pandas-2.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:213cd63c43263dbb522c1f8a7c9d072e25900f6975596f883f4bebd77295d4f3", size = 12024789, upload_time = "2025-06-05T03:27:09.875Z" }, - { url = "https://files.pythonhosted.org/packages/04/0c/e0704ccdb0ac40aeb3434d1c641c43d05f75c92e67525df39575ace35468/pandas-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1d2b33e68d0ce64e26a4acc2e72d747292084f4e8db4c847c6f5f6cbe56ed6d8", size = 12480734, upload_time = "2025-06-06T00:00:22.246Z" }, - { url = "https://files.pythonhosted.org/packages/e9/df/815d6583967001153bb27f5cf075653d69d51ad887ebbf4cfe1173a1ac58/pandas-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:430a63bae10b5086995db1b02694996336e5a8ac9a96b4200572b413dfdfccb9", size = 13223381, upload_time = "2025-06-05T03:27:15.641Z" }, - { url = "https://files.pythonhosted.org/packages/79/88/ca5973ed07b7f484c493e941dbff990861ca55291ff7ac67c815ce347395/pandas-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4930255e28ff5545e2ca404637bcc56f031893142773b3468dc021c6c32a1390", size = 10970135, upload_time = "2025-06-05T03:27:24.131Z" }, - { url = "https://files.pythonhosted.org/packages/24/fb/0994c14d1f7909ce83f0b1fb27958135513c4f3f2528bde216180aa73bfc/pandas-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f925f1ef673b4bd0271b1809b72b3270384f2b7d9d14a189b12b7fc02574d575", size = 12141356, upload_time = "2025-06-05T03:27:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a2/9b903e5962134497ac4f8a96f862ee3081cb2506f69f8e4778ce3d9c9d82/pandas-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78ad363ddb873a631e92a3c063ade1ecfb34cae71e9a2be6ad100f875ac1042", size = 11474674, upload_time = "2025-06-05T03:27:39.448Z" }, - { url = "https://files.pythonhosted.org/packages/81/3a/3806d041bce032f8de44380f866059437fb79e36d6b22c82c187e65f765b/pandas-2.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951805d146922aed8357e4cc5671b8b0b9be1027f0619cea132a9f3f65f2f09c", size = 11439876, upload_time = "2025-06-05T03:27:43.652Z" }, - { url = "https://files.pythonhosted.org/packages/15/aa/3fc3181d12b95da71f5c2537c3e3b3af6ab3a8c392ab41ebb766e0929bc6/pandas-2.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a881bc1309f3fce34696d07b00f13335c41f5f5a8770a33b09ebe23261cfc67", size = 11966182, upload_time = "2025-06-05T03:27:47.652Z" }, - { url = "https://files.pythonhosted.org/packages/37/e7/e12f2d9b0a2c4a2cc86e2aabff7ccfd24f03e597d770abfa2acd313ee46b/pandas-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e1991bbb96f4050b09b5f811253c4f3cf05ee89a589379aa36cd623f21a31d6f", size = 12547686, upload_time = "2025-06-06T00:00:26.142Z" }, - { url = "https://files.pythonhosted.org/packages/39/c2/646d2e93e0af70f4e5359d870a63584dacbc324b54d73e6b3267920ff117/pandas-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bb3be958022198531eb7ec2008cfc78c5b1eed51af8600c6c5d9160d89d8d249", size = 13231847, upload_time = "2025-06-05T03:27:51.465Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload_time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload_time = "2023-12-10T22:30:43.14Z" }, -] - -[[package]] -name = "pillow" -version = "11.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload_time = "2025-04-12T17:50:03.289Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/8b/b158ad57ed44d3cc54db8d68ad7c0a58b8fc0e4c7a3f995f9d62d5b464a1/pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047", size = 3198442, upload_time = "2025-04-12T17:47:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f8/bb5d956142f86c2d6cc36704943fa761f2d2e4c48b7436fd0a85c20f1713/pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95", size = 3030553, upload_time = "2025-04-12T17:47:13.153Z" }, - { url = "https://files.pythonhosted.org/packages/22/7f/0e413bb3e2aa797b9ca2c5c38cb2e2e45d88654e5b12da91ad446964cfae/pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61", size = 4405503, upload_time = "2025-04-12T17:47:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b4/cc647f4d13f3eb837d3065824aa58b9bcf10821f029dc79955ee43f793bd/pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1", size = 4490648, upload_time = "2025-04-12T17:47:17.37Z" }, - { url = "https://files.pythonhosted.org/packages/c2/6f/240b772a3b35cdd7384166461567aa6713799b4e78d180c555bd284844ea/pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c", size = 4508937, upload_time = "2025-04-12T17:47:19.066Z" }, - { url = "https://files.pythonhosted.org/packages/f3/5e/7ca9c815ade5fdca18853db86d812f2f188212792780208bdb37a0a6aef4/pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d", size = 4599802, upload_time = "2025-04-12T17:47:21.404Z" }, - { url = "https://files.pythonhosted.org/packages/02/81/c3d9d38ce0c4878a77245d4cf2c46d45a4ad0f93000227910a46caff52f3/pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97", size = 4576717, upload_time = "2025-04-12T17:47:23.571Z" }, - { url = "https://files.pythonhosted.org/packages/42/49/52b719b89ac7da3185b8d29c94d0e6aec8140059e3d8adcaa46da3751180/pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579", size = 4654874, upload_time = "2025-04-12T17:47:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/5b/0b/ede75063ba6023798267023dc0d0401f13695d228194d2242d5a7ba2f964/pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d", size = 2331717, upload_time = "2025-04-12T17:47:28.922Z" }, - { url = "https://files.pythonhosted.org/packages/ed/3c/9831da3edea527c2ed9a09f31a2c04e77cd705847f13b69ca60269eec370/pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad", size = 2676204, upload_time = "2025-04-12T17:47:31.283Z" }, - { url = "https://files.pythonhosted.org/packages/01/97/1f66ff8a1503d8cbfc5bae4dc99d54c6ec1e22ad2b946241365320caabc2/pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2", size = 2414767, upload_time = "2025-04-12T17:47:34.655Z" }, - { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450, upload_time = "2025-04-12T17:47:37.135Z" }, - { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550, upload_time = "2025-04-12T17:47:39.345Z" }, - { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018, upload_time = "2025-04-12T17:47:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006, upload_time = "2025-04-12T17:47:42.912Z" }, - { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773, upload_time = "2025-04-12T17:47:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069, upload_time = "2025-04-12T17:47:46.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460, upload_time = "2025-04-12T17:47:49.255Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304, upload_time = "2025-04-12T17:47:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809, upload_time = "2025-04-12T17:47:54.425Z" }, - { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338, upload_time = "2025-04-12T17:47:56.535Z" }, - { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918, upload_time = "2025-04-12T17:47:58.217Z" }, - { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185, upload_time = "2025-04-12T17:48:00.417Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306, upload_time = "2025-04-12T17:48:02.391Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121, upload_time = "2025-04-12T17:48:04.554Z" }, - { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707, upload_time = "2025-04-12T17:48:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921, upload_time = "2025-04-12T17:48:09.229Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523, upload_time = "2025-04-12T17:48:11.631Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836, upload_time = "2025-04-12T17:48:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390, upload_time = "2025-04-12T17:48:15.938Z" }, - { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload_time = "2025-04-12T17:48:17.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload_time = "2025-04-12T17:48:19.655Z" }, - { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload_time = "2025-04-12T17:48:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload_time = "2025-04-12T17:48:23.915Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166, upload_time = "2025-04-12T17:48:25.738Z" }, - { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674, upload_time = "2025-04-12T17:48:27.908Z" }, - { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005, upload_time = "2025-04-12T17:48:29.888Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707, upload_time = "2025-04-12T17:48:31.874Z" }, - { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008, upload_time = "2025-04-12T17:48:34.422Z" }, - { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420, upload_time = "2025-04-12T17:48:37.641Z" }, - { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655, upload_time = "2025-04-12T17:48:39.652Z" }, - { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329, upload_time = "2025-04-12T17:48:41.765Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388, upload_time = "2025-04-12T17:48:43.625Z" }, - { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950, upload_time = "2025-04-12T17:48:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759, upload_time = "2025-04-12T17:48:47.866Z" }, - { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284, upload_time = "2025-04-12T17:48:50.189Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826, upload_time = "2025-04-12T17:48:52.346Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329, upload_time = "2025-04-12T17:48:54.403Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049, upload_time = "2025-04-12T17:48:56.383Z" }, - { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408, upload_time = "2025-04-12T17:48:58.782Z" }, - { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863, upload_time = "2025-04-12T17:49:00.709Z" }, - { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938, upload_time = "2025-04-12T17:49:02.946Z" }, - { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774, upload_time = "2025-04-12T17:49:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895, upload_time = "2025-04-12T17:49:06.635Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload_time = "2025-04-12T17:49:08.399Z" }, - { url = "https://files.pythonhosted.org/packages/33/49/c8c21e4255b4f4a2c0c68ac18125d7f5460b109acc6dfdef1a24f9b960ef/pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156", size = 3181727, upload_time = "2025-04-12T17:49:31.898Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f1/f7255c0838f8c1ef6d55b625cfb286835c17e8136ce4351c5577d02c443b/pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772", size = 2999833, upload_time = "2025-04-12T17:49:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/e2/57/9968114457bd131063da98d87790d080366218f64fa2943b65ac6739abb3/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363", size = 3437472, upload_time = "2025-04-12T17:49:36.294Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1b/e35d8a158e21372ecc48aac9c453518cfe23907bb82f950d6e1c72811eb0/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0", size = 3459976, upload_time = "2025-04-12T17:49:38.988Z" }, - { url = "https://files.pythonhosted.org/packages/26/da/2c11d03b765efff0ccc473f1c4186dc2770110464f2177efaed9cf6fae01/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01", size = 3527133, upload_time = "2025-04-12T17:49:40.985Z" }, - { url = "https://files.pythonhosted.org/packages/79/1a/4e85bd7cadf78412c2a3069249a09c32ef3323650fd3005c97cca7aa21df/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193", size = 3571555, upload_time = "2025-04-12T17:49:42.964Z" }, - { url = "https://files.pythonhosted.org/packages/69/03/239939915216de1e95e0ce2334bf17a7870ae185eb390fab6d706aadbfc0/pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013", size = 2674713, upload_time = "2025-04-12T17:49:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734, upload_time = "2025-04-12T17:49:46.789Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841, upload_time = "2025-04-12T17:49:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470, upload_time = "2025-04-12T17:49:50.831Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013, upload_time = "2025-04-12T17:49:53.278Z" }, - { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165, upload_time = "2025-04-12T17:49:55.164Z" }, - { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586, upload_time = "2025-04-12T17:49:57.171Z" }, - { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload_time = "2025-04-12T17:49:59.628Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.3.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload_time = "2025-05-07T22:47:42.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload_time = "2025-05-07T22:47:40.376Z" }, -] - -[[package]] -name = "plotly" -version = "6.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "narwhals" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/0efc297df362b88b74957a230af61cd6929f531f72f48063e8408702ffba/plotly-6.2.0.tar.gz", hash = "sha256:9dfa23c328000f16c928beb68927444c1ab9eae837d1fe648dbcda5360c7953d", size = 6801941, upload_time = "2025-06-26T16:20:45.765Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/20/f2b7ac96a91cc5f70d81320adad24cc41bf52013508d649b1481db225780/plotly-6.2.0-py3-none-any.whl", hash = "sha256:32c444d4c940887219cb80738317040363deefdfee4f354498cc0b6dab8978bd", size = 9635469, upload_time = "2025-06-26T16:20:40.76Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pooch" -version = "1.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "platformdirs" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/b3d3e00c696c16cf99af81ef7b1f5fe73bd2a307abca41bd7605429fe6e5/pooch-1.8.2.tar.gz", hash = "sha256:76561f0de68a01da4df6af38e9955c4c9d1a5c90da73f7e40276a5728ec83d10", size = 59353, upload_time = "2024-06-06T16:53:46.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574, upload_time = "2024-06-06T16:53:44.343Z" }, -] - -[[package]] -name = "primepy" -version = "1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/77/0cfa1b4697cfb5336f3a96e8bc73327f64610be3a64c97275f1801afb395/primePy-1.3.tar.gz", hash = "sha256:25fd7e25344b0789a5984c75d89f054fcf1f180bef20c998e4befbac92de4669", size = 3914, upload_time = "2018-05-29T17:18:18.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload_time = "2018-05-29T17:18:17.53Z" }, -] - -[[package]] -name = "propcache" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload_time = "2025-06-09T22:56:06.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload_time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload_time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload_time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload_time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload_time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload_time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload_time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload_time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload_time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload_time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload_time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload_time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload_time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload_time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload_time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload_time = "2025-06-09T22:54:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload_time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload_time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload_time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload_time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload_time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload_time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload_time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload_time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload_time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload_time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload_time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload_time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload_time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload_time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload_time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload_time = "2025-06-09T22:54:29.4Z" }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload_time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload_time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload_time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload_time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload_time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload_time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload_time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload_time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload_time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload_time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload_time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload_time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload_time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload_time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload_time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload_time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload_time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload_time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload_time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload_time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload_time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload_time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload_time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload_time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload_time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload_time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload_time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload_time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload_time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload_time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload_time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload_time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload_time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload_time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload_time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload_time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload_time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload_time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload_time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload_time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload_time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload_time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload_time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload_time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload_time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload_time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload_time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload_time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload_time = "2025-06-09T22:56:04.484Z" }, -] - -[[package]] -name = "protobuf" -version = "6.31.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload_time = "2025-05-28T19:25:54.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload_time = "2025-05-28T19:25:41.198Z" }, - { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload_time = "2025-05-28T19:25:44.275Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload_time = "2025-05-28T19:25:45.702Z" }, - { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload_time = "2025-05-28T19:25:47.128Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload_time = "2025-05-28T19:25:50.036Z" }, - { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload_time = "2025-05-28T19:25:53.926Z" }, -] - -[[package]] -name = "pyannote-audio" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asteroid-filterbanks" }, - { name = "einops" }, - { name = "huggingface-hub" }, - { name = "lightning" }, - { name = "omegaconf" }, - { name = "pyannote-core" }, - { name = "pyannote-database" }, - { name = "pyannote-metrics" }, - { name = "pyannote-pipeline" }, - { name = "pytorch-metric-learning" }, - { name = "rich" }, - { name = "semver" }, - { name = "soundfile" }, - { name = "speechbrain" }, - { name = "tensorboardx" }, - { name = "torch" }, - { name = "torch-audiomentations" }, - { name = "torchaudio" }, - { name = "torchmetrics" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/00/3b96ca7ad0641e4f64cfaa2af153dc7da0998ff972280e1c1681b1fcc243/pyannote_audio-3.3.2.tar.gz", hash = "sha256:b2115e86b0db5faedb9f36ee1a150cebd07f7758e65e815accdac1a12ca9c777", size = 13664309, upload_time = "2024-09-11T11:07:48.274Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/e6/76049470d90217f9a15a34abf3e92d782cabc3fb4ab27515c9baaa5495d1/pyannote.audio-3.3.2-py2.py3-none-any.whl", hash = "sha256:599c694acd5d193215147ff82d0bf638bb191204ed502bd9fde8ff582e20aa1c", size = 898707, upload_time = "2024-09-11T11:07:46.12Z" }, -] - -[[package]] -name = "pyannote-core" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "sortedcontainers" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/65/03/feaf7534206f02c75baf151ce4b8c322b402a6f477c2be82f69d9269cbe6/pyannote.core-5.0.0.tar.gz", hash = "sha256:1a55bcc8bd680ba6be5fa53efa3b6f3d2cdd67144c07b6b4d8d66d5cb0d2096f", size = 59247, upload_time = "2022-12-15T13:02:05.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/c4/370bc8ba66815a5832ece753a1009388bb07ea353d21c83f2d5a1a436f2c/pyannote.core-5.0.0-py3-none-any.whl", hash = "sha256:04920a6754492242ce0dc6017545595ab643870fe69a994f20c1a5f2da0544d0", size = 58475, upload_time = "2022-12-15T13:02:03.265Z" }, -] - -[[package]] -name = "pyannote-database" -version = "5.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pandas" }, - { name = "pyannote-core" }, - { name = "pyyaml" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/ae/de36413d69a46be87cb612ebbcdc4eacbeebce3bc809124603e44a88fe26/pyannote.database-5.1.3.tar.gz", hash = "sha256:0eaf64c1cc506718de60d2d702f1359b1ae7ff252ee3e4799f1c5e378cd52c31", size = 49957, upload_time = "2025-01-15T20:28:26.437Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/64/92d51a3a05615ba58be8ba62a43f9f9f952d9f3646f7e4fb7826e5a3a24e/pyannote.database-5.1.3-py3-none-any.whl", hash = "sha256:37887844c7dfbcc075cb591eddc00aff45fae1ed905344e1f43e0090e63bd40a", size = 48127, upload_time = "2025-01-15T20:28:25.326Z" }, -] - -[[package]] -name = "pyannote-metrics" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docopt" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "pyannote-core" }, - { name = "pyannote-database" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "sympy" }, - { name = "tabulate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/2b/6c5f01d3c49aa1c160765946e23782ca6436ae8b9bc514b56319ff5f16e7/pyannote.metrics-3.2.1.tar.gz", hash = "sha256:08024255a3550e96a8e9da4f5f4af326886548480de891414567c8900920ee5c", size = 49086, upload_time = "2022-06-20T14:10:34.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/7d/035b370ab834b30e849fe9cd092b7bd7f321fcc4a2c56b84e96476b7ede5/pyannote.metrics-3.2.1-py3-none-any.whl", hash = "sha256:46be797cdade26c82773e5018659ae610145260069c7c5bf3d3c8a029ade8e22", size = 51386, upload_time = "2022-06-20T14:10:32.621Z" }, -] - -[[package]] -name = "pyannote-pipeline" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docopt" }, - { name = "filelock" }, - { name = "optuna" }, - { name = "pyannote-core" }, - { name = "pyannote-database" }, - { name = "pyyaml" }, - { name = "scikit-learn" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/04/4bcfe0dd588577a188328b806f3a7213d8cead0ce5fe5784d01fd57df93f/pyannote.pipeline-3.0.1.tar.gz", hash = "sha256:021794e26a2cf5d8fb5bb1835951e71f5fac33eb14e23dfb7468e16b1b805151", size = 34486, upload_time = "2023-09-22T20:16:49.951Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/42/1bf7cbf061ed05c580bfb63bffdd3f3474cbd5c02bee4fac518eea9e9d9e/pyannote.pipeline-3.0.1-py3-none-any.whl", hash = "sha256:819bde4c4dd514f740f2373dfec794832b9fc8e346a35e43a7681625ee187393", size = 31517, upload_time = "2023-09-22T20:16:48.153Z" }, -] - -[[package]] -name = "pyaudio" -version = "0.2.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/1d/8878c7752febb0f6716a7e1a52cb92ac98871c5aa522cba181878091607c/PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87", size = 47066, upload_time = "2023-11-07T07:11:48.806Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/90/1553487277e6aa25c0b7c2c38709cdd2b49e11c66c0b25c6e8b7b6638c72/PyAudio-0.2.14-cp310-cp310-win32.whl", hash = "sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61", size = 144624, upload_time = "2023-11-07T07:11:33.599Z" }, - { url = "https://files.pythonhosted.org/packages/27/bc/719d140ee63cf4b0725016531d36743a797ffdbab85e8536922902c9349a/PyAudio-0.2.14-cp310-cp310-win_amd64.whl", hash = "sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83", size = 164069, upload_time = "2023-11-07T07:11:35.439Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f0/b0eab89eafa70a86b7b566a4df2f94c7880a2d483aa8de1c77d335335b5b/PyAudio-0.2.14-cp311-cp311-win32.whl", hash = "sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289", size = 144624, upload_time = "2023-11-07T07:11:36.94Z" }, - { url = "https://files.pythonhosted.org/packages/82/d8/f043c854aad450a76e476b0cf9cda1956419e1dacf1062eb9df3c0055abe/PyAudio-0.2.14-cp311-cp311-win_amd64.whl", hash = "sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903", size = 164070, upload_time = "2023-11-07T07:11:38.579Z" }, - { url = "https://files.pythonhosted.org/packages/8d/45/8d2b76e8f6db783f9326c1305f3f816d4a12c8eda5edc6a2e1d03c097c3b/PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b", size = 144750, upload_time = "2023-11-07T07:11:40.142Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/d25812e5f79f06285767ec607b39149d02aa3b31d50c2269768f48768930/PyAudio-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3", size = 164126, upload_time = "2023-11-07T07:11:41.539Z" }, - { url = "https://files.pythonhosted.org/packages/3a/77/66cd37111a87c1589b63524f3d3c848011d21ca97828422c7fde7665ff0d/PyAudio-0.2.14-cp313-cp313-win32.whl", hash = "sha256:95328285b4dab57ea8c52a4a996cb52be6d629353315be5bfda403d15932a497", size = 150982, upload_time = "2024-11-20T19:12:12.404Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8b/7f9a061c1cc2b230f9ac02a6003fcd14c85ce1828013aecbaf45aa988d20/PyAudio-0.2.14-cp313-cp313-win_amd64.whl", hash = "sha256:692d8c1446f52ed2662120bcd9ddcb5aa2b71f38bda31e58b19fb4672fffba69", size = 173655, upload_time = "2024-11-20T19:12:13.616Z" }, -] - -[[package]] -name = "pycparser" -version = "2.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload_time = "2024-03-30T13:22:22.564Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload_time = "2024-03-30T13:22:20.476Z" }, -] - -[[package]] -name = "pydantic" -version = "2.11.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload_time = "2025-06-14T08:33:17.137Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload_time = "2025-06-14T08:33:14.905Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload_time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload_time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload_time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload_time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload_time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload_time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload_time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload_time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload_time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload_time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload_time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload_time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload_time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload_time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload_time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload_time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload_time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload_time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload_time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload_time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload_time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload_time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload_time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload_time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload_time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload_time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload_time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload_time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload_time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload_time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload_time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload_time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload_time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload_time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload_time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload_time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload_time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload_time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload_time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload_time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload_time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload_time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload_time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload_time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload_time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload_time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload_time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload_time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload_time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload_time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload_time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload_time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload_time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload_time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload_time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload_time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload_time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload_time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload_time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload_time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload_time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload_time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload_time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload_time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload_time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload_time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload_time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload_time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload_time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload_time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload_time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload_time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload_time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload_time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload_time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload_time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload_time = "2025-04-23T18:33:30.645Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload_time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload_time = "2025-06-24T13:26:45.485Z" }, -] - -[[package]] -name = "pydub" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload_time = "2021-03-10T02:09:54.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload_time = "2021-03-10T02:09:53.503Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pynndescent" -version = "0.5.13" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "llvmlite" }, - { name = "numba" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7e/58/560a4db5eb3794d922fe55804b10326534ded3d971e1933c1eef91193f5e/pynndescent-0.5.13.tar.gz", hash = "sha256:d74254c0ee0a1eeec84597d5fe89fedcf778593eeabe32c2f97412934a9800fb", size = 2975955, upload_time = "2024-06-17T15:48:32.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/53/d23a97e0a2c690d40b165d1062e2c4ccc796be458a1ce59f6ba030434663/pynndescent-0.5.13-py3-none-any.whl", hash = "sha256:69aabb8f394bc631b6ac475a1c7f3994c54adf3f51cd63b2730fefba5771b949", size = 56850, upload_time = "2024-06-17T15:48:31.184Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload_time = "2025-03-25T05:01:28.114Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload_time = "2025-03-25T05:01:24.908Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload_time = "2025-06-18T05:48:06.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload_time = "2025-06-18T05:48:03.955Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload_time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload_time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload_time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload_time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, -] - -[[package]] -name = "pytorch-lightning" -version = "2.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fsspec", extra = ["http"] }, - { name = "lightning-utilities" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "torch" }, - { name = "torchmetrics" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/3e/728fbdc671d07727ad447f9401d98a43570573965beb3cb2060f9a330b4f/pytorch_lightning-2.5.2.tar.gz", hash = "sha256:f817087d611be8d43b777dd4e543d72703e235510936677a13e6c29f7fd790e3", size = 636859, upload_time = "2025-06-20T15:58:27.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/42/47c186c8f9e956e559c89e6c764d5d5d0d0af517c04ca0ad39bd0a357d3a/pytorch_lightning-2.5.2-py3-none-any.whl", hash = "sha256:17cfdf89bd98074e389101f097cdf34c486a1f5c6d3fdcefbaf4dea7f97ff0bf", size = 825366, upload_time = "2025-06-20T15:58:25.534Z" }, -] - -[[package]] -name = "pytorch-metric-learning" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "torch" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/94/1bfb2c3eaf195b2d72912b65b3d417f2d9ac22491563eca360d453512c59/pytorch-metric-learning-2.8.1.tar.gz", hash = "sha256:fcc4d3b4a805e5fce25fb2e67505c47ba6fea0563fc09c5655ea1f08d1e8ed93", size = 83117, upload_time = "2024-12-11T19:21:15.982Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/15/eee4e24c3f5a63b3e73692ff79766a66cab8844e24f5912be29350937592/pytorch_metric_learning-2.8.1-py3-none-any.whl", hash = "sha256:aba6da0508d29ee9661a67fbfee911cdf62e65fc07e404b167d82871ca7e3e88", size = 125923, upload_time = "2024-12-11T19:21:13.448Z" }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, -] - -[[package]] -name = "requests" -version = "2.32.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload_time = "2025-06-09T16:43:07.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload_time = "2025-06-09T16:43:05.728Z" }, -] - -[[package]] -name = "rich" -version = "13.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload_time = "2024-11-01T16:43:57.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload_time = "2024-11-01T16:43:55.817Z" }, -] - -[[package]] -name = "ruamel-yaml" -version = "0.18.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml-clib", marker = "(python_full_version < '3.14' and platform_python_implementation == 'CPython') or (python_full_version >= '3.14' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_python_implementation != 'CPython' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload_time = "2025-06-09T08:51:09.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload_time = "2025-06-09T08:51:06.348Z" }, -] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload_time = "2024-10-20T10:10:56.22Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301, upload_time = "2024-10-20T10:12:35.876Z" }, - { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728, upload_time = "2024-10-20T10:12:37.858Z" }, - { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230, upload_time = "2024-10-20T10:12:39.457Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712, upload_time = "2024-10-20T10:12:41.119Z" }, - { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936, upload_time = "2024-10-21T11:26:37.419Z" }, - { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580, upload_time = "2024-10-21T11:26:39.503Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393, upload_time = "2024-12-11T19:58:13.873Z" }, - { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326, upload_time = "2024-10-20T10:12:42.967Z" }, - { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079, upload_time = "2024-10-20T10:12:44.117Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224, upload_time = "2024-10-20T10:12:45.162Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480, upload_time = "2024-10-20T10:12:46.758Z" }, - { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068, upload_time = "2024-10-20T10:12:48.605Z" }, - { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012, upload_time = "2024-10-20T10:12:51.124Z" }, - { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352, upload_time = "2024-10-21T11:26:41.438Z" }, - { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344, upload_time = "2024-10-21T11:26:43.62Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498, upload_time = "2024-12-11T19:58:15.592Z" }, - { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205, upload_time = "2024-10-20T10:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185, upload_time = "2024-10-20T10:12:54.652Z" }, - { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload_time = "2024-10-20T10:12:55.657Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload_time = "2024-10-20T10:12:57.155Z" }, - { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload_time = "2024-10-20T10:12:58.501Z" }, - { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload_time = "2024-10-20T10:13:00.211Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload_time = "2024-10-21T11:26:46.038Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload_time = "2024-10-21T11:26:47.487Z" }, - { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload_time = "2024-12-11T19:58:17.252Z" }, - { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload_time = "2024-10-20T10:13:01.395Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload_time = "2024-10-20T10:13:02.768Z" }, - { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload_time = "2024-10-20T10:13:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload_time = "2024-10-20T10:13:05.906Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload_time = "2024-10-20T10:13:07.26Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload_time = "2024-10-20T10:13:08.504Z" }, - { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload_time = "2024-10-21T11:26:48.866Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload_time = "2024-10-21T11:26:50.213Z" }, - { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload_time = "2024-12-11T19:58:18.846Z" }, - { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload_time = "2024-10-20T10:13:09.658Z" }, - { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload_time = "2024-10-20T10:13:10.66Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/3b/29fa87e76b1d7b3b77cc1fcbe82e6e6b8cd704410705b008822de530277c/scikit_learn-1.7.0.tar.gz", hash = "sha256:c01e869b15aec88e2cdb73d27f15bdbe03bce8e2fb43afbe77c45d399e73a5a3", size = 7178217, upload_time = "2025-06-05T22:02:46.703Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/70/e725b1da11e7e833f558eb4d3ea8b7ed7100edda26101df074f1ae778235/scikit_learn-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9fe7f51435f49d97bd41d724bb3e11eeb939882af9c29c931a8002c357e8cdd5", size = 11728006, upload_time = "2025-06-05T22:01:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/32/aa/43874d372e9dc51eb361f5c2f0a4462915c9454563b3abb0d9457c66b7e9/scikit_learn-1.7.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0c93294e1e1acbee2d029b1f2a064f26bd928b284938d51d412c22e0c977eb3", size = 10726255, upload_time = "2025-06-05T22:01:46.082Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1a/da73cc18e00f0b9ae89f7e4463a02fb6e0569778120aeab138d9554ecef0/scikit_learn-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3755f25f145186ad8c403312f74fb90df82a4dfa1af19dc96ef35f57237a94", size = 12205657, upload_time = "2025-06-05T22:01:48.729Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f6/800cb3243dd0137ca6d98df8c9d539eb567ba0a0a39ecd245c33fab93510/scikit_learn-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2726c8787933add436fb66fb63ad18e8ef342dfb39bbbd19dc1e83e8f828a85a", size = 12877290, upload_time = "2025-06-05T22:01:51.073Z" }, - { url = "https://files.pythonhosted.org/packages/4c/bd/99c3ccb49946bd06318fe194a1c54fb7d57ac4fe1c2f4660d86b3a2adf64/scikit_learn-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:e2539bb58886a531b6e86a510c0348afaadd25005604ad35966a85c2ec378800", size = 10713211, upload_time = "2025-06-05T22:01:54.107Z" }, - { url = "https://files.pythonhosted.org/packages/5a/42/c6b41711c2bee01c4800ad8da2862c0b6d2956a399d23ce4d77f2ca7f0c7/scikit_learn-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ef09b1615e1ad04dc0d0054ad50634514818a8eb3ee3dee99af3bffc0ef5007", size = 11719657, upload_time = "2025-06-05T22:01:56.345Z" }, - { url = "https://files.pythonhosted.org/packages/a3/24/44acca76449e391b6b2522e67a63c0454b7c1f060531bdc6d0118fb40851/scikit_learn-1.7.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7d7240c7b19edf6ed93403f43b0fcb0fe95b53bc0b17821f8fb88edab97085ef", size = 10712636, upload_time = "2025-06-05T22:01:59.093Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1b/fcad1ccb29bdc9b96bcaa2ed8345d56afb77b16c0c47bafe392cc5d1d213/scikit_learn-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80bd3bd4e95381efc47073a720d4cbab485fc483966f1709f1fd559afac57ab8", size = 12242817, upload_time = "2025-06-05T22:02:01.43Z" }, - { url = "https://files.pythonhosted.org/packages/c6/38/48b75c3d8d268a3f19837cb8a89155ead6e97c6892bb64837183ea41db2b/scikit_learn-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dbe48d69aa38ecfc5a6cda6c5df5abef0c0ebdb2468e92437e2053f84abb8bc", size = 12873961, upload_time = "2025-06-05T22:02:03.951Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5a/ba91b8c57aa37dbd80d5ff958576a9a8c14317b04b671ae7f0d09b00993a/scikit_learn-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:8fa979313b2ffdfa049ed07252dc94038def3ecd49ea2a814db5401c07f1ecfa", size = 10717277, upload_time = "2025-06-05T22:02:06.77Z" }, - { url = "https://files.pythonhosted.org/packages/70/3a/bffab14e974a665a3ee2d79766e7389572ffcaad941a246931c824afcdb2/scikit_learn-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2c7243d34aaede0efca7a5a96d67fddaebb4ad7e14a70991b9abee9dc5c0379", size = 11646758, upload_time = "2025-06-05T22:02:09.51Z" }, - { url = "https://files.pythonhosted.org/packages/58/d8/f3249232fa79a70cb40595282813e61453c1e76da3e1a44b77a63dd8d0cb/scikit_learn-1.7.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f39f6a811bf3f15177b66c82cbe0d7b1ebad9f190737dcdef77cfca1ea3c19c", size = 10673971, upload_time = "2025-06-05T22:02:12.217Z" }, - { url = "https://files.pythonhosted.org/packages/67/93/eb14c50533bea2f77758abe7d60a10057e5f2e2cdcf0a75a14c6bc19c734/scikit_learn-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63017a5f9a74963d24aac7590287149a8d0f1a0799bbe7173c0d8ba1523293c0", size = 11818428, upload_time = "2025-06-05T22:02:14.947Z" }, - { url = "https://files.pythonhosted.org/packages/08/17/804cc13b22a8663564bb0b55fb89e661a577e4e88a61a39740d58b909efe/scikit_learn-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f8a0b1e73e9a08b7cc498bb2aeab36cdc1f571f8ab2b35c6e5d1c7115d97d", size = 12505887, upload_time = "2025-06-05T22:02:17.824Z" }, - { url = "https://files.pythonhosted.org/packages/68/c7/4e956281a077f4835458c3f9656c666300282d5199039f26d9de1dabd9be/scikit_learn-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:34cc8d9d010d29fb2b7cbcd5ccc24ffdd80515f65fe9f1e4894ace36b267ce19", size = 10668129, upload_time = "2025-06-05T22:02:20.536Z" }, - { url = "https://files.pythonhosted.org/packages/9a/c3/a85dcccdaf1e807e6f067fa95788a6485b0491d9ea44fd4c812050d04f45/scikit_learn-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5b7974f1f32bc586c90145df51130e02267e4b7e77cab76165c76cf43faca0d9", size = 11559841, upload_time = "2025-06-05T22:02:23.308Z" }, - { url = "https://files.pythonhosted.org/packages/d8/57/eea0de1562cc52d3196eae51a68c5736a31949a465f0b6bb3579b2d80282/scikit_learn-1.7.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:014e07a23fe02e65f9392898143c542a50b6001dbe89cb867e19688e468d049b", size = 10616463, upload_time = "2025-06-05T22:02:26.068Z" }, - { url = "https://files.pythonhosted.org/packages/10/a4/39717ca669296dfc3a62928393168da88ac9d8cbec88b6321ffa62c6776f/scikit_learn-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7e7ced20582d3a5516fb6f405fd1d254e1f5ce712bfef2589f51326af6346e8", size = 11766512, upload_time = "2025-06-05T22:02:28.689Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cd/a19722241d5f7b51e08351e1e82453e0057aeb7621b17805f31fcb57bb6c/scikit_learn-1.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1babf2511e6ffd695da7a983b4e4d6de45dce39577b26b721610711081850906", size = 12461075, upload_time = "2025-06-05T22:02:31.233Z" }, - { url = "https://files.pythonhosted.org/packages/f3/bc/282514272815c827a9acacbe5b99f4f1a4bc5961053719d319480aee0812/scikit_learn-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:5abd2acff939d5bd4701283f009b01496832d50ddafa83c90125a4e41c33e314", size = 10652517, upload_time = "2025-06-05T22:02:34.139Z" }, - { url = "https://files.pythonhosted.org/packages/ea/78/7357d12b2e4c6674175f9a09a3ba10498cde8340e622715bcc71e532981d/scikit_learn-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e39d95a929b112047c25b775035c8c234c5ca67e681ce60d12413afb501129f7", size = 12111822, upload_time = "2025-06-05T22:02:36.904Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0c/9c3715393343f04232f9d81fe540eb3831d0b4ec351135a145855295110f/scikit_learn-1.7.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:0521cb460426c56fee7e07f9365b0f45ec8ca7b2d696534ac98bfb85e7ae4775", size = 11325286, upload_time = "2025-06-05T22:02:39.739Z" }, - { url = "https://files.pythonhosted.org/packages/64/e0/42282ad3dd70b7c1a5f65c412ac3841f6543502a8d6263cae7b466612dc9/scikit_learn-1.7.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:317ca9f83acbde2883bd6bb27116a741bfcb371369706b4f9973cf30e9a03b0d", size = 12380865, upload_time = "2025-06-05T22:02:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d0/3ef4ab2c6be4aa910445cd09c5ef0b44512e3de2cfb2112a88bb647d2cf7/scikit_learn-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:126c09740a6f016e815ab985b21e3a0656835414521c81fc1a8da78b679bdb75", size = 11549609, upload_time = "2025-06-05T22:02:44.483Z" }, -] - -[[package]] -name = "scipy" -version = "1.15.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload_time = "2025-05-08T16:13:05.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload_time = "2025-05-08T16:04:20.849Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload_time = "2025-05-08T16:04:27.103Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload_time = "2025-05-08T16:04:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload_time = "2025-05-08T16:04:36.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload_time = "2025-05-08T16:04:43.546Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload_time = "2025-05-08T16:04:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload_time = "2025-05-08T16:04:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload_time = "2025-05-08T16:05:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload_time = "2025-05-08T16:05:08.166Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload_time = "2025-05-08T16:05:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload_time = "2025-05-08T16:05:20.152Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload_time = "2025-05-08T16:05:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload_time = "2025-05-08T16:05:29.313Z" }, - { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload_time = "2025-05-08T16:05:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload_time = "2025-05-08T16:05:40.762Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload_time = "2025-05-08T16:05:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload_time = "2025-05-08T16:05:54.22Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload_time = "2025-05-08T16:06:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload_time = "2025-05-08T16:06:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload_time = "2025-05-08T16:06:11.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload_time = "2025-05-08T16:06:15.97Z" }, - { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload_time = "2025-05-08T16:06:20.394Z" }, - { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload_time = "2025-05-08T16:06:26.159Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload_time = "2025-05-08T16:06:32.778Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload_time = "2025-05-08T16:06:39.249Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload_time = "2025-05-08T16:06:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload_time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload_time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload_time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload_time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload_time = "2025-05-08T16:07:14.091Z" }, - { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload_time = "2025-05-08T16:07:19.427Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload_time = "2025-05-08T16:07:25.712Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload_time = "2025-05-08T16:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload_time = "2025-05-08T16:07:38.002Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload_time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload_time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload_time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload_time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload_time = "2025-05-08T16:07:58.506Z" }, - { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload_time = "2025-05-08T16:08:03.929Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload_time = "2025-05-08T16:08:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload_time = "2025-05-08T16:08:15.34Z" }, - { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload_time = "2025-05-08T16:08:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload_time = "2025-05-08T16:08:27.627Z" }, -] - -[[package]] -name = "scipy" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/18/b06a83f0c5ee8cddbde5e3f3d0bb9b702abfa5136ef6d4620ff67df7eee5/scipy-1.16.0.tar.gz", hash = "sha256:b5ef54021e832869c8cfb03bc3bf20366cbcd426e02a58e8a58d7584dfbb8f62", size = 30581216, upload_time = "2025-06-22T16:27:55.782Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/f8/53fc4884df6b88afd5f5f00240bdc49fee2999c7eff3acf5953eb15bc6f8/scipy-1.16.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:deec06d831b8f6b5fb0b652433be6a09db29e996368ce5911faf673e78d20085", size = 36447362, upload_time = "2025-06-22T16:18:17.817Z" }, - { url = "https://files.pythonhosted.org/packages/c9/25/fad8aa228fa828705142a275fc593d701b1817c98361a2d6b526167d07bc/scipy-1.16.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d30c0fe579bb901c61ab4bb7f3eeb7281f0d4c4a7b52dbf563c89da4fd2949be", size = 28547120, upload_time = "2025-06-22T16:18:24.117Z" }, - { url = "https://files.pythonhosted.org/packages/8d/be/d324ddf6b89fd1c32fecc307f04d095ce84abb52d2e88fab29d0cd8dc7a8/scipy-1.16.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b2243561b45257f7391d0f49972fca90d46b79b8dbcb9b2cb0f9df928d370ad4", size = 20818922, upload_time = "2025-06-22T16:18:28.035Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e0/cf3f39e399ac83fd0f3ba81ccc5438baba7cfe02176be0da55ff3396f126/scipy-1.16.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:e6d7dfc148135e9712d87c5f7e4f2ddc1304d1582cb3a7d698bbadedb61c7afd", size = 23409695, upload_time = "2025-06-22T16:18:32.497Z" }, - { url = "https://files.pythonhosted.org/packages/5b/61/d92714489c511d3ffd6830ac0eb7f74f243679119eed8b9048e56b9525a1/scipy-1.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90452f6a9f3fe5a2cf3748e7be14f9cc7d9b124dce19667b54f5b429d680d539", size = 33444586, upload_time = "2025-06-22T16:18:37.992Z" }, - { url = "https://files.pythonhosted.org/packages/af/2c/40108915fd340c830aee332bb85a9160f99e90893e58008b659b9f3dddc0/scipy-1.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2f0bf2f58031c8701a8b601df41701d2a7be17c7ffac0a4816aeba89c4cdac8", size = 35284126, upload_time = "2025-06-22T16:18:43.605Z" }, - { url = "https://files.pythonhosted.org/packages/d3/30/e9eb0ad3d0858df35d6c703cba0a7e16a18a56a9e6b211d861fc6f261c5f/scipy-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c4abb4c11fc0b857474241b812ce69ffa6464b4bd8f4ecb786cf240367a36a7", size = 35608257, upload_time = "2025-06-22T16:18:49.09Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ff/950ee3e0d612b375110d8cda211c1f787764b4c75e418a4b71f4a5b1e07f/scipy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b370f8f6ac6ef99815b0d5c9f02e7ade77b33007d74802efc8316c8db98fd11e", size = 38040541, upload_time = "2025-06-22T16:18:55.077Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c9/750d34788288d64ffbc94fdb4562f40f609d3f5ef27ab4f3a4ad00c9033e/scipy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:a16ba90847249bedce8aa404a83fb8334b825ec4a8e742ce6012a7a5e639f95c", size = 38570814, upload_time = "2025-06-22T16:19:00.912Z" }, - { url = "https://files.pythonhosted.org/packages/01/c0/c943bc8d2bbd28123ad0f4f1eef62525fa1723e84d136b32965dcb6bad3a/scipy-1.16.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:7eb6bd33cef4afb9fa5f1fb25df8feeb1e52d94f21a44f1d17805b41b1da3180", size = 36459071, upload_time = "2025-06-22T16:19:06.605Z" }, - { url = "https://files.pythonhosted.org/packages/99/0d/270e2e9f1a4db6ffbf84c9a0b648499842046e4e0d9b2275d150711b3aba/scipy-1.16.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:1dbc8fdba23e4d80394ddfab7a56808e3e6489176d559c6c71935b11a2d59db1", size = 28490500, upload_time = "2025-06-22T16:19:11.775Z" }, - { url = "https://files.pythonhosted.org/packages/1c/22/01d7ddb07cff937d4326198ec8d10831367a708c3da72dfd9b7ceaf13028/scipy-1.16.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7dcf42c380e1e3737b343dec21095c9a9ad3f9cbe06f9c05830b44b1786c9e90", size = 20762345, upload_time = "2025-06-22T16:19:15.813Z" }, - { url = "https://files.pythonhosted.org/packages/34/7f/87fd69856569ccdd2a5873fe5d7b5bbf2ad9289d7311d6a3605ebde3a94b/scipy-1.16.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26ec28675f4a9d41587266084c626b02899db373717d9312fa96ab17ca1ae94d", size = 23418563, upload_time = "2025-06-22T16:19:20.746Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f1/e4f4324fef7f54160ab749efbab6a4bf43678a9eb2e9817ed71a0a2fd8de/scipy-1.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:952358b7e58bd3197cfbd2f2f2ba829f258404bdf5db59514b515a8fe7a36c52", size = 33203951, upload_time = "2025-06-22T16:19:25.813Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f0/b6ac354a956384fd8abee2debbb624648125b298f2c4a7b4f0d6248048a5/scipy-1.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03931b4e870c6fef5b5c0970d52c9f6ddd8c8d3e934a98f09308377eba6f3824", size = 35070225, upload_time = "2025-06-22T16:19:31.416Z" }, - { url = "https://files.pythonhosted.org/packages/e5/73/5cbe4a3fd4bc3e2d67ffad02c88b83edc88f381b73ab982f48f3df1a7790/scipy-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:512c4f4f85912767c351a0306824ccca6fd91307a9f4318efe8fdbd9d30562ef", size = 35389070, upload_time = "2025-06-22T16:19:37.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/e8/a60da80ab9ed68b31ea5a9c6dfd3c2f199347429f229bf7f939a90d96383/scipy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e69f798847e9add03d512eaf5081a9a5c9a98757d12e52e6186ed9681247a1ac", size = 37825287, upload_time = "2025-06-22T16:19:43.375Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b5/29fece1a74c6a94247f8a6fb93f5b28b533338e9c34fdcc9cfe7a939a767/scipy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:adf9b1999323ba335adc5d1dc7add4781cb5a4b0ef1e98b79768c05c796c4e49", size = 38431929, upload_time = "2025-06-22T16:19:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/46/95/0746417bc24be0c2a7b7563946d61f670a3b491b76adede420e9d173841f/scipy-1.16.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:e9f414cbe9ca289a73e0cc92e33a6a791469b6619c240aa32ee18abdce8ab451", size = 36418162, upload_time = "2025-06-22T16:19:56.3Z" }, - { url = "https://files.pythonhosted.org/packages/19/5a/914355a74481b8e4bbccf67259bbde171348a3f160b67b4945fbc5f5c1e5/scipy-1.16.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:bbba55fb97ba3cdef9b1ee973f06b09d518c0c7c66a009c729c7d1592be1935e", size = 28465985, upload_time = "2025-06-22T16:20:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/46/63477fc1246063855969cbefdcee8c648ba4b17f67370bd542ba56368d0b/scipy-1.16.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:58e0d4354eacb6004e7aa1cd350e5514bd0270acaa8d5b36c0627bb3bb486974", size = 20737961, upload_time = "2025-06-22T16:20:05.913Z" }, - { url = "https://files.pythonhosted.org/packages/93/86/0fbb5588b73555e40f9d3d6dde24ee6fac7d8e301a27f6f0cab9d8f66ff2/scipy-1.16.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b2094ec975c80efc273567436e16bb794660509c12c6a31eb5c195cbf4b6dc", size = 23377941, upload_time = "2025-06-22T16:20:10.668Z" }, - { url = "https://files.pythonhosted.org/packages/ca/80/a561f2bf4c2da89fa631b3cbf31d120e21ea95db71fd9ec00cb0247c7a93/scipy-1.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b65d232157a380fdd11a560e7e21cde34fdb69d65c09cb87f6cc024ee376351", size = 33196703, upload_time = "2025-06-22T16:20:16.097Z" }, - { url = "https://files.pythonhosted.org/packages/11/6b/3443abcd0707d52e48eb315e33cc669a95e29fc102229919646f5a501171/scipy-1.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d8747f7736accd39289943f7fe53a8333be7f15a82eea08e4afe47d79568c32", size = 35083410, upload_time = "2025-06-22T16:20:21.734Z" }, - { url = "https://files.pythonhosted.org/packages/20/ab/eb0fc00e1e48961f1bd69b7ad7e7266896fe5bad4ead91b5fc6b3561bba4/scipy-1.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb9f147a1b8529bb7fec2a85cf4cf42bdfadf9e83535c309a11fdae598c88e8b", size = 35387829, upload_time = "2025-06-22T16:20:27.548Z" }, - { url = "https://files.pythonhosted.org/packages/57/9e/d6fc64e41fad5d481c029ee5a49eefc17f0b8071d636a02ceee44d4a0de2/scipy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d2b83c37edbfa837a8923d19c749c1935ad3d41cf196006a24ed44dba2ec4358", size = 37841356, upload_time = "2025-06-22T16:20:35.112Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a7/4c94bbe91f12126b8bf6709b2471900577b7373a4fd1f431f28ba6f81115/scipy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:79a3c13d43c95aa80b87328a46031cf52508cf5f4df2767602c984ed1d3c6bbe", size = 38403710, upload_time = "2025-06-22T16:21:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/47/20/965da8497f6226e8fa90ad3447b82ed0e28d942532e92dd8b91b43f100d4/scipy-1.16.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:f91b87e1689f0370690e8470916fe1b2308e5b2061317ff76977c8f836452a47", size = 36813833, upload_time = "2025-06-22T16:20:43.925Z" }, - { url = "https://files.pythonhosted.org/packages/28/f4/197580c3dac2d234e948806e164601c2df6f0078ed9f5ad4a62685b7c331/scipy-1.16.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:88a6ca658fb94640079e7a50b2ad3b67e33ef0f40e70bdb7dc22017dae73ac08", size = 28974431, upload_time = "2025-06-22T16:20:51.302Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fc/e18b8550048d9224426e76906694c60028dbdb65d28b1372b5503914b89d/scipy-1.16.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ae902626972f1bd7e4e86f58fd72322d7f4ec7b0cfc17b15d4b7006efc385176", size = 21246454, upload_time = "2025-06-22T16:20:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/8c/48/07b97d167e0d6a324bfd7484cd0c209cc27338b67e5deadae578cf48e809/scipy-1.16.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:8cb824c1fc75ef29893bc32b3ddd7b11cf9ab13c1127fe26413a05953b8c32ed", size = 23772979, upload_time = "2025-06-22T16:21:03.363Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4f/9efbd3f70baf9582edf271db3002b7882c875ddd37dc97f0f675ad68679f/scipy-1.16.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:de2db7250ff6514366a9709c2cba35cb6d08498e961cba20d7cff98a7ee88938", size = 33341972, upload_time = "2025-06-22T16:21:11.14Z" }, - { url = "https://files.pythonhosted.org/packages/3f/dc/9e496a3c5dbe24e76ee24525155ab7f659c20180bab058ef2c5fa7d9119c/scipy-1.16.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e85800274edf4db8dd2e4e93034f92d1b05c9421220e7ded9988b16976f849c1", size = 35185476, upload_time = "2025-06-22T16:21:19.156Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b3/21001cff985a122ba434c33f2c9d7d1dc3b669827e94f4fc4e1fe8b9dfd8/scipy-1.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4f720300a3024c237ace1cb11f9a84c38beb19616ba7c4cdcd771047a10a1706", size = 35570990, upload_time = "2025-06-22T16:21:27.797Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d3/7ba42647d6709251cdf97043d0c107e0317e152fa2f76873b656b509ff55/scipy-1.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aad603e9339ddb676409b104c48a027e9916ce0d2838830691f39552b38a352e", size = 37950262, upload_time = "2025-06-22T16:21:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c4/231cac7a8385394ebbbb4f1ca662203e9d8c332825ab4f36ffc3ead09a42/scipy-1.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f56296fefca67ba605fd74d12f7bd23636267731a72cb3947963e76b8c0a25db", size = 38515076, upload_time = "2025-06-22T16:21:45.694Z" }, -] - -[[package]] -name = "semver" -version = "3.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload_time = "2025-01-24T13:19:27.617Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload_time = "2025-01-24T13:19:24.949Z" }, -] - -[[package]] -name = "sentencepiece" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/d2/b9c7ca067c26d8ff085d252c89b5f69609ca93fb85a00ede95f4857865d4/sentencepiece-0.2.0.tar.gz", hash = "sha256:a52c19171daaf2e697dc6cbe67684e0fa341b1248966f6aebb541de654d15843", size = 2632106, upload_time = "2024-02-19T17:06:47.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/71/98648c3b64b23edb5403f74bcc906ad21766872a6e1ada26ea3f1eb941ab/sentencepiece-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:188779e1298a1c8b8253c7d3ad729cb0a9891e5cef5e5d07ce4592c54869e227", size = 2408979, upload_time = "2024-02-19T17:05:34.651Z" }, - { url = "https://files.pythonhosted.org/packages/77/9f/7efbaa6d4c0c718a9affbecc536b03ca62f99f421bdffb531c16030e2d2b/sentencepiece-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bed9cf85b296fa2b76fc2547b9cbb691a523864cebaee86304c43a7b4cb1b452", size = 1238845, upload_time = "2024-02-19T17:05:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e4/c2541027a43ec6962ba9b601805d17ba3f86b38bdeae0e8ac65a2981e248/sentencepiece-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7b67e724bead13f18db6e1d10b6bbdc454af574d70efbb36f27d90387be1ca3", size = 1181472, upload_time = "2024-02-19T17:05:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/fd/46/316c1ba6c52b97de76aff7b9da678f7afbb52136afb2987c474d95630e65/sentencepiece-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fde4b08cfe237be4484c6c7c2e2c75fb862cfeab6bd5449ce4caeafd97b767a", size = 1259151, upload_time = "2024-02-19T17:05:42.594Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5a/3c48738a0835d76dd06c62b6ac48d39c923cde78dd0f587353bdcbb99851/sentencepiece-0.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c378492056202d1c48a4979650981635fd97875a00eabb1f00c6a236b013b5e", size = 1355931, upload_time = "2024-02-19T17:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/a6/27/33019685023221ca8ed98e8ceb7ae5e166032686fa3662c68f1f1edf334e/sentencepiece-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1380ce6540a368de2ef6d7e6ba14ba8f3258df650d39ba7d833b79ee68a52040", size = 1301537, upload_time = "2024-02-19T17:05:46.713Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e4/55f97cef14293171fef5f96e96999919ab5b4d1ce95b53547ad653d7e3bf/sentencepiece-0.2.0-cp310-cp310-win32.whl", hash = "sha256:a1151d6a6dd4b43e552394aed0edfe9292820272f0194bd56c7c1660a0c06c3d", size = 936747, upload_time = "2024-02-19T17:05:48.705Z" }, - { url = "https://files.pythonhosted.org/packages/85/f4/4ef1a6e0e9dbd8a60780a91df8b7452ada14cfaa0e17b3b8dfa42cecae18/sentencepiece-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:d490142b0521ef22bc1085f061d922a2a6666175bb6b42e588ff95c0db6819b2", size = 991525, upload_time = "2024-02-19T17:05:55.145Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/8f8885168a47a02eba1455bd3f4f169f50ad5b8cebd2402d0f5e20854d04/sentencepiece-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17982700c4f6dbb55fa3594f3d7e5dd1c8659a274af3738e33c987d2a27c9d5c", size = 2409036, upload_time = "2024-02-19T17:05:58.021Z" }, - { url = "https://files.pythonhosted.org/packages/0f/35/e63ba28062af0a3d688a9f128e407a1a2608544b2f480cb49bf7f4b1cbb9/sentencepiece-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7c867012c0e8bcd5bdad0f791609101cb5c66acb303ab3270218d6debc68a65e", size = 1238921, upload_time = "2024-02-19T17:06:06.434Z" }, - { url = "https://files.pythonhosted.org/packages/de/42/ae30952c4a0bd773e90c9bf2579f5533037c886dfc8ec68133d5694f4dd2/sentencepiece-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd6071249c74f779c5b27183295b9202f8dedb68034e716784364443879eaa6", size = 1181477, upload_time = "2024-02-19T17:06:09.292Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ac/2f2ab1d60bb2d795d054eebe5e3f24b164bc21b5a9b75fba7968b3b91b5a/sentencepiece-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f90c55a65013cbb8f4d7aab0599bf925cde4adc67ae43a0d323677b5a1c6cb", size = 1259182, upload_time = "2024-02-19T17:06:16.459Z" }, - { url = "https://files.pythonhosted.org/packages/45/fb/14633c6ecf262c468759ffcdb55c3a7ee38fe4eda6a70d75ee7c7d63c58b/sentencepiece-0.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b293734059ef656dcd65be62ff771507bea8fed0a711b6733976e1ed3add4553", size = 1355537, upload_time = "2024-02-19T17:06:19.274Z" }, - { url = "https://files.pythonhosted.org/packages/fb/12/2f5c8d4764b00033cf1c935b702d3bb878d10be9f0b87f0253495832d85f/sentencepiece-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58b47f933aca74c6a60a79dcb21d5b9e47416256c795c2d58d55cec27f9551d", size = 1301464, upload_time = "2024-02-19T17:06:21.796Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/67afc0bde24f6dcb3acdea0dd8dcdf4b8b0db240f6bacd39378bd32d09f8/sentencepiece-0.2.0-cp311-cp311-win32.whl", hash = "sha256:c581258cf346b327c62c4f1cebd32691826306f6a41d8c4bec43b010dee08e75", size = 936749, upload_time = "2024-02-19T17:06:24.167Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f6/587c62fd21fc988555b85351f50bbde43a51524caafd63bc69240ded14fd/sentencepiece-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:0993dbc665f4113017892f1b87c3904a44d0640eda510abcacdfb07f74286d36", size = 991520, upload_time = "2024-02-19T17:06:26.936Z" }, - { url = "https://files.pythonhosted.org/packages/27/5a/141b227ed54293360a9ffbb7bf8252b4e5efc0400cdeac5809340e5d2b21/sentencepiece-0.2.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ea5f536e32ea8ec96086ee00d7a4a131ce583a1b18d130711707c10e69601cb2", size = 2409370, upload_time = "2024-02-19T17:06:29.315Z" }, - { url = "https://files.pythonhosted.org/packages/2e/08/a4c135ad6fc2ce26798d14ab72790d66e813efc9589fd30a5316a88ca8d5/sentencepiece-0.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0cb51f53b6aae3c36bafe41e86167c71af8370a039f542c43b0cce5ef24a68c", size = 1239288, upload_time = "2024-02-19T17:06:31.674Z" }, - { url = "https://files.pythonhosted.org/packages/49/0a/2fe387f825ac5aad5a0bfe221904882106cac58e1b693ba7818785a882b6/sentencepiece-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3212121805afc58d8b00ab4e7dd1f8f76c203ddb9dc94aa4079618a31cf5da0f", size = 1181597, upload_time = "2024-02-19T17:06:33.763Z" }, - { url = "https://files.pythonhosted.org/packages/cc/38/e4698ee2293fe4835dc033c49796a39b3eebd8752098f6bd0aa53a14af1f/sentencepiece-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3149e3066c2a75e0d68a43eb632d7ae728c7925b517f4c05c40f6f7280ce08", size = 1259220, upload_time = "2024-02-19T17:06:35.85Z" }, - { url = "https://files.pythonhosted.org/packages/12/24/fd7ef967c9dad2f6e6e5386d0cadaf65cda8b7be6e3861a9ab3121035139/sentencepiece-0.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:632f3594d3e7ac8b367bca204cb3fd05a01d5b21455acd097ea4c0e30e2f63d7", size = 1355962, upload_time = "2024-02-19T17:06:38.616Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d2/18246f43ca730bb81918f87b7e886531eda32d835811ad9f4657c54eee35/sentencepiece-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f295105c6bdbb05bd5e1b0cafbd78ff95036f5d3641e7949455a3f4e5e7c3109", size = 1301706, upload_time = "2024-02-19T17:06:40.712Z" }, - { url = "https://files.pythonhosted.org/packages/8a/47/ca237b562f420044ab56ddb4c278672f7e8c866e183730a20e413b38a989/sentencepiece-0.2.0-cp312-cp312-win32.whl", hash = "sha256:fb89f811e5efd18bab141afc3fea3de141c3f69f3fe9e898f710ae7fe3aab251", size = 936941, upload_time = "2024-02-19T17:06:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/c6/97/d159c32642306ee2b70732077632895438867b3b6df282354bd550cf2a67/sentencepiece-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7a673a72aab81fef5ebe755c6e0cc60087d1f3a4700835d40537183c1703a45f", size = 991994, upload_time = "2024-02-19T17:06:45.01Z" }, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload_time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload_time = "2025-05-27T00:56:49.664Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "simple-speaker-recognition" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiohttp" }, - { name = "alembic" }, - { name = "deepgram-sdk" }, - { name = "easy-audio-interfaces" }, - { name = "fastapi" }, - { name = "librosa" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "plotly" }, - { name = "pyannote-audio" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pydub" }, - { name = "python-multipart" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "soundfile" }, - { name = "sqlalchemy" }, - { name = "umap-learn" }, - { name = "uvicorn" }, - { name = "websockets" }, - { name = "yt-dlp" }, -] - -[package.optional-dependencies] -local-audio = [ - { name = "easy-audio-interfaces", extra = ["local-audio"] }, -] - -[package.dev-dependencies] -cpu = [ - { name = "faiss-cpu" }, - { name = "torch" }, - { name = "torchaudio" }, -] -dev = [ - { name = "black" }, - { name = "isort" }, -] -gpu = [ - { name = "faiss-cpu" }, - { name = "torch" }, - { name = "torchaudio" }, -] -test = [ - { name = "pytest" }, - { name = "requests" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiohttp", specifier = ">=3.8.0" }, - { name = "alembic", specifier = ">=1.13.0" }, - { name = "deepgram-sdk", specifier = ">=4.7.0" }, - { name = "easy-audio-interfaces", specifier = ">=0.7.1" }, - { name = "easy-audio-interfaces", extras = ["local-audio"], marker = "extra == 'local-audio'", specifier = ">=0.7.1" }, - { name = "fastapi", specifier = ">=0.115.12" }, - { name = "librosa", specifier = ">=0.10.0" }, - { name = "matplotlib", specifier = ">=3.8.0" }, - { name = "numpy", specifier = ">=1.26" }, - { name = "pandas", specifier = ">=2.2.0" }, - { name = "plotly", specifier = ">=5.18.0" }, - { name = "pyannote-audio", specifier = ">=3.3.2" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pydantic-settings", specifier = ">=2.10.1" }, - { name = "pydub", specifier = ">=0.25.1" }, - { name = "python-multipart", specifier = ">=0.0.6" }, - { name = "scikit-learn", specifier = ">=1.4.0" }, - { name = "scipy", specifier = ">=1.10.0" }, - { name = "soundfile", specifier = ">=0.12" }, - { name = "sqlalchemy", specifier = ">=2.0.0" }, - { name = "umap-learn", specifier = ">=0.5.3" }, - { name = "uvicorn", specifier = ">=0.34.2" }, - { name = "websockets", specifier = ">=12.0" }, - { name = "yt-dlp", specifier = ">=2025.7.21" }, -] -provides-extras = ["local-audio"] - -[package.metadata.requires-dev] -cpu = [ - { name = "faiss-cpu", specifier = ">=1.8" }, - { name = "torch", specifier = ">=2.0.0" }, - { name = "torchaudio", specifier = ">=2.0.0" }, -] -dev = [ - { name = "black", specifier = ">=25.1.0" }, - { name = "isort", specifier = ">=6.0.1" }, -] -gpu = [ - { name = "faiss-cpu", specifier = ">=1.8" }, - { name = "torch", specifier = ">=2.0.0" }, - { name = "torchaudio", specifier = ">=2.0.0" }, -] -test = [ - { name = "pytest" }, - { name = "requests" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload_time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload_time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload_time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload_time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "soundfile" -version = "0.13.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload_time = "2025-01-25T09:17:04.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload_time = "2025-01-25T09:16:44.235Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload_time = "2025-01-25T09:16:47.583Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload_time = "2025-01-25T09:16:49.662Z" }, - { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload_time = "2025-01-25T09:16:53.018Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload_time = "2025-01-25T09:16:54.872Z" }, - { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload_time = "2025-01-25T09:16:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload_time = "2025-01-25T09:16:59.573Z" }, -] - -[[package]] -name = "soxr" -version = "0.5.0.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/c0/4429bf9b3be10e749149e286aa5c53775399ec62891c6b970456c6dca325/soxr-0.5.0.post1.tar.gz", hash = "sha256:7092b9f3e8a416044e1fa138c8172520757179763b85dc53aa9504f4813cff73", size = 170853, upload_time = "2024-08-31T03:43:33.058Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/96/bee1eb69d66fc28c3b219ba9b8674b49d3dcc6cd2f9b3e5114ff28cf88b5/soxr-0.5.0.post1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:7406d782d85f8cf64e66b65e6b7721973de8a1dc50b9e88bc2288c343a987484", size = 203841, upload_time = "2024-08-31T03:42:59.186Z" }, - { url = "https://files.pythonhosted.org/packages/1f/5d/56ad3d181d30d103128f65cc44f4c4e24c199e6d5723e562704e47c89f78/soxr-0.5.0.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa0a382fb8d8e2afed2c1642723b2d2d1b9a6728ff89f77f3524034c8885b8c9", size = 160192, upload_time = "2024-08-31T03:43:01.128Z" }, - { url = "https://files.pythonhosted.org/packages/7f/09/e43c39390e26b4c1b8d46f8a1c252a5077fa9f81cc2326b03c3d2b85744e/soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b01d3efb95a2851f78414bcd00738b0253eec3f5a1e5482838e965ffef84969", size = 221176, upload_time = "2024-08-31T03:43:02.663Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e6/059070b4cdb7fdd8ffbb67c5087c1da9716577127fb0540cd11dbf77923b/soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc049b0a151a65aa75b92f0ac64bb2dba785d16b78c31c2b94e68c141751d6d", size = 252779, upload_time = "2024-08-31T03:43:04.582Z" }, - { url = "https://files.pythonhosted.org/packages/ad/64/86082b6372e5ff807dfa79b857da9f50e94e155706000daa43fdc3b59851/soxr-0.5.0.post1-cp310-cp310-win_amd64.whl", hash = "sha256:97f269bc26937c267a2ace43a77167d0c5c8bba5a2b45863bb6042b5b50c474e", size = 166881, upload_time = "2024-08-31T03:43:06.255Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/dc62dae260a77603e8257e9b79078baa2ca4c0b4edc6f9f82c9113d6ef18/soxr-0.5.0.post1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6fb77b626773a966e3d8f6cb24f6f74b5327fa5dc90f1ff492450e9cdc03a378", size = 203648, upload_time = "2024-08-31T03:43:08.339Z" }, - { url = "https://files.pythonhosted.org/packages/0e/48/3e88329a695f6e0e38a3b171fff819d75d7cc055dae1ec5d5074f34d61e3/soxr-0.5.0.post1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:39e0f791ba178d69cd676485dbee37e75a34f20daa478d90341ecb7f6d9d690f", size = 159933, upload_time = "2024-08-31T03:43:10.053Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a5/6b439164be6871520f3d199554568a7656e96a867adbbe5bac179caf5776/soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f0b558f445ba4b64dbcb37b5f803052eee7d93b1dbbbb97b3ec1787cb5a28eb", size = 221010, upload_time = "2024-08-31T03:43:11.839Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e5/400e3bf7f29971abad85cb877e290060e5ec61fccd2fa319e3d85709c1be/soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca6903671808e0a6078b0d146bb7a2952b118dfba44008b2aa60f221938ba829", size = 252471, upload_time = "2024-08-31T03:43:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/6a7e91bea7e6ca193ee429869b8f18548cd79759e064021ecb5756024c7c/soxr-0.5.0.post1-cp311-cp311-win_amd64.whl", hash = "sha256:c4d8d5283ed6f5efead0df2c05ae82c169cfdfcf5a82999c2d629c78b33775e8", size = 166723, upload_time = "2024-08-31T03:43:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e3/d422d279e51e6932e7b64f1170a4f61a7ee768e0f84c9233a5b62cd2c832/soxr-0.5.0.post1-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:fef509466c9c25f65eae0ce1e4b9ac9705d22c6038c914160ddaf459589c6e31", size = 199993, upload_time = "2024-08-31T03:43:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/20/f1/88adaca3c52e03bcb66b63d295df2e2d35bf355d19598c6ce84b20be7fca/soxr-0.5.0.post1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4704ba6b13a3f1e41d12acf192878384c1c31f71ce606829c64abdf64a8d7d32", size = 156373, upload_time = "2024-08-31T03:43:18.633Z" }, - { url = "https://files.pythonhosted.org/packages/b8/38/bad15a9e615215c8219652ca554b601663ac3b7ac82a284aca53ec2ff48c/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd052a66471a7335b22a6208601a9d0df7b46b8d087dce4ff6e13eed6a33a2a1", size = 216564, upload_time = "2024-08-31T03:43:20.789Z" }, - { url = "https://files.pythonhosted.org/packages/e1/1a/569ea0420a0c4801c2c8dd40d8d544989522f6014d51def689125f3f2935/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f16810dd649ab1f433991d2a9661e9e6a116c2b4101039b53b3c3e90a094fc", size = 248455, upload_time = "2024-08-31T03:43:22.165Z" }, - { url = "https://files.pythonhosted.org/packages/bc/10/440f1ba3d4955e0dc740bbe4ce8968c254a3d644d013eb75eea729becdb8/soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6", size = 164937, upload_time = "2024-08-31T03:43:23.671Z" }, -] - -[[package]] -name = "speechbrain" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, - { name = "hyperpyyaml" }, - { name = "joblib" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "sentencepiece" }, - { name = "torch" }, - { name = "torchaudio" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/10/87e666544a4e0cec7cbdc09f26948994831ae0f8bbc58de3bf53b68285ff/speechbrain-1.0.3.tar.gz", hash = "sha256:fcab3c6e90012cecb1eed40ea235733b550137e73da6bfa2340ba191ec714052", size = 747735, upload_time = "2025-04-07T17:17:06.749Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/13/e61f1085aebee17d5fc2df19fcc5177c10379be52578afbecdd615a831c9/speechbrain-1.0.3-py3-none-any.whl", hash = "sha256:9859d4c1b1fb3af3b85523c0c89f52e45a04f305622ed55f31aa32dd2fba19e9", size = 864091, upload_time = "2025-04-07T17:17:04.706Z" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.41" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64') or (platform_machine != 'AMD64' and platform_machine != 'WIN32' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'ppc64le' and platform_machine != 'win32' and platform_machine != 'x86_64' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'AMD64' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'WIN32' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'aarch64' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'amd64' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'ppc64le' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'win32' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu') or (platform_machine == 'x86_64' and extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload_time = "2025-05-14T17:10:32.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/12/d7c445b1940276a828efce7331cb0cb09d6e5f049651db22f4ebb0922b77/sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b", size = 2117967, upload_time = "2025-05-14T17:48:15.841Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b8/cb90f23157e28946b27eb01ef401af80a1fab7553762e87df51507eaed61/sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5", size = 2107583, upload_time = "2025-05-14T17:48:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/eef84283a1c8164a207d898e063edf193d36a24fb6a5bb3ce0634b92a1e8/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747", size = 3186025, upload_time = "2025-05-14T17:51:51.226Z" }, - { url = "https://files.pythonhosted.org/packages/bd/72/49d52bd3c5e63a1d458fd6d289a1523a8015adedbddf2c07408ff556e772/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30", size = 3186259, upload_time = "2025-05-14T17:55:22.526Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9e/e3ffc37d29a3679a50b6bbbba94b115f90e565a2b4545abb17924b94c52d/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29", size = 3126803, upload_time = "2025-05-14T17:51:53.277Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/56b21e363f6039978ae0b72690237b38383e4657281285a09456f313dd77/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11", size = 3148566, upload_time = "2025-05-14T17:55:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/3b/92/11b8e1b69bf191bc69e300a99badbbb5f2f1102f2b08b39d9eee2e21f565/sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda", size = 2086696, upload_time = "2025-05-14T17:55:59.136Z" }, - { url = "https://files.pythonhosted.org/packages/5c/88/2d706c9cc4502654860f4576cd54f7db70487b66c3b619ba98e0be1a4642/sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08", size = 2110200, upload_time = "2025-05-14T17:56:00.757Z" }, - { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232, upload_time = "2025-05-14T17:48:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897, upload_time = "2025-05-14T17:48:21.634Z" }, - { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313, upload_time = "2025-05-14T17:51:56.205Z" }, - { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807, upload_time = "2025-05-14T17:55:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632, upload_time = "2025-05-14T17:51:59.384Z" }, - { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642, upload_time = "2025-05-14T17:55:29.901Z" }, - { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475, upload_time = "2025-05-14T17:56:02.095Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903, upload_time = "2025-05-14T17:56:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload_time = "2025-05-14T17:55:24.854Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload_time = "2025-05-14T17:55:28.097Z" }, - { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload_time = "2025-05-14T17:50:38.227Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload_time = "2025-05-14T17:51:49.829Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload_time = "2025-05-14T17:50:39.774Z" }, - { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload_time = "2025-05-14T17:51:51.736Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload_time = "2025-05-14T17:55:49.915Z" }, - { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload_time = "2025-05-14T17:55:51.349Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload_time = "2025-05-14T17:55:31.177Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload_time = "2025-05-14T17:55:34.921Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload_time = "2025-05-14T17:50:41.418Z" }, - { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload_time = "2025-05-14T17:51:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload_time = "2025-05-14T17:50:43.483Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload_time = "2025-05-14T17:51:57.308Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload_time = "2025-05-14T17:55:52.69Z" }, - { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload_time = "2025-05-14T17:55:54.495Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload_time = "2025-05-14T17:39:42.154Z" }, -] - -[[package]] -name = "standard-aifc" -version = "3.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "standard-chunk", marker = "python_full_version >= '3.13' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload_time = "2024-10-30T16:01:31.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload_time = "2024-10-30T16:01:07.071Z" }, -] - -[[package]] -name = "standard-chunk" -version = "3.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload_time = "2024-10-30T16:18:28.326Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload_time = "2024-10-30T16:18:26.694Z" }, -] - -[[package]] -name = "standard-sunau" -version = "3.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload_time = "2024-10-30T16:01:41.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload_time = "2024-10-30T16:01:28.003Z" }, -] - -[[package]] -name = "starlette" -version = "0.46.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload_time = "2025-04-13T13:56:17.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload_time = "2025-04-13T13:56:16.21Z" }, -] - -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload_time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload_time = "2025-04-27T18:04:59.103Z" }, -] - -[[package]] -name = "tabulate" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload_time = "2022-10-06T17:21:48.54Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload_time = "2022-10-06T17:21:44.262Z" }, -] - -[[package]] -name = "tensorboardx" -version = "2.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/c5/d4cc6e293fb837aaf9f76dd7745476aeba8ef7ef5146c3b3f9ee375fe7a5/tensorboardx-2.6.4.tar.gz", hash = "sha256:b163ccb7798b31100b9f5fa4d6bc22dad362d7065c2f24b51e50731adde86828", size = 4769801, upload_time = "2025-06-10T22:37:07.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/1d/b5d63f1a6b824282b57f7b581810d20b7a28ca951f2d5b59f1eb0782c12b/tensorboardx-2.6.4-py3-none-any.whl", hash = "sha256:5970cf3a1f0a6a6e8b180ccf46f3fe832b8a25a70b86e5a237048a7c0beb18e2", size = 87201, upload_time = "2025-06-10T22:37:05.44Z" }, -] - -[[package]] -name = "termcolor" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload_time = "2025-04-30T11:37:53.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload_time = "2025-04-30T11:37:52.382Z" }, -] - -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload_time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload_time = "2025-03-13T13:49:21.846Z" }, -] - -[[package]] -name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload_time = "2024-11-27T22:38:36.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload_time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload_time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload_time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload_time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload_time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload_time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload_time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload_time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload_time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload_time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload_time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload_time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload_time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload_time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload_time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload_time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload_time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload_time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload_time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload_time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload_time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload_time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload_time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload_time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload_time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload_time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload_time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload_time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload_time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload_time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" }, -] - -[[package]] -name = "torch" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.12'" }, - { name = "sympy" }, - { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/27/2e06cb52adf89fe6e020963529d17ed51532fc73c1e6d1b18420ef03338c/torch-2.7.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a103b5d782af5bd119b81dbcc7ffc6fa09904c423ff8db397a1e6ea8fd71508f", size = 99089441, upload_time = "2025-06-04T17:38:48.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7c/0a5b3aee977596459ec45be2220370fde8e017f651fecc40522fd478cb1e/torch-2.7.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fe955951bdf32d182ee8ead6c3186ad54781492bf03d547d31771a01b3d6fb7d", size = 821154516, upload_time = "2025-06-04T17:36:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/f9/91/3d709cfc5e15995fb3fe7a6b564ce42280d3a55676dad672205e94f34ac9/torch-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:885453d6fba67d9991132143bf7fa06b79b24352f4506fd4d10b309f53454162", size = 216093147, upload_time = "2025-06-04T17:39:38.132Z" }, - { url = "https://files.pythonhosted.org/packages/92/f6/5da3918414e07da9866ecb9330fe6ffdebe15cb9a4c5ada7d4b6e0a6654d/torch-2.7.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:d72acfdb86cee2a32c0ce0101606f3758f0d8bb5f8f31e7920dc2809e963aa7c", size = 68630914, upload_time = "2025-06-04T17:39:31.162Z" }, - { url = "https://files.pythonhosted.org/packages/11/56/2eae3494e3d375533034a8e8cf0ba163363e996d85f0629441fa9d9843fe/torch-2.7.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:236f501f2e383f1cb861337bdf057712182f910f10aeaf509065d54d339e49b2", size = 99093039, upload_time = "2025-06-04T17:39:06.963Z" }, - { url = "https://files.pythonhosted.org/packages/e5/94/34b80bd172d0072c9979708ccd279c2da2f55c3ef318eceec276ab9544a4/torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:06eea61f859436622e78dd0cdd51dbc8f8c6d76917a9cf0555a333f9eac31ec1", size = 821174704, upload_time = "2025-06-04T17:37:03.799Z" }, - { url = "https://files.pythonhosted.org/packages/50/9e/acf04ff375b0b49a45511c55d188bcea5c942da2aaf293096676110086d1/torch-2.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:8273145a2e0a3c6f9fd2ac36762d6ee89c26d430e612b95a99885df083b04e52", size = 216095937, upload_time = "2025-06-04T17:39:24.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2b/d36d57c66ff031f93b4fa432e86802f84991477e522adcdffd314454326b/torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:aea4fc1bf433d12843eb2c6b2204861f43d8364597697074c8d38ae2507f8730", size = 68640034, upload_time = "2025-06-04T17:39:17.989Z" }, - { url = "https://files.pythonhosted.org/packages/87/93/fb505a5022a2e908d81fe9a5e0aa84c86c0d5f408173be71c6018836f34e/torch-2.7.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ea1e518df4c9de73af7e8a720770f3628e7f667280bce2be7a16292697e3fa", size = 98948276, upload_time = "2025-06-04T17:39:12.852Z" }, - { url = "https://files.pythonhosted.org/packages/56/7e/67c3fe2b8c33f40af06326a3d6ae7776b3e3a01daa8f71d125d78594d874/torch-2.7.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c33360cfc2edd976c2633b3b66c769bdcbbf0e0b6550606d188431c81e7dd1fc", size = 821025792, upload_time = "2025-06-04T17:34:58.747Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/a37495502bc7a23bf34f89584fa5a78e25bae7b8da513bc1b8f97afb7009/torch-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:d8bf6e1856ddd1807e79dc57e54d3335f2b62e6f316ed13ed3ecfe1fc1df3d8b", size = 216050349, upload_time = "2025-06-04T17:38:59.709Z" }, - { url = "https://files.pythonhosted.org/packages/3a/60/04b77281c730bb13460628e518c52721257814ac6c298acd25757f6a175c/torch-2.7.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:787687087412c4bd68d315e39bc1223f08aae1d16a9e9771d95eabbb04ae98fb", size = 68645146, upload_time = "2025-06-04T17:38:52.97Z" }, - { url = "https://files.pythonhosted.org/packages/66/81/e48c9edb655ee8eb8c2a6026abdb6f8d2146abd1f150979ede807bb75dcb/torch-2.7.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:03563603d931e70722dce0e11999d53aa80a375a3d78e6b39b9f6805ea0a8d28", size = 98946649, upload_time = "2025-06-04T17:38:43.031Z" }, - { url = "https://files.pythonhosted.org/packages/3a/24/efe2f520d75274fc06b695c616415a1e8a1021d87a13c68ff9dce733d088/torch-2.7.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d632f5417b6980f61404a125b999ca6ebd0b8b4bbdbb5fbbba44374ab619a412", size = 821033192, upload_time = "2025-06-04T17:38:09.146Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d9/9c24d230333ff4e9b6807274f6f8d52a864210b52ec794c5def7925f4495/torch-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:23660443e13995ee93e3d844786701ea4ca69f337027b05182f5ba053ce43b38", size = 216055668, upload_time = "2025-06-04T17:38:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/e086ee36ddcef9299f6e708d3b6c8487c1651787bb9ee2939eb2a7f74911/torch-2.7.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0da4f4dba9f65d0d203794e619fe7ca3247a55ffdcbd17ae8fb83c8b2dc9b585", size = 68925988, upload_time = "2025-06-04T17:38:29.273Z" }, - { url = "https://files.pythonhosted.org/packages/69/6a/67090dcfe1cf9048448b31555af6efb149f7afa0a310a366adbdada32105/torch-2.7.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e08d7e6f21a617fe38eeb46dd2213ded43f27c072e9165dc27300c9ef9570934", size = 99028857, upload_time = "2025-06-04T17:37:50.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/1c/48b988870823d1cc381f15ec4e70ed3d65e043f43f919329b0045ae83529/torch-2.7.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:30207f672328a42df4f2174b8f426f354b2baa0b7cca3a0adb3d6ab5daf00dc8", size = 821098066, upload_time = "2025-06-04T17:37:33.939Z" }, - { url = "https://files.pythonhosted.org/packages/7b/eb/10050d61c9d5140c5dc04a89ed3257ef1a6b93e49dd91b95363d757071e0/torch-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:79042feca1c634aaf6603fe6feea8c6b30dfa140a6bbc0b973e2260c7e79a22e", size = 216336310, upload_time = "2025-06-04T17:36:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/beb45cdf5c4fc3ebe282bf5eafc8dfd925ead7299b3c97491900fe5ed844/torch-2.7.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:988b0cbc4333618a1056d2ebad9eb10089637b659eb645434d0809d8d937b946", size = 68645708, upload_time = "2025-06-04T17:34:39.852Z" }, -] - -[[package]] -name = "torch-audiomentations" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "julius" }, - { name = "torch" }, - { name = "torch-pitch-shift" }, - { name = "torchaudio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/31/8d/2f8fd7e34c75f5ee8de4310c3bd3f22270acd44d1f809e2fe7c12fbf35f8/torch_audiomentations-0.12.0.tar.gz", hash = "sha256:b02d4c5eb86376986a53eb405cca5e34f370ea9284411237508e720c529f7888", size = 52094, upload_time = "2025-01-15T09:07:01.071Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/9d/1ee04f49c15d2d632f6f7102061d7c07652858e6d91b58a091531034e84f/torch_audiomentations-0.12.0-py3-none-any.whl", hash = "sha256:1b80b91d2016ccf83979622cac8f702072a79b7dcc4c2bee40f00b26433a786b", size = 48506, upload_time = "2025-01-15T09:06:59.687Z" }, -] - -[[package]] -name = "torch-pitch-shift" -version = "1.2.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "primepy" }, - { name = "torch" }, - { name = "torchaudio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/a6/722a832bca75d5079f6731e005b3d0c2eec7c6c6863d030620952d143d57/torch_pitch_shift-1.2.5.tar.gz", hash = "sha256:6e1c7531f08d0f407a4c55e5ff8385a41355c5c5d27ab7fa08632e51defbd0ed", size = 4725, upload_time = "2024-09-25T19:10:12.922Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/4c/96ac2a09efb56cc3c41fb3ce9b6f4d8c0604499f7481d4a13a7b03e21382/torch_pitch_shift-1.2.5-py3-none-any.whl", hash = "sha256:6f8500cbc13f1c98b11cde1805ce5084f82cdd195c285f34287541f168a7c6a7", size = 5005, upload_time = "2024-09-25T19:10:11.521Z" }, -] - -[[package]] -name = "torchaudio" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "torch" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/bfc6d2b28ede6c4c5446901cfa4d98fa25b2606eb12e641baccec16fcde0/torchaudio-2.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4739af57d0eb94347d1c6a1b5668be78a7383afe826dde18a04883b9f9f263b1", size = 1842457, upload_time = "2025-06-04T17:44:12.073Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/35eea5138ccd4abf38b163743d5ab4a8b25349bafa8bdf3d629e7f3036b9/torchaudio-2.7.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c089dbfc14c5f47091b7bf3f6bf2bbac93b86619299d04d9c102f4ad53758990", size = 1680682, upload_time = "2025-06-04T17:44:11.056Z" }, - { url = "https://files.pythonhosted.org/packages/7d/dc/7569889c1fc95ebf18b0295bc4fdebafbbb89ba9e0018c7e9b0844bae011/torchaudio-2.7.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6bb1e6db22fa2aad6b89b2a455ec5c6dc31df2635dbfafa213394f8b07b09516", size = 3498891, upload_time = "2025-06-04T17:43:52.161Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e0/ff0ac4234798a0b6b1398fa878a2e7d22f1d06d4327feb312d9e77e079bd/torchaudio-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ba4df6e3ad35cb1e5bd162cf86b492526138f6476f5a06b10725b8880c618eb", size = 2483343, upload_time = "2025-06-04T17:43:57.779Z" }, - { url = "https://files.pythonhosted.org/packages/85/a2/52e6760d352584ae1ab139d97647bdc51d1eb7d480b688fe69c72616c956/torchaudio-2.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5a62f88c629035913f506df03f710c48fc8bb9637191933f27c67088d5ca136", size = 1849254, upload_time = "2025-06-04T17:44:05.392Z" }, - { url = "https://files.pythonhosted.org/packages/df/e6/0f3835895f9d0b8900ca4a7196932b13b74156ad9ffb76e7aacfc5bb4157/torchaudio-2.7.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:53bc4ba12e7468be34a7ca2ee837ee5c8bd5755b25c12f665af9339cae37e265", size = 1686156, upload_time = "2025-06-04T17:44:09.39Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c5/8ba8869ac5607bbd83ea864bda2c628f8b7b55a9200f8147687995e95a49/torchaudio-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f8bd69354a397753b9dea9699d9e1251f8496fbbdf3028c7086a57a615bf33c3", size = 3508053, upload_time = "2025-06-04T17:43:49.398Z" }, - { url = "https://files.pythonhosted.org/packages/78/cc/11709b2cbf841eda124918523088d9aaa1509ae4400f346192037e6de6c6/torchaudio-2.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:0ae0678ad27355eebea5a9fdd9ae9bfec444f8405f9b6c60026905ba3665c43a", size = 2488974, upload_time = "2025-06-04T17:44:04.294Z" }, - { url = "https://files.pythonhosted.org/packages/0b/d1/eb8bc3b3502dddb1b789567b7b19668b1d32817266887b9f381494cfe463/torchaudio-2.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9306dcfc4586cebd7647a93fe9a448e791c4f83934da616b9433b75597a1f978", size = 1846897, upload_time = "2025-06-04T17:44:07.79Z" }, - { url = "https://files.pythonhosted.org/packages/62/7d/6c15f15d3edc5271abc808f70713644b50f0f7bfb85a09dba8b5735fbad3/torchaudio-2.7.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d66bd76b226fdd4135c97650e1b7eb63fb7659b4ed0e3a778898e41dbba21b61", size = 1686680, upload_time = "2025-06-04T17:43:58.986Z" }, - { url = "https://files.pythonhosted.org/packages/48/65/0f46ba74cdc67ea9a8c37c8acfb5194d81639e481e85903c076bcd97188c/torchaudio-2.7.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9cbcdaab77ad9a73711acffee58f4eebc8a0685289a938a3fa6f660af9489aee", size = 3506966, upload_time = "2025-06-04T17:44:06.537Z" }, - { url = "https://files.pythonhosted.org/packages/52/29/06f887baf22cbba85ae331b71b110b115bf11b3968f5914a50c17dde5ab7/torchaudio-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:9cfb8f6ace8e01e2b89de74eb893ba5ce936b88b415383605b0a4d974009dec7", size = 2484265, upload_time = "2025-06-04T17:44:00.277Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ee/6e308868b9467e1b51da9d781cb73dd5aadca7c8b6256f88ce5d18a7fb77/torchaudio-2.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5f0599a507f4683546878ed9667e1b32d7ca3c8a957e4c15c6b302378ef4dee", size = 1847208, upload_time = "2025-06-04T17:44:01.365Z" }, - { url = "https://files.pythonhosted.org/packages/3a/f9/ca0e0960526e6deaa476d168b877480a3fbae5d44668a54de963a9800097/torchaudio-2.7.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:271f717844e5c7f9e05c8328de817bf90f46d83281c791e94f54d4edea2f5817", size = 1686311, upload_time = "2025-06-04T17:44:02.785Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ab/83f282ca5475ae34c58520a4a97b6d69438bc699d70d16432deb19791cda/torchaudio-2.7.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1862b063d8d4e55cb4862bcbd63568545f549825a3c5605bd312224c3ebb1919", size = 3507174, upload_time = "2025-06-04T17:43:46.526Z" }, - { url = "https://files.pythonhosted.org/packages/12/91/dbd17a6eda4b0504d9b4f1f721a1654456e39f7178b8462344f942100865/torchaudio-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb4deaa6f95acd5522912ed643303d0b86d79a6f15914362f5a5d49baaf5d13", size = 2484503, upload_time = "2025-06-04T17:43:48.169Z" }, - { url = "https://files.pythonhosted.org/packages/73/5e/da52d2fa9f7cc89512b63dd8a88fb3e097a89815f440cc16159b216ec611/torchaudio-2.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:18560955b8beb2a8d39a6bfae20a442337afcefb3dfd4ee007ce82233a796799", size = 1929983, upload_time = "2025-06-04T17:43:56.659Z" }, - { url = "https://files.pythonhosted.org/packages/f7/16/9d03dc62613f276f9666eb0609164287df23986b67d20b53e78d21a3d8d8/torchaudio-2.7.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1850475ef9101ea0b3593fe93ff6ee4e7a20598f6da6510761220b9fe56eb7fa", size = 1700436, upload_time = "2025-06-04T17:43:55.589Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/57a437fe41b302fc79b4eb78fdb3e480ff42c66270e7505eedf0b000969c/torchaudio-2.7.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98257fc14dd493ba5a3258fb6d61d27cd64a48ee79537c3964c4da26b9bf295f", size = 3521631, upload_time = "2025-06-04T17:43:50.628Z" }, - { url = "https://files.pythonhosted.org/packages/91/5e/9262a7e41e47bc87eb245c4fc485eb26ff41a05886b241c003440c9e0107/torchaudio-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c802e0dcbf38669007327bb52f065573cc5cac106eaca987f6e1a32e6282263a", size = 2534956, upload_time = "2025-06-04T17:43:42.324Z" }, -] - -[[package]] -name = "torchmetrics" -version = "1.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lightning-utilities" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "torch" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/22/8b16c4ec34d93ee15024924cbbe84fbd235bb3e1df2cc8f48c865c1528e7/torchmetrics-1.7.3.tar.gz", hash = "sha256:08450a19cdb67ba1608aac0b213e5dc73033e11b60ad4719696ebcede591621e", size = 566545, upload_time = "2025-06-13T15:39:37.498Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/f2/bed7da46003c26ed44fc7fa3ecc98a84216f0d4758e5e6a3693754d490d9/torchmetrics-1.7.3-py3-none-any.whl", hash = "sha256:7b6fd43e92f0a1071c8bcb029637f252b0630699140a93ed8817ce7afe9db34e", size = 962639, upload_time = "2025-06-13T15:39:35.69Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload_time = "2024-11-24T20:12:22.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload_time = "2024-11-24T20:12:19.698Z" }, -] - -[[package]] -name = "triton" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/a9/549e51e9b1b2c9b854fd761a1d23df0ba2fbc60bd0c13b489ffa518cfcb7/triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e", size = 155600257, upload_time = "2025-05-29T23:39:36.085Z" }, - { url = "https://files.pythonhosted.org/packages/21/2f/3e56ea7b58f80ff68899b1dbe810ff257c9d177d288c6b0f55bf2fe4eb50/triton-3.3.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b31e3aa26f8cb3cc5bf4e187bf737cbacf17311e1112b781d4a059353dfd731b", size = 155689937, upload_time = "2025-05-29T23:39:44.182Z" }, - { url = "https://files.pythonhosted.org/packages/24/5f/950fb373bf9c01ad4eb5a8cd5eaf32cdf9e238c02f9293557a2129b9c4ac/triton-3.3.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9999e83aba21e1a78c1f36f21bce621b77bcaa530277a50484a7cb4a822f6e43", size = 155669138, upload_time = "2025-05-29T23:39:51.771Z" }, - { url = "https://files.pythonhosted.org/packages/74/1f/dfb531f90a2d367d914adfee771babbd3f1a5b26c3f5fbc458dee21daa78/triton-3.3.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b89d846b5a4198317fec27a5d3a609ea96b6d557ff44b56c23176546023c4240", size = 155673035, upload_time = "2025-05-29T23:40:02.468Z" }, - { url = "https://files.pythonhosted.org/packages/28/71/bd20ffcb7a64c753dc2463489a61bf69d531f308e390ad06390268c4ea04/triton-3.3.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3198adb9d78b77818a5388bff89fa72ff36f9da0bc689db2f0a651a67ce6a42", size = 155735832, upload_time = "2025-05-29T23:40:10.522Z" }, -] - -[[package]] -name = "typer" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload_time = "2025-05-26T14:30:31.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload_time = "2025-05-26T14:30:30.523Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload_time = "2025-06-02T14:52:11.399Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload_time = "2025-06-02T14:52:10.026Z" }, -] - -[[package]] -name = "typing-inspect" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload_time = "2023-05-24T20:25:47.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload_time = "2023-05-24T20:25:45.287Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload_time = "2025-05-21T18:55:23.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload_time = "2025-05-21T18:55:22.152Z" }, -] - -[[package]] -name = "tzdata" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload_time = "2025-03-23T13:54:43.652Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload_time = "2025-03-23T13:54:41.845Z" }, -] - -[[package]] -name = "umap-learn" -version = "0.5.9.post2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numba" }, - { name = "numpy" }, - { name = "pynndescent" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/ee/6bc65bd375c812026a7af63fe9d09d409382120aff25f2152f1ba12af5ec/umap_learn-0.5.9.post2.tar.gz", hash = "sha256:bdf60462d779bd074ce177a0714ced17e6d161285590fa487f3f9548dd3c31c9", size = 95441, upload_time = "2025-07-03T00:18:02.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/b1/c24deeda9baf1fd491aaad941ed89e0fed6c583a117fd7b79e0a33a1e6c0/umap_learn-0.5.9.post2-py3-none-any.whl", hash = "sha256:fbe51166561e0e7fab00ef3d516ac2621243b8d15cf4bef9f656d701736b16a0", size = 90146, upload_time = "2025-07-03T00:18:01.042Z" }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload_time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload_time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.34.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-26-simple-speaker-recognition-cpu' and extra == 'group-26-simple-speaker-recognition-gpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload_time = "2025-06-01T07:48:17.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload_time = "2025-06-01T07:48:15.664Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload_time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload_time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload_time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload_time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload_time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload_time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload_time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload_time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload_time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload_time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload_time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload_time = "2025-03-05T20:01:53.922Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload_time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload_time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload_time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload_time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload_time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload_time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload_time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload_time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload_time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload_time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload_time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload_time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload_time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload_time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload_time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload_time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload_time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload_time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload_time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload_time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload_time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload_time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload_time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload_time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload_time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload_time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload_time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload_time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload_time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload_time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload_time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload_time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload_time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload_time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload_time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload_time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload_time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload_time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload_time = "2025-03-05T20:03:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload_time = "2025-03-05T20:03:39.41Z" }, -] - -[[package]] -name = "wyoming" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/c4/9b1149338c54a3f7b06cc2978aa0822b0a52c80204d22a56c97742e09244/wyoming-1.7.1.tar.gz", hash = "sha256:bcb54982d85e1a3430d9eb59ff616aa0e4e28041888568035fc4301081621c3f", size = 39355, upload_time = "2025-06-23T21:27:14.425Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/5e/322b03125cf72beda9bdc7d7bf80bd769053c93760a55611d64c48150abd/wyoming-1.7.1-py3-none-any.whl", hash = "sha256:bf2ad92873cfff785cb4c4f6c4e027a92d32282dbef4346669e0854637547601", size = 41634, upload_time = "2025-06-23T21:27:12.964Z" }, -] - -[[package]] -name = "yarl" -version = "1.20.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload_time = "2025-06-10T00:46:09.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload_time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload_time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload_time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload_time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload_time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload_time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload_time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload_time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload_time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload_time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload_time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload_time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload_time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload_time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload_time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload_time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload_time = "2025-06-10T00:43:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload_time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload_time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload_time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload_time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload_time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload_time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload_time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload_time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload_time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload_time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload_time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload_time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload_time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload_time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload_time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload_time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload_time = "2025-06-10T00:43:42.692Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload_time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload_time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload_time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload_time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload_time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload_time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload_time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload_time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload_time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload_time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload_time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload_time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload_time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload_time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload_time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload_time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload_time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload_time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload_time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload_time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload_time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload_time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload_time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload_time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload_time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload_time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload_time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload_time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload_time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload_time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload_time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload_time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload_time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload_time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload_time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload_time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload_time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload_time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload_time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload_time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload_time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload_time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload_time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload_time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload_time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload_time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload_time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload_time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload_time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload_time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload_time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload_time = "2025-06-10T00:46:07.521Z" }, -] - -[[package]] -name = "yt-dlp" -version = "2025.7.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/3a/343f7a0024ddd4c30f150e8d8f57fd7b924846f97d99fc0dcd75ea8d2773/yt_dlp-2025.7.21.tar.gz", hash = "sha256:46fbb53eab1afbe184c45b4c17e9a6eba614be680e4c09de58b782629d0d7f43", size = 3050219, upload_time = "2025-07-21T23:59:03.826Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2f/abe59a3204c749fed494849ea29176bcefa186ec8898def9e43f649ddbcf/yt_dlp-2025.7.21-py3-none-any.whl", hash = "sha256:d7aa2b53f9b2f35453346360f41811a0dad1e956e70b35a4ae95039d4d815d15", size = 3288681, upload_time = "2025-07-21T23:59:01.788Z" }, -] diff --git a/pytest/tests/resources/setup_resources.robot b/pytest/tests/resources/setup_resources.robot new file mode 100644 index 00000000..39aad912 --- /dev/null +++ b/pytest/tests/resources/setup_resources.robot @@ -0,0 +1,69 @@ +*** Settings *** +Documentation Reusable keywords for API testing +Library RequestsLibrary +Library Collections +Library OperatingSystem +Library String +Variables ../test_env.py +Resource ../resources/session_resources.robot + + +*** Keywords *** + +Suite Setup + [Documentation] Setup for auth test suite + ${random_id}= Generate Random String 8 [LETTERS][NUMBERS] + Set Suite Variable ${RANDOM_ID} ${random_id} + Start advanced-server + Create API session ${API_URL} + +Start advanced-server + [Documentation] Start the server using docker-compose + ${is_up}= Run Keyword And Return Status Readiness Check ${API_URL} + IF ${is_up} + Log advanced-server is already running + RETURN + ELSE + Log Starting advanced-server + Run docker-compose -f ../../backends/advanced/docker-compose-test.yml up -d --build + Log Waiting for services to start... + Wait Until Keyword Succeeds 60s 5s Readiness Check ${API_URL} + Log Services are ready + END + +Stop advanced-server + [Documentation] Stop the server using docker-compose + Run docker-compose -f docker-compose.test.yml down + +Start speaker-recognition-service + [Documentation] Start the speaker recognition service using docker-compose + ${is_up}= Run Keyword And Return Status Readiness Check ${SPEAKER_RECOGNITION_URL} + IF ${is_up} + Log speaker-recognition-service is already running + RETURN + ELSE + Log Starting speaker-recognition-service + Run docker-compose -f ../../extras/speaker_recognition/docker-compose.test.yml up -d --build + Log Waiting for speaker recognition service to start... + Wait Until Keyword Succeeds 60s 5s Readiness Check ${SPEAKER_RECOGNITION_URL} + Log Speaker recognition service is ready + END + +Readiness Check + [Documentation] Verify that the readiness endpoint is accessible (faster than /health) + [Tags] health api + [Arguments] ${base_url}=${API_URL} + + ${response}= GET ${base_url}/readiness expected_status=200 + Should Be Equal As Integers ${response.status_code} 200 + RETURN ${True} + +Health Check + [Documentation] Verify that the readiness endpoint is accessible (faster than /health) + [Tags] health api + [Arguments] ${base_url}=${API_URL} + + ${response}= GET ${base_url}/health expected_status=200 + Should Be Equal As Integers ${response.status_code} 200 + RETURN ${True} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..48b8ad96 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +robotframework +robotframework-tidy +robotframework-requests +robotframework-browser +python-dotenv + \ No newline at end of file diff --git a/skaffold.yaml b/skaffold.yaml index 2b844435..f40d4407 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -107,6 +107,18 @@ profiles: persistence.size: "10Gi" persistence.storageClass: "openebs-hostpath" + - name: redis + remoteChart: oci://registry-1.docker.io/bitnamicharts/redis + version: "22.0.7" + namespace: "{{.INFRASTRUCTURE_NAMESPACE}}" + setValueTemplates: + auth.enabled: "false" # Disable authentication for simplicity + master.persistence.enabled: "true" + master.persistence.size: "2Gi" + master.persistence.storageClass: "openebs-hostpath" + master.service.nameOverride: "redis" + replica.replicaCount: "0" # Single instance for development + # Application profile - frequently updated - name: advanced-backend build: @@ -137,8 +149,9 @@ profiles: image.repository: "{{.IMAGE_REPO_advanced_backend}}" image.tag: "{{.IMAGE_TAG_advanced_backend}}" # Override specific Kubernetes-specific values (not in env file) - env.MONGODB_URI: "mongodb://mongodb.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local:27017/friend" + env.MONGODB_URI: "mongodb://mongodb.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local:27017/friend-lite" env.QDRANT_BASE_URL: "qdrant.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local" + env.REDIS_URL: "redis://redis-master.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local:6379/0" persistence.storageClass: "openebs-hostpath" persistence.size: "10Gi" service.nodePort: "{{.BACKEND_NODEPORT}}" @@ -161,6 +174,31 @@ profiles: # Node selector nodeSelector.kubernetes\.io/hostname: anubis + - name: test-env + portForward: + - resourceType: container + resourceName: redis-test + namespace: advanced + localPort: 6379 + port: 6380 + build: + + artifacts: + - image: friend-backend-test + context: backends/advanced + docker: + dockerfile: Dockerfile + - image: webui-test + context: backends/advanced/webui + docker: + dockerfile: Dockerfile + + deploy: + docker: + images: [friend-backend-test, webui-test, mongo-test, qdrant-test, redis-test] + + + - name: speaker-recognition-gtx1070 build: local: diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..c8104a52 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,230 @@ +# Friend-Lite API Tests + +Comprehensive Robot Framework test suite for the Friend-Lite advanced backend API endpoints. + +## Test Structure + +### Test Files +- **`auth_tests.robot`** - Authentication and user management tests +- **`memory_tests.robot`** - Memory management and search tests +- **`conversation_tests.robot`** - Conversation management and versioning tests +- **`health_tests.robot`** - Health check and status endpoint tests +- **`chat_tests.robot`** - Chat service and session management tests +- **`client_queue_tests.robot`** - Client management and queue monitoring tests +- **`system_admin_tests.robot`** - System administration and configuration tests +- **`all_api_tests.robot`** - Master test suite runner + +### Resource Files +- **`resources/auth_keywords.robot`** - Authentication helper keywords +- **`resources/memory_keywords.robot`** - Memory management keywords +- **`resources/conversation_keywords.robot`** - Conversation management keywords +- **`resources/chat_keywords.robot`** - Chat service keywords +- **`resources/setup_resources.robot`** - Basic setup and health check keywords +- **`resources/login_resources.robot`** - Login-specific utilities + +### Configuration +- **`test_env.py`** - Environment configuration and test data +- **`.env`** - Environment variables (create from template) + +## Running Tests + +### Prerequisites +1. Friend-Lite backend running at `http://localhost:8001` (or set `API_URL` in `.env`) +2. Admin user credentials configured in `.env` +3. Robot Framework and RequestsLibrary installed + +### Environment Setup +```bash +# Copy environment template +cp .env.template .env + +# Edit .env with your configuration +API_URL=http://localhost:8001 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=your-secure-admin-password +``` + +### Running Individual Test Suites +```bash +# Authentication and user tests +robot auth_tests.robot + +# Memory management tests +robot memory_tests.robot + +# Conversation management tests +robot conversation_tests.robot + +# Health and status tests +robot health_tests.robot + +# Chat service tests +robot chat_tests.robot + +# Client and queue tests +robot client_queue_tests.robot + +# System administration tests +robot system_admin_tests.robot +``` + +### Running All Tests +```bash +# Run complete test suite +robot *.robot + +# Run with specific tags +robot --include auth *.robot +robot --include positive *.robot +robot --include admin *.robot +``` + +### Test Output +```bash +# Custom output directory +robot --outputdir results *.robot + +# Verbose logging +robot --loglevel DEBUG *.robot + +# Parallel execution +pabot --processes 4 *.robot +``` + +## Test Coverage + +### Authentication & Users (`/api/users`, `/auth`) +- βœ… Login with valid/invalid credentials +- βœ… Get current user information +- βœ… Create/update/delete users (admin only) +- βœ… User authorization and access control +- βœ… Admin privilege enforcement + +### Memory Management (`/api/memories`) +- βœ… Get user memories with pagination +- βœ… Search memories with similarity thresholds +- βœ… Get memories with transcripts +- βœ… Delete specific memories +- βœ… Admin memory access across users +- βœ… Unfiltered memory access for debugging + +### Conversation Management (`/api/conversations`) +- βœ… List and retrieve conversations +- βœ… Conversation version history +- βœ… Transcript reprocessing +- βœ… Memory reprocessing with version selection +- βœ… Version activation (transcript/memory) +- βœ… Conversation deletion and cleanup +- βœ… User data isolation + +### Health & Status (`/health`, `/readiness`) +- βœ… Main health check with service details +- βœ… Readiness check for orchestration +- βœ… Authentication service health +- βœ… Queue system health status +- βœ… Chat service health check +- βœ… System metrics (admin only) + +### Chat Service (`/api/chat`) +- βœ… Session creation and management +- βœ… Session title updates +- βœ… Message retrieval +- βœ… Chat statistics +- βœ… Memory extraction from sessions +- βœ… Session deletion and cleanup + +### Client & Queue Management +- βœ… Active client monitoring +- βœ… Queue job listing with pagination +- βœ… Queue statistics and health +- βœ… User job isolation +- βœ… Processing task monitoring (admin only) + +### System Administration +- βœ… Authentication configuration +- βœ… Diarization settings management +- βœ… Speaker configuration +- βœ… Memory configuration (YAML) +- βœ… Configuration validation and reload +- βœ… Bulk memory deletion + +## Test Categories + +### By Access Level +- **Public**: Health checks, auth config +- **User**: Memories, conversations, chat sessions +- **Admin**: User management, system config, metrics + +### By Test Type +- **Positive**: Valid operations and expected responses +- **Negative**: Invalid inputs, unauthorized access +- **Security**: Authentication, authorization, data isolation +- **Integration**: Cross-service functionality + +### By Component +- **Auth**: Authentication and authorization +- **Memory**: Memory storage and retrieval +- **Conversation**: Audio processing and transcription +- **Chat**: Interactive chat functionality +- **System**: Configuration and administration + +## Key Features Tested + +### Security +- JWT token authentication +- Role-based access control (admin vs user) +- Data isolation between users +- Unauthorized access prevention + +### Data Management +- CRUD operations for all entities +- Pagination and filtering +- Search functionality with thresholds +- Versioning and history tracking + +### System Integration +- Service health monitoring +- Configuration management +- Queue system monitoring +- Cross-service communication + +### Error Handling +- Invalid input validation +- Non-existent resource handling +- Permission denied scenarios +- Service unavailability graceful degradation + +## Maintenance + +### Adding New Tests +1. Create test file or add to existing suite +2. Use appropriate resource keywords +3. Follow naming conventions (`Test Name Test`) +4. Include proper tags and documentation +5. Add cleanup in teardown if needed + +### Updating Keywords +1. Modify resource files for reusable functionality +2. Keep keywords focused and single-purpose +3. Use proper argument handling +4. Include documentation strings + +### Environment Variables +Update `test_env.py` when adding new configuration options or test data. + +## Troubleshooting + +### Common Issues +- **401 Unauthorized**: Check admin credentials in `.env` +- **Connection Refused**: Ensure backend is running +- **Test Failures**: Check service health endpoints first +- **Timeout Errors**: Increase timeouts in test configuration + +### Debug Mode +```bash +# Run with detailed logging +robot --loglevel TRACE auth_tests.robot + +# Stop on first failure +robot --exitonfailure *.robot +``` \ No newline at end of file diff --git a/tests/TESTING_GUIDELINES.md b/tests/TESTING_GUIDELINES.md new file mode 100644 index 00000000..5b0287e4 --- /dev/null +++ b/tests/TESTING_GUIDELINES.md @@ -0,0 +1,188 @@ +# Robot Framework Testing Guidelines + +This file provides specific guidelines for organizing and writing Robot Framework tests in this project. + +## Test Organization Principles + +### Resource File Organization + +Each resource file should have a clear purpose and contain related keywords. Resource files should include documentation explaining what types of functions belong in that file. + +#### Resource File Categories + +**setup_resources.robot** +- Docker service management (start/stop services) +- Environment validation +- Health checks and service dependency verification +- System preparation keywords +- Any keywords that prepare the testing environment + +**session_resources.robot** +- API session creation and management +- Authentication workflows +- Token management (when needed for external tools like curl) +- Session validation and cleanup +- Keywords that handle API authentication and session state + +**user_resources.robot** +- User account creation, deletion, and management +- User-related operations and utilities +- User permission validation +- Keywords specific to user account lifecycle + +**integration_keywords.robot** +- Core integration workflow keywords +- File processing and upload operations +- System interaction keywords that don't fit in other categories +- Complex multi-step operations that combine multiple services + +### Verification vs Setup Separation + +**Verification Steps** +- **MUST be written directly in test files, not abstracted into resource keywords** +- Keep verifications close to the test logic for readability and maintainability +- Use descriptive assertion messages that explain what is being verified +- Example: `Should Be Equal As Integers ${response.status_code} 200 Health check should return 200` +- Verification keywords should only exist in resource files if they perform complex multi-step verification that needs to be reused across multiple test suites + +**Setup/Action Keywords** +- Environment setup, service management, and system actions belong in resource files +- These can be reused across multiple tests and suites +- Focus on "what to do" rather than "what to verify" +- Examples: `Get Admin API Session`, `Upload Audio File For Processing`, `Start Docker Services` + +**Suite-Level Keywords** +- If a specific set of verifications needs to be repeated multiple times within a single test suite, create keywords at the suite level (in the *** Keywords *** section of the test file) +- These should be specific to that suite's testing needs +- Only create suite-level keywords when the same verification logic is used 3+ times in the same suite + +## Code Style Guidelines + +### Human Readability +- Tests should be readable by domain experts without deep Robot Framework knowledge +- Use descriptive keyword names that explain the business purpose +- Prefer explicit over implicit - make test intentions clear +- Use meaningful variable names and comments where helpful +- Avoid Robot Framework-specific jargon in test names and documentation + +### Test Structure +```robot +*** Test Cases *** +Test Name Should Describe Business Scenario + [Documentation] Clear explanation of what this test validates + [Tags] relevant tags + + # Arrange - Setup test data and environment + ${session}= Get Admin API Session + + # Act - Perform the operation being tested + ${result}= Upload Audio File For Processing ${session} ${TEST_FILE} + + # Assert - Verify results directly in test (NOT in resource keywords) + Should Be True ${result}[successful] > 0 At least one file should be processed successfully + Should Contain ${result}[message] processing completed Processing should complete successfully +``` + +### Resource File Documentation +Each resource file should start with clear documentation: + +```robot +*** Settings *** +Documentation Brief description of this resource file's purpose +... +... This file contains keywords for [specific purpose]. +... Keywords in this file should handle [what types of operations]. +... +... Examples of keywords that belong here: +... - Keyword type 1 +... - Keyword type 2 +... +... Keywords that should NOT be in this file: +... - Verification/assertion keywords (belong in tests) +... - Keywords specific to other domains +``` + +### Authentication Pattern +- Tests should use session-based authentication via `session_resources.robot` +- Avoid passing tokens directly in tests - use sessions instead +- Extract tokens from sessions only when required for external tools (like curl) + +Example: +```robot +# Good - Session-based approach +${admin_session}= Get Admin API Session +${conversations}= Get User Conversations ${admin_session} + +# Avoid - Direct token handling in tests +${token}= Get Admin Token +${conversations}= Get User Conversations ${token} +``` + +## File Naming and Structure + +### Test Files +- Use descriptive names that indicate the testing scope +- Example: `full_pipeline_test.robot`, `user_management_test.robot` +- Use `_test.robot` suffix for test files + +### Resource Files +- Use `_resources.robot` suffix +- Name should indicate the domain: `session_resources.robot`, `user_resources.robot` + +### Keywords +- Use descriptive names with clear action words +- Start with action verb when possible: `Get User Conversations`, `Upload Audio File`, `Create Test User` +- Avoid abbreviations unless they're widely understood in the domain +- Use consistent naming patterns across similar keywords + +## Error Handling + +### Resource Keywords +- Should handle expected error conditions gracefully +- Use appropriate Robot Framework error handling (TRY/EXCEPT blocks) +- Log meaningful error messages for debugging +- Fail fast with clear error messages when setup fails + +### Test Assertions +- Write verification steps directly in tests with clear failure messages +- Use descriptive assertion messages that explain what went wrong +- Example: `Should Be Equal ${status} active User should be in active status after creation` +- Include relevant context in failure messages (expected vs actual values) + +## Keywords vs Inline Code + +### When to Create Keywords +- Reusable operations that are used across multiple tests or suites +- Complex multi-step setup or teardown operations +- Operations that encapsulate business logic or domain concepts +- Operations that interact with external systems (APIs, databases, files) + +### When to Keep Code Inline +- Verification steps (assertions) - these should almost always be inline in tests +- Simple operations that are only used once +- Test-specific logic that doesn't need to be reused +- Variable assignments and simple data manipulation + +## Variable and Data Management + +### Test Data +- Use meaningful variable names that describe the data's purpose +- Define test data at the appropriate scope (suite variables for shared data, test variables for test-specific data) +- Store complex test data in separate variable files when it becomes large +- Use descriptive names: `${VALID_USER_EMAIL}` instead of `${EMAIL1}` + +### Environment Configuration +- Load environment variables through `test_env.py` +- Use consistent variable naming across tests +- Document required environment variables and their purposes + +## Future Additions + +As we develop more conventions and encounter new patterns, we will add them to this file: +- Performance testing guidelines +- Data management patterns +- Mock and test double strategies +- Continuous integration considerations +- Test reporting and metrics +- Parallel test execution patterns +- Test data isolation strategies \ No newline at end of file diff --git a/tests/all_api_tests.robot b/tests/all_api_tests.robot new file mode 100644 index 00000000..2e775fdc --- /dev/null +++ b/tests/all_api_tests.robot @@ -0,0 +1,58 @@ +*** Settings *** +Documentation Master Test Suite for All Friend-Lite API Endpoints +Suite Setup Master Suite Setup +Suite Teardown Master Suite Teardown + +*** Test Cases *** + +Run Auth Tests + [Documentation] Execute authentication and user management tests + [Tags] auth users suite + Run Tests auth_tests.robot + +Run Memory Tests + [Documentation] Execute memory management tests + [Tags] memory suite + Run Tests memory_tests.robot + +Run Conversation Tests + [Documentation] Execute conversation management tests + [Tags] conversation suite + Run Tests conversation_tests.robot + +Run Health Tests + [Documentation] Execute health and status tests + [Tags] health status suite + Run Tests health_tests.robot + +Run Chat Tests + [Documentation] Execute chat service tests + [Tags] chat suite + Run Tests chat_tests.robot + +Run Client Queue Tests + [Documentation] Execute client and queue management tests + [Tags] client queue suite + Run Tests client_queue_tests.robot + +Run System Admin Tests + [Documentation] Execute system and admin tests + [Tags] system admin suite + Run Tests system_admin_tests.robot + +*** Keywords *** + +Master Suite Setup + [Documentation] Setup for the entire test suite + Log Starting Friend-Lite API Test Suite + Log Testing against: ${API_URL} + +Master Suite Teardown + [Documentation] Cleanup for the entire test suite + Log Friend-Lite API Test Suite completed + +Run Tests + [Documentation] Run a specific test file + [Arguments] ${test_file} + Log Executing: ${test_file} + # Note: This is a placeholder - actual test execution handled by robot command \ No newline at end of file diff --git a/tests/browser/browser_auth.robot b/tests/browser/browser_auth.robot new file mode 100644 index 00000000..1cac4e26 --- /dev/null +++ b/tests/browser/browser_auth.robot @@ -0,0 +1,32 @@ +*** Settings *** +Documentation Browser Authentication Tests +Library Browser +Resource ../resources/setup_resources.robot +Suite Setup Suite Setup + + + +*** Variables *** + + + +*** Test Cases *** +Test Browser Can Access Login Page + [Documentation] Test that the browser can access the login page + + Log Testing browser access to login page INFO + + # Use the backend URL from test.env + ${backend_url}= Set Variable ${WEB_URL} + + Open Browser ${backend_url}/login chromium + Wait For Elements State id=email visible timeout=10s + Fill Text id=email ${ADMIN_EMAIL} + Fill Text id=password ${ADMIN_PASSWORD} + Click button[type="submit"] + # Verify that we are logged in by checking for the presence of the dashboard + Get Element text=Friend-Lite Dashboard + Log Successfully accessed login page and logged in INFO + + + Close Browser \ No newline at end of file diff --git a/tests/endpoints/*client_queue_tests.robot b/tests/endpoints/*client_queue_tests.robot new file mode 100644 index 00000000..ace3588a --- /dev/null +++ b/tests/endpoints/*client_queue_tests.robot @@ -0,0 +1,215 @@ +*** Settings *** +Documentation Client and Queue Management API Tests +Library RequestsLibrary +Library Collections +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/user_resources.robot +Suite Setup Suite Setup +Suite Teardown Delete All Sessions + +*** Test Cases *** + +Get Active Clients Test + [Documentation] Test getting active client information + [Tags] client active positive + + Create API Session admin_session + ${response}= GET On Session admin_session /api/clients/active + Should Be Equal As Integers ${response.status_code} 200 + + ${clients}= Set Variable ${response.json()} + Should Be True isinstance($clients, (dict, list)) + + # Structure depends on implementation - may be dict with client info or list + IF isinstance($clients, list) + FOR ${client} IN @{clients} + Should Be True isinstance($client, dict) + END + END + +Get Queue Jobs Test + [Documentation] Test getting queue jobs with pagination + [Tags] queue jobs positive + + Create API Session admin_session + &{params}= Create Dictionary limit=20 offset=0 + ${response}= GET On Session admin_session /api/queue/jobs params=${params} + Should Be Equal As Integers ${response.status_code} 200 + + ${result}= Set Variable ${response.json()} + Dictionary Should Contain Key ${result} jobs + Dictionary Should Contain Key ${result} pagination + + ${jobs}= Set Variable ${result}[jobs] + Should Be True isinstance($jobs, list) + + ${pagination}= Set Variable ${result}[pagination] + Dictionary Should Contain Key ${pagination} total + Dictionary Should Contain Key ${pagination} limit + Dictionary Should Contain Key ${pagination} offset + Dictionary Should Contain Key ${pagination} has_more + +Get Queue Jobs With Different Limits Test + [Documentation] Test queue jobs pagination with different limits + [Tags] queue jobs pagination positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with small limit + &{params1}= Create Dictionary limit=5 offset=0 + ${response1}= GET On Session admin_session /api/queue/jobs params=${params1} + Should Be Equal As Integers ${response1.status_code} 200 + + # Test with larger limit + &{params2}= Create Dictionary limit=50 offset=0 + ${response2}= GET On Session admin_session /api/queue/jobs params=${params2} + Should Be Equal As Integers ${response2.status_code} 200 + + ${result1}= Set Variable ${response1.json()} + ${result2}= Set Variable ${response2.json()} + ${count1}= Get Length ${result1}[jobs] + ${count2}= Get Length ${result2}[jobs] + + # Second request should have >= first request count + Should Be True ${count2} >= ${count1} + +Get Queue Statistics Test + [Documentation] Test getting queue statistics + [Tags] queue statistics positive + Get Anonymous Session anon_session + + Create API Session admin_session + + ${response}= GET On Session admin_session /api/queue/stats + Should Be Equal As Integers ${response.status_code} 200 + + ${stats}= Set Variable ${response.json()} + Dictionary Should Contain Key ${stats} queued + Dictionary Should Contain Key ${stats} processing + Dictionary Should Contain Key ${stats} completed + Dictionary Should Contain Key ${stats} failed + + # All counts should be non-negative + Should Be True ${stats}[queued] >= 0 + Should Be True ${stats}[processing] >= 0 + Should Be True ${stats}[completed] >= 0 + Should Be True ${stats}[failed] >= 0 + +Get Queue Health Test + [Documentation] Test getting queue health status + [Tags] queue health positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/queue/health + Should Be Equal As Integers ${response.status_code} 200 + + ${health}= Set Variable ${response.json()} + Dictionary Should Contain Key ${health} status + Dictionary Should Contain Key ${health} worker_running + Dictionary Should Contain Key ${health} message + + # Status should be one of expected values + Should Be True '${health}[status]' in ['healthy', 'stopped', 'unhealthy'] + +Queue Jobs User Isolation Test + [Documentation] Test that regular users only see their own queue jobs + [Tags] queue security isolation + Get Anonymous Session anon_session + + Create API Session admin_session + + # Create a test user + ${test_user}= Create Test User admin_session test-user-${RANDOM_ID}@example.com test-password-123 + Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 + + # Get user's jobs (should be filtered to their user_id) + ${response}= GET On Session user_session /api/queue/jobs + Should Be Equal As Integers ${response.status_code} 200 + + ${result}= Set Variable ${response.json()} + ${jobs}= Set Variable ${result}[jobs] + + # All jobs should belong to the test user + FOR ${job} IN @{jobs} + IF 'user_id' in $job + Should Be Equal ${job}[user_id] ${test_user}[user_id] + END + END + + # Cleanup + Delete Test User ${test_user}[user_id] + +Invalid Queue Parameters Test + [Documentation] Test queue endpoints with invalid parameters + [Tags] queue negative validation + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with invalid limit (too high) + &{params}= Create Dictionary limit=1000 offset=0 + ${response}= GET On Session admin_session /api/queue/jobs params=${params} expected_status=422 + Should Be Equal As Integers ${response.status_code} 422 + + # Test with negative offset + &{params}= Create Dictionary limit=20 offset=-1 + ${response}= GET On Session admin_session /api/queue/jobs params=${params} expected_status=422 + Should Be Equal As Integers ${response.status_code} 422 + + # Test with invalid limit (too low) + &{params}= Create Dictionary limit=0 offset=0 + ${response}= GET On Session admin_session /api/queue/jobs params=${params} expected_status=422 + Should Be Equal As Integers ${response.status_code} 422 + +Unauthorized Client Access Test + [Documentation] Test that client endpoints require authentication + [Tags] client security negative + Get Anonymous Session session + + # Try to access active clients without token + ${response}= GET On Session ${session} /api/clients/active expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Unauthorized Queue Access Test + [Documentation] Test that queue endpoints require authentication + [Tags] queue security negative + Get Anonymous Session session + + # Try to access queue jobs without token + ${response}= GET On Session ${session} /api/queue/jobs expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + + # Try to access queue stats without token + ${response}= GET On Session ${session} /api/queue/stats expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Queue Health Public Access Test + [Documentation] Test that queue health endpoint is publicly accessible + [Tags] queue health public + Get Anonymous Session session + + # Queue health should be accessible without authentication + ${response}= GET On Session ${session} /api/queue/health + Should Be Equal As Integers ${response.status_code} 200 + + ${health}= Set Variable ${response.json()} + Dictionary Should Contain Key ${health} status + +Client Manager Integration Test + [Documentation] Test client manager functionality + [Tags] client manager integration + Get Anonymous Session anon_session + + Create API Session admin_session + + # Get active clients (may be empty) + ${response}= GET On Session admin_session /api/clients/active + Should Be Equal As Integers ${response.status_code} 200 + + ${clients}= Set Variable ${response.json()} + # Verify structure - should be a valid JSON response + Should Be True isinstance($clients, (dict, list)) + diff --git a/tests/endpoints/auth_tests.robot b/tests/endpoints/auth_tests.robot new file mode 100644 index 00000000..17adb84e --- /dev/null +++ b/tests/endpoints/auth_tests.robot @@ -0,0 +1,147 @@ +*** Settings *** +Documentation Authentication and User Management API Tests +Library RequestsLibrary +Library Collections +Library String +Resource ../resources/setup_resources.robot +Resource ../resources/user_resources.robot +Suite Setup Suite Setup +Test Setup Clear Test Databases +Suite Teardown Suite Teardown + +*** Variables *** +# Test users are now imported from test_env.py via resource files + +*** Test Cases *** + +Login With Valid Credentials Test + [Documentation] Test successful login with admin credentials + [Tags] auth login positive + ${user}= Get Admin User Details api + Should Be Equal ${user}[email] ${ADMIN_EMAIL} + +Login With Invalid Credentials Test + [Documentation] Test login failure with invalid credentials + [Tags] auth login negative + Get Anonymous Session anon_session + + &{auth_data}= Create Dictionary username=${ADMIN_EMAIL} password=wrong-password + &{headers}= Create Dictionary Content-Type=application/x-www-form-urlencoded + + ${response}= POST On Session anon_session /auth/jwt/login data=${auth_data} headers=${headers} expected_status=400 + Should Be Equal As Integers ${response.status_code} 400 + +Get Current User Test + [Documentation] Test getting current authenticated user + [Tags] auth user positive + + Create API Session api + ${user}= Get Admin User Details api + + Dictionary Should Contain Key ${user} email + Dictionary Should Contain Key ${user} id + Should Be Equal ${user}[email] ${ADMIN_EMAIL} + +Unauthorized Access Test + [Documentation] Test that endpoints require authentication + [Tags] auth security negative + Get Anonymous Session anon_session + + # Try to access protected endpoint without token + ${response}= GET On Session anon_session /users/me expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Create User Test + [Documentation] Test creating a new user (admin only) + [Tags] users admin positive + + Create API Session api + ${test_email}= Set Variable test-user-${RANDOM_ID}@example.com + ${user}= Create Test User api ${test_email} ${TEST_USER_PASSWORD} + + Should Be Equal ${user}[user_email] ${test_email} + Should Contain ${user}[message] created successfully + + # Cleanup + Delete Test User api ${user}[user_id] + +Create Admin User Test + [Documentation] Test creating an admin user + [Tags] users admin positive + + + Create API Session session + ${test_admin_email}= Set Variable test-admin-${RANDOM_ID}@example.com + ${user}= Create Test User session ${test_admin_email} ${TEST_USER_PASSWORD} ${True} + + Should Be Equal ${user}[user_email] ${test_admin_email} + Should Contain ${user}[message] created successfully + + # Cleanup + Delete Test User session ${user}[user_id] + +Get All Users Test + [Documentation] Test getting all users (admin only) + [Tags] users admin positive + Create API Session api + ${response}= GET On Session api /api/users + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # Should contain at least the admin user + ${users}= Set Variable ${response.json()} + ${admin_found}= Set Variable ${False} + FOR ${user} IN @{users} + IF '${user}[email]' == '${ADMIN_EMAIL}' + ${admin_found}= Set Variable ${True} + END + END + Should Be True ${admin_found} + +Non-Admin User Cannot Create Users Test + [Documentation] Test that non-admin users cannot create users + [Tags] users security negative + ${random_id}= Generate Random String 8 [LETTERS][NUMBERS] + # Create a non-admin user + # Create user + Create API Session user_session email=${TEST_USER_EMAIL} password=${TEST_USER_PASSWORD} + &{user_data}= Create Dictionary email=${random_id}${TEST_USER_EMAIL} password=${TEST_USER_PASSWORD} is_superuser=${False} + ${response}= POST On Session user_session /api/users json=${user_data} expected_status=403 + Should Be Equal As Integers ${response.status_code} 403 + +Update User Test + [Documentation] Test updating a user (admin only) + [Tags] users admin positive + + Create API Session session + ${test_email}= Set Variable test-user-${RANDOM_ID}@example.com + ${user}= Create Test User session ${test_email} ${TEST_USER_PASSWORD} + + # Update user to admin + &{update_data}= Create Dictionary email=${test_email} password=${TEST_USER_PASSWORD} is_superuser=${True} + + ${response}= PUT On Session session /api/users/${user}[user_id] json=${update_data} + Should Be Equal As Integers ${response.status_code} 200 + + ${updated_user}= Set Variable ${response.json()} + Should Be True ${updated_user}[is_superuser] + +Delete User Test + [Documentation] Test deleting a user (admin only) + [Tags] users admin positive + + Create API Session session + ${test_email}= Set Variable test-user-${RANDOM_ID}@example.com + ${user}= Create Test User session ${test_email} ${TEST_USER_PASSWORD} + + # Delete the user + Delete Test User session ${user}[user_id] + + # Verify user is deleted by trying to login + &{auth_data}= Create Dictionary username=${test_email} password=${TEST_USER_PASSWORD} + &{headers}= Create Dictionary Content-Type=application/x-www-form-urlencoded + + ${response}= POST On Session session /auth/jwt/login data=${auth_data} headers=${headers} expected_status=400 + Should Be Equal As Integers ${response.status_code} 400 + diff --git a/tests/endpoints/chat_tests.robot b/tests/endpoints/chat_tests.robot new file mode 100644 index 00000000..1f654654 --- /dev/null +++ b/tests/endpoints/chat_tests.robot @@ -0,0 +1,283 @@ +*** Settings *** +Documentation Chat Service API Tests +Library RequestsLibrary +Library Collections +Library String +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/user_resources.robot +Resource ../resources/chat_keywords.robot +Suite Setup Suite Setup +Suite Teardown Suite Teardown + +*** Test Cases *** + +Create Chat Session Test + [Documentation] Test creating a new chat session + [Tags] chat session create positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${session}= Create Test Chat Session + + # Verify chat session structure + Dictionary Should Contain Key ${session} session_id + Dictionary Should Contain Key ${session} title + Dictionary Should Contain Key ${session} created_at + Dictionary Should Contain Key ${session} updated_at + Should Contain ${session}[title] Test Session + + # Cleanup + Cleanup Test Chat Session ${session}[session_id] + +Create Chat Session With Custom Title Test + [Documentation] Test creating chat session with custom title + [Tags] chat session create positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${custom_title}= Set Variable Custom Chat Title ${RANDOM_ID} + ${response}= Create Chat Session ${custom_title} + + Should Be Equal As Integers ${response.status_code} 200 + ${session}= Set Variable ${response.json()} + Should Be Equal ${session}[title] ${custom_title} + + # Cleanup + Cleanup Test Chat Session ${session}[session_id] + +Get Chat Sessions Test + [Documentation] Test getting all chat sessions for user + [Tags] chat session list positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # Create a test session first + ${test_session}= Create Test Chat Session + + # Get all sessions + ${response}= Get Chat Sessions + Should Be Equal As Integers ${response.status_code} 200 + + ${sessions}= Set Variable ${response.json()} + Should Be True isinstance($sessions, list) + + # Should contain our test session + ${found}= Set Variable ${False} + FOR ${session} IN @{sessions} + # Verify chat session structure + Dictionary Should Contain Key ${session} session_id + Dictionary Should Contain Key ${session} title + Dictionary Should Contain Key ${session} created_at + Dictionary Should Contain Key ${session} updated_at + IF '${session}[session_id]' == '${test_session}[session_id]' + ${found}= Set Variable ${True} + END + END + Should Be True ${found} + + # Cleanup + Cleanup Test Chat Session ${test_session}[session_id] + +Get Specific Chat Session Test + [Documentation] Test getting a specific chat session + [Tags] chat session individual positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_session}= Create Test Chat Session + + ${response}= GET On Session admin_session /api/chat/sessions/${test_session}[session_id] + Should Be Equal As Integers ${response.status_code} 200 + + ${session}= Set Variable ${response.json()} + # Verify chat session structure + Dictionary Should Contain Key ${session} session_id + Dictionary Should Contain Key ${session} title + Dictionary Should Contain Key ${session} created_at + Dictionary Should Contain Key ${session} updated_at + Should Be Equal ${session}[session_id] ${test_session}[session_id] + + # Cleanup + Cleanup Test Chat Session ${test_session}[session_id] + +Update Chat Session Test + [Documentation] Test updating a chat session title + [Tags] chat session update positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_session}= Create Test Chat Session + + ${new_title}= Set Variable Updated Title ${RANDOM_ID} + ${response}= Update Chat Session ${test_session}[session_id] ${new_title} + + Should Be Equal As Integers ${response.status_code} 200 + ${updated_session}= Set Variable ${response.json()} + Should Be Equal ${updated_session}[title] ${new_title} + + # Cleanup + Cleanup Test Chat Session ${test_session}[session_id] + +Delete Chat Session Test + [Documentation] Test deleting a chat session + [Tags] chat session delete positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_session}= Create Test Chat Session + + ${response}= Delete Chat Session ${test_session}[session_id] + Should Be Equal As Integers ${response.status_code} 200 + + # Verify session is deleted + ${response}= Get Chat Session ${test_session}[session_id] + Should Be Equal As Integers ${response.status_code} 404 + +Get Session Messages Test + [Documentation] Test getting messages from a chat session + [Tags] chat messages positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_session}= Create Test Chat Session + + ${response}= Get Session Messages ${test_session}[session_id] + Should Be Equal As Integers ${response.status_code} 200 + + ${messages}= Set Variable ${response.json()} + Should Be True isinstance($messages, list) + + # New session should have no messages + ${count}= Get Length ${messages} + Should Be Equal As Integers ${count} 0 + + # Cleanup + Cleanup Test Chat Session ${test_session}[session_id] + +Get Chat Statistics Test + [Documentation] Test getting chat statistics for user + [Tags] chat statistics positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= Get Chat Statistics + + Should Be Equal As Integers ${response.status_code} 200 + ${stats}= Set Variable ${response.json()} + # Verify chat statistics structure + Dictionary Should Contain Key ${stats} total_sessions + Dictionary Should Contain Key ${stats} total_messages + + # Statistics should be non-negative + Should Be True ${stats}[total_sessions] >= 0 + Should Be True ${stats}[total_messages] >= 0 + +Chat Session Pagination Test + [Documentation] Test chat session pagination + [Tags] chat session pagination positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with different limits + ${response1}= Get Chat Sessions 5 + Should Be Equal As Integers ${response1.status_code} 200 + + ${response2}= Get Chat Sessions 50 + Should Be Equal As Integers ${response2.status_code} 200 + + ${sessions1}= Set Variable ${response1.json()} + ${sessions2}= Set Variable ${response2.json()} + ${count1}= Get Length ${sessions1} + ${count2}= Get Length ${sessions2} + + # Second request should have >= first request count + Should Be True ${count2} >= ${count1} + +Unauthorized Chat Access Test + [Documentation] Test that chat endpoints require authentication + [Tags] chat security negative + Get Anonymous Session session + + # Try to access sessions without token + ${response}= GET On Session session /api/chat/sessions expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + + # Try to get statistics without token + ${response}= GET On Session session /api/chat/statistics expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Non-Existent Session Operations Test + [Documentation] Test operations on non-existent chat sessions + [Tags] chat session negative notfound + Get Anonymous Session anon_session + + Create API Session admin_session + ${fake_id}= Set Variable non-existent-session-id + + # Try to get non-existent session + ${response}= Get Chat Session ${fake_id} 404 + Should Be Equal As Integers ${response.status_code} 404 + + # Try to update non-existent session + ${response}= Update Chat Session ${fake_id} New Title 404 + Should Be Equal As Integers ${response.status_code} 404 + + # Try to delete non-existent session + ${response}= Delete Chat Session ${fake_id} 404 + Should Be Equal As Integers ${response.status_code} 404 + + # Try to get messages from non-existent session + ${response}= Get Session Messages ${fake_id} expected_status=404 + Should Be Equal As Integers ${response.status_code} 404 + +Invalid Chat Session Data Test + [Documentation] Test creating chat session with invalid data + [Tags] chat session negative validation + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with title too long (over 200 characters) + ${long_title}= Generate Random String 201 [LETTERS] + ${response}= Create Chat Session ${long_title} 422 + Should Be Equal As Integers ${response.status_code} 422 + + # Test updating with empty title + ${test_session}= Create Test Chat Session + ${response}= Update Chat Session ${test_session}[session_id] ${EMPTY} 422 + Should Be Equal As Integers ${response.status_code} 422 + + # Cleanup + Cleanup Test Chat Session ${test_session}[session_id] + +User Isolation Test + [Documentation] Test that users can only access their own chat sessions + [Tags] chat security isolation + Get Anonymous Session anon_session + + Create API Session admin_session + + # Create a test user + ${test_user}= Create Test User admin_session test-user-${RANDOM_ID}@example.com test-password-123 + Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 + + # Create session as admin + ${admin_chat_session}= Create Test Chat Session + + # User should not be able to access admin's session + ${response}= GET On Session user_session /api/chat/sessions/${admin_chat_session}[session_id] expected_status=404 + Should Be Equal As Integers ${response.status_code} 404 + + # User should see empty session list + ${user_sessions}= GET On Session user_session /api/chat/sessions + Should Be Equal As Integers ${user_sessions.status_code} 200 + ${sessions}= Set Variable ${user_sessions.json()} + ${count}= Get Length ${sessions} + Should Be Equal As Integers ${count} 0 + + # Cleanup + Cleanup Test Chat Session ${admin_chat_session}[session_id] + diff --git a/tests/endpoints/conversation_tests.robot b/tests/endpoints/conversation_tests.robot new file mode 100644 index 00000000..9225aed6 --- /dev/null +++ b/tests/endpoints/conversation_tests.robot @@ -0,0 +1,232 @@ +*** Settings *** +Documentation Conversation Management API Tests +Library RequestsLibrary +Library Collections +Library String +Resource ../resources/setup_resources.robot +Resource ../resources/user_resources.robot +Resource ../resources/conversation_keywords.robot +Suite Setup Suite Setup +Suite Teardown Delete All Sessions + +*** Test Cases *** + +Get User Conversations Test + [Documentation] Test getting conversations for authenticated user + [Tags] conversation user positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${conversations_data}= Get User Conversations + + # Verify conversation structure if any exist + + IF isinstance($conversations_data, dict) and len($conversations_data) > 0 + ${client_ids}= Get Dictionary Keys ${conversations_data} + FOR ${client_id} IN @{client_ids} + ${client_conversations}= Set Variable ${conversations_data}[${client_id}] + FOR ${conversation} IN @{client_conversations} + # Verify conversation structure + Dictionary Should Contain Key ${conversation} conversation_id + Dictionary Should Contain Key ${conversation} audio_uuid + Dictionary Should Contain Key ${conversation} created_at + END + END + END + +Get Conversation By ID Test + [Documentation] Test getting a specific conversation by ID + [Tags] conversation individual positive + + Create API Session admin_session + ${test_conversation}= Find Test Conversation + + IF $test_conversation != $None + ${conversation_id}= Set Variable ${test_conversation}[conversation_id] + ${conversation}= Get Conversation By ID ${conversation_id} + + # Verify conversation structure + Dictionary Should Contain Key ${conversation} conversation_id + Dictionary Should Contain Key ${conversation} audio_uuid + Dictionary Should Contain Key ${conversation} created_at + Should Be Equal ${conversation}[conversation_id] ${conversation_id} + ELSE + Log No conversations available for testing + Pass Execution No conversations available for individual conversation test + END + +# Get Conversation Versions Test +# [Documentation] Test getting version history for a conversation +# [Tags] conversation versions positive + +# ${test_conversation}= Find Test Conversation + +# IF $test_conversation != $None +# ${conversation_id}= Set Variable ${test_conversation}[conversation_id] +# ${versions}= Get Conversation Versions ${conversation_id} + + +# # Verify version history structure +# Dictionary Should Contain Key ${versions} transcript_versions +# Dictionary Should Contain Key ${versions} memory_versions +# Dictionary Should Contain Key ${versions} active_transcript_version +# Dictionary Should Contain Key ${versions} active_memory_version +# ELSE +# Log No conversations available for testing +# Pass Execution No conversations available for version history test +# END + +Unauthorized Conversation Access Test + [Documentation] Test that conversation endpoints require authentication + [Tags] conversation security negative + Get Anonymous Session session + + # Try to access conversations without token + ${response}= GET On Session session /api/conversations expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Non-Existent Conversation Test + [Documentation] Test accessing a non-existent conversation + [Tags] conversation negative notfound + Get Anonymous Session anon_session + + Create API Session admin_session + ${fake_id}= Set Variable non-existent-conversation-id + + ${response}= GET On Session admin_session /api/conversations/${fake_id} expected_status=404 + Should Be Equal As Integers ${response.status_code} 404 + +Reprocess Transcript Test + [Documentation] Test triggering transcript reprocessing + [Tags] conversation reprocess positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_conversation}= Find Test Conversation + + IF $test_conversation != $None + ${conversation_id}= Set Variable ${test_conversation}[conversation_id] + ${response}= Reprocess Transcript ${conversation_id} + + # Reprocessing might return 200 (success) or 202 (accepted) depending on implementation + Should Be True ${response.status_code} in [200, 202] + ELSE + Log No conversations available for reprocessing test + Pass Execution No conversations available for transcript reprocessing test + END + +Reprocess Memory Test + [Documentation] Test triggering memory reprocessing + [Tags] conversation reprocess memory positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_conversation}= Find Test Conversation + + IF $test_conversation != $None + ${conversation_id}= Set Variable ${test_conversation}[conversation_id] + ${response}= Reprocess Memory ${token} ${conversation_id} + + # Memory reprocessing might return 200 (success) or 202 (accepted) + Should Be True ${response.status_code} in [200, 202] + ELSE + Log No conversations available for memory reprocessing test + Pass Execution No conversations available for memory reprocessing test + END + +Close Conversation Test + [Documentation] Test closing current conversation for a client + [Tags] conversation close positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${client_id}= Set Variable test-client-${RANDOM_ID} + + # This might return 404 if client doesn't exist, which is expected + ${response}= POST On Session admin_session /api/conversations/${client_id}/close expected_status=any + Should Be True ${response.status_code} in [200, 404] + +Invalid Conversation Operations Test + [Documentation] Test invalid operations on conversations + [Tags] conversation negative invalid + Get Anonymous Session anon_session + + Create API Session admin_session + ${fake_id}= Set Variable invalid-conversation-id + + # Test reprocessing non-existent conversation + ${response}= POST On Session admin_session /api/conversations/${fake_id}/reprocess-transcript expected_status=404 + Should Be Equal As Integers ${response.status_code} 404 + + # Test getting versions of non-existent conversation + ${response}= GET On Session admin_session /api/conversations/${fake_id}/versions expected_status=404 + Should Be Equal As Integers ${response.status_code} 404 + +Version Management Test + [Documentation] Test version activation (if versions exist) + [Tags] conversation versions activation + Get Anonymous Session anon_session + + Create API Session admin_session + ${test_conversation}= Find Test Conversation + + IF $test_conversation != $None + ${conversation_id}= Set Variable ${test_conversation}[conversation_id] + ${versions_response}= Get Conversation Versions ${token} ${conversation_id} + + Should Be Equal As Integers ${versions_response.status_code} 200 + ${versions}= Set Variable ${versions_response.json()} + + # Test activating existing active version (should succeed) + ${active_transcript}= Set Variable ${versions}[active_transcript_version] + IF '${active_transcript}' != '${None}' and '${active_transcript}' != 'null' + ${response}= Activate Transcript Version ${token} ${conversation_id} ${active_transcript} + Should Be Equal As Integers ${response.status_code} 200 + END + + ${active_memory}= Set Variable ${versions}[active_memory_version] + IF '${active_memory}' != '${None}' and '${active_memory}' != 'null' + ${response}= Activate Memory Version ${token} ${conversation_id} ${active_memory} + Should Be Equal As Integers ${response.status_code} 200 + END + ELSE + Log No conversations available for version management test + Pass Execution No conversations available for version management test + END + +User Isolation Test + [Documentation] Test that users can only access their own conversations + [Tags] conversation security isolation + Get Anonymous Session anon_session + + Create API Session admin_session + + # Create a test user + ${test_user}= Create Test User admin_session test-user-${RANDOM_ID}@example.com test-password-123 + Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 + + # Get admin conversations + ${admin_conversations}= Get User Conversations + Should Be Equal As Integers ${admin_conversations.status_code} 200 + + # Get user conversations (should be empty for new user) + ${user_conversations}= GET On Session user_session /api/conversations + Should Be Equal As Integers ${user_conversations.status_code} 200 + + # User should see empty or only their own conversations + ${user_conv_data}= Set Variable ${user_conversations.json()}[conversations] + IF isinstance($user_conv_data, dict) and len($user_conv_data) > 0 + ${client_ids}= Get Dictionary Keys ${user_conv_data} + FOR ${client_id} IN @{client_ids} + ${client_conversations}= Set Variable ${user_conv_data}[${client_id}] + FOR ${conversation} IN @{client_conversations} + # Note: The actual conversation structure doesn't have user_id field exposed + # This test should verify that only this user's conversations are returned + Dictionary Should Contain Key ${conversation} conversation_id + END + END + END + + # Cleanup + Delete Test User ${test_user}[user_id] + diff --git a/tests/endpoints/health_tests.robot b/tests/endpoints/health_tests.robot new file mode 100644 index 00000000..b7a55bbc --- /dev/null +++ b/tests/endpoints/health_tests.robot @@ -0,0 +1,217 @@ +*** Settings *** +Documentation Health and Status Endpoint API Tests +Library RequestsLibrary +Library Collections +Resource ../resources/setup_resources.robot +Resource ../resources/user_resources.robot +Resource ../resources/session_resources.robot +Suite Setup Suite Setup +Suite Teardown Delete All Sessions + +*** Test Cases *** + +Readiness Check Test + [Documentation] Test readiness check endpoint for container orchestration + [Tags] readiness status positive + Get Anonymous Session anon_session + + ${response}= GET On Session anon_session /readiness + Should Be Equal As Integers ${response.status_code} 200 + + ${readiness}= Set Variable ${response.json()} + Dictionary Should Contain Key ${readiness} status + Dictionary Should Contain Key ${readiness} timestamp + Should Be Equal ${readiness}[status] ready + +Health Check Test + [Documentation] Test main health check endpoint + [Tags] health status positive + # Get Anonymous Session + Create API Session health_check_session + ${response}= GET On Session health_check_session /health + Should Be Equal As Integers ${response.status_code} 200 + + ${health}= Set Variable ${response.json()} + Dictionary Should Contain Key ${health} status + Dictionary Should Contain Key ${health} timestamp + Dictionary Should Contain Key ${health} services + Dictionary Should Contain Key ${health} overall_healthy + Dictionary Should Contain Key ${health} critical_services_healthy + + ${services}= Set Variable ${health}[services] + Log To Console \n + Log To Console Mongodb: ${services}[mongodb][status] + Log To Console AudioAI: ${services}[audioai][status] + Log To Console Memory Service: ${services}[memory_service][status] + Log To Console Speech to Text: ${services}[speech_to_text][status] + Log To Console Speaker recognition: ${services}[speaker_recognition][status] + # Verify status is one of expected values + Should Be True '${health}[status]' in ['healthy', 'degraded', 'critical'] + + ${config}= Set Variable ${health}[config] + Dictionary Should Contain Key ${config} mongodb_uri + Dictionary Should Contain Key ${config} qdrant_url + Dictionary Should Contain Key ${config} transcription_service + Dictionary Should Contain Key ${config} asr_uri + Dictionary Should Contain Key ${config} provider_type + Dictionary Should Contain Key ${config} chunk_dir + Dictionary Should Contain Key ${config} active_clients + Dictionary Should Contain Key ${config} new_conversation_timeout_minutes + Dictionary Should Contain Key ${config} audio_cropping_enabled + Dictionary Should Contain Key ${config} llm_provider + Dictionary Should Contain Key ${config} llm_model + Dictionary Should Contain Key ${config} llm_base_url + + # Verify config values are not empty + Should Not Be Empty ${config}[mongodb_uri] + Should Not Be Empty ${config}[qdrant_url] + Should Not Be Empty ${config}[transcription_service] + Should Not Be Empty ${config}[asr_uri] + Should Not Be Empty ${config}[provider_type] + Should Not Be Empty ${config}[chunk_dir] + Should Be True isinstance(${config}[active_clients], int) + Should Be True ${config}[new_conversation_timeout_minutes] > 0 + Should Be True isinstance(${config}[audio_cropping_enabled], bool) + Should Not Be Empty ${config}[llm_provider] + Should Not Be Empty ${config}[llm_model] + Should Not Be Empty ${config}[llm_base_url] + +Auth Health Check Test + [Documentation] Test authentication service health check + [Tags] auth health positive + Get Anonymous Session session + + ${response}= GET On Session session /api/auth/health + Should Be Equal As Integers ${response.status_code} 200 + + ${auth_health}= Set Variable ${response.json()} + Dictionary Should Contain Key ${auth_health} status + Dictionary Should Contain Key ${auth_health} database + Dictionary Should Contain Key ${auth_health} memory_service + Dictionary Should Contain Key ${auth_health} timestamp + +Queue Health Check Test + [Documentation] Test queue system health check + [Tags] queue health positive + Get Anonymous Session session + + ${response}= GET On Session session /api/queue/health + Should Be Equal As Integers ${response.status_code} 200 + + ${queue_health}= Set Variable ${response.json()} + Dictionary Should Contain Key ${queue_health} status + Dictionary Should Contain Key ${queue_health} worker_running + Dictionary Should Contain Key ${queue_health} message + +Chat Health Check Test + [Documentation] Test chat service health check + [Tags] chat health positive + Get Anonymous Session session + + ${response}= GET On Session session /api/chat/health + Should Be Equal As Integers ${response.status_code} 200 + + ${chat_health}= Set Variable ${response.json()} + Dictionary Should Contain Key ${chat_health} status + Dictionary Should Contain Key ${chat_health} service + Dictionary Should Contain Key ${chat_health} timestamp + Should Be Equal ${chat_health}[service] chat + +System Metrics Test + [Documentation] Test system metrics endpoint (admin only) + [Tags] metrics admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/metrics + Should Be Equal As Integers ${response.status_code} 200 + + ${metrics}= Set Variable ${response.json()} + # Metrics structure may vary, just verify it's a valid response + Should Be True isinstance($metrics, dict) + +Processor Status Test + [Documentation] Test processor status endpoint (admin only) + [Tags] processor admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/processor/status + Should Be Equal As Integers ${response.status_code} 200 + + ${status}= Set Variable ${response.json()} + # Processor status structure may vary + Should Be True isinstance($status, dict) + +Processing Tasks Test + [Documentation] Test processing tasks endpoint (admin only) + [Tags] processor tasks admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/processor/tasks + Should Be Equal As Integers ${response.status_code} 200 + + ${tasks}= Set Variable ${response.json()} + Should Be True isinstance($tasks, (dict, list)) + +Health Check Service Details Test + [Documentation] Test detailed service health information + [Tags] health services detailed + Get Anonymous Session session + ${response}= GET On Session session /health + Should Be Equal As Integers ${response.status_code} 200 + + ${health}= Set Variable ${response.json()} + ${services}= Set Variable ${health}[services] + + # Check for expected services + ${expected_services}= Create List mongodb audioai memory_service speech_to_text + + FOR ${service} IN @{expected_services} + IF '${service}' in $services + ${service_info}= Set Variable ${services}[${service}] + Dictionary Should Contain Key ${service_info} status + Dictionary Should Contain Key ${service_info} healthy + Dictionary Should Contain Key ${service_info} critical + END + END + +Non-Admin Cannot Access Admin Endpoints Test + [Documentation] Test that non-admin users cannot access admin health endpoints + [Tags] health security negative + Get Anonymous Session session + + Create API Session admin_session + + # Create a non-admin user + ${test_user}= Create Test User admin_session test-user-${RANDOM_ID}@example.com test-password-123 + Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 + + # Metrics endpoint should be forbidden + ${response}= GET On Session user_session /api/metrics expected_status=403 + Should Be Equal As Integers ${response.status_code} 403 + + # Processor status should be forbidden + ${response}= GET On Session user_session /api/processor/status expected_status=403 + Should Be Equal As Integers ${response.status_code} 403 + + # Processing tasks should be forbidden + ${response}= GET On Session user_session /api/processor/tasks expected_status=403 + Should Be Equal As Integers ${response.status_code} 403 + + # Cleanup + Delete Test User ${test_user}[user_id] + +Unauthorized Health Access Test + [Documentation] Test health endpoints that require authentication + [Tags] health security negative + Get Anonymous Session session + + # Admin-only endpoints should require authentication + ${response}= GET On Session session /api/metrics expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + + ${response}= GET On Session session /api/processor/status expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + diff --git a/tests/endpoints/memory_tests.robot b/tests/endpoints/memory_tests.robot new file mode 100644 index 00000000..29d71feb --- /dev/null +++ b/tests/endpoints/memory_tests.robot @@ -0,0 +1,220 @@ +*** Settings *** +Documentation Memory Management API Tests +Library RequestsLibrary +Library Collections +Library String +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/user_resources.robot +Resource ../resources/memory_keywords.robot +Suite Setup Suite Setup +Suite Teardown Delete All Sessions + +*** Test Cases *** + +Get User Memories Test + [Documentation] Test getting memories for authenticated user + [Tags] memory user positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/memories + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # Verify memory structure if any exist + ${memories}= Set Variable ${response.json()} + FOR ${memory} IN @{memories} + # Verify memory structure + Dictionary Should Contain Key ${memory} id + Dictionary Should Contain Key ${memory} user_id + Dictionary Should Contain Key ${memory} text + Dictionary Should Contain Key ${memory} created_at + END + +Get Memories With Transcripts Test + [Documentation] Test getting memories with their source transcripts + [Tags] memory transcripts positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/memories/with-transcripts + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # Verify enhanced structure if any exist + ${memories}= Set Variable ${response.json()} + FOR ${memory} IN @{memories} + # Verify memory structure + Dictionary Should Contain Key ${memory} id + Dictionary Should Contain Key ${memory} user_id + Dictionary Should Contain Key ${memory} text + Dictionary Should Contain Key ${memory} created_at + # May have additional transcript fields + END + +Search Memories Test + [Documentation] Test searching memories by query + [Tags] memory search positive + Get Anonymous Session anon_session + + Create API Session admin_session + &{params}= Create Dictionary query=test limit=20 score_threshold=0.0 + ${response}= GET On Session admin_session /api/memories/search params=${params} + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # Verify search results structure + ${results}= Set Variable ${response.json()} + # Verify search results structure + FOR ${memory} IN @{results} + # Verify memory structure + Dictionary Should Contain Key ${memory} id + Dictionary Should Contain Key ${memory} user_id + Dictionary Should Contain Key ${memory} text + Dictionary Should Contain Key ${memory} created_at + END + +Search Memories With High Threshold Test + [Documentation] Test searching memories with high similarity threshold + [Tags] memory search threshold + Get Anonymous Session anon_session + + Create API Session admin_session + &{params}= Create Dictionary query=nonexistent-query limit=10 score_threshold=0.9 + ${response}= GET On Session admin_session /api/memories/search params=${params} + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # High threshold might return fewer results + ${results}= Set Variable ${response.json()} + FOR ${memory} IN @{results} + # Verify memory structure + Dictionary Should Contain Key ${memory} id + Dictionary Should Contain Key ${memory} user_id + Dictionary Should Contain Key ${memory} text + Dictionary Should Contain Key ${memory} created_at + END + +Get Unfiltered Memories Test + [Documentation] Test getting unfiltered memories for debugging + [Tags] memory debug positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/memories/unfiltered + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # Unfiltered may include more memories than filtered + ${memories}= Set Variable ${response.json()} + FOR ${memory} IN @{memories} + # Verify memory structure + Dictionary Should Contain Key ${memory} id + Dictionary Should Contain Key ${memory} user_id + Dictionary Should Contain Key ${memory} text + Dictionary Should Contain Key ${memory} created_at + END + +Get All Memories Admin Test + [Documentation] Test getting all memories across users (admin only) + [Tags] memory admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/admin/memories + + Should Be Equal As Integers ${response.status_code} 200 + Should Be True isinstance($response.json(), list) + + # Admin endpoint should return memories from all users + ${memories}= Set Variable ${response.json()} + FOR ${memory} IN @{memories} + # Verify memory structure + Dictionary Should Contain Key ${memory} id + Dictionary Should Contain Key ${memory} user_id + Dictionary Should Contain Key ${memory} text + Dictionary Should Contain Key ${memory} created_at + # Should have user_id from potentially different users + Dictionary Should Contain Key ${memory} user_id + END + +Memory Pagination Test + [Documentation] Test memory pagination with different limits + [Tags] memory pagination positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with small limit + &{params1}= Create Dictionary limit=5 + ${response1}= GET On Session admin_session /api/memories params=${params1} + Should Be Equal As Integers ${response1.status_code} 200 + ${memories1}= Set Variable ${response1.json()} + ${count1}= Get Length ${memories1} + Should Be True ${count1} <= 5 + + # Test with larger limit + &{params2}= Create Dictionary limit=100 + ${response2}= GET On Session admin_session /api/memories params=${params2} + Should Be Equal As Integers ${response2.status_code} 200 + ${memories2}= Set Variable ${response2.json()} + ${count2}= Get Length ${memories2} + + # Second request should have >= first request count + Should Be True ${count2} >= ${count1} + +Non-Admin Cannot Access Admin Memories Test + [Documentation] Test that non-admin users cannot access admin memory endpoint + [Tags] memory security negative + Get Anonymous Session anon_session + + Create API Session admin_session + + # Create a non-admin user + ${test_user}= Create Test User admin_session test-user-${RANDOM_ID}@example.com test-password-123 + Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 + + # Try to access admin memories endpoint + ${response}= GET On Session user_session /api/memories/admin expected_status=403 + Should Be Equal As Integers ${response.status_code} 403 + + # Cleanup + Delete Test User ${test_user}[user_id] + +Unauthorized Memory Access Test + [Documentation] Test that memory endpoints require authentication + [Tags] memory security negative + Get Anonymous Session session + + # Try to access memories without token + ${response}= GET On Session session /api/memories expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + + # Try to search memories without token + &{params}= Create Dictionary query=test + ${response}= GET On Session session /api/memories/search params=${params} expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Invalid Search Parameters Test + [Documentation] Test search with invalid parameters + [Tags] memory search negative + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with empty query (should fail) + &{params}= Create Dictionary query=${EMPTY} + ${response}= GET On Session admin_session /api/memories/search params=${params} expected_status=422 + Should Be Equal As Integers ${response.status_code} 422 + + # Test with invalid score threshold + &{params}= Create Dictionary query=test score_threshold=2.0 + ${response}= GET On Session admin_session /api/memories/search params=${params} expected_status=422 + Should Be Equal As Integers ${response.status_code} 422 + diff --git a/tests/endpoints/rq_queue_tests.robot b/tests/endpoints/rq_queue_tests.robot new file mode 100644 index 00000000..160185bb --- /dev/null +++ b/tests/endpoints/rq_queue_tests.robot @@ -0,0 +1,278 @@ +*** Settings *** +Documentation RQ Job Persistence Tests - Verify Redis Queue job persistence through service restarts +Library RequestsLibrary +Library Collections +Library Process +Library OperatingSystem +Library String +Library DateTime +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/user_resources.robot +Resource ../resources/conversation_keywords.robot +Variables ../test_env.py + +Suite Setup Suite Setup +Suite Teardown Delete All Sessions +Test Setup Suite Setup +Test Teardown Clear RQ Test Data + +*** Variables *** +${TEST_TIMEOUT} 60s +${COMPOSE_FILE} backends/advanced/docker-compose-test.yml + +*** Keywords *** + +Clear RQ Test Data + [Documentation] Clear test data between tests + # Clear Redis queues + Run Process docker-compose -f ${COMPOSE_FILE} exec -T redis-test redis-cli FLUSHALL + ... cwd=. + + +Check Queue Stats + [Documentation] Get current queue statistics + Create API Session admin_session + ${response}= GET On Session admin_session /api/queue/stats expected_status=200 + RETURN ${response.json()} + +Check Queue Jobs + [Documentation] Get current jobs in queue + Create API Session admin_session + ${response}= GET On Session admin_session /api/queue/jobs expected_status=200 + RETURN ${response.json()} + +Check Queue Health + [Documentation] Get queue health status + Create API Session admin_session + ${response}= GET On Session admin_session /api/queue/health expected_status=200 + RETURN ${response.json()} + +Restart Backend Service + [Documentation] Restart the backend service to test persistence + Log Restarting backend service to test job persistence + + # Stop backend container + Run Process docker-compose -f ${COMPOSE_FILE} stop friend-backend-test + ... cwd=. timeout=30s + + # Start backend container again + Run Process docker-compose -f ${COMPOSE_FILE} start friend-backend-test + ... cwd=. timeout=60s + + # Wait for backend to be ready again + Wait Until Keyword Succeeds ${TEST_TIMEOUT} 5s + ... Health Check ${API_URL} + + Log Backend service restarted successfully + +Trigger Transcript Reprocessing + [Documentation] Trigger transcript reprocessing to create an RQ job + [Arguments] ${conversation_id} + + Create API Session admin_session + ${token}= Get Authentication Token admin_session ${ADMIN_EMAIL} ${ADMIN_PASSWORD} + + Log Triggering transcript reprocessing for conversation: ${conversation_id} + ${response}= Reprocess Transcript ${token} ${conversation_id} + + Should Be True ${response.status_code} in [200, 202] + + # The response might contain job_id, but it's not guaranteed in all implementations + ${job_id}= Set Variable reprocess-${conversation_id} + Log Triggered reprocessing job for conversation: ${conversation_id} + RETURN ${job_id} + +*** Test Cases *** +Test RQ Job Enqueuing + [Documentation] Test that jobs can be enqueued in Redis + [Tags] rq enqueue positive + + # Check initial queue state + ${initial_stats}= Check Queue Stats + ${initial_queued}= Set Variable ${initial_stats}[queued_jobs] + + # Find or create test conversation and trigger reprocessing + ${conversation_id}= Find Or Create Test Conversation + + IF $conversation_id != $None + ${job_id}= Trigger Transcript Reprocessing ${conversation_id} + + # Verify job was enqueued + ${stats_after}= Check Queue Stats + ${queued_after}= Set Variable ${stats_after}[queued_jobs] + + Should Be True ${queued_after} >= ${initial_queued} + Log Successfully enqueued job: ${job_id} + ELSE + Log No conversations available for job enqueuing test + Pass Execution No conversations available for RQ job enqueuing test + END + +Test Job Persistence Through Backend Restart + [Documentation] Test that RQ jobs persist when backend service restarts + [Tags] rq persistence restart critical + + # Find test conversation + ${conversation_id}= Find Or Create Test Conversation + + IF $conversation_id != $None + # Create and enqueue a job + ${job_id}= Trigger Transcript Reprocessing ${conversation_id} + + # Verify jobs exist in queue (may include other jobs) + ${jobs_before}= Check Queue Jobs + ${jobs_count_before}= Get Length ${jobs_before}[jobs] + + # Restart backend service + Restart Backend Service + + # Verify queue is still accessible and jobs persist + ${jobs_after}= Check Queue Jobs + ${jobs_count_after}= Get Length ${jobs_after}[jobs] + + # Jobs should persist through restart (count may be same or greater) + Should Be True ${jobs_count_after} >= 0 + Log Job persistence test passed - queue survived backend restart with ${jobs_count_after} jobs + ELSE + Log No conversations available for persistence test + Pass Execution No conversations available for job persistence test + END + +Test Queue Health After Restart + [Documentation] Test that queue health checks work after service restart + [Tags] rq health restart positive + + # Check initial health + ${health_before}= Check Queue Health + # Queue might be healthy or no_workers - both indicate Redis connectivity + Should Be True '${health_before}[status]' in ['healthy', 'no_workers'] + Should Be True ${health_before}[redis_connected] + + # Restart backend + Restart Backend Service + + # Check health after restart + ${health_after}= Check Queue Health + # Queue might be healthy or no_workers - both indicate Redis connectivity + Should Be True '${health_after}[status]' in ['healthy', 'no_workers'] + Should Be True ${health_after}[redis_connected] + + Log Queue health check passed after restart + +Test Multiple Jobs Persistence + [Documentation] Test that multiple jobs persist through restart + [Tags] rq persistence multiple stress + + # Find test conversation + ${conversation_id}= Find Or Create Test Conversation + + IF $conversation_id != $None + # Create multiple jobs using the same conversation + ${job_count}= Set Variable 3 + FOR ${i} IN RANGE ${job_count} + ${job_id}= Trigger Transcript Reprocessing ${conversation_id} + Sleep 1s # Small delay between jobs + END + + Log Created ${job_count} reprocessing jobs + + # Get baseline job count + ${jobs_before}= Check Queue Jobs + ${jobs_count_before}= Get Length ${jobs_before}[jobs] + + # Restart backend + Restart Backend Service + + # Verify jobs persist through restart + ${jobs_after}= Check Queue Jobs + ${jobs_count_after}= Get Length ${jobs_after}[jobs] + + # Jobs should persist (exact count may vary based on processing) + Should Be True ${jobs_count_after} >= 0 + Log Jobs persisted through restart: ${jobs_count_before} -> ${jobs_count_after} + ELSE + Log No conversations available for multiple jobs test + Pass Execution No conversations available for multiple jobs persistence test + END + +Test Redis Data Persistence + [Documentation] Test that Redis data itself persists (not just connections) + [Tags] rq redis persistence infrastructure + + # Store a test key directly in Redis + ${test_key}= Set Variable test:persistence:key:${RANDOM_ID} + ${test_value}= Set Variable persistence-test-value-${RANDOM_ID} + + Run Process docker-compose -f ${COMPOSE_FILE} exec -T redis-test + ... redis-cli SET ${test_key} ${test_value} + ... cwd=. + + # Restart entire test environment (Redis included) + Log Restarting Redis to test data persistence + Run Process docker-compose -f ${COMPOSE_FILE} restart redis-test + ... cwd=. timeout=30s + + # Wait for Redis to be ready + Wait Until Keyword Succeeds 30s 2s + ... Check Redis Health + + # Check if data persisted + ${result}= Run Process docker-compose -f ${COMPOSE_FILE} exec -T redis-test + ... redis-cli GET ${test_key} + ... cwd=. + + Should Be Equal As Strings ${result.stdout.strip()} ${test_value} + Log Redis data persistence verified + +Test Queue Stats Accuracy + [Documentation] Test that queue statistics accurately reflect job states + [Tags] rq statistics accuracy positive + + # Get baseline stats + ${initial_stats}= Check Queue Stats + ${initial_queued}= Set Variable ${initial_stats}[queued_jobs] + + # Find test conversation + ${conversation_id}= Find Or Create Test Conversation + + IF $conversation_id != $None + # Create multiple jobs to get meaningful stats + ${job_count}= Set Variable 3 + FOR ${i} IN RANGE ${job_count} + ${job_id}= Trigger Transcript Reprocessing ${conversation_id} + Sleep 0.5s + END + + # Check updated stats + ${updated_stats}= Check Queue Stats + ${updated_queued}= Set Variable ${updated_stats}[queued_jobs] + + # Should have same or more jobs queued (jobs may process quickly) + Should Be True ${updated_queued} >= ${initial_queued} + Log Queue statistics updated: ${initial_queued} -> ${updated_queued} + ELSE + Log No conversations available for stats accuracy test + Pass Execution No conversations available for queue stats accuracy test + END + +Test Queue API Authentication + [Documentation] Test that queue endpoints properly enforce authentication + [Tags] rq security authentication negative + + # Create anonymous session (no authentication) + Get Anonymous Session anon_session + + # Queue jobs endpoint should require authentication + ${response}= GET On Session anon_session /api/queue/jobs expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + + # Queue stats endpoint should require authentication + ${response}= GET On Session anon_session /api/queue/stats expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + + # Queue health should be publicly accessible + ${response}= GET On Session anon_session /api/queue/health expected_status=200 + Should Be Equal As Integers ${response.status_code} 200 + + Log Queue API authentication properly enforced \ No newline at end of file diff --git a/tests/endpoints/system_admin_tests.robot b/tests/endpoints/system_admin_tests.robot new file mode 100644 index 00000000..b92ad8be --- /dev/null +++ b/tests/endpoints/system_admin_tests.robot @@ -0,0 +1,295 @@ +*** Settings *** +Documentation System and Admin API Tests +Library RequestsLibrary +Library Collections +Library String +Library OperatingSystem +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/user_resources.robot +Suite Setup Suite Setup +Suite Teardown Delete All Sessions + +*** Test Cases *** + +Get Diarization Settings Test + [Documentation] Test getting diarization settings (admin only) + [Tags] system diarization admin positive + Get Anonymous Session anon_session + + Create API Session session + ${response}= GET On Session session /api/diarization-settings + Should Be Equal As Integers ${response.status_code} 200 + + ${settings}= Set Variable ${response.json()} + Should Be True isinstance($settings, dict) + +Save Diarization Settings Test + [Documentation] Test saving diarization settings (admin only) + [Tags] system diarization admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # First get current settings + ${get_response}= GET On Session admin_session /api/diarization-settings + Should Be Equal As Integers ${get_response.status_code} 200 + ${current_settings}= Set Variable ${get_response.json()} + + # Save the same settings (should succeed) + ${response}= POST On Session admin_session /api/diarization-settings json=${current_settings} + Should Be Equal As Integers ${response.status_code} 200 + +Get Speaker Configuration Test + [Documentation] Test getting user's speaker configuration + [Tags] system speakers user positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/speaker-configuration + Should Be Equal As Integers ${response.status_code} 200 + + ${config}= Set Variable ${response.json()} + Should Be True isinstance($config, dict) + + # Verify expected structure + Dictionary Should Contain Key ${config} primary_speakers + Dictionary Should Contain Key ${config} user_id + Dictionary Should Contain Key ${config} status + Should Be True isinstance($config['primary_speakers'], list) + +Update Speaker Configuration Test + [Documentation] Test updating user's speaker configuration + [Tags] system speakers user positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # Update with empty speaker list + ${speakers}= Create List + ${response}= POST On Session admin_session /api/speaker-configuration json=${speakers} + Should Be Equal As Integers ${response.status_code} 200 + +Get Enrolled Speakers Test + [Documentation] Test getting enrolled speakers from speaker recognition service + [Tags] system speakers service positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/enrolled-speakers + Should Be Equal As Integers ${response.status_code} 200 + + ${response_data}= Set Variable ${response.json()} + Dictionary Should Contain Key ${response_data} service_available + Dictionary Should Contain Key ${response_data} speakers + + # Check if speaker service is actually available + IF ${response_data}[service_available] == $False + Skip Speaker recognition service is not available or configured + END + + # If service is available, verify speakers data + Should Be True isinstance($response_data[speakers], list) + +Get Speaker Service Status Test + [Documentation] Test checking speaker recognition service status (admin only) + [Tags] system speakers service admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/speaker-service-status + Should Be Equal As Integers ${response.status_code} 200 + + ${status}= Set Variable ${response.json()} + Should Be True isinstance($status, dict) + + # Verify expected keys are present + Dictionary Should Contain Key ${status} service_available + Dictionary Should Contain Key ${status} healthy + Dictionary Should Contain Key ${status} status + + # Check if speaker service is actually available + IF ${status}[service_available] == $False + Skip Speaker recognition service is not available or configured + END + +Get Memory Config Raw Test + [Documentation] Test getting raw memory configuration (admin only) + [Tags] system memory config admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/admin/memory/config/raw + Should Be Equal As Integers ${response.status_code} 200 + + # Raw config should be text/yaml + ${config}= Set Variable ${response.text} + Should Not Be Empty ${config} + +Validate Memory Config Test + [Documentation] Test validating memory configuration YAML (admin only) + [Tags] system memory config validation admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test with valid YAML + ${valid_yaml}= Set Variable memory_provider: "friend_lite"\nextraction:\n enabled: true + &{data}= Create Dictionary config_yaml=${valid_yaml} + ${response}= POST On Session admin_session /api/admin/memory/config/validate json=${data} + Should Be Equal As Integers ${response.status_code} 200 + + # Test with invalid YAML + ${invalid_yaml}= Set Variable invalid: yaml: structure: + &{data}= Create Dictionary config_yaml=${invalid_yaml} + ${response}= POST On Session admin_session /api/admin/memory/config/validate json=${data} expected_status=any + Should Be True ${response.status_code} in [400, 422] + +Reload Memory Config Test + [Documentation] Test reloading memory configuration (admin only) + [Tags] system memory config reload admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= POST On Session admin_session /api/admin/memory/config/reload + Should Be Equal As Integers ${response.status_code} 200 + +Delete All User Memories Test + [Documentation] Test deleting all memories for current user + [Tags] system memory delete user positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= DELETE On Session admin_session /api/admin/memory/delete-all + Should Be Equal As Integers ${response.status_code} 200 + + ${result}= Set Variable ${response.json()} + Dictionary Should Contain Key ${result} message + +List Processing Jobs Test + [Documentation] Test listing processing jobs (admin only) + [Tags] system processing jobs admin positive + Get Anonymous Session anon_session + + Create API Session admin_session + ${response}= GET On Session admin_session /api/process-audio-files/jobs + Should Be Equal As Integers ${response.status_code} 200 + + ${jobs}= Set Variable ${response.json()} + Should Be True isinstance($jobs, (dict, list)) + +Non-Admin Cannot Access Admin Endpoints Test + [Documentation] Test that non-admin users cannot access admin endpoints + [Tags] system security negative + Get Anonymous Session anon_session + + Create API Session session + + # Create a non-admin user + ${test_user}= Create Test User session test-user-${RANDOM_ID}@example.com test-password-123 + Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 + + # Test various admin endpoints + ${endpoints}= Create List + ... /api/diarization-settings + ... /api/speaker-service-status + ... /api/admin/memory/config/raw + ... /api/admin/memory/config/reload + ... /api/process-audio-files/jobs + + FOR ${endpoint} IN @{endpoints} + ${response}= GET On Session user_session ${endpoint} expected_status=any + Should Be True ${response.status_code} in [405, 403] + END + + # Cleanup + Delete Test User session ${test_user}[user_id] + +Unauthorized System Access Test + [Documentation] Test that system endpoints require authentication + [Tags] system security negative + Get Anonymous Session anon_session + + # Test endpoints that require authentication + ${auth_endpoints}= Create List + ... /api/diarization-settings + ... /api/speaker-configuration + ... /api/enrolled-speakers + ... /api/admin/memory/delete-all + + FOR ${endpoint} IN @{auth_endpoints} + ${response}= GET On Session anon_session ${endpoint} expected_status=any + Should Be True ${response.status_code} in [401, 405, 403] + END + +Invalid System Operations Test + [Documentation] Test invalid operations on system endpoints + [Tags] system negative validation + Get Anonymous Session anon_session + + Create API Session admin_session + + # Test saving invalid diarization settings + ${invalid_settings}= Create Dictionary invalid_key=invalid_value + ${response}= POST On Session admin_session /api/diarization-settings json=${invalid_settings} expected_status=any + Should Be True ${response.status_code} in [400, 422] + + # Test updating memory config with invalid YAML + ${invalid_yaml}= Set Variable {invalid yaml content + &{data}= Create Dictionary config_yaml=${invalid_yaml} + ${response}= POST On Session admin_session /api/admin/memory/config/raw json=${data} expected_status=any + Should Be True ${response.status_code} in [400, 422] + +Memory Configuration Workflow Test + [Documentation] Test complete memory configuration workflow (admin only) + [Tags] system memory config workflow admin + Get Anonymous Session anon_session + + Create API Session admin_session + + # 1. Get current config + ${get_response}= GET On Session admin_session /api/admin/memory/config/raw + Should Be Equal As Integers ${get_response.status_code} 200 + ${original_config}= Set Variable ${get_response.text} + + # 2. Validate the current config + &{validate_data}= Create Dictionary config_yaml=${original_config} + ${validate_response}= POST On Session admin_session /api/admin/memory/config/validate json=${validate_data} + Should Be Equal As Integers ${validate_response.status_code} 200 + + # 3. Reload config (should succeed) + ${reload_response}= POST On Session admin_session /api/admin/memory/config/reload + Should Be Equal As Integers ${reload_response.status_code} 200 + +Speaker Configuration Workflow Test + [Documentation] Test complete speaker configuration workflow + [Tags] system speakers workflow user + Get Anonymous Session anon_session + + Create API Session admin_session + + # 1. Get current speaker configuration + ${get_response}= GET On Session admin_session /api/speaker-configuration + Should Be Equal As Integers ${get_response.status_code} 200 + ${current_config}= Set Variable ${get_response.json()} + + # 2. Update speaker configuration (with empty list) + ${empty_speakers}= Create List + ${update_response}= POST On Session admin_session /api/speaker-configuration json=${empty_speakers} + Should Be Equal As Integers ${update_response.status_code} 200 + + # 3. Verify the update + ${verify_response}= GET On Session admin_session /api/speaker-configuration + Should Be Equal As Integers ${verify_response.status_code} 200 + ${updated_config}= Set Variable ${verify_response.json()} + + # Verify response structure + Dictionary Should Contain Key ${updated_config} primary_speakers + Dictionary Should Contain Key ${updated_config} user_id + Dictionary Should Contain Key ${updated_config} status + + # Should be empty list now + ${speakers_list}= Set Variable ${updated_config}[primary_speakers] + ${length}= Get Length ${speakers_list} + Should Be Equal As Integers ${length} 0 + diff --git a/tests/integration/conversation_queue.robot b/tests/integration/conversation_queue.robot new file mode 100644 index 00000000..937c2158 --- /dev/null +++ b/tests/integration/conversation_queue.robot @@ -0,0 +1,149 @@ +*** Settings *** +Documentation Conversation Queue Integration Tests +Library RequestsLibrary +Library Collections +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/audio_keywords.robot +Resource ../resources/conversation_keywords.robot +Resource ../resources/queue_keywords.robot +Variables ../test_env.py +Variables ../test_data.py +Suite Setup Suite Setup +Suite Teardown Delete All Sessions +Test Setup Clear Test Databases + + +*** Test Cases *** + +Test Upload audio creates transcription job + [Documentation] Test that uploading audio creates a transcription job in the queue + [Tags] integration queue upload + [Timeout] 120s + + Log Starting Upload Job Queue Test INFO + + # Verify queue is empty + ${initial_job_count}= Get queue length + Should Be Equal As Integers ${initial_job_count} 0 + + # Upload audio file to create conversation and trigger transcription job + ${conversation}= Upload Audio File ${TEST_AUDIO_FILE} ${TEST_DEVICE_NAME} + ${conversation_id}= Set Variable ${conversation}[conversation_id] + + Log Created conversation: ${conversation_id} INFO + + # Verify a new job has been added to the queue + Wait Until Keyword Succeeds 10s 2s Get queue length + ${job_count}= Get queue length + Should Be True ${job_count} >= 1 Expected at least 1 job in queue, got ${job_count} + + # Get the list of jobs and find the transcription job for our conversation + ${jobs}= Get job queue + + # Find the transcription job for our conversation + # Note: transcript jobs have job_type "reprocess_transcript" and conversation_id as args[0] + ${transcription_job}= Set Variable None + FOR ${job} IN @{jobs} + ${job_type}= Set Variable ${job}[job_type] + # Check if this is a transcript job (job_type contains "transcript") + ${is_transcript_job}= Evaluate "transcript" in "${job_type}".lower() + IF ${is_transcript_job} + # Get conversation_id from args[0] (first argument to transcript job) + ${job_conv_id}= Set Variable ${job}[args][0] + # Check if conversation_id matches (compare first 8 chars for short IDs) + ${conv_id_short}= Evaluate "${conversation_id}"[:8] + ${job_conv_id_short}= Evaluate "${job_conv_id}"[:8] + IF '${conv_id_short}' == '${job_conv_id_short}' + ${transcription_job}= Set Variable ${job} + Exit For Loop + END + END + END + Should Not Be Equal ${transcription_job} None Transcription job for conversation ${conversation_id} not found in queue + +Test Reprocess Conversation Job Queue + [Documentation] Test that reprocess transcript jobs are created and processed correctly + [Tags] integration queue reprocess + [Timeout] 180s + + Log Starting Reprocess Job Queue Test INFO + + # First, create a conversation by uploading audio + ${conversation}= Upload Audio File ${TEST_AUDIO_FILE} ${TEST_DEVICE_NAME} + ${conversation_id}= Set Variable ${conversation}[conversation_id] + + Log Created conversation: ${conversation_id} INFO + + # verify existing jobs to get clean baseline + ${initial_job_count}= Get queue length + Log To Console Initial job count: ${initial_job_count} INFO + + # Trigger transcript reprocessing + Log Triggering transcript reprocessing for conversation ${conversation_id} INFO + ${reprocess_data}= Reprocess Transcript ${conversation_id} + ${job_id}= Set Variable ${reprocess_data}[job_id] + # Verify job is not failed initially + Should Not Be Equal As Strings "failed" ${reprocess_data}[status] + + Sleep 2s # Give RQ workers a moment to pick up the job + ${job}= Get Job Details ${job_id} + + # Wait for job to be processed with timeout (RQ workers need time for real transcription) + Wait Until Keyword Succeeds 60s 2s Job Should Be Complete ${job_id} + ${job_details}= Get Job Details ${job_id} + + # Verify job structure matches UI expectations + Dictionary Should Contain Key ${job_details} job_id + Dictionary Should Contain Key ${job_details} job_type + Dictionary Should Contain Key ${job_details} status + Dictionary Should Contain Key ${job_details} priority + Dictionary Should Contain Key ${job_details} created_at + Dictionary Should Contain Key ${job_details} queue_name + + # Verify job details + Should Be Equal As Strings ${job_details}[job_id] ${job_id} + Should Be Equal As Strings ${job_details}[job_type] reprocess_transcript + Should Be Equal As Strings ${job_details}[queue_name] transcription + Should Be Equal As Strings ${job_details}[priority] normal + + # Job should be completed (or failed) by now + Should Be True '${job_details}[status]' in ['completed', 'finished'] Job status: ${job_details}[status], expected completed or finished + + # Log job details for debugging + Log Job details: ${job_details} INFO + + # Check if job has result data (some completed jobs might not have result field) + ${has_result}= Run Keyword And Return Status Dictionary Should Contain Key ${job_details} result + IF ${has_result} + ${result}= Set Variable ${job_details}[result] + Log Job result: ${result} INFO + Dictionary Should Contain Key ${result} success + Dictionary Should Contain Key ${result} conversation_id + Should Be Equal As Strings ${result}[conversation_id] ${conversation_id} + ELSE + Log Job completed but has no result field - checking conversation was updated WARN + END + + # Verify conversation was actually updated with new transcript version + ${updated_conversation}= Get Conversation By ID ${conversation_id} + + # Check that version info shows multiple transcript versions + Dictionary Should Contain Key ${updated_conversation} version_info + ${version_info}= Set Variable ${updated_conversation}[version_info] + Dictionary Should Contain Key ${version_info} transcript_count + ${transcript_count}= Set Variable ${version_info}[transcript_count] + Should Be True ${transcript_count} > 1 Expected multiple transcript versions, got ${transcript_count} + Should Be True ${updated_conversation}[transcript] != [] Expected conversation to have transcript after reprocessing + + Log Reprocess Job Queue Test Completed Successfully INFO + +*** Keywords *** + +Job Should Be Complete + [Documentation] Check if job has reached a completed state + [Arguments] ${job_id} + + ${job_details}= Get Job Details ${job_id} + ${status}= Set Variable ${job_details}[status] + Should Be True '${status}' in ['completed', 'finished', 'failed'] Job status: ${status} diff --git a/tests/integration/integration_test.robot b/tests/integration/integration_test.robot new file mode 100644 index 00000000..1d65ac08 --- /dev/null +++ b/tests/integration/integration_test.robot @@ -0,0 +1,149 @@ +*** Settings *** +Documentation Full Pipeline Integration Test +Library RequestsLibrary +Library Collections +Library Process +Library String +Library DateTime +Library OperatingSystem +Resource ../resources/setup_resources.robot +Resource ../resources/session_resources.robot +Resource ../resources/audio_keywords.robot +Resource ../resources/conversation_keywords.robot +Variables ../test_env.py +Variables ../test_data.py +Suite Setup Suite Setup +Suite Teardown Delete All Sessions +Test Setup Clear Test Databases + + +*** Test Cases *** +Full Pipeline Integration Test + [Documentation] Complete end-to-end test of audio processing pipeline + [Tags] integration pipeline e2e + [Timeout] 600s + + Log Starting Full Pipeline Integration Test INFO + + + # Phase 4: Audio Processing - Upload and wait for conversation completion + Log Starting audio upload and processing INFO + ${conversation}= Upload Audio File ${TEST_AUDIO_FILE} ${TEST_DEVICE_NAME} + + Log Audio processing completed, conversation created INFO + Set Global Variable ${TEST_CONVERSATION} ${conversation} + + # Phase 5: Transcription Verification + Verify Transcription Quality ${TEST_CONVERSATION} ${EXPECTED_TRANSCRIPT} + + # Phase 6: Memory Extraction Verification + Verify Memory Extraction api ${TEST_CONVERSATION} + + # Phase 7: Chat Integration + Verify Chat Integration api ${TEST_CONVERSATION} + + Log Full Pipeline Integration Test Completed Successfully INFO + +*** Keywords *** + + +Verify Transcription Quality + [Documentation] Verify the transcription meets quality standards + [Arguments] ${conversation} ${expected_content} + + Log Verifying transcription quality INFO + + # Extract transcript (can be string or array of segments) + Dictionary Should Contain Key ${conversation} transcript + ${transcript_raw}= Set Variable ${conversation}[transcript] + Should Not Be Empty ${transcript_raw} Transcript is empty + + # Handle both string and array formats + ${transcript_text}= Run Keyword If isinstance($transcript_raw, list) + ... Set Variable ${transcript_raw}[0][text] + ... ELSE Set Variable ${transcript_raw} + + # Check transcript contains expected content + ${transcript_lower}= Convert To Lower Case ${transcript_text} + ${expected_lower}= Convert To Lower Case ${expected_content} + Should Contain ${transcript_lower} ${expected_lower} Transcript does not contain expected content + + # Verify transcript has reasonable length (at least 50 characters for 4-minute audio) + ${transcript_length}= Get Length ${transcript_text} + Should Be True ${transcript_length} >= 50 Transcript too short: ${transcript_length} characters + + # Check segments exist (if transcript is array format) + ${segment_count}= Run Keyword If isinstance($transcript_raw, list) + ... Get Length ${transcript_raw} + ... ELSE Set Variable 1 + + Should Be True ${segment_count} > 0 No transcript segments found + + Log Transcription quality verification passed INFO + Log Transcript length: ${transcript_length} characters, Segments: ${segment_count} INFO + +Verify Memory Extraction + [Documentation] Verify memories were extracted from the conversation + [Arguments] ${session_alias} ${conversation} + + Log Verifying memory extraction INFO + + # Check if conversation has memory count (may still be processing) + ${has_memory_count}= Run Keyword And Return Status Dictionary Should Contain Key ${conversation} memory_count + ${memory_count}= Run Keyword If ${has_memory_count} + ... Set Variable ${conversation}[memory_count] + ... ELSE Set Variable 0 + + # Get memories from API using session + ${response}= GET On Session ${session_alias} /api/memories + Should Be Equal As Integers ${response.status_code} 200 + + ${memories_data}= Set Variable ${response.json()} + Dictionary Should Contain Key ${memories_data} memories + ${memories}= Set Variable ${memories_data}[memories] + + ${api_memory_count}= Get Length ${memories} + + # Verify memory extraction status (allow for memory processing to be in progress) + Should Be True ${memory_count} >= 0 Memory count is negative + Should Be True ${api_memory_count} >= 0 API memory count is negative + + Log Memory extraction verification passed (may still be processing) INFO + Log Conversation memory count: ${memory_count}, API memory count: ${api_memory_count} INFO + +Verify Chat Integration + [Documentation] Verify chat system can access conversation data + [Arguments] ${session_alias} ${conversation} + + Log Verifying chat integration INFO + + # Create a chat session to test basic chat functionality + ${chat_data}= Create Dictionary title=Integration Test Chat + ${response}= POST On Session ${session_alias} /api/chat/sessions json=${chat_data} expected_status=any + Should Be True ${response.status_code} in [200, 201] Chat session creation failed with status ${response.status_code} + + ${session_data}= Set Variable ${response.json()} + Dictionary Should Contain Key ${session_data} session_id + ${session_id}= Set Variable ${session_data}[session_id] + + Log Chat session created successfully: ${session_id} INFO + + # Try to send a message (if endpoint is available) + ${conversation_id}= Set Variable ${conversation}[conversation_id] + ${message_data}= Create Dictionary content=What did we discuss about glass blowing in conversation ${conversation_id}? + ${msg_status}= Run Keyword And Return Status + ... POST On Session ${session_alias} /api/chat/sessions/${session_id}/messages json=${message_data} expected_status=200 + + IF ${msg_status} + Log Chat message functionality is available INFO + ELSE + Log Chat message endpoints not available or not implemented - skipping message test WARN + END + + # Clean up chat session + ${response}= DELETE On Session ${session_alias} /api/chat/sessions/${session_id} expected_status=any + Should Be True ${response.status_code} in [200, 204] Chat session deletion failed with status ${response.status_code} + + Log Chat integration verification completed INFO + + diff --git a/tests/integration/mobile_client_tests.robot b/tests/integration/mobile_client_tests.robot new file mode 100644 index 00000000..bcf4ff1f --- /dev/null +++ b/tests/integration/mobile_client_tests.robot @@ -0,0 +1,50 @@ +*** Settings *** +Documentation Debug Pipeline Step by Step +Resource ../resources/integration_keywords.robot +Resource ../resources/setup_resources.robot +Suite Setup Suite Setup +Suite Teardown Suite Teardown + +*** Test Cases *** + +Test server connection + [Documentation] Test connection to the server + [Tags] debug connection todo + + Log Testing server connection INFO + Fail Test not written yet - placeholder test + +Login to server + [Documentation] Test logging in to the server from mobile client + Log Logging in to server INFO + Fail Test not written yet - placeholder test + +Scan bluetooth devices + [Documentation] Scan for available bluetooth devices + Log Scanning bluetooth devices INFO + Fail Test not written yet - placeholder test + +Filter devices by omi + [Documentation] Filter scanned devices by omi + Log Filtering devices by omi INFO + Fail Test not written yet - placeholder test + +Connect to bluetooth device + [Documentation] Connect to a bluetooth device + Log Connecting to bluetooth device INFO + Fail Test not written yet - placeholder test + +Get device codec + [Documentation] Get the codec information from the device + Log Getting device codec INFO + Fail Test not written yet - placeholder test + +Get device battery level + [Documentation] Get the battery level from the device + Log Getting device battery level INFO + Fail Test not written yet - placeholder test + +Start audio stream + [Documentation] Start streaming audio from the device + Log Starting audio stream INFO + Fail Test not written yet - placeholder test diff --git a/tests/resources/audio_keywords.robot b/tests/resources/audio_keywords.robot new file mode 100644 index 00000000..d1d00b3c --- /dev/null +++ b/tests/resources/audio_keywords.robot @@ -0,0 +1,117 @@ +*** Settings *** +Documentation Audio Keywords +Library RequestsLibrary +Library Collections +Library OperatingSystem +Variables ../test_data.py +Resource session_resources.robot +Resource conversation_keywords.robot +Resource queue_keywords.robot + +*** Keywords *** +Upload Audio File + [Documentation] Upload audio file using session with proper multipart form data + [Arguments] ${audio_file_path} ${device_name}=robot-test + + # Verify file exists + File Should Exist ${audio_file_path} + + # Debug the request being sent + Log Sending file: ${audio_file_path} + Log Device name: ${device_name} + + # Create proper file upload using Python expressions to actually open the file + Log Files dictionary will contain: files -> ${audio_file_path} + Log Data dictionary will contain: device_name -> ${device_name} + + ${response}= POST On Session api /api/audio/upload + ... files=${{ {'files': open('${audio_file_path}', 'rb')} }} + ... params=device_name=${device_name} + ... expected_status=any + + # Detailed debugging of the response + Log Upload response status: ${response.status_code} + Log Upload response headers: ${response.headers} + Log Upload response content type: ${response.headers.get('content-type', 'not set')} + Log Upload response text length: ${response.text.__len__()} + Log Upload response raw text: ${response.text} + + # Parse JSON response to dictionary + ${upload_response}= Set Variable ${response.json()} + Log Parsed upload response: ${upload_response} + + # Validate upload was successful + Should Be Equal As Strings ${upload_response['summary']['processing']} 1 Upload failed: No files enqueued + Should Be Equal As Strings ${upload_response['files'][0]['status']} processing Upload failed: ${response.text} + + # Extract important values + ${audio_uuid}= Set Variable ${upload_response['files'][0]['audio_uuid']} + ${job_id}= Set Variable ${upload_response['files'][0]['job_id']} + Log Audio UUID: ${audio_uuid} + Log Job ID: ${job_id} + + + + # Wait for conversation to be created and transcribed + Log Waiting for transcription to complete... + + + Wait Until Keyword Succeeds 60s 5s Check job status ${job_id} completed + ${job}= Get Job Details ${job_id} + + # Get the completed conversation + ${conversation}= Get Conversation By ID ${job}[result][conversation_id] + Should Exist ${conversation} Conversation not found after upload and processing + + Log Found conversation: ${conversation} + RETURN ${conversation} + +Conversation Should Be Complete + [Documentation] Check if conversation exists and has transcript + [Arguments] ${device_name} + + ${conversations}= Get Conversations For Device ${device_name} + + # Should have at least one conversation + ${count}= Get Length ${conversations} + Should Be True ${count} > 0 No conversations found for device: ${device_name} + + # Check if first conversation has transcript (use segment_count from list endpoint) + ${conversation}= Set Variable ${conversations}[0] + Should Be True ${conversation}[segment_count] > 0 Transcript not ready yet (segment_count: ${conversation}[segment_count]) + + # Optional: Check if it has memories (if memory processing is expected) + Log Conversation ready: ${conversation}[conversation_id] + +Get Conversations For Device + [Documentation] Get conversations filtered by device name (or return latest if device name not in client_id) + [Arguments] ${device_name} + + ${all_conversations}= GET user conversations + + # Filter conversations by device name - all_conversations is now a flat list + ${matching_conversations}= Create List + + FOR ${conversation} IN @{all_conversations} + ${client_id}= Set Variable ${conversation}[client_id] + # Case-insensitive search - check if device_name (lowercased) is in client_id (lowercased) + ${device_lower}= Evaluate "${device_name}".lower() + ${client_lower}= Evaluate "${client_id}".lower() + ${is_match}= Evaluate "${device_lower}" in "${client_lower}" + IF ${is_match} + Append To List ${matching_conversations} ${conversation} + END + END + + # If no matches found with device name, return the most recent conversation (fallback) + ${match_count}= Get Length ${matching_conversations} + IF ${match_count} == 0 + Log No conversations found matching device name '${device_name}', using latest conversation as fallback + ${conv_count}= Get Length ${all_conversations} + IF ${conv_count} > 0 + ${latest_conversation}= Set Variable ${all_conversations}[0] + Append To List ${matching_conversations} ${latest_conversation} + END + END + + RETURN ${matching_conversations} \ No newline at end of file diff --git a/tests/resources/chat_keywords.robot b/tests/resources/chat_keywords.robot new file mode 100644 index 00000000..3a25dc2f --- /dev/null +++ b/tests/resources/chat_keywords.robot @@ -0,0 +1,107 @@ +*** Settings *** +Documentation Chat Service Keywords +Library RequestsLibrary +Library Collections +Variables ../test_env.py +Resource session_resources.robot + +*** Keywords *** + +Create Chat Session + [Documentation] Create a new chat session (uses admin session) + [Arguments] ${title}=${None} ${expected_status}=200 + + # Get admin session + Create API Session session + + &{data}= Create Dictionary + + IF '${title}' != '${None}' + Set To Dictionary ${data} title=${title} + END + + ${response}= POST On Session admin_session /api/chat/sessions json=${data} expected_status=${expected_status} + RETURN ${response} + +Get Chat Sessions + [Documentation] Get all chat sessions for user (uses admin session) + [Arguments] ${limit}=50 + + # Get admin session + Create API Session admin_session + + &{params}= Create Dictionary limit=${limit} + ${response}= GET On Session admin_session /api/chat/sessions params=${params} + RETURN ${response} + + +Delete Chat Session + [Documentation] Delete a chat session (uses admin session) + [Arguments] ${session_id} ${expected_status}=200 + + # Get admin session + Create API Session session + + ${response}= DELETE On Session session /api/chat/sessions/${session_id} expected_status=${expected_status} + RETURN ${response} + + + +Create Test Chat Session + [Documentation] Create a test chat session with random title (uses admin session) + [Arguments] ${title_prefix}=Test Session + ${random_suffix}= Generate Random String 6 [LETTERS][NUMBERS] + ${title}= Set Variable ${title_prefix} ${random_suffix} + + ${response}= Create Chat Session ${title} + Should Be Equal As Integers ${response.status_code} 200 + + RETURN ${response.json()} + +Cleanup Test Chat Session + [Documentation] Clean up a test chat session (uses admin session) + [Arguments] ${session_id} + ${response}= Delete Chat Session ${session_id} + Should Be Equal As Integers ${response.status_code} 200 + +Get Chat Session + [Documentation] Get a specific chat session (uses admin session) + [Arguments] ${session_id} ${expected_status}=200 + + # Get admin session + Create API Session admin_session + + ${response}= GET On Session admin_session /api/chat/sessions/${session_id} expected_status=${expected_status} + RETURN ${response} + +Get Session Messages + [Documentation] Get messages from a chat session (uses admin session) + [Arguments] ${session_id} ${limit}=100 ${expected_status}=200 + + # Get admin session + Create API Session admin_session + + &{params}= Create Dictionary limit=${limit} + ${response}= GET On Session admin_session /api/chat/sessions/${session_id}/messages params=${params} expected_status=${expected_status} + RETURN ${response} + +Update Chat Session + [Documentation] Update a chat session title (uses admin session) + [Arguments] ${session_id} ${new_title} ${expected_status}=200 + + # Get admin session + Create API Session admin_session + + &{data}= Create Dictionary title=${new_title} + ${response}= PUT On Session admin_session /api/chat/sessions/${session_id} json=${data} expected_status=${expected_status} + RETURN ${response} + +Get Chat Statistics + [Documentation] Get chat statistics for user (uses admin session) + + # Get admin session + Create API Session admin_session + + ${response}= GET On Session admin_session /api/chat/statistics + RETURN ${response} + diff --git a/tests/resources/conversation_keywords.robot b/tests/resources/conversation_keywords.robot new file mode 100644 index 00000000..afb5acf8 --- /dev/null +++ b/tests/resources/conversation_keywords.robot @@ -0,0 +1,152 @@ +*** Settings *** +Documentation Conversation Management Keywords +Library RequestsLibrary +Library Collections +Library Process +Resource session_resources.robot +Resource audio_keywords.robot + + +*** Keywords *** + +Get User Conversations + [Documentation] Get conversations for authenticated user (uses admin session) + + ${response}= GET On Session api /api/conversations expected_status=200 + RETURN ${response.json()}[conversations] + +Get Conversation By ID + [Documentation] Get a specific conversation by ID + [Arguments] ${conversation_id} + ${response}= GET On Session api /api/conversations/${conversation_id} + RETURN ${response.json()}[conversation] + +# Get Conversation Versions +# [Documentation] Get version history for a conversation +# [Arguments] ${conversation_id} +# ${response}= GET On Session api /api/conversations/${conversation_id}/versions +# RETURN ${response.json()}[versions] + +Reprocess Transcript + [Documentation] Trigger transcript reprocessing for a conversation + [Arguments] ${conversation_id} + + ${response}= POST On Session api /api/conversations/${conversation_id}/reprocess-transcript + Should Be Equal As Integers ${response.status_code} 200 + + ${reprocess_data}= Set Variable ${response.json()} + Dictionary Should Contain Key ${reprocess_data} job_id + Dictionary Should Contain Key ${reprocess_data} status + + ${job_id}= Set Variable ${reprocess_data}[job_id] + ${initial_status}= Set Variable ${reprocess_data}[status] + + Log Reprocess job created: ${job_id} with status: ${initial_status} INFO + Should Be Equal As Strings ${initial_status} queued + + RETURN ${response.json()} + +Reprocess Memory + [Documentation] Trigger memory reprocessing for a conversation + [Arguments] ${conversation_id} ${transcript_version_id}=active + &{params}= Create Dictionary transcript_version_id=${transcript_version_id} + + ${response}= POST On Session api /api/conversations/${conversation_id}/reprocess-memory headers=${headers} params=${params} + RETURN ${response.json()} + +Activate Transcript Version + [Documentation] Activate a specific transcript version + [Arguments] ${conversation_id} ${version_id} + + ${response}= POST On Session api /api/conversations/${conversation_id}/activate-transcript/${version_id} headers=${headers} + RETURN ${response.json()} + +Activate Memory Version + [Documentation] Activate a specific memory version + [Arguments] ${conversation_id} ${version_id} + + ${response}= POST On Session api /api/conversations/${conversation_id}/activate-memory/${version_id} headers=${headers} + RETURN ${response.json()} + +Delete Conversation + [Documentation] Delete a conversation + [Arguments] ${audio_uuid} + + ${response}= DELETE On Session api /api/conversations/${audio_uuid} headers=${headers} + RETURN ${response.json()} + +Delete Conversation Version + [Documentation] Delete a specific version from a conversation + [Arguments] ${conversation_id} ${version_type} ${version_id} + + ${response}= DELETE On Session api /api/conversations/${conversation_id}/versions/${version_type}/${version_id} headers=${headers} + RETURN ${response.json()} + +Close Current Conversation + [Documentation] Close the current conversation for a client + [Arguments] ${client_id} + + ${response}= POST On Session api /api/conversations/${client_id}/close headers=${headers} + RETURN ${response.json()} + +Get Cropped Audio Info + [Documentation] Get cropped audio information for a conversation + [Arguments] ${audio_uuid} + + ${response}= GET On Session api /api/conversations/${audio_uuid}/cropped headers=${headers} + RETURN ${response.json()}[cropped_audios] + +Add Speaker To Conversation + [Documentation] Add a speaker to the speakers_identified list + [Arguments] ${audio_uuid} ${speaker_id} + &{params}= Create Dictionary speaker_id=${speaker_id} + + ${response}= POST On Session api /api/conversations/${audio_uuid}/speakers headers=${headers} params=${params} + RETURN ${response.json()} + +Update Transcript Segment + [Documentation] Update a specific transcript segment + [Arguments] ${audio_uuid} ${segment_index} ${speaker_id}=${None} ${start_time}=${None} ${end_time}=${None} + &{params}= Create Dictionary + + IF '${speaker_id}' != '${None}' + Set To Dictionary ${params} speaker_id=${speaker_id} + END + IF '${start_time}' != '${None}' + Set To Dictionary ${params} start_time=${start_time} + END + IF '${end_time}' != '${None}' + Set To Dictionary ${params} end_time=${end_time} + END + + ${response}= PUT On Session api /api/conversations/${audio_uuid}/transcript/${segment_index} headers=${headers} params=${params} + RETURN ${response.json()} + + +Create Test Conversation + [Documentation] Create a test conversation by processing a test audio file + [Arguments] ${device_name}=test-device + + # Upload test audio file to create a conversation + ${test_audio_file}= Set Variable test-assets/DIY_Experts_Glass_Blowing_16khz_mono_4min.wav + + ${conversation}= Upload Audio File ${test_audio_file} ${device_name} + + RETURN ${conversation} + +Find Test Conversation + [Documentation] Find a conversation that exists for testing (uses admin session) + ${conversations_data}= Get User Conversations + Log Retrieved conversations data: ${conversations_data} + + # conversations_data is now a flat list + ${count}= Get Length ${conversations_data} + + IF ${count} > 0 + ${first_conv}= Set Variable ${conversations_data}[0] + RETURN ${first_conv} + END + + # If no conversations exist, return None (let tests handle appropriately) + RETURN ${None} + diff --git a/tests/resources/integration_keywords.robot b/tests/resources/integration_keywords.robot new file mode 100644 index 00000000..b6b556c5 --- /dev/null +++ b/tests/resources/integration_keywords.robot @@ -0,0 +1,215 @@ +*** Settings *** +Documentation Core integration workflow and system interaction keywords +... +... This file contains keywords for complex multi-step operations that combine +... multiple services. Keywords in this file should handle integration workflows, +... file processing, and system interactions that don't fit other categories. +... +... Examples of keywords that belong here: +... - Complex multi-step operations combining services +... - File processing and upload operations +... - Integration workflow keywords +... - System interaction keywords +... +... Keywords that should NOT be in this file: +... - Simple verification/assertion keywords (belong in tests) +... - User management operations (belong in user_resources.robot) +... - API session management (belong in session_resources.robot) +... - Docker service management (belong in setup_resources.robot) +Library RequestsLibrary +Library Collections +Library Process +Library String +Library DateTime +Library OperatingSystem +Variables ../test_env.py +Resource setup_resources.robot +Resource session_resources.robot +# Library JSONLibrary # Optional library, not required + +*** Keywords *** + + + + +Upload Audio File For Processing + [Documentation] Upload audio file and return processing result + [Arguments] ${session_alias} ${audio_file_path} ${device_name}=test-device + + # Verify file exists + File Should Exist ${audio_file_path} + + # For now, get a fresh token for curl (Robot Framework doesn't easily expose session headers) + ${token}= Get Token From Session ${session_alias} + + # Use curl for file upload (Robot Framework multipart is problematic) + ${curl_cmd}= Catenate SEPARATOR= + ... curl -s -X POST + ... ${SPACE}-H "Authorization: Bearer ${token}" + ... ${SPACE}-F "files=@${audio_file_path}" + ... ${SPACE}-F "device_name=${device_name}" + ... ${SPACE}${API_URL}/api/process-audio-files + + ${result}= Run Process ${curl_cmd} shell=True timeout=300 + + Should Be Equal As Integers ${result.rc} 0 File upload failed: ${result.stderr} + + ${response_data}= Evaluate json.loads('''${result.stdout}''') json + Should Be True ${response_data}[successful] > 0 No files processed successfully + + RETURN ${response_data} + +Wait For Audio Processing + [Documentation] Wait for audio processing to complete + [Arguments] ${processing_delay}=10s + + Log Waiting ${processing_delay} for audio processing to complete INFO + Sleep ${processing_delay} + +Get Latest Conversation + [Documentation] Get the most recent conversation for a device + [Arguments] ${session_alias} ${device_name} + + ${response}= GET On Session ${session_alias} /api/conversations expected_status=200 + ${conversations_list}= Set Variable ${response.json()}[conversations] + + # Find conversation for the specified device - conversations_list is now a flat list + FOR ${conversation} IN @{conversations_list} + ${client_id}= Set Variable ${conversation}[client_id] + ${is_target_device}= Evaluate "${device_name}" in "${client_id}" + IF ${is_target_device} + RETURN ${conversation} + END + END + + Fail No conversation found for device: ${device_name} + +Verify Transcript Content + [Documentation] Verify transcript contains expected content and quality + [Arguments] ${conversation} ${expected_keywords} ${min_length}=50 + + Dictionary Should Contain Key ${conversation} transcript + ${transcript}= Set Variable ${conversation}[transcript] + Should Not Be Empty ${transcript} + + # Check length + ${transcript_length}= Get Length ${transcript} + Should Be True ${transcript_length} >= ${min_length} Transcript too short: ${transcript_length} + + # Check for expected keywords + ${transcript_lower}= Convert To Lower Case ${transcript} + FOR ${keyword} IN @{expected_keywords} + ${keyword_lower}= Convert To Lower Case ${keyword} + Should Contain ${transcript_lower} ${keyword_lower} Missing keyword: ${keyword} + END + + # Verify segments exist + Dictionary Should Contain Key ${conversation} segments + ${segments}= Set Variable ${conversation}[segments] + ${segment_count}= Get Length ${segments} + Should Be True ${segment_count} > 0 No segments found + + Log Transcript verification passed: ${transcript_length} chars, ${segment_count} segments INFO + +Get User Memories + [Documentation] Get all memories for the authenticated user + [Arguments] ${session_alias} + + ${response}= GET On Session ${session_alias} /api/memories expected_status=200 + ${memories_data}= Set Variable ${response.json()} + + RETURN ${memories_data} + +Verify Memory Extraction + [Documentation] Verify memories were extracted successfully + [Arguments] ${conversation} ${memories_data} ${min_memories}=0 + + # Check conversation memory count + Dictionary Should Contain Key ${conversation} memory_count + ${conv_memory_count}= Set Variable ${conversation}[memory_count] + + # Check API memories + Dictionary Should Contain Key ${memories_data} memories + ${memories}= Set Variable ${memories_data}[memories] + ${api_memory_count}= Get Length ${memories} + + # Verify reasonable memory extraction + Should Be True ${conv_memory_count} >= ${min_memories} Insufficient memories: ${conv_memory_count} + Should Be True ${api_memory_count} >= ${min_memories} Insufficient API memories: ${api_memory_count} + + Log Memory extraction verified: conversation=${conv_memory_count}, api=${api_memory_count} INFO + +Create Test Chat Session + [Documentation] Create a chat session for testing + [Arguments] ${base_url} ${token} ${title}=Integration Test Chat + + Create Session api ${base_url} + &{headers}= Create Dictionary Authorization=Bearer ${token} + + ${chat_data}= Create Dictionary title=${title} + ${response}= POST On Session api /api/chat/sessions headers=${headers} json=${chat_data} expected_status=201 + + ${session_data}= Set Variable ${response.json()} + Dictionary Should Contain Key ${session_data} session_id + + Delete All Sessions api + RETURN ${session_data} + +Send Chat Message + [Documentation] Send a message to a chat session + [Arguments] ${base_url} ${token} ${session_id} ${message_content} + + Create Session api ${base_url} + &{headers}= Create Dictionary Authorization=Bearer ${token} + + ${message_data}= Create Dictionary content=${message_content} + ${response}= POST On Session api /api/chat/sessions/${session_id}/messages headers=${headers} json=${message_data} expected_status=201 + + ${message_response}= Set Variable ${response.json()} + Dictionary Should Contain Key ${message_response} message_id + + Delete All Sessions api + RETURN ${message_response} + +Delete Chat Session + [Documentation] Delete a chat session + [Arguments] ${base_url} ${token} ${session_id} + + Create Session api ${base_url} + &{headers}= Create Dictionary Authorization=Bearer ${token} + + DELETE On Session api /api/chat/sessions/${session_id} headers=${headers} expected_status=204 + Delete All Sessions api + +Check Environment Variables + [Documentation] Check required environment variables and return missing ones + [Arguments] @{required_vars} + + @{missing_vars}= Create List + FOR ${var} IN @{required_vars} + ${value}= Get Environment Variable ${var} ${EMPTY} + IF '${value}' == '${EMPTY}' + Append To List ${missing_vars} ${var} + ELSE + Log Environment variable ${var} is set DEBUG + END + END + RETURN ${missing_vars} + +Log Test Phase + [Documentation] Log the current test phase with timing + [Arguments] ${phase_name} + + ${timestamp}= Get Current Date result_format=%Y-%m-%d %H:%M:%S + Log === PHASE: ${phase_name} (${timestamp}) === INFO + +Measure Processing Time + [Documentation] Measure and log processing time for an operation + [Arguments] ${operation_name} ${start_time} + + ${end_time}= Get Current Date result_format=epoch + ${duration}= Evaluate ${end_time} - ${start_time} + ${duration_str}= Convert To String ${duration} + + Log ${operation_name} completed in ${duration_str} seconds INFO + RETURN ${duration} \ No newline at end of file diff --git a/tests/resources/memory_keywords.robot b/tests/resources/memory_keywords.robot new file mode 100644 index 00000000..ae9ba8b6 --- /dev/null +++ b/tests/resources/memory_keywords.robot @@ -0,0 +1,74 @@ +*** Settings *** +Documentation Memory Management Keywords +Library RequestsLibrary +Library Collections +Variables ../test_env.py + +*** Keywords *** + +Get User Memories + [Documentation] Get memories for authenticated user + [Arguments] ${token} ${limit}=50 ${user_id}=${None} + &{headers}= Create Dictionary Authorization=Bearer ${token} + &{params}= Create Dictionary limit=${limit} + + IF '${user_id}' != '${None}' + Set To Dictionary ${params} user_id=${user_id} + END + + ${response}= GET On Session api /api/memories headers=${headers} params=${params} + RETURN ${response} + +Get Memories With Transcripts + [Documentation] Get memories with their source transcripts + [Arguments] ${token} ${limit}=50 + &{headers}= Create Dictionary Authorization=Bearer ${token} + &{params}= Create Dictionary limit=${limit} + + ${response}= GET On Session api /api/memories/with-transcripts headers=${headers} params=${params} + RETURN ${response} + +Search Memories + [Documentation] Search memories by query + [Arguments] ${token} ${query} ${limit}=20 ${score_threshold}=0.0 + &{headers}= Create Dictionary Authorization=Bearer ${token} + &{params}= Create Dictionary query=${query} limit=${limit} score_threshold=${score_threshold} + + ${response}= GET On Session api /api/memories/search headers=${headers} params=${params} + RETURN ${response} + +Delete Memory + [Documentation] Delete a specific memory + [Arguments] ${token} ${memory_id} + &{headers}= Create Dictionary Authorization=Bearer ${token} + + ${response}= DELETE On Session api /api/memories/${memory_id} headers=${headers} + RETURN ${response} + +Get Unfiltered Memories + [Documentation] Get all memories including fallback transcript memories + [Arguments] ${token} ${limit}=50 + &{headers}= Create Dictionary Authorization=Bearer ${token} + &{params}= Create Dictionary limit=${limit} + + ${response}= GET On Session api /api/memories/unfiltered headers=${headers} params=${params} + RETURN ${response} + +Get All Memories Admin + [Documentation] Get all memories across all users (admin only) + [Arguments] ${admin_token} ${limit}=200 + &{headers}= Create Dictionary Authorization=Bearer ${admin_token} + &{params}= Create Dictionary limit=${limit} + + ${response}= GET On Session api /api/memories/admin headers=${headers} params=${params} + RETURN ${response} + + +Count User Memories + [Documentation] Count memories for a user + [Arguments] ${token} + ${response}= Get User Memories ${token} 1000 + Should Be Equal As Integers ${response.status_code} 200 + ${memories}= Set Variable ${response.json()} + ${count}= Get Length ${memories} + RETURN ${count} \ No newline at end of file diff --git a/tests/resources/queue_keywords.robot b/tests/resources/queue_keywords.robot new file mode 100644 index 00000000..10291a18 --- /dev/null +++ b/tests/resources/queue_keywords.robot @@ -0,0 +1,69 @@ +*** Settings *** +Documentation Memory Management Keywords +Library RequestsLibrary +Library Collections +Variables ../test_env.py +Resource session_resources.robot + +*** Keywords *** + +Get job queue + [Documentation] Get the current job queue from Redis + [Arguments] ${queue_name}=default + ${response}= GET On Session api /api/queue/jobs + ${jobs}= Set Variable ${response.json()}[jobs] + RETURN ${jobs} + +Get queue length + [Documentation] Get the length of the specified job queue + [Arguments] ${queue_name}=default + ${jobs}= Get job queue ${queue_name} + ${length}= Get Length ${jobs} + RETURN ${length} + +Get Job Details + [Documentation] Get job details from the queue API by searching the jobs list + [Arguments] ${job_id} + + ${response}= GET On Session api /api/queue/jobs + Should Be Equal As Integers ${response.status_code} 200 + ${jobs_data}= Set Variable ${response.json()} + ${jobs}= Set Variable ${jobs_data}[jobs] + + # Find the job with matching job_id + FOR ${job} IN @{jobs} + IF '${job}[job_id]' == '${job_id}' + RETURN ${job} + END + END + +Check job status + [Documentation] Check the status of a specific job by ID + [Arguments] ${job_id} ${expected_status} + ${job}= Get Job Details ${job_id} + ${actual_status}= Set Variable ${job}[status] + Should Be Equal As Strings ${actual_status} ${expected_status} Job status does not match expected status + RETURN ${job} + + # If we get here, job not found + Fail Job with ID ${job_id} not found in queue +Clear job queue + [Documentation] Clear all jobs from the specified queue + [Arguments] ${queue_name}=default + ${response}= DELETE On Session api /api/queue/jobs + RETURN ${response} + +Clear job by ID + [Documentation] Clear a specific job by ID + [Arguments] ${job_id} + + ${response}= DELETE On Session api /api/queue/jobs/${job_id} + RETURN ${response} + +Enqueue test job + [Documentation] Enqueue a test job into the specified queue + [Arguments] ${queue_name}=default ${job_data}={} + + &{data}= Create Dictionary queue=${queue_name} job_data=${job_data} + ${response}= POST On Session api /api/queue/enqueue json=${data} + RETURN ${response} \ No newline at end of file diff --git a/tests/resources/session_resources.robot b/tests/resources/session_resources.robot new file mode 100644 index 00000000..94ca2d09 --- /dev/null +++ b/tests/resources/session_resources.robot @@ -0,0 +1,66 @@ +*** Settings *** +Documentation API session creation and authentication management keywords +... +... This file contains keywords for API session management and authentication. +... Keywords in this file should handle session creation, authentication workflows, +... token management, and session cleanup. +... +... Examples of keywords that belong here: +... - API session creation and management +... - Authentication workflows +... - Token extraction (when needed for external tools) +... - Session validation and cleanup +... +... Keywords that should NOT be in this file: +... - Verification/assertion keywords (belong in tests) +... - User management operations (belong in user_resources.robot) +... - Docker service management (belong in setup_resources.robot) +Library RequestsLibrary +Library Collections +Variables ../test_env.py + +*** Keywords *** + +# Core Session Creation +Create API Session + [Documentation] Create an API session (authenticated or anonymous) + [Arguments] ${session_name} ${email}=${ADMIN_EMAIL} ${password}=${ADMIN_PASSWORD} ${base_url}=${API_URL} + + # Create base session + Create Session ${session_name} ${base_url} verify=True + + + ${token}= Get Authentication Token ${session_name} ${email} ${password} + &{headers}= Create Dictionary Authorization=Bearer ${token} + # Update session with auth headers + Create Session ${session_name} ${base_url} verify=True headers=${headers} + Set Suite Variable ${session_name} + +Get Anonymous Session + [Documentation] Get an unauthenticated API session + [Arguments] ${session_name} ${base_url}=${API_URL} + + Create Session ${session_name} ${base_url} verify=True + + +# Core Authentication +Get Authentication Token + [Documentation] Get authentication token for any user from existing session + [Arguments] ${session_alias} ${email} ${password} + + &{auth_data}= Create Dictionary username=${email} password=${password} + &{headers}= Create Dictionary Content-Type=application/x-www-form-urlencoded + + ${response}= POST On Session ${session_alias} /auth/jwt/login data=${auth_data} headers=${headers} expected_status=200 + + ${json_response}= Set Variable ${response.json()} + ${token}= Get From Dictionary ${json_response} access_token + RETURN ${token} + + +Get Current User From Session + [Documentation] Get current user information from authenticated session + [Arguments] ${session_alias} + + ${response}= GET On Session ${session_alias} /users/me expected_status=any + RETURN ${response} diff --git a/tests/resources/setup_resources.robot b/tests/resources/setup_resources.robot new file mode 100644 index 00000000..bb7d06fc --- /dev/null +++ b/tests/resources/setup_resources.robot @@ -0,0 +1,107 @@ +*** Settings *** +Documentation Reusable keywords for API testing +Library RequestsLibrary +Library Collections +Library OperatingSystem +Library String +Library Process +Variables ../test_env.py +Resource ../resources/session_resources.robot + + +*** Keywords *** + +Suite Setup + [Documentation] Setup for auth test suite + ${random_id}= Generate Random String 8 [LETTERS][NUMBERS] + Set Suite Variable ${RANDOM_ID} ${random_id} + Start advanced-server + Create API session api + +Suite Teardown + # Stop and remove containers with volumes + Run docker-compose -f backends/advanced/docker-compose-test.yml down -v + # Clean up any remaining volumes + Run rm -rf backends/advanced/data/test_mongo_data + Run rm -rf ${EXECDIR}/backends/advanced/data/test_qdrant_data + Run rm -rf ${EXECDIR}/backends/advanced/data/test_audio_chunks + Delete All Sessions + +Start advanced-server + [Documentation] Start the server using docker-compose + ${is_up}= Run Keyword And Return Status Readiness Check ${API_URL} + IF ${is_up} + Log advanced-server is already running + RETURN + ELSE + Log Starting advanced-server + Run docker-compose -f backends/advanced/docker-compose-test.yml up -d --build + Log Waiting for services to start... + Wait Until Keyword Succeeds 60s 5s Readiness Check ${API_URL} + Log Services are ready + END + +Stop advanced-server + [Documentation] Stop the server using docker-compose + Run docker-compose -f docker-compose.test.yml down + +Start speaker-recognition-service + [Documentation] Start the speaker recognition service using docker-compose + ${is_up}= Run Keyword And Return Status Readiness Check ${SPEAKER_RECOGNITION_URL} + IF ${is_up} + Log speaker-recognition-service is already running + RETURN + ELSE + Log Starting speaker-recognition-service + Run docker-compose -f ../../extras/speaker_recognition/docker-compose.test.yml up -d --build + Log Waiting for speaker recognition service to start... + Wait Until Keyword Succeeds 60s 5s Readiness Check ${SPEAKER_RECOGNITION_URL} + Log Speaker recognition service is ready + END + +Readiness Check + [Documentation] Verify that the readiness endpoint is accessible (faster than /health) + [Tags] health api + [Arguments] ${base_url}=${API_URL} + + ${response}= GET ${base_url}/readiness expected_status=200 + Should Be Equal As Integers ${response.status_code} 200 + RETURN ${True} + +Health Check + [Documentation] Verify that the readiness endpoint is accessible (faster than /health) + [Tags] health api + [Arguments] ${base_url}=${API_URL} + + ${response}= GET ${base_url}/health expected_status=200 + Should Be Equal As Integers ${response.status_code} 200 + RETURN ${True} + +Clear Test Databases + [Documentation] Quickly clear test databases and audio files without restarting containers (preserves admin user) + Log To Console Clearing test databases and audio files... + + # Clear MongoDB collections but preserve admin user + Run docker exec advanced-mongo-test-1 mongo test_db --eval "db.users.deleteMany({'email': {$ne:'${ADMIN_EMAIL}'}})" + Run docker exec advanced-mongo-test-1 mongo test_db --eval "db.conversations.deleteMany({})" + Run docker exec advanced-mongo-test-1 mongo test_db --eval "db.audio_chunks.deleteMany({})" + # Clear admin user's registered_clients array to prevent client_id counter increments + Run docker exec advanced-mongo-test-1 mongo test_db --eval "db.users.updateOne({'email':'${ADMIN_EMAIL}'}, {$set: {'registered_clients': []}})" + Log To Console MongoDB collections cleared (except admin user) + + # Clear Qdrant collections + Run curl -s -X DELETE http://localhost:6337/collections/memories + Run curl -s -X DELETE http://localhost:6337/collections/conversations + Log To Console Qdrant collections cleared + + # Clear audio files from mounted volumes + Run rm -rf ${EXECDIR}/backends/advanced/data/test_audio_chunks/* + Run rm -rf ${EXECDIR}/backends/advanced/data/test_debug_dir/* + # Also clear any files inside the container (in case of different mount paths) + Run docker exec advanced-friend-backend-test-1 find /app/audio_chunks -name "*.wav" -delete 2>/dev/null || true + Run docker exec advanced-friend-backend-test-1 find /app/debug_dir -name "*" -type f -delete 2>/dev/null || true + Log To Console Audio files and debug files cleared + + # Clear Redis queues and job registries + Run docker exec advanced-redis-test-1 redis-cli FLUSHALL + Log To Console Redis queues and job registries cleared \ No newline at end of file diff --git a/tests/resources/timing_keywords.robot b/tests/resources/timing_keywords.robot new file mode 100644 index 00000000..dc75f806 --- /dev/null +++ b/tests/resources/timing_keywords.robot @@ -0,0 +1,190 @@ +*** Settings *** +Documentation Timing and Performance Measurement Keywords +Library DateTime +Library Collections + +*** Variables *** +&{TIMING_DATA} # Global timing storage + +*** Keywords *** +Start Timer + [Documentation] Start a timer for a specific operation + [Arguments] ${operation_name} + + ${start_time}= Get Current Date result_format=epoch + Set To Dictionary ${TIMING_DATA} ${operation_name}_start ${start_time} + + Log Started timer for: ${operation_name} DEBUG + RETURN ${start_time} + +Stop Timer + [Documentation] Stop a timer and return the duration + [Arguments] ${operation_name} + + ${end_time}= Get Current Date result_format=epoch + + # Get start time + ${start_key}= Set Variable ${operation_name}_start + ${start_time}= Get From Dictionary ${TIMING_DATA} ${start_key} + + # Calculate duration + ${duration}= Evaluate ${end_time} - ${start_time} + + # Store results + Set To Dictionary ${TIMING_DATA} ${operation_name}_end ${end_time} + Set To Dictionary ${TIMING_DATA} ${operation_name}_duration ${duration} + + ${duration_formatted}= Evaluate f"{${duration}:.2f}" + Log ${operation_name} completed in ${duration_formatted} seconds INFO + RETURN ${duration} + +Get Timer Duration + [Documentation] Get duration for a completed timer + [Arguments] ${operation_name} + + ${duration_key}= Set Variable ${operation_name}_duration + ${duration}= Get From Dictionary ${TIMING_DATA} ${duration_key} + RETURN ${duration} + +Log Timing Summary + [Documentation] Log summary of all timing measurements + + Log === TIMING SUMMARY === INFO + + ${total_time}= Set Variable 0 + + FOR ${key} IN @{TIMING_DATA.keys()} + ${is_duration}= Evaluate "${key}".endswith("_duration") + IF ${is_duration} + ${operation}= Evaluate "${key}".replace("_duration", "") + ${duration}= Get From Dictionary ${TIMING_DATA} ${key} + ${total_time}= Evaluate ${total_time} + ${duration} + ${duration_formatted}= Evaluate f"{${duration}:.2f}" + Log ${operation}: ${duration_formatted}s INFO + END + END + + ${total_formatted}= Evaluate f"{${total_time}:.2f}" + Log Total measured time: ${total_formatted}s INFO + Log === END TIMING SUMMARY === INFO + +Reset Timers + [Documentation] Clear all timing data + &{empty_dict}= Create Dictionary + Set Global Variable &{TIMING_DATA} &{empty_dict} + Log All timers reset DEBUG + +Time Operation + [Documentation] Time a single operation and return duration + [Arguments] ${operation_name} ${keyword} @{args} + + Start Timer ${operation_name} + ${result}= Run Keyword ${keyword} @{args} + ${duration}= Stop Timer ${operation_name} + + RETURN ${result} ${duration} + +Check Performance Thresholds + [Documentation] Check if operations completed within expected thresholds + [Arguments] &{thresholds} + + ${violations}= Create List + + FOR ${operation} ${threshold} IN &{thresholds} + ${duration_key}= Set Variable ${operation}_duration + ${duration}= Get From Dictionary ${TIMING_DATA} ${duration_key} default=0 + + IF ${duration} > ${threshold} + ${duration_formatted}= Evaluate f"{${duration}:.2f}" + Append To List ${violations} ${operation}: ${duration_formatted}s > ${threshold}s + Log Performance threshold exceeded: ${operation} WARN + ELSE + Log Performance threshold met: ${operation} DEBUG + END + END + + ${violation_count}= Get Length ${violations} + IF ${violation_count} > 0 + ${violation_msg}= Catenate SEPARATOR=\n @{violations} + Log Performance violations found:\n${violation_msg} WARN + RETURN ${False} + ELSE + Log All performance thresholds met INFO + RETURN ${True} + END + +Create Performance Report + [Documentation] Create a detailed performance report + [Arguments] ${report_title}=Performance Report + + ${timestamp}= Get Current Date result_format=%Y-%m-%d %H:%M:%S + + Log === ${report_title} (${timestamp}) === INFO + + # Calculate total test time + ${total_time}= Set Variable 0 + &{operation_times}= Create Dictionary + + FOR ${key} IN @{TIMING_DATA.keys()} + ${is_duration}= Evaluate "${key}".endswith("_duration") + IF ${is_duration} + ${operation}= Evaluate "${key}".replace("_duration", "") + ${duration}= Get From Dictionary ${TIMING_DATA} ${key} + ${total_time}= Evaluate ${total_time} + ${duration} + Set To Dictionary ${operation_times} ${operation} ${duration} + END + END + + # Log operations sorted by duration + ${operations}= Get Dictionary Keys ${operation_times} + ${sorted_ops}= Evaluate sorted($operations, key=lambda x: $operation_times[x], reverse=True) + + Log Operations by duration (slowest first): INFO + FOR ${operation} IN @{sorted_ops} + ${duration}= Get From Dictionary ${operation_times} ${operation} + ${percentage}= Evaluate (${duration} / ${total_time}) * 100 if ${total_time} > 0 else 0 + ${duration_formatted}= Evaluate f"{${duration}:.2f}" + ${percentage_formatted}= Evaluate f"{${percentage}:.1f}" + Log ${operation}: ${duration_formatted}s (${percentage_formatted}%) INFO + END + + ${total_formatted}= Evaluate f"{${total_time}:.2f}" + Log Total execution time: ${total_formatted}s INFO + Log === End ${report_title} === INFO + + RETURN &{operation_times} + +Benchmark Operation + [Documentation] Benchmark an operation multiple times + [Arguments] ${operation_name} ${iterations} ${keyword} @{args} + + ${durations}= Create List + + FOR ${i} IN RANGE ${iterations} + ${iteration_name}= Set Variable ${operation_name}_iteration_${i+1} + Start Timer ${iteration_name} + Run Keyword ${keyword} @{args} + ${duration}= Stop Timer ${iteration_name} + Append To List ${durations} ${duration} + END + + # Calculate statistics + ${total}= Evaluate sum($durations) + ${avg}= Evaluate ${total} / ${iterations} + ${min_val}= Evaluate min($durations) + ${max_val}= Evaluate max($durations) + + Log Benchmark results for ${operation_name} (${iterations} iterations): INFO + ${avg_formatted}= Evaluate f"{${avg}:.3f}" + ${min_formatted}= Evaluate f"{${min_val}:.3f}" + ${max_formatted}= Evaluate f"{${max_val}:.3f}" + Log Average: ${avg_formatted}s, Min: ${min_formatted}s, Max: ${max_formatted}s INFO + + &{stats}= Create Dictionary + ... average=${avg} + ... minimum=${min_val} + ... maximum=${max_val} + ... total=${total} + ... iterations=${iterations} + + RETURN &{stats} \ No newline at end of file diff --git a/tests/resources/transcript_verification.robot b/tests/resources/transcript_verification.robot new file mode 100644 index 00000000..0dbce010 --- /dev/null +++ b/tests/resources/transcript_verification.robot @@ -0,0 +1,209 @@ +*** Settings *** +Documentation Advanced Transcript Verification Keywords +... Includes OpenAI-powered similarity checking similar to the Python integration tests +Library RequestsLibrary +Library Collections +Library String +# Library JSONLibrary # Optional library, not required + +*** Variables *** +${OPENAI_API_BASE} https://api.openai.com/v1 +${OPENAI_MODEL} gpt-4o-mini +${SIMILARITY_THRESHOLD} 0.7 +${EXPECTED_GROUND_TRUTH} experts in glass blowing demonstrating techniques + +*** Keywords *** +Verify Transcript With AI Similarity + [Documentation] Use OpenAI to verify transcript similarity to ground truth + [Arguments] ${transcript} ${ground_truth}=${EXPECTED_GROUND_TRUTH} ${threshold}=${SIMILARITY_THRESHOLD} + + # Get OpenAI API key + ${openai_key}= Get Environment Variable OPENAI_API_KEY ${EMPTY} + Should Not Be Empty ${openai_key} OPENAI_API_KEY required for AI similarity checking + + # Prepare similarity check prompt + ${prompt}= Create Similarity Check Prompt ${transcript} ${ground_truth} + + # Call OpenAI API + ${similarity_score}= Get Transcript Similarity Score ${openai_key} ${prompt} + + # Validate similarity + Should Be True ${similarity_score} >= ${threshold} Transcript similarity ${similarity_score} below threshold ${threshold} + + Log Transcript similarity verification passed: ${similarity_score} INFO + RETURN ${similarity_score} + +Create Similarity Check Prompt + [Documentation] Create prompt for OpenAI similarity checking + [Arguments] ${transcript} ${ground_truth} + + ${prompt}= Catenate SEPARATOR=\n + ... You are evaluating the similarity between a speech-to-text transcript and ground truth content. + ... + ... Ground Truth: "${ground_truth}" + ... Transcript: "${transcript}" + ... + ... Rate the semantic similarity on a scale of 0.0 to 1.0, where: + ... - 1.0 = Perfect semantic match + ... - 0.8+ = Very similar meaning, minor differences + ... - 0.6+ = Generally similar topics and concepts + ... - 0.4+ = Some related content + ... - 0.0 = Completely unrelated + ... + ... Focus on meaning and content, not exact word matching. + ... Consider that speech-to-text may have minor transcription errors. + ... + ... Respond with just the numerical score (e.g., "0.85"). + + RETURN ${prompt} + +Get Transcript Similarity Score + [Documentation] Call OpenAI API to get similarity score + [Arguments] ${api_key} ${prompt} + + # Prepare request + Create Session openai ${OPENAI_API_BASE} + &{headers}= Create Dictionary + ... Authorization=Bearer ${api_key} + ... Content-Type=application/json + + &{request_data}= Create Dictionary + ... model=${OPENAI_MODEL} + ... messages=${[{"role": "user", "content": "${prompt}"}]} + ... max_tokens=10 + ... temperature=0.1 + + # Make API call + ${response}= POST On Session openai /chat/completions headers=${headers} json=${request_data} expected_status=200 + + # Parse response + ${response_data}= Set Variable ${response.json()} + ${content}= Set Variable ${response_data}[choices][0][message][content] + ${score_text}= Strip String ${content} + + # Convert to float + TRY + ${similarity_score}= Convert To Number ${score_text} + Delete All Sessions openai + RETURN ${similarity_score} + EXCEPT + Delete All Sessions openai + Fail Invalid similarity score from OpenAI: ${score_text} + END + +Verify Transcript Quality Metrics + [Documentation] Verify various transcript quality metrics + [Arguments] ${conversation} ${expected_keywords} ${min_length}=100 + + Dictionary Should Contain Key ${conversation} transcript + ${transcript}= Set Variable ${conversation}[transcript] + + # Basic quality checks + Should Not Be Empty ${transcript} + ${length}= Get Length ${transcript} + Should Be True ${length} >= ${min_length} Transcript too short: ${length} chars + + # Check for expected keywords + ${transcript_lower}= Convert To Lower Case ${transcript} + FOR ${keyword} IN @{expected_keywords} + ${keyword_lower}= Convert To Lower Case ${keyword} + Should Contain ${transcript_lower} ${keyword_lower} Missing keyword: ${keyword} + END + + # Segment validation + Dictionary Should Contain Key ${conversation} segments + ${segments}= Set Variable ${conversation}[segments] + ${segment_count}= Get Length ${segments} + Should Be True ${segment_count} > 0 No segments found + + # Validate segment structure + FOR ${segment} IN @{segments} + Dictionary Should Contain Key ${segment} start + Dictionary Should Contain Key ${segment} end + Dictionary Should Contain Key ${segment} text + Should Be True ${segment}[end] > ${segment}[start] Invalid segment timing + END + + # Quality heuristics + ${word_count}= Get Word Count ${transcript} + Should Be True ${word_count} >= 20 Too few words: ${word_count} + + # Check for common transcription errors/patterns + ${error_patterns}= Create List [inaudible] [unclear] *** ERROR FAILED + FOR ${pattern} IN @{error_patterns} + Should Not Contain ${transcript_lower} ${pattern} Transcript contains error pattern: ${pattern} + END + + Log Transcript quality metrics passed: ${length} chars, ${word_count} words, ${segment_count} segments INFO + +Get Word Count + [Documentation] Count words in text + [Arguments] ${text} + + ${words}= Split String ${text} + ${count}= Get Length ${words} + RETURN ${count} + +Calculate Transcript Statistics + [Documentation] Calculate detailed transcript statistics + [Arguments] ${conversation} + + ${transcript}= Set Variable ${conversation}[transcript] + ${segments}= Set Variable ${conversation}[segments] + + # Basic statistics + ${char_count}= Get Length ${transcript} + ${word_count}= Get Word Count ${transcript} + ${segment_count}= Get Length ${segments} + + # Timing statistics + ${total_duration}= Calculate Total Duration ${segments} + ${speech_rate}= Evaluate ${word_count} / (${total_duration} / 60) if ${total_duration} > 0 else 0 + + # Create statistics dictionary + &{stats}= Create Dictionary + ... character_count=${char_count} + ... word_count=${word_count} + ... segment_count=${segment_count} + ... total_duration_seconds=${total_duration} + ... words_per_minute=${speech_rate} + + Log Transcript statistics: ${stats} INFO + RETURN &{stats} + +Calculate Total Duration + [Documentation] Calculate total duration from segments + [Arguments] ${segments} + + ${total}= Set Variable 0 + FOR ${segment} IN @{segments} + ${duration}= Evaluate ${segment}[end] - ${segment}[start] + ${total}= Evaluate ${total} + ${duration} + END + RETURN ${total} + +Verify Segment Speaker Diarization + [Documentation] Verify speaker diarization in segments + [Arguments] ${segments} ${expect_multiple_speakers}=${False} + + ${speaker_ids}= Create List + FOR ${segment} IN @{segments} + IF 'speaker' in ${segment} + ${speaker_id}= Set Variable ${segment}[speaker] + ${contains}= Evaluate $speaker_id in $speaker_ids + IF not ${contains} + Append To List ${speaker_ids} ${speaker_id} + END + END + END + + ${speaker_count}= Get Length ${speaker_ids} + + IF ${expect_multiple_speakers} + Should Be True ${speaker_count} > 1 Expected multiple speakers, found ${speaker_count} + ELSE + Should Be True ${speaker_count} >= 1 No speakers identified + END + + Log Speaker diarization: ${speaker_count} unique speakers found INFO + RETURN ${speaker_count} \ No newline at end of file diff --git a/tests/resources/user_resources.robot b/tests/resources/user_resources.robot new file mode 100644 index 00000000..1980d1a0 --- /dev/null +++ b/tests/resources/user_resources.robot @@ -0,0 +1,155 @@ +*** Settings *** +Documentation User account management and lifecycle keywords +... +... This file contains keywords for user account creation, deletion, and management. +... Keywords in this file should handle user-related operations, user account lifecycle, +... and user permission management. +... +... Examples of keywords that belong here: +... - User account creation and deletion +... - User management operations +... - User permission validation +... - User account lifecycle operations +... +... Keywords that should NOT be in this file: +... - Verification/assertion keywords (belong in tests) +... - API session management (belong in session_resources.robot) +... - Docker service management (belong in setup_resources.robot) +Library RequestsLibrary +Library Collections +Library String +Variables ../test_env.py +Resource session_resources.robot + +*** Keywords *** + +Create Test User + [Documentation] Create a test user for testing (uses admin session) + [Arguments] ${session}=${EMPTY} ${email}=${TEST_USER_EMAIL} ${password}=${TEST_USER_PASSWORD} ${is_superuser}=False + + # Create user + IF '${session}' == '${EMPTY}' + Create API Session admin_session + ${session}= Set Variable admin_session + END + + &{user_data}= Create Dictionary email=${email} password=${password} is_superuser=${is_superuser} + ${response}= POST On Session ${session} /api/users json=${user_data} expected_status=201 + + ${user}= Set Variable ${response.json()} + RETURN ${user} + +Create Random Test User + [Documentation] Create a test user with random email + [Arguments] ${session} ${password}=test-password-123 ${is_superuser}=False + + ${random_id}= Generate Random String 8 [LETTERS][NUMBERS] + ${email}= Set Variable test-user-${random_id}@example.com + + ${user}= Create Test User ${session} ${email} ${password} ${is_superuser} + RETURN ${user} + +Delete Test User + [Documentation] Delete a test user (uses admin session) + [Arguments] ${session} ${user_id} + + # Delete user + ${response}= DELETE On Session ${session} /api/users/${user_id} expected_status=200 + RETURN ${response.json()} + +Get User Details + [Documentation] Get user details by ID + [Arguments] ${user_id} + + # Get admin session + Create API Session admin_session + + # Get user + ${response}= GET On Session admin_session /api/users/${user_id} expected_status=200 + RETURN ${response.json()} + +Get Current User + [Documentation] Get current authenticated user details + [Arguments] ${session_alias} + + ${response}= GET On Session ${session_alias} /users/me expected_status=200 + RETURN ${response.json()} + +Get Admin User Details + [Documentation] Get current admin user details (session-based) + [Arguments] ${session_alias} + + ${user}= Get Current User ${session_alias} + RETURN ${user} + +Get Admin User Details With Token + [Documentation] Get current admin user details using token (legacy compatibility) + [Arguments] ${token} + + Create Session temp_user_session ${API_URL} verify=True + &{headers}= Create Dictionary Authorization=Bearer ${token} + ${response}= GET On Session temp_user_session /users/me headers=${headers} expected_status=200 + ${user}= Set Variable ${response.json()} + Delete All Sessions + RETURN ${user} + +List All Users + [Documentation] List all users (admin only) + + # Get admin session + ${admin_session}= Get Admin Session + + # Get users + ${response}= GET On Session ${admin_session} /api/users expected_status=200 + RETURN ${response.json()} + +Update User + [Documentation] Update user details + [Arguments] ${user_id} &{updates} + + # Get admin session + ${admin_session}= Get Admin Session + + # Update user + ${response}= PUT On Session ${admin_session} /api/users/${user_id} json=${updates} expected_status=200 + RETURN ${response.json()} + +Attempt User Login + [Documentation] Attempt to log in with user credentials + [Arguments] ${email} ${password} + + ${session}= Get User Session ${email} ${password} + ${user}= Get Current User ${session} + RETURN ${user} + +Attempt User Login With Invalid Credentials + [Documentation] Attempt login with invalid credentials and return response + [Arguments] ${email} ${password} + + Create Session temp ${API_URL} verify=True + &{auth_data}= Create Dictionary username=${email} password=${password} + &{headers}= Create Dictionary Content-Type=application/x-www-form-urlencoded + + ${response}= POST On Session temp /auth/jwt/login data=${auth_data} expected_status=any + Delete All Sessions + RETURN ${response} + +Cleanup Test User + [Documentation] Create and cleanup a test user (for use in test teardown) + [Arguments] ${user_email} + + TRY + # Try to find and delete the user + ${users}= List All Users + FOR ${user} IN @{users} + IF "${user}[email]" == "${user_email}" + Delete Test User ${user}[user_id] + Log Deleted test user: ${user_email} INFO + RETURN + END + END + Log Test user not found: ${user_email} INFO + EXCEPT AS ${error} + Log Failed to cleanup test user ${user_email}: ${error} WARN + END + diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 00000000..c2bb356e --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,35 @@ +""" +Test data for Robot Framework tests +""" + + +# Test Data +SAMPLE_CONVERSATIONS = [ + { + "id": "conv_001", + "transcript": "This is a test conversation about AI development.", + "created_at": "2025-01-15T10:00:00Z" + }, + { + "id": "conv_002", + "transcript": "Another test conversation discussing machine learning.", + "created_at": "2025-01-15T11:00:00Z" + } +] + +SAMPLE_MEMORIES = [ + { + "text": "User prefers AI discussions in the morning", + "importance": 0.8 + }, + { + "text": "User is interested in machine learning applications", + "importance": 0.7 + } +] + +TEST_AUDIO_FILE = "tests/test_assets/DIY_Experts_Glass_Blowing_16khz_mono_1min.wav" +TEST_DEVICE_NAME = "Robot-test-device" + +# Expected content for transcript quality verification +EXPECTED_TRANSCRIPT = "glass blowing" diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 00000000..1f41be7b --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,57 @@ +# Test Environment Configuration +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load .env.test from the tests directory +test_env_path = Path(__file__).parent / ".env.test" +load_dotenv(test_env_path) + +# API Configuration +API_URL = 'http://localhost:8001' # Use BACKEND_URL from test.env +API_BASE = 'http://localhost:8001/api' + +WEB_URL = os.getenv('FRONTEND_URL', 'http://localhost:3001') # Use FRONTEND_URL from test.env +# Admin user credentials (Robot Framework format) +ADMIN_USER = { + "email": os.getenv('ADMIN_EMAIL', 'test-admin@example.com'), + "password": os.getenv('ADMIN_PASSWORD', 'test-admin-password-123') +} + +# Individual variables for Robot Framework +ADMIN_EMAIL = os.getenv('ADMIN_EMAIL', 'test-admin@example.com') +ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'test-admin-password-123') + +TEST_USER = { + "email": "test@example.com", + "password": "test-password" +} + +# Individual variables for Robot Framework +TEST_USER_EMAIL = "test@example.com" +TEST_USER_PASSWORD = "test-password" + + + +# API Endpoints +ENDPOINTS = { + "health": "/health", + "readiness": "/readiness", + "auth": "/auth/jwt/login", + "conversations": "/api/conversations", + "memories": "/api/memories", + "memory_search": "/api/memories/search", + "users": "/api/users" +} + +# API Keys (loaded from test.env) +OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') +DEEPGRAM_API_KEY = os.getenv('DEEPGRAM_API_KEY') +HF_TOKEN = os.getenv('HF_TOKEN') + +# Test Configuration +TEST_CONFIG = { + "retry_count": 3, + "retry_delay": 1, + "default_timeout": 30 +} \ No newline at end of file