-
Notifications
You must be signed in to change notification settings - Fork 22
✨(feat) POC for audio prompting with Albert API #414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Exceptions related to chat.""" | ||
|
|
||
|
|
||
| class TranscriptionError(Exception): | ||
| """Raised when an error occurs during transcription.""" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,63 @@ | ||||||||||||||||||||||||
| """Albert API implementation for audio transcription.""" | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||
| from typing import IO | ||||||||||||||||||||||||
| from urllib.parse import urljoin | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| from django.conf import settings | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import requests | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| from chat.exceptions import TranscriptionError | ||||||||||||||||||||||||
| from chat.input.base_audio_backend import BaseAudioBackend | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| class AlbertAudioBackend(BaseAudioBackend): | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| Audio transcription backend using the Albert API. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Sends audio files to the Albert ASR endpoint and returns the transcribed text. | ||||||||||||||||||||||||
| Requires ALBERT_API_URL and ALBERT_API_KEY to be configured in settings. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def __init__(self): | ||||||||||||||||||||||||
| self._transcriptions_endpoint = urljoin(settings.ALBERT_API_URL, "/v1/audio/transcriptions") | ||||||||||||||||||||||||
| self._headers = {"Authorization": f"Bearer {settings.ALBERT_API_KEY}"} | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
Comment on lines
+25
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast when Albert credentials are not configured. Line 26 will send 🛠️ Suggested fix from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
@@
def __init__(self):
+ if not settings.ALBERT_API_KEY:
+ raise ImproperlyConfigured("ALBERT_API_KEY must be set for AlbertAudioBackend")
self._transcriptions_endpoint = urljoin(settings.ALBERT_API_URL, "/v1/audio/transcriptions")
self._headers = {"Authorization": f"Bearer {settings.ALBERT_API_KEY}"}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| def transcribe(self, file_name: str, file_content: IO[bytes], content_type: str) -> str: | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| Transcribe the given audio file using the Albert ASR API. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||
| file_name (str): The original file name of the audio file. | ||||||||||||||||||||||||
| file_content (IO[bytes]): A file-like object with the audio content. | ||||||||||||||||||||||||
| content_type (str): The MIME type of the audio file. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||
| str: The transcribed text. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||
| TranscriptionError: If an error occurs during transcription. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||
| response = requests.post( | ||||||||||||||||||||||||
| self._transcriptions_endpoint, | ||||||||||||||||||||||||
| headers=self._headers, | ||||||||||||||||||||||||
| files={"file": (file_name, file_content, content_type)}, | ||||||||||||||||||||||||
| # whisper on vLLM does not support language detection yet, | ||||||||||||||||||||||||
| # so we need to pass the language parameter to Albert API. | ||||||||||||||||||||||||
| # We should allow passing 'auto' when whisper once vLLM | ||||||||||||||||||||||||
| # supports language detection, or when Albert API migrates | ||||||||||||||||||||||||
| # to whisperx. | ||||||||||||||||||||||||
| data={ | ||||||||||||||||||||||||
| "model": settings.ALBERT_API_ASR_MODEL, | ||||||||||||||||||||||||
| "language": settings.ALBERT_API_ASR_LANGUAGE, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| timeout=settings.ALBERT_API_TIMEOUT, | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| response.raise_for_status() | ||||||||||||||||||||||||
| return response.json().get("text", "").strip() | ||||||||||||||||||||||||
| except requests.RequestException as e: | ||||||||||||||||||||||||
|
Comment on lines
+60
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not silently accept invalid transcription payloads. Line 58 falls back to 🛠️ Suggested fix response.raise_for_status()
- return response.json().get("text", "").strip()
+ payload = response.json()
+ text = payload.get("text")
+ if not isinstance(text, str) or not text.strip():
+ raise TranscriptionError("Transcription response missing non-empty 'text'")
+ return text.strip()
except requests.RequestException as e:
raise TranscriptionError(f"Error during transcription: {e}") from e📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| raise TranscriptionError(f"Error during transcription: {e}") from e | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| """Base class for audio input backends.""" | ||
|
|
||
| from abc import ABC, abstractmethod | ||
| from typing import IO | ||
|
|
||
|
|
||
| class BaseAudioBackend(ABC): | ||
| """Abstract base class for audio transcription backends.""" | ||
|
|
||
| @abstractmethod | ||
| def transcribe(self, file_name: str, file_content: IO[bytes], content_type: str) -> str: | ||
| """ | ||
| Transcribe the given audio file to text. | ||
|
|
||
| Args: | ||
| file_name (str): The original file name of the audio file. | ||
| file_content (IO[bytes]): A file-like object with the audio content. | ||
| content_type (str): The MIME type of the audio file. | ||
|
|
||
| Returns: | ||
| str: The transcribed text. | ||
|
|
||
| Raises: | ||
| TranscriptionError: If an error occurs during transcription. | ||
| """ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| """Tests for the Albert audio transcription backend.""" | ||
|
|
||
| import pytest | ||
| import responses | ||
|
|
||
| from chat.exceptions import TranscriptionError | ||
| from chat.input.albert_audio_backend import AlbertAudioBackend | ||
|
|
||
|
|
||
| @pytest.fixture(name="backend") | ||
| def backend_fixture(settings): | ||
| """Fixture providing a configured AlbertAudioBackend instance.""" | ||
| settings.ALBERT_API_URL = "https://albert.example.com" | ||
| settings.ALBERT_API_KEY = "test-key" | ||
| settings.ALBERT_API_TIMEOUT = 10 | ||
| settings.ALBERT_API_ASR_MODEL = "openweight-audio" | ||
| return AlbertAudioBackend() | ||
|
|
||
|
|
||
| @responses.activate | ||
| def test_transcribe_strips_whitespace(backend): | ||
| """Transcription result has surrounding whitespace stripped.""" | ||
| responses.add( | ||
| responses.POST, | ||
| "https://albert.example.com/v1/audio/transcriptions", | ||
| json={"text": " bonjour monde "}, | ||
| status=200, | ||
| ) | ||
|
|
||
| result = backend.transcribe("audio.webm", b"data", "audio/webm") | ||
|
|
||
| assert result == "bonjour monde" | ||
|
|
||
|
|
||
| @responses.activate | ||
| def test_transcribe_raises_transcription_error_on_http_failure(backend): | ||
| """An HTTP error from the Albert API raises TranscriptionError.""" | ||
| responses.add( | ||
| responses.POST, | ||
| "https://albert.example.com/v1/audio/transcriptions", | ||
| status=503, | ||
| ) | ||
|
|
||
| with pytest.raises(TranscriptionError): | ||
| backend.transcribe("audio.webm", b"data", "audio/webm") | ||
|
|
||
|
|
||
| @responses.activate | ||
| def test_transcribe_sends_correct_request(backend): | ||
| """The backend sends the file and required fields to the Albert API.""" | ||
| responses.add( | ||
| responses.POST, | ||
| "https://albert.example.com/v1/audio/transcriptions", | ||
| json={"text": "test"}, | ||
| status=200, | ||
| ) | ||
|
|
||
| backend.transcribe("recording.webm", b"audio-content", "audio/webm") | ||
|
|
||
| assert len(responses.calls) == 1 | ||
| request = responses.calls[0].request | ||
| assert "Bearer test-key" in request.headers["Authorization"] | ||
| assert b"audio-content" in request.body |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Tests for the audio transcription view.""" | ||
|
|
||
| from io import BytesIO | ||
|
|
||
| import pytest | ||
| import responses | ||
|
|
||
| from core.factories import UserFactory | ||
|
|
||
| pytestmark = pytest.mark.django_db | ||
|
|
||
| AUDIO_BYTES = b"RIFF....WAVEfmt " | ||
|
|
||
|
|
||
| @responses.activate | ||
| def test_audio_transcription_success(api_client, settings): | ||
| """Test successful audio transcription returns stripped text.""" | ||
| settings.AUDIO_TRANSCRIPTION_BACKEND = "chat.input.albert_audio_backend.AlbertAudioBackend" | ||
| settings.ALBERT_API_URL = "https://albert.example.com" | ||
| settings.ALBERT_API_KEY = "test-key" | ||
| settings.ALBERT_API_TIMEOUT = 10 | ||
| settings.ALBERT_API_ASR_MODEL = "openweight-audio" | ||
|
|
||
| responses.add( | ||
| responses.POST, | ||
| "https://albert.example.com/v1/audio/transcriptions", | ||
| json={"text": " hello world"}, | ||
| status=200, | ||
| ) | ||
|
|
||
| user = UserFactory() | ||
| api_client.force_login(user) | ||
| response = api_client.post( | ||
| "/api/v1.0/transcribe/", | ||
| {"audio": BytesIO(AUDIO_BYTES)}, | ||
| format="multipart", | ||
| ) | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json() == {"text": "hello world"} | ||
|
|
||
|
|
||
| def test_audio_transcription_anonymous(api_client, settings): | ||
| """Anonymous users cannot access the transcription endpoint.""" | ||
| settings.AUDIO_TRANSCRIPTION_BACKEND = "chat.input.albert_audio_backend.AlbertAudioBackend" | ||
|
|
||
| response = api_client.post( | ||
| "/api/v1.0/transcribe/", | ||
| {"audio": BytesIO(AUDIO_BYTES)}, | ||
| format="multipart", | ||
| ) | ||
|
|
||
| assert response.status_code == 401 | ||
|
|
||
|
|
||
| def test_audio_transcription_backend_disabled(api_client, settings): | ||
| """Returns 501 when no transcription backend is configured.""" | ||
| settings.AUDIO_TRANSCRIPTION_BACKEND = None | ||
|
|
||
| user = UserFactory() | ||
| api_client.force_login(user) | ||
| response = api_client.post( | ||
| "/api/v1.0/transcribe/", | ||
| {"audio": BytesIO(AUDIO_BYTES)}, | ||
| format="multipart", | ||
| ) | ||
|
|
||
| assert response.status_code == 501 | ||
|
|
||
|
|
||
| def test_audio_transcription_no_file(api_client, settings): | ||
| """Returns 400 when no audio file is provided.""" | ||
| settings.AUDIO_TRANSCRIPTION_BACKEND = "chat.input.albert_audio_backend.AlbertAudioBackend" | ||
| settings.ALBERT_API_URL = "https://albert.example.com" | ||
| settings.ALBERT_API_KEY = "test-key" | ||
| settings.ALBERT_API_TIMEOUT = 10 | ||
| settings.ALBERT_API_ASR_MODEL = "openweight-audio" | ||
|
|
||
| user = UserFactory() | ||
| api_client.force_login(user) | ||
| response = api_client.post("/api/v1.0/transcribe/", {}, format="multipart") | ||
|
|
||
| assert response.status_code == 400 | ||
|
|
||
|
|
||
| @responses.activate | ||
| def test_audio_transcription_backend_error(api_client, settings): | ||
| """Returns 502 when the transcription backend fails.""" | ||
| settings.AUDIO_TRANSCRIPTION_BACKEND = "chat.input.albert_audio_backend.AlbertAudioBackend" | ||
| settings.ALBERT_API_URL = "https://albert.example.com" | ||
| settings.ALBERT_API_KEY = "test-key" | ||
| settings.ALBERT_API_TIMEOUT = 10 | ||
| settings.ALBERT_API_ASR_MODEL = "openweight-audio" | ||
|
|
||
| responses.add( | ||
| responses.POST, | ||
| "https://albert.example.com/v1/audio/transcriptions", | ||
| status=500, | ||
| ) | ||
|
|
||
| user = UserFactory() | ||
| api_client.force_login(user) | ||
| response = api_client.post( | ||
| "/api/v1.0/transcribe/", | ||
| {"audio": BytesIO(AUDIO_BYTES)}, | ||
| format="multipart", | ||
| ) | ||
|
|
||
| assert response.status_code == 502 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
urljoinwith an absolute path discards the base URL's path.urljoin("https://albert.example.com/api", "/v1/audio/transcriptions")returns"https://albert.example.com/v1/audio/transcriptions"— the/apiportion is lost. IfALBERT_API_URLever includes a path prefix, this will break.🛠️ Suggested fix using rstrip/lstrip
🤖 Prompt for AI Agents