You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
7.3 KiB
160 lines
7.3 KiB
from __future__ import annotations |
|
|
|
import random |
|
import time |
|
from dataclasses import dataclass |
|
from pathlib import Path |
|
from typing import Callable |
|
|
|
import openai |
|
|
|
from voice_transcriptor.models import AppSettings |
|
from voice_transcriptor.services.job_manifest import ChunkStatus, JobManifestRepository |
|
from voice_transcriptor.services.preprocessing import CancellationToken, PreprocessingCancelled |
|
|
|
|
|
BRAZILIAN_PROMPT = ( |
|
"Conversa em português brasileiro. Preserve a língua falada; não traduza. " |
|
"Preserve ortografia e pontuação brasileiras, números, nomes próprios, " |
|
"termos técnicos e siglas com máxima fidelidade." |
|
) |
|
|
|
|
|
class TranscriptionError(RuntimeError): pass |
|
class PermanentTranscriptionError(TranscriptionError): pass |
|
class RetryExhaustedError(TranscriptionError): pass |
|
|
|
|
|
@dataclass(frozen=True, slots=True) |
|
class RetryPolicy: |
|
max_attempts: int = 5 |
|
initial_delay_seconds: float = 1.0 |
|
max_delay_seconds: float = 30.0 |
|
jitter_ratio: float = 0.2 |
|
|
|
|
|
@dataclass(frozen=True, slots=True) |
|
class TranscriptionProgress: |
|
completed: int |
|
total: int |
|
current_chunk: int | None |
|
elapsed_seconds: float |
|
phase: str |
|
message: str |
|
api_error: str | None = None |
|
|
|
|
|
def normalize_language(value: str) -> str: |
|
normalized = value.strip().replace("_", "-") |
|
return normalized.split("-", 1)[0].lower() |
|
|
|
|
|
def build_prompt(context: str) -> str: |
|
extra = context.strip() |
|
return f"{BRAZILIAN_PROMPT}\n\n{extra}" if extra else BRAZILIAN_PROMPT |
|
|
|
|
|
class OpenAITranscriptionClient: |
|
def __init__(self, client) -> None: self.client = client |
|
|
|
def transcribe(self, path: Path, model: str, language: str, prompt: str) -> str: |
|
with path.open("rb") as audio_file: |
|
response = self.client.audio.transcriptions.create( |
|
file=audio_file, |
|
model=model, |
|
language=normalize_language(language), |
|
prompt=prompt, |
|
response_format="json", |
|
) |
|
text = getattr(response, "text", None) |
|
if not isinstance(text, str): |
|
raise PermanentTranscriptionError("The transcription API returned no text.") |
|
return text |
|
|
|
|
|
class TranscriptionService: |
|
def __init__( |
|
self, |
|
client: OpenAITranscriptionClient, |
|
manifests: JobManifestRepository, |
|
retry_policy: RetryPolicy | None = None, |
|
sleep: Callable[[float], None] = time.sleep, |
|
monotonic: Callable[[], float] = time.monotonic, |
|
random_value: Callable[[], float] = random.random, |
|
) -> None: |
|
self.client = client; self.manifests = manifests |
|
self.retry_policy = retry_policy or RetryPolicy() |
|
self.sleep = sleep; self.monotonic = monotonic; self.random_value = random_value |
|
|
|
def run( |
|
self, |
|
manifest_path: Path, |
|
settings: AppSettings, |
|
token: CancellationToken | None = None, |
|
progress: Callable[[TranscriptionProgress], None] | None = None, |
|
) -> Path: |
|
token = token or CancellationToken(); started = self.monotonic() |
|
try: |
|
token.raise_if_cancelled() |
|
manifest = self.manifests.recover_for_resume(manifest_path) |
|
total = manifest["total_chunks"] |
|
for item in manifest["chunks"]: |
|
if item["status"] == ChunkStatus.COMPLETED: continue |
|
token.raise_if_cancelled(); index = item["index"] |
|
text = self._transcribe_with_retry(manifest_path, item, settings, token, progress, started, total) |
|
self.manifests.mark_completed(manifest_path, index, text) |
|
self.manifests.assemble_transcript(manifest_path) |
|
completed = self.manifests.load(manifest_path)["completed_chunks"] |
|
self._emit(progress, completed, total, index + 1, started, "transcribing", f"Completed chunk {index + 1} of {total}") |
|
self.manifests.mark_job_state(manifest_path, "completed") |
|
return self.manifests.assemble_transcript(manifest_path) |
|
except PreprocessingCancelled: |
|
self.manifests.mark_job_state(manifest_path, "cancelled") |
|
raise |
|
|
|
def _transcribe_with_retry(self, manifest_path: Path, item: dict, settings: AppSettings, token: CancellationToken, progress, started: float, total: int) -> str: |
|
policy = self.retry_policy; index = item["index"] |
|
for attempt in range(1, policy.max_attempts + 1): |
|
token.raise_if_cancelled(); self.manifests.mark_processing(manifest_path, index) |
|
self._emit(progress, self.manifests.load(manifest_path)["completed_chunks"], total, index + 1, started, "transcribing", f"Transcribing chunk {index + 1} of {total}") |
|
try: |
|
return self.client.transcribe(manifest_path.parent / item["path"], settings.model, settings.language, build_prompt(settings.context_vocabulary)) |
|
except PreprocessingCancelled: raise |
|
except Exception as exc: |
|
status = getattr(exc, "status_code", None) |
|
retryable = self._retryable(exc, status) |
|
safe = self._safe_error(status, retryable) |
|
if not retryable: |
|
self.manifests.mark_failed(manifest_path, index, safe) |
|
raise PermanentTranscriptionError(safe) from None |
|
if attempt >= policy.max_attempts: |
|
self.manifests.mark_failed(manifest_path, index, safe) |
|
raise RetryExhaustedError(f"{safe} Retry limit reached.") from None |
|
delay = self._delay(exc, attempt) |
|
self._emit(progress, self.manifests.load(manifest_path)["completed_chunks"], total, index + 1, started, "retrying", f"API temporarily unavailable; retrying in {delay:g} seconds.", safe) |
|
token.raise_if_cancelled(); self.sleep(delay); token.raise_if_cancelled() |
|
raise AssertionError("unreachable") |
|
|
|
def _delay(self, exc: Exception, attempt: int) -> float: |
|
headers = getattr(getattr(exc, "response", None), "headers", {}) or {} |
|
retry_after = headers.get("retry-after") or headers.get("Retry-After") |
|
try: server_delay = float(retry_after) |
|
except (TypeError, ValueError): server_delay = 0 |
|
base = max(server_delay, self.retry_policy.initial_delay_seconds * (2 ** (attempt - 1))) |
|
base = min(base, self.retry_policy.max_delay_seconds) |
|
jitter = base * self.retry_policy.jitter_ratio * ((self.random_value() * 2) - 1) |
|
return max(0, min(self.retry_policy.max_delay_seconds, base + jitter)) |
|
|
|
@staticmethod |
|
def _retryable(exc: Exception, status: int | None) -> bool: |
|
transient_types = (openai.RateLimitError, openai.APIConnectionError, openai.APITimeoutError) |
|
return isinstance(exc, transient_types) or status in (408, 409, 429) or (isinstance(status, int) and status >= 500) |
|
|
|
@staticmethod |
|
def _safe_error(status: int | None, retryable: bool) -> str: |
|
kind = "transient" if retryable else "permanent" |
|
suffix = f" (HTTP {status})" if isinstance(status, int) else "" |
|
return f"OpenAI API {kind} error{suffix}." |
|
|
|
def _emit(self, callback, completed: int, total: int, current: int | None, started: float, phase: str, message: str, api_error: str | None = None) -> None: |
|
if callback: callback(TranscriptionProgress(completed, total, current, max(0, self.monotonic() - started), phase, message, api_error))
|
|
|