Spaces:
Runtime error
Runtime error
Gabriel Ramos
commited on
Commit
·
780413d
1
Parent(s):
72b25f8
feat: Docling Document Processor - Gradio + ZeroGPU
Browse files- Interface para upload múltiplo (1-5 arquivos)
- Suporte a PDF, DOC, DOCX (até 50MB)
- Saída em JSON, Markdown ou ZIP (ambos)
- Aceleração GPU via @spaces.GPU
- Rate limiting, logging e validação robusta
- README.md +200 -9
- app.py +491 -0
- config.py +109 -0
- logs/.gitkeep +1 -0
- processors/__init__.py +18 -0
- processors/docling_processor.py +289 -0
- processors/json_formatter.py +226 -0
- processors/markdown_formatter.py +333 -0
- requirements.txt +26 -0
- tests/__init__.py +3 -0
- tests/test_processors.py +403 -0
- utils/__init__.py +38 -0
- utils/file_handler.py +257 -0
- utils/logger.py +246 -0
- utils/validators.py +297 -0
README.md
CHANGED
|
@@ -1,12 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
| 1 |
+
# 📄 Docling Document Processor
|
| 2 |
+
|
| 3 |
+
Aplicação Gradio para processamento de documentos usando [Docling](https://github.com/docling-project/docling) com aceleração ZeroGPU.
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+

|
| 7 |
+

|
| 8 |
+
|
| 9 |
+
## ✨ Recursos
|
| 10 |
+
|
| 11 |
+
- 🔍 **Extração inteligente** de texto, tabelas e metadados
|
| 12 |
+
- 🌐 **Detecção automática** de idioma
|
| 13 |
+
- 🚀 **Aceleração GPU** via ZeroGPU (Hugging Face Spaces)
|
| 14 |
+
- 📊 **Preserva estrutura** hierárquica do documento
|
| 15 |
+
- 📁 **Upload múltiplo** (1-5 arquivos simultâneos)
|
| 16 |
+
- 🔒 **Segurança** com validação de MIME type e sanitização
|
| 17 |
+
|
| 18 |
+
## 📋 Formatos Suportados
|
| 19 |
+
|
| 20 |
+
| Entrada | Saída |
|
| 21 |
+
|---------|-------|
|
| 22 |
+
| PDF | JSON |
|
| 23 |
+
| DOC | Markdown |
|
| 24 |
+
| DOCX | ZIP (ambos) |
|
| 25 |
+
|
| 26 |
+
## 🚀 Instalação Local
|
| 27 |
+
|
| 28 |
+
### Pré-requisitos
|
| 29 |
+
|
| 30 |
+
- Python 3.10+
|
| 31 |
+
- [uv](https://docs.astral.sh/uv/) (recomendado) ou pip
|
| 32 |
+
|
| 33 |
+
### Com uv
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
# Clone o repositório
|
| 37 |
+
git clone https://huggingface.co/spaces/SEU_USUARIO/docling-processor
|
| 38 |
+
cd docling-processor
|
| 39 |
+
|
| 40 |
+
# Crie ambiente virtual e instale dependências
|
| 41 |
+
uv venv
|
| 42 |
+
source .venv/bin/activate # Linux/macOS
|
| 43 |
+
uv pip install -r requirements.txt
|
| 44 |
+
|
| 45 |
+
# Execute
|
| 46 |
+
python app.py
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### Com pip
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
python -m venv venv
|
| 53 |
+
source venv/bin/activate
|
| 54 |
+
pip install -r requirements.txt
|
| 55 |
+
python app.py
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Acesse: http://localhost:7860
|
| 59 |
+
|
| 60 |
+
## 🌐 Deploy no Hugging Face Spaces
|
| 61 |
+
|
| 62 |
+
### 1. Crie o Space
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# Login no Hugging Face
|
| 66 |
+
hf login
|
| 67 |
+
|
| 68 |
+
# Crie o Space (substitua SEU_USUARIO pelo seu username)
|
| 69 |
+
hf repo create SEU_USUARIO/docling-processor --repo-type space --space-sdk gradio
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### 2. Configure ZeroGPU
|
| 73 |
+
|
| 74 |
+
No Hugging Face, vá em **Settings > Hardware** e selecione **ZeroGPU**.
|
| 75 |
+
|
| 76 |
+
### 3. Push do código
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
cd /caminho/para/docling_hf
|
| 80 |
+
git remote add space https://huggingface.co/spaces/SEU_USUARIO/docling-processor
|
| 81 |
+
git push space main
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
### 4. Verifique
|
| 85 |
+
|
| 86 |
+
Acesse: `https://huggingface.co/spaces/SEU_USUARIO/docling-processor`
|
| 87 |
+
|
| 88 |
+
## 📂 Estrutura do Projeto
|
| 89 |
+
|
| 90 |
+
```
|
| 91 |
+
docling_hf/
|
| 92 |
+
├── app.py # Interface Gradio + ZeroGPU
|
| 93 |
+
├── config.py # Configurações centralizadas
|
| 94 |
+
├── requirements.txt # Dependências
|
| 95 |
+
├── README.md # Esta documentação
|
| 96 |
+
├── processors/ # Lógica de processamento
|
| 97 |
+
│ ├── docling_processor.py
|
| 98 |
+
│ ├── json_formatter.py
|
| 99 |
+
│ └── markdown_formatter.py
|
| 100 |
+
├── utils/ # Utilitários
|
| 101 |
+
│ ├── validators.py
|
| 102 |
+
│ ├── file_handler.py
|
| 103 |
+
│ └── logger.py
|
| 104 |
+
├── tests/ # Testes unitários
|
| 105 |
+
│ └── test_processors.py
|
| 106 |
+
└── logs/ # Arquivos de log
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
## 📤 Formatos de Saída
|
| 110 |
+
|
| 111 |
+
### JSON
|
| 112 |
+
|
| 113 |
+
```json
|
| 114 |
+
{
|
| 115 |
+
"arquivo": "documento.pdf",
|
| 116 |
+
"idioma": "pt",
|
| 117 |
+
"processado_em": "2024-01-15T10:30:00",
|
| 118 |
+
"metadados": {
|
| 119 |
+
"nome_arquivo": "documento.pdf",
|
| 120 |
+
"num_paginas": 5,
|
| 121 |
+
"num_tabelas": 2
|
| 122 |
+
},
|
| 123 |
+
"tabelas": [...],
|
| 124 |
+
"conteudo": {...}
|
| 125 |
+
}
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### Markdown
|
| 129 |
+
|
| 130 |
+
```markdown
|
| 131 |
+
# Título do Documento
|
| 132 |
+
|
| 133 |
+
**Autor:** João Silva
|
| 134 |
+
**Idioma:** Português
|
| 135 |
+
**Páginas:** 5
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## Conteúdo
|
| 140 |
+
|
| 141 |
+
[Texto extraído do documento...]
|
| 142 |
+
|
| 143 |
---
|
| 144 |
+
|
| 145 |
+
## Tabelas Extraídas
|
| 146 |
+
|
| 147 |
+
| Coluna 1 | Coluna 2 |
|
| 148 |
+
|----------|----------|
|
| 149 |
+
| Valor 1 | Valor 2 |
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
## ⚙️ Configuração
|
| 153 |
+
|
| 154 |
+
Edite `config.py` para personalizar:
|
| 155 |
+
|
| 156 |
+
| Variável | Padrão | Descrição |
|
| 157 |
+
|----------|--------|-----------|
|
| 158 |
+
| `MAX_FILE_SIZE_MB` | 50 | Limite por arquivo |
|
| 159 |
+
| `MAX_FILES_PER_SESSION` | 5 | Arquivos por upload |
|
| 160 |
+
| `PROCESSING_TIMEOUT_SECONDS` | 300 | Timeout de processamento |
|
| 161 |
+
| `RATE_LIMIT_REQUESTS` | 10 | Requisições por hora |
|
| 162 |
+
|
| 163 |
+
## 🧪 Testes
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
# Executar testes
|
| 167 |
+
python -m pytest tests/ -v
|
| 168 |
+
|
| 169 |
+
# Com cobertura
|
| 170 |
+
python -m pytest tests/ -v --cov=. --cov-report=html
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
## 🔧 Troubleshooting
|
| 174 |
+
|
| 175 |
+
### ❌ "Arquivo muito grande"
|
| 176 |
+
|
| 177 |
+
Reduza o tamanho do PDF ou divida em partes menores.
|
| 178 |
+
|
| 179 |
+
### ❌ "Tipo de arquivo inválido"
|
| 180 |
+
|
| 181 |
+
Verifique se o arquivo não está corrompido. O sistema valida o conteúdo real, não apenas a extensão.
|
| 182 |
+
|
| 183 |
+
### ❌ "Timeout"
|
| 184 |
+
|
| 185 |
+
- Arquivos muito grandes ou complexos podem exceder o limite
|
| 186 |
+
- Tente processar menos arquivos por vez
|
| 187 |
+
- PDFs escaneados (OCR) levam mais tempo
|
| 188 |
+
|
| 189 |
+
### ❌ "Rate limit excedido"
|
| 190 |
+
|
| 191 |
+
Aguarde 1 hora ou use uma conta diferente.
|
| 192 |
+
|
| 193 |
+
## 📄 Licença
|
| 194 |
+
|
| 195 |
+
MIT License - veja [LICENSE](LICENSE) para detalhes.
|
| 196 |
+
|
| 197 |
+
## 🤝 Contribuições
|
| 198 |
+
|
| 199 |
+
Contribuições são bem-vindas! Abra uma issue ou pull request.
|
| 200 |
+
|
| 201 |
---
|
| 202 |
|
| 203 |
+
Desenvolvido com ❤️ usando [Docling](https://github.com/docling-project/docling) e [Gradio](https://gradio.app)
|
app.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Docling Document Processor - Aplicação Principal.
|
| 3 |
+
|
| 4 |
+
Este é o ponto de entrada da aplicação Gradio que permite
|
| 5 |
+
o upload e processamento de documentos usando Docling.
|
| 6 |
+
|
| 7 |
+
Recursos:
|
| 8 |
+
- Upload múltiplo (1-5 arquivos)
|
| 9 |
+
- Formatos: PDF, DOC, DOCX
|
| 10 |
+
- Saída: JSON, Markdown ou ambos (ZIP)
|
| 11 |
+
- Aceleração GPU via ZeroGPU
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
import traceback
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
from datetime import datetime, timedelta
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
import gradio as gr
|
| 24 |
+
|
| 25 |
+
# Importação condicional do spaces para ZeroGPU
|
| 26 |
+
try:
|
| 27 |
+
import spaces
|
| 28 |
+
HAS_SPACES = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
HAS_SPACES = False
|
| 31 |
+
|
| 32 |
+
# Adiciona o diretório atual ao path para imports locais
|
| 33 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 34 |
+
|
| 35 |
+
import config
|
| 36 |
+
from utils.validators import validate_files, ValidationError
|
| 37 |
+
from utils.file_handler import (
|
| 38 |
+
create_temp_directory,
|
| 39 |
+
cleanup_old_files,
|
| 40 |
+
create_zip_output,
|
| 41 |
+
save_output_file,
|
| 42 |
+
)
|
| 43 |
+
from utils.logger import setup_logger, get_logger
|
| 44 |
+
from processors.docling_processor import DoclingProcessor
|
| 45 |
+
from processors.json_formatter import format_to_json, JSONFormatter
|
| 46 |
+
from processors.markdown_formatter import format_to_markdown, MarkdownFormatter
|
| 47 |
+
|
| 48 |
+
# Configura logger
|
| 49 |
+
logger = setup_logger("docling_space")
|
| 50 |
+
|
| 51 |
+
# =============================================================================
|
| 52 |
+
# RATE LIMITING (in-memory)
|
| 53 |
+
# =============================================================================
|
| 54 |
+
|
| 55 |
+
# Armazena requisições por IP: {ip: [timestamps]}
|
| 56 |
+
_rate_limit_store: dict[str, list[datetime]] = defaultdict(list)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def check_rate_limit(request: gr.Request) -> bool:
|
| 60 |
+
"""
|
| 61 |
+
Verifica se o IP excedeu o limite de requisições.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
request: Objeto de request do Gradio.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
True se está dentro do limite, False se excedeu.
|
| 68 |
+
"""
|
| 69 |
+
if request is None:
|
| 70 |
+
return True
|
| 71 |
+
|
| 72 |
+
# Obtém IP do cliente
|
| 73 |
+
client_ip = getattr(request, "client", {})
|
| 74 |
+
if isinstance(client_ip, dict):
|
| 75 |
+
ip = client_ip.get("host", "unknown")
|
| 76 |
+
else:
|
| 77 |
+
ip = str(client_ip)
|
| 78 |
+
|
| 79 |
+
now = datetime.now()
|
| 80 |
+
window_start = now - timedelta(hours=config.RATE_LIMIT_WINDOW_HOURS)
|
| 81 |
+
|
| 82 |
+
# Limpa requisições antigas
|
| 83 |
+
_rate_limit_store[ip] = [
|
| 84 |
+
ts for ts in _rate_limit_store[ip]
|
| 85 |
+
if ts > window_start
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
# Verifica limite
|
| 89 |
+
if len(_rate_limit_store[ip]) >= config.RATE_LIMIT_REQUESTS:
|
| 90 |
+
logger.warning(f"Rate limit excedido para IP: {ip}")
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
# Registra nova requisição
|
| 94 |
+
_rate_limit_store[ip].append(now)
|
| 95 |
+
return True
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# =============================================================================
|
| 99 |
+
# FUNÇÃO DE PROCESSAMENTO PRINCIPAL
|
| 100 |
+
# =============================================================================
|
| 101 |
+
|
| 102 |
+
def _process_documents_internal(
|
| 103 |
+
files: list,
|
| 104 |
+
output_format: str,
|
| 105 |
+
progress: Optional[gr.Progress] = None
|
| 106 |
+
) -> tuple[str | list[str], str]:
|
| 107 |
+
"""
|
| 108 |
+
Função interna de processamento (sem decorator GPU).
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
files: Lista de arquivos enviados.
|
| 112 |
+
output_format: Formato de saída ("JSON", "Markdown", "Ambos").
|
| 113 |
+
progress: Objeto de progresso do Gradio.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
Tupla (caminho(s) do arquivo de saída, mensagem de status).
|
| 117 |
+
"""
|
| 118 |
+
start_time = time.time()
|
| 119 |
+
|
| 120 |
+
# Limpa arquivos temporários antigos
|
| 121 |
+
cleanup_old_files()
|
| 122 |
+
|
| 123 |
+
# Valida arquivos
|
| 124 |
+
if progress:
|
| 125 |
+
progress(0.1, desc="Validando arquivos...")
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
validated_files = validate_files(files)
|
| 129 |
+
except ValidationError as e:
|
| 130 |
+
logger.warning(f"Erro de validação: {e.message}")
|
| 131 |
+
raise gr.Error(e.message)
|
| 132 |
+
|
| 133 |
+
# Prepara processador
|
| 134 |
+
if progress:
|
| 135 |
+
progress(0.2, desc="Inicializando Docling...")
|
| 136 |
+
|
| 137 |
+
processor = DoclingProcessor(
|
| 138 |
+
enable_ocr=True,
|
| 139 |
+
enable_table_detection=True,
|
| 140 |
+
use_gpu=HAS_SPACES
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Cria diretório de saída
|
| 144 |
+
output_dir = create_temp_directory(prefix="output_")
|
| 145 |
+
output_files = []
|
| 146 |
+
processed_count = 0
|
| 147 |
+
total_files = len(validated_files)
|
| 148 |
+
|
| 149 |
+
# Processa cada arquivo
|
| 150 |
+
for i, (file_path, sanitized_name) in enumerate(validated_files):
|
| 151 |
+
progress_pct = 0.2 + (0.6 * (i / total_files))
|
| 152 |
+
|
| 153 |
+
if progress:
|
| 154 |
+
progress(progress_pct, desc=f"Processando {sanitized_name}...")
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
# Processa documento
|
| 158 |
+
processed_data = processor.process_document(file_path)
|
| 159 |
+
|
| 160 |
+
# Gera nome base sem extensão
|
| 161 |
+
base_name = Path(sanitized_name).stem
|
| 162 |
+
|
| 163 |
+
# Formata saída
|
| 164 |
+
if output_format == "JSON":
|
| 165 |
+
json_content = format_to_json(processed_data, sanitized_name)
|
| 166 |
+
json_path = save_output_file(
|
| 167 |
+
json_content,
|
| 168 |
+
f"{base_name}.json",
|
| 169 |
+
output_dir
|
| 170 |
+
)
|
| 171 |
+
output_files.append((json_path, f"{base_name}.json"))
|
| 172 |
+
|
| 173 |
+
elif output_format == "Markdown":
|
| 174 |
+
md_content = format_to_markdown(processed_data)
|
| 175 |
+
md_path = save_output_file(
|
| 176 |
+
md_content,
|
| 177 |
+
f"{base_name}.md",
|
| 178 |
+
output_dir
|
| 179 |
+
)
|
| 180 |
+
output_files.append((md_path, f"{base_name}.md"))
|
| 181 |
+
|
| 182 |
+
else: # Ambos
|
| 183 |
+
json_content = format_to_json(processed_data, sanitized_name)
|
| 184 |
+
md_content = format_to_markdown(processed_data)
|
| 185 |
+
|
| 186 |
+
json_path = save_output_file(
|
| 187 |
+
json_content,
|
| 188 |
+
f"{base_name}.json",
|
| 189 |
+
output_dir
|
| 190 |
+
)
|
| 191 |
+
md_path = save_output_file(
|
| 192 |
+
md_content,
|
| 193 |
+
f"{base_name}.md",
|
| 194 |
+
output_dir
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
output_files.append((json_path, f"{base_name}.json"))
|
| 198 |
+
output_files.append((md_path, f"{base_name}.md"))
|
| 199 |
+
|
| 200 |
+
processed_count += 1
|
| 201 |
+
logger.info(f"Processado: {sanitized_name}")
|
| 202 |
+
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error(f"Erro ao processar {sanitized_name}: {e}")
|
| 205 |
+
logger.debug(traceback.format_exc())
|
| 206 |
+
|
| 207 |
+
# Continua com próximos arquivos
|
| 208 |
+
if total_files == 1:
|
| 209 |
+
raise gr.Error(
|
| 210 |
+
f"❌ Erro ao processar {sanitized_name}: {str(e)}"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Prepara saída final
|
| 214 |
+
if progress:
|
| 215 |
+
progress(0.9, desc="Preparando download...")
|
| 216 |
+
|
| 217 |
+
if not output_files:
|
| 218 |
+
raise gr.Error("❌ Nenhum arquivo foi processado com sucesso.")
|
| 219 |
+
|
| 220 |
+
# Se há múltiplos arquivos ou formato "Ambos", cria ZIP
|
| 221 |
+
if len(output_files) > 1 or output_format == "Ambos":
|
| 222 |
+
zip_path = create_zip_output(
|
| 223 |
+
output_files,
|
| 224 |
+
output_name="documentos_processados"
|
| 225 |
+
)
|
| 226 |
+
final_output = str(zip_path)
|
| 227 |
+
else:
|
| 228 |
+
final_output = str(output_files[0][0])
|
| 229 |
+
|
| 230 |
+
# Calcula tempo total
|
| 231 |
+
elapsed_time = time.time() - start_time
|
| 232 |
+
|
| 233 |
+
if progress:
|
| 234 |
+
progress(1.0, desc="Concluído!")
|
| 235 |
+
|
| 236 |
+
# Mensagem de status
|
| 237 |
+
status_msg = (
|
| 238 |
+
f"✅ Processamento concluído!\n\n"
|
| 239 |
+
f"📄 **Arquivos processados:** {processed_count}/{total_files}\n"
|
| 240 |
+
f"📦 **Formato:** {output_format}\n"
|
| 241 |
+
f"⏱️ **Tempo:** {elapsed_time:.1f} segundos"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
logger.info(
|
| 245 |
+
f"Batch concluído: {processed_count}/{total_files} arquivos, "
|
| 246 |
+
f"{elapsed_time:.1f}s, formato={output_format}"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return final_output, status_msg
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# Versão com GPU (se disponível)
|
| 253 |
+
if HAS_SPACES:
|
| 254 |
+
@spaces.GPU(duration=config.GPU_TIMEOUT_SECONDS)
|
| 255 |
+
def process_documents_gpu(
|
| 256 |
+
files: list,
|
| 257 |
+
output_format: str,
|
| 258 |
+
progress: gr.Progress = gr.Progress()
|
| 259 |
+
) -> tuple[str | list[str], str]:
|
| 260 |
+
"""Processamento com aceleração GPU via ZeroGPU."""
|
| 261 |
+
return _process_documents_internal(files, output_format, progress)
|
| 262 |
+
else:
|
| 263 |
+
process_documents_gpu = None
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def process_documents(
|
| 267 |
+
files: list,
|
| 268 |
+
output_format: str,
|
| 269 |
+
request: gr.Request,
|
| 270 |
+
progress: gr.Progress = gr.Progress()
|
| 271 |
+
) -> tuple[str | list[str], str]:
|
| 272 |
+
"""
|
| 273 |
+
Função principal de processamento.
|
| 274 |
+
|
| 275 |
+
Usa GPU se disponível, senão fallback para CPU.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
files: Lista de arquivos enviados.
|
| 279 |
+
output_format: Formato de saída.
|
| 280 |
+
request: Request do Gradio (para rate limiting).
|
| 281 |
+
progress: Objeto de progresso.
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
Tupla (caminho do arquivo de saída, mensagem de status).
|
| 285 |
+
"""
|
| 286 |
+
# Verifica rate limit
|
| 287 |
+
if not check_rate_limit(request):
|
| 288 |
+
raise gr.Error(
|
| 289 |
+
f"⚠️ Limite de requisições excedido. "
|
| 290 |
+
f"Máximo: {config.RATE_LIMIT_REQUESTS} por hora. "
|
| 291 |
+
f"Tente novamente mais tarde."
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
try:
|
| 295 |
+
# Tenta usar GPU
|
| 296 |
+
if HAS_SPACES and process_documents_gpu is not None:
|
| 297 |
+
logger.info("Usando processamento GPU (ZeroGPU)")
|
| 298 |
+
return process_documents_gpu(files, output_format, progress)
|
| 299 |
+
else:
|
| 300 |
+
logger.info("Usando processamento CPU (fallback)")
|
| 301 |
+
return _process_documents_internal(files, output_format, progress)
|
| 302 |
+
|
| 303 |
+
except gr.Error:
|
| 304 |
+
# Re-raise erros do Gradio
|
| 305 |
+
raise
|
| 306 |
+
except TimeoutError:
|
| 307 |
+
logger.error("Timeout no processamento")
|
| 308 |
+
raise gr.Error(
|
| 309 |
+
"⏱️ Tempo limite excedido. Tente com arquivos menores ou menos arquivos."
|
| 310 |
+
)
|
| 311 |
+
except MemoryError:
|
| 312 |
+
logger.error("Memória insuficiente")
|
| 313 |
+
raise gr.Error(
|
| 314 |
+
"💾 Memória insuficiente. Tente com arquivos menores."
|
| 315 |
+
)
|
| 316 |
+
except Exception as e:
|
| 317 |
+
logger.error(f"Erro inesperado: {e}")
|
| 318 |
+
logger.debug(traceback.format_exc())
|
| 319 |
+
raise gr.Error(f"❌ Erro inesperado: {str(e)}")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# =============================================================================
|
| 323 |
+
# INTERFACE GRADIO
|
| 324 |
+
# =============================================================================
|
| 325 |
+
|
| 326 |
+
# CSS customizado
|
| 327 |
+
CUSTOM_CSS = """
|
| 328 |
+
.main-container {
|
| 329 |
+
max-width: 900px;
|
| 330 |
+
margin: 0 auto;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.upload-box {
|
| 334 |
+
border: 2px dashed #4a90a4;
|
| 335 |
+
border-radius: 12px;
|
| 336 |
+
padding: 20px;
|
| 337 |
+
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.status-box {
|
| 341 |
+
background: #f0f7f4;
|
| 342 |
+
border-radius: 8px;
|
| 343 |
+
padding: 15px;
|
| 344 |
+
margin-top: 10px;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.info-text {
|
| 348 |
+
font-size: 0.9em;
|
| 349 |
+
color: #666;
|
| 350 |
+
}
|
| 351 |
+
"""
|
| 352 |
+
|
| 353 |
+
# Texto de descrição
|
| 354 |
+
DESCRIPTION = """
|
| 355 |
+
# 📄 Docling Document Processor
|
| 356 |
+
|
| 357 |
+
Converta documentos PDF, DOC e DOCX em formatos estruturados usando IA.
|
| 358 |
+
|
| 359 |
+
## Recursos
|
| 360 |
+
- 🔍 **Extração inteligente** de texto, tabelas e metadados
|
| 361 |
+
- **Detecção automática** de idioma
|
| 362 |
+
- 🚀 **Aceleração GPU** para processamento rápido
|
| 363 |
+
- 📊 **Preserva estrutura** hierárquica do documento
|
| 364 |
+
"""
|
| 365 |
+
|
| 366 |
+
INSTRUCTIONS = """
|
| 367 |
+
### Como usar
|
| 368 |
+
|
| 369 |
+
1. **Upload**: Arraste ou selecione seus arquivos (máx. 5 arquivos, 50MB cada)
|
| 370 |
+
2. **Formato**: Escolha o formato de saída desejado
|
| 371 |
+
3. **Processar**: Clique no botão e aguarde
|
| 372 |
+
4. **Download**: Baixe o resultado quando concluído
|
| 373 |
+
|
| 374 |
+
### Formatos suportados
|
| 375 |
+
- **Entrada**: PDF, DOC, DOCX
|
| 376 |
+
- **Saída**: JSON, Markdown ou ambos (ZIP)
|
| 377 |
+
"""
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def create_interface() -> gr.Blocks:
|
| 381 |
+
"""Cria e retorna a interface Gradio."""
|
| 382 |
+
|
| 383 |
+
with gr.Blocks(
|
| 384 |
+
title="Docling Document Processor",
|
| 385 |
+
theme=gr.themes.Soft(
|
| 386 |
+
primary_hue="teal",
|
| 387 |
+
secondary_hue="blue",
|
| 388 |
+
),
|
| 389 |
+
css=CUSTOM_CSS,
|
| 390 |
+
) as demo:
|
| 391 |
+
|
| 392 |
+
# Header
|
| 393 |
+
gr.Markdown(DESCRIPTION)
|
| 394 |
+
|
| 395 |
+
with gr.Row():
|
| 396 |
+
# Coluna principal
|
| 397 |
+
with gr.Column(scale=2):
|
| 398 |
+
# Upload de arquivos
|
| 399 |
+
file_input = gr.File(
|
| 400 |
+
file_count="multiple",
|
| 401 |
+
file_types=[".pdf", ".doc", ".docx"],
|
| 402 |
+
label="📁 Upload de Documentos",
|
| 403 |
+
elem_classes=["upload-box"],
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
# Seletor de formato
|
| 407 |
+
format_selector = gr.Radio(
|
| 408 |
+
choices=config.OUTPUT_FORMATS,
|
| 409 |
+
value="Markdown",
|
| 410 |
+
label="📤 Formato de Saída",
|
| 411 |
+
info="Escolha como deseja receber o documento processado",
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
# Botão de processar
|
| 415 |
+
process_btn = gr.Button(
|
| 416 |
+
"🚀 Processar Documentos",
|
| 417 |
+
variant="primary",
|
| 418 |
+
size="lg",
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
# Coluna de informações
|
| 422 |
+
with gr.Column(scale=1):
|
| 423 |
+
gr.Markdown(INSTRUCTIONS)
|
| 424 |
+
|
| 425 |
+
# Área de resultados
|
| 426 |
+
with gr.Row():
|
| 427 |
+
with gr.Column():
|
| 428 |
+
# Status
|
| 429 |
+
status_output = gr.Markdown(
|
| 430 |
+
label="Status",
|
| 431 |
+
elem_classes=["status-box"],
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
# Arquivo de saída
|
| 435 |
+
file_output = gr.File(
|
| 436 |
+
label="📥 Download do Resultado",
|
| 437 |
+
interactive=False,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# Informações de limites
|
| 441 |
+
gr.Markdown(
|
| 442 |
+
f"""
|
| 443 |
+
---
|
| 444 |
+
**Limites:** {config.MAX_FILES_PER_SESSION} arquivos por vez |
|
| 445 |
+
{config.MAX_FILE_SIZE_MB}MB por arquivo |
|
| 446 |
+
{config.RATE_LIMIT_REQUESTS} requisições/hora
|
| 447 |
+
""",
|
| 448 |
+
elem_classes=["info-text"],
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# Evento de processamento
|
| 452 |
+
process_btn.click(
|
| 453 |
+
fn=process_documents,
|
| 454 |
+
inputs=[file_input, format_selector],
|
| 455 |
+
outputs=[file_output, status_output],
|
| 456 |
+
show_progress="full",
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
# Limpa status quando novos arquivos são selecionados
|
| 460 |
+
file_input.change(
|
| 461 |
+
fn=lambda: ("", None),
|
| 462 |
+
outputs=[status_output, file_output],
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
return demo
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
# =============================================================================
|
| 469 |
+
# PONTO DE ENTRADA
|
| 470 |
+
# =============================================================================
|
| 471 |
+
|
| 472 |
+
if __name__ == "__main__":
|
| 473 |
+
# Cria diretórios necessários
|
| 474 |
+
config.TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 475 |
+
config.LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
| 476 |
+
|
| 477 |
+
# Limpa arquivos temporários antigos
|
| 478 |
+
cleanup_old_files()
|
| 479 |
+
|
| 480 |
+
logger.info("Iniciando Docling Document Processor...")
|
| 481 |
+
logger.info(f"ZeroGPU disponível: {HAS_SPACES}")
|
| 482 |
+
|
| 483 |
+
# Cria e lança a interface
|
| 484 |
+
demo = create_interface()
|
| 485 |
+
|
| 486 |
+
demo.queue().launch(
|
| 487 |
+
server_name="0.0.0.0",
|
| 488 |
+
server_port=7860,
|
| 489 |
+
max_file_size=f"{config.MAX_FILE_SIZE_MB}mb",
|
| 490 |
+
show_error=True,
|
| 491 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configurações centralizadas para o Docling Document Processor.
|
| 3 |
+
|
| 4 |
+
Este módulo contém todas as constantes e configurações usadas
|
| 5 |
+
em toda a aplicação.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
# =============================================================================
|
| 11 |
+
# LIMITES DE ARQUIVO
|
| 12 |
+
# =============================================================================
|
| 13 |
+
|
| 14 |
+
MAX_FILE_SIZE_MB: int = 50
|
| 15 |
+
"""Tamanho máximo de arquivo em megabytes."""
|
| 16 |
+
|
| 17 |
+
MAX_FILE_SIZE_BYTES: int = MAX_FILE_SIZE_MB * 1024 * 1024
|
| 18 |
+
"""Tamanho máximo de arquivo em bytes (calculado)."""
|
| 19 |
+
|
| 20 |
+
MAX_FILES_PER_SESSION: int = 5
|
| 21 |
+
"""Número máximo de arquivos por upload."""
|
| 22 |
+
|
| 23 |
+
# =============================================================================
|
| 24 |
+
# PROCESSAMENTO
|
| 25 |
+
# =============================================================================
|
| 26 |
+
|
| 27 |
+
PROCESSING_TIMEOUT_SECONDS: int = 300
|
| 28 |
+
"""Timeout para processamento de documentos (5 minutos)."""
|
| 29 |
+
|
| 30 |
+
GPU_TIMEOUT_SECONDS: int = 300
|
| 31 |
+
"""Timeout para execução GPU via ZeroGPU."""
|
| 32 |
+
|
| 33 |
+
# =============================================================================
|
| 34 |
+
# TIPOS DE ARQUIVO SUPORTADOS
|
| 35 |
+
# =============================================================================
|
| 36 |
+
|
| 37 |
+
SUPPORTED_EXTENSIONS: list[str] = [".pdf", ".doc", ".docx"]
|
| 38 |
+
"""Extensões de arquivo aceitas."""
|
| 39 |
+
|
| 40 |
+
SUPPORTED_MIME_TYPES: dict[str, list[str]] = {
|
| 41 |
+
".pdf": ["application/pdf"],
|
| 42 |
+
".doc": ["application/msword"],
|
| 43 |
+
".docx": [
|
| 44 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 45 |
+
],
|
| 46 |
+
}
|
| 47 |
+
"""Mapeamento de extensões para MIME types válidos."""
|
| 48 |
+
|
| 49 |
+
# =============================================================================
|
| 50 |
+
# RATE LIMITING
|
| 51 |
+
# =============================================================================
|
| 52 |
+
|
| 53 |
+
RATE_LIMIT_REQUESTS: int = 10
|
| 54 |
+
"""Número máximo de requisições por janela de tempo."""
|
| 55 |
+
|
| 56 |
+
RATE_LIMIT_WINDOW_HOURS: int = 1
|
| 57 |
+
"""Janela de tempo para rate limiting em horas."""
|
| 58 |
+
|
| 59 |
+
# =============================================================================
|
| 60 |
+
# DIRETÓRIOS
|
| 61 |
+
# =============================================================================
|
| 62 |
+
|
| 63 |
+
BASE_DIR: Path = Path(__file__).parent
|
| 64 |
+
"""Diretório base da aplicação."""
|
| 65 |
+
|
| 66 |
+
TEMP_DIR: Path = BASE_DIR / "temp"
|
| 67 |
+
"""Diretório para arquivos temporários."""
|
| 68 |
+
|
| 69 |
+
LOGS_DIR: Path = BASE_DIR / "logs"
|
| 70 |
+
"""Diretório para arquivos de log."""
|
| 71 |
+
|
| 72 |
+
TEMP_DIR_CLEANUP_HOURS: int = 1
|
| 73 |
+
"""Tempo máximo de retenção de arquivos temporários em horas."""
|
| 74 |
+
|
| 75 |
+
# =============================================================================
|
| 76 |
+
# FORMATOS DE SAÍDA
|
| 77 |
+
# =============================================================================
|
| 78 |
+
|
| 79 |
+
OUTPUT_FORMATS: list[str] = ["JSON", "Markdown", "Ambos"]
|
| 80 |
+
"""Formatos de saída disponíveis."""
|
| 81 |
+
|
| 82 |
+
# =============================================================================
|
| 83 |
+
# LOGGING
|
| 84 |
+
# =============================================================================
|
| 85 |
+
|
| 86 |
+
LOG_FILE: str = "docling_space.log"
|
| 87 |
+
"""Nome do arquivo de log."""
|
| 88 |
+
|
| 89 |
+
LOG_MAX_BYTES: int = 10 * 1024 * 1024 # 10MB
|
| 90 |
+
"""Tamanho máximo do arquivo de log antes de rotacionar."""
|
| 91 |
+
|
| 92 |
+
LOG_BACKUP_COUNT: int = 5
|
| 93 |
+
"""Número de arquivos de backup de log a manter."""
|
| 94 |
+
|
| 95 |
+
LOG_FORMAT: str = "[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s"
|
| 96 |
+
"""Formato das mensagens de log."""
|
| 97 |
+
|
| 98 |
+
LOG_DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S"
|
| 99 |
+
"""Formato de data para logs."""
|
| 100 |
+
|
| 101 |
+
# =============================================================================
|
| 102 |
+
# CARACTERES PROIBIDOS EM NOMES DE ARQUIVO
|
| 103 |
+
# =============================================================================
|
| 104 |
+
|
| 105 |
+
FORBIDDEN_FILENAME_CHARS: str = r'<>:"/\|?*'
|
| 106 |
+
"""Caracteres que devem ser removidos de nomes de arquivo."""
|
| 107 |
+
|
| 108 |
+
FILENAME_MAX_LENGTH: int = 255
|
| 109 |
+
"""Comprimento máximo de nome de arquivo."""
|
logs/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Este arquivo mantém o diretório logs no git
|
processors/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de processadores para o Docling Document Processor.
|
| 3 |
+
|
| 4 |
+
Este pacote contém as classes e funções responsáveis pelo
|
| 5 |
+
processamento de documentos e formatação de saída.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from processors.docling_processor import DoclingProcessor
|
| 9 |
+
from processors.json_formatter import format_to_json, JSONFormatter
|
| 10 |
+
from processors.markdown_formatter import format_to_markdown, MarkdownFormatter
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"DoclingProcessor",
|
| 14 |
+
"format_to_json",
|
| 15 |
+
"JSONFormatter",
|
| 16 |
+
"format_to_markdown",
|
| 17 |
+
"MarkdownFormatter",
|
| 18 |
+
]
|
processors/docling_processor.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Processador principal usando Docling.
|
| 3 |
+
|
| 4 |
+
Este módulo contém a classe DoclingProcessor que é responsável por
|
| 5 |
+
converter documentos usando a biblioteca Docling.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
from docling.document_converter import DocumentConverter
|
| 13 |
+
from docling.datamodel.base_models import InputFormat
|
| 14 |
+
from docling.datamodel.pipeline_options import (
|
| 15 |
+
PdfPipelineOptions,
|
| 16 |
+
TableFormerMode,
|
| 17 |
+
)
|
| 18 |
+
from docling.document_converter import PdfFormatOption
|
| 19 |
+
|
| 20 |
+
from utils.logger import get_logger, ProcessingLogger
|
| 21 |
+
|
| 22 |
+
# Logger para este módulo
|
| 23 |
+
logger = get_logger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DoclingProcessor:
|
| 27 |
+
"""
|
| 28 |
+
Processador de documentos usando Docling.
|
| 29 |
+
|
| 30 |
+
Esta classe encapsula a lógica de conversão de documentos,
|
| 31 |
+
incluindo configuração de pipeline e extração de metadados.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
enable_ocr: bool = True,
|
| 37 |
+
enable_table_detection: bool = True,
|
| 38 |
+
use_gpu: bool = True
|
| 39 |
+
):
|
| 40 |
+
"""
|
| 41 |
+
Inicializa o processador Docling.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
enable_ocr: Se deve habilitar OCR para imagens.
|
| 45 |
+
enable_table_detection: Se deve detectar tabelas.
|
| 46 |
+
use_gpu: Se deve tentar usar GPU para aceleração.
|
| 47 |
+
"""
|
| 48 |
+
self.enable_ocr = enable_ocr
|
| 49 |
+
self.enable_table_detection = enable_table_detection
|
| 50 |
+
self.use_gpu = use_gpu
|
| 51 |
+
|
| 52 |
+
# Configuração do pipeline
|
| 53 |
+
self._setup_converter()
|
| 54 |
+
|
| 55 |
+
logger.info(
|
| 56 |
+
f"DoclingProcessor inicializado "
|
| 57 |
+
f"(OCR={enable_ocr}, tabelas={enable_table_detection}, GPU={use_gpu})"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def _setup_converter(self) -> None:
|
| 61 |
+
"""Configura o DocumentConverter com as opções adequadas."""
|
| 62 |
+
# Configurações específicas para PDF
|
| 63 |
+
pipeline_options = PdfPipelineOptions()
|
| 64 |
+
pipeline_options.do_ocr = self.enable_ocr
|
| 65 |
+
pipeline_options.do_table_structure = self.enable_table_detection
|
| 66 |
+
|
| 67 |
+
if self.enable_table_detection:
|
| 68 |
+
# Usa TableFormer para melhor detecção de tabelas
|
| 69 |
+
pipeline_options.table_structure_options.mode = TableFormerMode.ACCURATE
|
| 70 |
+
|
| 71 |
+
# Cria o converter com as opções
|
| 72 |
+
self.converter = DocumentConverter(
|
| 73 |
+
format_options={
|
| 74 |
+
InputFormat.PDF: PdfFormatOption(
|
| 75 |
+
pipeline_options=pipeline_options
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def process_document(self, file_path: str | Path) -> dict[str, Any]:
|
| 81 |
+
"""
|
| 82 |
+
Processa um documento e retorna dados estruturados.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
file_path: Caminho para o arquivo a processar.
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
Dicionário com documento convertido, metadados e tabelas.
|
| 89 |
+
|
| 90 |
+
Raises:
|
| 91 |
+
Exception: Se o processamento falhar.
|
| 92 |
+
"""
|
| 93 |
+
file_path = Path(file_path)
|
| 94 |
+
|
| 95 |
+
with ProcessingLogger(logger, "Conversão Docling", file_path.name):
|
| 96 |
+
start_time = time.time()
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
# Converte o documento
|
| 100 |
+
result = self.converter.convert(str(file_path))
|
| 101 |
+
|
| 102 |
+
# Extrai informações
|
| 103 |
+
document = result.document
|
| 104 |
+
|
| 105 |
+
processing_time = time.time() - start_time
|
| 106 |
+
|
| 107 |
+
return {
|
| 108 |
+
"document": document,
|
| 109 |
+
"metadata": self._extract_metadata(result, file_path),
|
| 110 |
+
"tables": self._extract_tables(document),
|
| 111 |
+
"language": self._detect_language(document),
|
| 112 |
+
"processing_time_seconds": processing_time,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Erro ao processar {file_path.name}: {e}")
|
| 117 |
+
raise
|
| 118 |
+
|
| 119 |
+
def _extract_metadata(
|
| 120 |
+
self,
|
| 121 |
+
result: Any,
|
| 122 |
+
file_path: Path
|
| 123 |
+
) -> dict[str, Any]:
|
| 124 |
+
"""
|
| 125 |
+
Extrai metadados do documento processado.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
result: Resultado da conversão Docling.
|
| 129 |
+
file_path: Caminho do arquivo original.
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
Dicionário com metadados do documento.
|
| 133 |
+
"""
|
| 134 |
+
document = result.document
|
| 135 |
+
|
| 136 |
+
metadata = {
|
| 137 |
+
"nome_arquivo": file_path.name,
|
| 138 |
+
"extensao": file_path.suffix.lower(),
|
| 139 |
+
"tamanho_bytes": file_path.stat().st_size if file_path.exists() else 0,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# Tenta extrair metadados do documento
|
| 143 |
+
try:
|
| 144 |
+
if hasattr(document, "metadata") and document.metadata:
|
| 145 |
+
doc_meta = document.metadata
|
| 146 |
+
if hasattr(doc_meta, "title") and doc_meta.title:
|
| 147 |
+
metadata["titulo"] = doc_meta.title
|
| 148 |
+
if hasattr(doc_meta, "author") and doc_meta.author:
|
| 149 |
+
metadata["autor"] = doc_meta.author
|
| 150 |
+
if hasattr(doc_meta, "creation_date") and doc_meta.creation_date:
|
| 151 |
+
metadata["data_criacao"] = str(doc_meta.creation_date)
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.debug(f"Não foi possível extrair metadados: {e}")
|
| 154 |
+
|
| 155 |
+
# Contagem de elementos
|
| 156 |
+
try:
|
| 157 |
+
if hasattr(document, "pages"):
|
| 158 |
+
metadata["num_paginas"] = len(list(document.pages))
|
| 159 |
+
if hasattr(document, "tables"):
|
| 160 |
+
metadata["num_tabelas"] = len(list(document.tables))
|
| 161 |
+
if hasattr(document, "pictures"):
|
| 162 |
+
metadata["num_imagens"] = len(list(document.pictures))
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.debug(f"Erro ao contar elementos: {e}")
|
| 165 |
+
|
| 166 |
+
return metadata
|
| 167 |
+
|
| 168 |
+
def _extract_tables(self, document: Any) -> list[dict[str, Any]]:
|
| 169 |
+
"""
|
| 170 |
+
Extrai tabelas do documento.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
document: Documento Docling convertido.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Lista de dicionários representando tabelas.
|
| 177 |
+
"""
|
| 178 |
+
tables = []
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
if not hasattr(document, "tables"):
|
| 182 |
+
return tables
|
| 183 |
+
|
| 184 |
+
for i, table in enumerate(document.tables):
|
| 185 |
+
table_data = {
|
| 186 |
+
"indice": i + 1,
|
| 187 |
+
"dados": None,
|
| 188 |
+
"linhas": 0,
|
| 189 |
+
"colunas": 0,
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
# Tenta extrair dados da tabela
|
| 193 |
+
try:
|
| 194 |
+
if hasattr(table, "export_to_dataframe"):
|
| 195 |
+
df = table.export_to_dataframe()
|
| 196 |
+
table_data["dados"] = df.to_dict(orient="records")
|
| 197 |
+
table_data["linhas"] = len(df)
|
| 198 |
+
table_data["colunas"] = len(df.columns)
|
| 199 |
+
table_data["colunas_nomes"] = list(df.columns)
|
| 200 |
+
elif hasattr(table, "to_markdown"):
|
| 201 |
+
table_data["markdown"] = table.to_markdown()
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.debug(f"Erro ao exportar tabela {i+1}: {e}")
|
| 204 |
+
# Fallback: tenta obter texto
|
| 205 |
+
if hasattr(table, "text"):
|
| 206 |
+
table_data["texto"] = table.text
|
| 207 |
+
|
| 208 |
+
tables.append(table_data)
|
| 209 |
+
|
| 210 |
+
except Exception as e:
|
| 211 |
+
logger.warning(f"Erro ao extrair tabelas: {e}")
|
| 212 |
+
|
| 213 |
+
logger.debug(f"Extraídas {len(tables)} tabelas")
|
| 214 |
+
return tables
|
| 215 |
+
|
| 216 |
+
def _detect_language(self, document: Any) -> str:
|
| 217 |
+
"""
|
| 218 |
+
Detecta o idioma do documento.
|
| 219 |
+
|
| 220 |
+
Args:
|
| 221 |
+
document: Documento Docling convertido.
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
Código do idioma detectado (ex: "pt", "en").
|
| 225 |
+
"""
|
| 226 |
+
try:
|
| 227 |
+
# Tenta usar langdetect
|
| 228 |
+
from langdetect import detect, LangDetectException
|
| 229 |
+
|
| 230 |
+
# Extrai texto do documento
|
| 231 |
+
if hasattr(document, "export_to_text"):
|
| 232 |
+
text = document.export_to_text()
|
| 233 |
+
elif hasattr(document, "export_to_markdown"):
|
| 234 |
+
text = document.export_to_markdown()
|
| 235 |
+
else:
|
| 236 |
+
return "desconhecido"
|
| 237 |
+
|
| 238 |
+
# Usa apenas os primeiros 1000 caracteres para detecção
|
| 239 |
+
sample = text[:1000] if text else ""
|
| 240 |
+
|
| 241 |
+
if len(sample) < 20:
|
| 242 |
+
return "desconhecido"
|
| 243 |
+
|
| 244 |
+
lang = detect(sample)
|
| 245 |
+
logger.debug(f"Idioma detectado: {lang}")
|
| 246 |
+
return lang
|
| 247 |
+
|
| 248 |
+
except LangDetectException:
|
| 249 |
+
return "desconhecido"
|
| 250 |
+
except ImportError:
|
| 251 |
+
logger.warning("langdetect não disponível")
|
| 252 |
+
return "nao_detectado"
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.debug(f"Erro na detecção de idioma: {e}")
|
| 255 |
+
return "erro"
|
| 256 |
+
|
| 257 |
+
def get_markdown(self, processed_data: dict[str, Any]) -> str:
|
| 258 |
+
"""
|
| 259 |
+
Obtém o documento em formato Markdown.
|
| 260 |
+
|
| 261 |
+
Args:
|
| 262 |
+
processed_data: Dados retornados por process_document().
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
String com o documento em Markdown.
|
| 266 |
+
"""
|
| 267 |
+
document = processed_data.get("document")
|
| 268 |
+
|
| 269 |
+
if document and hasattr(document, "export_to_markdown"):
|
| 270 |
+
return document.export_to_markdown()
|
| 271 |
+
|
| 272 |
+
return ""
|
| 273 |
+
|
| 274 |
+
def get_text(self, processed_data: dict[str, Any]) -> str:
|
| 275 |
+
"""
|
| 276 |
+
Obtém o documento em texto puro.
|
| 277 |
+
|
| 278 |
+
Args:
|
| 279 |
+
processed_data: Dados retornados por process_document().
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
String com o texto do documento.
|
| 283 |
+
"""
|
| 284 |
+
document = processed_data.get("document")
|
| 285 |
+
|
| 286 |
+
if document and hasattr(document, "export_to_text"):
|
| 287 |
+
return document.export_to_text()
|
| 288 |
+
|
| 289 |
+
return ""
|
processors/json_formatter.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatador de saída JSON.
|
| 3 |
+
|
| 4 |
+
Este módulo contém funções e classes para formatar documentos
|
| 5 |
+
processados em formato JSON estruturado.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from utils.logger import get_logger
|
| 14 |
+
|
| 15 |
+
# Logger para este módulo
|
| 16 |
+
logger = get_logger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def format_to_json(
|
| 20 |
+
processed_data: dict[str, Any],
|
| 21 |
+
filename: str,
|
| 22 |
+
include_raw_content: bool = True,
|
| 23 |
+
pretty_print: bool = True
|
| 24 |
+
) -> str:
|
| 25 |
+
"""
|
| 26 |
+
Formata dados processados em JSON estruturado.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
processed_data: Dados retornados pelo DoclingProcessor.
|
| 30 |
+
filename: Nome do arquivo original.
|
| 31 |
+
include_raw_content: Se deve incluir conteúdo completo.
|
| 32 |
+
pretty_print: Se deve formatar com indentação.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
String JSON formatada.
|
| 36 |
+
"""
|
| 37 |
+
document = processed_data.get("document")
|
| 38 |
+
metadata = processed_data.get("metadata", {})
|
| 39 |
+
tables = processed_data.get("tables", [])
|
| 40 |
+
language = processed_data.get("language", "desconhecido")
|
| 41 |
+
|
| 42 |
+
# Estrutura de saída
|
| 43 |
+
output = {
|
| 44 |
+
"arquivo": filename,
|
| 45 |
+
"idioma": language,
|
| 46 |
+
"processado_em": datetime.now().isoformat(),
|
| 47 |
+
"metadados": metadata,
|
| 48 |
+
"tabelas": tables,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
# Adiciona conteúdo
|
| 52 |
+
if include_raw_content and document:
|
| 53 |
+
try:
|
| 54 |
+
# Tenta exportar para dict
|
| 55 |
+
if hasattr(document, "export_to_dict"):
|
| 56 |
+
output["conteudo"] = document.export_to_dict()
|
| 57 |
+
elif hasattr(document, "export_to_markdown"):
|
| 58 |
+
output["conteudo_markdown"] = document.export_to_markdown()
|
| 59 |
+
elif hasattr(document, "export_to_text"):
|
| 60 |
+
output["conteudo_texto"] = document.export_to_text()
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.warning(f"Erro ao exportar conteúdo: {e}")
|
| 63 |
+
output["conteudo"] = None
|
| 64 |
+
output["erro_exportacao"] = str(e)
|
| 65 |
+
|
| 66 |
+
# Adiciona tempo de processamento se disponível
|
| 67 |
+
if "processing_time_seconds" in processed_data:
|
| 68 |
+
output["tempo_processamento_segundos"] = processed_data["processing_time_seconds"]
|
| 69 |
+
|
| 70 |
+
# Serializa para JSON
|
| 71 |
+
indent = 2 if pretty_print else None
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
return json.dumps(
|
| 75 |
+
output,
|
| 76 |
+
ensure_ascii=False,
|
| 77 |
+
indent=indent,
|
| 78 |
+
default=_json_serializer
|
| 79 |
+
)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Erro ao serializar JSON: {e}")
|
| 82 |
+
# Fallback: tenta sem conteúdo complexo
|
| 83 |
+
output.pop("conteudo", None)
|
| 84 |
+
output["erro_serializacao"] = str(e)
|
| 85 |
+
return json.dumps(output, ensure_ascii=False, indent=indent)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _json_serializer(obj: Any) -> Any:
|
| 89 |
+
"""
|
| 90 |
+
Serializador customizado para objetos não-JSON.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
obj: Objeto a serializar.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Representação serializável do objeto.
|
| 97 |
+
"""
|
| 98 |
+
if hasattr(obj, "isoformat"):
|
| 99 |
+
return obj.isoformat()
|
| 100 |
+
if hasattr(obj, "__dict__"):
|
| 101 |
+
return obj.__dict__
|
| 102 |
+
if isinstance(obj, bytes):
|
| 103 |
+
return obj.decode("utf-8", errors="replace")
|
| 104 |
+
if isinstance(obj, set):
|
| 105 |
+
return list(obj)
|
| 106 |
+
if isinstance(obj, Path):
|
| 107 |
+
return str(obj)
|
| 108 |
+
|
| 109 |
+
return str(obj)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class JSONFormatter:
|
| 113 |
+
"""
|
| 114 |
+
Classe para formatação JSON com configurações personalizadas.
|
| 115 |
+
|
| 116 |
+
Permite manter configurações consistentes entre múltiplas formatações.
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
def __init__(
|
| 120 |
+
self,
|
| 121 |
+
include_raw_content: bool = True,
|
| 122 |
+
pretty_print: bool = True,
|
| 123 |
+
include_tables: bool = True,
|
| 124 |
+
include_metadata: bool = True
|
| 125 |
+
):
|
| 126 |
+
"""
|
| 127 |
+
Inicializa o formatador JSON.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
include_raw_content: Se deve incluir conteúdo completo.
|
| 131 |
+
pretty_print: Se deve formatar com indentação.
|
| 132 |
+
include_tables: Se deve incluir tabelas extraídas.
|
| 133 |
+
include_metadata: Se deve incluir metadados.
|
| 134 |
+
"""
|
| 135 |
+
self.include_raw_content = include_raw_content
|
| 136 |
+
self.pretty_print = pretty_print
|
| 137 |
+
self.include_tables = include_tables
|
| 138 |
+
self.include_metadata = include_metadata
|
| 139 |
+
|
| 140 |
+
def format(
|
| 141 |
+
self,
|
| 142 |
+
processed_data: dict[str, Any],
|
| 143 |
+
filename: str
|
| 144 |
+
) -> str:
|
| 145 |
+
"""
|
| 146 |
+
Formata dados processados em JSON.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
processed_data: Dados do DoclingProcessor.
|
| 150 |
+
filename: Nome do arquivo original.
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
String JSON formatada.
|
| 154 |
+
"""
|
| 155 |
+
# Copia para não modificar original
|
| 156 |
+
data = processed_data.copy()
|
| 157 |
+
|
| 158 |
+
# Remove elementos não desejados
|
| 159 |
+
if not self.include_tables:
|
| 160 |
+
data["tables"] = []
|
| 161 |
+
|
| 162 |
+
if not self.include_metadata:
|
| 163 |
+
data["metadata"] = {}
|
| 164 |
+
|
| 165 |
+
return format_to_json(
|
| 166 |
+
data,
|
| 167 |
+
filename,
|
| 168 |
+
include_raw_content=self.include_raw_content,
|
| 169 |
+
pretty_print=self.pretty_print
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
def format_batch(
|
| 173 |
+
self,
|
| 174 |
+
items: list[tuple[dict[str, Any], str]]
|
| 175 |
+
) -> str:
|
| 176 |
+
"""
|
| 177 |
+
Formata múltiplos documentos em um único JSON.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
items: Lista de tuplas (processed_data, filename).
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
String JSON com array de documentos.
|
| 184 |
+
"""
|
| 185 |
+
documents = []
|
| 186 |
+
|
| 187 |
+
for processed_data, filename in items:
|
| 188 |
+
# Formata individualmente e converte de volta para dict
|
| 189 |
+
json_str = self.format(processed_data, filename)
|
| 190 |
+
doc = json.loads(json_str)
|
| 191 |
+
documents.append(doc)
|
| 192 |
+
|
| 193 |
+
indent = 2 if self.pretty_print else None
|
| 194 |
+
|
| 195 |
+
return json.dumps(
|
| 196 |
+
{"documentos": documents, "total": len(documents)},
|
| 197 |
+
ensure_ascii=False,
|
| 198 |
+
indent=indent
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def save_json(
|
| 203 |
+
content: str | dict,
|
| 204 |
+
output_path: str | Path,
|
| 205 |
+
encoding: str = "utf-8"
|
| 206 |
+
) -> Path:
|
| 207 |
+
"""
|
| 208 |
+
Salva conteúdo JSON em arquivo.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
content: String JSON ou dicionário.
|
| 212 |
+
output_path: Caminho do arquivo de saída.
|
| 213 |
+
encoding: Encoding do arquivo.
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
Path para o arquivo salvo.
|
| 217 |
+
"""
|
| 218 |
+
output_path = Path(output_path)
|
| 219 |
+
|
| 220 |
+
if isinstance(content, dict):
|
| 221 |
+
content = json.dumps(content, ensure_ascii=False, indent=2)
|
| 222 |
+
|
| 223 |
+
output_path.write_text(content, encoding=encoding)
|
| 224 |
+
logger.debug(f"JSON salvo: {output_path}")
|
| 225 |
+
|
| 226 |
+
return output_path
|
processors/markdown_formatter.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatador de saída Markdown.
|
| 3 |
+
|
| 4 |
+
Este módulo contém funções e classes para formatar documentos
|
| 5 |
+
processados em formato Markdown.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
from utils.logger import get_logger
|
| 13 |
+
|
| 14 |
+
# Logger para este módulo
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def format_to_markdown(
|
| 19 |
+
processed_data: dict[str, Any],
|
| 20 |
+
include_metadata_header: bool = True,
|
| 21 |
+
include_tables: bool = True
|
| 22 |
+
) -> str:
|
| 23 |
+
"""
|
| 24 |
+
Formata dados processados em Markdown.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
processed_data: Dados retornados pelo DoclingProcessor.
|
| 28 |
+
include_metadata_header: Se deve incluir cabeçalho com metadados.
|
| 29 |
+
include_tables: Se deve incluir tabelas formatadas.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
String Markdown formatada.
|
| 33 |
+
"""
|
| 34 |
+
document = processed_data.get("document")
|
| 35 |
+
metadata = processed_data.get("metadata", {})
|
| 36 |
+
tables = processed_data.get("tables", [])
|
| 37 |
+
language = processed_data.get("language", "desconhecido")
|
| 38 |
+
|
| 39 |
+
sections = []
|
| 40 |
+
|
| 41 |
+
# Cabeçalho com metadados
|
| 42 |
+
if include_metadata_header:
|
| 43 |
+
header = _format_metadata_header(metadata, language)
|
| 44 |
+
if header:
|
| 45 |
+
sections.append(header)
|
| 46 |
+
|
| 47 |
+
# Conteúdo principal do documento
|
| 48 |
+
if document:
|
| 49 |
+
try:
|
| 50 |
+
if hasattr(document, "export_to_markdown"):
|
| 51 |
+
content = document.export_to_markdown()
|
| 52 |
+
if content:
|
| 53 |
+
sections.append(content)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.warning(f"Erro ao exportar Markdown: {e}")
|
| 56 |
+
sections.append(f"> ⚠️ Erro ao exportar conteúdo: {e}")
|
| 57 |
+
|
| 58 |
+
# Tabelas (se não foram incluídas no export padrão)
|
| 59 |
+
if include_tables and tables:
|
| 60 |
+
tables_section = _format_tables_section(tables)
|
| 61 |
+
if tables_section:
|
| 62 |
+
sections.append(tables_section)
|
| 63 |
+
|
| 64 |
+
return "\n\n---\n\n".join(sections)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _format_metadata_header(
|
| 68 |
+
metadata: dict[str, Any],
|
| 69 |
+
language: str
|
| 70 |
+
) -> str:
|
| 71 |
+
"""
|
| 72 |
+
Formata cabeçalho com metadados.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
metadata: Dicionário de metadados.
|
| 76 |
+
language: Código do idioma.
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
String Markdown com metadados.
|
| 80 |
+
"""
|
| 81 |
+
lines = []
|
| 82 |
+
|
| 83 |
+
# Título
|
| 84 |
+
titulo = metadata.get("titulo", metadata.get("nome_arquivo", "Documento"))
|
| 85 |
+
lines.append(f"# {titulo}")
|
| 86 |
+
lines.append("")
|
| 87 |
+
|
| 88 |
+
# Metadados como lista
|
| 89 |
+
meta_items = []
|
| 90 |
+
|
| 91 |
+
if metadata.get("autor"):
|
| 92 |
+
meta_items.append(f"**Autor:** {metadata['autor']}")
|
| 93 |
+
|
| 94 |
+
if metadata.get("data_criacao"):
|
| 95 |
+
meta_items.append(f"**Data de criação:** {metadata['data_criacao']}")
|
| 96 |
+
|
| 97 |
+
if language and language not in ("desconhecido", "erro", "nao_detectado"):
|
| 98 |
+
lang_names = {
|
| 99 |
+
"pt": "Português",
|
| 100 |
+
"en": "Inglês",
|
| 101 |
+
"es": "Espanhol",
|
| 102 |
+
"fr": "Francês",
|
| 103 |
+
"de": "Alemão",
|
| 104 |
+
"it": "Italiano",
|
| 105 |
+
}
|
| 106 |
+
lang_name = lang_names.get(language, language.upper())
|
| 107 |
+
meta_items.append(f"**Idioma:** {lang_name}")
|
| 108 |
+
|
| 109 |
+
if metadata.get("num_paginas"):
|
| 110 |
+
meta_items.append(f"**Páginas:** {metadata['num_paginas']}")
|
| 111 |
+
|
| 112 |
+
if metadata.get("num_tabelas"):
|
| 113 |
+
meta_items.append(f"**Tabelas:** {metadata['num_tabelas']}")
|
| 114 |
+
|
| 115 |
+
if metadata.get("num_imagens"):
|
| 116 |
+
meta_items.append(f"**Imagens:** {metadata['num_imagens']}")
|
| 117 |
+
|
| 118 |
+
if meta_items:
|
| 119 |
+
lines.extend(meta_items)
|
| 120 |
+
lines.append("")
|
| 121 |
+
|
| 122 |
+
return "\n".join(lines)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _format_tables_section(tables: list[dict[str, Any]]) -> str:
|
| 126 |
+
"""
|
| 127 |
+
Formata seção de tabelas.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
tables: Lista de tabelas extraídas.
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
String Markdown com tabelas.
|
| 134 |
+
"""
|
| 135 |
+
if not tables:
|
| 136 |
+
return ""
|
| 137 |
+
|
| 138 |
+
lines = ["## Tabelas Extraídas", ""]
|
| 139 |
+
|
| 140 |
+
for table in tables:
|
| 141 |
+
index = table.get("indice", 0)
|
| 142 |
+
lines.append(f"### Tabela {index}")
|
| 143 |
+
lines.append("")
|
| 144 |
+
|
| 145 |
+
# Se tem dados como dict/list, formata como tabela MD
|
| 146 |
+
if table.get("dados"):
|
| 147 |
+
md_table = _dict_to_markdown_table(table["dados"])
|
| 148 |
+
lines.append(md_table)
|
| 149 |
+
elif table.get("markdown"):
|
| 150 |
+
lines.append(table["markdown"])
|
| 151 |
+
elif table.get("texto"):
|
| 152 |
+
lines.append(f"```\n{table['texto']}\n```")
|
| 153 |
+
else:
|
| 154 |
+
lines.append("*Dados da tabela não disponíveis*")
|
| 155 |
+
|
| 156 |
+
lines.append("")
|
| 157 |
+
|
| 158 |
+
return "\n".join(lines)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _dict_to_markdown_table(data: list[dict[str, Any]]) -> str:
|
| 162 |
+
"""
|
| 163 |
+
Converte lista de dicionários em tabela Markdown.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
data: Lista de dicionários (cada dict = uma linha).
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
String com tabela em formato Markdown pipe.
|
| 170 |
+
"""
|
| 171 |
+
if not data:
|
| 172 |
+
return "*Tabela vazia*"
|
| 173 |
+
|
| 174 |
+
# Pega colunas do primeiro item
|
| 175 |
+
headers = list(data[0].keys())
|
| 176 |
+
|
| 177 |
+
lines = []
|
| 178 |
+
|
| 179 |
+
# Cabeçalho
|
| 180 |
+
header_line = "| " + " | ".join(str(h) for h in headers) + " |"
|
| 181 |
+
lines.append(header_line)
|
| 182 |
+
|
| 183 |
+
# Separador
|
| 184 |
+
separator = "| " + " | ".join("---" for _ in headers) + " |"
|
| 185 |
+
lines.append(separator)
|
| 186 |
+
|
| 187 |
+
# Dados
|
| 188 |
+
for row in data:
|
| 189 |
+
values = []
|
| 190 |
+
for h in headers:
|
| 191 |
+
value = row.get(h, "")
|
| 192 |
+
# Escapa pipes no conteúdo
|
| 193 |
+
value = str(value).replace("|", "\\|")
|
| 194 |
+
# Remove quebras de linha
|
| 195 |
+
value = value.replace("\n", " ")
|
| 196 |
+
values.append(value)
|
| 197 |
+
|
| 198 |
+
row_line = "| " + " | ".join(values) + " |"
|
| 199 |
+
lines.append(row_line)
|
| 200 |
+
|
| 201 |
+
return "\n".join(lines)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class MarkdownFormatter:
|
| 205 |
+
"""
|
| 206 |
+
Classe para formatação Markdown com configurações personalizadas.
|
| 207 |
+
|
| 208 |
+
Permite manter configurações consistentes entre múltiplas formatações.
|
| 209 |
+
"""
|
| 210 |
+
|
| 211 |
+
def __init__(
|
| 212 |
+
self,
|
| 213 |
+
include_metadata_header: bool = True,
|
| 214 |
+
include_tables: bool = True,
|
| 215 |
+
include_toc: bool = False,
|
| 216 |
+
max_heading_level: int = 6
|
| 217 |
+
):
|
| 218 |
+
"""
|
| 219 |
+
Inicializa o formatador Markdown.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
include_metadata_header: Se deve incluir cabeçalho com metadados.
|
| 223 |
+
include_tables: Se deve incluir tabelas extraídas.
|
| 224 |
+
include_toc: Se deve incluir sumário (Table of Contents).
|
| 225 |
+
max_heading_level: Nível máximo de heading a usar.
|
| 226 |
+
"""
|
| 227 |
+
self.include_metadata_header = include_metadata_header
|
| 228 |
+
self.include_tables = include_tables
|
| 229 |
+
self.include_toc = include_toc
|
| 230 |
+
self.max_heading_level = max_heading_level
|
| 231 |
+
|
| 232 |
+
def format(self, processed_data: dict[str, Any]) -> str:
|
| 233 |
+
"""
|
| 234 |
+
Formata dados processados em Markdown.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
processed_data: Dados do DoclingProcessor.
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
String Markdown formatada.
|
| 241 |
+
"""
|
| 242 |
+
content = format_to_markdown(
|
| 243 |
+
processed_data,
|
| 244 |
+
include_metadata_header=self.include_metadata_header,
|
| 245 |
+
include_tables=self.include_tables
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
if self.include_toc:
|
| 249 |
+
toc = self._generate_toc(content)
|
| 250 |
+
if toc:
|
| 251 |
+
content = f"{toc}\n\n---\n\n{content}"
|
| 252 |
+
|
| 253 |
+
return content
|
| 254 |
+
|
| 255 |
+
def _generate_toc(self, content: str) -> str:
|
| 256 |
+
"""
|
| 257 |
+
Gera sumário (Table of Contents) do conteúdo.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
content: Conteúdo Markdown.
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
String com sumário em Markdown.
|
| 264 |
+
"""
|
| 265 |
+
import re
|
| 266 |
+
|
| 267 |
+
lines = []
|
| 268 |
+
lines.append("## Sumário")
|
| 269 |
+
lines.append("")
|
| 270 |
+
|
| 271 |
+
# Encontra headings
|
| 272 |
+
heading_pattern = r"^(#{1,6})\s+(.+)$"
|
| 273 |
+
|
| 274 |
+
for line in content.split("\n"):
|
| 275 |
+
match = re.match(heading_pattern, line)
|
| 276 |
+
if match:
|
| 277 |
+
level = len(match.group(1))
|
| 278 |
+
title = match.group(2)
|
| 279 |
+
|
| 280 |
+
if level <= self.max_heading_level:
|
| 281 |
+
# Cria link
|
| 282 |
+
anchor = self._slugify(title)
|
| 283 |
+
indent = " " * (level - 1)
|
| 284 |
+
lines.append(f"{indent}- [{title}](#{anchor})")
|
| 285 |
+
|
| 286 |
+
return "\n".join(lines) if len(lines) > 2 else ""
|
| 287 |
+
|
| 288 |
+
def _slugify(self, text: str) -> str:
|
| 289 |
+
"""
|
| 290 |
+
Converte texto em slug para anchor.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
text: Texto a converter.
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
Slug do texto.
|
| 297 |
+
"""
|
| 298 |
+
import re
|
| 299 |
+
|
| 300 |
+
# Converte para lowercase
|
| 301 |
+
slug = text.lower()
|
| 302 |
+
|
| 303 |
+
# Remove caracteres especiais
|
| 304 |
+
slug = re.sub(r"[^\w\s-]", "", slug)
|
| 305 |
+
|
| 306 |
+
# Substitui espaços por hífens
|
| 307 |
+
slug = re.sub(r"\s+", "-", slug)
|
| 308 |
+
|
| 309 |
+
return slug
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def save_markdown(
|
| 313 |
+
content: str,
|
| 314 |
+
output_path: str | Path,
|
| 315 |
+
encoding: str = "utf-8"
|
| 316 |
+
) -> Path:
|
| 317 |
+
"""
|
| 318 |
+
Salva conteúdo Markdown em arquivo.
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
content: String Markdown.
|
| 322 |
+
output_path: Caminho do arquivo de saída.
|
| 323 |
+
encoding: Encoding do arquivo.
|
| 324 |
+
|
| 325 |
+
Returns:
|
| 326 |
+
Path para o arquivo salvo.
|
| 327 |
+
"""
|
| 328 |
+
output_path = Path(output_path)
|
| 329 |
+
|
| 330 |
+
output_path.write_text(content, encoding=encoding)
|
| 331 |
+
logger.debug(f"Markdown salvo: {output_path}")
|
| 332 |
+
|
| 333 |
+
return output_path
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Docling Document Processor - Dependências
|
| 3 |
+
# =============================================================================
|
| 4 |
+
|
| 5 |
+
# Framework de interface web
|
| 6 |
+
gradio>=4.44.0
|
| 7 |
+
|
| 8 |
+
# Processamento de documentos
|
| 9 |
+
docling>=2.31.0
|
| 10 |
+
|
| 11 |
+
# Hugging Face
|
| 12 |
+
huggingface-hub>=0.24.0
|
| 13 |
+
spaces>=0.34.0
|
| 14 |
+
|
| 15 |
+
# Manipulação de arquivos
|
| 16 |
+
python-docx>=1.1.0
|
| 17 |
+
PyPDF2>=3.0.0
|
| 18 |
+
|
| 19 |
+
# Validação de MIME types
|
| 20 |
+
python-magic>=0.4.27
|
| 21 |
+
|
| 22 |
+
# Detecção de idioma
|
| 23 |
+
langdetect>=1.0.9
|
| 24 |
+
|
| 25 |
+
# Utilitários
|
| 26 |
+
tqdm>=4.66.0
|
tests/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de testes para o Docling Document Processor.
|
| 3 |
+
"""
|
tests/test_processors.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Testes unitários para os processadores e validadores.
|
| 3 |
+
|
| 4 |
+
Execute com: python -m pytest tests/test_processors.py -v
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from unittest.mock import MagicMock, patch
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
# Adiciona o diretório pai ao path
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 18 |
+
|
| 19 |
+
import config
|
| 20 |
+
from utils.validators import (
|
| 21 |
+
ValidationError,
|
| 22 |
+
sanitize_filename,
|
| 23 |
+
validate_file_count,
|
| 24 |
+
validate_file_size,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# =============================================================================
|
| 29 |
+
# FIXTURES
|
| 30 |
+
# =============================================================================
|
| 31 |
+
|
| 32 |
+
@pytest.fixture
|
| 33 |
+
def temp_file():
|
| 34 |
+
"""Cria um arquivo temporário para testes."""
|
| 35 |
+
with tempfile.NamedTemporaryFile(
|
| 36 |
+
mode="wb",
|
| 37 |
+
suffix=".pdf",
|
| 38 |
+
delete=False
|
| 39 |
+
) as f:
|
| 40 |
+
# Escreve conteúdo mínimo de PDF
|
| 41 |
+
f.write(b"%PDF-1.4\n")
|
| 42 |
+
f.write(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
| 43 |
+
f.write(b"%%EOF\n")
|
| 44 |
+
temp_path = f.name
|
| 45 |
+
|
| 46 |
+
yield temp_path
|
| 47 |
+
|
| 48 |
+
# Cleanup
|
| 49 |
+
if os.path.exists(temp_path):
|
| 50 |
+
os.unlink(temp_path)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest.fixture
|
| 54 |
+
def large_temp_file():
|
| 55 |
+
"""Cria um arquivo temporário grande (> limite)."""
|
| 56 |
+
with tempfile.NamedTemporaryFile(
|
| 57 |
+
mode="wb",
|
| 58 |
+
suffix=".pdf",
|
| 59 |
+
delete=False
|
| 60 |
+
) as f:
|
| 61 |
+
# Escreve mais que o limite
|
| 62 |
+
f.write(b"X" * (config.MAX_FILE_SIZE_BYTES + 1000))
|
| 63 |
+
temp_path = f.name
|
| 64 |
+
|
| 65 |
+
yield temp_path
|
| 66 |
+
|
| 67 |
+
if os.path.exists(temp_path):
|
| 68 |
+
os.unlink(temp_path)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture
|
| 72 |
+
def empty_temp_file():
|
| 73 |
+
"""Cria um arquivo temporário vazio."""
|
| 74 |
+
with tempfile.NamedTemporaryFile(
|
| 75 |
+
mode="wb",
|
| 76 |
+
suffix=".pdf",
|
| 77 |
+
delete=False
|
| 78 |
+
) as f:
|
| 79 |
+
temp_path = f.name
|
| 80 |
+
|
| 81 |
+
yield temp_path
|
| 82 |
+
|
| 83 |
+
if os.path.exists(temp_path):
|
| 84 |
+
os.unlink(temp_path)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# =============================================================================
|
| 88 |
+
# TESTES DE VALIDAÇÃO
|
| 89 |
+
# =============================================================================
|
| 90 |
+
|
| 91 |
+
class TestValidateFileCount:
|
| 92 |
+
"""Testes para validate_file_count()."""
|
| 93 |
+
|
| 94 |
+
def test_valid_count_single(self):
|
| 95 |
+
"""Teste com um arquivo."""
|
| 96 |
+
assert validate_file_count([1]) is True
|
| 97 |
+
|
| 98 |
+
def test_valid_count_multiple(self):
|
| 99 |
+
"""Teste com múltiplos arquivos dentro do limite."""
|
| 100 |
+
files = list(range(config.MAX_FILES_PER_SESSION))
|
| 101 |
+
assert validate_file_count(files) is True
|
| 102 |
+
|
| 103 |
+
def test_empty_list_raises(self):
|
| 104 |
+
"""Teste com lista vazia deve falhar."""
|
| 105 |
+
with pytest.raises(ValidationError) as exc_info:
|
| 106 |
+
validate_file_count([])
|
| 107 |
+
|
| 108 |
+
assert exc_info.value.error_code == "NO_FILES"
|
| 109 |
+
|
| 110 |
+
def test_too_many_files_raises(self):
|
| 111 |
+
"""Teste com arquivos demais deve falhar."""
|
| 112 |
+
files = list(range(config.MAX_FILES_PER_SESSION + 1))
|
| 113 |
+
|
| 114 |
+
with pytest.raises(ValidationError) as exc_info:
|
| 115 |
+
validate_file_count(files)
|
| 116 |
+
|
| 117 |
+
assert exc_info.value.error_code == "TOO_MANY_FILES"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class TestValidateFileSize:
|
| 121 |
+
"""Testes para validate_file_size()."""
|
| 122 |
+
|
| 123 |
+
def test_valid_size(self, temp_file):
|
| 124 |
+
"""Teste com arquivo de tamanho válido."""
|
| 125 |
+
assert validate_file_size(temp_file) is True
|
| 126 |
+
|
| 127 |
+
def test_file_too_large(self, large_temp_file):
|
| 128 |
+
"""Teste com arquivo muito grande."""
|
| 129 |
+
with pytest.raises(ValidationError) as exc_info:
|
| 130 |
+
validate_file_size(large_temp_file)
|
| 131 |
+
|
| 132 |
+
assert exc_info.value.error_code == "FILE_TOO_LARGE"
|
| 133 |
+
|
| 134 |
+
def test_empty_file(self, empty_temp_file):
|
| 135 |
+
"""Teste com arquivo vazio."""
|
| 136 |
+
with pytest.raises(ValidationError) as exc_info:
|
| 137 |
+
validate_file_size(empty_temp_file)
|
| 138 |
+
|
| 139 |
+
assert exc_info.value.error_code == "EMPTY_FILE"
|
| 140 |
+
|
| 141 |
+
def test_file_not_found(self):
|
| 142 |
+
"""Teste com arquivo inexistente."""
|
| 143 |
+
with pytest.raises(ValidationError) as exc_info:
|
| 144 |
+
validate_file_size("/caminho/inexistente/arquivo.pdf")
|
| 145 |
+
|
| 146 |
+
assert exc_info.value.error_code == "FILE_NOT_FOUND"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class TestSanitizeFilename:
|
| 150 |
+
"""Testes para sanitize_filename()."""
|
| 151 |
+
|
| 152 |
+
def test_normal_filename(self):
|
| 153 |
+
"""Teste com nome normal."""
|
| 154 |
+
assert sanitize_filename("documento.pdf") == "documento.pdf"
|
| 155 |
+
|
| 156 |
+
def test_special_characters(self):
|
| 157 |
+
"""Teste com caracteres especiais."""
|
| 158 |
+
result = sanitize_filename("doc<>:test.pdf")
|
| 159 |
+
assert "<" not in result
|
| 160 |
+
assert ">" not in result
|
| 161 |
+
assert ":" not in result
|
| 162 |
+
|
| 163 |
+
def test_spaces(self):
|
| 164 |
+
"""Teste com espaços."""
|
| 165 |
+
result = sanitize_filename("meu documento.pdf")
|
| 166 |
+
assert result == "meu_documento.pdf"
|
| 167 |
+
|
| 168 |
+
def test_multiple_underscores(self):
|
| 169 |
+
"""Teste com underscores múltiplos."""
|
| 170 |
+
result = sanitize_filename("doc___test.pdf")
|
| 171 |
+
assert "___" not in result
|
| 172 |
+
|
| 173 |
+
def test_empty_filename(self):
|
| 174 |
+
"""Teste com nome vazio."""
|
| 175 |
+
result = sanitize_filename("")
|
| 176 |
+
assert result == "arquivo_sem_nome"
|
| 177 |
+
|
| 178 |
+
def test_long_filename(self):
|
| 179 |
+
"""Teste com nome muito longo."""
|
| 180 |
+
long_name = "a" * 300 + ".pdf"
|
| 181 |
+
result = sanitize_filename(long_name)
|
| 182 |
+
assert len(result) <= config.FILENAME_MAX_LENGTH
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# =============================================================================
|
| 186 |
+
# TESTES DE FORMATAÇÃO JSON
|
| 187 |
+
# =============================================================================
|
| 188 |
+
|
| 189 |
+
class TestJSONFormatter:
|
| 190 |
+
"""Testes para json_formatter.py."""
|
| 191 |
+
|
| 192 |
+
def test_format_to_json_basic(self):
|
| 193 |
+
"""Teste de formatação JSON básica."""
|
| 194 |
+
from processors.json_formatter import format_to_json
|
| 195 |
+
|
| 196 |
+
# Mock de dados processados
|
| 197 |
+
mock_document = MagicMock()
|
| 198 |
+
mock_document.export_to_dict.return_value = {"content": "teste"}
|
| 199 |
+
|
| 200 |
+
processed_data = {
|
| 201 |
+
"document": mock_document,
|
| 202 |
+
"metadata": {"nome_arquivo": "test.pdf"},
|
| 203 |
+
"tables": [],
|
| 204 |
+
"language": "pt",
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
result = format_to_json(processed_data, "test.pdf")
|
| 208 |
+
|
| 209 |
+
assert isinstance(result, str)
|
| 210 |
+
|
| 211 |
+
parsed = json.loads(result)
|
| 212 |
+
assert parsed["arquivo"] == "test.pdf"
|
| 213 |
+
assert parsed["idioma"] == "pt"
|
| 214 |
+
assert "processado_em" in parsed
|
| 215 |
+
|
| 216 |
+
def test_format_to_json_with_tables(self):
|
| 217 |
+
"""Teste de formatação JSON com tabelas."""
|
| 218 |
+
from processors.json_formatter import format_to_json
|
| 219 |
+
|
| 220 |
+
mock_document = MagicMock()
|
| 221 |
+
mock_document.export_to_dict.return_value = {}
|
| 222 |
+
|
| 223 |
+
processed_data = {
|
| 224 |
+
"document": mock_document,
|
| 225 |
+
"metadata": {},
|
| 226 |
+
"tables": [
|
| 227 |
+
{"indice": 1, "dados": [{"col1": "val1"}]}
|
| 228 |
+
],
|
| 229 |
+
"language": "en",
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
result = format_to_json(processed_data, "test.pdf")
|
| 233 |
+
parsed = json.loads(result)
|
| 234 |
+
|
| 235 |
+
assert len(parsed["tabelas"]) == 1
|
| 236 |
+
assert parsed["tabelas"][0]["indice"] == 1
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# =============================================================================
|
| 240 |
+
# TESTES DE FORMATAÇÃO MARKDOWN
|
| 241 |
+
# =============================================================================
|
| 242 |
+
|
| 243 |
+
class TestMarkdownFormatter:
|
| 244 |
+
"""Testes para markdown_formatter.py."""
|
| 245 |
+
|
| 246 |
+
def test_format_to_markdown_basic(self):
|
| 247 |
+
"""Teste de formatação Markdown básica."""
|
| 248 |
+
from processors.markdown_formatter import format_to_markdown
|
| 249 |
+
|
| 250 |
+
mock_document = MagicMock()
|
| 251 |
+
mock_document.export_to_markdown.return_value = "# Conteúdo\n\nTexto aqui."
|
| 252 |
+
|
| 253 |
+
processed_data = {
|
| 254 |
+
"document": mock_document,
|
| 255 |
+
"metadata": {"nome_arquivo": "test.pdf", "num_paginas": 3},
|
| 256 |
+
"tables": [],
|
| 257 |
+
"language": "pt",
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
result = format_to_markdown(processed_data)
|
| 261 |
+
|
| 262 |
+
assert isinstance(result, str)
|
| 263 |
+
assert "# " in result or "## " in result # Tem headings
|
| 264 |
+
|
| 265 |
+
def test_dict_to_markdown_table(self):
|
| 266 |
+
"""Teste de conversão de dict para tabela MD."""
|
| 267 |
+
from processors.markdown_formatter import _dict_to_markdown_table
|
| 268 |
+
|
| 269 |
+
data = [
|
| 270 |
+
{"Nome": "Alice", "Idade": 30},
|
| 271 |
+
{"Nome": "Bob", "Idade": 25},
|
| 272 |
+
]
|
| 273 |
+
|
| 274 |
+
result = _dict_to_markdown_table(data)
|
| 275 |
+
|
| 276 |
+
assert "| Nome | Idade |" in result
|
| 277 |
+
assert "| --- | --- |" in result
|
| 278 |
+
assert "| Alice | 30 |" in result
|
| 279 |
+
assert "| Bob | 25 |" in result
|
| 280 |
+
|
| 281 |
+
def test_empty_table(self):
|
| 282 |
+
"""Teste com tabela vazia."""
|
| 283 |
+
from processors.markdown_formatter import _dict_to_markdown_table
|
| 284 |
+
|
| 285 |
+
result = _dict_to_markdown_table([])
|
| 286 |
+
assert "vazia" in result.lower()
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# =============================================================================
|
| 290 |
+
# TESTES DE FILE HANDLER
|
| 291 |
+
# =============================================================================
|
| 292 |
+
|
| 293 |
+
class TestFileHandler:
|
| 294 |
+
"""Testes para file_handler.py."""
|
| 295 |
+
|
| 296 |
+
def test_create_temp_directory(self):
|
| 297 |
+
"""Teste de criação de diretório temporário."""
|
| 298 |
+
from utils.file_handler import create_temp_directory
|
| 299 |
+
|
| 300 |
+
temp_dir = create_temp_directory(prefix="test_")
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
assert temp_dir.exists()
|
| 304 |
+
assert temp_dir.is_dir()
|
| 305 |
+
assert "test_" in temp_dir.name
|
| 306 |
+
finally:
|
| 307 |
+
# Cleanup
|
| 308 |
+
if temp_dir.exists():
|
| 309 |
+
import shutil
|
| 310 |
+
shutil.rmtree(temp_dir)
|
| 311 |
+
|
| 312 |
+
def test_save_output_file(self):
|
| 313 |
+
"""Teste de salvamento de arquivo de saída."""
|
| 314 |
+
from utils.file_handler import save_output_file, create_temp_directory
|
| 315 |
+
|
| 316 |
+
temp_dir = create_temp_directory(prefix="test_")
|
| 317 |
+
|
| 318 |
+
try:
|
| 319 |
+
content = "Conteúdo de teste"
|
| 320 |
+
output_path = save_output_file(content, "teste.txt", temp_dir)
|
| 321 |
+
|
| 322 |
+
assert output_path.exists()
|
| 323 |
+
assert output_path.read_text() == content
|
| 324 |
+
finally:
|
| 325 |
+
import shutil
|
| 326 |
+
if temp_dir.exists():
|
| 327 |
+
shutil.rmtree(temp_dir)
|
| 328 |
+
|
| 329 |
+
def test_format_size(self):
|
| 330 |
+
"""Teste de formatação de tamanho."""
|
| 331 |
+
from utils.file_handler import format_size
|
| 332 |
+
|
| 333 |
+
assert "B" in format_size(500)
|
| 334 |
+
assert "KB" in format_size(1024 * 5)
|
| 335 |
+
assert "MB" in format_size(1024 * 1024 * 10)
|
| 336 |
+
assert "GB" in format_size(1024 * 1024 * 1024 * 2)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# =============================================================================
|
| 340 |
+
# TESTES DE INTEGRAÇÃO (MOCK)
|
| 341 |
+
# =============================================================================
|
| 342 |
+
|
| 343 |
+
class TestDoclingProcessorMock:
|
| 344 |
+
"""Testes do DoclingProcessor com mocks."""
|
| 345 |
+
|
| 346 |
+
@patch("processors.docling_processor.DocumentConverter")
|
| 347 |
+
def test_processor_initialization(self, mock_converter_class):
|
| 348 |
+
"""Teste de inicialização do processador."""
|
| 349 |
+
from processors.docling_processor import DoclingProcessor
|
| 350 |
+
|
| 351 |
+
processor = DoclingProcessor(
|
| 352 |
+
enable_ocr=True,
|
| 353 |
+
enable_table_detection=True,
|
| 354 |
+
use_gpu=False
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
assert processor.enable_ocr is True
|
| 358 |
+
assert processor.enable_table_detection is True
|
| 359 |
+
assert processor.use_gpu is False
|
| 360 |
+
|
| 361 |
+
@patch("processors.docling_processor.DocumentConverter")
|
| 362 |
+
def test_processor_process_document(self, mock_converter_class):
|
| 363 |
+
"""Teste de processamento de documento."""
|
| 364 |
+
from processors.docling_processor import DoclingProcessor
|
| 365 |
+
|
| 366 |
+
# Setup mock
|
| 367 |
+
mock_converter = MagicMock()
|
| 368 |
+
mock_converter_class.return_value = mock_converter
|
| 369 |
+
|
| 370 |
+
mock_result = MagicMock()
|
| 371 |
+
mock_document = MagicMock()
|
| 372 |
+
mock_document.export_to_markdown.return_value = "# Teste"
|
| 373 |
+
mock_result.document = mock_document
|
| 374 |
+
|
| 375 |
+
mock_converter.convert.return_value = mock_result
|
| 376 |
+
|
| 377 |
+
# Cria arquivo temporário
|
| 378 |
+
with tempfile.NamedTemporaryFile(
|
| 379 |
+
mode="wb",
|
| 380 |
+
suffix=".pdf",
|
| 381 |
+
delete=False
|
| 382 |
+
) as f:
|
| 383 |
+
f.write(b"%PDF-1.4\n%%EOF\n")
|
| 384 |
+
temp_path = f.name
|
| 385 |
+
|
| 386 |
+
try:
|
| 387 |
+
processor = DoclingProcessor()
|
| 388 |
+
result = processor.process_document(temp_path)
|
| 389 |
+
|
| 390 |
+
assert "document" in result
|
| 391 |
+
assert "metadata" in result
|
| 392 |
+
assert "tables" in result
|
| 393 |
+
assert "language" in result
|
| 394 |
+
finally:
|
| 395 |
+
os.unlink(temp_path)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
# =============================================================================
|
| 399 |
+
# EXECUTAR TESTES
|
| 400 |
+
# =============================================================================
|
| 401 |
+
|
| 402 |
+
if __name__ == "__main__":
|
| 403 |
+
pytest.main([__file__, "-v"])
|
utils/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Módulo de utilitários para o Docling Document Processor.
|
| 3 |
+
|
| 4 |
+
Este pacote contém funções auxiliares para validação, manipulação
|
| 5 |
+
de arquivos e logging.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from utils.validators import (
|
| 9 |
+
validate_file_count,
|
| 10 |
+
validate_file_size,
|
| 11 |
+
validate_mime_type,
|
| 12 |
+
sanitize_filename,
|
| 13 |
+
ValidationError,
|
| 14 |
+
)
|
| 15 |
+
from utils.file_handler import (
|
| 16 |
+
create_temp_directory,
|
| 17 |
+
cleanup_old_files,
|
| 18 |
+
create_zip_output,
|
| 19 |
+
get_temp_file_path,
|
| 20 |
+
)
|
| 21 |
+
from utils.logger import setup_logger, get_logger
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
# Validators
|
| 25 |
+
"validate_file_count",
|
| 26 |
+
"validate_file_size",
|
| 27 |
+
"validate_mime_type",
|
| 28 |
+
"sanitize_filename",
|
| 29 |
+
"ValidationError",
|
| 30 |
+
# File handler
|
| 31 |
+
"create_temp_directory",
|
| 32 |
+
"cleanup_old_files",
|
| 33 |
+
"create_zip_output",
|
| 34 |
+
"get_temp_file_path",
|
| 35 |
+
# Logger
|
| 36 |
+
"setup_logger",
|
| 37 |
+
"get_logger",
|
| 38 |
+
]
|
utils/file_handler.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Manipulação de arquivos temporários e outputs.
|
| 3 |
+
|
| 4 |
+
Este módulo contém funções para gerenciar arquivos temporários,
|
| 5 |
+
criar outputs em diferentes formatos e limpar arquivos antigos.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import shutil
|
| 10 |
+
import tempfile
|
| 11 |
+
import time
|
| 12 |
+
import zipfile
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import config
|
| 18 |
+
from utils.logger import get_logger
|
| 19 |
+
|
| 20 |
+
# Logger para este módulo
|
| 21 |
+
logger = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def create_temp_directory(prefix: str = "docling_") -> Path:
|
| 25 |
+
"""
|
| 26 |
+
Cria um diretório temporário isolado para processamento.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
prefix: Prefixo para o nome do diretório.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Path para o diretório temporário criado.
|
| 33 |
+
"""
|
| 34 |
+
# Garante que o diretório base existe
|
| 35 |
+
config.TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
# Cria subdiretório com timestamp
|
| 38 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 39 |
+
temp_path = config.TEMP_DIR / f"{prefix}{timestamp}"
|
| 40 |
+
temp_path.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
|
| 42 |
+
logger.debug(f"Diretório temporário criado: {temp_path}")
|
| 43 |
+
return temp_path
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_temp_file_path(
|
| 47 |
+
filename: str,
|
| 48 |
+
temp_dir: Optional[Path] = None
|
| 49 |
+
) -> Path:
|
| 50 |
+
"""
|
| 51 |
+
Retorna um caminho de arquivo temporário sanitizado.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
filename: Nome do arquivo (será sanitizado se necessário).
|
| 55 |
+
temp_dir: Diretório temporário opcional. Se não fornecido, cria um novo.
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
Path completo para o arquivo temporário.
|
| 59 |
+
"""
|
| 60 |
+
from utils.validators import sanitize_filename
|
| 61 |
+
|
| 62 |
+
if temp_dir is None:
|
| 63 |
+
temp_dir = create_temp_directory()
|
| 64 |
+
|
| 65 |
+
safe_filename = sanitize_filename(filename)
|
| 66 |
+
return temp_dir / safe_filename
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def cleanup_old_files(
|
| 70 |
+
max_age_hours: Optional[int] = None,
|
| 71 |
+
target_dir: Optional[Path] = None
|
| 72 |
+
) -> int:
|
| 73 |
+
"""
|
| 74 |
+
Remove arquivos temporários mais antigos que o limite especificado.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
max_age_hours: Idade máxima em horas. Se não especificado, usa config.
|
| 78 |
+
target_dir: Diretório a limpar. Se não especificado, usa TEMP_DIR.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
Número de arquivos/diretórios removidos.
|
| 82 |
+
"""
|
| 83 |
+
if max_age_hours is None:
|
| 84 |
+
max_age_hours = config.TEMP_DIR_CLEANUP_HOURS
|
| 85 |
+
|
| 86 |
+
if target_dir is None:
|
| 87 |
+
target_dir = config.TEMP_DIR
|
| 88 |
+
|
| 89 |
+
if not target_dir.exists():
|
| 90 |
+
return 0
|
| 91 |
+
|
| 92 |
+
cutoff_time = time.time() - (max_age_hours * 3600)
|
| 93 |
+
removed_count = 0
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
for item in target_dir.iterdir():
|
| 97 |
+
try:
|
| 98 |
+
item_stat = item.stat()
|
| 99 |
+
|
| 100 |
+
# Usa tempo de modificação
|
| 101 |
+
if item_stat.st_mtime < cutoff_time:
|
| 102 |
+
if item.is_dir():
|
| 103 |
+
shutil.rmtree(item)
|
| 104 |
+
logger.info(f"Diretório removido: {item}")
|
| 105 |
+
else:
|
| 106 |
+
item.unlink()
|
| 107 |
+
logger.info(f"Arquivo removido: {item}")
|
| 108 |
+
|
| 109 |
+
removed_count += 1
|
| 110 |
+
|
| 111 |
+
except PermissionError:
|
| 112 |
+
logger.warning(f"Sem permissão para remover: {item}")
|
| 113 |
+
except FileNotFoundError:
|
| 114 |
+
# Já foi removido
|
| 115 |
+
pass
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"Erro ao remover {item}: {e}")
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Erro ao limpar diretório {target_dir}: {e}")
|
| 121 |
+
|
| 122 |
+
if removed_count > 0:
|
| 123 |
+
logger.info(f"Limpeza concluída: {removed_count} itens removidos")
|
| 124 |
+
|
| 125 |
+
return removed_count
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def create_zip_output(
|
| 129 |
+
files: list[tuple[Path, str]],
|
| 130 |
+
output_name: str = "resultado"
|
| 131 |
+
) -> Path:
|
| 132 |
+
"""
|
| 133 |
+
Cria um arquivo ZIP contendo múltiplos arquivos de saída.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
files: Lista de tuplas (caminho_arquivo, nome_no_zip).
|
| 137 |
+
output_name: Nome base para o arquivo ZIP (sem extensão).
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Path para o arquivo ZIP criado.
|
| 141 |
+
"""
|
| 142 |
+
# Cria diretório temporário para o ZIP
|
| 143 |
+
temp_dir = create_temp_directory(prefix="zip_")
|
| 144 |
+
zip_path = temp_dir / f"{output_name}.zip"
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 148 |
+
for file_path, archive_name in files:
|
| 149 |
+
if file_path.exists():
|
| 150 |
+
zf.write(file_path, archive_name)
|
| 151 |
+
logger.debug(f"Adicionado ao ZIP: {archive_name}")
|
| 152 |
+
else:
|
| 153 |
+
logger.warning(f"Arquivo não encontrado para ZIP: {file_path}")
|
| 154 |
+
|
| 155 |
+
logger.info(f"ZIP criado: {zip_path} ({len(files)} arquivos)")
|
| 156 |
+
return zip_path
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Erro ao criar ZIP: {e}")
|
| 160 |
+
raise
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def copy_file_to_temp(
|
| 164 |
+
source: Path | str,
|
| 165 |
+
temp_dir: Optional[Path] = None
|
| 166 |
+
) -> Path:
|
| 167 |
+
"""
|
| 168 |
+
Copia um arquivo para o diretório temporário.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
source: Caminho do arquivo fonte.
|
| 172 |
+
temp_dir: Diretório de destino opcional.
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
Path para o arquivo copiado.
|
| 176 |
+
"""
|
| 177 |
+
source = Path(source)
|
| 178 |
+
|
| 179 |
+
if temp_dir is None:
|
| 180 |
+
temp_dir = create_temp_directory()
|
| 181 |
+
|
| 182 |
+
dest_path = temp_dir / source.name
|
| 183 |
+
|
| 184 |
+
shutil.copy2(source, dest_path)
|
| 185 |
+
logger.debug(f"Arquivo copiado: {source} -> {dest_path}")
|
| 186 |
+
|
| 187 |
+
return dest_path
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def save_output_file(
|
| 191 |
+
content: str | bytes,
|
| 192 |
+
filename: str,
|
| 193 |
+
temp_dir: Optional[Path] = None,
|
| 194 |
+
encoding: str = "utf-8"
|
| 195 |
+
) -> Path:
|
| 196 |
+
"""
|
| 197 |
+
Salva conteúdo em um arquivo temporário.
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
content: Conteúdo a ser salvo (string ou bytes).
|
| 201 |
+
filename: Nome do arquivo de saída.
|
| 202 |
+
temp_dir: Diretório de destino opcional.
|
| 203 |
+
encoding: Encoding para strings (padrão: utf-8).
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
Path para o arquivo salvo.
|
| 207 |
+
"""
|
| 208 |
+
if temp_dir is None:
|
| 209 |
+
temp_dir = create_temp_directory(prefix="output_")
|
| 210 |
+
|
| 211 |
+
output_path = get_temp_file_path(filename, temp_dir)
|
| 212 |
+
|
| 213 |
+
if isinstance(content, str):
|
| 214 |
+
output_path.write_text(content, encoding=encoding)
|
| 215 |
+
else:
|
| 216 |
+
output_path.write_bytes(content)
|
| 217 |
+
|
| 218 |
+
logger.debug(f"Arquivo salvo: {output_path}")
|
| 219 |
+
return output_path
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def get_temp_dir_size() -> int:
|
| 223 |
+
"""
|
| 224 |
+
Calcula o tamanho total do diretório temporário.
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
Tamanho em bytes.
|
| 228 |
+
"""
|
| 229 |
+
if not config.TEMP_DIR.exists():
|
| 230 |
+
return 0
|
| 231 |
+
|
| 232 |
+
total_size = 0
|
| 233 |
+
for item in config.TEMP_DIR.rglob("*"):
|
| 234 |
+
if item.is_file():
|
| 235 |
+
try:
|
| 236 |
+
total_size += item.stat().st_size
|
| 237 |
+
except Exception:
|
| 238 |
+
pass
|
| 239 |
+
|
| 240 |
+
return total_size
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def format_size(size_bytes: int) -> str:
|
| 244 |
+
"""
|
| 245 |
+
Formata tamanho em bytes para string legível.
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
size_bytes: Tamanho em bytes.
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
String formatada (ex: "1.5 MB").
|
| 252 |
+
"""
|
| 253 |
+
for unit in ["B", "KB", "MB", "GB"]:
|
| 254 |
+
if size_bytes < 1024:
|
| 255 |
+
return f"{size_bytes:.1f} {unit}"
|
| 256 |
+
size_bytes /= 1024
|
| 257 |
+
return f"{size_bytes:.1f} TB"
|
utils/logger.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sistema de logging para o Docling Document Processor.
|
| 3 |
+
|
| 4 |
+
Este módulo configura e gerencia o sistema de logging da aplicação,
|
| 5 |
+
incluindo rotação de arquivos e formatação consistente.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import sys
|
| 10 |
+
from logging.handlers import RotatingFileHandler
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
import config
|
| 15 |
+
|
| 16 |
+
# Flag para evitar configuração duplicada
|
| 17 |
+
_logging_configured = False
|
| 18 |
+
|
| 19 |
+
# Cache de loggers
|
| 20 |
+
_loggers: dict[str, logging.Logger] = {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def setup_logger(
|
| 24 |
+
name: str = "docling_space",
|
| 25 |
+
level: int = logging.INFO,
|
| 26 |
+
log_to_file: bool = True,
|
| 27 |
+
log_to_console: bool = True
|
| 28 |
+
) -> logging.Logger:
|
| 29 |
+
"""
|
| 30 |
+
Configura e retorna um logger.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
name: Nome do logger.
|
| 34 |
+
level: Nível de logging (default: INFO).
|
| 35 |
+
log_to_file: Se deve logar em arquivo.
|
| 36 |
+
log_to_console: Se deve logar no console.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Logger configurado.
|
| 40 |
+
"""
|
| 41 |
+
global _logging_configured
|
| 42 |
+
|
| 43 |
+
# Se já existe no cache, retorna
|
| 44 |
+
if name in _loggers:
|
| 45 |
+
return _loggers[name]
|
| 46 |
+
|
| 47 |
+
# Cria o logger
|
| 48 |
+
logger = logging.getLogger(name)
|
| 49 |
+
logger.setLevel(level)
|
| 50 |
+
|
| 51 |
+
# Evita handlers duplicados
|
| 52 |
+
if logger.handlers:
|
| 53 |
+
return logger
|
| 54 |
+
|
| 55 |
+
# Formatter
|
| 56 |
+
formatter = logging.Formatter(
|
| 57 |
+
config.LOG_FORMAT,
|
| 58 |
+
datefmt=config.LOG_DATE_FORMAT
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Handler de console
|
| 62 |
+
if log_to_console:
|
| 63 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 64 |
+
console_handler.setLevel(level)
|
| 65 |
+
console_handler.setFormatter(formatter)
|
| 66 |
+
logger.addHandler(console_handler)
|
| 67 |
+
|
| 68 |
+
# Handler de arquivo com rotação
|
| 69 |
+
if log_to_file:
|
| 70 |
+
try:
|
| 71 |
+
# Garante que o diretório existe
|
| 72 |
+
config.LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
|
| 74 |
+
log_file = config.LOGS_DIR / config.LOG_FILE
|
| 75 |
+
|
| 76 |
+
file_handler = RotatingFileHandler(
|
| 77 |
+
log_file,
|
| 78 |
+
maxBytes=config.LOG_MAX_BYTES,
|
| 79 |
+
backupCount=config.LOG_BACKUP_COUNT,
|
| 80 |
+
encoding="utf-8"
|
| 81 |
+
)
|
| 82 |
+
file_handler.setLevel(level)
|
| 83 |
+
file_handler.setFormatter(formatter)
|
| 84 |
+
logger.addHandler(file_handler)
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
# Se não conseguir criar o arquivo de log, continua só com console
|
| 88 |
+
if log_to_console:
|
| 89 |
+
logger.warning(f"Não foi possível criar arquivo de log: {e}")
|
| 90 |
+
|
| 91 |
+
# Não propaga para o root logger
|
| 92 |
+
logger.propagate = False
|
| 93 |
+
|
| 94 |
+
# Adiciona ao cache
|
| 95 |
+
_loggers[name] = logger
|
| 96 |
+
_logging_configured = True
|
| 97 |
+
|
| 98 |
+
return logger
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
| 102 |
+
"""
|
| 103 |
+
Obtém um logger pelo nome.
|
| 104 |
+
|
| 105 |
+
Se o logger não existir, cria um novo com as configurações padrão.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
name: Nome do logger. Se None, usa "docling_space".
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Logger configurado.
|
| 112 |
+
"""
|
| 113 |
+
if name is None:
|
| 114 |
+
name = "docling_space"
|
| 115 |
+
|
| 116 |
+
# Se for um nome de módulo completo, usa apenas a última parte
|
| 117 |
+
if "." in name:
|
| 118 |
+
short_name = name.split(".")[-1]
|
| 119 |
+
else:
|
| 120 |
+
short_name = name
|
| 121 |
+
|
| 122 |
+
logger_name = f"docling_space.{short_name}"
|
| 123 |
+
|
| 124 |
+
if logger_name not in _loggers:
|
| 125 |
+
return setup_logger(logger_name)
|
| 126 |
+
|
| 127 |
+
return _loggers[logger_name]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def log_exception(
|
| 131 |
+
logger: logging.Logger,
|
| 132 |
+
message: str,
|
| 133 |
+
exc: Exception,
|
| 134 |
+
include_traceback: bool = True
|
| 135 |
+
) -> None:
|
| 136 |
+
"""
|
| 137 |
+
Loga uma exceção com detalhes.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
logger: Logger a usar.
|
| 141 |
+
message: Mensagem descritiva.
|
| 142 |
+
exc: Exceção a logar.
|
| 143 |
+
include_traceback: Se deve incluir traceback completo.
|
| 144 |
+
"""
|
| 145 |
+
if include_traceback:
|
| 146 |
+
logger.exception(f"{message}: {exc}")
|
| 147 |
+
else:
|
| 148 |
+
logger.error(f"{message}: {type(exc).__name__}: {exc}")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def log_processing_start(
|
| 152 |
+
logger: logging.Logger,
|
| 153 |
+
filename: str,
|
| 154 |
+
file_size: int
|
| 155 |
+
) -> None:
|
| 156 |
+
"""
|
| 157 |
+
Loga o início do processamento de um arquivo.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
logger: Logger a usar.
|
| 161 |
+
filename: Nome do arquivo.
|
| 162 |
+
file_size: Tamanho em bytes.
|
| 163 |
+
"""
|
| 164 |
+
size_mb = file_size / (1024 * 1024)
|
| 165 |
+
logger.info(f"Iniciando processamento: {filename} ({size_mb:.2f} MB)")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def log_processing_complete(
|
| 169 |
+
logger: logging.Logger,
|
| 170 |
+
filename: str,
|
| 171 |
+
duration_seconds: float,
|
| 172 |
+
output_format: str
|
| 173 |
+
) -> None:
|
| 174 |
+
"""
|
| 175 |
+
Loga a conclusão do processamento de um arquivo.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
logger: Logger a usar.
|
| 179 |
+
filename: Nome do arquivo.
|
| 180 |
+
duration_seconds: Tempo de processamento em segundos.
|
| 181 |
+
output_format: Formato de saída usado.
|
| 182 |
+
"""
|
| 183 |
+
logger.info(
|
| 184 |
+
f"Processamento concluído: {filename} "
|
| 185 |
+
f"({duration_seconds:.2f}s, formato: {output_format})"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def log_validation_error(
|
| 190 |
+
logger: logging.Logger,
|
| 191 |
+
filename: str,
|
| 192 |
+
error_code: str,
|
| 193 |
+
message: str
|
| 194 |
+
) -> None:
|
| 195 |
+
"""
|
| 196 |
+
Loga um erro de validação.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
logger: Logger a usar.
|
| 200 |
+
filename: Nome do arquivo.
|
| 201 |
+
error_code: Código do erro.
|
| 202 |
+
message: Mensagem de erro.
|
| 203 |
+
"""
|
| 204 |
+
logger.warning(f"Validação falhou [{error_code}] {filename}: {message}")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class ProcessingLogger:
|
| 208 |
+
"""
|
| 209 |
+
Context manager para logging de processamento.
|
| 210 |
+
|
| 211 |
+
Automaticamente loga início e fim do processamento com timing.
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def __init__(
|
| 215 |
+
self,
|
| 216 |
+
logger: logging.Logger,
|
| 217 |
+
operation: str,
|
| 218 |
+
filename: str
|
| 219 |
+
):
|
| 220 |
+
self.logger = logger
|
| 221 |
+
self.operation = operation
|
| 222 |
+
self.filename = filename
|
| 223 |
+
self.start_time: float = 0
|
| 224 |
+
|
| 225 |
+
def __enter__(self):
|
| 226 |
+
import time
|
| 227 |
+
self.start_time = time.time()
|
| 228 |
+
self.logger.info(f"[INÍCIO] {self.operation}: {self.filename}")
|
| 229 |
+
return self
|
| 230 |
+
|
| 231 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 232 |
+
import time
|
| 233 |
+
duration = time.time() - self.start_time
|
| 234 |
+
|
| 235 |
+
if exc_type is None:
|
| 236 |
+
self.logger.info(
|
| 237 |
+
f"[FIM] {self.operation}: {self.filename} ({duration:.2f}s)"
|
| 238 |
+
)
|
| 239 |
+
else:
|
| 240 |
+
self.logger.error(
|
| 241 |
+
f"[ERRO] {self.operation}: {self.filename} "
|
| 242 |
+
f"({duration:.2f}s) - {exc_type.__name__}: {exc_val}"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Não suprime exceções
|
| 246 |
+
return False
|
utils/validators.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Validadores para arquivos de entrada.
|
| 3 |
+
|
| 4 |
+
Este módulo contém funções para validar arquivos antes do processamento,
|
| 5 |
+
incluindo verificação de tamanho, contagem, MIME type e sanitização de nomes.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import BinaryIO
|
| 12 |
+
|
| 13 |
+
import config
|
| 14 |
+
|
| 15 |
+
# Tenta importar python-magic, mas oferece fallback se não disponível
|
| 16 |
+
try:
|
| 17 |
+
import magic
|
| 18 |
+
HAS_MAGIC = True
|
| 19 |
+
except ImportError:
|
| 20 |
+
HAS_MAGIC = False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ValidationError(Exception):
|
| 24 |
+
"""Exceção levantada quando uma validação falha."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"):
|
| 27 |
+
self.message = message
|
| 28 |
+
self.error_code = error_code
|
| 29 |
+
super().__init__(self.message)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def validate_file_count(files: list) -> bool:
|
| 33 |
+
"""
|
| 34 |
+
Valida se o número de arquivos está dentro do limite permitido.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
files: Lista de arquivos para validar.
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
True se a contagem está válida.
|
| 41 |
+
|
| 42 |
+
Raises:
|
| 43 |
+
ValidationError: Se houver arquivos demais ou nenhum arquivo.
|
| 44 |
+
"""
|
| 45 |
+
if not files:
|
| 46 |
+
raise ValidationError(
|
| 47 |
+
"Nenhum arquivo enviado. Por favor, selecione ao menos um arquivo.",
|
| 48 |
+
error_code="NO_FILES"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if len(files) > config.MAX_FILES_PER_SESSION:
|
| 52 |
+
raise ValidationError(
|
| 53 |
+
f"Muitos arquivos! Máximo permitido: {config.MAX_FILES_PER_SESSION}. "
|
| 54 |
+
f"Você enviou: {len(files)}.",
|
| 55 |
+
error_code="TOO_MANY_FILES"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
return True
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def validate_file_size(file_path: str | Path) -> bool:
|
| 62 |
+
"""
|
| 63 |
+
Valida se o tamanho do arquivo está dentro do limite permitido.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
file_path: Caminho para o arquivo a ser validado.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
True se o tamanho está válido.
|
| 70 |
+
|
| 71 |
+
Raises:
|
| 72 |
+
ValidationError: Se o arquivo for muito grande.
|
| 73 |
+
"""
|
| 74 |
+
file_path = Path(file_path)
|
| 75 |
+
|
| 76 |
+
if not file_path.exists():
|
| 77 |
+
raise ValidationError(
|
| 78 |
+
f"Arquivo não encontrado: {file_path.name}",
|
| 79 |
+
error_code="FILE_NOT_FOUND"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
file_size = file_path.stat().st_size
|
| 83 |
+
|
| 84 |
+
if file_size > config.MAX_FILE_SIZE_BYTES:
|
| 85 |
+
size_mb = file_size / (1024 * 1024)
|
| 86 |
+
raise ValidationError(
|
| 87 |
+
f"Arquivo muito grande: {file_path.name} ({size_mb:.1f}MB). "
|
| 88 |
+
f"Máximo permitido: {config.MAX_FILE_SIZE_MB}MB.",
|
| 89 |
+
error_code="FILE_TOO_LARGE"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
if file_size == 0:
|
| 93 |
+
raise ValidationError(
|
| 94 |
+
f"Arquivo vazio: {file_path.name}",
|
| 95 |
+
error_code="EMPTY_FILE"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _get_mime_type_magic(file_path: str | Path) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Obtém o MIME type usando python-magic.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
file_path: Caminho para o arquivo.
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
String com o MIME type detectado.
|
| 110 |
+
"""
|
| 111 |
+
mime = magic.Magic(mime=True)
|
| 112 |
+
return mime.from_file(str(file_path))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _get_mime_type_fallback(file_path: str | Path) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Fallback para detecção de MIME type sem python-magic.
|
| 118 |
+
Usa assinaturas de arquivo (magic bytes).
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
file_path: Caminho para o arquivo.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
String com o MIME type detectado ou extensão-based guess.
|
| 125 |
+
"""
|
| 126 |
+
file_path = Path(file_path)
|
| 127 |
+
|
| 128 |
+
# Magic bytes para tipos comuns
|
| 129 |
+
signatures = {
|
| 130 |
+
b"%PDF": "application/pdf",
|
| 131 |
+
b"PK\x03\x04": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 132 |
+
b"\xd0\xcf\x11\xe0": "application/msword", # OLE Compound Document
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
with open(file_path, "rb") as f:
|
| 137 |
+
header = f.read(8)
|
| 138 |
+
|
| 139 |
+
for sig, mime_type in signatures.items():
|
| 140 |
+
if header.startswith(sig):
|
| 141 |
+
return mime_type
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
|
| 145 |
+
# Fallback para extensão
|
| 146 |
+
ext = file_path.suffix.lower()
|
| 147 |
+
ext_to_mime = {
|
| 148 |
+
".pdf": "application/pdf",
|
| 149 |
+
".doc": "application/msword",
|
| 150 |
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return ext_to_mime.get(ext, "application/octet-stream")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def get_mime_type(file_path: str | Path) -> str:
|
| 157 |
+
"""
|
| 158 |
+
Obtém o MIME type de um arquivo.
|
| 159 |
+
|
| 160 |
+
Usa python-magic se disponível, caso contrário usa fallback
|
| 161 |
+
baseado em assinaturas de arquivo.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
file_path: Caminho para o arquivo.
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
String com o MIME type detectado.
|
| 168 |
+
"""
|
| 169 |
+
if HAS_MAGIC:
|
| 170 |
+
return _get_mime_type_magic(file_path)
|
| 171 |
+
return _get_mime_type_fallback(file_path)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def validate_mime_type(file_path: str | Path) -> bool:
|
| 175 |
+
"""
|
| 176 |
+
Valida se o MIME type do arquivo é suportado.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
file_path: Caminho para o arquivo a ser validado.
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
True se o MIME type é válido.
|
| 183 |
+
|
| 184 |
+
Raises:
|
| 185 |
+
ValidationError: Se o tipo de arquivo não for suportado.
|
| 186 |
+
"""
|
| 187 |
+
file_path = Path(file_path)
|
| 188 |
+
extension = file_path.suffix.lower()
|
| 189 |
+
|
| 190 |
+
# Verifica se a extensão é suportada
|
| 191 |
+
if extension not in config.SUPPORTED_EXTENSIONS:
|
| 192 |
+
raise ValidationError(
|
| 193 |
+
f"Extensão não suportada: {extension}. "
|
| 194 |
+
f"Tipos aceitos: {', '.join(config.SUPPORTED_EXTENSIONS)}",
|
| 195 |
+
error_code="UNSUPPORTED_EXTENSION"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Obtém o MIME type real do arquivo
|
| 199 |
+
detected_mime = get_mime_type(file_path)
|
| 200 |
+
|
| 201 |
+
# Verifica se o MIME type corresponde à extensão
|
| 202 |
+
expected_mimes = config.SUPPORTED_MIME_TYPES.get(extension, [])
|
| 203 |
+
|
| 204 |
+
if detected_mime not in expected_mimes:
|
| 205 |
+
# DOCX pode ser detectado como ZIP em alguns casos
|
| 206 |
+
if extension == ".docx" and detected_mime == "application/zip":
|
| 207 |
+
return True
|
| 208 |
+
|
| 209 |
+
raise ValidationError(
|
| 210 |
+
f"Tipo de arquivo inválido: {file_path.name}. "
|
| 211 |
+
f"O conteúdo não corresponde à extensão {extension}. "
|
| 212 |
+
f"Detectado: {detected_mime}",
|
| 213 |
+
error_code="MIME_MISMATCH"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
return True
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def sanitize_filename(filename: str) -> str:
|
| 220 |
+
"""
|
| 221 |
+
Remove caracteres especiais/perigosos do nome de arquivo.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
filename: Nome original do arquivo.
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
Nome de arquivo sanitizado.
|
| 228 |
+
"""
|
| 229 |
+
if not filename:
|
| 230 |
+
return "arquivo_sem_nome"
|
| 231 |
+
|
| 232 |
+
# Remove caracteres proibidos
|
| 233 |
+
for char in config.FORBIDDEN_FILENAME_CHARS:
|
| 234 |
+
filename = filename.replace(char, "_")
|
| 235 |
+
|
| 236 |
+
# Remove caracteres de controle
|
| 237 |
+
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
|
| 238 |
+
|
| 239 |
+
# Substitui espaços múltiplos por um único underscore
|
| 240 |
+
filename = re.sub(r"\s+", "_", filename)
|
| 241 |
+
|
| 242 |
+
# Remove underscores múltiplos
|
| 243 |
+
filename = re.sub(r"_+", "_", filename)
|
| 244 |
+
|
| 245 |
+
# Remove underscores no início e fim
|
| 246 |
+
filename = filename.strip("_")
|
| 247 |
+
|
| 248 |
+
# Limita o comprimento
|
| 249 |
+
if len(filename) > config.FILENAME_MAX_LENGTH:
|
| 250 |
+
# Preserva a extensão
|
| 251 |
+
name, ext = os.path.splitext(filename)
|
| 252 |
+
max_name_len = config.FILENAME_MAX_LENGTH - len(ext)
|
| 253 |
+
filename = name[:max_name_len] + ext
|
| 254 |
+
|
| 255 |
+
# Se ficou vazio após sanitização
|
| 256 |
+
if not filename or filename == "." or filename == "..":
|
| 257 |
+
return "arquivo_sanitizado"
|
| 258 |
+
|
| 259 |
+
return filename
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def validate_files(files: list) -> list[tuple[Path, str]]:
|
| 263 |
+
"""
|
| 264 |
+
Valida uma lista de arquivos completamente.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
files: Lista de arquivos (podem ser paths ou objetos de arquivo).
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
Lista de tuplas (path, nome_sanitizado) para arquivos válidos.
|
| 271 |
+
|
| 272 |
+
Raises:
|
| 273 |
+
ValidationError: Se qualquer validação falhar.
|
| 274 |
+
"""
|
| 275 |
+
validate_file_count(files)
|
| 276 |
+
|
| 277 |
+
validated = []
|
| 278 |
+
|
| 279 |
+
for file_obj in files:
|
| 280 |
+
# Gradio retorna objetos com atributo 'name'
|
| 281 |
+
if hasattr(file_obj, "name"):
|
| 282 |
+
file_path = Path(file_obj.name)
|
| 283 |
+
else:
|
| 284 |
+
file_path = Path(file_obj)
|
| 285 |
+
|
| 286 |
+
# Valida tamanho
|
| 287 |
+
validate_file_size(file_path)
|
| 288 |
+
|
| 289 |
+
# Valida MIME type
|
| 290 |
+
validate_mime_type(file_path)
|
| 291 |
+
|
| 292 |
+
# Sanitiza nome
|
| 293 |
+
sanitized_name = sanitize_filename(file_path.name)
|
| 294 |
+
|
| 295 |
+
validated.append((file_path, sanitized_name))
|
| 296 |
+
|
| 297 |
+
return validated
|