From 5d2ba407f469dd7098e3d1670a8c5c42432c9ea2 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:54:05 +0200 Subject: [PATCH] cleaned version --- README.md | 3 +- app/clients/deepgram_client.py | 10 +-- app/clients/openai_client.py | 18 ++--- app/errors.py | 33 +++++++- app/routers/transcribe.py | 141 ++++++++++----------------------- documentation/overview.md | 5 +- documentation/transcribe.md | 74 +++++------------ 7 files changed, 104 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index cd213f0..4edcbd3 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Twilio bylo vynecháno — audio se posílá přímo jako proměnná (upload). - `GET /health` — health check - `GET /version` — verze + root_path -- `POST /transcribe/dual` — Deepgram + Whisper (dva nezávislé texty) -- `POST /transcribe/combined` — dual + sloučený `merged` +- `POST /dual-with-merge` — Deepgram + OpenAI přepis + AI sloučení, vrací `{gpt, deepgram, merge}` Swagger: `/docs`. diff --git a/app/clients/deepgram_client.py b/app/clients/deepgram_client.py index 80bfa19..5ec4158 100644 --- a/app/clients/deepgram_client.py +++ b/app/clients/deepgram_client.py @@ -5,7 +5,7 @@ Nepoužíváme těžké SDK — voláme REST endpoint přímo, aby nebyl závisl import httpx from ..config import DEEPGRAM_BASE_URL, UPSTREAM_TIMEOUT_SECONDS -from ..errors import UpstreamError +from ..errors import UpstreamError, raise_for_upstream from ..logging_config import get_logger log = get_logger("audio-transcription.deepgram") @@ -43,12 +43,8 @@ async def transcribe( if resp.status_code >= 400: # Deepgram vrací chybu v JSON; nikdy nelogujeme klíč (ten je jen v hlavičce). - snippet = resp.text[:500] - log.warning("Deepgram returned %s: %s", resp.status_code, snippet) - raise UpstreamError( - f"Deepgram vrátil chybu {resp.status_code}.", - detail=snippet, - ) + log.warning("Deepgram returned %s: %s", resp.status_code, resp.text[:500]) + raise_for_upstream("Deepgram", resp.status_code, resp.text) data = resp.json() try: diff --git a/app/clients/openai_client.py b/app/clients/openai_client.py index 495224c..37d9e42 100644 --- a/app/clients/openai_client.py +++ b/app/clients/openai_client.py @@ -2,7 +2,7 @@ import httpx from ..config import OPENAI_BASE_URL, UPSTREAM_TIMEOUT_SECONDS -from ..errors import UpstreamError +from ..errors import UpstreamError, raise_for_upstream from ..logging_config import get_logger log = get_logger("audio-transcription.openai") @@ -33,12 +33,8 @@ async def transcribe( raise UpstreamError(f"OpenAI Whisper request se nezdařil: {exc}") from exc if resp.status_code >= 400: - snippet = resp.text[:500] - log.warning("OpenAI transcription returned %s: %s", resp.status_code, snippet) - raise UpstreamError( - f"OpenAI Whisper vrátil chybu {resp.status_code}.", - detail=snippet, - ) + log.warning("OpenAI transcription returned %s: %s", resp.status_code, resp.text[:500]) + raise_for_upstream("OpenAI Whisper", resp.status_code, resp.text) data = resp.json() return data.get("text", "") or "" @@ -72,12 +68,8 @@ async def merge_transcripts( raise UpstreamError(f"OpenAI chat (merge) request se nezdařil: {exc}") from exc if resp.status_code >= 400: - snippet = resp.text[:500] - log.warning("OpenAI chat returned %s: %s", resp.status_code, snippet) - raise UpstreamError( - f"OpenAI chat (merge) vrátil chybu {resp.status_code}.", - detail=snippet, - ) + log.warning("OpenAI chat returned %s: %s", resp.status_code, resp.text[:500]) + raise_for_upstream("OpenAI chat (merge)", resp.status_code, resp.text) data = resp.json() try: diff --git a/app/errors.py b/app/errors.py index 10d6611..f8b82d3 100644 --- a/app/errors.py +++ b/app/errors.py @@ -35,12 +35,43 @@ class BadRequestError(TranscriptionError): status_code = 400 +class RateLimitError(TranscriptionError): + """Upstream rate limit (HTTP 429).""" + + status_code = 429 + + class UpstreamError(TranscriptionError): - """Chyba při volání upstream API (Deepgram / OpenAI / stažení audia).""" + """Chyba při volání upstream API (Deepgram / OpenAI).""" status_code = 502 +def raise_for_upstream(upstream: str, status: int, body: str) -> None: + """Zmapuje HTTP status z upstreamu (Deepgram/OpenAI) na správnou chybu služby. + + - 400/415/422 → 400 (špatný / nepodporovaný audio soubor = chyba klienta) + - 401/403 → 401 (upstream odmítl API klíč volajícího) + - 429 → 429 (rate limit) + - jinak → 502 (výpadek / neočekávaná chyba upstreamu) + """ + snippet = (body or "")[:500] + if status in (400, 415, 422): + raise BadRequestError( + f"{upstream} odmítl audio (HTTP {status}) — pravděpodobně nepodporovaný " + f"formát nebo poškozený soubor.", + detail=snippet, + ) + if status in (401, 403): + raise CredentialsError( + f"{upstream} odmítl API klíč (HTTP {status}).", + detail=snippet, + ) + if status == 429: + raise RateLimitError(f"{upstream} rate limit (HTTP 429).", detail=snippet) + raise UpstreamError(f"{upstream} vrátil chybu {status}.", detail=snippet) + + def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(TranscriptionError) async def _handle_transcription_error(request: Request, exc: TranscriptionError): diff --git a/app/routers/transcribe.py b/app/routers/transcribe.py index b683290..93ec450 100644 --- a/app/routers/transcribe.py +++ b/app/routers/transcribe.py @@ -1,11 +1,12 @@ -"""Endpointy pro přepis audia. +"""Endpoint pro přepis audia. -- POST /transcribe/dual → paralelní přepis Deepgram + OpenAI Whisper (dva texty) -- POST /transcribe/combined → dual + sloučení do jednoho co nejlepšího přepisu (merge) +POST /transcribe — udělá vše najednou: audio přepíše paralelně Deepgramem i OpenAI +(whisper-1) a oba texty sloučí přes OpenAI Chat podle `combine_prompt` do jednoho +co nejlepšího přepisu. Audio přichází jako proměnná (upload `file` ve form-data). Všechny ostatní parametry jsou v POST těle (form fields). API klíče jsou v X- hlavičkách (viz credentials.py). -Výstup je vždy JSON. +Výstup je vždy JSON: {"gpt": ..., "deepgram": ..., "merge": ...}. """ import asyncio @@ -29,17 +30,10 @@ log = get_logger("audio-transcription.transcribe") router = APIRouter(tags=["transcribe"]) -class DualResult(BaseModel): - text1: str = Field(..., description="Přepis z Deepgramu.") - text2: str = Field(..., description="Přepis z OpenAI Whisper.") - deepgram_model: str - whisper_model: str - language: str - - -class CombinedResult(DualResult): - merged: str = Field(..., description="Sloučený, co nejpřesnější výsledný přepis.") - chat_model: str +class TranscribeResult(BaseModel): + gpt: str = Field(..., description="Přepis z OpenAI (model whisper-1).") + deepgram: str = Field(..., description="Přepis z Deepgramu.") + merge: str = Field(..., description="Sloučený, co nejpřesnější výsledný přepis.") async def _read_upload(file: UploadFile) -> tuple[bytes, str, str]: @@ -52,22 +46,36 @@ async def _read_upload(file: UploadFile) -> tuple[bytes, str, str]: return audio, filename, content_type -async def _transcribe_dual( - creds: Credentials, - audio: bytes, - filename: str, - content_type: str, - deepgram_model: str, - whisper_model: str, - language: str, - diarize: bool, - smart_format: bool, -) -> tuple[str, str]: - """Spustí oba přepisy paralelně; vrátí (deepgram_text, whisper_text).""" +@router.post( + "/dual-with-merge", + response_model=TranscribeResult, + summary="Přepis audia (Deepgram + OpenAI) a AI sloučení — vše najednou", + description="Přijme audio soubor, vytvoří paralelně přepis z Deepgramu i z OpenAI " + "(whisper-1) a poté je sloučí přes OpenAI Chat podle `combine_prompt` do jediného, " + "co nejpřesnějšího českého přepisu. Vrací JSON `{gpt, deepgram, merge}`.", +) +async def transcribe( + file: UploadFile = File(..., description="Audio soubor k přepisu (proměnná)."), + combine_prompt: str = Form( + DEFAULT_COMBINE_PROMPT, + description="System prompt pro sloučení. Nezadáš-li, použije se výchozí " + "'Czech Transcript Merger'.", + ), + deepgram_model: str = Form(DEFAULT_DEEPGRAM_MODEL, description="Deepgram model."), + whisper_model: str = Form(DEFAULT_WHISPER_MODEL, description="OpenAI přepisový model."), + chat_model: str = Form(DEFAULT_CHAT_MODEL, description="OpenAI chat model pro sloučení."), + language: str = Form(DEFAULT_LANGUAGE, description="Jazyk audia (ISO kód, např. cs)."), + diarize: bool = Form(True, description="Deepgram diarizace (rozlišení mluvčích)."), + smart_format: bool = Form(True, description="Deepgram smart formatting."), + creds: Credentials = Depends(get_credentials), +) -> TranscribeResult: deepgram_key = creds.require_deepgram() openai_key = creds.require_openai() - text1, text2 = await asyncio.gather( + audio, filename, content_type = await _read_upload(file) + + # Oba přepisy paralelně. + text_deepgram, text_gpt = await asyncio.gather( deepgram_client.transcribe( api_key=deepgram_key, audio=audio, @@ -86,83 +94,14 @@ async def _transcribe_dual( language=language, ), ) - return text1, text2 - - -@router.post( - "/transcribe/dual", - response_model=DualResult, - summary="Paralelní přepis Deepgram + OpenAI Whisper", - description="Přijme audio soubor a vrátí dva nezávislé přepisy: " - "`text1` z Deepgramu a `text2` z OpenAI Whisper.", -) -async def transcribe_dual( - file: UploadFile = File(..., description="Audio soubor k přepisu (proměnná)."), - deepgram_model: str = Form(DEFAULT_DEEPGRAM_MODEL, description="Deepgram model."), - whisper_model: str = Form(DEFAULT_WHISPER_MODEL, description="OpenAI přepisový model."), - language: str = Form(DEFAULT_LANGUAGE, description="Jazyk audia (ISO kód, např. cs)."), - diarize: bool = Form(True, description="Deepgram diarizace (rozlišení mluvčích)."), - smart_format: bool = Form(True, description="Deepgram smart formatting."), - creds: Credentials = Depends(get_credentials), -) -> DualResult: - audio, filename, content_type = await _read_upload(file) - text1, text2 = await _transcribe_dual( - creds, audio, filename, content_type, - deepgram_model, whisper_model, language, diarize, smart_format, - ) - return DualResult( - text1=text1, - text2=text2, - deepgram_model=deepgram_model, - whisper_model=whisper_model, - language=language, - ) - - -@router.post( - "/transcribe/combined", - response_model=CombinedResult, - summary="Dual přepis + AI sloučení do nejlepšího přepisu", - description="Přijme audio soubor, vytvoří přepis z Deepgramu i Whisperu a poté je " - "sloučí přes OpenAI Chat podle `combine_prompt` do jediného, co nejpřesnějšího " - "českého přepisu. Vrací `text1`, `text2` i výsledný `merged`.", -) -async def transcribe_combined( - file: UploadFile = File(..., description="Audio soubor k přepisu (proměnná)."), - combine_prompt: str = Form( - DEFAULT_COMBINE_PROMPT, - description="System prompt pro sloučení. Nezadáš-li, použije se výchozí " - "'Czech Transcript Merger'.", - ), - deepgram_model: str = Form(DEFAULT_DEEPGRAM_MODEL, description="Deepgram model."), - whisper_model: str = Form(DEFAULT_WHISPER_MODEL, description="OpenAI přepisový model."), - chat_model: str = Form(DEFAULT_CHAT_MODEL, description="OpenAI chat model pro sloučení."), - language: str = Form(DEFAULT_LANGUAGE, description="Jazyk audia (ISO kód, např. cs)."), - diarize: bool = Form(True, description="Deepgram diarizace (rozlišení mluvčích)."), - smart_format: bool = Form(True, description="Deepgram smart formatting."), - creds: Credentials = Depends(get_credentials), -) -> CombinedResult: - audio, filename, content_type = await _read_upload(file) - text1, text2 = await _transcribe_dual( - creds, audio, filename, content_type, - deepgram_model, whisper_model, language, diarize, smart_format, - ) prompt = combine_prompt.strip() if combine_prompt and combine_prompt.strip() else DEFAULT_COMBINE_PROMPT merged = await openai_client.merge_transcripts( - api_key=creds.require_openai(), + api_key=openai_key, model=chat_model, system_prompt=prompt, - text_deepgram=text1, - text_whisper=text2, + text_deepgram=text_deepgram, + text_whisper=text_gpt, ) - return CombinedResult( - text1=text1, - text2=text2, - merged=merged, - deepgram_model=deepgram_model, - whisper_model=whisper_model, - chat_model=chat_model, - language=language, - ) + return TranscribeResult(gpt=text_gpt, deepgram=text_deepgram, merge=merged) diff --git a/documentation/overview.md b/documentation/overview.md index bb712ad..74f9f46 100644 --- a/documentation/overview.md +++ b/documentation/overview.md @@ -22,7 +22,7 @@ app/ openai_client.py # OpenAI Whisper + Chat Completions (httpx) routers/ meta.py # /health, /version - transcribe.py # /transcribe/dual, /transcribe/combined + transcribe.py # /transcribe (vše najednou) ``` Žádné SDK — upstream API se volají přímo přes `httpx`, aby služba nebyla závislá na @@ -39,8 +39,7 @@ OpenAPI `servers` i Swagger „Try it out“ míří na správný prefix. |--------|-------|-------| | GET | `/health` | Health check (200) | | GET | `/version` | Verze + root_path | -| POST | `/transcribe/dual` | Deepgram + Whisper (dva texty) | -| POST | `/transcribe/combined` | dual + sloučený `merged` | +| POST | `/dual-with-merge` | Deepgram + OpenAI přepis + AI sloučení (vše najednou) | Detaily viz [transcribe.md](transcribe.md). diff --git a/documentation/transcribe.md b/documentation/transcribe.md index 4d80ef5..6245772 100644 --- a/documentation/transcribe.md +++ b/documentation/transcribe.md @@ -1,6 +1,7 @@ -# Endpointy přepisu +# Endpoint přepisu -Audio se posílá jako proměnná `file` (multipart/form-data). Všechny ostatní parametry +Jediná metoda, která udělá vše najednou: přepis Deepgramem + OpenAI (whisper-1) a jejich +AI sloučení. Audio se posílá jako proměnná `file` (multipart/form-data), ostatní parametry jsou form fields v POST. API klíče jsou v X- hlavičkách. Výstup je vždy JSON. ## Hlavičky (secrets) @@ -8,90 +9,57 @@ jsou form fields v POST. API klíče jsou v X- hlavičkách. Výstup je vždy JS | Hlavička | Nutná | Popis | |----------|-------|-------| | `X-Deepgram-Api-Key` | ano | Deepgram API klíč | -| `X-OpenAI-Api-Key` | ano | OpenAI API klíč (Whisper + slučovací chat) | +| `X-OpenAI-Api-Key` | ano | OpenAI API klíč (whisper-1 přepis + slučovací chat) | -## POST /transcribe/dual - -Paralelní přepis dvěma engine. Vrací dva nezávislé texty. +## POST /dual-with-merge ### Form parametry | Pole | Typ | Default | Popis | |------|-----|---------|-------| | `file` | soubor | — | Audio k přepisu (povinné) | +| `combine_prompt` | text | výchozí „Czech Transcript Merger“ | System prompt pro sloučení | | `deepgram_model` | text | `nova-2` | Deepgram model | | `whisper_model` | text | `whisper-1` | OpenAI přepisový model | +| `chat_model` | text | `gpt-4o` | OpenAI chat model pro sloučení | | `language` | text | `cs` | Jazyk (ISO kód) | | `diarize` | bool | `true` | Deepgram diarizace | | `smart_format` | bool | `true` | Deepgram smart formatting | -### Odpověď - -```json -{ - "text1": "přepis z Deepgramu", - "text2": "přepis z OpenAI Whisper", - "deepgram_model": "nova-2", - "whisper_model": "whisper-1", - "language": "cs" -} -``` - -### Příklad (curl) - -```bash -curl -X POST https://services.csbot.cz/apps/audio-transcription/transcribe/dual \ - -H "X-Deepgram-Api-Key: " \ - -H "X-OpenAI-Api-Key: " \ - -F "file=@nahravka.mp3" \ - -F "language=cs" -``` - -## POST /transcribe/combined - -Jako `dual`, navíc oba přepisy sloučí přes OpenAI Chat do jednoho výsledku (`merged`). - -### Form parametry - -Vše z `dual`, plus: - -| Pole | Typ | Default | Popis | -|------|-----|---------|-------| -| `combine_prompt` | text | výchozí „Czech Transcript Merger“ | System prompt pro sloučení | -| `chat_model` | text | `gpt-4o` | OpenAI chat model pro sloučení | - Nezadá-li se `combine_prompt`, použije se výchozí prompt z `app/prompts.py`. ### Odpověď ```json { - "text1": "přepis z Deepgramu", - "text2": "přepis z OpenAI Whisper", - "merged": "sloučený, co nejpřesnější výsledný přepis", - "deepgram_model": "nova-2", - "whisper_model": "whisper-1", - "chat_model": "gpt-4o", - "language": "cs" + "gpt": "přepis z OpenAI (whisper-1)", + "deepgram": "přepis z Deepgramu", + "merge": "sloučený, co nejpřesnější výsledný přepis" } ``` ### Příklad (curl) ```bash -curl -X POST https://services.csbot.cz/apps/audio-transcription/transcribe/combined \ +curl -X POST https://services.csbot.cz/apps/audio-transcription/dual-with-merge \ -H "X-Deepgram-Api-Key: " \ -H "X-OpenAI-Api-Key: " \ - -F "file=@nahravka.mp3" + -F "file=@nahravka.mp3" \ + -F "language=cs" ``` ## Chybové stavy +Chybové odpovědi jsou JSON: `{"error": "", "message": "...", "detail": ""}`. + | HTTP | Kdy | |------|-----| -| 400 | Chybí / prázdný audio soubor | -| 401 | Chybí `X-Deepgram-Api-Key` nebo `X-OpenAI-Api-Key` | -| 502 | Chyba upstream API (Deepgram / OpenAI) | +| 400 | Chybí / prázdný audio soubor; **nepodporovaný nebo poškozený audio formát** (Deepgram/OpenAI vrátí 400/415/422) | +| 401 | Chybí `X-Deepgram-Api-Key` / `X-OpenAI-Api-Key`, **nebo upstream odmítl API klíč** (Deepgram/OpenAI 401/403) | +| 429 | Rate limit na Deepgram nebo OpenAI | +| 502 | Výpadek / neočekávaná chyba upstreamu (5xx, síťová chyba, timeout, neočekávaná struktura odpovědi) | | 500 | Neočekávaná chyba serveru | +Mapování dělá `raise_for_upstream` v `app/errors.py`. Špatný typ souboru se pozná až +z odpovědi Deepgramu/OpenAI (bytes validují oni) a mapuje se na **400**, ne na 502. Všechny chyby se logují (bez secrets), žádné tiché selhání.