feat: Implement the initial IPSExtractor project structure, including application logic, GUI, core utilities, and documentation.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user