Spaces:
Build error
Build error
Michael Hu
commited on
Commit
Β·
111538d
1
Parent(s):
48ba7e8
Implement application DTOs
Browse files
src/application/dtos/__init__.py
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Application Data Transfer Objects (DTOs)
|
2 |
+
|
3 |
+
This module contains DTOs used for data transfer between layers.
|
4 |
+
DTOs handle serialization/deserialization and validation of data
|
5 |
+
crossing layer boundaries.
|
6 |
+
"""
|
7 |
+
|
8 |
+
from .audio_upload_dto import AudioUploadDto
|
9 |
+
from .processing_request_dto import ProcessingRequestDto
|
10 |
+
from .processing_result_dto import ProcessingResultDto
|
11 |
+
from .dto_validation import validate_dto, ValidationError
|
12 |
+
|
13 |
+
__all__ = [
|
14 |
+
'AudioUploadDto',
|
15 |
+
'ProcessingRequestDto',
|
16 |
+
'ProcessingResultDto',
|
17 |
+
'validate_dto',
|
18 |
+
'ValidationError'
|
19 |
+
]
|
src/application/dtos/audio_upload_dto.py
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Audio Upload Data Transfer Object"""
|
2 |
+
|
3 |
+
from dataclasses import dataclass
|
4 |
+
from typing import Optional
|
5 |
+
import mimetypes
|
6 |
+
import os
|
7 |
+
|
8 |
+
|
9 |
+
@dataclass
|
10 |
+
class AudioUploadDto:
|
11 |
+
"""DTO for file upload data
|
12 |
+
|
13 |
+
Handles audio file upload information including filename,
|
14 |
+
content, and content type validation.
|
15 |
+
"""
|
16 |
+
filename: str
|
17 |
+
content: bytes
|
18 |
+
content_type: str
|
19 |
+
size: Optional[int] = None
|
20 |
+
|
21 |
+
def __post_init__(self):
|
22 |
+
"""Validate the DTO after initialization"""
|
23 |
+
self._validate()
|
24 |
+
if self.size is None:
|
25 |
+
self.size = len(self.content)
|
26 |
+
|
27 |
+
def _validate(self):
|
28 |
+
"""Validate audio upload data"""
|
29 |
+
if not self.filename:
|
30 |
+
raise ValueError("Filename cannot be empty")
|
31 |
+
|
32 |
+
if not self.content:
|
33 |
+
raise ValueError("Audio content cannot be empty")
|
34 |
+
|
35 |
+
if not self.content_type:
|
36 |
+
raise ValueError("Content type cannot be empty")
|
37 |
+
|
38 |
+
# Validate file extension
|
39 |
+
_, ext = os.path.splitext(self.filename.lower())
|
40 |
+
supported_extensions = ['.wav', '.mp3', '.m4a', '.flac', '.ogg']
|
41 |
+
if ext not in supported_extensions:
|
42 |
+
raise ValueError(f"Unsupported file extension: {ext}. Supported: {supported_extensions}")
|
43 |
+
|
44 |
+
# Validate content type
|
45 |
+
expected_content_type = mimetypes.guess_type(self.filename)[0]
|
46 |
+
if expected_content_type and not self.content_type.startswith('audio/'):
|
47 |
+
raise ValueError(f"Invalid content type: {self.content_type}. Expected audio/* type")
|
48 |
+
|
49 |
+
# Validate file size (max 100MB)
|
50 |
+
max_size = 100 * 1024 * 1024 # 100MB
|
51 |
+
if len(self.content) > max_size:
|
52 |
+
raise ValueError(f"File too large: {len(self.content)} bytes. Maximum: {max_size} bytes")
|
53 |
+
|
54 |
+
# Validate minimum file size (at least 1KB)
|
55 |
+
min_size = 1024 # 1KB
|
56 |
+
if len(self.content) < min_size:
|
57 |
+
raise ValueError(f"File too small: {len(self.content)} bytes. Minimum: {min_size} bytes")
|
58 |
+
|
59 |
+
@property
|
60 |
+
def file_extension(self) -> str:
|
61 |
+
"""Get the file extension"""
|
62 |
+
return os.path.splitext(self.filename.lower())[1]
|
63 |
+
|
64 |
+
@property
|
65 |
+
def base_filename(self) -> str:
|
66 |
+
"""Get filename without extension"""
|
67 |
+
return os.path.splitext(self.filename)[0]
|
68 |
+
|
69 |
+
def to_dict(self) -> dict:
|
70 |
+
"""Convert to dictionary representation"""
|
71 |
+
return {
|
72 |
+
'filename': self.filename,
|
73 |
+
'content_type': self.content_type,
|
74 |
+
'size': self.size,
|
75 |
+
'file_extension': self.file_extension
|
76 |
+
}
|
src/application/dtos/dto_validation.py
ADDED
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""DTO Validation Utilities
|
2 |
+
|
3 |
+
Provides validation functions and utilities for DTOs,
|
4 |
+
including custom validation decorators and error handling.
|
5 |
+
"""
|
6 |
+
|
7 |
+
from typing import Any, Callable, TypeVar, Union
|
8 |
+
from functools import wraps
|
9 |
+
import logging
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
T = TypeVar('T')
|
14 |
+
|
15 |
+
|
16 |
+
class ValidationError(Exception):
|
17 |
+
"""Custom exception for DTO validation errors"""
|
18 |
+
|
19 |
+
def __init__(self, message: str, field: str = None, value: Any = None):
|
20 |
+
self.message = message
|
21 |
+
self.field = field
|
22 |
+
self.value = value
|
23 |
+
super().__init__(self.message)
|
24 |
+
|
25 |
+
def __str__(self):
|
26 |
+
if self.field:
|
27 |
+
return f"Validation error for field '{self.field}': {self.message}"
|
28 |
+
return f"Validation error: {self.message}"
|
29 |
+
|
30 |
+
|
31 |
+
def validate_dto(dto_instance: Any) -> bool:
|
32 |
+
"""Validate a DTO instance
|
33 |
+
|
34 |
+
Args:
|
35 |
+
dto_instance: The DTO instance to validate
|
36 |
+
|
37 |
+
Returns:
|
38 |
+
bool: True if validation passes
|
39 |
+
|
40 |
+
Raises:
|
41 |
+
ValidationError: If validation fails
|
42 |
+
"""
|
43 |
+
try:
|
44 |
+
# Call the DTO's validation method if it exists
|
45 |
+
if hasattr(dto_instance, '_validate'):
|
46 |
+
dto_instance._validate()
|
47 |
+
|
48 |
+
# Additional validation can be added here
|
49 |
+
logger.debug(f"Successfully validated {type(dto_instance).__name__}")
|
50 |
+
return True
|
51 |
+
|
52 |
+
except ValueError as e:
|
53 |
+
logger.error(f"Validation failed for {type(dto_instance).__name__}: {e}")
|
54 |
+
raise ValidationError(str(e)) from e
|
55 |
+
except Exception as e:
|
56 |
+
logger.error(f"Unexpected error during validation of {type(dto_instance).__name__}: {e}")
|
57 |
+
raise ValidationError(f"Validation failed: {e}") from e
|
58 |
+
|
59 |
+
|
60 |
+
def validation_required(func: Callable[..., T]) -> Callable[..., T]:
|
61 |
+
"""Decorator to ensure DTO validation before method execution
|
62 |
+
|
63 |
+
Args:
|
64 |
+
func: The method to decorate
|
65 |
+
|
66 |
+
Returns:
|
67 |
+
Decorated function that validates 'self' before execution
|
68 |
+
"""
|
69 |
+
@wraps(func)
|
70 |
+
def wrapper(self, *args, **kwargs):
|
71 |
+
try:
|
72 |
+
validate_dto(self)
|
73 |
+
return func(self, *args, **kwargs)
|
74 |
+
except ValidationError:
|
75 |
+
raise
|
76 |
+
except Exception as e:
|
77 |
+
raise ValidationError(f"Error in {func.__name__}: {e}") from e
|
78 |
+
|
79 |
+
return wrapper
|
80 |
+
|
81 |
+
|
82 |
+
def validate_field(value: Any, field_name: str, validator: Callable[[Any], bool],
|
83 |
+
error_message: str = None) -> Any:
|
84 |
+
"""Validate a single field value
|
85 |
+
|
86 |
+
Args:
|
87 |
+
value: The value to validate
|
88 |
+
field_name: Name of the field being validated
|
89 |
+
validator: Function that returns True if value is valid
|
90 |
+
error_message: Custom error message
|
91 |
+
|
92 |
+
Returns:
|
93 |
+
The validated value
|
94 |
+
|
95 |
+
Raises:
|
96 |
+
ValidationError: If validation fails
|
97 |
+
"""
|
98 |
+
try:
|
99 |
+
if not validator(value):
|
100 |
+
message = error_message or f"Invalid value for field '{field_name}'"
|
101 |
+
raise ValidationError(message, field_name, value)
|
102 |
+
return value
|
103 |
+
except ValidationError:
|
104 |
+
raise
|
105 |
+
except Exception as e:
|
106 |
+
raise ValidationError(f"Validation error for field '{field_name}': {e}", field_name, value) from e
|
107 |
+
|
108 |
+
|
109 |
+
def validate_required(value: Any, field_name: str) -> Any:
|
110 |
+
"""Validate that a field is not None or empty
|
111 |
+
|
112 |
+
Args:
|
113 |
+
value: The value to validate
|
114 |
+
field_name: Name of the field being validated
|
115 |
+
|
116 |
+
Returns:
|
117 |
+
The validated value
|
118 |
+
|
119 |
+
Raises:
|
120 |
+
ValidationError: If field is None or empty
|
121 |
+
"""
|
122 |
+
if value is None:
|
123 |
+
raise ValidationError(f"Field '{field_name}' is required", field_name, value)
|
124 |
+
|
125 |
+
if isinstance(value, (str, list, dict)) and len(value) == 0:
|
126 |
+
raise ValidationError(f"Field '{field_name}' cannot be empty", field_name, value)
|
127 |
+
|
128 |
+
return value
|
129 |
+
|
130 |
+
|
131 |
+
def validate_type(value: Any, field_name: str, expected_type: Union[type, tuple]) -> Any:
|
132 |
+
"""Validate that a field is of the expected type
|
133 |
+
|
134 |
+
Args:
|
135 |
+
value: The value to validate
|
136 |
+
field_name: Name of the field being validated
|
137 |
+
expected_type: Expected type or tuple of types
|
138 |
+
|
139 |
+
Returns:
|
140 |
+
The validated value
|
141 |
+
|
142 |
+
Raises:
|
143 |
+
ValidationError: If type doesn't match
|
144 |
+
"""
|
145 |
+
if not isinstance(value, expected_type):
|
146 |
+
if isinstance(expected_type, tuple):
|
147 |
+
type_names = [t.__name__ for t in expected_type]
|
148 |
+
expected_str = " or ".join(type_names)
|
149 |
+
else:
|
150 |
+
expected_str = expected_type.__name__
|
151 |
+
|
152 |
+
actual_type = type(value).__name__
|
153 |
+
raise ValidationError(
|
154 |
+
f"Field '{field_name}' must be of type {expected_str}, got {actual_type}",
|
155 |
+
field_name, value
|
156 |
+
)
|
157 |
+
|
158 |
+
return value
|
159 |
+
|
160 |
+
|
161 |
+
def validate_range(value: Union[int, float], field_name: str,
|
162 |
+
min_value: Union[int, float] = None,
|
163 |
+
max_value: Union[int, float] = None) -> Union[int, float]:
|
164 |
+
"""Validate that a numeric value is within a specified range
|
165 |
+
|
166 |
+
Args:
|
167 |
+
value: The numeric value to validate
|
168 |
+
field_name: Name of the field being validated
|
169 |
+
min_value: Minimum allowed value (inclusive)
|
170 |
+
max_value: Maximum allowed value (inclusive)
|
171 |
+
|
172 |
+
Returns:
|
173 |
+
The validated value
|
174 |
+
|
175 |
+
Raises:
|
176 |
+
ValidationError: If value is outside the range
|
177 |
+
"""
|
178 |
+
if min_value is not None and value < min_value:
|
179 |
+
raise ValidationError(
|
180 |
+
f"Field '{field_name}' must be >= {min_value}, got {value}",
|
181 |
+
field_name, value
|
182 |
+
)
|
183 |
+
|
184 |
+
if max_value is not None and value > max_value:
|
185 |
+
raise ValidationError(
|
186 |
+
f"Field '{field_name}' must be <= {max_value}, got {value}",
|
187 |
+
field_name, value
|
188 |
+
)
|
189 |
+
|
190 |
+
return value
|
191 |
+
|
192 |
+
|
193 |
+
def validate_choices(value: Any, field_name: str, choices: list) -> Any:
|
194 |
+
"""Validate that a value is one of the allowed choices
|
195 |
+
|
196 |
+
Args:
|
197 |
+
value: The value to validate
|
198 |
+
field_name: Name of the field being validated
|
199 |
+
choices: List of allowed values
|
200 |
+
|
201 |
+
Returns:
|
202 |
+
The validated value
|
203 |
+
|
204 |
+
Raises:
|
205 |
+
ValidationError: If value is not in choices
|
206 |
+
"""
|
207 |
+
if value not in choices:
|
208 |
+
raise ValidationError(
|
209 |
+
f"Field '{field_name}' must be one of {choices}, got '{value}'",
|
210 |
+
field_name, value
|
211 |
+
)
|
212 |
+
|
213 |
+
return value
|
src/application/dtos/processing_request_dto.py
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Processing Request Data Transfer Object"""
|
2 |
+
|
3 |
+
from dataclasses import dataclass
|
4 |
+
from typing import Optional, Dict, Any
|
5 |
+
from .audio_upload_dto import AudioUploadDto
|
6 |
+
|
7 |
+
|
8 |
+
@dataclass
|
9 |
+
class ProcessingRequestDto:
|
10 |
+
"""DTO for pipeline input parameters
|
11 |
+
|
12 |
+
Contains all parameters needed to process audio through
|
13 |
+
the STT -> Translation -> TTS pipeline.
|
14 |
+
"""
|
15 |
+
audio: AudioUploadDto
|
16 |
+
asr_model: str
|
17 |
+
target_language: str
|
18 |
+
voice: str
|
19 |
+
speed: float = 1.0
|
20 |
+
source_language: Optional[str] = None
|
21 |
+
additional_params: Optional[Dict[str, Any]] = None
|
22 |
+
|
23 |
+
def __post_init__(self):
|
24 |
+
"""Validate the DTO after initialization"""
|
25 |
+
self._validate()
|
26 |
+
if self.additional_params is None:
|
27 |
+
self.additional_params = {}
|
28 |
+
|
29 |
+
def _validate(self):
|
30 |
+
"""Validate processing request parameters"""
|
31 |
+
if not isinstance(self.audio, AudioUploadDto):
|
32 |
+
raise ValueError("Audio must be an AudioUploadDto instance")
|
33 |
+
|
34 |
+
if not self.asr_model:
|
35 |
+
raise ValueError("ASR model cannot be empty")
|
36 |
+
|
37 |
+
# Validate ASR model options
|
38 |
+
supported_asr_models = ['whisper-small', 'whisper-medium', 'whisper-large', 'parakeet']
|
39 |
+
if self.asr_model not in supported_asr_models:
|
40 |
+
raise ValueError(f"Unsupported ASR model: {self.asr_model}. Supported: {supported_asr_models}")
|
41 |
+
|
42 |
+
if not self.target_language:
|
43 |
+
raise ValueError("Target language cannot be empty")
|
44 |
+
|
45 |
+
# Validate language codes (ISO 639-1)
|
46 |
+
supported_languages = [
|
47 |
+
'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'zh',
|
48 |
+
'ar', 'hi', 'tr', 'pl', 'nl', 'sv', 'da', 'no', 'fi'
|
49 |
+
]
|
50 |
+
if self.target_language not in supported_languages:
|
51 |
+
raise ValueError(f"Unsupported target language: {self.target_language}. Supported: {supported_languages}")
|
52 |
+
|
53 |
+
if self.source_language and self.source_language not in supported_languages:
|
54 |
+
raise ValueError(f"Unsupported source language: {self.source_language}. Supported: {supported_languages}")
|
55 |
+
|
56 |
+
if not self.voice:
|
57 |
+
raise ValueError("Voice cannot be empty")
|
58 |
+
|
59 |
+
# Validate voice options
|
60 |
+
supported_voices = ['kokoro', 'dia', 'cosyvoice2', 'dummy']
|
61 |
+
if self.voice not in supported_voices:
|
62 |
+
raise ValueError(f"Unsupported voice: {self.voice}. Supported: {supported_voices}")
|
63 |
+
|
64 |
+
# Validate speed range
|
65 |
+
if not 0.5 <= self.speed <= 2.0:
|
66 |
+
raise ValueError(f"Speed must be between 0.5 and 2.0, got: {self.speed}")
|
67 |
+
|
68 |
+
# Validate additional params if provided
|
69 |
+
if self.additional_params and not isinstance(self.additional_params, dict):
|
70 |
+
raise ValueError("Additional params must be a dictionary")
|
71 |
+
|
72 |
+
@property
|
73 |
+
def requires_translation(self) -> bool:
|
74 |
+
"""Check if translation is required"""
|
75 |
+
if not self.source_language:
|
76 |
+
return True # Assume translation needed if source not specified
|
77 |
+
return self.source_language != self.target_language
|
78 |
+
|
79 |
+
def to_dict(self) -> dict:
|
80 |
+
"""Convert to dictionary representation"""
|
81 |
+
return {
|
82 |
+
'audio': self.audio.to_dict(),
|
83 |
+
'asr_model': self.asr_model,
|
84 |
+
'target_language': self.target_language,
|
85 |
+
'source_language': self.source_language,
|
86 |
+
'voice': self.voice,
|
87 |
+
'speed': self.speed,
|
88 |
+
'requires_translation': self.requires_translation,
|
89 |
+
'additional_params': self.additional_params or {}
|
90 |
+
}
|
91 |
+
|
92 |
+
@classmethod
|
93 |
+
def from_dict(cls, data: dict) -> 'ProcessingRequestDto':
|
94 |
+
"""Create instance from dictionary"""
|
95 |
+
audio_data = data.get('audio', {})
|
96 |
+
if isinstance(audio_data, dict):
|
97 |
+
# Reconstruct AudioUploadDto if needed
|
98 |
+
audio = AudioUploadDto(
|
99 |
+
filename=audio_data['filename'],
|
100 |
+
content=audio_data.get('content', b''),
|
101 |
+
content_type=audio_data['content_type']
|
102 |
+
)
|
103 |
+
else:
|
104 |
+
audio = audio_data
|
105 |
+
|
106 |
+
return cls(
|
107 |
+
audio=audio,
|
108 |
+
asr_model=data['asr_model'],
|
109 |
+
target_language=data['target_language'],
|
110 |
+
voice=data['voice'],
|
111 |
+
speed=data.get('speed', 1.0),
|
112 |
+
source_language=data.get('source_language'),
|
113 |
+
additional_params=data.get('additional_params')
|
114 |
+
)
|
src/application/dtos/processing_result_dto.py
ADDED
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Processing Result Data Transfer Object"""
|
2 |
+
|
3 |
+
from dataclasses import dataclass
|
4 |
+
from typing import Optional, Dict, Any
|
5 |
+
from datetime import datetime
|
6 |
+
|
7 |
+
|
8 |
+
@dataclass
|
9 |
+
class ProcessingResultDto:
|
10 |
+
"""DTO for pipeline output data
|
11 |
+
|
12 |
+
Contains the results of processing audio through the
|
13 |
+
STT -> Translation -> TTS pipeline.
|
14 |
+
"""
|
15 |
+
success: bool
|
16 |
+
original_text: Optional[str] = None
|
17 |
+
translated_text: Optional[str] = None
|
18 |
+
audio_path: Optional[str] = None
|
19 |
+
processing_time: float = 0.0
|
20 |
+
error_message: Optional[str] = None
|
21 |
+
error_code: Optional[str] = None
|
22 |
+
metadata: Optional[Dict[str, Any]] = None
|
23 |
+
timestamp: Optional[datetime] = None
|
24 |
+
|
25 |
+
def __post_init__(self):
|
26 |
+
"""Validate and set defaults after initialization"""
|
27 |
+
self._validate()
|
28 |
+
if self.metadata is None:
|
29 |
+
self.metadata = {}
|
30 |
+
if self.timestamp is None:
|
31 |
+
self.timestamp = datetime.utcnow()
|
32 |
+
|
33 |
+
def _validate(self):
|
34 |
+
"""Validate processing result data"""
|
35 |
+
if not isinstance(self.success, bool):
|
36 |
+
raise ValueError("Success must be a boolean value")
|
37 |
+
|
38 |
+
if self.processing_time < 0:
|
39 |
+
raise ValueError("Processing time cannot be negative")
|
40 |
+
|
41 |
+
if self.success:
|
42 |
+
# For successful processing, we should have some output
|
43 |
+
if not self.original_text and not self.translated_text and not self.audio_path:
|
44 |
+
raise ValueError("Successful processing must have at least one output (text or audio)")
|
45 |
+
else:
|
46 |
+
# For failed processing, we should have an error message
|
47 |
+
if not self.error_message:
|
48 |
+
raise ValueError("Failed processing must include an error message")
|
49 |
+
|
50 |
+
# Validate error code format if provided
|
51 |
+
if self.error_code:
|
52 |
+
valid_error_codes = [
|
53 |
+
'STT_ERROR', 'TRANSLATION_ERROR', 'TTS_ERROR',
|
54 |
+
'AUDIO_FORMAT_ERROR', 'VALIDATION_ERROR', 'SYSTEM_ERROR'
|
55 |
+
]
|
56 |
+
if self.error_code not in valid_error_codes:
|
57 |
+
raise ValueError(f"Invalid error code: {self.error_code}. Valid codes: {valid_error_codes}")
|
58 |
+
|
59 |
+
# Validate metadata if provided
|
60 |
+
if self.metadata and not isinstance(self.metadata, dict):
|
61 |
+
raise ValueError("Metadata must be a dictionary")
|
62 |
+
|
63 |
+
@property
|
64 |
+
def has_text_output(self) -> bool:
|
65 |
+
"""Check if result has text output"""
|
66 |
+
return bool(self.original_text or self.translated_text)
|
67 |
+
|
68 |
+
@property
|
69 |
+
def has_audio_output(self) -> bool:
|
70 |
+
"""Check if result has audio output"""
|
71 |
+
return bool(self.audio_path)
|
72 |
+
|
73 |
+
@property
|
74 |
+
def is_complete(self) -> bool:
|
75 |
+
"""Check if processing is complete (success or failure with error)"""
|
76 |
+
return self.success or bool(self.error_message)
|
77 |
+
|
78 |
+
def add_metadata(self, key: str, value: Any) -> None:
|
79 |
+
"""Add metadata entry"""
|
80 |
+
if self.metadata is None:
|
81 |
+
self.metadata = {}
|
82 |
+
self.metadata[key] = value
|
83 |
+
|
84 |
+
def get_metadata(self, key: str, default: Any = None) -> Any:
|
85 |
+
"""Get metadata value"""
|
86 |
+
if self.metadata is None:
|
87 |
+
return default
|
88 |
+
return self.metadata.get(key, default)
|
89 |
+
|
90 |
+
def to_dict(self) -> dict:
|
91 |
+
"""Convert to dictionary representation"""
|
92 |
+
return {
|
93 |
+
'success': self.success,
|
94 |
+
'original_text': self.original_text,
|
95 |
+
'translated_text': self.translated_text,
|
96 |
+
'audio_path': self.audio_path,
|
97 |
+
'processing_time': self.processing_time,
|
98 |
+
'error_message': self.error_message,
|
99 |
+
'error_code': self.error_code,
|
100 |
+
'metadata': self.metadata or {},
|
101 |
+
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
102 |
+
'has_text_output': self.has_text_output,
|
103 |
+
'has_audio_output': self.has_audio_output,
|
104 |
+
'is_complete': self.is_complete
|
105 |
+
}
|
106 |
+
|
107 |
+
@classmethod
|
108 |
+
def success_result(cls, original_text: str = None, translated_text: str = None,
|
109 |
+
audio_path: str = None, processing_time: float = 0.0,
|
110 |
+
metadata: Dict[str, Any] = None) -> 'ProcessingResultDto':
|
111 |
+
"""Create a successful processing result"""
|
112 |
+
return cls(
|
113 |
+
success=True,
|
114 |
+
original_text=original_text,
|
115 |
+
translated_text=translated_text,
|
116 |
+
audio_path=audio_path,
|
117 |
+
processing_time=processing_time,
|
118 |
+
metadata=metadata
|
119 |
+
)
|
120 |
+
|
121 |
+
@classmethod
|
122 |
+
def error_result(cls, error_message: str, error_code: str = None,
|
123 |
+
processing_time: float = 0.0, metadata: Dict[str, Any] = None) -> 'ProcessingResultDto':
|
124 |
+
"""Create a failed processing result"""
|
125 |
+
return cls(
|
126 |
+
success=False,
|
127 |
+
error_message=error_message,
|
128 |
+
error_code=error_code,
|
129 |
+
processing_time=processing_time,
|
130 |
+
metadata=metadata
|
131 |
+
)
|
132 |
+
|
133 |
+
@classmethod
|
134 |
+
def from_dict(cls, data: dict) -> 'ProcessingResultDto':
|
135 |
+
"""Create instance from dictionary"""
|
136 |
+
timestamp = None
|
137 |
+
if data.get('timestamp'):
|
138 |
+
timestamp = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
|
139 |
+
|
140 |
+
return cls(
|
141 |
+
success=data['success'],
|
142 |
+
original_text=data.get('original_text'),
|
143 |
+
translated_text=data.get('translated_text'),
|
144 |
+
audio_path=data.get('audio_path'),
|
145 |
+
processing_time=data.get('processing_time', 0.0),
|
146 |
+
error_message=data.get('error_message'),
|
147 |
+
error_code=data.get('error_code'),
|
148 |
+
metadata=data.get('metadata'),
|
149 |
+
timestamp=timestamp
|
150 |
+
)
|
test_dtos.py
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python3
|
2 |
+
"""Test script for DTOs"""
|
3 |
+
|
4 |
+
import sys
|
5 |
+
import os
|
6 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
7 |
+
|
8 |
+
from application.dtos import AudioUploadDto, ProcessingRequestDto, ProcessingResultDto, ValidationError
|
9 |
+
|
10 |
+
def test_audio_upload_dto():
|
11 |
+
"""Test AudioUploadDto"""
|
12 |
+
print("Testing AudioUploadDto...")
|
13 |
+
|
14 |
+
# Test valid DTO
|
15 |
+
try:
|
16 |
+
audio_dto = AudioUploadDto(
|
17 |
+
filename="test.wav",
|
18 |
+
content=b"fake audio content" * 100, # Make it larger than 1KB
|
19 |
+
content_type="audio/wav"
|
20 |
+
)
|
21 |
+
print(f"β Valid AudioUploadDto created: {audio_dto.filename}")
|
22 |
+
print(f" Size: {audio_dto.size} bytes")
|
23 |
+
print(f" Extension: {audio_dto.file_extension}")
|
24 |
+
print(f" Base filename: {audio_dto.base_filename}")
|
25 |
+
except Exception as e:
|
26 |
+
print(f"β Failed to create valid AudioUploadDto: {e}")
|
27 |
+
|
28 |
+
# Test invalid extension
|
29 |
+
try:
|
30 |
+
AudioUploadDto(
|
31 |
+
filename="test.txt",
|
32 |
+
content=b"fake content" * 100,
|
33 |
+
content_type="text/plain"
|
34 |
+
)
|
35 |
+
print("β Should have failed with invalid extension")
|
36 |
+
except ValueError as e:
|
37 |
+
print(f"β Correctly rejected invalid extension: {e}")
|
38 |
+
|
39 |
+
# Test empty content
|
40 |
+
try:
|
41 |
+
AudioUploadDto(
|
42 |
+
filename="test.wav",
|
43 |
+
content=b"",
|
44 |
+
content_type="audio/wav"
|
45 |
+
)
|
46 |
+
print("β Should have failed with empty content")
|
47 |
+
except ValueError as e:
|
48 |
+
print(f"β Correctly rejected empty content: {e}")
|
49 |
+
|
50 |
+
def test_processing_request_dto():
|
51 |
+
"""Test ProcessingRequestDto"""
|
52 |
+
print("\nTesting ProcessingRequestDto...")
|
53 |
+
|
54 |
+
# Create valid audio DTO first
|
55 |
+
audio_dto = AudioUploadDto(
|
56 |
+
filename="test.wav",
|
57 |
+
content=b"fake audio content" * 100,
|
58 |
+
content_type="audio/wav"
|
59 |
+
)
|
60 |
+
|
61 |
+
# Test valid DTO
|
62 |
+
try:
|
63 |
+
request_dto = ProcessingRequestDto(
|
64 |
+
audio=audio_dto,
|
65 |
+
asr_model="whisper-small",
|
66 |
+
target_language="es",
|
67 |
+
voice="kokoro",
|
68 |
+
speed=1.2,
|
69 |
+
source_language="en"
|
70 |
+
)
|
71 |
+
print(f"β Valid ProcessingRequestDto created")
|
72 |
+
print(f" ASR Model: {request_dto.asr_model}")
|
73 |
+
print(f" Target Language: {request_dto.target_language}")
|
74 |
+
print(f" Requires Translation: {request_dto.requires_translation}")
|
75 |
+
print(f" Dict representation keys: {list(request_dto.to_dict().keys())}")
|
76 |
+
except Exception as e:
|
77 |
+
print(f"β Failed to create valid ProcessingRequestDto: {e}")
|
78 |
+
|
79 |
+
# Test invalid speed
|
80 |
+
try:
|
81 |
+
ProcessingRequestDto(
|
82 |
+
audio=audio_dto,
|
83 |
+
asr_model="whisper-small",
|
84 |
+
target_language="es",
|
85 |
+
voice="kokoro",
|
86 |
+
speed=3.0 # Invalid speed
|
87 |
+
)
|
88 |
+
print("β Should have failed with invalid speed")
|
89 |
+
except ValueError as e:
|
90 |
+
print(f"β Correctly rejected invalid speed: {e}")
|
91 |
+
|
92 |
+
# Test invalid ASR model
|
93 |
+
try:
|
94 |
+
ProcessingRequestDto(
|
95 |
+
audio=audio_dto,
|
96 |
+
asr_model="invalid-model",
|
97 |
+
target_language="es",
|
98 |
+
voice="kokoro"
|
99 |
+
)
|
100 |
+
print("β Should have failed with invalid ASR model")
|
101 |
+
except ValueError as e:
|
102 |
+
print(f"β Correctly rejected invalid ASR model: {e}")
|
103 |
+
|
104 |
+
def test_processing_result_dto():
|
105 |
+
"""Test ProcessingResultDto"""
|
106 |
+
print("\nTesting ProcessingResultDto...")
|
107 |
+
|
108 |
+
# Test successful result
|
109 |
+
try:
|
110 |
+
success_result = ProcessingResultDto.success_result(
|
111 |
+
original_text="Hello world",
|
112 |
+
translated_text="Hola mundo",
|
113 |
+
audio_path="/tmp/output.wav",
|
114 |
+
processing_time=2.5
|
115 |
+
)
|
116 |
+
print(f"β Valid success result created")
|
117 |
+
print(f" Success: {success_result.success}")
|
118 |
+
print(f" Has text output: {success_result.has_text_output}")
|
119 |
+
print(f" Has audio output: {success_result.has_audio_output}")
|
120 |
+
print(f" Is complete: {success_result.is_complete}")
|
121 |
+
except Exception as e:
|
122 |
+
print(f"β Failed to create success result: {e}")
|
123 |
+
|
124 |
+
# Test error result
|
125 |
+
try:
|
126 |
+
error_result = ProcessingResultDto.error_result(
|
127 |
+
error_message="TTS generation failed",
|
128 |
+
error_code="TTS_ERROR",
|
129 |
+
processing_time=1.0
|
130 |
+
)
|
131 |
+
print(f"β Valid error result created")
|
132 |
+
print(f" Success: {error_result.success}")
|
133 |
+
print(f" Error message: {error_result.error_message}")
|
134 |
+
print(f" Error code: {error_result.error_code}")
|
135 |
+
except Exception as e:
|
136 |
+
print(f"β Failed to create error result: {e}")
|
137 |
+
|
138 |
+
# Test invalid success result (no outputs)
|
139 |
+
try:
|
140 |
+
ProcessingResultDto(success=True) # No outputs provided
|
141 |
+
print("β Should have failed with no outputs for success")
|
142 |
+
except ValueError as e:
|
143 |
+
print(f"β Correctly rejected success result with no outputs: {e}")
|
144 |
+
|
145 |
+
# Test invalid error result (no error message)
|
146 |
+
try:
|
147 |
+
ProcessingResultDto(success=False) # No error message
|
148 |
+
print("β Should have failed with no error message for failure")
|
149 |
+
except ValueError as e:
|
150 |
+
print(f"β Correctly rejected error result with no message: {e}")
|
151 |
+
|
152 |
+
def test_dto_serialization():
|
153 |
+
"""Test DTO serialization/deserialization"""
|
154 |
+
print("\nTesting DTO serialization...")
|
155 |
+
|
156 |
+
# Test ProcessingResultDto serialization
|
157 |
+
try:
|
158 |
+
original_result = ProcessingResultDto.success_result(
|
159 |
+
original_text="Test text",
|
160 |
+
translated_text="Texto de prueba",
|
161 |
+
audio_path="/tmp/test.wav",
|
162 |
+
processing_time=1.5
|
163 |
+
)
|
164 |
+
|
165 |
+
# Convert to dict and back
|
166 |
+
result_dict = original_result.to_dict()
|
167 |
+
restored_result = ProcessingResultDto.from_dict(result_dict)
|
168 |
+
|
169 |
+
print(f"β ProcessingResultDto serialization successful")
|
170 |
+
print(f" Original success: {original_result.success}")
|
171 |
+
print(f" Restored success: {restored_result.success}")
|
172 |
+
print(f" Original text matches: {original_result.original_text == restored_result.original_text}")
|
173 |
+
|
174 |
+
except Exception as e:
|
175 |
+
print(f"β ProcessingResultDto serialization failed: {e}")
|
176 |
+
|
177 |
+
if __name__ == "__main__":
|
178 |
+
test_audio_upload_dto()
|
179 |
+
test_processing_request_dto()
|
180 |
+
test_processing_result_dto()
|
181 |
+
test_dto_serialization()
|
182 |
+
print("\nDTO testing completed!")
|