| """ |
| MedBuddy: Transcrição Médica em Tempo Real com OpenAI e Gradio |
| |
| Versão: 2.0 |
| Data: 20/07/2025 |
| |
| Este aplicativo Gradio, projetado para rodar em um Hugging Face Space, demonstra |
| um pipeline de transcrição e sumarização de consultas médicas em tempo real. |
| |
| Funcionalidades: |
| - Captura de áudio do microfone via streaming. |
| - Transcrição ao vivo usando a Realtime API da OpenAI (gpt-4o-realtime). |
| - Geração de resumos periódicos em "bullet points" (gpt-4o-mini). |
| - Elaboração de uma nota final no formato SOAP (gpt-4o). |
| - Interface limpa com componentes nativos para copiar texto e baixar o áudio. |
| |
| Requisitos: |
| - gradio |
| - openai |
| - websockets |
| - soundfile |
| - numpy |
| |
| ⚠️ AVISO: Este código é um protótipo de referência. Para uso em produção, |
| é mandatório tratar informações de saúde protegidas (PHI) com o máximo rigor, |
| utilizar o sistema de Secrets do Hugging Face para chaves de API e implementar |
| um tratamento de erros mais abrangente. |
| """ |
|
|
| import asyncio |
| import json |
| import os |
| import tempfile |
| import time |
|
|
| import gradio as gr |
| import numpy as np |
| import openai |
| import soundfile as sf |
| import websockets |
|
|
| |
| |
| |
| |
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") |
| if not OPENAI_API_KEY: |
| raise ValueError("A variável de ambiente OPENAI_API_KEY não foi definida.") |
| openai.api_key = OPENAI_API_KEY |
|
|
| |
| STT_MODEL = "gpt-4o-realtime-preview-2025-06-03" |
| SUMMARY_MODEL = "gpt-4o-mini" |
| SOAP_MODEL = "gpt-4o" |
|
|
| |
| SAMPLE_RATE = 16000 |
| SUMMARY_EVERY_SEC = 60 |
|
|
| |
| |
| |
| |
| |
| |
| class SessionState: |
| """Encapsula o estado de uma sessão de gravação ativa.""" |
| def __init__(self): |
| self.ws: websockets.WebSocketClientProtocol | None = None |
| self.running: bool = False |
| self.transcript_full: str = "" |
| self.bullets: list[str] = [] |
| self.last_summary_ts: float = 0.0 |
| self.audio_chunks: list[np.ndarray] = [] |
| self.contexto: str = "" |
|
|
| state = SessionState() |
|
|
| |
| |
| |
| async def open_realtime_ws() -> websockets.WebSocketClientProtocol: |
| """Abre e valida a conexão WebSocket com a Realtime API.""" |
| uri = f"wss://api.openai.com/v1/realtime?model={STT_MODEL}&sample_rate={SAMPLE_RATE}" |
| try: |
| ws = await websockets.connect( |
| uri, |
| extra_headers={"Authorization": f"Bearer {OPENAI_API_KEY}"}, |
| subprotocols=["realtime"], |
| max_size=2 * 1024 * 1024, |
| ) |
| |
| evt = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) |
| if evt.get("type") != "session.created": |
| raise ConnectionRefusedError(f"Falha ao criar sessão: {evt}") |
| return ws |
| except Exception as e: |
| print(f"Erro ao conectar ao WebSocket: {e}") |
| raise |
|
|
| async def pcm_from_numpy(chunk: np.ndarray) -> bytes: |
| """Converte um array numpy float32 para bytes no formato PCM 16-bit little-endian.""" |
| |
| if chunk.dtype != np.float32: |
| chunk = chunk.astype(np.float32) |
| chunk = np.clip(chunk, -1.0, 1.0) |
| |
| |
| pcm16 = (chunk * 32767).astype(np.int16) |
| return pcm16.tobytes() |
|
|
| |
| |
| |
| async def summarize_block(text: str) -> str: |
| """Gera um resumo conciso (bullet points) para um trecho da transcrição.""" |
| if not text.strip(): |
| return "" |
| try: |
| response = await openai.chat.completions.create( |
| model=SUMMARY_MODEL, |
| messages=[ |
| {"role": "system", "content": "Você é um escriba clínico. Resuma o texto a seguir em até 5 bullet points concisos, em português do Brasil."}, |
| {"role": "user", "content": text}, |
| ], |
| temperature=0.3, |
| max_tokens=200, |
| ) |
| return response.choices[0].message.content.strip() |
| except Exception as e: |
| print(f"Erro ao gerar sumário: {e}") |
| return "[Erro ao gerar sumário parcial]" |
|
|
| async def generate_soap(full_txt: str, bullets: list[str], contexto: str) -> str: |
| """Gera a nota final no formato SOAP a partir do contexto e da transcrição.""" |
| if not full_txt.strip(): |
| return "Nenhuma transcrição foi gerada para criar a nota SOAP." |
| |
| bullet_summary = "\n".join(bullets) |
| prompt_context = f"Contexto prévio da consulta: {contexto if contexto else 'Nenhum'}" |
| |
| try: |
| response = await openai.chat.completions.create( |
| model=SOAP_MODEL, |
| messages=[ |
| {"role": "system", "content": "Você é um assistente médico sênior especializado em documentação clínica. Sua tarefa é criar uma nota no formato SOAP (Subjetivo, Objetivo, Avaliação, Plano) baseada na transcrição da consulta."}, |
| {"role": "user", "content": f"{prompt_context}\n\nResumo dos pontos chave (para guia):\n{bullet_summary}\n\nUse a transcrição completa a seguir para elaborar a nota SOAP final em português do Brasil, de forma estruturada e profissional:\n\n---\n{full_txt}"}, |
| ], |
| temperature=0.2, |
| max_tokens=1500, |
| ) |
| return response.choices[0].message.content.strip() |
| except Exception as e: |
| print(f"Erro ao gerar nota SOAP: {e}") |
| return f"[Erro ao gerar nota SOAP final]\n\nTranscrição completa:\n{full_txt}" |
|
|
| |
| |
| |
| async def cb_start(contexto: str): |
| """Callback: Inicia a gravação.""" |
| if state.running: |
| return |
| |
| state.__init__() |
| state.contexto = contexto |
| state.running = True |
| |
| try: |
| state.ws = await open_realtime_ws() |
| state.last_summary_ts = time.time() |
| print("Sessão de gravação iniciada.") |
| |
| return "", "", "", None |
| except Exception as e: |
| state.running = False |
| gr.Warning(f"Não foi possível iniciar a gravação: {e}") |
| return "", "", "", None |
|
|
| async def cb_stream(audio_stream, live_txt, live_sum): |
| """Callback: Processa o stream de áudio em tempo real.""" |
| if not state.running or audio_stream is None or not state.ws: |
| return live_txt, live_sum |
|
|
| try: |
| await state.ws.send(await pcm_from_numpy(audio_stream)) |
| state.audio_chunks.append(audio_stream) |
|
|
| |
| while True: |
| try: |
| msg = await asyncio.wait_for(state.ws.recv(), timeout=0.01) |
| evt = json.loads(msg) |
| if evt.get("type") == "transcript" and (text := evt.get("transcript", {}).get("text")): |
| state.transcript_full += text + " " |
| except asyncio.TimeoutError: |
| break |
|
|
| |
| if (time.time() - state.last_summary_ts) >= SUMMARY_EVERY_SEC: |
| |
| transcript_slice = state.transcript_full[-4000:] |
| bullet = await summarize_block(transcript_slice) |
| if bullet: |
| state.bullets.append(bullet) |
| state.last_summary_ts = time.time() |
| |
| live_summary_md = "\n\n".join(state.bullets) |
| return state.transcript_full, live_summary_md |
|
|
| except (websockets.exceptions.ConnectionClosed, Exception) as e: |
| print(f"Erro durante o streaming: {e}") |
| await cb_stop() |
| return live_txt, live_sum |
|
|
| async def cb_stop(audio_filepath): |
| """Callback: Finaliza a gravação, gera a nota SOAP e prepara o download.""" |
| if not state.running: |
| return "", None |
|
|
| print("Finalizando a gravação...") |
| state.running = False |
| if state.ws: |
| await state.ws.close() |
|
|
| |
| if state.transcript_full and (not state.bullets or (time.time() - state.last_summary_ts) > 15): |
| bullet = await summarize_block(state.transcript_full[-4000:]) |
| if bullet: |
| state.bullets.append(bullet) |
|
|
| soap_note = await generate_soap(state.transcript_full, state.bullets, state.contexto) |
|
|
| |
| |
| print(f"Áudio final salvo em: {audio_filepath}") |
| |
| return soap_note, audio_filepath |
|
|
| |
| |
| |
| with gr.Blocks(theme=gr.themes.Soft(), title="MedBuddy – Transcrição Médica") as demo: |
| gr.Markdown("# MedBuddy") |
| gr.Markdown("### Um Modelo Open-Source de Transcrição Inteligente de Consultas Médicas") |
|
|
| with gr.Tabs(): |
| with gr.TabItem("Gravação e Transcrição"): |
| contexto_txt = gr.Textbox( |
| label="Contexto da Consulta (opcional)", |
| lines=3, |
| placeholder="Ex.: Paciente com histórico de dispneia crônica, fumante há 20 anos, apresentando tosse persistente." |
| ) |
| |
| mic_audio = gr.Audio( |
| sources=["microphone"], |
| type="filepath", |
| label="Microfone (16kHz)", |
| streaming=True, |
| ) |
|
|
| with gr.Row(): |
| transcricao_txt = gr.Textbox(label="Transcrição em Tempo Real", lines=15, interactive=False) |
| sumario_basico_txt = gr.Textbox(label="Resumo (Bullet Points)", lines=15, interactive=False) |
| |
| with gr.Accordion("Resultados Finais", open=False): |
| sumario_final_txt = gr.Textbox( |
| label="Nota SOAP Final", |
| lines=15, |
| interactive=False, |
| show_copy_button=True |
| ) |
| baixar_btn = gr.DownloadButton( |
| "Baixar Áudio (.wav)", |
| interactive=True |
| ) |
|
|
| |
| |
| |
| mic_audio.start_recording( |
| fn=cb_start, |
| inputs=[contexto_txt], |
| outputs=[transcricao_txt, sumario_basico_txt, sumario_final_txt, baixar_btn] |
| ) |
| |
| mic_audio.stream( |
| fn=cb_stream, |
| inputs=[mic_audio, transcricao_txt, sumario_basico_txt], |
| outputs=[transcricao_txt, sumario_basico_txt] |
| ) |
| |
| mic_audio.stop_recording( |
| fn=cb_stop, |
| inputs=[mic_audio], |
| outputs=[sumario_final_txt, baixar_btn] |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch(debug=True) |