nia-etl-utils 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nia_etl_utils/__init__.py CHANGED
@@ -11,6 +11,7 @@ Este pacote fornece funções reutilizáveis para:
11
11
  - Processamento e exportação de CSV (processa_csv)
12
12
  - Processamento paralelo de CSV grandes (processa_csv_paralelo)
13
13
  - Manipulação de arquivos e diretórios (limpeza_pastas)
14
+ - Processamento de OCR via API IntelliDoc (ocr)
14
15
 
15
16
  Exemplo de uso:
16
17
 
@@ -44,9 +45,21 @@ Exemplo de uso:
44
45
  )
45
46
  with conectar_postgresql(config) as conn:
46
47
  # ...
48
+
49
+ Exemplo de OCR:
50
+
51
+ from nia_etl_utils import executar_ocr
52
+ from nia_etl_utils.ocr import OcrError
53
+
54
+ try:
55
+ resultado = executar_ocr(blob_bytes, url_base="INTELLIDOC_URL")
56
+ texto = resultado["full_text"]
57
+ qualidade = resultado["overall_quality"]
58
+ except OcrError as e:
59
+ logger.error(f"Falha no OCR: {e}")
47
60
  """
48
61
 
49
- __version__ = "0.2.0"
62
+ __version__ = "0.2.1"
50
63
  __author__ = "Nícolas Galdino Esmael"
51
64
 
52
65
  # =============================================================================
@@ -134,6 +147,11 @@ from .logger_config import (
134
147
  remover_handlers_existentes,
135
148
  )
136
149
 
150
+ # OCR
151
+ from .ocr import (
152
+ executar_ocr,
153
+ )
154
+
137
155
  # Processamento CSV
138
156
  from .processa_csv import (
139
157
  exportar_multiplos_csv,
@@ -233,4 +251,6 @@ __all__ = [
233
251
  "remover_pasta_recursivamente",
234
252
  "criar_pasta_se_nao_existir",
235
253
  "listar_arquivos",
254
+ # OCR
255
+ "executar_ocr",
236
256
  ]
@@ -18,6 +18,10 @@ Hierarquia:
18
18
  ├── EmailError
19
19
  │ ├── DestinatarioError
20
20
  │ └── SmtpError
21
+ ├── OcrError
22
+ │ ├── OcrSubmissaoError
23
+ │ ├── OcrProcessamentoError
24
+ │ └── OcrTimeoutError
21
25
  └── ValidacaoError
22
26
 
23
27
  Examples:
@@ -306,6 +310,69 @@ class SmtpError(EmailError):
306
310
  pass
307
311
 
308
312
 
313
+ # =============================================================================
314
+ # OCR
315
+ # =============================================================================
316
+
317
+
318
+ class OcrError(NiaEtlError):
319
+ """Erro base para operações de OCR.
320
+
321
+ Examples:
322
+ >>> raise OcrError("Falha no processamento OCR")
323
+ """
324
+
325
+ pass
326
+
327
+
328
+ class OcrSubmissaoError(OcrError):
329
+ """Falha ao submeter documento para OCR.
330
+
331
+ Levantado quando não é possível enviar o documento para a API,
332
+ seja por problemas de rede, timeout ou resposta inválida.
333
+
334
+ Examples:
335
+ >>> raise OcrSubmissaoError(
336
+ ... "Timeout ao submeter documento",
337
+ ... details={"tentativas": 3, "status": 504}
338
+ ... )
339
+ """
340
+
341
+ pass
342
+
343
+
344
+ class OcrProcessamentoError(OcrError):
345
+ """Falha no processamento do documento pela API.
346
+
347
+ Levantado quando a API retorna status FAILURE ou REVOKED,
348
+ indicando que o documento não pôde ser processado.
349
+
350
+ Examples:
351
+ >>> raise OcrProcessamentoError(
352
+ ... "Documento corrompido",
353
+ ... details={"document_id": "abc-123", "erro_api": "invalid format"}
354
+ ... )
355
+ """
356
+
357
+ pass
358
+
359
+
360
+ class OcrTimeoutError(OcrError):
361
+ """Timeout aguardando resultado do OCR.
362
+
363
+ Levantado quando o tempo máximo de polling é atingido
364
+ sem que a API retorne um resultado final.
365
+
366
+ Examples:
367
+ >>> raise OcrTimeoutError(
368
+ ... "Timeout após 300s",
369
+ ... details={"document_id": "abc-123", "ultimo_status": "PENDING"}
370
+ ... )
371
+ """
372
+
373
+ pass
374
+
375
+
309
376
  # =============================================================================
310
377
  # VALIDAÇÃO
311
378
  # =============================================================================
nia_etl_utils/ocr.py ADDED
@@ -0,0 +1,401 @@
1
+ """Módulo para processamento de OCR via API IntelliDoc.
2
+
3
+ Este módulo fornece funções para submeter documentos ao serviço de OCR
4
+ do MPRJ e aguardar o resultado do processamento.
5
+
6
+ A API IntelliDoc processa documentos de forma assíncrona:
7
+ 1. POST /documents/files → submete documento, retorna document_id
8
+ 2. GET /documents/{id} → consulta status/resultado
9
+
10
+ Example:
11
+ Uso básico com variável de ambiente:
12
+
13
+ >>> from nia_etl_utils.ocr import executar_ocr
14
+ >>>
15
+ >>> with open("documento.pdf", "rb") as f:
16
+ ... resultado = executar_ocr(
17
+ ... conteudo=f.read(),
18
+ ... url_base="INTELLIDOC_URL", # env var
19
+ ... )
20
+ >>> print(resultado["full_text"])
21
+
22
+ Uso com URL direta e configurações customizadas:
23
+
24
+ >>> resultado = executar_ocr(
25
+ ... conteudo=blob_bytes,
26
+ ... url_base="http://google.com",
27
+ ... timeout_polling=600,
28
+ ... max_tentativas=5,
29
+ ... )
30
+ """
31
+
32
+ import time
33
+
34
+ import requests
35
+ from loguru import logger
36
+
37
+ from .env_config import obter_variavel_env
38
+ from .exceptions import NiaEtlError
39
+
40
+ # =============================================================================
41
+ # EXCEÇÕES
42
+ # =============================================================================
43
+
44
+
45
+ class OcrError(NiaEtlError):
46
+ """Erro base para operações de OCR.
47
+
48
+ Examples:
49
+ >>> raise OcrError("Falha no processamento OCR")
50
+ """
51
+
52
+ pass
53
+
54
+
55
+ class OcrSubmissaoError(OcrError):
56
+ """Falha ao submeter documento para OCR.
57
+
58
+ Levantado quando não é possível enviar o documento para a API,
59
+ seja por problemas de rede, timeout ou resposta inválida.
60
+
61
+ Examples:
62
+ >>> raise OcrSubmissaoError(
63
+ ... "Timeout ao submeter documento",
64
+ ... details={"tentativas": 3, "status": 504}
65
+ ... )
66
+ """
67
+
68
+ pass
69
+
70
+
71
+ class OcrProcessamentoError(OcrError):
72
+ """Falha no processamento do documento pela API.
73
+
74
+ Levantado quando a API retorna status FAILURE ou REVOKED,
75
+ indicando que o documento não pôde ser processado.
76
+
77
+ Examples:
78
+ >>> raise OcrProcessamentoError(
79
+ ... "Documento corrompido",
80
+ ... details={"document_id": "abc-123", "erro_api": "invalid format"}
81
+ ... )
82
+ """
83
+
84
+ pass
85
+
86
+
87
+ class OcrTimeoutError(OcrError):
88
+ """Timeout aguardando resultado do OCR.
89
+
90
+ Levantado quando o tempo máximo de polling é atingido
91
+ sem que a API retorne um resultado final.
92
+
93
+ Examples:
94
+ >>> raise OcrTimeoutError(
95
+ ... "Timeout após 300s",
96
+ ... details={"document_id": "abc-123", "ultimo_status": "PENDING"}
97
+ ... )
98
+ """
99
+
100
+ pass
101
+
102
+
103
+ # =============================================================================
104
+ # CONSTANTES
105
+ # =============================================================================
106
+
107
+ MAGIC_BYTES: dict[bytes, str] = {
108
+ b"%PDF": ".pdf",
109
+ b"\xff\xd8\xff": ".jpg",
110
+ b"\x89PNG\r\n\x1a\n": ".png",
111
+ b"GIF87a": ".gif",
112
+ b"GIF89a": ".gif",
113
+ b"BM": ".bmp",
114
+ b"II*\x00": ".tiff",
115
+ b"MM\x00*": ".tiff",
116
+ }
117
+
118
+ PATH_ENVIO = "/documents/files"
119
+ PATH_CONSULTA = "/documents"
120
+
121
+
122
+ # =============================================================================
123
+ # FUNÇÕES AUXILIARES
124
+ # =============================================================================
125
+
126
+
127
+ def _detectar_extensao(conteudo: bytes) -> str:
128
+ """Detecta a extensão do arquivo baseado nos magic bytes.
129
+
130
+ Args:
131
+ conteudo: Bytes do arquivo.
132
+
133
+ Returns:
134
+ Extensão detectada (ex: '.pdf', '.jpg') ou '.bin' se não reconhecido.
135
+ """
136
+ conteudo_inicio = conteudo[:32].lstrip()
137
+
138
+ for magic, extensao in MAGIC_BYTES.items():
139
+ if conteudo_inicio.startswith(magic):
140
+ return extensao
141
+
142
+ return ".bin"
143
+
144
+
145
+ def _normalizar_para_bytes(blob: object) -> bytes:
146
+ """Converte diferentes tipos de BLOB para bytes.
147
+
148
+ Suporta LOBs do cx_Oracle/oracledb, memoryview e bytes/bytearray.
149
+
150
+ Args:
151
+ blob: Objeto contendo dados binários.
152
+
153
+ Returns:
154
+ Conteúdo em bytes.
155
+
156
+ Raises:
157
+ TypeError: Se o tipo do blob não for suportado.
158
+ """
159
+ if hasattr(blob, "read"):
160
+ return blob.read() # type: ignore[union-attr]
161
+
162
+ if isinstance(blob, memoryview):
163
+ return blob.tobytes()
164
+
165
+ if isinstance(blob, (bytes, bytearray)):
166
+ return bytes(blob)
167
+
168
+ raise TypeError(f"Tipo de blob não suportado: {type(blob)}")
169
+
170
+
171
+ def _resolver_url_base(url_base: str) -> str:
172
+ """Resolve URL base a partir de valor direto ou nome de variável de ambiente.
173
+
174
+ Args:
175
+ url_base: URL direta (começa com http) ou nome de variável de ambiente.
176
+
177
+ Returns:
178
+ URL base resolvida, sem barra final.
179
+ """
180
+ if url_base.startswith(("http://", "https://")): # noqa
181
+ url = url_base
182
+ else:
183
+ url = obter_variavel_env(url_base)
184
+
185
+ return url.rstrip("/")
186
+
187
+
188
+ def _submeter_documento(
189
+ url_base: str,
190
+ conteudo: bytes,
191
+ extensao: str,
192
+ max_tentativas: int,
193
+ intervalo_retry: int,
194
+ ) -> str:
195
+ """Submete documento para processamento OCR.
196
+
197
+ Args:
198
+ url_base: URL base da API.
199
+ conteudo: Bytes do documento.
200
+ extensao: Extensão do arquivo.
201
+ max_tentativas: Número máximo de tentativas.
202
+ intervalo_retry: Segundos entre tentativas.
203
+
204
+ Returns:
205
+ document_id retornado pela API.
206
+
207
+ Raises:
208
+ OcrSubmissaoError: Se todas as tentativas falharem.
209
+ """
210
+ url = f"{url_base}{PATH_ENVIO}"
211
+ nome_arquivo = f"documento{extensao}"
212
+ files = {"files": (nome_arquivo, conteudo)}
213
+
214
+ ultima_excecao: Exception | None = None
215
+
216
+ for tentativa in range(1, max_tentativas + 1):
217
+ try:
218
+ logger.debug(
219
+ f"Tentativa {tentativa}/{max_tentativas} - "
220
+ f"Submetendo OCR (extensao={extensao}, tamanho={len(conteudo)} bytes)"
221
+ )
222
+
223
+ resp = requests.post(url, files=files, timeout=120)
224
+
225
+ if resp.ok:
226
+ resultado = resp.json()
227
+ document_id = resultado["documents"][0]["document_id"]
228
+ logger.info(f"Documento submetido com sucesso. document_id={document_id}")
229
+ return document_id
230
+
231
+ try:
232
+ detalhe = resp.json()
233
+ except Exception:
234
+ detalhe = resp.text
235
+
236
+ ultima_excecao = OcrSubmissaoError(
237
+ f"API retornou status {resp.status_code}",
238
+ details={"status": resp.status_code, "detalhe": detalhe},
239
+ )
240
+ logger.warning(f"Tentativa {tentativa} falhou: status={resp.status_code}")
241
+
242
+ except requests.RequestException as e:
243
+ ultima_excecao = e
244
+ logger.warning(f"Tentativa {tentativa} falhou com exceção: {e}")
245
+
246
+ if tentativa < max_tentativas:
247
+ logger.info(f"Aguardando {intervalo_retry}s antes da próxima tentativa...")
248
+ time.sleep(intervalo_retry)
249
+
250
+ raise OcrSubmissaoError(
251
+ f"Submissão OCR falhou após {max_tentativas} tentativas",
252
+ details={"tentativas": max_tentativas, "ultimo_erro": str(ultima_excecao)},
253
+ )
254
+
255
+
256
+ def _aguardar_resultado(
257
+ url_base: str,
258
+ document_id: str,
259
+ timeout_polling: int,
260
+ intervalo_polling: int,
261
+ ) -> dict:
262
+ """Aguarda resultado do processamento OCR via polling.
263
+
264
+ Args:
265
+ url_base: URL base da API.
266
+ document_id: ID do documento retornado na submissão.
267
+ timeout_polling: Tempo máximo de espera em segundos.
268
+ intervalo_polling: Intervalo entre consultas em segundos.
269
+
270
+ Returns:
271
+ Dicionário com resultado completo da API (campo 'result').
272
+
273
+ Raises:
274
+ OcrProcessamentoError: Se a API retornar FAILURE ou REVOKED.
275
+ OcrTimeoutError: Se o timeout for atingido.
276
+ """
277
+ url = f"{url_base}{PATH_CONSULTA}/{document_id}"
278
+ inicio = time.time()
279
+ ultimo_status = "UNKNOWN"
280
+
281
+ while True:
282
+ tempo_decorrido = time.time() - inicio
283
+
284
+ if tempo_decorrido >= timeout_polling:
285
+ raise OcrTimeoutError(
286
+ f"Timeout após {timeout_polling}s aguardando OCR",
287
+ details={"document_id": document_id, "ultimo_status": ultimo_status},
288
+ )
289
+
290
+ try:
291
+ resp = requests.get(url, timeout=30)
292
+
293
+ if resp.ok:
294
+ resultado = resp.json()
295
+ ultimo_status = resultado.get("status", "UNKNOWN")
296
+
297
+ if ultimo_status == "SUCCESS":
298
+ logger.info(f"OCR concluído para document_id={document_id}")
299
+ return resultado.get("result", {})
300
+
301
+ if ultimo_status in ("FAILURE", "REVOKED"):
302
+ erro = resultado.get("error") or resultado.get("message") or "Erro desconhecido"
303
+ raise OcrProcessamentoError(
304
+ f"OCR falhou: {erro}",
305
+ details={"document_id": document_id, "status": ultimo_status, "erro_api": erro},
306
+ )
307
+
308
+ logger.debug(f"document_id={document_id} status={ultimo_status}, aguardando...")
309
+
310
+ except requests.RequestException as e:
311
+ logger.warning(f"Erro ao consultar status: {e}")
312
+
313
+ time.sleep(intervalo_polling)
314
+
315
+
316
+ # =============================================================================
317
+ # FUNÇÃO PRINCIPAL
318
+ # =============================================================================
319
+
320
+
321
+ def executar_ocr(
322
+ conteudo: bytes | object,
323
+ url_base: str = "INTELLIDOC_URL",
324
+ timeout_polling: int = 300,
325
+ max_tentativas: int = 3,
326
+ intervalo_retry: int = 5,
327
+ intervalo_polling: int = 1,
328
+ ) -> dict:
329
+ """Executa OCR em documento via API IntelliDoc.
330
+
331
+ Submete o documento para processamento e aguarda o resultado.
332
+ A extensão do arquivo é detectada automaticamente pelos magic bytes.
333
+
334
+ Args:
335
+ conteudo: Bytes do documento ou objeto com método read() (LOB Oracle).
336
+ url_base: URL da API ou nome da variável de ambiente.
337
+ Se começar com 'http://' ou 'https://', usa como URL direta.
338
+ Caso contrário, busca no .env. Default: "INTELLIDOC_URL".
339
+ timeout_polling: Tempo máximo em segundos para aguardar resultado.
340
+ Default: 300 (5 minutos).
341
+ max_tentativas: Número de tentativas para submissão. Default: 3.
342
+ intervalo_retry: Segundos entre tentativas de submissão. Default: 5.
343
+ intervalo_polling: Segundos entre consultas de status. Default: 1.
344
+
345
+ Returns:
346
+ Dicionário com resultado completo da API, contendo:
347
+ - document_id: ID do documento
348
+ - full_text: Texto extraído completo
349
+ - mime_type: Tipo MIME detectado
350
+ - overall_quality: Qualidade geral do OCR (0-1)
351
+ - total_pages: Número de páginas
352
+ - processing_time_ms: Tempo de processamento
353
+ - pages: Lista com detalhes de cada página
354
+ - metadata: Metadados adicionais
355
+
356
+ Raises:
357
+ OcrSubmissaoError: Se falhar ao submeter documento.
358
+ OcrProcessamentoError: Se a API retornar erro no processamento.
359
+ OcrTimeoutError: Se timeout for atingido aguardando resultado.
360
+ TypeError: Se o tipo do conteúdo não for suportado.
361
+
362
+ Examples:
363
+ Uso básico:
364
+
365
+ >>> resultado = executar_ocr(blob_bytes, url_base="INTELLIDOC_URL")
366
+ >>> print(resultado["full_text"])
367
+
368
+ Uso com URL direta:
369
+
370
+ >>> resultado = executar_ocr(
371
+ ... conteudo=pdf_bytes,
372
+ ... url_base="http://google.com",
373
+ ... timeout_polling=600,
374
+ ... )
375
+ >>> print(f"Qualidade: {resultado['overall_quality']}")
376
+
377
+ Acessando detalhes das páginas:
378
+
379
+ >>> for page in resultado["pages"]:
380
+ ... print(f"Página {page['page_number']}: {page['extraction_method']}")
381
+ """
382
+ conteudo_bytes = _normalizar_para_bytes(conteudo)
383
+ extensao = _detectar_extensao(conteudo_bytes)
384
+ url = _resolver_url_base(url_base)
385
+
386
+ logger.info(f"Iniciando OCR (tamanho={len(conteudo_bytes)} bytes, extensao={extensao})")
387
+
388
+ document_id = _submeter_documento(
389
+ url_base=url,
390
+ conteudo=conteudo_bytes,
391
+ extensao=extensao,
392
+ max_tentativas=max_tentativas,
393
+ intervalo_retry=intervalo_retry,
394
+ )
395
+
396
+ return _aguardar_resultado(
397
+ url_base=url,
398
+ document_id=document_id,
399
+ timeout_polling=timeout_polling,
400
+ intervalo_polling=intervalo_polling,
401
+ )