diff --git a/app/clients/deepgram_client.py b/app/clients/deepgram_client.py index 5ec4158..26fae1b 100644 --- a/app/clients/deepgram_client.py +++ b/app/clients/deepgram_client.py @@ -21,12 +21,19 @@ async def transcribe( diarize: bool, smart_format: bool, ) -> 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 = { "model": model, "language": language, "diarize": "true" if diarize 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 = { "Authorization": f"Token {api_key}", @@ -48,9 +55,36 @@ async def transcribe( data = resp.json() try: - channels = data["results"]["channels"] - alternative = channels[0]["alternatives"][0] + results = data["results"] + # 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 "" 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 + + +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) diff --git a/app/clients/openai_client.py b/app/clients/openai_client.py index 37d9e42..d5e7925 100644 --- a/app/clients/openai_client.py +++ b/app/clients/openai_client.py @@ -20,7 +20,8 @@ async def transcribe( """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} + # temperature=0 => deterministický přepis, méně halucinací na tichu/šumu. + form = {"model": model, "temperature": "0"} if language: form["language"] = language url = f"{OPENAI_BASE_URL}/audio/transcriptions" diff --git a/app/prompts.py b/app/prompts.py index 76a031d..943884a 100644 --- a/app/prompts.py +++ b/app/prompts.py @@ -1,31 +1,43 @@ """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. +Vychází z původní .NET implementace, ale je upravený tak, aby: +- 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 = ( - "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." + "Jsi \"Czech Transcript Merger\". Ze dvou přepisů TÉHOŽ českého hovoru — *Text1* " + "(Deepgram, obsahuje rozlišení mluvčích ve tvaru „Mluvčí 0:\", „Mluvčí 1:\") a " + "*Text2* (OpenAI Whisper, plynulejší, ale bez mluvčích a někdy s halucinacemi) — " + "vytvoř jediný, co nejpřesnější a ÚPLNÝ přepis.\n" + "\n" + "PRAVIDLA:\n" + "1) ÚPLNOST je priorita: zachovej VŠECHNY repliky z obou zdrojů (sjednocení). " + "Nikdy nevynechávej reakce mluvčích jen proto, že jsou jen v jednom zdroji — typicky " + "krátké odpovědi volajícího („Ne, teď to nejde.\", „Ano.\", „Děkuji.\") bývají jen " + "v jednom přepisu, přesto je zařaď.\n" + "2) Rozlišení mluvčích: použij strukturu mluvčích z Text1. Každou repliku uveď " + "štítkem na začátku řádku. Když je z kontextu jasné, kdo je kdo, přejmenuj mluvčí na " + "„Asistent:\" (volá bot/firma) a „Volající:\"; jinak ponech „Mluvčí 0:\" / „Mluvčí 1:\". " + "Když začátek není jistý, první mluví Asistent.\n" + "3) Plynulost & běžná mluva: upřednostňuj znění z Text1; Text2 použij pro doplnění " + "chybějících pasáží a upřesnění.\n" + "4) Strukturované údaje (NEZKRESLUJ, NEVYMÝŠLEJ) — jména/příjmení, firmy/brand, SPZ, " + "čísla (tel., částky, datum/čas), e-maily, URL/domény, adresy, čísla objednávek, kódy: " + "vyber tu variantu, která je formátově i významově věrohodnější (často Text2). " + "Oprav zjevné chyby rozpoznání (např. „CS Technoloužís\"/„CS Technologys\" → " + "„CS Technologies\", „bojce bota\" → „voicebota\", „I služby\" → „e-služby\"). " + "Zachovej diakritiku a přirozenou češtinu.\n" + "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." ) diff --git a/app/routers/transcribe.py b/app/routers/transcribe.py index 93ec450..320d8db 100644 --- a/app/routers/transcribe.py +++ b/app/routers/transcribe.py @@ -31,9 +31,16 @@ router = APIRouter(tags=["transcribe"]) class TranscribeResult(BaseModel): - gpt: str = Field(..., description="Přepis z OpenAI (model whisper-1).") - deepgram: str = Field(..., description="Přepis z Deepgramu.") - merge: str = Field(..., description="Sloučený, co nejpřesnější výsledný přepis.") + gpt: str = Field(..., description="Přepis z OpenAI (model whisper-1), plynulý, bez mluvčích.") + deepgram: str = Field( + ..., + 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]: @@ -65,7 +72,11 @@ async def transcribe( 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)."), + 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."), creds: Credentials = Depends(get_credentials), ) -> TranscribeResult: diff --git a/documentation/transcribe.md b/documentation/transcribe.md index 6245772..e77fb59 100644 --- a/documentation/transcribe.md +++ b/documentation/transcribe.md @@ -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 | | `chat_model` | text | `gpt-4o` | OpenAI chat model pro sloučení | | `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 | 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ěď ```json { - "gpt": "přepis z OpenAI (whisper-1)", - "deepgram": "přepis z Deepgramu", - "merge": "sloučený, co nejpřesnější výsledný přepis" + "gpt": "Dobrý den, tady je asistentka ...", + "deepgram": "Mluvčí 0: Dobrý den, tady je asistentka ...\nMluvčí 1: Ne, teď to nejde.", + "merge": "Asistent: Dobrý den, tady je asistentka ...\nVolající: Ne, teď to nejde." } ```