diff --git a/README.md b/README.md index dbda650..cd213f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ # audio-transcription -Generated by AppFactory. +Stateless FastAPI služba (AppFactory) pro přepis audia. Přijme audio soubor + parametry +v POST a vrátí JSON. Přepis běží paralelně dvěma engine — **Deepgram** a **OpenAI Whisper** +— a volitelně se oba texty sloučí přes OpenAI Chat do jednoho co nejpřesnějšího přepisu. + +Port původní .NET implementace `CallCenterController` (dual/combined přepis); napojení na +Twilio bylo vynecháno — audio se posílá přímo jako proměnná (upload). + +## Endpointy + +- `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` + +Swagger: `/docs`. + +## Přihlašovací údaje (hlavičky) + +- `X-Deepgram-Api-Key` +- `X-OpenAI-Api-Key` + +## Lokální běh + +```bash +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +Podrobná dokumentace: [documentation/overview.md](documentation/overview.md), +[documentation/transcribe.md](documentation/transcribe.md). diff --git a/app/clients/__init__.py b/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clients/deepgram_client.py b/app/clients/deepgram_client.py new file mode 100644 index 0000000..80bfa19 --- /dev/null +++ b/app/clients/deepgram_client.py @@ -0,0 +1,60 @@ +"""Klient pro Deepgram pre-recorded přepis (REST, přes httpx). + +Nepoužíváme těžké SDK — voláme REST endpoint přímo, aby nebyl závislý na verzích. +""" +import httpx + +from ..config import DEEPGRAM_BASE_URL, UPSTREAM_TIMEOUT_SECONDS +from ..errors import UpstreamError +from ..logging_config import get_logger + +log = get_logger("audio-transcription.deepgram") + + +async def transcribe( + *, + api_key: str, + audio: bytes, + content_type: str, + model: str, + language: str, + diarize: bool, + smart_format: bool, +) -> str: + """Přepíše audio Deepgramem a vrátí prostý transkript (channels[0].alternatives[0].transcript).""" + params = { + "model": model, + "language": language, + "diarize": "true" if diarize else "false", + "smart_format": "true" if smart_format else "false", + } + headers = { + "Authorization": f"Token {api_key}", + "Content-Type": content_type or "application/octet-stream", + } + url = f"{DEEPGRAM_BASE_URL}/listen" + + try: + async with httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS) as client: + resp = await client.post(url, params=params, headers=headers, content=audio) + except httpx.HTTPError as exc: + log.error("Deepgram request failed: %s", exc) + raise UpstreamError(f"Deepgram request se nezdařil: {exc}") from exc + + 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, + ) + + data = resp.json() + try: + channels = data["results"]["channels"] + alternative = channels[0]["alternatives"][0] + return alternative.get("transcript", "") or "" + except (KeyError, IndexError, TypeError) as exc: + log.error("Unexpected Deepgram response shape: %s", exc) + raise UpstreamError("Neočekávaná struktura odpovědi z Deepgramu.") from exc diff --git a/app/clients/openai_client.py b/app/clients/openai_client.py new file mode 100644 index 0000000..495224c --- /dev/null +++ b/app/clients/openai_client.py @@ -0,0 +1,87 @@ +"""Klient pro OpenAI — Whisper přepis + Chat Completions (slučování), přes httpx.""" +import httpx + +from ..config import OPENAI_BASE_URL, UPSTREAM_TIMEOUT_SECONDS +from ..errors import UpstreamError +from ..logging_config import get_logger + +log = get_logger("audio-transcription.openai") + + +async def transcribe( + *, + api_key: str, + audio: bytes, + filename: str, + content_type: str, + model: str, + language: str | None = None, +) -> str: + """Přepíše audio přes OpenAI Whisper (/audio/transcriptions) a vrátí text.""" + headers = {"Authorization": f"Bearer {api_key}"} + files = {"file": (filename or "audio.mp3", audio, content_type or "application/octet-stream")} + form = {"model": model} + if language: + form["language"] = language + url = f"{OPENAI_BASE_URL}/audio/transcriptions" + + try: + async with httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS) as client: + resp = await client.post(url, headers=headers, data=form, files=files) + except httpx.HTTPError as exc: + log.error("OpenAI transcription request failed: %s", exc) + 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, + ) + + data = resp.json() + return data.get("text", "") or "" + + +async def merge_transcripts( + *, + api_key: str, + model: str, + system_prompt: str, + text_deepgram: str, + text_whisper: str, +) -> str: + """Sloučí dva přepisy do jednoho přes Chat Completions dle system promptu.""" + headers = {"Authorization": f"Bearer {api_key}"} + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Text z Deepgram: {text_deepgram}"}, + {"role": "user", "content": f"Text z OpenAI Whisper: {text_whisper}"}, + ], + } + url = f"{OPENAI_BASE_URL}/chat/completions" + + try: + async with httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS) as client: + resp = await client.post(url, headers=headers, json=payload) + except httpx.HTTPError as exc: + log.error("OpenAI chat request failed: %s", exc) + 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, + ) + + data = resp.json() + try: + return data["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError, TypeError) as exc: + log.error("Unexpected OpenAI chat response shape: %s", exc) + raise UpstreamError("Neočekávaná struktura odpovědi z OpenAI chatu.") from exc diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e2b6b39 --- /dev/null +++ b/app/config.py @@ -0,0 +1,23 @@ +"""Konfigurace čtená z environment variables (AppFactory runtime .env). + +Žádné secrets zde nejsou — API klíče se předávají per-request přes X- hlavičky +(viz app/credentials.py). Zde jsou pouze veřejné defaulty a nastavení proxy. +""" +import os + +APP_NAME = os.getenv("APP_NAME", "audio-transcription") +APP_VERSION = os.getenv("APP_VERSION", "1.0.0") +ROOT_PATH = os.getenv("ROOT_PATH", "") + +# Upstream base URL adresy (přepsatelné přes env, kdyby se změnily). +DEEPGRAM_BASE_URL = os.getenv("DEEPGRAM_BASE_URL", "https://api.deepgram.com/v1") +OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + +# Defaultní modely — přepsatelné v POST parametrech requestu. +DEFAULT_DEEPGRAM_MODEL = os.getenv("DEFAULT_DEEPGRAM_MODEL", "nova-2") +DEFAULT_WHISPER_MODEL = os.getenv("DEFAULT_WHISPER_MODEL", "whisper-1") +DEFAULT_CHAT_MODEL = os.getenv("DEFAULT_CHAT_MODEL", "gpt-4o") +DEFAULT_LANGUAGE = os.getenv("DEFAULT_LANGUAGE", "cs") + +# Timeout pro upstream volání (přepis dlouhého audia může trvat). +UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "300")) diff --git a/app/credentials.py b/app/credentials.py new file mode 100644 index 0000000..925abd6 --- /dev/null +++ b/app/credentials.py @@ -0,0 +1,44 @@ +"""Extrakce per-request přihlašovacích údajů z X- hlaviček. + +Secrets (API klíče) chodí VÝHRADNĚ v hlavičkách, nikdy v těle requestu ani v URL. +Nic se neukládá — služba je stateless. +""" +from dataclasses import dataclass + +from fastapi import Header + +from .errors import CredentialsError + + +@dataclass +class Credentials: + deepgram_api_key: str | None + openai_api_key: str | None + + def require_deepgram(self) -> str: + if not self.deepgram_api_key or not self.deepgram_api_key.strip(): + raise CredentialsError("Chybí hlavička X-Deepgram-Api-Key.") + return self.deepgram_api_key.strip() + + def require_openai(self) -> str: + if not self.openai_api_key or not self.openai_api_key.strip(): + raise CredentialsError("Chybí hlavička X-OpenAI-Api-Key.") + return self.openai_api_key.strip() + + +def get_credentials( + x_deepgram_api_key: str | None = Header( + default=None, + alias="X-Deepgram-Api-Key", + description="Deepgram API klíč (secret). Nutný pro Deepgram přepis.", + ), + x_openai_api_key: str | None = Header( + default=None, + alias="X-OpenAI-Api-Key", + description="OpenAI API klíč (secret). Nutný pro Whisper přepis a slučovací chat.", + ), +) -> Credentials: + return Credentials( + deepgram_api_key=x_deepgram_api_key, + openai_api_key=x_openai_api_key, + ) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..10d6611 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,70 @@ +"""Typované výjimky + centrální exception handlery. + +Platí pravidlo: žádná tichá selhání — každá chyba se loguje (bez secrets). +""" +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from .logging_config import get_logger + +log = get_logger("audio-transcription.errors") + + +class TranscriptionError(Exception): + """Základní chyba služby s HTTP status kódem.""" + + status_code = 500 + + def __init__(self, message: str, status_code: int | None = None, detail=None): + super().__init__(message) + self.message = message + if status_code is not None: + self.status_code = status_code + self.detail = detail + + +class CredentialsError(TranscriptionError): + """Chybějící / neplatné přihlašovací údaje (X- hlavičky).""" + + status_code = 401 + + +class BadRequestError(TranscriptionError): + """Neplatný vstup (chybí audio, špatné parametry).""" + + status_code = 400 + + +class UpstreamError(TranscriptionError): + """Chyba při volání upstream API (Deepgram / OpenAI / stažení audia).""" + + status_code = 502 + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(TranscriptionError) + async def _handle_transcription_error(request: Request, exc: TranscriptionError): + log.warning( + "%s on %s: %s", + exc.__class__.__name__, + request.url.path, + exc.message, + ) + body = {"error": exc.__class__.__name__, "message": exc.message} + if exc.detail is not None: + body["detail"] = exc.detail + return JSONResponse(status_code=exc.status_code, content=body) + + @app.exception_handler(Exception) + async def _handle_unexpected(request: Request, exc: Exception): + # Nelogujeme celý stack s možnými secrets ve vstupu; logujeme typ + zprávu. + log.error( + "Unhandled %s on %s: %s", + exc.__class__.__name__, + request.url.path, + exc, + ) + return JSONResponse( + status_code=500, + content={"error": "InternalError", "message": "Neočekávaná chyba serveru."}, + ) diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..cd84309 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,16 @@ +"""Centrální logging. Nikdy nelogujeme secrets (API klíče, hlavičky s creds).""" +import logging +import os + +_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() + + +def configure_logging() -> None: + logging.basicConfig( + level=_LEVEL, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/app/main.py b/app/main.py index 15000a1..065eff8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,25 +1,57 @@ +"""Vstupní bod aplikace audio-transcription. + +Stateless FastAPI služba běžící v AppFactory za reverse proxy `/apps/`. +Přijímá audio + parametry v POST, přepisuje ho paralelně (Deepgram + OpenAI Whisper) +a volitelně slučuje do jednoho co nejlepšího přepisu. Výstup je vždy JSON. + +API klíče se předávají per-request v X- hlavičkách, nikdy se neukládají ani nelogují. +""" import os + from fastapi import FastAPI -APP_NAME = os.getenv("APP_NAME", "audio-transcription") -APP_VERSION = os.getenv("APP_VERSION", "1.0.0") -ROOT_PATH = os.getenv("ROOT_PATH", "") +from .config import APP_NAME, APP_VERSION, ROOT_PATH +from .errors import register_exception_handlers +from .logging_config import configure_logging +from .routers import meta, transcribe + +configure_logging() + +DESCRIPTION = """ +Přepis hovorů / audia dvěma engine (Deepgram + OpenAI Whisper) a jejich AI sloučení. + +### Přihlašovací údaje (hlavičky) +Secrets se předávají v hlavičkách u každého requestu — nikdy v těle ani v URL: + +- `X-Deepgram-Api-Key` — Deepgram API klíč (Deepgram Console → API Keys) +- `X-OpenAI-Api-Key` — OpenAI API klíč (platform.openai.com → API keys) + +### Endpointy +- `POST /transcribe/dual` — vrátí dva nezávislé přepisy (`text1` = Deepgram, `text2` = Whisper) +- `POST /transcribe/combined` — dual + `merged` (sloučený výsledek dle `combine_prompt`) + +Audio se posílá jako proměnná `file` (multipart/form-data). Ostatní parametry +(modely, jazyk, prompt) jsou form fields v POST. +""" app = FastAPI( title=APP_NAME, version=APP_VERSION, - root_path=ROOT_PATH + description=DESCRIPTION, + root_path=ROOT_PATH, ) -@app.get("/health") -def health(): - return {"status": "ok"} +register_exception_handlers(app) -@app.get("/version") -def version(): - return { - "app": APP_NAME, - "version": APP_VERSION, - "language": "python", - "root_path": ROOT_PATH - } +app.include_router(meta.router) +app.include_router(transcribe.router) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=int(os.getenv("PORT", "8000")), + ) diff --git a/app/prompts.py b/app/prompts.py new file mode 100644 index 0000000..76a031d --- /dev/null +++ b/app/prompts.py @@ -0,0 +1,31 @@ +"""Defaultní prompt pro slučování přepisů (Czech Transcript Merger). + +Přebráno z původní .NET implementace CallCenterController. Volající může poslat +vlastní `combine_prompt` v POST; když ho nepošle, použije se tento. +""" + +DEFAULT_COMBINE_PROMPT = ( + "Jsi \"Czech Transcript Merger\". Tvým úkolem je z *Text1* (Deepgram) a *Text2* " + "(OpenAI) vytvořit jediný, co nejpřesnější český přepis téhož hovoru." + "PRAVIDLA SLOUČENÍ" + "1) Plynulost & běžná mluva: upřednostňuj Text1." + "2) Strukturované údaje (NEZKRESLUJ, NEVYMÝŠLEJ): upřednostňuj Text2 pro: jména a " + "příjmení, firmy/brand, SPZ, čísla (tel., částky, datum/čas), e-maily, URL/domény, " + "adresy, čísla objednávek, kódy." + "3) Pokud si Text1 a Text2 odporují:" + " - pro údaje z bodu (2) zvol Text2, *pokud* je formátově i významově věrohodný; " + "jinak použij lépe vypadající variantu z Text1 nebo bezpečně oprav na gramaticky " + "správnou podobu beze změny významu." + " - pro běžné věty a obraty preferuj Text1." + "4) Oprav zjevné chyby rozpoznání (např. „panešance“ → „pane Švance“, " + "„CS Technologys“ → „CS Technologies“, „Samalepa.cz“ → „Samolepak.cz“). Zachovej " + "diakritiku a přirozenou češtinu." + "5) Odstraň duplicity, záseky a šum (např. náhodná slova typu „Přiším.“, opakování " + "„slyšíme se, slyšíme se“ zkrať na přirozené)." + "6) Nepřidávej obsah, který není v žádném zdroji. Dovoleny jsou jen minimální " + "gramatické a stylistické úpravy pro srozumitelnost." + "7) Když je to z kontextu jasné, můžeš rozlišit mluvčí „Asistent:“ / „Volající:“. " + "Není-li to jisté, ponech bez štítků." + "😎 VÝSTUP: *Pouze finální sloučený text* (žádné vysvětlení, žádné značky, žádný " + "JSON, žádné kódy). Když nevíš, kým začít, tak na prvním místě je asistent." +) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/meta.py b/app/routers/meta.py new file mode 100644 index 0000000..46db80c --- /dev/null +++ b/app/routers/meta.py @@ -0,0 +1,21 @@ +"""Povinné meta endpointy: /health a /version.""" +from fastapi import APIRouter + +from ..config import APP_NAME, APP_VERSION, ROOT_PATH + +router = APIRouter(tags=["meta"]) + + +@router.get("/health", summary="Health check") +def health(): + return {"status": "ok"} + + +@router.get("/version", summary="Verze a runtime informace") +def version(): + return { + "app": APP_NAME, + "version": APP_VERSION, + "language": "python", + "root_path": ROOT_PATH, + } diff --git a/app/routers/transcribe.py b/app/routers/transcribe.py new file mode 100644 index 0000000..b683290 --- /dev/null +++ b/app/routers/transcribe.py @@ -0,0 +1,168 @@ +"""Endpointy 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) + +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. +""" +import asyncio + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from pydantic import BaseModel, Field + +from ..clients import deepgram_client, openai_client +from ..config import ( + DEFAULT_CHAT_MODEL, + DEFAULT_DEEPGRAM_MODEL, + DEFAULT_LANGUAGE, + DEFAULT_WHISPER_MODEL, +) +from ..credentials import Credentials, get_credentials +from ..errors import BadRequestError +from ..logging_config import get_logger +from ..prompts import DEFAULT_COMBINE_PROMPT + +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 + + +async def _read_upload(file: UploadFile) -> tuple[bytes, str, str]: + """Načte upload do paměti a vrátí (bytes, filename, content_type).""" + audio = await file.read() + if not audio: + raise BadRequestError("Nahraný soubor je prázdný.") + filename = file.filename or "audio.mp3" + content_type = file.content_type or "application/octet-stream" + 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).""" + deepgram_key = creds.require_deepgram() + openai_key = creds.require_openai() + + text1, text2 = await asyncio.gather( + deepgram_client.transcribe( + api_key=deepgram_key, + audio=audio, + content_type=content_type, + model=deepgram_model, + language=language, + diarize=diarize, + smart_format=smart_format, + ), + openai_client.transcribe( + api_key=openai_key, + audio=audio, + filename=filename, + content_type=content_type, + model=whisper_model, + 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(), + model=chat_model, + system_prompt=prompt, + text_deepgram=text1, + text_whisper=text2, + ) + + return CombinedResult( + text1=text1, + text2=text2, + merged=merged, + deepgram_model=deepgram_model, + whisper_model=whisper_model, + chat_model=chat_model, + language=language, + ) diff --git a/documentation/overview.md b/documentation/overview.md new file mode 100644 index 0000000..bb712ad --- /dev/null +++ b/documentation/overview.md @@ -0,0 +1,66 @@ +# audio-transcription — přehled + +Stateless FastAPI služba v AppFactory. Přijme audio + parametry v POST a vrátí JSON +s přepisy. Přepis běží dvěma engine paralelně (Deepgram + OpenAI Whisper) a volitelně +se oba texty sloučí přes OpenAI Chat do jednoho co nejpřesnějšího přepisu. + +Vychází z původní .NET implementace `CallCenterController` (dual/combined přepis). +Napojení na Twilio bylo vynecháno — audio se posílá přímo jako proměnná (upload). + +## Architektura + +``` +app/ + main.py # FastAPI app, root_path, Swagger, exception handlers + config.py # env konfigurace + defaulty (žádné secrets) + logging_config.py # logging (bez secrets) + errors.py # typované výjimky + handlery (žádná tichá selhání) + credentials.py # X- hlavičky → přihlašovací údaje (stateless) + prompts.py # výchozí slučovací prompt (Czech Transcript Merger) + clients/ + deepgram_client.py # Deepgram pre-recorded REST (httpx) + openai_client.py # OpenAI Whisper + Chat Completions (httpx) + routers/ + meta.py # /health, /version + transcribe.py # /transcribe/dual, /transcribe/combined +``` + +Žádné SDK — upstream API se volají přímo přes `httpx`, aby služba nebyla závislá na +verzích knihoven. Nic se neukládá; služba je multi-tenant a stateless. + +## Reverse proxy + +Běží za `/apps/audio-transcription`. `root_path` se čte z env `ROOT_PATH`, takže +OpenAPI `servers` i Swagger „Try it out“ míří na správný prefix. + +## Endpointy + +| Metoda | Cesta | Popis | +|--------|-------|-------| +| GET | `/health` | Health check (200) | +| GET | `/version` | Verze + root_path | +| POST | `/transcribe/dual` | Deepgram + Whisper (dva texty) | +| POST | `/transcribe/combined` | dual + sloučený `merged` | + +Detaily viz [transcribe.md](transcribe.md). + +## Přihlašovací údaje + +Secrets se předávají v hlavičkách u každého requestu (nikdy v těle/URL, nikdy se +nelogují ani neukládají): + +- `X-Deepgram-Api-Key` +- `X-OpenAI-Api-Key` + +## Konfigurace (env) + +| Proměnná | Default | Popis | +|----------|---------|-------| +| `ROOT_PATH` | `""` | Prefix reverse proxy (`/apps/audio-transcription`) | +| `DEFAULT_DEEPGRAM_MODEL` | `nova-2` | Výchozí Deepgram model | +| `DEFAULT_WHISPER_MODEL` | `whisper-1` | Výchozí OpenAI přepisový model | +| `DEFAULT_CHAT_MODEL` | `gpt-4o` | Výchozí chat model pro sloučení | +| `DEFAULT_LANGUAGE` | `cs` | Výchozí jazyk | +| `UPSTREAM_TIMEOUT_SECONDS` | `300` | Timeout upstream volání | +| `DEEPGRAM_BASE_URL` | `https://api.deepgram.com/v1` | Base URL Deepgram | +| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Base URL OpenAI | diff --git a/documentation/transcribe.md b/documentation/transcribe.md new file mode 100644 index 0000000..4d80ef5 --- /dev/null +++ b/documentation/transcribe.md @@ -0,0 +1,97 @@ +# Endpointy přepisu + +Audio se posílá jako proměnná `file` (multipart/form-data). Všechny 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) + +| Hlavička | Nutná | Popis | +|----------|-------|-------| +| `X-Deepgram-Api-Key` | ano | Deepgram API klíč | +| `X-OpenAI-Api-Key` | ano | OpenAI API klíč (Whisper + slučovací chat) | + +## POST /transcribe/dual + +Paralelní přepis dvěma engine. Vrací dva nezávislé texty. + +### Form parametry + +| Pole | Typ | Default | Popis | +|------|-----|---------|-------| +| `file` | soubor | — | Audio k přepisu (povinné) | +| `deepgram_model` | text | `nova-2` | Deepgram model | +| `whisper_model` | text | `whisper-1` | OpenAI přepisový model | +| `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" +} +``` + +### Příklad (curl) + +```bash +curl -X POST https://services.csbot.cz/apps/audio-transcription/transcribe/combined \ + -H "X-Deepgram-Api-Key: " \ + -H "X-OpenAI-Api-Key: " \ + -F "file=@nahravka.mp3" +``` + +## Chybové stavy + +| 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) | +| 500 | Neočekávaná chyba serveru | + +Všechny chyby se logují (bez secrets), žádné tiché selhání. diff --git a/requirements.txt b/requirements.txt index 364e2ee..f8ea7f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ fastapi uvicorn[standard] +httpx +python-multipart