diff --git a/README.md b/README.md index d3ce3ee..7b8fe5c 100644 --- a/README.md +++ b/README.md @@ -224,13 +224,20 @@ The project consists of the following components: - `config.json`: Service configuration file - `app/`: Main application module - `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization - - `utils.py`: Logging configuration - - `transcriber.py`: Contains the `WhisperTranscriber` class for speech recognition - - `audio_processor.py`: Contains the `AudioProcessor` class for audio preprocessing - - `audio_sources.py`: Contains different audio source handlers (upload, URL, base64, local) - - `transcriber_service.py`: Manages the transcription workflow - - `history_logger.py`: Handles saving transcription history - - `routes.py`: Contains the API route definitions + - `routes.py`: API route definitions + - `history.py`: Saving transcription history + - `core/`: Core logic + - `transcriber.py`: `WhisperTranscriber` class for speech recognition + - `transcription_service.py`: Manages the transcription workflow + - `audio/`: Audio processing + - `processor.py`: `AudioProcessor` class for audio preprocessing + - `sources.py`: Audio source handlers (upload, URL, base64) + - `utils.py`: Audio utilities (loading, duration) + - `infrastructure/`: Supporting modules + - `log.py`: Logging configuration + - `validation.py`: File validation + - `storage.py`: Temp file management + - `async_tasks.py`: Async task manager - `static/`: Web interface files ## Advanced usage diff --git a/app/audio/processor.py b/app/audio/processor.py index 6da6ef8..cf1df3f 100644 --- a/app/audio/processor.py +++ b/app/audio/processor.py @@ -77,7 +77,7 @@ class AudioProcessor: output_path ] - logger.info(f"Конвертация в WAV: {' '.join(cmd)}") + logger.debug(f"Конвертация в WAV: {' '.join(cmd)}") try: subprocess.run(cmd, check=True, capture_output=True) diff --git a/app/audio/sources.py b/app/audio/sources.py index 2941965..a39adc0 100644 --- a/app/audio/sources.py +++ b/app/audio/sources.py @@ -1,12 +1,14 @@ """ Модуль sources.py содержит функции для получения аудиофайлов -из различных источников (загруженные файлы, URL, base64, локальные файлы). +из различных источников (загруженные файлы, URL, base64). """ import os import uuid import tempfile import base64 +from urllib.parse import urlparse +import magic import requests from typing import Tuple, Optional import logging @@ -66,7 +68,11 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке). """ try: - response = requests.get(url, stream=True) + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https'): + return None, None, f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed" + + response = requests.get(url, stream=True, timeout=30) response.raise_for_status() # Проверка размера по Content-Length @@ -76,12 +82,22 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], if error: return None, None, error + # Извлекаем имя файла из Content-Disposition или URL + original_name = None + cd = response.headers.get('Content-Disposition', '') + if 'filename=' in cd: + original_name = cd.split('filename=')[-1].strip('" ') + if not original_name: + url_path = parsed.path.rstrip('/') + if url_path: + original_name = os.path.basename(url_path) + temp_path = _make_temp_path() with open(temp_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) - return temp_path, os.path.basename(temp_path), None + return temp_path, original_name or os.path.basename(temp_path), None except Exception as e: logger.error(f"Ошибка при получении файла по URL {url}: {e}") @@ -102,7 +118,20 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti if error: return None, None, error - temp_path = _make_temp_path() + # Определяем формат по содержимому + mime_to_ext = { + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/flac": ".flac", + "audio/mp4": ".m4a", + "audio/x-m4a": ".m4a", + "audio/aac": ".aac", + "audio/webm": ".webm", + } + detected_mime = magic.from_buffer(audio_data[:1024], mime=True) + suffix = mime_to_ext.get(detected_mime, ".wav") + + temp_path = _make_temp_path(suffix) with open(temp_path, 'wb') as f: f.write(audio_data) @@ -111,26 +140,3 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti except Exception as e: logger.error(f"Ошибка при декодировании base64 данных: {e}") return None, None, f"Error decoding base64 data: {str(e)}" - - -def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """ - Получает локальный аудиофайл. - - Returns: - Кортеж (путь к файлу, имя файла, сообщение об ошибке). - Примечание: возвращает оригинальный путь, не копирует файл. - """ - if not os.path.exists(file_path): - return None, None, f"File not found: {file_path}" - - try: - error = _check_size(os.path.getsize(file_path), max_file_size_mb) - if error: - return None, None, error - - return file_path, os.path.basename(file_path), None - - except Exception as e: - logger.error(f"Ошибка при открытии локального файла {file_path}: {e}") - return None, None, f"Error opening local file: {str(e)}" diff --git a/app/audio/utils.py b/app/audio/utils.py index 94f781b..729df9b 100644 --- a/app/audio/utils.py +++ b/app/audio/utils.py @@ -6,6 +6,7 @@ import os import subprocess import wave import numpy as np +from scipy.signal import resample as scipy_resample import logging from typing import Tuple @@ -33,9 +34,8 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]: sampling_rate = wav_file.getframerate() if sampling_rate != sr: - from scipy.signal import resample num_samples = int(len(audio_array) * sr / sampling_rate) - audio_array = resample(audio_array, num_samples) + audio_array = scipy_resample(audio_array, num_samples) sampling_rate = sr return audio_array, sampling_rate diff --git a/app/core/transcriber.py b/app/core/transcriber.py index 9713673..131255a 100644 --- a/app/core/transcriber.py +++ b/app/core/transcriber.py @@ -117,32 +117,25 @@ class WhisperTranscriber: """ logger.info(f"Загрузка модели из {self.model_path}") + model_kwargs = dict( + torch_dtype=self.torch_dtype, + low_cpu_mem_usage=True, + use_safetensors=True, + ) + try: - # Проверка возможности использования Flash Attention 2 if self.device.type == "cuda": - self.model = WhisperForConditionalGeneration.from_pretrained( - self.model_path, - torch_dtype=self.torch_dtype, - low_cpu_mem_usage=True, - use_safetensors=True, - attn_implementation="flash_attention_2" - ).to(self.device) + model_kwargs["attn_implementation"] = "flash_attention_2" + self.model = WhisperForConditionalGeneration.from_pretrained( + self.model_path, **model_kwargs + ).to(self.device) + if self.device.type == "cuda": logger.info("Используется Flash Attention 2") - else: - self.model = WhisperForConditionalGeneration.from_pretrained( - self.model_path, - torch_dtype=self.torch_dtype, - low_cpu_mem_usage=True, - use_safetensors=True - ).to(self.device) except Exception as e: logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}") - # Fallback к обычной версии + model_kwargs.pop("attn_implementation", None) self.model = WhisperForConditionalGeneration.from_pretrained( - self.model_path, - torch_dtype=self.torch_dtype, - low_cpu_mem_usage=True, - use_safetensors=True + self.model_path, **model_kwargs ).to(self.device) self.processor = WhisperProcessor.from_pretrained(self.model_path) @@ -161,37 +154,41 @@ class WhisperTranscriber: logger.info("Модель успешно загружена и готова к использованию") - def transcribe(self, audio_path: str) -> Union[str, Dict]: + def transcribe(self, audio_path: str, return_timestamps: bool = None) -> Union[str, Dict]: """ Транскрибация аудиофайла. - + Args: audio_path: Путь к обработанному аудиофайлу. + return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига. Returns: В зависимости от параметра return_timestamps: - Если return_timestamps=False: строка с распознанным текстом - Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст) """ + if return_timestamps is None: + return_timestamps = self.return_timestamps + logger.info(f"Начало транскрибации файла: {audio_path}") - + try: # Загрузка аудио в формате numpy array audio_array, sampling_rate = load_audio(audio_path, sr=16000) - + # Транскрибация с корректным форматом данных result = self.asr_pipeline( - {"raw": audio_array, "sampling_rate": sampling_rate}, + {"raw": audio_array, "sampling_rate": sampling_rate}, generate_kwargs={ - "language": self.language, - "max_new_tokens": self.max_new_tokens, + "language": self.language, + "max_new_tokens": self.max_new_tokens, "temperature": self.temperature }, - return_timestamps=self.return_timestamps + return_timestamps=return_timestamps ) # Если временные метки не запрошены, возвращаем только текст - if not self.return_timestamps: + if not return_timestamps: transcribed_text = result.get("text", "") logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста") return transcribed_text @@ -241,13 +238,14 @@ class WhisperTranscriber: logger.error(f"Traceback: {traceback.format_exc()}") raise - def process_file(self, input_path: str) -> Union[str, Dict]: + def process_file(self, input_path: str, return_timestamps: bool = None) -> Union[str, Dict]: """ Полный процесс обработки и транскрибации аудиофайла. - + Args: input_path: Путь к исходному аудиофайлу. - + return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига. + Returns: В зависимости от параметра return_timestamps: - Если return_timestamps=False: строка с распознанным текстом @@ -263,7 +261,7 @@ class WhisperTranscriber: processed_path, temp_files = self.audio_processor.process_audio(input_path) # Транскрибация - result = self.transcribe(processed_path) + result = self.transcribe(processed_path, return_timestamps=return_timestamps) elapsed_time = time.time() - start_time logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд") diff --git a/app/core/transcription_service.py b/app/core/transcription_service.py index 34b8d76..d8b5575 100644 --- a/app/core/transcription_service.py +++ b/app/core/transcription_service.py @@ -3,6 +3,7 @@ который отвечает за транскрибацию аудиофайлов. """ +import json import os import time import traceback @@ -36,7 +37,7 @@ class TranscriptionService: """ params = params or {} language = params.get('language', self.config.get('language', 'en')) - temperature = float(params.get('temperature', 0.0)) + temperature = max(0.0, min(1.0, float(params.get('temperature', 0.0)))) prompt = params.get('prompt', '') # Проверяем, запрошены ли временные метки @@ -44,10 +45,6 @@ class TranscriptionService: if isinstance(return_timestamps, str): return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1') - # Временно изменяем настройку return_timestamps в транскрайбере - original_return_timestamps = self.transcriber.return_timestamps - self.transcriber.return_timestamps = return_timestamps - try: # Определяем длительность аудиофайла try: @@ -57,7 +54,7 @@ class TranscriptionService: return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500 start_time = time.time() - result = self.transcriber.process_file(file_path) + result = self.transcriber.process_file(file_path, return_timestamps=return_timestamps) processing_time = time.time() - start_time # Формируем ответ @@ -66,7 +63,7 @@ class TranscriptionService: "segments": result.get("segments", []), "text": result.get("text", ""), "processing_time": processing_time, - "response_size_bytes": len(str(result).encode('utf-8')), + "response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')), "duration_seconds": duration, "model": os.path.basename(self.config["model_path"]) } @@ -74,7 +71,7 @@ class TranscriptionService: response = { "text": result, "processing_time": processing_time, - "response_size_bytes": len(str(result).encode('utf-8')), + "response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')), "duration_seconds": duration, "model": os.path.basename(self.config["model_path"]) } @@ -86,6 +83,3 @@ class TranscriptionService: logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}") logger.error(f"Traceback: {traceback.format_exc()}") return {"error": str(e)}, 500 - - finally: - self.transcriber.return_timestamps = original_return_timestamps diff --git a/app/history.py b/app/history.py index 2a933c2..406ce1f 100644 --- a/app/history.py +++ b/app/history.py @@ -1,5 +1,5 @@ """ -Модуль history_logger.py — сохранение истории транскрибации в JSON-файлы. +Модуль history.py — сохранение истории транскрибации в JSON-файлы. """ import os @@ -13,7 +13,7 @@ import logging logger = logging.getLogger('app.history') # Корневая директория истории (относительно корня проекта) -_history_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history") +_history_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), "history") def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]: diff --git a/app/infrastructure/async_tasks.py b/app/infrastructure/async_tasks.py index 566438e..645e8a9 100644 --- a/app/infrastructure/async_tasks.py +++ b/app/infrastructure/async_tasks.py @@ -1,11 +1,11 @@ """ -Модуль manager.py содержит функции для асинхронной обработки задач. +Модуль async_tasks.py содержит функции для асинхронной обработки задач. """ import uuid import time from typing import Dict, Any, Callable, Optional -from threading import Thread +from threading import Thread, Lock import logging logger = logging.getLogger('app.async_tasks') @@ -19,10 +19,13 @@ class AsyncTaskManager: tasks (Dict): Словарь для хранения информации о задачах. """ + TASK_TTL = 3600 # 1 час + def __init__(self): """ Инициализация менеджера асинхронных задач. """ + self._lock = Lock() self.tasks: Dict[str, Dict[str, Any]] = {} def run_task(self, func: Callable, *args, **kwargs) -> str: @@ -38,23 +41,33 @@ class AsyncTaskManager: ID задачи. """ task_id = str(uuid.uuid4()) - - # Создание информации о задаче - self.tasks[task_id] = { - "status": "pending", - "result": None, - "error": None, - "created_at": time.time(), - "started_at": None, - "completed_at": None - } - - # Создание и запуск потока + + with self._lock: + self._cleanup_old_tasks() + self.tasks[task_id] = { + "status": "pending", + "result": None, + "error": None, + "created_at": time.time(), + "started_at": None, + "completed_at": None + } + thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs)) thread.daemon = True thread.start() - + return task_id + + def _cleanup_old_tasks(self) -> None: + """Удаляет завершённые задачи старше TASK_TTL. Вызывать под self._lock.""" + now = time.time() + expired = [ + tid for tid, info in self.tasks.items() + if info["completed_at"] and now - info["completed_at"] > self.TASK_TTL + ] + for tid in expired: + del self.tasks[tid] def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None: """ @@ -66,26 +79,25 @@ class AsyncTaskManager: args: Позиционные аргументы для функции. kwargs: Именованные аргументы для функции. """ - try: - # Обновление статуса задачи + with self._lock: self.tasks[task_id]["status"] = "running" self.tasks[task_id]["started_at"] = time.time() - - # Выполнение функции + + try: result = func(*args, **kwargs) - - # Сохранение результата - self.tasks[task_id]["status"] = "completed" - self.tasks[task_id]["result"] = result - self.tasks[task_id]["completed_at"] = time.time() - + + with self._lock: + self.tasks[task_id]["status"] = "completed" + self.tasks[task_id]["result"] = result + self.tasks[task_id]["completed_at"] = time.time() + logger.info(f"Задача {task_id} завершена успешно") except Exception as e: - # Обработка ошибки - self.tasks[task_id]["status"] = "failed" - self.tasks[task_id]["error"] = str(e) - self.tasks[task_id]["completed_at"] = time.time() - + with self._lock: + self.tasks[task_id]["status"] = "failed" + self.tasks[task_id]["error"] = str(e) + self.tasks[task_id]["completed_at"] = time.time() + logger.error(f"Задача {task_id} завершилась с ошибкой: {e}") def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]: @@ -98,7 +110,9 @@ class AsyncTaskManager: Returns: Информация о задаче или None, если задача не найдена. """ - return self.tasks.get(task_id) + with self._lock: + task = self.tasks.get(task_id) + return dict(task) if task else None # Глобальный экземпляр менеджера асинхронных задач diff --git a/app/infrastructure/validation.py b/app/infrastructure/validation.py index 761bae8..13e1453 100644 --- a/app/infrastructure/validation.py +++ b/app/infrastructure/validation.py @@ -1,10 +1,10 @@ """ -Модуль validators.py содержит классы и функции для валидации входных данных. +Модуль validation.py содержит классы и функции для валидации входных данных. """ import os import magic -from typing import Dict, List, BinaryIO, Optional +from typing import Dict, List, BinaryIO import logging # Получаем логгер из централизованной настройки @@ -186,35 +186,3 @@ class FileValidator: return True - @staticmethod - def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str: - """ - Валидирует путь к локальному файлу для предотвращения атак обхода пути. - - Args: - file_path: Путь к файлу. - allowed_directories: Список разрешенных директорий. - - Returns: - Нормализованный и проверенный путь к файлу. - - Raises: - ValidationError: Если путь к файлу небезопасен. - """ - # Нормализация пути - normalized_path = os.path.normpath(file_path) - - # Если указаны разрешенные директории, проверяем, что путь находится в одной из них - if allowed_directories: - for allowed_dir in allowed_directories: - full_allowed_path = os.path.abspath(allowed_dir) - full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path)) - - if full_file_path.startswith(full_allowed_path): - return full_file_path - - logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}") - raise ValidationError("Путь к файлу не находится в разрешенных директориях") - - # Если разрешенные директории не указаны, просто возвращаем нормализованный путь - return normalized_path \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index 38d098e..13662ab 100644 --- a/app/routes.py +++ b/app/routes.py @@ -9,8 +9,9 @@ from typing import Dict import logging from .core.transcription_service import TranscriptionService -from .audio.sources import get_uploaded_file, get_url_file, get_base64_file, get_local_file +from .audio.sources import get_uploaded_file, get_url_file, get_base64_file from .infrastructure.validation import ValidationError +from .infrastructure.storage import cleanup_temp_files from .infrastructure.async_tasks import transcribe_audio_async, task_manager logger = logging.getLogger('app.routes') @@ -43,33 +44,6 @@ class Routes: """Эндпоинт для получения конфигурации сервиса.""" return jsonify(self.config), 200 - @self.app.route('/local/transcriptions', methods=['POST']) - def local_transcribe(): - """Эндпоинт для локальной транскрибации файла по пути на сервере.""" - data = request.json - - if not data or "file_path" not in data: - return jsonify({"error": "No file_path provided"}), 400 - - file_path = data["file_path"] - - try: - validated_path = self.file_validator.validate_local_file_path( - file_path, - allowed_directories=self.config.get("allowed_directories", []) - ) - except ValidationError as e: - client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown')) - logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}") - return jsonify({"error": str(e)}), 400 - - temp_path, filename, error = get_local_file(validated_path, self._max_size) - if error: - return jsonify({"error": error}), 400 - - response, status_code = self.transcription_service.transcribe(temp_path, filename, data) - return jsonify(response), status_code - @self.app.route('/v1/models', methods=['GET']) def list_models(): """Эндпоинт для получения списка доступных моделей.""" @@ -105,15 +79,15 @@ class Routes: if error: return jsonify({"error": error}), 400 - # Валидация файла try: self.file_validator.validate_file_by_path(temp_path, filename) + response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form)) + return jsonify(response), status_code except ValidationError as e: logger.warning(f"Ошибка валидации файла '{filename}': {e}") return jsonify({"error": str(e)}), 400 - - response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form)) - return jsonify(response), status_code + finally: + cleanup_temp_files([temp_path]) @self.app.route('/v1/audio/transcriptions/url', methods=['POST']) def transcribe_from_url(): @@ -135,12 +109,13 @@ class Routes: try: self.file_validator.validate_file_by_path(temp_path, filename) + response, status_code = self.transcription_service.transcribe(temp_path, filename, params) + return jsonify(response), status_code except ValidationError as e: logger.warning(f"Ошибка валидации файла '{filename}': {e}") return jsonify({"error": str(e)}), 400 - - response, status_code = self.transcription_service.transcribe(temp_path, filename, params) - return jsonify(response), status_code + finally: + cleanup_temp_files([temp_path]) @self.app.route('/v1/audio/transcriptions/base64', methods=['POST']) def transcribe_from_base64(): @@ -162,12 +137,13 @@ class Routes: try: self.file_validator.validate_file_by_path(temp_path, filename) + response, status_code = self.transcription_service.transcribe(temp_path, filename, params) + return jsonify(response), status_code except ValidationError as e: logger.warning(f"Ошибка валидации файла '{filename}': {e}") return jsonify({"error": str(e)}), 400 - - response, status_code = self.transcription_service.transcribe(temp_path, filename, params) - return jsonify(response), status_code + finally: + cleanup_temp_files([temp_path]) @self.app.route('/v1/audio/transcriptions/async', methods=['POST']) def transcribe_async(): @@ -179,8 +155,10 @@ class Routes: try: self.file_validator.validate_file_by_path(temp_path, filename) except ValidationError as e: + cleanup_temp_files([temp_path]) return jsonify({"error": str(e)}), 400 + # Не чистим temp_path здесь — async task отвечает за cleanup task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber) return jsonify({"task_id": task_id}), 202 diff --git a/config.json b/config.json index 704e56b..3f7ef76 100644 --- a/config.json +++ b/config.json @@ -8,7 +8,7 @@ "max_new_tokens": 384, "temperature": 0.01, "return_timestamps": false, - "audio_rate": 8000, + "audio_rate": 16000, "norm_level": "-0.55", "compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15", "device_id": 0, @@ -17,7 +17,6 @@ "allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"], "allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"] }, - "allowed_directories": [], "log_level": "INFO", "log_file": "logs/whisper_api.log", "request_logging": { diff --git a/requirements.txt b/requirements.txt index d62581b..cbb6889 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # Conda environment dependencies -# Tourch +# Torch torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13' torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12' torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'