# ============================================================================ # DOCLING ENGINE — RADIOTERAPIA.AI # Hugging Face Spaces · Gradio · Motor de Transcrição de Documentos # ============================================================================ import os import time import base64 import hashlib import tempfile from pathlib import Path import gradio as gr try: from docling.document_converter import DocumentConverter DOCLING_READY = True except ImportError: DOCLING_READY = False # ============================================================================ # MOTOR DE PROCESSAMENTO # ============================================================================ class DoclingAppProcessor: """Motor Docling com deduplicação SHA-256 e conformidade LGPD.""" def __init__(self): self.processed_hashes = set() self._converter = None @property def converter(self): # Lazy-load: o DocumentConverter inicializa modelos pesados. # Carregamos só na primeira chamada de processamento. if self._converter is None and DOCLING_READY: self._converter = DocumentConverter() return self._converter def compute_hash(self, file_path): hasher = hashlib.sha256() with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): hasher.update(chunk) return hasher.hexdigest() def calculate_confidence(self, text, ext): if not text or len(text.strip()) < 10: return 0.0 base = 95.0 if ext == ".pdf" else 75.0 words = len(text.split()) if words < 30: base *= 0.8 return min(100.0, base) def process_batch(self, files): if not DOCLING_READY: return "", "❌ Sistema indisponível. Verifique a instalação do Docling no ambiente." if not files: return "", "⚠️ Nenhum arquivo foi enviado para processamento." start_time = time.time() markdown_outputs = [] logs = [] for f in files: path = f.name if hasattr(f, "name") else f name = Path(path).name ext = Path(path).suffix.lower() if time.time() - start_time > 100: logs.append("⏱️ Limite de tempo de 100s atingido. Processamento interrompido.") break try: f_hash = self.compute_hash(path) except Exception: logs.append(f"❌ Falha ao ler {name} — Erro de acesso ao arquivo ⚠️") continue if f_hash in self.processed_hashes: logs.append(f"⚠️ {name} — Informação duplicada detectada (conteúdo idêntico ignorado).") continue try: result = self.converter.convert(path) md_content = result.document.export_to_markdown() if md_content.strip(): conf_score = self.calculate_confidence(md_content, ext) self.processed_hashes.add(f_hash) markdown_outputs.append(f"## 📄 {name}\n\n{md_content}\n\n---\n") logs.append(f"✅ {name} processado com sucesso 📄 | Confiança: {conf_score:.1f}%") else: logs.append(f"⚠️ Falha ao ler {name} — Conteúdo vazio ou ilegível ⚠️") except Exception: logs.append(f"❌ Falha ao ler {name} — Erro no processamento do arquivo ⚠️") finally: # Conformidade LGPD: exclusão imediata do arquivo temporário. try: if os.path.exists(path): os.remove(path) except Exception: pass elapsed_time = time.time() - start_time total_md = "\n".join(markdown_outputs) if markdown_outputs else "*Nenhum conteúdo inédito extraído.*" log_header = f"⏱️ Tempo de Execução: {elapsed_time:.2f}s | Arquivos Inéditos: {len(markdown_outputs)}\n" total_logs = ( log_header + "\n".join(logs) + "\n🔒 Arquivos temporários excluídos. Conformidade LGPD garantida 🔒" ) return total_md, total_logs def clear_session(self): self.processed_hashes.clear() return None, "", "🔒 Histórico e cache de dados limpos com sucesso." engine = DoclingAppProcessor() # ============================================================================ # CSS — LAYOUT CLAUDE DESIGN (radioterapia.ai) # ============================================================================ CSS_THEME = """ /* -------- Fontes -------- */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); /* -------- Tokens -------- */ :root, .dark { --bg-0: #060d1a; --bg-1: #0a1426; --bg-2: #0f1729; --bg-3: #16213a; --panel: #0c1322; --panel-2: #0f1729; --line: #1e293b; --line-strong: #2a3a55; --text: #e6edf7; --text-muted: #a3b1c6; --text-dim: #6b7a92; --accent: #4DA3FF; --accent-2: #8B9EE6; --accent-soft: rgba(77, 163, 255, 0.10); --accent-line: rgba(77, 163, 255, 0.35); --success: #5BC57F; --warn: #E1B23B; --error: #E96A5C; --r-md: 10px; --r-lg: 14px; --r-xl: 18px; --font-display: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, monospace; --shadow-lift: 0 8px 32px -12px rgba(0, 0, 0, 0.5); } /* -------- Reset / base -------- */ html, body, .gradio-container { background-color: var(--bg-0) !important; color: var(--text) !important; font-family: var(--font-display) !important; } .gradio-container { max-width: 1320px !important; margin: 0 auto !important; padding: 0 !important; } footer { display: none !important; } /* Remove Gradio default backgrounds on blocks */ .gr-block, .gr-form, .gr-box, .gr-panel { background: transparent !important; border: none !important; } /* -------- Top bar -------- */ .rt-topbar { display: flex; align-items: center; justify-content: space-between; padding: 20px 36px; border-bottom: 1px solid var(--line); background: rgba(6, 13, 26, 0.7); backdrop-filter: blur(10px); position: sticky; top: 0; z-index: 10; } .rt-logo { display: flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -0.01em; color: var(--text); } .rt-logo .ai { color: var(--accent); } .rt-logo svg { display: block; } .rt-status-pill { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); } .rt-status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px rgba(91, 197, 127, 0.25); } /* -------- Hero -------- */ .rt-hero-row { padding: 40px 36px 28px !important; max-width: 1320px; margin: 0 auto !important; align-items: center !important; gap: 36px !important; } .rt-hero-text { padding: 4px 0; } .rt-hero-image img { border-radius: var(--r-xl) !important; border: 1px solid var(--line-strong) !important; background: var(--panel) !important; box-shadow: var(--shadow-lift) !important; width: 100% !important; height: auto !important; display: block !important; } .rt-hero-image-placeholder { border-radius: var(--r-xl); border: 1px dashed var(--line-strong); background: var(--panel-2); box-shadow: var(--shadow-lift); padding: 60px 24px; text-align: center; color: var(--text-dim); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.05em; text-transform: uppercase; } .rt-eyebrow { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--accent-line); background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 20px; } .rt-h1 { margin: 0; font-family: var(--font-display); font-size: clamp(44px, 5.4vw, 72px); font-weight: 600; line-height: 1.02; letter-spacing: -0.035em; color: var(--text); } .rt-sub { margin: 14px 0 0; font-size: clamp(17px, 1.6vw, 20px); color: var(--text-muted); line-height: 1.45; max-width: 56ch; } .rt-features { display: flex; gap: 24px; margin-top: 28px; flex-wrap: wrap; } .rt-feature { padding-left: 14px; border-left: 2px solid var(--accent-line); } .rt-feature-k { font-size: 13px; color: var(--text); font-weight: 500; font-family: var(--font-mono); } .rt-feature-v { font-size: 12px; color: var(--text-dim); margin-top: 2px; } /* -------- Section headers -------- */ .rt-panel-head { margin-bottom: 14px; } .rt-panel-eyebrow { display: flex; align-items: center; gap: 10px; font-size: 11px; font-family: var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 8px; } .rt-panel-eyebrow .num { color: var(--accent); } .rt-panel-eyebrow .bar { height: 1px; width: 40px; background: var(--accent-line); } .rt-panel-title { margin: 0 0 4px; font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -0.015em; color: var(--text); } .rt-panel-sub { font-size: 13px; color: var(--text-muted); } /* -------- Panels / Cards -------- */ .rt-panel { border-radius: var(--r-xl) !important; border: 1px solid var(--line) !important; background: var(--panel) !important; padding: 24px !important; box-shadow: var(--shadow-lift) !important; } /* -------- File upload (Gradio) -------- */ .gr-file, .gr-file-upload, [data-testid="file"] { background: var(--panel-2) !important; border: 1.5px dashed var(--line-strong) !important; border-radius: var(--r-lg) !important; color: var(--text-muted) !important; transition: all 0.2s ease; } .gr-file:hover, [data-testid="file"]:hover { border-color: var(--accent) !important; background: var(--accent-soft) !important; } .gr-file label, [data-testid="file"] label { color: var(--text) !important; font-family: var(--font-display) !important; } /* -------- Textareas / inputs -------- */ textarea, input[type="text"], .gr-textbox textarea { background: var(--panel-2) !important; color: var(--text) !important; border: 1px solid var(--line) !important; border-radius: var(--r-md) !important; font-family: var(--font-mono) !important; font-size: 13px !important; line-height: 1.6 !important; } textarea:focus, .gr-textbox textarea:focus { border-color: var(--accent) !important; outline: none !important; box-shadow: 0 0 0 3px var(--accent-soft) !important; } label, .gr-textbox label, .gr-form label { color: var(--text-muted) !important; font-family: var(--font-display) !important; font-size: 12px !important; font-weight: 500 !important; letter-spacing: 0.04em !important; text-transform: uppercase !important; } /* -------- Buttons -------- */ button.gr-button, .gr-button { font-family: var(--font-display) !important; font-weight: 600 !important; border-radius: var(--r-md) !important; transition: all 0.2s ease !important; letter-spacing: -0.005em !important; } button.gr-button-primary, .gr-button-primary { background: var(--accent) !important; color: white !important; border: none !important; box-shadow: 0 8px 24px -8px rgba(77, 163, 255, 0.5) !important; } button.gr-button-primary:hover, .gr-button-primary:hover { background: #6BB4FF !important; transform: translateY(-1px); } button.gr-button-secondary, .gr-button-secondary { background: transparent !important; color: var(--text-muted) !important; border: 1px solid var(--line-strong) !important; } button.gr-button-secondary:hover, .gr-button-secondary:hover { color: var(--text) !important; border-color: var(--accent-line) !important; background: var(--accent-soft) !important; } button.gr-button-stop, .gr-button-stop { background: transparent !important; color: var(--text-muted) !important; border: 1px solid var(--line-strong) !important; } button.gr-button-stop:hover, .gr-button-stop:hover { color: var(--error) !important; border-color: var(--error) !important; } /* -------- Headings inside markdown blocks -------- */ .gradio-container h1, .gradio-container h2, .gradio-container h3, .gradio-container h4 { color: var(--text) !important; font-family: var(--font-display) !important; font-weight: 600 !important; letter-spacing: -0.015em !important; } .gradio-container h4 { font-size: 13px !important; color: var(--text-muted) !important; text-transform: uppercase; letter-spacing: 0.08em !important; margin-bottom: 8px !important; } /* -------- Accordion -------- */ .gr-accordion, [data-testid="accordion"] { background: var(--panel) !important; border: 1px solid var(--line) !important; border-radius: var(--r-lg) !important; overflow: hidden; } .gr-accordion > button, [data-testid="accordion"] > button { color: var(--text) !important; font-family: var(--font-display) !important; font-weight: 500 !important; background: transparent !important; } /* -------- File pills row -------- */ .rt-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .rt-pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px 6px 6px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel-2); font-size: 12px; color: var(--text-muted); } .rt-pill-badge { padding: 2px 6px; border-radius: 6px; color: white; font-size: 9px; font-weight: 700; font-family: var(--font-mono); letter-spacing: 0.04em; } /* -------- Footer -------- */ .rt-footer { margin-top: 32px; padding: 20px 36px; border-top: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); font-family: var(--font-mono); letter-spacing: 0.04em; } /* -------- File download component -------- */ .gr-file-output { background: var(--panel-2) !important; border: 1px solid var(--line) !important; border-radius: var(--r-md) !important; } """ # ============================================================================ # COMPONENTES HTML — TOP BAR, HERO, FOOTER # ============================================================================ def image_to_data_uri(path: str): """Lê uma imagem do disco e devolve uma data URI base64 (ou None).""" if not os.path.exists(path): return None ext = path.lower().rsplit(".", 1)[-1] mime = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "svg": "image/svg+xml", "webp": "image/webp", "gif": "image/gif", }.get(ext, "image/png") with open(path, "rb") as fh: b64 = base64.b64encode(fh.read()).decode("ascii") return f"data:{mime};base64,{b64}" # Logo dinâmico: usa logo.png se existir na raiz, senão usa o SVG inline. LOGO_FILE = "logo.png" _logo_uri = image_to_data_uri(LOGO_FILE) if _logo_uri: LOGO_BLOCK_HTML = ( f'radioterapia.ai' ) else: # Fallback: emblema SVG + wordmark (como no design Claude original) LOGO_BLOCK_HTML = """ radioterapia.ai """ TOPBAR_HTML = f"""
Motor Docling pronto
""" HERO_TEXT_HTML = """
Motor de Inovação - radioterapia.ai

Docling

Transcrição de documentos para texto. Extraia conteúdo estruturado de laudos, exames, fotos, etc. — para auxiliar a digitação para seu prontuário ou agentes.

PDF · DOCX · PPTX
Documentos clínicos
JPG · PNG · TIFF
Laudos fotografados
""" PILLS_HTML = """
PDFPDFs
JPGFotos de laudos
XLSXTabelas
PPTXApresentações
DOCXDocumentos
""" FOOTER_HTML = """ """ def panel_head(num: str, title: str, sub: str) -> str: return f"""
{num}

{title}

{sub}
""" # ============================================================================ # UI — GRADIO BLOCKS # ============================================================================ with gr.Blocks( title="Docling — Radioterapia.ai", css=CSS_THEME, theme=gr.themes.Base( primary_hue=gr.themes.colors.blue, neutral_hue=gr.themes.colors.slate, font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"], ), ) as app: # Hero — 2 colunas: texto + imagem do workflow with gr.Row(elem_classes="rt-hero-row"): with gr.Column(scale=11, min_width=320): gr.HTML(HERO_TEXT_HTML) with gr.Column(scale=10, min_width=280): # A imagem fica em /docling-workflow.png na raiz do repo. # Se o arquivo não existir, o componente exibe placeholder vazio. workflow_image_path = "docling-workflow.png" if os.path.exists(workflow_image_path): gr.Image( value=workflow_image_path, show_label=False, container=False, interactive=False, show_download_button=False, show_share_button=False, elem_classes="rt-hero-image", ) else: gr.HTML('
📊 Workflow Docling
') # ===== Linha principal: Upload + Resultado ===== with gr.Row(equal_height=False): # ---------- UPLOAD ---------- with gr.Column(scale=1, elem_classes="rt-panel"): gr.HTML(panel_head( "01", "Upload", "Anexe PDFs, fotos de laudos, tabelas e apresentações" )) file_input = gr.File( file_count="multiple", type="filepath", label="Arraste arquivos ou clique para selecionar", file_types=[".pdf", ".docx", ".pptx", ".xlsx", ".jpg", ".jpeg", ".png", ".tiff", ".html", ".md"], ) gr.HTML(PILLS_HTML) process_btn = gr.Button( "✨ Processar Documentos", variant="primary", size="lg", ) # ---------- RESULTADO ---------- with gr.Column(scale=1, elem_classes="rt-panel"): gr.HTML(panel_head( "02", "Resultado", "Texto extraído — em formato Markdown" )) md_output = gr.Textbox( lines=14, max_lines=22, label="output.md", placeholder="Aguardando documentos — o texto estruturado aparecerá aqui após o processamento.", show_copy_button=True, ) with gr.Row(): download_btn = gr.Button("⬇️ Baixar .md", size="sm") clear_btn = gr.Button("🗑️ Limpar Sessão", size="sm", variant="stop") download_file = gr.File(label="Download disponível", visible=False) # ===== Logs (fora do sistema de etapas) ===== with gr.Row(elem_classes="rt-panel"): with gr.Column(): gr.HTML("""

Logs & comentários

Status de processamento, confiança e conformidade
""") log_output = gr.Textbox( lines=6, max_lines=12, label="Status do Sistema", placeholder="$ aguardando arquivos…", ) # ===== Accordion "Sobre a ferramenta" ===== with gr.Accordion("Sobre a ferramenta", open=False): gr.Markdown(""" **🔒 Conformidade com a LGPD — Sem retenção de dados** * Arquivos processados estritamente em memória RAM volátil. * Exclusão forçada imediata após a leitura de cada documento do lote. * Sem gravação permanente ou retenção de dados sensíveis em servidor. * Deduplicação SHA-256 evita o reprocessamento de informações idênticas. **📚 Sobre o Docling** Incorpore a biblioteca **docling** em seus agentes médicos de transcrição. Saiba mais em: [https://www.docling.ai/](https://www.docling.ai/) """) # ===== Footer ===== gr.HTML(FOOTER_HTML) # ===== Handlers ===== def save_and_export_file(text): if not text or len(text.strip()) < 10: return gr.update(value=None, visible=False) temp_file = tempfile.NamedTemporaryFile( mode="w", suffix=".md", delete=False, encoding="utf-8" ) temp_file.write(text) temp_file.close() return gr.update(value=temp_file.name, visible=True) process_btn.click( fn=engine.process_batch, inputs=[file_input], outputs=[md_output, log_output], ) download_btn.click( fn=save_and_export_file, inputs=[md_output], outputs=[download_file], ) clear_btn.click( fn=engine.clear_session, outputs=[file_input, md_output, log_output], ) # ============================================================================ # LAUNCH # ============================================================================ if __name__ == "__main__": if not DOCLING_READY: print("⚠️ Aviso: Docling não está instalado. Verifique requirements.txt.") app.queue(max_size=20).launch( server_name="0.0.0.0", server_port=7860, show_error=True, )