Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ logs/

# Temporary files
*.tmp
*.temp
*.temp
# Playwright saved login state for the local test user
frontend/playwright/.auth/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ class WhisperTranscriptionService:
### Backend (pytest-django)
- 25+ tests covering auth, voice notes, serializers, API contracts
- Runs against SQLite in CI, PostgreSQL in Docker
- Language handling is pinned by `tests/test_language_detection.py` (recorded provider
responses) and `tests/live/` (real audio fixtures through real Deepgram, run with
`pytest -m live`; excluded from the default run). Fixtures live in
`tests/fixtures/audio/`; see `docs/adr/004-language-detection-strategy.md`.

### Frontend (Playwright E2E)
- Auth setup: register + login with storage state persistence
Expand Down
1 change: 1 addition & 0 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@
'user-agent',
'x-csrftoken',
'x-requested-with',
'x-request-id', # browser-originated trace id (see RequestIDMiddleware); without it cross-origin note creation fails preflight
'range', # Important for audio/video streaming
]

Expand Down
4 changes: 3 additions & 1 deletion backend/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ DJANGO_SETTINGS_MODULE = config.settings
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = --tb=short -q
markers =
live: sends real audio to the real transcription provider (needs DEEPGRAM_API_KEY); run with -m live
addopts = --tb=short -q -m "not live"
Binary file added backend/tests/fixtures/audio/en_us.m4a
Binary file not shown.
Binary file added backend/tests/fixtures/audio/es_es.m4a
Binary file not shown.
Binary file added backend/tests/fixtures/audio/es_mx.m4a
Binary file not shown.
Binary file added backend/tests/fixtures/audio/ko_kr.m4a
Binary file not shown.
Binary file added backend/tests/fixtures/audio/mixed_en_es.m4a
Binary file not shown.
Binary file added backend/tests/fixtures/audio/zh_cn.m4a
Binary file not shown.
Empty file added backend/tests/live/__init__.py
Empty file.
59 changes: 59 additions & 0 deletions backend/tests/live/test_deepgram_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Live provider check: real audio through the real Deepgram service.

Excluded from the default run (pytest.ini adds ``-m "not live"``). Run before a
deploy with::

DEEPGRAM_API_KEY=... pytest -m live

Each fixture in tests/fixtures/audio/ is a short synthetic clip (macOS speech
voices) with a known language and a keyword the transcript must contain.
This is the MODULE-tier evidence that the auto path keeps the spoken language;
the unit tests only prove how a recorded response is handled.
"""
from pathlib import Path

import pytest
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile

from apps.core.services import DeepgramTranscriptionService

FIXTURES = Path(__file__).resolve().parent.parent / 'fixtures' / 'audio'

# file, expected language, keyword that must appear in the transcript
CASES = [
('es_mx.m4a', 'es', 'hermana'),
('es_es.m4a', 'es', 'informe'),
('en_us.m4a', 'en', 'dentist'),
('ko_kr.m4a', 'ko', '병원'),
('zh_cn.m4a', 'zh', '医生'),
]

pytestmark = pytest.mark.live


@pytest.fixture(scope='module')
def service():
if not settings.DEEPGRAM_API_KEY:
pytest.skip('DEEPGRAM_API_KEY not set; live provider check skipped')
return DeepgramTranscriptionService()


@pytest.mark.parametrize('file_name, expected_language, keyword', CASES)
def test_auto_keeps_spoken_language(service, file_name, expected_language, keyword):
audio = SimpleUploadedFile(file_name, (FIXTURES / file_name).read_bytes(), content_type='audio/mp4')
result = service.transcribe_audio(audio, language='auto')
assert result['success'] is True, result.get('error')
assert result['language'] == expected_language
assert keyword in result['text'].lower()


def test_mixed_english_spanish_keeps_both_languages(service):
audio = SimpleUploadedFile(
'mixed_en_es.m4a', (FIXTURES / 'mixed_en_es.m4a').read_bytes(), content_type='audio/mp4'
)
result = service.transcribe_audio(audio, language='auto')
assert result['success'] is True, result.get('error')
text = result['text'].lower()
assert 'sister' in text # English half survives
assert 'hermana' in text # Spanish half survives, not translated
23 changes: 23 additions & 0 deletions backend/tests/test_cors_preflight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""CORS preflight contract for the headers the frontend actually sends.

The browser sends X-Request-ID on note creation so the trace starts client-side
(frontend/src/services/api.ts). If the header is missing from
CORS_ALLOW_HEADERS the preflight succeeds but the browser drops the real POST,
so uploads silently fail on any cross-origin deployment (the dev stack, CI e2e).
"""
import pytest


@pytest.mark.django_db
def test_preflight_allows_x_request_id(api_client):
resp = api_client.options(
'/api/notes/',
HTTP_ORIGIN='http://localhost:3011',
HTTP_ACCESS_CONTROL_REQUEST_METHOD='POST',
HTTP_ACCESS_CONTROL_REQUEST_HEADERS='content-type,x-request-id',
)
assert resp.status_code == 200
allowed = {h.strip().lower() for h in resp['Access-Control-Allow-Headers'].split(',')}
assert 'x-request-id' in allowed
assert 'content-type' in allowed
assert resp['Access-Control-Allow-Origin'] == 'http://localhost:3011'
108 changes: 108 additions & 0 deletions backend/tests/test_language_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Spoken-language handling for the Deepgram transcription path.

Regression guard for the report "Spanish audio comes back as English".
These tests pin the contract that the auto path asks Deepgram to detect the
language and that whatever it detects is what the note stores, for every
language the app offers. Provider responses are recorded shapes (see
docs/adr/004-language-detection-strategy.md); the live provider check lives
in tests/live/.
"""
from typing import Optional
from unittest import mock

import pytest

from apps.core.services import DeepgramTranscriptionService
from apps.voice_notes.models import VoiceNote


def _payload(transcript: str, detected: Optional[str], confidence: float = 0.99) -> dict:
channel = {
'alternatives': [{'transcript': transcript, 'confidence': 0.97, 'words': []}],
}
if detected is not None:
channel['detected_language'] = detected
channel['language_confidence'] = confidence
return {
'metadata': {'duration': 7.5},
'results': {
'channels': [channel],
'utterances': [
{'start': 0.0, 'end': 7.5, 'transcript': transcript, 'speaker': 0, 'confidence': 0.97},
],
},
}


def _mock_response(payload: dict) -> mock.Mock:
response = mock.Mock(status_code=200)
response.json.return_value = payload
response.raise_for_status.return_value = None
return response


@pytest.fixture
def deepgram(settings):
settings.DEEPGRAM_API_KEY = 'dg-test-key'
return DeepgramTranscriptionService()


OFFERED_LANGUAGES = [code for code, _ in VoiceNote.LANGUAGE_CHOICES if code != 'auto']


class TestAutoLanguagePath:
def test_auto_requests_detection_not_a_fixed_language(self, deepgram, audio_file):
with mock.patch(
'apps.core.services.requests.post', return_value=_mock_response(_payload('Hola', 'es'))
) as post:
deepgram.transcribe_audio(audio_file, language='auto')
params = post.call_args.kwargs['params']
assert params['detect_language'] == 'true'
assert 'language' not in params

@pytest.mark.parametrize('detected', OFFERED_LANGUAGES)
def test_detected_language_is_stored_verbatim(self, deepgram, audio_file, detected):
with mock.patch(
'apps.core.services.requests.post',
return_value=_mock_response(_payload('texto', detected)),
):
result = deepgram.transcribe_audio(audio_file, language='auto')
assert result['success'] is True
assert result['language'] == detected

def test_spanish_text_is_kept_not_translated(self, deepgram, audio_file):
spanish = 'Hola, soy tu hermana. Mañana tengo cita con el médico.'
with mock.patch(
'apps.core.services.requests.post', return_value=_mock_response(_payload(spanish, 'es'))
):
result = deepgram.transcribe_audio(audio_file, language='auto')
assert result['text'] == spanish
assert result['segments'][0].text == spanish
assert result['language'] == 'es'

def test_regional_code_is_normalised_to_base_language(self, deepgram, audio_file):
with mock.patch(
'apps.core.services.requests.post', return_value=_mock_response(_payload('Olá', 'pt-BR'))
):
result = deepgram.transcribe_audio(audio_file, language='auto')
assert result['language'] == 'pt'

def test_missing_detection_falls_back_to_auto(self, deepgram, audio_file):
with mock.patch(
'apps.core.services.requests.post', return_value=_mock_response(_payload('...', None))
):
result = deepgram.transcribe_audio(audio_file, language='auto')
assert result['language'] == 'auto'


class TestExplicitLanguagePath:
@pytest.mark.parametrize('requested', ['es', 'ko', 'zh'])
def test_explicit_language_is_sent_and_stored(self, deepgram, audio_file, requested):
with mock.patch(
'apps.core.services.requests.post', return_value=_mock_response(_payload('texto', None))
) as post:
result = deepgram.transcribe_audio(audio_file, language=requested)
params = post.call_args.kwargs['params']
assert params['language'] == requested
assert 'detect_language' not in params
assert result['language'] == requested
47 changes: 47 additions & 0 deletions docs/adr/004-language-detection-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ADR-004: Spoken-language detection strategy

## Status
Accepted (2026-09-01)

## Context
A user reported that Spanish audio was transcribed as English. The report predates the
switch to Deepgram (PR #38, deployed 2026-06-29); the earlier path was a self-hosted
`faster-whisper-small` model, which is weak on short non-English clips.

The auto path today sends `detect_language=true` to Deepgram nova-3. Deepgram also offers
`language=multi` (code-switching mode, per-word language tags). Before choosing, both were
run against synthetic clips (macOS speech voices, now committed under
`backend/tests/fixtures/audio/`):

| clip | `detect_language` | `language=multi` |
|---|---|---|
| Spanish (MX, ES) | correct Spanish, detected `es` at 0.99 | correct Spanish |
| English then Spanish | both kept; minor errors ("dentista", "Than") | cleanest text; words tagged en/es |
| Korean | correct, detected `ko` at 1.00 | garbage, tagged `ja` |
| Chinese | correct, detected `zh` at 1.00 | garbage, tagged `ja` |

`multi` returns no `detected_language`; the language would have to be derived from word
tags. Its supported language set does not include Korean or Chinese, both of which the app
offers.

## Decision
Keep `detect_language=true` as the auto path. Do not switch to `multi`.

Add the evidence instead of a code change: committed audio fixtures, unit tests that pin
"whatever Deepgram detects is what the note stores" for every offered language, a live
provider test (`pytest -m live`) that runs the fixtures through real Deepgram, and a
Playwright upload flow that asserts Spanish text and a Spanish badge on the note page.

## Alternatives considered
- **`language=multi` for auto.** Rejected: breaks Korean and Chinese (table above).
- **Two-pass: detect, then re-run with `multi` when `language_confidence` is low.** The
mixed clip scored 0.56 against 0.99+ for single-language clips, so a threshold would
separate them. Rejected for now: doubles provider cost on ambiguous clips, and the
second pass still needs a guard for languages `multi` cannot handle. Revisit only if
code-switched notes show real accuracy problems.

## Consequences
- Mixed-language recordings keep each language but may carry small errors at the switch
points.
- The live test is the pre-deploy gate for provider behaviour changes; it is not part of CI.
- Translation is a separate capability and is not addressed here.
55 changes: 55 additions & 0 deletions frontend/e2e/tests/multilingual-upload.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Spoken-language proof through the real UI: upload a Spanish clip, wait for the
// worker to transcribe it, and check the note shows Spanish text with a Spanish
// badge. Needs the dev stack running with a real provider key. Opt in with
// CLIO_LIVE_E2E=1 so CI (which has no provider key) is unaffected.
import path from 'path';
import { test, expect } from '@playwright/test';

const FIXTURES = path.resolve(__dirname, '../../../backend/tests/fixtures/audio');

test.use({ storageState: 'playwright/.auth/user.json' });

const uploadAndWaitForTranscript = async (page, fileName, title) => {
await page.goto('/record');
await page.waitForLoadState('networkidle');

await page.getByRole('tab', { name: 'Upload' }).click();
await page.getByLabel('Choose an audio file to upload').setInputFiles(path.join(FIXTURES, fileName));
await expect(page.getByText('Ready to transcribe')).toBeVisible();

await page.fill('input#note-title', title);
await page.getByRole('button', { name: 'Save & Transcribe' }).click();

await page.waitForURL(/\/notes\/\d+/, { timeout: 15000 });
// The page polls while the worker runs. Wait for a positive completion signal (the
// language badge only renders once transcription finished) rather than for the
// processing indicator to disappear, which is not yet rendered on first paint.
await expect(page.getByTestId('note-language')).toBeVisible({ timeout: 90000 });
return page;
};

test.describe('Multilingual transcription', () => {
test.skip(!process.env.CLIO_LIVE_E2E, 'set CLIO_LIVE_E2E=1 against a dev stack that has a real provider key');

test('Spanish upload comes back as Spanish text with a Spanish badge', async ({ page }) => {
await uploadAndWaitForTranscript(page, 'es_mx.m4a', 'e2e spanish');

const badge = page.getByTestId('note-language');
await expect(badge).toHaveText('Spanish');
await expect(badge).toHaveAttribute('data-language', 'es');

const body = (await page.textContent('main, body')).toLowerCase();
expect(body).toContain('hermana');
expect(body).not.toContain('my sister'); // not translated
await page.screenshot({ path: 'test-results/multilingual-spanish.png', fullPage: true });
});

test('English then Spanish upload keeps both languages', async ({ page }) => {
await uploadAndWaitForTranscript(page, 'mixed_en_es.m4a', 'e2e mixed');

const body = (await page.textContent('main, body')).toLowerCase();
expect(body).toContain('sister');
expect(body).toContain('hermana');
await page.screenshot({ path: 'test-results/multilingual-mixed.png', fullPage: true });
});
});
38 changes: 25 additions & 13 deletions frontend/src/pages/NoteDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ interface SpeakerTurn {
segments: TranscriptionSegment[];
}

// Languages the app offers for transcription. Mirrors VoiceNote.LANGUAGE_CHOICES
// on the backend; the badge and the re-transcribe dialog both read from here.
const LANGUAGE_OPTIONS = [
{ value: 'auto', label: 'Auto-detect' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'zh', label: 'Chinese' },
];

// Human label for a detected language code; unknown codes show as-is.
const languageLabel = (code: string): string =>
LANGUAGE_OPTIONS.find((o) => o.value === code)?.label ?? code;

// Collapse consecutive same-speaker segments into a single turn for display.
const groupBySpeaker = (segments: TranscriptionSegment[]): SpeakerTurn[] => {
const turns: SpeakerTurn[] = [];
Expand Down Expand Up @@ -196,18 +215,7 @@ const NoteDetailPage: React.FC = () => {
}
);

const languageOptions = [
{ value: 'auto', label: 'Auto-detect' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'zh', label: 'Chinese' },
];
const languageOptions = LANGUAGE_OPTIONS;

const handleRetranscribe = () => {
retranscribeMutation.mutate({ language: selectedLanguage });
Expand Down Expand Up @@ -272,7 +280,11 @@ const NoteDetailPage: React.FC = () => {
{note.language_detected && note.language_detected !== 'auto' && (
<>
<span>·</span>
<span>{note.language_detected}</span>
{/* The label itself is English, so no `lang` here; the detected code is
exposed as data for tests and tooling. */}
<span data-testid="note-language" data-language={note.language_detected}>
{languageLabel(note.language_detected)}
</span>
</>
)}
</div>
Expand Down