first commit
Browse files- README.md +1 -1
- app.py +231 -4
- requirements.txt +5 -0
README.md
CHANGED
@@ -8,7 +8,7 @@ sdk_version: 5.38.0
|
|
8 |
app_file: app.py
|
9 |
pinned: false
|
10 |
license: mit
|
11 |
-
short_description: Open-
|
12 |
---
|
13 |
|
14 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
8 |
app_file: app.py
|
9 |
pinned: false
|
10 |
license: mit
|
11 |
+
short_description: Open-Source Intelligent Transcription for medical encounters
|
12 |
---
|
13 |
|
14 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
CHANGED
@@ -1,7 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
-
|
4 |
-
|
|
|
|
|
5 |
|
6 |
-
|
7 |
-
demo.launch()
|
|
|
1 |
+
# app.py – Transcrição Inteligente de Consultas Médicas em Tempo Real
|
2 |
+
# Autor: OpenAI ChatGPT (o3)
|
3 |
+
"""
|
4 |
+
Este aplicativo Gradio roda em um Hugging Face Space e demonstra:
|
5 |
+
• Captura de áudio do microfone em tempo real
|
6 |
+
• Envio de chunks para a Realtime API da OpenAI (modelo gpt‑4o‑transcribe)
|
7 |
+
• Exibição da transcrição ao vivo
|
8 |
+
• Resumo de ~60 s em bullet points
|
9 |
+
• Geração de nota SOAP final
|
10 |
+
• Download do áudio e botão para copiar texto
|
11 |
+
|
12 |
+
⚠️ Este código é um protótipo de referência. Em produção, trate PHI com rigor, use
|
13 |
+
chaves de API via Secrets do Space e adicione controle de erros mais robusto.
|
14 |
+
"""
|
15 |
+
|
16 |
+
import asyncio
|
17 |
+
import json
|
18 |
+
import os
|
19 |
+
import tempfile
|
20 |
+
import time
|
21 |
+
from datetime import datetime
|
22 |
+
|
23 |
import gradio as gr
|
24 |
+
import numpy as np
|
25 |
+
import openai
|
26 |
+
import soundfile as sf
|
27 |
+
import websockets
|
28 |
+
|
29 |
+
# -------------------------------------------------------
|
30 |
+
# Configuração
|
31 |
+
# -------------------------------------------------------
|
32 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
33 |
+
if not OPENAI_API_KEY:
|
34 |
+
raise RuntimeError("Defina OPENAI_API_KEY nas variáveis de ambiente ou Secrets do HF Space!")
|
35 |
+
|
36 |
+
openai.api_key = OPENAI_API_KEY
|
37 |
+
|
38 |
+
STT_MODEL = "gpt-4o-realtime-preview-2025-06-03" # modelo de transcrição
|
39 |
+
SUMMARY_MODEL = "gpt-4o-mini" # bullet points minuto a minuto
|
40 |
+
SOAP_MODEL = "gpt-4o" # sumário final SOAP
|
41 |
+
SAMPLE_RATE = 16000 # Hz, mono
|
42 |
+
SUMMARY_EVERY_SEC = 60 # janela de resumo
|
43 |
+
|
44 |
+
# -------------------------------------------------------
|
45 |
+
# Estado de sessão (single‑user Space simplificado)
|
46 |
+
# -------------------------------------------------------
|
47 |
+
class SessionState:
|
48 |
+
def __init__(self):
|
49 |
+
self.ws = None # conexão WebSocket com API Realtime
|
50 |
+
self.running = False # flag de captura
|
51 |
+
self.transcript_full = "" # transcrição acumulada
|
52 |
+
self.bullets = [] # lista de bullets
|
53 |
+
self.last_summary_ts = time.time()
|
54 |
+
self.audio_chunks = [] # lista de arrays numpy
|
55 |
+
self.contexto = "" # texto inserido pelo usuário
|
56 |
+
|
57 |
+
state = SessionState()
|
58 |
+
|
59 |
+
# -------------------------------------------------------
|
60 |
+
# Funções auxiliares – OpenAI Realtime API
|
61 |
+
# -------------------------------------------------------
|
62 |
+
async def open_realtime_ws() -> websockets.WebSocketClientProtocol:
|
63 |
+
"""Abre e retorna a conexão WebSocket com a Realtime API."""
|
64 |
+
uri = f"wss://api.openai.com/v1/realtime?model={STT_MODEL}"
|
65 |
+
ws = await websockets.connect(
|
66 |
+
uri,
|
67 |
+
extra_headers={
|
68 |
+
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
69 |
+
"OpenAI-Beta": "realtime=v1",
|
70 |
+
},
|
71 |
+
subprotocols=["realtime"],
|
72 |
+
max_size=1 * 1024 * 1024, # 1 MB
|
73 |
+
)
|
74 |
+
|
75 |
+
# Primeiro evento deve ser session.created
|
76 |
+
evt = json.loads(await ws.recv())
|
77 |
+
if evt.get("type") != "session.created":
|
78 |
+
raise RuntimeError(f"Evento inicial inesperado: {evt}")
|
79 |
+
return ws
|
80 |
+
|
81 |
+
async def pcm_from_numpy(chunk: np.ndarray) -> bytes:
|
82 |
+
"""Converte numpy float32 (-1..1) → bytes PCM 16‑bit LE."""
|
83 |
+
if chunk.dtype != np.float32:
|
84 |
+
chunk = chunk.astype(np.float32)
|
85 |
+
pcm16 = (np.clip(chunk, -1, 1) * 32767).astype(np.int16)
|
86 |
+
return pcm16.tobytes()
|
87 |
+
|
88 |
+
async def send_audio_chunk(chunk: np.ndarray, ws: websockets.WebSocketClientProtocol):
|
89 |
+
await ws.send(await pcm_from_numpy(chunk))
|
90 |
+
|
91 |
+
# -------------------------------------------------------
|
92 |
+
# Funções de resumo / SOAP (Chat Completions)
|
93 |
+
# -------------------------------------------------------
|
94 |
+
async def summarize_block(text: str) -> str:
|
95 |
+
"""Gera até 5 bullet points em PT‑BR para o bloco de texto fornecido."""
|
96 |
+
rsp = openai.chat.completions.create(
|
97 |
+
model=SUMMARY_MODEL,
|
98 |
+
messages=[
|
99 |
+
{
|
100 |
+
"role": "system",
|
101 |
+
"content": "Você é escriba clínico. Resuma o texto a seguir em até 5 bullet points concisos, em português.",
|
102 |
+
},
|
103 |
+
{"role": "user", "content": text},
|
104 |
+
],
|
105 |
+
temperature=0.3,
|
106 |
+
)
|
107 |
+
return rsp.choices[0].message.content.strip()
|
108 |
+
|
109 |
+
async def generate_soap(full_txt: str, bullets: list[str], contexto: str) -> str:
|
110 |
+
"""Combina transcript + bullets + contexto para gerar nota SOAP final."""
|
111 |
+
rsp = openai.chat.completions.create(
|
112 |
+
model=SOAP_MODEL,
|
113 |
+
messages=[
|
114 |
+
{"role": "system", "content": "Você é um escriba médico sênior."},
|
115 |
+
{"role": "user", "content": f"Contexto: {contexto}"},
|
116 |
+
{"role": "assistant", "content": "\n".join(bullets)},
|
117 |
+
{
|
118 |
+
"role": "user",
|
119 |
+
"content": (
|
120 |
+
"Transcrição completa a seguir. Elabore a nota final no formato SOAP, em português, "
|
121 |
+
"utilizando os bullet points como guia.\n\n" + full_txt
|
122 |
+
),
|
123 |
+
},
|
124 |
+
],
|
125 |
+
temperature=0.2,
|
126 |
+
)
|
127 |
+
return rsp.choices[0].message.content.strip()
|
128 |
+
|
129 |
+
# -------------------------------------------------------
|
130 |
+
# Callbacks Gradio
|
131 |
+
# -------------------------------------------------------
|
132 |
+
async def cb_start(contexto: str):
|
133 |
+
"""Inicia gravação: abre WS, reseta estados."""
|
134 |
+
if state.running:
|
135 |
+
return gr.update(value="Já gravando...")
|
136 |
+
|
137 |
+
state.__init__() # reset
|
138 |
+
state.contexto = contexto
|
139 |
+
state.running = True
|
140 |
+
state.ws = await open_realtime_ws()
|
141 |
+
state.last_summary_ts = time.time()
|
142 |
+
return gr.update(value="Gravando… (clique em Finalizar para encerrar)")
|
143 |
+
|
144 |
+
async def cb_stream(audio_chunk, live_txt, live_sum):
|
145 |
+
"""Callback contínuo do componente de microfone (streaming=True)."""
|
146 |
+
if not state.running or audio_chunk is None:
|
147 |
+
return live_txt, live_sum
|
148 |
+
|
149 |
+
# Garantir mono
|
150 |
+
if audio_chunk.ndim == 2:
|
151 |
+
audio_chunk = audio_chunk.mean(axis=1)
|
152 |
+
|
153 |
+
# Envia chunk para a API e guarda localmente
|
154 |
+
await send_audio_chunk(audio_chunk, state.ws)
|
155 |
+
state.audio_chunks.append(audio_chunk)
|
156 |
+
|
157 |
+
# Tenta ler rapidamente novos transcripts (non‑blocking)
|
158 |
+
try:
|
159 |
+
for _ in range(5):
|
160 |
+
msg = await asyncio.wait_for(state.ws.recv(), timeout=0.01)
|
161 |
+
evt = json.loads(msg)
|
162 |
+
if evt.get("type") == "transcript":
|
163 |
+
txt = evt["transcript"]["text"]
|
164 |
+
state.transcript_full += txt + " "
|
165 |
+
except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK):
|
166 |
+
pass
|
167 |
+
|
168 |
+
# Resumo a cada SUMMARY_EVERY_SEC
|
169 |
+
now = time.time()
|
170 |
+
if now - state.last_summary_ts >= SUMMARY_EVERY_SEC:
|
171 |
+
bullet = await summarize_block(state.transcript_full[-4000:])
|
172 |
+
state.bullets.append(bullet)
|
173 |
+
state.last_summary_ts = now
|
174 |
+
|
175 |
+
live_summary_md = "\n\n".join(state.bullets)
|
176 |
+
return state.transcript_full, live_summary_md
|
177 |
+
|
178 |
+
async def cb_stop():
|
179 |
+
"""Finaliza gravação, gera SOAP e disponibiliza download do áudio."""
|
180 |
+
if not state.running:
|
181 |
+
return "", "", ""
|
182 |
+
|
183 |
+
state.running = False
|
184 |
+
if state.ws:
|
185 |
+
await state.ws.close()
|
186 |
+
|
187 |
+
# Garantir último resumo se necessário
|
188 |
+
if state.transcript_full and (not state.bullets or (time.time() - state.last_summary_ts) > 15):
|
189 |
+
bullet = await summarize_block(state.transcript_full[-4000:])
|
190 |
+
state.bullets.append(bullet)
|
191 |
+
|
192 |
+
soap = await generate_soap(state.transcript_full, state.bullets, state.contexto)
|
193 |
+
|
194 |
+
# Salvar áudio
|
195 |
+
wav_path = tempfile.mktemp(suffix=".wav", prefix="consulta_")
|
196 |
+
if state.audio_chunks:
|
197 |
+
audio_np = np.concatenate(state.audio_chunks)
|
198 |
+
sf.write(wav_path, audio_np, SAMPLE_RATE)
|
199 |
+
|
200 |
+
# Botões de download/cópia
|
201 |
+
download_link = f"<a href='file={wav_path}' download>Baixar áudio (.wav)</a>"
|
202 |
+
copy_btn = (
|
203 |
+
"<button onclick=\"navigator.clipboard.writeText("\" + "`" + soap.replace("`", "\\`") + "`" + ")\">Copiar SOAP</button>"
|
204 |
+
)
|
205 |
+
|
206 |
+
soap_html = f"<h3>Nota SOAP</h3><pre>{soap}</pre>{download_link}<br>{copy_btn}"
|
207 |
+
return state.transcript_full, "\n\n".join(state.bullets), soap_html
|
208 |
+
|
209 |
+
# -------------------------------------------------------
|
210 |
+
# Interface Gradio
|
211 |
+
# -------------------------------------------------------
|
212 |
+
with gr.Blocks(title="Transcrição Inteligente – Demo") as demo:
|
213 |
+
gr.Markdown("## Transcrição inteligente de consultas médicas em tempo real")
|
214 |
+
|
215 |
+
with gr.Row():
|
216 |
+
contexto_txt = gr.Textbox(label="Contexto da consulta (opcional)", lines=2, placeholder="Ex.: Paciente com dispneia crônica...")
|
217 |
+
btn_start = gr.Button("Iniciar", variant="primary")
|
218 |
+
btn_stop = gr.Button("Finalizar", variant="stop")
|
219 |
+
|
220 |
+
with gr.Row():
|
221 |
+
md_transcript = gr.Markdown("", label="Transcrição em tempo real")
|
222 |
+
md_summary = gr.Markdown("", label="Resumo (bullet points)")
|
223 |
+
|
224 |
+
md_soap = gr.HTML("", label="Nota SOAP final")
|
225 |
+
|
226 |
+
mic = gr.Audio(source="microphone", type="numpy", streaming=True, label="Microfone (16 kHz)")
|
227 |
|
228 |
+
# Eventos
|
229 |
+
btn_start.click(cb_start, inputs=[contexto_txt], outputs=[btn_start])
|
230 |
+
mic.stream(cb_stream, inputs=[mic, md_transcript, md_summary], outputs=[md_transcript, md_summary])
|
231 |
+
btn_stop.click(cb_stop, inputs=None, outputs=[md_transcript, md_summary, md_soap])
|
232 |
|
233 |
+
if __name__ == "__main__":
|
234 |
+
demo.launch()
|
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
openai>=1.24.0
|
2 |
+
gradio>=4.27.0
|
3 |
+
websockets>=12.0
|
4 |
+
soundfile>=0.12.1
|
5 |
+
numpy>=1.26.0
|