This commit is contained in:
JiriUhlir
2026-07-10 09:46:49 +02:00
parent 429cb84461
commit 7a3f61c125
16 changed files with 762 additions and 16 deletions
View File
+60
View File
@@ -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
+87
View File
@@ -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
+23
View File
@@ -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"))
+44
View File
@@ -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,
)
+70
View File
@@ -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."},
)
+16
View File
@@ -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
View File
@@ -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")),
)
+31
View File
@@ -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."
)
View File
+21
View File
@@ -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,
}
+168
View File
@@ -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,
)