Docling / app.py
Radioterapia-AI's picture
Update app.py
e27462b verified
Raw
History Blame Contribute Delete
25.2 kB
# ============================================================================
# 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'<img src="{_logo_uri}" alt="radioterapia.ai" '
f'style="height: 28px; width: auto; display: block;" />'
)
else:
# Fallback: emblema SVG + wordmark (como no design Claude original)
LOGO_BLOCK_HTML = """
<svg width="22" height="22" viewBox="0 0 40 40" fill="none" aria-hidden>
<defs>
<radialGradient id="rt-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#8B9EE6" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#4DA3FF" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="20" cy="20" r="19" stroke="#4DA3FF" stroke-opacity="0.35" stroke-width="0.8"/>
<circle cx="20" cy="20" r="14" stroke="#8B9EE6" stroke-opacity="0.5" stroke-width="0.8"/>
<circle cx="20" cy="20" r="9" stroke="#4DA3FF" stroke-opacity="0.7" stroke-width="0.8"/>
<circle cx="20" cy="20" r="20" fill="url(#rt-glow)"/>
<circle cx="20" cy="20" r="3" fill="#4DA3FF"/>
<circle cx="32.5" cy="14.5" r="1.4" fill="#8B9EE6"/>
</svg>
<span>radioterapia<span class="ai">.ai</span></span>
"""
TOPBAR_HTML = f"""
<div class="rt-topbar">
<div class="rt-logo">
{LOGO_BLOCK_HTML}
</div>
<div class="rt-status-pill">
<span class="rt-status-dot"></span>
Motor Docling pronto
</div>
</div>
"""
HERO_TEXT_HTML = """
<div class="rt-hero-text">
<div class="rt-eyebrow">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/>
</svg>
<span>Motor de Inovação - radioterapia.ai</span>
</div>
<h1 class="rt-h1">Docling</h1>
<p class="rt-sub">
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.
</p>
<div class="rt-features">
<div class="rt-feature">
<div class="rt-feature-k">PDF · DOCX · PPTX</div>
<div class="rt-feature-v">Documentos clínicos</div>
</div>
<div class="rt-feature">
<div class="rt-feature-k">JPG · PNG · TIFF</div>
<div class="rt-feature-v">Laudos fotografados</div>
</div>
</div>
</div>
"""
PILLS_HTML = """
<div class="rt-pills">
<div class="rt-pill"><span class="rt-pill-badge" style="background:#E0533A">PDF</span>PDFs</div>
<div class="rt-pill"><span class="rt-pill-badge" style="background:#16A34A">JPG</span>Fotos de laudos</div>
<div class="rt-pill"><span class="rt-pill-badge" style="background:#15803D">XLSX</span>Tabelas</div>
<div class="rt-pill"><span class="rt-pill-badge" style="background:#E07B1E">PPTX</span>Apresentações</div>
<div class="rt-pill"><span class="rt-pill-badge" style="background:#2563EB">DOCX</span>Documentos</div>
</div>
"""
FOOTER_HTML = """
<div class="rt-footer">
<span>radioterapia.ai · docling engine v1.0</span>
<span>Powered by IBM Docling · OSS</span>
</div>
"""
def panel_head(num: str, title: str, sub: str) -> str:
return f"""
<div class="rt-panel-head">
<div class="rt-panel-eyebrow">
<span class="num">{num}</span>
<span class="bar"></span>
</div>
<h2 class="rt-panel-title">{title}</h2>
<div class="rt-panel-sub">{sub}</div>
</div>
"""
# ============================================================================
# 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('<div class="rt-hero-image-placeholder">📊 Workflow Docling</div>')
# ===== 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("""
<div class="rt-panel-head">
<h2 class="rt-panel-title rt-panel-title-plain">Logs & comentários</h2>
<div class="rt-panel-sub">Status de processamento, confiança e conformidade</div>
</div>
""")
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,
)