first
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."},
|
||||
)
|
||||
@@ -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)
|
||||
+47
-15
@@ -1,25 +1,57 @@
|
||||
"""Vstupní bod aplikace audio-transcription.
|
||||
|
||||
Stateless FastAPI služba běžící v AppFactory za reverse proxy `/apps/<app-id>`.
|
||||
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")),
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 |
|
||||
@@ -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: <DEEPGRAM_KEY>" \
|
||||
-H "X-OpenAI-Api-Key: <OPENAI_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: <DEEPGRAM_KEY>" \
|
||||
-H "X-OpenAI-Api-Key: <OPENAI_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í.
|
||||
@@ -1,2 +1,4 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
httpx
|
||||
python-multipart
|
||||
|
||||
Reference in New Issue
Block a user