# ============================================================================
# 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''
)
else:
# Fallback: emblema SVG + wordmark (como no design Claude original)
LOGO_BLOCK_HTML = """
radioterapia.ai
"""
TOPBAR_HTML = f"""
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.