174 lines
10 KiB
Python
174 lines
10 KiB
Python
"""Short recordings are validated and sent only to the separately configured ASR."""
|
|
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import subprocess
|
|
|
|
import requests
|
|
|
|
from logistics_chat import create_app
|
|
from logistics_chat.speech_service import MAX_AUDIO_BYTES
|
|
|
|
|
|
class SpeechResponse:
|
|
def __init__(self, payload=None, status=200, raw=None):
|
|
self.status_code = status
|
|
self.raw = raw if raw is not None else json.dumps(payload or {'choices': [{'message': {'content': '查询订单状态'}}]}).encode()
|
|
self.closed = False
|
|
|
|
def iter_content(self, size):
|
|
yield self.raw
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class SpeechTests(unittest.TestCase):
|
|
def setUp(self):
|
|
directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(directory.cleanup)
|
|
self.root = Path(directory.name)
|
|
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
|
|
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups', 'LOG_DIR': self.root/'logs',
|
|
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
|
|
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
|
|
self.client = self.app.test_client()
|
|
|
|
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
|
|
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
|
|
'duration': duration, 'language':'zh', **extra})
|
|
self.addCleanup(response.close)
|
|
self.addCleanup(response.request.environ['wsgi.input'].close)
|
|
return response
|
|
|
|
def test_missing_asr_key_does_not_reuse_chat_key_or_accept_audio(self):
|
|
self.app.config['ASR_API_KEY'] = ''
|
|
response = self.client.get('/api/speech/config')
|
|
self.assertFalse(response.json['enabled'])
|
|
self.assertIn('no-store', response.headers['Cache-Control'])
|
|
self.assertNotIn('key', response.get_data(as_text=True).lower())
|
|
with patch('logistics_chat.speech_service.requests.post') as post:
|
|
self.assertEqual(self.recording().status_code, 503)
|
|
post.assert_not_called()
|
|
|
|
def test_valid_recording_uses_asr_only_and_never_persists_audio(self):
|
|
upstream = SpeechResponse()
|
|
with patch('logistics_chat.speech_service.requests.post', return_value=upstream) as post:
|
|
response = self.recording()
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json, {'ok': True, 'text': '查询订单状态'})
|
|
url = post.call_args.args[0]
|
|
options = post.call_args.kwargs
|
|
self.assertEqual(url, self.app.config['ASR_URL'])
|
|
self.assertEqual(options['headers']['Authorization'], 'Bearer asr-test-key')
|
|
self.assertEqual(options['json']['model'], 'test-asr')
|
|
self.assertEqual(options['json']['asr_options']['language'], 'zh')
|
|
self.assertTrue(options['json']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/webm;base64,'))
|
|
self.assertFalse(options['allow_redirects'])
|
|
self.assertTrue(upstream.closed)
|
|
self.assertEqual(list(self.root.glob('uploads/**/*')), [])
|
|
self.assertEqual(self.client.get('/api/get_history').json, [])
|
|
|
|
def test_browser_audio_containers_are_supported_without_trusting_filename(self):
|
|
samples = [(b'\x00\x00\x00\x18ftypisom' + b'x'*20, 'audio/mp4'),
|
|
(b'OggS'+b'x'*32, 'audio/ogg'), (b'RIFF'+b'x'*4+b'WAVE'+b'x'*20, 'audio/wav')]
|
|
for content, mime in samples:
|
|
with self.subTest(mime=mime), patch('logistics_chat.speech_service.requests.post', return_value=SpeechResponse()):
|
|
self.assertEqual(self.recording(content, mime).status_code, 200)
|
|
|
|
def test_invalid_audio_size_duration_and_container_never_reach_provider(self):
|
|
with patch('logistics_chat.speech_service.requests.post') as post:
|
|
self.assertEqual(self.recording(b'html masquerading as audio'*2).status_code, 415)
|
|
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*MAX_AUDIO_BYTES).status_code, 413)
|
|
for duration in ('NaN', 'inf', '-1', '0.1', '62', 'broken'):
|
|
with self.subTest(duration=duration):
|
|
self.assertEqual(self.recording(duration=duration).status_code, 400)
|
|
self.assertEqual(self.client.post('/api/speech/transcribe', data={}).status_code, 400)
|
|
post.assert_not_called()
|
|
|
|
def test_request_body_limit_returns_json_instead_of_an_html_error(self):
|
|
response = self.recording(b'x'*(MAX_AUDIO_BYTES+128*1024))
|
|
self.assertEqual(response.status_code, 413)
|
|
self.assertFalse(response.json['ok'])
|
|
|
|
def test_upstream_failures_timeout_and_malformed_results_are_recoverable(self):
|
|
for upstream in (SpeechResponse(status=401, raw=b'secret upstream message'),
|
|
SpeechResponse(raw=b'invalid'), SpeechResponse(payload={'choices': []}),
|
|
SpeechResponse(raw=b'x' * 65537)):
|
|
with self.subTest(raw=upstream.raw[:20]), patch('logistics_chat.speech_service.requests.post', return_value=upstream):
|
|
response = self.recording()
|
|
self.assertEqual(response.status_code, 502)
|
|
self.assertNotIn('secret', response.get_data(as_text=True))
|
|
self.assertTrue(upstream.closed)
|
|
with patch('logistics_chat.speech_service.requests.post', side_effect=requests.Timeout):
|
|
self.assertEqual(self.recording().status_code, 504)
|
|
|
|
def test_silence_and_excessively_long_transcripts_are_not_sent_to_chat(self):
|
|
for text in ('', ' ', '字' * 2001):
|
|
with self.subTest(length=len(text)), patch('logistics_chat.speech_service.requests.post',
|
|
return_value=SpeechResponse(payload={'choices': [{'message': {'content': text}}]})):
|
|
self.assertEqual(self.recording().status_code, 422)
|
|
|
|
def test_baidu_converts_browser_audio_and_caches_a_separate_access_token(self):
|
|
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
|
|
responses=[SpeechResponse(payload={'access_token':'baidu-test-token','expires_in':3600}),
|
|
SpeechResponse(payload={'err_no':0,'result':['查询订单。']}),
|
|
SpeechResponse(payload={'err_no':0,'result':['查询订单。']})]
|
|
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
|
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
|
|
patch('logistics_chat.speech_service.requests.post',side_effect=responses) as post:
|
|
for _ in range(2):
|
|
response=self.recording()
|
|
self.assertEqual(response.status_code,200)
|
|
self.assertEqual(response.json['text'],'查询订单。')
|
|
self.assertEqual(post.call_count,3)
|
|
self.assertEqual(post.call_args_list[0].kwargs['data']['client_id'],'baidu-test-key')
|
|
for call in post.call_args_list[1:]:
|
|
self.assertEqual(call.args[0],'https://vop.baidu.com/server_api')
|
|
self.assertEqual(call.kwargs['json']['format'],'pcm')
|
|
self.assertEqual(call.kwargs['json']['rate'],16000)
|
|
self.assertEqual(call.kwargs['json']['channel'],1)
|
|
self.assertEqual(call.kwargs['json']['token'],'baidu-test-token')
|
|
self.assertTrue(all(response.closed for response in responses))
|
|
|
|
def test_baidu_reports_permission_failure_and_never_returns_upstream_secrets(self):
|
|
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
|
|
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
|
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
|
|
patch('logistics_chat.speech_service.requests.post',side_effect=[
|
|
SpeechResponse(payload={'access_token':'token','expires_in':3600}),
|
|
SpeechResponse(payload={'err_no':3302,'err_msg':'private provider details'})]):
|
|
response=self.recording()
|
|
self.assertEqual(response.status_code,503)
|
|
self.assertNotIn('private',response.get_data(as_text=True))
|
|
|
|
def test_decoder_rejects_real_duration_beyond_limit_and_handles_failure_or_timeout(self):
|
|
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='test-key', BAIDU_ASR_SECRET_KEY='test-secret')
|
|
cases=[(subprocess.CompletedProcess([],0,stdout=b'\0'*(32000*61),stderr=b''),400),
|
|
(subprocess.CompletedProcess([],1,stdout=b'',stderr=b'private decoder error'),422)]
|
|
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
|
patch('logistics_chat.speech_service.requests.post') as post:
|
|
for result,status in cases:
|
|
with patch('logistics_chat.speech_service.subprocess.run',return_value=result):
|
|
self.assertEqual(self.recording(duration='1').status_code,status)
|
|
with patch('logistics_chat.speech_service.subprocess.run',side_effect=subprocess.TimeoutExpired('ffmpeg',10)):
|
|
self.assertEqual(self.recording().status_code,504)
|
|
post.assert_not_called()
|
|
|
|
def test_large_recordings_stay_in_memory_and_the_upload_stream_is_closed(self):
|
|
from logistics_chat import speech_service
|
|
original=speech_service.transcribe_recording
|
|
streams=[]
|
|
def inspect(upload,*args):
|
|
self.assertIsInstance(upload.stream,io.BytesIO)
|
|
streams.append(upload.stream)
|
|
return original(upload,*args)
|
|
with patch('logistics_chat.speech_service.transcribe_recording',side_effect=inspect), \
|
|
patch('logistics_chat.speech_service.requests.post',return_value=SpeechResponse()):
|
|
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*600000).status_code,200)
|
|
self.assertTrue(streams[0].closed)
|