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.
Files changed (42) hide show
  1. csf_scraper/__init__.py +119 -0
  2. csf_scraper/data_extractor.py +74 -0
  3. csf_scraper/date_parser.py +26 -0
  4. csf_scraper/exceptions/__init__.py +17 -0
  5. csf_scraper/exceptions/image_reader.py +106 -0
  6. csf_scraper/exceptions/pdf_reader.py +33 -0
  7. csf_scraper/exceptions/value_objects.py +11 -0
  8. csf_scraper/image_readers/__init__.py +52 -0
  9. csf_scraper/image_readers/adapters/__init__.py +18 -0
  10. csf_scraper/image_readers/adapters/llms/__init__.py +33 -0
  11. csf_scraper/image_readers/adapters/llms/gemini_image_reader.py +157 -0
  12. csf_scraper/image_readers/adapters/llms/prompts/__init__.py +8 -0
  13. csf_scraper/image_readers/adapters/llms/prompts/llm_image_reader_prompt.py +17 -0
  14. csf_scraper/image_readers/csf_image_extractor.py +29 -0
  15. csf_scraper/image_readers/ports/__init__.py +5 -0
  16. csf_scraper/image_readers/ports/image_reader_port.py +37 -0
  17. csf_scraper/image_readers/schemas/__init__.py +5 -0
  18. csf_scraper/image_readers/schemas/rfc_cif_schema.py +42 -0
  19. csf_scraper/image_readers/services/__init__.py +18 -0
  20. csf_scraper/image_readers/services/llm_image_reader_service.py +328 -0
  21. csf_scraper/pdf_reader/__init__.py +7 -0
  22. csf_scraper/pdf_reader/csf_pdf_extractor.py +33 -0
  23. csf_scraper/pdf_reader/pdf_csf_text_parser.py +54 -0
  24. csf_scraper/pdf_reader/pdf_to_text.py +36 -0
  25. csf_scraper/persona.py +64 -0
  26. csf_scraper/persona_fisica.py +25 -0
  27. csf_scraper/persona_moral.py +21 -0
  28. csf_scraper/ports/__init__.py +5 -0
  29. csf_scraper/ports/rfc_cif_extractor_port.py +40 -0
  30. csf_scraper/py.typed +0 -0
  31. csf_scraper/regimen.py +62 -0
  32. csf_scraper/rfc_cif_data.py +15 -0
  33. csf_scraper/scraper.py +117 -0
  34. csf_scraper/value_objects/__init__.py +7 -0
  35. csf_scraper/value_objects/csf_image.py +68 -0
  36. csf_scraper/value_objects/id_cif.py +48 -0
  37. csf_scraper/value_objects/rfc.py +56 -0
  38. csf_scraper_python-0.1.1.dist-info/METADATA +390 -0
  39. csf_scraper_python-0.1.1.dist-info/RECORD +42 -0
  40. csf_scraper_python-0.1.1.dist-info/WHEEL +4 -0
  41. csf_scraper_python-0.1.1.dist-info/licenses/LICENSE +21 -0
  42. csf_scraper_python-0.1.1.dist-info/licenses/NOTICE.md +16 -0
@@ -0,0 +1,119 @@
1
+ """Obtiene los datos fiscales actuales de una persona moral o física del SAT.
2
+
3
+ El núcleo de la librería no depende de ningún cliente de LLM. Los adaptadores
4
+ que sí lo requieren (por ahora :class:`GeminiImageReader`, que necesita el
5
+ extra ``llm-gemini``) se resuelven de forma perezosa (:pep:`562`), de modo que
6
+ ``import csf_scraper`` funciona con las dependencias base únicamente.
7
+ """
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from csf_scraper.data_extractor import DataExtractor
12
+ from csf_scraper.exceptions import (
13
+ CifDownloadException,
14
+ CifNotFoundException,
15
+ CsfScraperException,
16
+ )
17
+ from csf_scraper.exceptions.image_reader import (
18
+ CifFromImageNotFoundException,
19
+ ImageExtractorNotConfiguredException,
20
+ ImageReaderApiException,
21
+ ImageReaderAuthenticationException,
22
+ ImageReaderUnavailableException,
23
+ ImageReadingException,
24
+ InvalidImageException,
25
+ InvalidImageFormatException,
26
+ RfcFromImageNotFoundException,
27
+ )
28
+ from csf_scraper.exceptions.pdf_reader import (
29
+ CifFromPdfNotFoundException,
30
+ EmptyPdfContentException,
31
+ PdfToTextConvertException,
32
+ RfcFromPdfNotFoundException,
33
+ )
34
+ from csf_scraper.exceptions.value_objects import (
35
+ InvalidIdCifFormatException,
36
+ InvalidRfcFormatException,
37
+ )
38
+ from csf_scraper.image_readers import (
39
+ CSF_PROMPT,
40
+ MAX_RETRIES,
41
+ MAX_RETRY_BACKOFF,
42
+ RETRY_BACKOFF,
43
+ RETRY_PROMPT,
44
+ CsfImageExtractor,
45
+ ImageReaderPort,
46
+ LlmImageReaderService,
47
+ RfcCifSchema,
48
+ )
49
+ from csf_scraper.pdf_reader import CsfPdfExtractor, PdfCsfTextParser, PdfTextProvider
50
+ from csf_scraper.persona import Persona
51
+ from csf_scraper.persona_fisica import PersonaFisica
52
+ from csf_scraper.persona_moral import PersonaMoral
53
+ from csf_scraper.ports import RfcCifExtractorPort
54
+ from csf_scraper.regimen import Regimen
55
+ from csf_scraper.rfc_cif_data import RfcCifData
56
+ from csf_scraper.scraper import Scraper
57
+ from csf_scraper.value_objects import CsfImage, IdCif, Rfc
58
+
59
+ if TYPE_CHECKING:
60
+ from csf_scraper.image_readers.adapters.llms import GeminiImageReader
61
+
62
+ __all__ = [
63
+ "CSF_PROMPT",
64
+ "MAX_RETRIES",
65
+ "MAX_RETRY_BACKOFF",
66
+ "RETRY_BACKOFF",
67
+ "RETRY_PROMPT",
68
+ "CifDownloadException",
69
+ "CifFromImageNotFoundException",
70
+ "CifFromPdfNotFoundException",
71
+ "CifNotFoundException",
72
+ "CsfImage",
73
+ "CsfImageExtractor",
74
+ "CsfPdfExtractor",
75
+ "CsfScraperException",
76
+ "DataExtractor",
77
+ "EmptyPdfContentException",
78
+ "GeminiImageReader",
79
+ "IdCif",
80
+ "ImageExtractorNotConfiguredException",
81
+ "ImageReaderApiException",
82
+ "ImageReaderAuthenticationException",
83
+ "ImageReaderPort",
84
+ "ImageReaderUnavailableException",
85
+ "ImageReadingException",
86
+ "InvalidIdCifFormatException",
87
+ "InvalidImageException",
88
+ "InvalidImageFormatException",
89
+ "InvalidRfcFormatException",
90
+ "LlmImageReaderService",
91
+ "PdfCsfTextParser",
92
+ "PdfTextProvider",
93
+ "PdfToTextConvertException",
94
+ "Persona",
95
+ "PersonaFisica",
96
+ "PersonaMoral",
97
+ "Regimen",
98
+ "Rfc",
99
+ "RfcCifData",
100
+ "RfcCifExtractorPort",
101
+ "RfcCifSchema",
102
+ "RfcFromImageNotFoundException",
103
+ "RfcFromPdfNotFoundException",
104
+ "Scraper",
105
+ ]
106
+
107
+
108
+ def __getattr__(name: str) -> object:
109
+ """Resuelve los adaptadores opcionales de forma perezosa.
110
+
111
+ Así ``from csf_scraper import GeminiImageReader`` sigue funcionando, pero
112
+ solo importa el SDK de Google cuando alguien realmente lo pide.
113
+ """
114
+ if name == "GeminiImageReader":
115
+ from csf_scraper.image_readers.adapters.llms import GeminiImageReader
116
+
117
+ return GeminiImageReader
118
+
119
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,74 @@
1
+ """Extracción de datos fiscales a partir del HTML de la consulta del CSF."""
2
+
3
+ from bs4 import BeautifulSoup, Tag
4
+
5
+ from csf_scraper.exceptions import CifNotFoundException
6
+ from csf_scraper.persona_fisica import PersonaFisica
7
+ from csf_scraper.persona_moral import PersonaMoral
8
+ from csf_scraper.regimen import Regimen
9
+
10
+ _XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" ?>'
11
+
12
+
13
+ def _normalize_whitespace(value: str) -> str:
14
+ """Colapsa cualquier espacio en blanco en un único espacio y recorta los extremos."""
15
+ return " ".join(value.split())
16
+
17
+
18
+ class DataExtractor:
19
+ """Extrae los datos de una persona desde el HTML de la consulta del CSF."""
20
+
21
+ def __init__(self, html: str) -> None:
22
+ self.html = html
23
+
24
+ def extract(self, is_fisica: bool) -> PersonaMoral | PersonaFisica:
25
+ """Construye la persona correspondiente; lanza :class:`CifNotFoundException`."""
26
+ person: PersonaMoral | PersonaFisica = PersonaFisica() if is_fisica else PersonaMoral()
27
+ soup = BeautifulSoup(self._clear_html(self.html), "lxml")
28
+
29
+ if soup.select("span[class=ui-messages-info-detail]"):
30
+ raise CifNotFoundException("Failed to found CIF info.")
31
+
32
+ fields = type(person).model_fields
33
+ for field, title in person.field_titles.items():
34
+ value = self._find_value(soup, title)
35
+ if field in fields:
36
+ setattr(person, field, value)
37
+ else:
38
+ person.extra_data[field] = value
39
+
40
+ person.regimenes = self._get_regimenes(soup)
41
+ return person
42
+
43
+ def _find_value(self, soup: BeautifulSoup, title: str) -> str:
44
+ for element in soup.select('td[role="gridcell"] > span'):
45
+ if title not in _normalize_whitespace(element.get_text()):
46
+ continue
47
+ row = element.find_parent("tr")
48
+ text = _normalize_whitespace(row.get_text()) if isinstance(row, Tag) else ""
49
+ return text.replace(title, " ").strip()
50
+ return ""
51
+
52
+ @staticmethod
53
+ def _clear_html(html: str) -> str:
54
+ return html.replace(_XML_DECLARATION, "")
55
+
56
+ @staticmethod
57
+ def _get_regimenes(soup: BeautifulSoup) -> list[Regimen]:
58
+ tables = soup.select('tbody[class="ui-datatable-data ui-widget-content"]')
59
+ if len(tables) < 5:
60
+ return []
61
+
62
+ values = [
63
+ _normalize_whitespace(cell.get_text())
64
+ for cell in tables[4].select('td[role="gridcell"]')
65
+ if not cell.select("span")
66
+ ]
67
+
68
+ regimenes: list[Regimen] = []
69
+ for index in range(0, len(values), 2):
70
+ regimen = Regimen(regimen=values[index])
71
+ if index + 1 < len(values):
72
+ regimen.fecha_alta = values[index + 1] # type: ignore[assignment]
73
+ regimenes.append(regimen)
74
+ return regimenes
@@ -0,0 +1,26 @@
1
+ """Conversión de fechas ``dd-mm-yyyy`` a :class:`datetime.datetime`."""
2
+
3
+ from datetime import datetime
4
+ from typing import Annotated, Any
5
+
6
+ from pydantic import BeforeValidator
7
+
8
+
9
+ def parse_date(value: str) -> datetime | None:
10
+ """Convierte una fecha ``dd-mm-yyyy`` a la medianoche; ``None`` si es inválida."""
11
+ try:
12
+ return datetime.strptime(value, "%d-%m-%Y")
13
+ except (TypeError, ValueError):
14
+ return None
15
+
16
+
17
+ def _coerce_date(value: Any) -> datetime | None:
18
+ if value is None or isinstance(value, datetime):
19
+ return value
20
+ if isinstance(value, str):
21
+ return parse_date(value)
22
+ return None
23
+
24
+
25
+ #: Campo de fecha opcional que acepta ``datetime``, ``None`` o una cadena ``dd-mm-yyyy``.
26
+ DateField = Annotated[datetime | None, BeforeValidator(_coerce_date)]
@@ -0,0 +1,17 @@
1
+ """Excepciones de la librería."""
2
+
3
+
4
+ class CsfScraperException(Exception):
5
+ """Tipo base de todas las excepciones de la librería."""
6
+
7
+
8
+ class CifDownloadException(CsfScraperException):
9
+ """La petición HTTP para descargar la información del CIF falló."""
10
+
11
+ def __init__(self, url: str) -> None:
12
+ super().__init__("The request has failed")
13
+ self.url = url
14
+
15
+
16
+ class CifNotFoundException(CsfScraperException):
17
+ """No se encontró la información del CIF en la respuesta del SAT."""
@@ -0,0 +1,106 @@
1
+ """Excepciones del lector de imágenes (CSF escaneada)."""
2
+
3
+ from csf_scraper.exceptions import CsfScraperException
4
+
5
+
6
+ class InvalidImageException(CsfScraperException):
7
+ """La ruta de la imagen no existe o no puede leerse."""
8
+
9
+
10
+ class InvalidImageFormatException(CsfScraperException):
11
+ """El archivo recibido no es una imagen soportada (JPEG o PNG)."""
12
+
13
+
14
+ class ImageExtractorNotConfiguredException(CsfScraperException):
15
+ """No se inyectó un extractor de imágenes (motor LLM/OCR)."""
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__(
19
+ "An image extractor is required to obtain the data from an image; "
20
+ "inject one via Scraper(..., image_extractor=...).",
21
+ )
22
+
23
+
24
+ class ImageReadingException(CsfScraperException):
25
+ """El motor de lectura de imágenes (LLM/OCR) falló al procesar la CSF.
26
+
27
+ ``provider`` y ``model`` identifican el motor que falló, de modo que un
28
+ adaptador pueda reportar el origen del error sin exponer credenciales.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ message: str = "The image reader failed to process the CSF.",
34
+ *,
35
+ provider: str | None = None,
36
+ model: str | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.provider = provider
40
+ self.model = model
41
+
42
+
43
+ class ImageReaderApiException(ImageReadingException):
44
+ """La API del proveedor del modelo respondió con un error.
45
+
46
+ Es la traducción propia de las excepciones de cada proveedor: el resto de
47
+ la librería no importa ni conoce las librerías de Gemini, OpenAI, etc.
48
+
49
+ ``status_code`` es el código HTTP devuelto por el proveedor (``None`` si no
50
+ hubo respuesta, por ejemplo en un fallo de red) y ``retryable`` indica si
51
+ vale la pena volver a intentarlo: los fallos temporales (saturación,
52
+ indisponibilidad, límite de peticiones, red) sí lo son; los de credenciales
53
+ o de petición mal formada, no.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ message: str = "The model provider API returned an error.",
59
+ *,
60
+ provider: str | None = None,
61
+ model: str | None = None,
62
+ status_code: int | None = None,
63
+ retryable: bool = False,
64
+ ) -> None:
65
+ super().__init__(message, provider=provider, model=model)
66
+ self.status_code = status_code
67
+ self.retryable = retryable
68
+
69
+
70
+ class ImageReaderUnavailableException(ImageReaderApiException):
71
+ """El servicio del proveedor está saturado, caído o no es alcanzable.
72
+
73
+ Siempre es reintentable: la misma imagen y las mismas credenciales pueden
74
+ funcionar una vez que el proveedor se recupere.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ message: str = "The model provider is temporarily unavailable.",
80
+ *,
81
+ provider: str | None = None,
82
+ model: str | None = None,
83
+ status_code: int | None = None,
84
+ ) -> None:
85
+ super().__init__(
86
+ message,
87
+ provider=provider,
88
+ model=model,
89
+ status_code=status_code,
90
+ retryable=True,
91
+ )
92
+
93
+
94
+ class ImageReaderAuthenticationException(ImageReaderApiException):
95
+ """El proveedor rechazó las credenciales (API key inválida, vencida o sin permiso).
96
+
97
+ No es reintentable: repetir la petición con la misma clave fallará igual.
98
+ """
99
+
100
+
101
+ class RfcFromImageNotFoundException(CsfScraperException):
102
+ """No se pudo obtener el RFC de la imagen procesada."""
103
+
104
+
105
+ class CifFromImageNotFoundException(CsfScraperException):
106
+ """No se pudo obtener el idCIF de la imagen procesada."""
@@ -0,0 +1,33 @@
1
+ """Excepciones del lector de PDF."""
2
+
3
+ from csf_scraper.exceptions import CsfScraperException
4
+
5
+
6
+ class CifFromPdfNotFoundException(CsfScraperException):
7
+ """No se pudo obtener el idCIF del PDF procesado."""
8
+
9
+
10
+ class EmptyPdfContentException(CsfScraperException):
11
+ """El contenido del PDF está vacío."""
12
+
13
+
14
+ class RfcFromPdfNotFoundException(CsfScraperException):
15
+ """No se pudo obtener el RFC del PDF procesado."""
16
+
17
+
18
+ class PdfToTextConvertException(CsfScraperException):
19
+ """La ejecución de ``pdftotext`` falló."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ command: list[str],
25
+ exit_code: int,
26
+ output: str,
27
+ error: str,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.command = command
31
+ self.exit_code = exit_code
32
+ self.output = output
33
+ self.error = error
@@ -0,0 +1,11 @@
1
+ """Excepciones de los value objects del dominio."""
2
+
3
+ from csf_scraper.exceptions import CsfScraperException
4
+
5
+
6
+ class InvalidRfcFormatException(CsfScraperException, ValueError):
7
+ """El valor recibido no corresponde al formato de un RFC."""
8
+
9
+
10
+ class InvalidIdCifFormatException(CsfScraperException, ValueError):
11
+ """El valor recibido no corresponde al formato de un idCIF."""
@@ -0,0 +1,52 @@
1
+ """Lectura de imágenes de la Constancia de Situación Fiscal.
2
+
3
+ El paquete base solo expone el puerto, el adaptador de ruta y la base de los
4
+ lectores LLM. :class:`GeminiImageReader` vive en
5
+ ``csf_scraper.image_readers.adapters`` y requiere el extra ``llm-gemini``, por
6
+ eso se resuelve de forma perezosa (:pep:`562`).
7
+ """
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from csf_scraper.image_readers.csf_image_extractor import CsfImageExtractor
12
+ from csf_scraper.image_readers.ports import ImageReaderPort
13
+ from csf_scraper.image_readers.schemas import RfcCifSchema
14
+ from csf_scraper.image_readers.services import (
15
+ CSF_PROMPT,
16
+ MAX_RETRIES,
17
+ MAX_RETRY_BACKOFF,
18
+ RETRY_BACKOFF,
19
+ RETRY_PROMPT,
20
+ LlmImageReaderService,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from csf_scraper.image_readers.adapters.llms import GeminiImageReader
25
+
26
+ __all__ = [
27
+ "CSF_PROMPT",
28
+ "MAX_RETRIES",
29
+ "MAX_RETRY_BACKOFF",
30
+ "RETRY_BACKOFF",
31
+ "RETRY_PROMPT",
32
+ "CsfImageExtractor",
33
+ "GeminiImageReader",
34
+ "ImageReaderPort",
35
+ "LlmImageReaderService",
36
+ "RfcCifSchema",
37
+ ]
38
+
39
+
40
+ def __getattr__(name: str) -> object:
41
+ """Resuelve :class:`GeminiImageReader` de forma perezosa.
42
+
43
+ Mantiene el import base libre de ``google-genai`` y de
44
+ ``llama-index-llms-google-genai``; si se pide sin el extra ``llm-gemini``
45
+ se propaga el ``ModuleNotFoundError`` del paquete faltante.
46
+ """
47
+ if name == "GeminiImageReader":
48
+ from csf_scraper.image_readers.adapters.llms import GeminiImageReader
49
+
50
+ return GeminiImageReader
51
+
52
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,18 @@
1
+ """Adaptadores de los lectores de imagen."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from csf_scraper.image_readers.adapters.llms import GeminiImageReader
7
+
8
+ __all__ = ["GeminiImageReader"]
9
+
10
+
11
+ def __getattr__(name: str) -> object:
12
+ """Reexporta los adaptadores concretos de forma perezosa."""
13
+ if name == "GeminiImageReader":
14
+ from csf_scraper.image_readers.adapters.llms import GeminiImageReader
15
+
16
+ return GeminiImageReader
17
+
18
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,33 @@
1
+ """Adaptadores de lectura de imágenes basados en LLMs multimodales.
2
+
3
+ Este paquete es el único lugar de la librería que conoce el SDK de un
4
+ proveedor concreto. Por eso **no** se importa ningún adaptador al cargar el
5
+ paquete: :class:`GeminiImageReader` requiere el extra ``llm-gemini`` y debe
6
+ resolverse solo cuando se usa (:pep:`562`), para que ``import csf_scraper``
7
+ funcione sin instalar extras.
8
+ """
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from csf_scraper.image_readers.adapters.llms.gemini_image_reader import (
14
+ GeminiImageReader,
15
+ )
16
+
17
+ __all__ = ["GeminiImageReader"]
18
+
19
+
20
+ def __getattr__(name: str) -> object:
21
+ """Resuelve :class:`GeminiImageReader` de forma perezosa.
22
+
23
+ Levanta ``ModuleNotFoundError`` (del paquete ``llama-index-llms-google-genai``)
24
+ si se pide el adaptador sin haber instalado el extra ``llm-gemini``.
25
+ """
26
+ if name == "GeminiImageReader":
27
+ from csf_scraper.image_readers.adapters.llms.gemini_image_reader import (
28
+ GeminiImageReader,
29
+ )
30
+
31
+ return GeminiImageReader
32
+
33
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,157 @@
1
+ """Adaptador del lector de imágenes basado en Gemini (Google GenAI).
2
+
3
+ Aquí sí se importan las librerías de LlamaIndex y del SDK de Google, porque el
4
+ transporte hacia el proveedor es responsabilidad exclusiva del adaptador::
5
+
6
+ from llama_index.llms.google_genai import GoogleGenAI
7
+
8
+ reader = GeminiImageReader(api_key="…", model_name="gemini-2.5-flash")
9
+
10
+ El paquete ``llama-index-llms-google-genai`` es una dependencia opcional
11
+ (``pip install "csf-scraper-python[llm-gemini]"``), junto con el SDK
12
+ ``google-genai`` que este adaptador importa directamente.
13
+
14
+ Las excepciones de ``google.genai.errors`` se traducen en
15
+ :meth:`GeminiImageReader._translate_api_exception` a excepciones de la librería,
16
+ de modo que fuera de este archivo nadie necesita conocer el SDK de Google.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from google.genai import errors
22
+ from llama_index.llms.google_genai import GoogleGenAI
23
+
24
+ from csf_scraper.exceptions.image_reader import (
25
+ ImageReaderApiException,
26
+ ImageReaderAuthenticationException,
27
+ ImageReaderUnavailableException,
28
+ )
29
+ from csf_scraper.image_readers.ports.image_reader_port import ImageReaderPort
30
+ from csf_scraper.image_readers.services.llm_image_reader_service import (
31
+ MAX_RETRIES,
32
+ RETRY_BACKOFF,
33
+ LlmImageReaderService,
34
+ )
35
+ from csf_scraper.rfc_cif_data import RfcCifData
36
+ from csf_scraper.value_objects.csf_image import CsfImage
37
+
38
+ #: Códigos HTTP de Google que indican una falla temporal del servicio: el
39
+ #: límite de peticiones (429), los errores del servidor (5xx) y los timeouts de
40
+ #: la propia API (408).
41
+ _RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
42
+
43
+ #: Estados de Google que también indican una falla temporal, por si la
44
+ #: respuesta llega sin un código HTTP reconocible.
45
+ _RETRYABLE_STATUSES = frozenset(
46
+ {
47
+ "RESOURCE_EXHAUSTED",
48
+ "INTERNAL",
49
+ "UNAVAILABLE",
50
+ "DEADLINE_EXCEEDED",
51
+ "ABORTED",
52
+ "UNKNOWN",
53
+ }
54
+ )
55
+
56
+ #: Estados que indican que las credenciales no sirven: reintentar con la misma
57
+ #: clave daría exactamente el mismo error.
58
+ _AUTHENTICATION_STATUSES = frozenset({"UNAUTHENTICATED", "PERMISSION_DENIED"})
59
+
60
+
61
+ class GeminiImageReader(LlmImageReaderService, ImageReaderPort):
62
+ """Lee el RFC y el idCIF de una imagen de CSF usando Gemini.
63
+
64
+ Hereda de :class:`~csf_scraper.image_readers.services.LlmImageReaderService`
65
+ el prompt, el armado de la imagen, los reintentos, la ejecución del
66
+ programa multimodal y el mapeo a
67
+ :class:`~csf_scraper.rfc_cif_data.RfcCifData`. Lo propio de este adaptador
68
+ es crear el cliente ``GoogleGenAI``, traducir los errores del SDK y exponer
69
+ ``read()`` delegando en el método plantilla de la clase base.
70
+
71
+ Args:
72
+ api_key: Clave de la API de Google GenAI.
73
+ model_name: Modelo de Gemini a utilizar.
74
+ max_retries: Intentos totales antes de rendirse.
75
+ retry_backoff: Espera inicial, en segundos, entre reintentos por errores
76
+ de API; se duplica en cada intento.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ api_key: str,
82
+ model_name: str,
83
+ *,
84
+ max_retries: int = MAX_RETRIES,
85
+ retry_backoff: float = RETRY_BACKOFF,
86
+ ) -> None:
87
+ if not api_key:
88
+ raise ValueError("The Gemini image reader requires an API key.")
89
+
90
+ if not model_name:
91
+ raise ValueError("The Gemini image reader requires an Model Name.")
92
+ super().__init__(
93
+ model=model_name,
94
+ max_retries=max_retries,
95
+ retry_backoff=retry_backoff,
96
+ )
97
+ self._llm_provider = GoogleGenAI(api_key=api_key, model=model_name)
98
+
99
+ @property
100
+ def provider(self) -> str:
101
+ """Identificador del proveedor."""
102
+ return "gemini"
103
+
104
+ async def read(self, image: CsfImage) -> RfcCifData:
105
+ """Delega el flujo de reintentos y mapeo en el método plantilla."""
106
+ return await self._run_program(image=image, llm_provider=self._llm_provider)
107
+
108
+ def _translate_api_exception(
109
+ self, exception: Exception
110
+ ) -> ImageReaderApiException | None:
111
+ """Traduce ``google.genai.errors`` a excepciones de la librería.
112
+
113
+ - Credenciales rechazadas (401/403) →
114
+ :class:`ImageReaderAuthenticationException`, **sin** reintentar.
115
+ - Saturación, caída o timeout (429, 408, 5xx) →
116
+ :class:`ImageReaderUnavailableException`, **con** reintento.
117
+ - Cualquier otro error de la API →
118
+ :class:`ImageReaderApiException` con ``retryable=False``.
119
+
120
+ Los errores de transporte genéricos (``httpx``, timeouts) los resuelve
121
+ la implementación base.
122
+ """
123
+ translated = super()._translate_api_exception(exception)
124
+ if translated is not None:
125
+ return translated
126
+
127
+ if not isinstance(exception, errors.APIError):
128
+ return None
129
+
130
+ status_code = exception.code or None
131
+ status = (exception.status or "").upper()
132
+
133
+ if status_code in {401, 403} or status in _AUTHENTICATION_STATUSES:
134
+ return ImageReaderAuthenticationException(
135
+ f"The {self.provider} API rejected the credentials: "
136
+ f"{exception.message or status_code}",
137
+ provider=self.provider,
138
+ model=self.model,
139
+ status_code=status_code,
140
+ )
141
+
142
+ if status_code in _RETRYABLE_STATUS_CODES or status in _RETRYABLE_STATUSES:
143
+ return ImageReaderUnavailableException(
144
+ f"The {self.provider} API is temporarily unavailable: "
145
+ f"{exception.message or status_code}",
146
+ provider=self.provider,
147
+ model=self.model,
148
+ status_code=status_code,
149
+ )
150
+
151
+ return ImageReaderApiException(
152
+ f"The {self.provider} API returned an error: {exception.message or status_code}",
153
+ provider=self.provider,
154
+ model=self.model,
155
+ status_code=status_code,
156
+ retryable=False,
157
+ )
@@ -0,0 +1,8 @@
1
+ """Prompts canónicos usados por los lectores de imagen basados en LLM."""
2
+
3
+ from csf_scraper.image_readers.adapters.llms.prompts.llm_image_reader_prompt import (
4
+ CSF_PROMPT,
5
+ RETRY_PROMPT,
6
+ )
7
+
8
+ __all__ = ["CSF_PROMPT", "RETRY_PROMPT"]
@@ -0,0 +1,17 @@
1
+ CSF_PROMPT = (
2
+ "Analiza la imagen adjunta, que es una Constancia de Situación Fiscal "
3
+ "emitida por el SAT, y extrae el RFC y el idCIF que aparecen en el "
4
+ "encabezado del documento. Transcribe ambos valores exactamente como están "
5
+ "impresos: conserva las mayúsculas, el signo & y la Ñ si aparecen, y no "
6
+ "elimines los ceros a la izquierda del idCIF. No inventes ni completes "
7
+ "valores: si un dato no se distingue en la imagen, devuélvelo como cadena "
8
+ "vacía."
9
+ )
10
+
11
+ #: Sufijo que se agrega al prompt cuando el intento anterior fue rechazado.
12
+ RETRY_PROMPT = (
13
+ "\n\nATENCIÓN: el intento #{previous_attempt} fue descartado por este "
14
+ "motivo:\n{error}\n\n"
15
+ "Vuelve a revisar la imagen con cuidado, carácter por carácter, y corrige "
16
+ "exactamente eso antes de responder."
17
+ )