This commit is contained in:
JiriUhlir
2026-07-10 10:08:51 +02:00
parent 57faf12d97
commit d55089b1c8
5 changed files with 105 additions and 37 deletions
+37 -3
View File
@@ -21,12 +21,19 @@ async def transcribe(
diarize: bool, diarize: bool,
smart_format: bool, smart_format: bool,
) -> str: ) -> str:
"""Přepíše audio Deepgramem a vrátí prostý transkript (channels[0].alternatives[0].transcript).""" """Přepíše audio Deepgramem.
Když je zapnutá diarizace, vrátí přepis se štítky mluvčích (z `utterances`),
např. `Mluvčí 0: ...`. Bez diarizace vrátí plochý transkript.
"""
params = { params = {
"model": model, "model": model,
"language": language, "language": language,
"diarize": "true" if diarize else "false", "diarize": "true" if diarize else "false",
"smart_format": "true" if smart_format else "false", "smart_format": "true" if smart_format else "false",
# utterances rozseká přepis na promluvy s přiřazeným mluvčím — nutné, aby
# v textu bylo vidět rozlišení mluvčích (samotný `transcript` je plochý).
"utterances": "true" if diarize else "false",
} }
headers = { headers = {
"Authorization": f"Token {api_key}", "Authorization": f"Token {api_key}",
@@ -48,9 +55,36 @@ async def transcribe(
data = resp.json() data = resp.json()
try: try:
channels = data["results"]["channels"] results = data["results"]
alternative = channels[0]["alternatives"][0] # 1) Preferuj diarizovaný výstup z utterances (se štítky mluvčích).
diarized = _format_utterances(results.get("utterances"))
if diarized:
return diarized
# 2) Fallback na plochý transkript.
alternative = results["channels"][0]["alternatives"][0]
return alternative.get("transcript", "") or "" return alternative.get("transcript", "") or ""
except (KeyError, IndexError, TypeError) as exc: except (KeyError, IndexError, TypeError) as exc:
log.error("Unexpected Deepgram response shape: %s", exc) log.error("Unexpected Deepgram response shape: %s", exc)
raise UpstreamError("Neočekávaná struktura odpovědi z Deepgramu.") from exc raise UpstreamError("Neočekávaná struktura odpovědi z Deepgramu.") from exc
def _format_utterances(utterances) -> str:
"""Sloučí Deepgram utterances do přepisu se štítky mluvčích (`Mluvčí N: ...`).
Po sobě jdoucí promluvy stejného mluvčího spojí do jednoho řádku.
"""
if not utterances:
return ""
lines: list[str] = []
prev_speaker = None
for utt in utterances:
text = (utt.get("transcript") or "").strip()
if not text:
continue
speaker = utt.get("speaker")
if speaker != prev_speaker:
lines.append(f"Mluvčí {speaker}: {text}")
prev_speaker = speaker
else:
lines[-1] += " " + text
return "\n".join(lines)
+2 -1
View File
@@ -20,7 +20,8 @@ async def transcribe(
"""Přepíše audio přes OpenAI Whisper (/audio/transcriptions) a vrátí text.""" """Přepíše audio přes OpenAI Whisper (/audio/transcriptions) a vrátí text."""
headers = {"Authorization": f"Bearer {api_key}"} headers = {"Authorization": f"Bearer {api_key}"}
files = {"file": (filename or "audio.mp3", audio, content_type or "application/octet-stream")} files = {"file": (filename or "audio.mp3", audio, content_type or "application/octet-stream")}
form = {"model": model} # temperature=0 => deterministický přepis, méně halucinací na tichu/šumu.
form = {"model": model, "temperature": "0"}
if language: if language:
form["language"] = language form["language"] = language
url = f"{OPENAI_BASE_URL}/audio/transcriptions" url = f"{OPENAI_BASE_URL}/audio/transcriptions"
+37 -25
View File
@@ -1,31 +1,43 @@
"""Defaultní prompt pro slučování přepisů (Czech Transcript Merger). """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 Vychází z původní .NET implementace, ale je upravený tak, aby:
vlastní `combine_prompt` v POST; když ho nepošle, použije se tento. - zachoval VŠECHNY repliky obou zdrojů (sjednocení, ne průnik),
- využil rozlišení mluvčích z Deepgramu (Text1 chodí se štítky `Mluvčí N:`),
- ignoroval typické halucinace Whisperu na tichu/šumu.
Volající může poslat vlastní `combine_prompt` v POST; jinak se použije tento.
""" """
DEFAULT_COMBINE_PROMPT = ( DEFAULT_COMBINE_PROMPT = (
"Jsi \"Czech Transcript Merger\". Tvým úkolem je z *Text1* (Deepgram) a *Text2* " "Jsi \"Czech Transcript Merger\". Ze dvou přepisů TÉHOŽ českého hovoru — *Text1* "
"(OpenAI) vytvořit jediný, co nejpřesnější český přepis téhož hovoru." "(Deepgram, obsahuje rozlišení mluvčích ve tvaru „Mluvčí 0:\", „Mluvčí 1:\") a "
"PRAVIDLA SLOUČENÍ" "*Text2* (OpenAI Whisper, plynulejší, ale bez mluvčích a někdy s halucinacemi) — "
"1) Plynulost & běžná mluva: upřednostňuj Text1." "vytvoř jediný, co nejpřesnější a ÚPLNÝ přepis.\n"
"2) Strukturované údaje (NEZKRESLUJ, NEVYMÝŠLEJ): upřednostňuj Text2 pro: jména a " "\n"
"příjmení, firmy/brand, SPZ, čísla (tel., částky, datum/čas), e-maily, URL/domény, " "PRAVIDLA:\n"
"adresy, čísla objednávek, kódy." "1) ÚPLNOST je priorita: zachovej VŠECHNY repliky z obou zdrojů (sjednocení). "
"3) Pokud si Text1 a Text2 odporují:" "Nikdy nevynechávej reakce mluvčích jen proto, že jsou jen v jednom zdroji — typicky "
" - pro údaje z bodu (2) zvol Text2, *pokud* je formátově i významově věrohodný; " "krátké odpovědi volajícího („Ne, teď to nejde.\", „Ano.\", „Děkuji.\") bývají jen "
"jinak použij lépe vypadající variantu z Text1 nebo bezpečně oprav na gramaticky " "v jednom přepisu, přesto je zařaď.\n"
"správnou podobu beze změny významu." "2) Rozlišení mluvčích: použij strukturu mluvčích z Text1. Každou repliku uveď "
" - pro běžné věty a obraty preferuj Text1." "štítkem na začátku řádku. Když je z kontextu jasné, kdo je kdo, přejmenuj mluvčí na "
"4) Oprav zjevné chyby rozpoznání (např. „panešance“ → „pane Švance“, " "„Asistent:\" (volá bot/firma) a „Volající:\"; jinak ponech „Mluvčí 0:\" / „Mluvčí 1:\". "
"„CS Technologys“ → „CS Technologies“, „Samalepa.cz“ → „Samolepak.cz“). Zachovej " "Když začátek není jistý, první mluví Asistent.\n"
"diakritiku a přirozenou češtinu." "3) Plynulost & běžná mluva: upřednostňuj znění z Text1; Text2 použij pro doplnění "
"5) Odstraň duplicity, záseky a šum (např. náhodná slova typu „Přiším.“, opakování " "chybějících pasáží a upřesnění.\n"
"„slyšíme se, slyšíme se“ zkrať na irozené)." "4) Strukturované údaje (NEZKRESLUJ, NEVYMÝŠLEJ) — jména/íjmení, firmy/brand, SPZ, "
"6) Nepřidávej obsah, který není v žádném zdroji. Dovoleny jsou jen minimální " "čísla (tel., částky, datum/čas), e-maily, URL/domény, adresy, čísla objednávek, kódy: "
"gramatické a stylistické úpravy pro srozumitelnost." "vyber tu variantu, která je formátově i významově věrohodnější (často Text2). "
"7) Když je to z kontextu jasné, můžeš rozlišit mluvčí „Asistent:“ / „Volající:“. " "Oprav zjevné chyby rozpoznání (např. „CS Technoloužís\"/„CS Technologys\" "
"Není-li to jisté, ponech bez štítků." "„CS Technologies\", „bojce bota\" → „voicebota\", „I služby\" → „e-služby\"). "
"😎 VÝSTUP: *Pouze finální sloučený text* (žádné vysvětlení, žádné značky, žádný " "Zachovej diakritiku a přirozenou češtinu.\n"
"JSON, žádné kódy). Když nevíš, kým začít, tak na prvním místě je asistent." "5) Ignoruj zjevné HALUCINACE Whisperu: nesmyslné či nesouvisející útržky (zvlášť na "
"konci hovoru na tichu/šumu, opakující se nebo mimo kontext) do výstupu NEDÁVEJ. "
"V pochybnostech se opři o Text1.\n"
"6) Odstraň duplicity, záseky a šum; opakování zkrať na přirozené.\n"
"7) Nepřidávej obsah, který není ani v jednom zdroji. Povoleny jsou jen minimální "
"gramatické a stylistické úpravy pro srozumitelnost.\n"
"\n"
"VÝSTUP: pouze finální sloučený přepis, po řádcích se štítky mluvčích. Žádné "
"vysvětlení, žádné poznámky, žádný JSON."
) )
+15 -4
View File
@@ -31,9 +31,16 @@ router = APIRouter(tags=["transcribe"])
class TranscribeResult(BaseModel): class TranscribeResult(BaseModel):
gpt: str = Field(..., description="Přepis z OpenAI (model whisper-1).") gpt: str = Field(..., description="Přepis z OpenAI (model whisper-1), plynulý, bez mluvčích.")
deepgram: str = Field(..., description="Přepis z Deepgramu.") deepgram: str = Field(
merge: str = Field(..., description="Sloučený, co nejpřesnější výsledný přepis.") ...,
description="Přepis z Deepgramu. Při zapnuté diarizaci se štítky mluvčích "
"(`Mluvčí 0: ...`).",
)
merge: str = Field(
...,
description="Sloučený, co nejpřesnější a úplný přepis se štítky mluvčích.",
)
async def _read_upload(file: UploadFile) -> tuple[bytes, str, str]: async def _read_upload(file: UploadFile) -> tuple[bytes, str, str]:
@@ -65,7 +72,11 @@ async def transcribe(
whisper_model: str = Form(DEFAULT_WHISPER_MODEL, description="OpenAI přepisový 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í."), 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)."), language: str = Form(DEFAULT_LANGUAGE, description="Jazyk audia (ISO kód, např. cs)."),
diarize: bool = Form(True, description="Deepgram diarizace (rozlišení mluvčích)."), diarize: bool = Form(
True,
description="Deepgram diarizace — rozlišení mluvčích. Při zapnutí vrací `deepgram` "
"přepis se štítky `Mluvčí N:` (interně zapne i utterances).",
),
smart_format: bool = Form(True, description="Deepgram smart formatting."), smart_format: bool = Form(True, description="Deepgram smart formatting."),
creds: Credentials = Depends(get_credentials), creds: Credentials = Depends(get_credentials),
) -> TranscribeResult: ) -> TranscribeResult:
+14 -4
View File
@@ -23,18 +23,28 @@ jsou form fields v POST. API klíče jsou v X- hlavičkách. Výstup je vždy JS
| `whisper_model` | text | `whisper-1` | OpenAI přepisový model | | `whisper_model` | text | `whisper-1` | OpenAI přepisový model |
| `chat_model` | text | `gpt-4o` | OpenAI chat model pro sloučení | | `chat_model` | text | `gpt-4o` | OpenAI chat model pro sloučení |
| `language` | text | `cs` | Jazyk (ISO kód) | | `language` | text | `cs` | Jazyk (ISO kód) |
| `diarize` | bool | `true` | Deepgram diarizace | | `diarize` | bool | `true` | Deepgram diarizace (rozlišení mluvčích) |
| `smart_format` | bool | `true` | Deepgram smart formatting | | `smart_format` | bool | `true` | Deepgram smart formatting |
Nezadá-li se `combine_prompt`, použije se výchozí prompt z `app/prompts.py`. Nezadá-li se `combine_prompt`, použije se výchozí prompt z `app/prompts.py`.
### Rozlišení mluvčích a kvalita
- Při `diarize=true` (výchozí) Deepgram interně zapne i `utterances` a přepis `deepgram`
se vrací se štítky mluvčích, např. `Mluvčí 0: ...` / `Mluvčí 1: ...`. Plochý přepis bez
mluvčích (samotné `alternatives[0].transcript`) se použije jen jako fallback.
- `gpt` (Whisper) je plynulý, ale **bez mluvčích** a občas halucinuje na tichu/šumu —
proto se volá s `temperature=0`.
- `merge` sjednotí oba zdroje: použije strukturu mluvčích z Deepgramu, doplní chybějící
repliky z Whisperu a ignoruje zjevné halucinace (řídí výchozí `combine_prompt`).
### Odpověď ### Odpověď
```json ```json
{ {
"gpt": "přepis z OpenAI (whisper-1)", "gpt": "Dobrý den, tady je asistentka ...",
"deepgram": "přepis z Deepgramu", "deepgram": "Mluvčí 0: Dobrý den, tady je asistentka ...\nMluvčí 1: Ne, teď to nejde.",
"merge": "sloučený, co nejpřesnější výsledný přepis" "merge": "Asistent: Dobrý den, tady je asistentka ...\nVolající: Ne, teď to nejde."
} }
``` ```