feat: Implement the initial IPSExtractor project structure, including application logic, GUI, core utilities, and documentation.

This commit is contained in:
erik
2026-02-23 09:24:06 -05:00
commit 69debb1c93
49 changed files with 7385 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
import sys
from pathlib import Path
# Asegurar que el directorio raíz del proyecto esté en sys.path para imports como `from application...`
ROOT = Path(__file__).resolve().parents[1]
root_str = str(ROOT)
if root_str not in sys.path:
sys.path.insert(0, root_str)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import pytest
from application.config import settings
from application.llm_client import LLMClient
def test_llm_smoke_returns_ok():
if not settings.api.api_key:
pytest.skip("Sin API key configurada; se omite prueba de integración.")
client = LLMClient()
text, usage = client.chat_text(
messages=[{"role": "user", "content": "Responde exactamente: ok"}],
component="test_llm_smoke",
)
assert isinstance(text, str)
assert "ok" in text.lower()
if usage is not None:
assert isinstance(usage, dict)
# No todos los backends devuelven 'total_tokens'; validar presencia básica si existe
assert ("total_tokens" in usage) or ("prompt_tokens" in usage) or ("completion_tokens" in usage)
+67
View File
@@ -0,0 +1,67 @@
from PIL import Image
from core.pdf_utils import tiff_to_images_bs64, document_to_images_bs64
def _create_multipage_tiff(path: str, pages: int = 4) -> None:
"""Crea un TIFF multipágina de prueba."""
frames = []
for i in range(pages):
# Colores distintos por página para facilitar depuración si es necesario
color = (50 * (i + 1) % 255, 0, 0)
frames.append(Image.new("RGB", (16, 16), color))
frames[0].save(path, save_all=True, append_images=frames[1:], format="TIFF")
def test_tiff_to_images_bs64_respects_full_and_sub_ranges(tmp_path) -> None:
tiff_path = tmp_path / "sample.tif"
_create_multipage_tiff(str(tiff_path), pages=4)
# Sin rango -> todas las páginas
imgs_all = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=None)
assert len(imgs_all) == 4
# Rango 2–3 (1-based, inclusivo)
imgs_2_3 = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(2, 3))
assert len(imgs_2_3) == 2
def test_tiff_to_images_bs64_allows_partial_ranges(tmp_path) -> None:
tiff_path = tmp_path / "sample_partial.tif"
_create_multipage_tiff(str(tiff_path), pages=4)
# Desde la 3 hasta el final
imgs_from_3 = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(3, None)) # type: ignore[arg-type]
assert len(imgs_from_3) == 2
# Desde el inicio hasta la 2
imgs_to_2 = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(None, 2)) # type: ignore[arg-type]
assert len(imgs_to_2) == 2
# Rango completamente fuera de límites -> lista vacía
imgs_out = tiff_to_images_bs64(str(tiff_path), validate=True, pages_range=(10, 20))
assert imgs_out == []
def test_document_to_images_bs64_uses_explicit_tiff_page_range_over_default(tmp_path) -> None:
tiff_path = tmp_path / "doc.tif"
_create_multipage_tiff(str(tiff_path), pages=4)
# Por defecto usa `tiff_pages=(2,3)` del helper
imgs_default = document_to_images_bs64(str(tiff_path), validate=True)
assert len(imgs_default) == 2
# tiff_page_range tiene prioridad sobre tiff_pages
imgs_range = document_to_images_bs64(
str(tiff_path),
validate=True,
tiff_pages=(1, 4),
tiff_page_range=(2, 3),
)
assert len(imgs_range) == 2
# Sin rango ni valor por defecto -> todas las páginas
imgs_all = document_to_images_bs64(str(tiff_path), validate=True, tiff_pages=None)
assert len(imgs_all) == 4
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from application.service import InvoiceProcessingService
from application.strategies import InvoiceExtractorStrategy
import application.service as service_module
class DummyExtractor(InvoiceExtractorStrategy):
"""Extractor mínimo para pruebas que devuelve un JSON fijo."""
def __init__(self, fail_first: bool = False) -> None:
self.fail_first = fail_first
self.calls = 0
def extract(self, images_b64: List[str], instruction: str) -> Dict[str, Any]:
self.calls += 1
if self.fail_first and self.calls == 1:
raise RuntimeError("forced failure")
return {
"raw": '{"ok": true}',
"usage": {},
}
def test_extract_full_from_pdf_propagates_tiff_page_range(monkeypatch, tmp_path) -> None:
dummy_pdf = tmp_path / "doc.tif"
dummy_pdf.write_bytes(b"fake")
captured: List[Dict[str, Any]] = []
def fake_document_to_images_bs64(path: str, **kwargs: Any) -> List[str]:
captured.append({"path": path, **kwargs})
return ["img"]
# Parchear el símbolo importado en el módulo de servicio
monkeypatch.setattr(service_module, "document_to_images_bs64", fake_document_to_images_bs64)
service = InvoiceProcessingService(DummyExtractor())
data, usage = service.extract_full_from_pdf(str(dummy_pdf), instruction="x", tiff_page_range=(2, 3))
assert data == {"ok": True}
assert isinstance(usage, dict)
assert captured, "Se esperaba al menos una llamada a document_to_images_bs64"
# Primera llamada (flujo normal) debe respetar el rango solicitado
assert captured[0].get("tiff_page_range") == (2, 3)
def test_extract_full_from_pdf_fallback_ignores_tiff_page_range(monkeypatch, tmp_path) -> None:
dummy_pdf = tmp_path / "doc2.tif"
dummy_pdf.write_bytes(b"fake")
captured: List[Dict[str, Any]] = []
def fake_document_to_images_bs64(path: str, **kwargs: Any) -> List[str]:
captured.append({"path": path, **kwargs})
return ["img"]
monkeypatch.setattr(service_module, "document_to_images_bs64", fake_document_to_images_bs64)
service = InvoiceProcessingService(DummyExtractor(fail_first=True))
data, usage = service.extract_full_from_pdf(str(dummy_pdf), instruction="x", tiff_page_range=(2, 3))
assert data == {"ok": True}
assert isinstance(usage, dict)
# Deben existir dos llamadas: una normal (que falla el extractor) y otra fallback
assert len(captured) == 2
assert captured[0].get("tiff_page_range") == (2, 3)
# En fallback NO debe propagarse el rango explícito
assert captured[1].get("tiff_page_range") is None
+43
View File
@@ -0,0 +1,43 @@
from core.utils import _matches_pattern, find_first_matching_file
def test_matches_pattern_simple_contains():
assert _matches_pattern("Factura_123.pdf", "factura")
assert _matches_pattern("otro_factura_1.tif", "FACTURA")
assert not _matches_pattern("recibo_123.txt", "factura")
def test_matches_pattern_wildcard_without_extension():
# Patrón sin extensión se aplica sobre el nombre sin extensión
assert _matches_pattern("Factura_1.pdf", "*_1")
assert _matches_pattern("doc_OK1.tif", "doc_*1")
assert not _matches_pattern("Factura_10.pdf", "*_1") # termina en _10
def test_matches_pattern_wildcard_with_extension():
# Patrón con extensión válida se aplica al nombre completo
assert _matches_pattern("Factura_OK1.pdf", "*_OK?.pdf")
assert not _matches_pattern("Factura_OK1.tif", "*_OK?.pdf")
assert not _matches_pattern("Factura_OK12.pdf", "*_OK?.pdf") # demasiados caracteres
def test_matches_pattern_only_allowed_exts():
# Extensiones que no sean pdf/tif/tiff se ignoran
assert not _matches_pattern("Factura_1.png", "*_1")
def test_find_first_matching_file_uses_helper_and_sorts():
files = [
"Z_Factura_1.pdf",
"A_Factura_1.pdf",
"B_Factura_2.tif",
"nota.txt",
]
# Sin comodines: contiene
assert find_first_matching_file(files, "Factura_") == "A_Factura_1.pdf"
# Con comodines sin extensión
assert find_first_matching_file(files, "*_2") == "B_Factura_2.tif"
# Con comodines + extensión explícita
assert find_first_matching_file(files, "*_1.pdf") == "A_Factura_1.pdf"