diff --git a/docs/v3/documentation/features/advanced/file-uploads.mdx b/docs/v3/documentation/features/advanced/file-uploads.mdx index 5f55aaf5..4937c892 100644 --- a/docs/v3/documentation/features/advanced/file-uploads.mdx +++ b/docs/v3/documentation/features/advanced/file-uploads.mdx @@ -103,7 +103,7 @@ Our approach involves... **JSON Files**: Structured data is converted to string format. -**Audio Files**: `.mp3` and `.wav` uploads are transcribed by OpenAI during upload using `whisper-1` by default. Large audio files may be split into multiple audio segments before transcription and then merged back into ordered transcript text before message chunking. +**Audio Files**: `.mp3` and `.wav` uploads are transcribed directly by OpenAI during upload using `whisper-1` by default. The resulting transcript is then split into normal message-sized text chunks before messages are created. ### Chunking Strategy diff --git a/src/utils/files.py b/src/utils/files.py index 2f66761f..4e959e82 100644 --- a/src/utils/files.py +++ b/src/utils/files.py @@ -238,16 +238,14 @@ async def is_validated_audio_upload(file: UploadFile) -> bool: content_type = processor.normalize_content_type(filename, file.content_type or "") suffix = processor.get_output_suffix(filename, content_type) - - with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp_file: - temp_path = Path(temp_file.name) - try: - while chunk := await file.read(UPLOAD_VALIDATION_CHUNK_BYTES): - temp_file.write(chunk) - finally: - await file.seek(0) + temp_path: Path | None = None try: + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp_file: + temp_path = Path(temp_file.name) + while chunk := await file.read(UPLOAD_VALIDATION_CHUNK_BYTES): + temp_file.write(chunk) + await asyncio.to_thread( processor.probe_audio_duration_seconds_from_path, temp_path, @@ -258,7 +256,9 @@ async def is_validated_audio_upload(file: UploadFile) -> bool: return False raise finally: - temp_path.unlink(missing_ok=True) + await file.seek(0) + if temp_path is not None: + temp_path.unlink(missing_ok=True) class FileProcessingService: diff --git a/tests/routes/test_files.py b/tests/routes/test_files.py index fb44e222..b666d08c 100644 --- a/tests/routes/test_files.py +++ b/tests/routes/test_files.py @@ -16,6 +16,7 @@ from starlette.datastructures import Headers from src import models, schemas from src.config import settings +from src.exceptions import ValidationException from src.models import Peer, Workspace from src.routers.messages import create_messages_with_file from src.utils.files import FileExtractionResult @@ -946,7 +947,13 @@ async def test_malformed_audio_upload_returns_validation_error( form_data = {"peer_id": test_peer.name} url = _get_upload_url(test_workspace.name, session_name) - with patch.dict("src.utils.files.CLIENTS", {"openai": object()}, clear=False): + with ( + patch.dict("src.utils.files.CLIENTS", {"openai": object()}, clear=False), + patch( + "src.utils.files.AudioProcessor._probe_audio_duration_seconds", + side_effect=ValidationException("Uploaded audio is invalid or unreadable"), + ), + ): response = client.post(url, files=files, data=form_data) assert response.status_code == 422 diff --git a/tests/utils/test_audio_processing.py b/tests/utils/test_audio_processing.py index f951ed14..68786cdd 100644 --- a/tests/utils/test_audio_processing.py +++ b/tests/utils/test_audio_processing.py @@ -1,5 +1,6 @@ import asyncio import io +from types import TracebackType from unittest.mock import AsyncMock, patch import pytest @@ -7,6 +8,7 @@ from fastapi import UploadFile from openai import AsyncOpenAI from starlette.datastructures import Headers +import src.utils.files as file_utils from src.config import settings from src.exceptions import ValidationException from src.utils.clients import CLIENTS, transcribe_audio @@ -299,3 +301,39 @@ async def test_audio_processor_normalizes_small_mime_only_audio_filename(): assert extracted.text == "normalized" assert extracted.metadata["audio_segment_count"] == 1 + + +@pytest.mark.asyncio +async def test_validated_audio_upload_cleans_up_temp_file_on_write_failure(): + file = UploadFile( + file=io.BytesIO(b"audio-bytes"), + filename="voice.mp3", + headers=Headers({"content-type": "audio/mpeg"}), + ) + + class FailingTempFile: + name: str = "/tmp/test-audio-validation.mp3" + + def __enter__(self) -> "FailingTempFile": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: + return False + + def write(self, _chunk: bytes) -> int: + raise OSError("disk full") + + with ( + patch("src.utils.files.tempfile.NamedTemporaryFile", return_value=FailingTempFile()), + patch("src.utils.files.Path.unlink") as mock_unlink, + pytest.raises(OSError, match="disk full"), + ): + await file_utils.is_validated_audio_upload(file) + + mock_unlink.assert_called_once_with(missing_ok=True) + assert file.file.tell() == 0