csf-scraper-python 0.1.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.
- csf_scraper/__init__.py +119 -0
- csf_scraper/data_extractor.py +74 -0
- csf_scraper/date_parser.py +26 -0
- csf_scraper/exceptions/__init__.py +17 -0
- csf_scraper/exceptions/image_reader.py +106 -0
- csf_scraper/exceptions/pdf_reader.py +33 -0
- csf_scraper/exceptions/value_objects.py +11 -0
- csf_scraper/image_readers/__init__.py +52 -0
- csf_scraper/image_readers/adapters/__init__.py +18 -0
- csf_scraper/image_readers/adapters/llms/__init__.py +33 -0
- csf_scraper/image_readers/adapters/llms/gemini_image_reader.py +157 -0
- csf_scraper/image_readers/adapters/llms/prompts/__init__.py +8 -0
- csf_scraper/image_readers/adapters/llms/prompts/llm_image_reader_prompt.py +17 -0
- csf_scraper/image_readers/csf_image_extractor.py +29 -0
- csf_scraper/image_readers/ports/__init__.py +5 -0
- csf_scraper/image_readers/ports/image_reader_port.py +37 -0
- csf_scraper/image_readers/schemas/__init__.py +5 -0
- csf_scraper/image_readers/schemas/rfc_cif_schema.py +42 -0
- csf_scraper/image_readers/services/__init__.py +18 -0
- csf_scraper/image_readers/services/llm_image_reader_service.py +328 -0
- csf_scraper/pdf_reader/__init__.py +7 -0
- csf_scraper/pdf_reader/csf_pdf_extractor.py +33 -0
- csf_scraper/pdf_reader/pdf_csf_text_parser.py +54 -0
- csf_scraper/pdf_reader/pdf_to_text.py +36 -0
- csf_scraper/persona.py +64 -0
- csf_scraper/persona_fisica.py +25 -0
- csf_scraper/persona_moral.py +21 -0
- csf_scraper/ports/__init__.py +5 -0
- csf_scraper/ports/rfc_cif_extractor_port.py +40 -0
- csf_scraper/py.typed +0 -0
- csf_scraper/regimen.py +62 -0
- csf_scraper/rfc_cif_data.py +15 -0
- csf_scraper/scraper.py +117 -0
- csf_scraper/value_objects/__init__.py +7 -0
- csf_scraper/value_objects/csf_image.py +68 -0
- csf_scraper/value_objects/id_cif.py +48 -0
- csf_scraper/value_objects/rfc.py +56 -0
- csf_scraper_python-0.1.1.dist-info/METADATA +390 -0
- csf_scraper_python-0.1.1.dist-info/RECORD +42 -0
- csf_scraper_python-0.1.1.dist-info/WHEEL +4 -0
- csf_scraper_python-0.1.1.dist-info/licenses/LICENSE +21 -0
- csf_scraper_python-0.1.1.dist-info/licenses/NOTICE.md +16 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Puente entre una ruta de archivo y un motor de lectura de imágenes."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from csf_scraper.image_readers.ports.image_reader_port import ImageReaderPort
|
|
6
|
+
from csf_scraper.ports.rfc_cif_extractor_port import RfcCifExtractorPort
|
|
7
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
8
|
+
from csf_scraper.value_objects.csf_image import CsfImage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CsfImageExtractor(RfcCifExtractorPort):
|
|
12
|
+
"""Implementa :class:`~csf_scraper.ports.RfcCifExtractorPort` con un motor.
|
|
13
|
+
|
|
14
|
+
Lee la imagen de ``path``, la valida como :class:`CsfImage` y delega en el
|
|
15
|
+
:class:`~csf_scraper.image_readers.ports.ImageReaderPort` inyectado. Sirve
|
|
16
|
+
para cualquier motor (LLM u OCR) y así todos los extractores de imagen
|
|
17
|
+
comparten el mismo contrato público: ``extract(path) -> RfcCifData``.
|
|
18
|
+
|
|
19
|
+
La lectura del archivo se hace con :func:`asyncio.to_thread` para no
|
|
20
|
+
bloquear el bucle de eventos, y el motor se espera con ``await``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, reader: ImageReaderPort) -> None:
|
|
24
|
+
self._reader = reader
|
|
25
|
+
|
|
26
|
+
async def extract(self, path: str) -> RfcCifData:
|
|
27
|
+
"""Devuelve el RFC y el idCIF de la imagen de CSF en ``path``."""
|
|
28
|
+
image = await asyncio.to_thread(CsfImage.new_from_path, path)
|
|
29
|
+
return await self._reader.read(image)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Puerto que abstrae el motor de lectura de imágenes (LLM multimodal u OCR)."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
6
|
+
from csf_scraper.value_objects.csf_image import CsfImage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ImageReaderPort(ABC):
|
|
10
|
+
"""Extrae el :class:`RfcCifData` de la imagen ya materializada de una CSF.
|
|
11
|
+
|
|
12
|
+
Este es el contrato de un **motor**, no de un origen: recibe una
|
|
13
|
+
:class:`~csf_scraper.value_objects.CsfImage` en memoria y nunca toca el
|
|
14
|
+
disco. Para leer desde una ruta se usa
|
|
15
|
+
:class:`~csf_scraper.image_readers.CsfImageExtractor`, que adapta este
|
|
16
|
+
puerto al contrato público
|
|
17
|
+
:class:`~csf_scraper.ports.RfcCifExtractorPort`.
|
|
18
|
+
|
|
19
|
+
Reglas del contrato:
|
|
20
|
+
|
|
21
|
+
- No lee archivos: toda la validación de formato la hace :class:`CsfImage`.
|
|
22
|
+
- Devuelve :class:`RfcCifData` con los value objects ya construidos.
|
|
23
|
+
- Las implementaciones de la librería reportan sus fallos con excepciones
|
|
24
|
+
de la librería; en particular
|
|
25
|
+
:class:`~csf_scraper.exceptions.image_reader.ImageReadingException`. Un
|
|
26
|
+
motor externo puede propagar sus propias excepciones.
|
|
27
|
+
|
|
28
|
+
Implementaciones: :class:`~csf_scraper.image_readers.adapters.llms.GeminiImageReader`
|
|
29
|
+
(hereda de :class:`~csf_scraper.image_readers.LlmImageReaderService` y de
|
|
30
|
+
este puerto; sirve de base para otros proveedores) o un pipeline de OCR
|
|
31
|
+
(OpenCV + Tesseract).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def read(self, image: CsfImage) -> RfcCifData:
|
|
36
|
+
"""Devuelve el RFC y el idCIF leídos de ``image``."""
|
|
37
|
+
...
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RfcCifSchema(BaseModel):
|
|
5
|
+
"""Datos que el LLM debe devolver de una imagen de Constancia de Situación Fiscal (CSF).
|
|
6
|
+
|
|
7
|
+
Se usa como ``output_cls`` del programa multimodal de LlamaIndex, que
|
|
8
|
+
adjunta automáticamente al prompt el JSON Schema de esta clase.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
rfc: str = Field(
|
|
12
|
+
min_length=1,
|
|
13
|
+
description=(
|
|
14
|
+
"Registro Federal de Contribuyentes (RFC) de la persona física o moral. "
|
|
15
|
+
"Ubicación en la imagen:\n"
|
|
16
|
+
"1. En el renglón 'RFC:' de la sección 'Datos de Identificación del "
|
|
17
|
+
"Contribuyente'.\n"
|
|
18
|
+
"2. Arriba del texto 'Registro Federal de Contribuyentes', a la derecha "
|
|
19
|
+
"del código QR.\n"
|
|
20
|
+
"3. Debajo del código de barras vertical del lado derecho del encabezado.\n"
|
|
21
|
+
"Formato: Cadena alfanumérica en mayúsculas de 12 caracteres (persona moral) o 13 "
|
|
22
|
+
"caracteres (persona física) sin espacios, guiones ni puntos.\n"
|
|
23
|
+
"Ejemplos válidos: 'SME2009297K7', 'MEPF050926N61', 'TME990513J64'."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
id_cif: str = Field(
|
|
27
|
+
min_length=1,
|
|
28
|
+
description=(
|
|
29
|
+
"Identificador de la Cédula de Identificación Fiscal (idCIF).\n"
|
|
30
|
+
"Ubicación en la imagen:\n"
|
|
31
|
+
"Se encuentra EXCLUSIVAMENTE dentro del recuadro de la 'CÉDULA DE "
|
|
32
|
+
"IDENTIFICACIÓN FISCAL' "
|
|
33
|
+
"(a la derecha del código QR), sobre la leyenda 'VALIDA TU INFORMACIÓN FISCAL', "
|
|
34
|
+
"antecedido por la etiqueta 'idCIF:'.\n"
|
|
35
|
+
"Formato: Extraer ÚNICAMENTE los números (de 8 a 13 dígitos consecutivos), "
|
|
36
|
+
"omitiendo el prefijo 'idCIF:', espacios, guiones o cualquier otro carácter.\n"
|
|
37
|
+
"Ejemplos válidos:\n"
|
|
38
|
+
"- Si dice 'idCIF: 20090374035', extraer únicamente '20090374035'.\n"
|
|
39
|
+
"- Si dice 'idCIF: 25020218873', extraer únicamente '25020218873'.\n"
|
|
40
|
+
"- Si dice 'idCIF: 15080334415', extraer únicamente '15080334415'."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Servicios compartidos por los adaptadores de lectura de imagen."""
|
|
2
|
+
|
|
3
|
+
from csf_scraper.image_readers.adapters.llms.prompts import CSF_PROMPT, RETRY_PROMPT
|
|
4
|
+
from csf_scraper.image_readers.services.llm_image_reader_service import (
|
|
5
|
+
MAX_RETRIES,
|
|
6
|
+
MAX_RETRY_BACKOFF,
|
|
7
|
+
RETRY_BACKOFF,
|
|
8
|
+
LlmImageReaderService,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CSF_PROMPT",
|
|
13
|
+
"MAX_RETRIES",
|
|
14
|
+
"MAX_RETRY_BACKOFF",
|
|
15
|
+
"RETRY_BACKOFF",
|
|
16
|
+
"RETRY_PROMPT",
|
|
17
|
+
"LlmImageReaderService",
|
|
18
|
+
]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Base común de los adaptadores de imagen basados en un LLM multimodal.
|
|
2
|
+
|
|
3
|
+
:class:`LlmImageReaderService` concentra exactamente cinco responsabilidades y
|
|
4
|
+
nada más:
|
|
5
|
+
|
|
6
|
+
1. **Armado del prompt** — el texto canónico (:data:`CSF_PROMPT`) más la
|
|
7
|
+
realimentación del intento anterior cuando toca reintentar.
|
|
8
|
+
2. **Armado de la imagen del mensaje** — la CSF viaja como un único
|
|
9
|
+
:class:`~llama_index.core.base.llms.types.ImageBlock`.
|
|
10
|
+
3. **Reintentos por respuestas inválidas** — cuando los value objects
|
|
11
|
+
:class:`~csf_scraper.value_objects.Rfc` y
|
|
12
|
+
:class:`~csf_scraper.value_objects.IdCif` rechazan el formato de negocio, se
|
|
13
|
+
vuelve a interrogar al modelo pasándole el error como contexto.
|
|
14
|
+
4. **Reintentos por errores de API** — cuando el proveedor responde que está
|
|
15
|
+
saturado, caído o inalcanzable, se vuelve a intentar tras una espera
|
|
16
|
+
creciente. El adaptador traduce las excepciones de su proveedor con
|
|
17
|
+
:meth:`LlmImageReaderService._translate_api_exception`, de modo que esta
|
|
18
|
+
clase —y el resto de la librería— nunca importe las excepciones de Gemini,
|
|
19
|
+
OpenAI, etc.
|
|
20
|
+
5. **Parseo al DTO** — un :class:`~csf_scraper.image_readers.RfcCifSchema` ya
|
|
21
|
+
tipado se mapea a :class:`~csf_scraper.rfc_cif_data.RfcCifData`.
|
|
22
|
+
|
|
23
|
+
Lo que **no** vive aquí es el transporte hacia el proveedor ni el acceso al
|
|
24
|
+
sistema de archivos. Cada adaptador concreto (``GeminiImageReader``, …) crea su
|
|
25
|
+
cliente de LlamaIndex, lo entrega como ``llm_provider`` y expone ``read()``
|
|
26
|
+
delegando en el *método plantilla*
|
|
27
|
+
:meth:`LlmImageReaderService._run_program`. Ese método arma la imagen y el
|
|
28
|
+
prompt de cada intento, ejecuta el ``MultiModalLLMCompletionProgram`` mediante
|
|
29
|
+
:meth:`LlmImageReaderService._image_to_schema` y mapea la respuesta a
|
|
30
|
+
:class:`~csf_scraper.rfc_cif_data.RfcCifData`. La lectura desde una **ruta** la
|
|
31
|
+
hace :class:`~csf_scraper.image_readers.CsfImageExtractor`, que adapta cualquier
|
|
32
|
+
:class:`~csf_scraper.image_readers.ImageReaderPort` al contrato público
|
|
33
|
+
:class:`~csf_scraper.ports.RfcCifExtractorPort`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import logging
|
|
40
|
+
from abc import ABC, abstractmethod
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
from typing import ClassVar, cast
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
from llama_index.core.base.llms.types import ImageBlock
|
|
46
|
+
from llama_index.core.llms.llm import LLM
|
|
47
|
+
from llama_index.core.program import MultiModalLLMCompletionProgram
|
|
48
|
+
|
|
49
|
+
from csf_scraper.exceptions.image_reader import (
|
|
50
|
+
CifFromImageNotFoundException,
|
|
51
|
+
ImageReaderApiException,
|
|
52
|
+
ImageReaderUnavailableException,
|
|
53
|
+
ImageReadingException,
|
|
54
|
+
RfcFromImageNotFoundException,
|
|
55
|
+
)
|
|
56
|
+
from csf_scraper.exceptions.value_objects import (
|
|
57
|
+
InvalidIdCifFormatException,
|
|
58
|
+
InvalidRfcFormatException,
|
|
59
|
+
)
|
|
60
|
+
from csf_scraper.image_readers.adapters.llms.prompts.llm_image_reader_prompt import (
|
|
61
|
+
CSF_PROMPT,
|
|
62
|
+
RETRY_PROMPT,
|
|
63
|
+
)
|
|
64
|
+
from csf_scraper.image_readers.schemas.rfc_cif_schema import RfcCifSchema
|
|
65
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
66
|
+
from csf_scraper.value_objects.csf_image import CsfImage
|
|
67
|
+
from csf_scraper.value_objects.id_cif import IdCif
|
|
68
|
+
from csf_scraper.value_objects.rfc import Rfc
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
#: Número de envíos al modelo antes de rendirse.
|
|
73
|
+
MAX_RETRIES = 3
|
|
74
|
+
|
|
75
|
+
#: Espera inicial, en segundos, antes de reintentar tras un error de API. Se
|
|
76
|
+
#: duplica en cada intento (1, 2, 4, …) hasta :data:`MAX_RETRY_BACKOFF`.
|
|
77
|
+
RETRY_BACKOFF = 1.0
|
|
78
|
+
|
|
79
|
+
#: Tope de la espera entre reintentos, en segundos.
|
|
80
|
+
MAX_RETRY_BACKOFF = 30.0
|
|
81
|
+
|
|
82
|
+
#: Prompt canónico. Todos los adaptadores LLM parten de este texto.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: Errores provocados por la *respuesta* del modelo. Se reintentan
|
|
86
|
+
#: realimentando el mensaje de error al prompt.
|
|
87
|
+
_RESPONSE_ERRORS: tuple[type[Exception], ...] = (
|
|
88
|
+
InvalidRfcFormatException,
|
|
89
|
+
InvalidIdCifFormatException,
|
|
90
|
+
RfcFromImageNotFoundException,
|
|
91
|
+
CifFromImageNotFoundException,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
#: Errores de transporte genéricos. ``TimeoutError``/``ConnectionError`` vienen
|
|
95
|
+
#: de la stdlib y los de ``httpx`` los usan por igual todos los SDK de LLM que
|
|
96
|
+
#: esta librería puede albergar; ninguno es específico de un proveedor.
|
|
97
|
+
_TRANSPORT_ERRORS: tuple[type[Exception], ...] = (
|
|
98
|
+
TimeoutError,
|
|
99
|
+
ConnectionError,
|
|
100
|
+
httpx.TimeoutException,
|
|
101
|
+
httpx.TransportError,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class LlmImageReaderService(ABC):
|
|
106
|
+
"""Base de los adaptadores de imagen basados en un LLM multimodal.
|
|
107
|
+
|
|
108
|
+
Aporta el *método plantilla* :meth:`_run_program`, que arma el prompt, arma
|
|
109
|
+
la imagen, ejecuta el programa multimodal del proveedor y mapea el resultado
|
|
110
|
+
a :class:`RfcCifData`, reintentando tanto cuando los value objects rechazan
|
|
111
|
+
el formato de negocio de los datos obtenidos como cuando la API del
|
|
112
|
+
proveedor falla de forma temporal.
|
|
113
|
+
|
|
114
|
+
El adaptador concreto hereda de esta clase e implementa
|
|
115
|
+
:class:`~csf_scraper.image_readers.ports.ImageReaderPort` exponiendo un
|
|
116
|
+
``read()`` que delega en :meth:`_run_program` con su propio
|
|
117
|
+
``llm_provider``::
|
|
118
|
+
|
|
119
|
+
class MiImageReader(LlmImageReaderService, ImageReaderPort):
|
|
120
|
+
@property
|
|
121
|
+
def provider(self) -> str:
|
|
122
|
+
return "mi-proveedor"
|
|
123
|
+
|
|
124
|
+
async def read(self, image):
|
|
125
|
+
return await self._run_program(
|
|
126
|
+
image=image, llm_provider=self._llm_provider
|
|
127
|
+
)
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
#: Prompt base. Una subclase puede reescribirlo sin tocar la lógica.
|
|
131
|
+
prompt: ClassVar[str] = CSF_PROMPT
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
model: str,
|
|
137
|
+
max_retries: int = MAX_RETRIES,
|
|
138
|
+
retry_backoff: float = RETRY_BACKOFF,
|
|
139
|
+
) -> None:
|
|
140
|
+
if max_retries < 1:
|
|
141
|
+
raise ValueError(f"max_retries must be at least 1, got {max_retries}.")
|
|
142
|
+
if retry_backoff < 0:
|
|
143
|
+
raise ValueError(f"retry_backoff must be zero or positive, got {retry_backoff}.")
|
|
144
|
+
self._model = model
|
|
145
|
+
self._max_retries = max_retries
|
|
146
|
+
self._retry_backoff = retry_backoff
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def model(self) -> str:
|
|
150
|
+
"""Nombre del modelo con el que trabaja el adaptador."""
|
|
151
|
+
return self._model
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def provider(self) -> str:
|
|
156
|
+
"""Identificador del proveedor (``"gemini"``, …)."""
|
|
157
|
+
|
|
158
|
+
async def _run_program(self, image: CsfImage, llm_provider: LLM) -> RfcCifData:
|
|
159
|
+
"""Lee el RFC y el idCIF de ``image``, reintentando si hace falta.
|
|
160
|
+
|
|
161
|
+
Es el *método plantilla*: arma la imagen y el prompt de cada intento,
|
|
162
|
+
delega la ejecución del programa multimodal en :meth:`_image_to_schema`
|
|
163
|
+
y mapea la respuesta a :class:`RfcCifData`.
|
|
164
|
+
|
|
165
|
+
Se reintenta en dos casos:
|
|
166
|
+
|
|
167
|
+
- **Respuesta inválida**: los value objects rechazan el formato de
|
|
168
|
+
negocio. El error viaja de vuelta al prompt para que el modelo se
|
|
169
|
+
corrija.
|
|
170
|
+
- **Error de API reintentable**: el proveedor está saturado, caído o no
|
|
171
|
+
es alcanzable. Se espera un tiempo creciente (ver
|
|
172
|
+
:data:`RETRY_BACKOFF`) y se repite el *mismo* prompt, porque el
|
|
173
|
+
problema no está en la imagen ni en la respuesta.
|
|
174
|
+
|
|
175
|
+
Un error de API no reintentable (credenciales inválidas, petición mal
|
|
176
|
+
formada) o cualquier otra excepción inesperada se propaga de inmediato:
|
|
177
|
+
repetirla daría el mismo resultado.
|
|
178
|
+
"""
|
|
179
|
+
image_documents = self._build_image_documents(image)
|
|
180
|
+
|
|
181
|
+
previous_error: str | None = None
|
|
182
|
+
last_error: Exception | None = None
|
|
183
|
+
last_cause: Exception | None = None
|
|
184
|
+
|
|
185
|
+
for attempt in range(1, self._max_retries + 1):
|
|
186
|
+
retry_prompt = self._build_prompt(previous_error=previous_error, attempt=attempt)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
schema = await self._image_to_schema(
|
|
190
|
+
image_documents=image_documents,
|
|
191
|
+
retry_prompt=retry_prompt,
|
|
192
|
+
llm_provider=llm_provider,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return self._to_rfc_cif_data(schema)
|
|
196
|
+
except _RESPONSE_ERRORS as exception:
|
|
197
|
+
last_error = exception
|
|
198
|
+
previous_error = str(exception)
|
|
199
|
+
self._log_retry(attempt, exception)
|
|
200
|
+
# Sin espera: la corrección viaja en el prompt del reintento.
|
|
201
|
+
continue
|
|
202
|
+
except Exception as exception:
|
|
203
|
+
api_error = self._translate_api_exception(exception)
|
|
204
|
+
if api_error is None:
|
|
205
|
+
raise ImageReadingException(
|
|
206
|
+
f"The {self.provider} engine failed to read the image.",
|
|
207
|
+
provider=self.provider,
|
|
208
|
+
model=self.model,
|
|
209
|
+
) from exception
|
|
210
|
+
if not api_error.retryable:
|
|
211
|
+
raise api_error from exception
|
|
212
|
+
|
|
213
|
+
last_error = api_error
|
|
214
|
+
last_cause = exception
|
|
215
|
+
# El error no proviene de la respuesta del modelo: se repite el
|
|
216
|
+
# prompt canónico en lugar de realimentar el fallo de la API.
|
|
217
|
+
previous_error = None
|
|
218
|
+
self._log_retry(attempt, api_error)
|
|
219
|
+
|
|
220
|
+
await self._wait_before_retry(attempt)
|
|
221
|
+
|
|
222
|
+
if last_error is not None:
|
|
223
|
+
raise last_error from last_cause
|
|
224
|
+
raise ImageReadingException( # pragma: no cover - max_retries >= 1
|
|
225
|
+
f"The {self.provider} engine did not process the image.",
|
|
226
|
+
provider=self.provider,
|
|
227
|
+
model=self.model,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _translate_api_exception(self, exception: Exception) -> ImageReaderApiException | None:
|
|
231
|
+
"""Traduce una excepción del proveedor a una de la librería.
|
|
232
|
+
|
|
233
|
+
Es el punto de extensión de los adaptadores: cada uno mapea los tipos de
|
|
234
|
+
error de su SDK a :class:`ImageReaderApiException` (o a sus derivadas
|
|
235
|
+
:class:`ImageReaderUnavailableException` y
|
|
236
|
+
:class:`ImageReaderAuthenticationException`), marcando ``retryable``.
|
|
237
|
+
|
|
238
|
+
Devolver ``None`` significa «no sé qué es»: la plantilla envuelve la
|
|
239
|
+
excepción original en :class:`ImageReadingException` sin reintentar.
|
|
240
|
+
|
|
241
|
+
La implementación base solo reconoce los errores de transporte de la
|
|
242
|
+
stdlib, que no pertenecen a ningún proveedor en particular.
|
|
243
|
+
"""
|
|
244
|
+
if isinstance(exception, _TRANSPORT_ERRORS):
|
|
245
|
+
return ImageReaderUnavailableException(
|
|
246
|
+
f"The {self.provider} service is not reachable.",
|
|
247
|
+
provider=self.provider,
|
|
248
|
+
model=self.model,
|
|
249
|
+
)
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
async def _wait_before_retry(self, attempt: int) -> None:
|
|
253
|
+
"""Espera un tiempo creciente entre intentos (1, 2, 4, … segundos).
|
|
254
|
+
|
|
255
|
+
No espera tras el último intento: ya no queda nada que reintentar.
|
|
256
|
+
"""
|
|
257
|
+
if attempt >= self._max_retries:
|
|
258
|
+
return
|
|
259
|
+
delay = self._retry_delay(attempt)
|
|
260
|
+
if delay > 0:
|
|
261
|
+
await asyncio.sleep(delay)
|
|
262
|
+
|
|
263
|
+
def _retry_delay(self, attempt: int) -> float:
|
|
264
|
+
"""Segundos de espera antes del intento ``attempt + 1``.
|
|
265
|
+
|
|
266
|
+
Crece exponencialmente desde ``retry_backoff`` y se topa en
|
|
267
|
+
:data:`MAX_RETRY_BACKOFF` para no esperar indefinidamente.
|
|
268
|
+
"""
|
|
269
|
+
if self._retry_backoff == 0:
|
|
270
|
+
return 0.0
|
|
271
|
+
return min(self._retry_backoff * float(2 ** (attempt - 1)), MAX_RETRY_BACKOFF)
|
|
272
|
+
|
|
273
|
+
async def _image_to_schema(
|
|
274
|
+
self,
|
|
275
|
+
image_documents: Sequence[ImageBlock],
|
|
276
|
+
retry_prompt: str,
|
|
277
|
+
llm_provider: LLM,
|
|
278
|
+
) -> RfcCifSchema:
|
|
279
|
+
"""Ejecuta el programa multimodal del proveedor para un intento."""
|
|
280
|
+
program = MultiModalLLMCompletionProgram.from_defaults(
|
|
281
|
+
output_cls=RfcCifSchema,
|
|
282
|
+
image_documents=list(image_documents),
|
|
283
|
+
prompt_template_str=retry_prompt,
|
|
284
|
+
multi_modal_llm=llm_provider,
|
|
285
|
+
)
|
|
286
|
+
return cast(RfcCifSchema, await program.acall())
|
|
287
|
+
|
|
288
|
+
def _build_image_documents(self, image: CsfImage) -> list[ImageBlock]:
|
|
289
|
+
"""Convierte la CSF en el bloque de imagen que consume el programa."""
|
|
290
|
+
return [
|
|
291
|
+
ImageBlock(
|
|
292
|
+
image=image.as_bytes(),
|
|
293
|
+
image_mimetype=image.get_mime_type(),
|
|
294
|
+
)
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
def _to_rfc_cif_data(self, schema: RfcCifSchema) -> RfcCifData:
|
|
298
|
+
"""Mapea la respuesta tipada del modelo al DTO de la librería.
|
|
299
|
+
|
|
300
|
+
Delega el formato de negocio en :class:`Rfc` y :class:`IdCif`, cuyas
|
|
301
|
+
excepciones disparan el siguiente reintento.
|
|
302
|
+
"""
|
|
303
|
+
rfc = schema.rfc.strip()
|
|
304
|
+
id_cif = schema.id_cif.strip()
|
|
305
|
+
|
|
306
|
+
if not rfc:
|
|
307
|
+
raise RfcFromImageNotFoundException("The LLM response does not contain an RFC value.")
|
|
308
|
+
if not id_cif:
|
|
309
|
+
raise CifFromImageNotFoundException("The LLM response does not contain an idCIF value.")
|
|
310
|
+
|
|
311
|
+
return RfcCifData(rfc=Rfc(rfc), id_cif=IdCif(id_cif))
|
|
312
|
+
|
|
313
|
+
def _build_prompt(self, *, previous_error: str | None, attempt: int) -> str:
|
|
314
|
+
"""Devuelve el prompt del intento ``attempt``, con realimentación opcional."""
|
|
315
|
+
if previous_error is None:
|
|
316
|
+
return self.prompt
|
|
317
|
+
return self.prompt + RETRY_PROMPT.format(previous_attempt=attempt - 1, error=previous_error)
|
|
318
|
+
|
|
319
|
+
def _log_retry(self, attempt: int, exception: Exception) -> None:
|
|
320
|
+
if attempt >= self._max_retries:
|
|
321
|
+
return
|
|
322
|
+
logger.warning(
|
|
323
|
+
"The %s engine could not read the image (attempt %d/%d): %s",
|
|
324
|
+
self.provider,
|
|
325
|
+
attempt,
|
|
326
|
+
self._max_retries,
|
|
327
|
+
exception,
|
|
328
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Lectura de la Constancia de Situación Fiscal en PDF."""
|
|
2
|
+
|
|
3
|
+
from csf_scraper.pdf_reader.csf_pdf_extractor import CsfPdfExtractor, PdfTextProvider
|
|
4
|
+
from csf_scraper.pdf_reader.pdf_csf_text_parser import PdfCsfTextParser
|
|
5
|
+
from csf_scraper.pdf_reader.pdf_to_text import PdfToText
|
|
6
|
+
|
|
7
|
+
__all__ = ["CsfPdfExtractor", "PdfCsfTextParser", "PdfTextProvider", "PdfToText"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Extracción del RFC y el idCIF a partir de un archivo PDF de CSF."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from csf_scraper.pdf_reader.pdf_csf_text_parser import PdfCsfTextParser
|
|
6
|
+
from csf_scraper.pdf_reader.pdf_to_text import PdfToText
|
|
7
|
+
from csf_scraper.ports.rfc_cif_extractor_port import RfcCifExtractorPort
|
|
8
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PdfTextProvider(Protocol):
|
|
12
|
+
"""Fuente del texto de un PDF; permite sustituir ``pdftotext`` en los tests."""
|
|
13
|
+
|
|
14
|
+
async def extract(self, path: str) -> str:
|
|
15
|
+
"""Devuelve el texto plano del PDF en ``path``."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CsfPdfExtractor(RfcCifExtractorPort):
|
|
20
|
+
"""Implementa :class:`RfcCifExtractorPort` leyendo CSFs en PDF.
|
|
21
|
+
|
|
22
|
+
Convierte el PDF a texto (``pdftotext`` por omisión, de forma asíncrona) y
|
|
23
|
+
delega el *parsing* en :class:`PdfCsfTextParser`. El proveedor de texto se
|
|
24
|
+
puede inyectar para probar el extractor sin invocar el binario.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, text_provider: PdfTextProvider | None = None) -> None:
|
|
28
|
+
self._text_provider: PdfTextProvider = text_provider or PdfToText()
|
|
29
|
+
|
|
30
|
+
async def extract(self, path: str) -> RfcCifData:
|
|
31
|
+
"""Devuelve el RFC y el idCIF del PDF en ``path``."""
|
|
32
|
+
contents = await self._text_provider.extract(path)
|
|
33
|
+
return PdfCsfTextParser(contents).extract()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Análisis del texto de una CSF para obtener su RFC y su idCIF."""
|
|
2
|
+
|
|
3
|
+
from csf_scraper.exceptions.pdf_reader import (
|
|
4
|
+
CifFromPdfNotFoundException,
|
|
5
|
+
EmptyPdfContentException,
|
|
6
|
+
RfcFromPdfNotFoundException,
|
|
7
|
+
)
|
|
8
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
9
|
+
from csf_scraper.value_objects.id_cif import IdCif
|
|
10
|
+
from csf_scraper.value_objects.rfc import Rfc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PdfCsfTextParser:
|
|
14
|
+
"""Obtiene el RFC y el idCIF de las líneas de texto de una CSF.
|
|
15
|
+
|
|
16
|
+
Es la pieza de *parsing* pura: recibe el texto (salida de ``pdftotext``) y
|
|
17
|
+
no conoce el origen ni el binario. :class:`~csf_scraper.pdf_reader.CsfPdfExtractor`
|
|
18
|
+
lo combina con un proveedor de texto para implementar
|
|
19
|
+
:class:`~csf_scraper.ports.RfcCifExtractorPort`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, contents: str) -> None:
|
|
23
|
+
if not contents:
|
|
24
|
+
raise EmptyPdfContentException("Cannot read pdf contents.")
|
|
25
|
+
self._lines = contents.split("\n")
|
|
26
|
+
|
|
27
|
+
def get_rfc(self) -> str | None:
|
|
28
|
+
"""RFC encontrado tras la etiqueta ``RFC:``, o ``None`` si no existe."""
|
|
29
|
+
return self._clean_value(self._first_line_starting_with("RFC:"))
|
|
30
|
+
|
|
31
|
+
def get_cif_id(self) -> str | None:
|
|
32
|
+
"""idCIF encontrado tras la etiqueta ``idCIF:``, o ``None`` si no existe."""
|
|
33
|
+
return self._clean_value(self._first_line_starting_with("idCIF:"))
|
|
34
|
+
|
|
35
|
+
def extract(self) -> RfcCifData:
|
|
36
|
+
"""Devuelve el RFC y el idCIF validados de la CSF en texto."""
|
|
37
|
+
rfc = self.get_rfc()
|
|
38
|
+
cif = self.get_cif_id()
|
|
39
|
+
|
|
40
|
+
if rfc is None:
|
|
41
|
+
raise RfcFromPdfNotFoundException("Cannot obtain rfc from given PDF")
|
|
42
|
+
if cif is None:
|
|
43
|
+
raise CifFromPdfNotFoundException("Cannot obtain cif from given PDF")
|
|
44
|
+
return RfcCifData(rfc=Rfc(rfc), id_cif=IdCif(cif))
|
|
45
|
+
|
|
46
|
+
def _first_line_starting_with(self, prefix: str) -> str:
|
|
47
|
+
return next((line for line in self._lines if line.startswith(prefix)), "")
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _clean_value(entry: str) -> str | None:
|
|
51
|
+
parts = entry.split(":")
|
|
52
|
+
if len(parts) < 2:
|
|
53
|
+
return None
|
|
54
|
+
return parts[1].strip()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Conversión de PDF a texto mediante el binario ``pdftotext``."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from csf_scraper.exceptions.pdf_reader import PdfToTextConvertException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PdfToText:
|
|
10
|
+
"""Extrae el contenido de un PDF usando ``pdftotext`` (paquete ``poppler-utils``)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, executable: str = "pdftotext") -> None:
|
|
13
|
+
self._executable = executable
|
|
14
|
+
|
|
15
|
+
async def extract(self, path: str) -> str:
|
|
16
|
+
"""Devuelve el texto del PDF; lanza :class:`PdfToTextConvertException` si falla.
|
|
17
|
+
|
|
18
|
+
``pdftotext`` es un proceso externo y bloqueante, así que se ejecuta en
|
|
19
|
+
un hilo aparte con :func:`asyncio.to_thread`; el bucle de eventos sigue
|
|
20
|
+
libre mientras el binario convierte el archivo.
|
|
21
|
+
"""
|
|
22
|
+
command = self.build_command(path)
|
|
23
|
+
result = await asyncio.to_thread(subprocess.run, command, capture_output=True, check=False)
|
|
24
|
+
if result.returncode != 0:
|
|
25
|
+
raise PdfToTextConvertException(
|
|
26
|
+
f"Running pdftotext exit with error (exit code {result.returncode})",
|
|
27
|
+
command,
|
|
28
|
+
result.returncode,
|
|
29
|
+
result.stdout.decode("utf-8", errors="replace"),
|
|
30
|
+
result.stderr.decode("utf-8", errors="replace"),
|
|
31
|
+
)
|
|
32
|
+
return result.stdout.decode("utf-8", errors="replace")
|
|
33
|
+
|
|
34
|
+
def build_command(self, pdf_file: str) -> list[str]:
|
|
35
|
+
"""Argumentos con los que se invoca a ``pdftotext``."""
|
|
36
|
+
return [self._executable, "-eol", "unix", "-raw", "-q", pdf_file, "-"]
|
csf_scraper/persona.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Modelo base para los datos fiscales de una persona."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from csf_scraper.date_parser import DateField
|
|
8
|
+
from csf_scraper.regimen import Regimen
|
|
9
|
+
from csf_scraper.value_objects.id_cif import IdCif
|
|
10
|
+
from csf_scraper.value_objects.rfc import Rfc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Persona(BaseModel):
|
|
14
|
+
"""Datos fiscales comunes a una persona moral o física."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
17
|
+
|
|
18
|
+
rfc: Rfc | None = None
|
|
19
|
+
id_cif: IdCif | None = None
|
|
20
|
+
fecha_inicio_operaciones: DateField = None
|
|
21
|
+
situacion_contribuyente: str = ""
|
|
22
|
+
fecha_ultimo_cambio_situacion: DateField = None
|
|
23
|
+
entidad_federativa: str = ""
|
|
24
|
+
municipio_delegacion: str = ""
|
|
25
|
+
colonia: str = ""
|
|
26
|
+
tipo_vialidad: str = ""
|
|
27
|
+
nombre_vialidad: str = ""
|
|
28
|
+
numero_exterior: str = ""
|
|
29
|
+
numero_interior: str = ""
|
|
30
|
+
codigo_postal: str = ""
|
|
31
|
+
correo_electronico: str = ""
|
|
32
|
+
al: str = ""
|
|
33
|
+
regimenes: list[Regimen] = Field(default_factory=list)
|
|
34
|
+
extra_data: dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
#: Títulos del HTML que se buscan y el campo (o clave de ``extra_data``) que alimentan.
|
|
37
|
+
field_titles: ClassVar[dict[str, str]] = {
|
|
38
|
+
"fecha_inicio_operaciones": "Fecha de Inicio de operaciones:",
|
|
39
|
+
"situacion_contribuyente": "Situación del contribuyente:",
|
|
40
|
+
"fecha_ultimo_cambio_situacion": "Fecha del último cambio de situación:",
|
|
41
|
+
"entidad_federativa": "Entidad Federativa:",
|
|
42
|
+
"municipio_delegacion": "Municipio o delegación:",
|
|
43
|
+
"colonia": "Colonia:",
|
|
44
|
+
"localidad": "Localidad:",
|
|
45
|
+
"tipo_vialidad": "Tipo de vialidad:",
|
|
46
|
+
"nombre_vialidad": "Nombre de la vialidad:",
|
|
47
|
+
"numero_exterior": "Número exterior:",
|
|
48
|
+
"numero_interior": "Número interior:",
|
|
49
|
+
"codigo_postal": "CP:",
|
|
50
|
+
"correo_electronico": "Correo electrónico:",
|
|
51
|
+
"al": "AL:",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def is_fisica(self) -> bool:
|
|
55
|
+
"""Indica si la persona es física (RFC de 13 caracteres)."""
|
|
56
|
+
return self.rfc.is_fisica() if self.rfc is not None else False
|
|
57
|
+
|
|
58
|
+
def is_moral(self) -> bool:
|
|
59
|
+
"""Indica si la persona es moral (RFC de 12 caracteres)."""
|
|
60
|
+
return self.rfc.is_moral() if self.rfc is not None else False
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
"""Devuelve los datos como un diccionario plano (envoltura de :meth:`model_dump`)."""
|
|
64
|
+
return self.model_dump()
|