nia-etl-utils 0.2.0__py3-none-any.whl → 0.2.2__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/ocr.py ADDED
@@ -0,0 +1,327 @@
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 import executar_ocr, OcrError
14
+ >>>
15
+ >>> try:
16
+ ... resultado = executar_ocr(blob_bytes, url_base="INTELLIDOC_URL")
17
+ ... print(resultado["full_text"])
18
+ ... except OcrError as e:
19
+ ... logger.error(f"Falha no OCR: {e}")
20
+ """
21
+
22
+ import time
23
+
24
+ import requests
25
+ from loguru import logger
26
+
27
+ from .env_config import obter_variavel_env
28
+ from .exceptions import (
29
+ OcrProcessamentoError,
30
+ OcrSubmissaoError,
31
+ OcrTimeoutError,
32
+ )
33
+
34
+ # =============================================================================
35
+ # CONSTANTES
36
+ # =============================================================================
37
+
38
+ MAGIC_BYTES: dict[bytes, str] = {
39
+ b"%PDF": ".pdf",
40
+ b"\xff\xd8\xff": ".jpg",
41
+ b"\x89PNG\r\n\x1a\n": ".png",
42
+ b"GIF87a": ".gif",
43
+ b"GIF89a": ".gif",
44
+ b"BM": ".bmp",
45
+ b"II*\x00": ".tiff",
46
+ b"MM\x00*": ".tiff",
47
+ }
48
+
49
+ PATH_ENVIO = "/documents/files"
50
+ PATH_CONSULTA = "/documents"
51
+
52
+
53
+ # =============================================================================
54
+ # FUNÇÕES AUXILIARES
55
+ # =============================================================================
56
+
57
+
58
+ def _detectar_extensao(conteudo: bytes) -> str:
59
+ """Detecta a extensão do arquivo baseado nos magic bytes.
60
+
61
+ Args:
62
+ conteudo: Bytes do arquivo.
63
+
64
+ Returns:
65
+ Extensão detectada (ex: '.pdf', '.jpg') ou '.bin' se não reconhecido.
66
+ """
67
+ conteudo_inicio = conteudo[:32].lstrip()
68
+
69
+ for magic, extensao in MAGIC_BYTES.items():
70
+ if conteudo_inicio.startswith(magic):
71
+ return extensao
72
+
73
+ return ".bin"
74
+
75
+
76
+ def _normalizar_para_bytes(blob: object) -> bytes:
77
+ """Converte diferentes tipos de BLOB para bytes.
78
+
79
+ Suporta LOBs do cx_Oracle/oracledb, memoryview e bytes/bytearray.
80
+
81
+ Args:
82
+ blob: Objeto contendo dados binários.
83
+
84
+ Returns:
85
+ Conteúdo em bytes.
86
+
87
+ Raises:
88
+ TypeError: Se o tipo do blob não for suportado.
89
+ """
90
+ if hasattr(blob, "read"):
91
+ return blob.read() # type: ignore[union-attr]
92
+
93
+ if isinstance(blob, memoryview):
94
+ return blob.tobytes()
95
+
96
+ if isinstance(blob, (bytes, bytearray)):
97
+ return bytes(blob)
98
+
99
+ raise TypeError(f"Tipo de blob não suportado: {type(blob)}")
100
+
101
+
102
+ def _resolver_url_base(url_base: str) -> str:
103
+ """Resolve URL base a partir de valor direto ou nome de variável de ambiente.
104
+
105
+ Args:
106
+ url_base: URL direta (começa com http) ou nome de variável de ambiente.
107
+
108
+ Returns:
109
+ URL base resolvida, sem barra final.
110
+ """
111
+ if url_base.startswith(("http://", "https://")): # noqa
112
+ url = url_base
113
+ else:
114
+ url = obter_variavel_env(url_base)
115
+
116
+ return url.rstrip("/")
117
+
118
+
119
+ def _submeter_documento(
120
+ url_base: str,
121
+ conteudo: bytes,
122
+ extensao: str,
123
+ max_tentativas: int,
124
+ intervalo_retry: int,
125
+ ) -> str:
126
+ """Submete documento para processamento OCR.
127
+
128
+ Args:
129
+ url_base: URL base da API.
130
+ conteudo: Bytes do documento.
131
+ extensao: Extensão do arquivo.
132
+ max_tentativas: Número máximo de tentativas.
133
+ intervalo_retry: Segundos entre tentativas.
134
+
135
+ Returns:
136
+ document_id retornado pela API.
137
+
138
+ Raises:
139
+ OcrSubmissaoError: Se todas as tentativas falharem.
140
+ """
141
+ url = f"{url_base}{PATH_ENVIO}"
142
+ nome_arquivo = f"documento{extensao}"
143
+ files = {"files": (nome_arquivo, conteudo)}
144
+
145
+ ultima_excecao: Exception | None = None
146
+
147
+ for tentativa in range(1, max_tentativas + 1):
148
+ try:
149
+ logger.debug(
150
+ f"Tentativa {tentativa}/{max_tentativas} - "
151
+ f"Submetendo OCR (extensao={extensao}, tamanho={len(conteudo)} bytes)"
152
+ )
153
+
154
+ resp = requests.post(url, files=files, timeout=120)
155
+
156
+ if resp.ok:
157
+ resultado = resp.json()
158
+ document_id = resultado["documents"][0]["document_id"]
159
+ logger.info(f"Documento submetido com sucesso. document_id={document_id}")
160
+ return document_id
161
+
162
+ try:
163
+ detalhe = resp.json()
164
+ except Exception:
165
+ detalhe = resp.text
166
+
167
+ ultima_excecao = OcrSubmissaoError(
168
+ f"API retornou status {resp.status_code}",
169
+ details={"status": resp.status_code, "detalhe": detalhe},
170
+ )
171
+ logger.warning(f"Tentativa {tentativa} falhou: status={resp.status_code}")
172
+
173
+ except requests.RequestException as e:
174
+ ultima_excecao = e
175
+ logger.warning(f"Tentativa {tentativa} falhou com exceção: {e}")
176
+
177
+ if tentativa < max_tentativas:
178
+ logger.info(f"Aguardando {intervalo_retry}s antes da próxima tentativa...")
179
+ time.sleep(intervalo_retry)
180
+
181
+ raise OcrSubmissaoError(
182
+ f"Submissão OCR falhou após {max_tentativas} tentativas",
183
+ details={"tentativas": max_tentativas, "ultimo_erro": str(ultima_excecao)},
184
+ )
185
+
186
+
187
+ def _aguardar_resultado(
188
+ url_base: str,
189
+ document_id: str,
190
+ timeout_polling: int,
191
+ intervalo_polling: int,
192
+ ) -> dict:
193
+ """Aguarda resultado do processamento OCR via polling.
194
+
195
+ Args:
196
+ url_base: URL base da API.
197
+ document_id: ID do documento retornado na submissão.
198
+ timeout_polling: Tempo máximo de espera em segundos.
199
+ intervalo_polling: Intervalo entre consultas em segundos.
200
+
201
+ Returns:
202
+ Dicionário com resultado completo da API (campo 'result').
203
+
204
+ Raises:
205
+ OcrProcessamentoError: Se a API retornar FAILURE ou REVOKED.
206
+ OcrTimeoutError: Se o timeout for atingido.
207
+ """
208
+ url = f"{url_base}{PATH_CONSULTA}/{document_id}"
209
+ inicio = time.time()
210
+ ultimo_status = "UNKNOWN"
211
+
212
+ while True:
213
+ tempo_decorrido = time.time() - inicio
214
+
215
+ if tempo_decorrido >= timeout_polling:
216
+ raise OcrTimeoutError(
217
+ f"Timeout após {timeout_polling}s aguardando OCR",
218
+ details={"document_id": document_id, "ultimo_status": ultimo_status},
219
+ )
220
+
221
+ try:
222
+ resp = requests.get(url, timeout=30)
223
+
224
+ if resp.ok:
225
+ resultado = resp.json()
226
+ ultimo_status = resultado.get("status", "UNKNOWN")
227
+
228
+ if ultimo_status == "SUCCESS":
229
+ logger.info(f"OCR concluído para document_id={document_id}")
230
+ return resultado.get("result", {})
231
+
232
+ if ultimo_status in ("FAILURE", "REVOKED"):
233
+ erro = resultado.get("error") or resultado.get("message") or "Erro desconhecido"
234
+ raise OcrProcessamentoError(
235
+ f"OCR falhou: {erro}",
236
+ details={"document_id": document_id, "status": ultimo_status, "erro_api": erro},
237
+ )
238
+
239
+ logger.debug(f"document_id={document_id} status={ultimo_status}, aguardando...")
240
+
241
+ except requests.RequestException as e:
242
+ logger.warning(f"Erro ao consultar status: {e}")
243
+
244
+ time.sleep(intervalo_polling)
245
+
246
+
247
+ # =============================================================================
248
+ # FUNÇÃO PRINCIPAL
249
+ # =============================================================================
250
+
251
+
252
+ def executar_ocr(
253
+ conteudo: bytes | object,
254
+ url_base: str = "INTELLIDOC_URL",
255
+ timeout_polling: int = 300,
256
+ max_tentativas: int = 3,
257
+ intervalo_retry: int = 5,
258
+ intervalo_polling: int = 1,
259
+ ) -> dict:
260
+ """Executa OCR em documento via API IntelliDoc.
261
+
262
+ Submete o documento para processamento e aguarda o resultado.
263
+ A extensão do arquivo é detectada automaticamente pelos magic bytes.
264
+
265
+ Args:
266
+ conteudo: Bytes do documento ou objeto com método read() (LOB Oracle).
267
+ url_base: URL da API ou nome da variável de ambiente.
268
+ Se começar com 'http://' ou 'https://', usa como URL direta.
269
+ Caso contrário, busca no .env. Default: "INTELLIDOC_URL".
270
+ timeout_polling: Tempo máximo em segundos para aguardar resultado.
271
+ Default: 300 (5 minutos).
272
+ max_tentativas: Número de tentativas para submissão. Default: 3.
273
+ intervalo_retry: Segundos entre tentativas de submissão. Default: 5.
274
+ intervalo_polling: Segundos entre consultas de status. Default: 1.
275
+
276
+ Returns:
277
+ Dicionário com resultado completo da API, contendo:
278
+ - document_id: ID do documento
279
+ - full_text: Texto extraído completo
280
+ - mime_type: Tipo MIME detectado
281
+ - overall_quality: Qualidade geral do OCR (0-1)
282
+ - total_pages: Número de páginas
283
+ - processing_time_ms: Tempo de processamento
284
+ - pages: Lista com detalhes de cada página
285
+ - metadata: Metadados adicionais
286
+
287
+ Raises:
288
+ OcrSubmissaoError: Se falhar ao submeter documento.
289
+ OcrProcessamentoError: Se a API retornar erro no processamento.
290
+ OcrTimeoutError: Se timeout for atingido aguardando resultado.
291
+ TypeError: Se o tipo do conteúdo não for suportado.
292
+
293
+ Examples:
294
+ Uso básico:
295
+
296
+ >>> resultado = executar_ocr(blob_bytes, url_base="INTELLIDOC_URL")
297
+ >>> print(resultado["full_text"])
298
+
299
+ Uso com URL direta:
300
+
301
+ >>> resultado = executar_ocr(
302
+ ... conteudo=pdf_bytes,
303
+ ... url_base="http://intellidoc.mprj.mp.br",
304
+ ... timeout_polling=600,
305
+ ... )
306
+ >>> print(f"Qualidade: {resultado['overall_quality']}")
307
+ """
308
+ conteudo_bytes = _normalizar_para_bytes(conteudo)
309
+ extensao = _detectar_extensao(conteudo_bytes)
310
+ url = _resolver_url_base(url_base)
311
+
312
+ logger.info(f"Iniciando OCR (tamanho={len(conteudo_bytes)} bytes, extensao={extensao})")
313
+
314
+ document_id = _submeter_documento(
315
+ url_base=url,
316
+ conteudo=conteudo_bytes,
317
+ extensao=extensao,
318
+ max_tentativas=max_tentativas,
319
+ intervalo_retry=intervalo_retry,
320
+ )
321
+
322
+ return _aguardar_resultado(
323
+ url_base=url,
324
+ document_id=document_id,
325
+ timeout_polling=timeout_polling,
326
+ intervalo_polling=intervalo_polling,
327
+ )