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/__init__.py +50 -29
- nia_etl_utils/exceptions.py +51 -141
- nia_etl_utils/ocr.py +327 -0
- nia_etl_utils-0.2.2.dist-info/METADATA +722 -0
- {nia_etl_utils-0.2.0.dist-info → nia_etl_utils-0.2.2.dist-info}/RECORD +7 -6
- {nia_etl_utils-0.2.0.dist-info → nia_etl_utils-0.2.2.dist-info}/WHEEL +1 -1
- nia_etl_utils-0.2.0.dist-info/METADATA +0 -615
- {nia_etl_utils-0.2.0.dist-info → nia_etl_utils-0.2.2.dist-info}/top_level.txt +0 -0
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
|
+
)
|