first
This commit is contained in:
@@ -1,3 +1,32 @@
|
|||||||
# audio-transcription
|
# 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
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
APP_NAME = os.getenv("APP_NAME", "audio-transcription")
|
from .config import APP_NAME, APP_VERSION, ROOT_PATH
|
||||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
from .errors import register_exception_handlers
|
||||||
ROOT_PATH = os.getenv("ROOT_PATH", "")
|
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(
|
app = FastAPI(
|
||||||
title=APP_NAME,
|
title=APP_NAME,
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
root_path=ROOT_PATH
|
description=DESCRIPTION,
|
||||||
|
root_path=ROOT_PATH,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/health")
|
register_exception_handlers(app)
|
||||||
def health():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.get("/version")
|
app.include_router(meta.router)
|
||||||
def version():
|
app.include_router(transcribe.router)
|
||||||
return {
|
|
||||||
"app": APP_NAME,
|
|
||||||
"version": APP_VERSION,
|
if __name__ == "__main__":
|
||||||
"language": "python",
|
import uvicorn
|
||||||
"root_path": ROOT_PATH
|
|
||||||
}
|
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
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
|
httpx
|
||||||
|
python-multipart
|
||||||
|
|||||||
Reference in New Issue
Block a user