213 lines
7.8 KiB
Python
213 lines
7.8 KiB
Python
"""Estrategias de extracción de información desde imágenes de documentos.
|
|
|
|
Define la interfaz `InvoiceExtractorStrategy` y una implementación basada en
|
|
un endpoint compatible con OpenAI (por ejemplo, Ollama) para modelos
|
|
multimodales como `qwen2.5vl`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
from .config import settings
|
|
from .llm_client import LLMClient
|
|
import logging
|
|
import time
|
|
|
|
|
|
class InvoiceExtractorStrategy(ABC):
|
|
"""Interfaz para extraer datos a partir de páginas en base64 e instrucción."""
|
|
@abstractmethod
|
|
def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
"""Devuelve un dict con al menos la clave "raw" (texto de salida del modelo)."""
|
|
raise NotImplementedError
|
|
|
|
async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
"""Versión asíncrona de extract. Por defecto lanza NotImplementedError."""
|
|
raise NotImplementedError
|
|
|
|
|
|
class OllamaExtractor(InvoiceExtractorStrategy):
|
|
"""Extractor contra un servidor Ollama/OpenAI-compatible.
|
|
|
|
Parámetros configurables: `api_key`, `base_url` y `model`.
|
|
"""
|
|
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: Optional[str] = None, llm_client: Optional[LLMClient] = None) -> None:
|
|
# Defaults desde settings si no se proveen
|
|
self.api_key = api_key or settings.api.api_key
|
|
self.base_url = base_url or settings.api.base_url
|
|
self.model = model or settings.api.model
|
|
self.llm = llm_client or LLMClient(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s)
|
|
self._logger = logging.getLogger(__name__)
|
|
|
|
def extract(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
"""Construye el prompt multimodal y devuelve el texto crudo del modelo."""
|
|
content: List[Dict[str, Any]] = []
|
|
for image_b64 in images_b64:
|
|
content.append({
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
|
})
|
|
content.append({"type": "text", "text": instruction})
|
|
|
|
messages = []
|
|
if history:
|
|
messages.extend(history)
|
|
messages.append({"role": "user", "content": content})
|
|
|
|
start = time.time()
|
|
try:
|
|
# Trazas DEBUG del request (sin exponer imágenes ni texto completo)
|
|
prompt_len = len(instruction) if isinstance(instruction, str) else 0
|
|
# Log INFO con metadatos efectivos de la llamada
|
|
self._logger.info(
|
|
"backend_call_start",
|
|
extra={
|
|
"base_url": self.base_url,
|
|
"model": self.model,
|
|
"timeout_s": settings.api.request_timeout_s,
|
|
"images": len(images_b64),
|
|
"prompt_len": prompt_len,
|
|
"history_len": len(history) if history else 0,
|
|
"dpi": '-', "pdf": '-', "radicado": '-',
|
|
},
|
|
)
|
|
self._logger.debug(
|
|
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
|
|
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
|
)
|
|
text, usage_dict = self.llm.chat_text(
|
|
messages=messages,
|
|
model=self.model,
|
|
timeout_s=settings.api.request_timeout_s,
|
|
component="extractor",
|
|
)
|
|
elapsed = time.time() - start
|
|
self._logger.info(
|
|
"Extracción completada",
|
|
extra={
|
|
"dpi": '-', "pdf": '-', "radicado": '-',
|
|
"base_url": self.base_url, "model": self.model,
|
|
"timeout_s": settings.api.request_timeout_s,
|
|
"elapsed_ms": int(elapsed * 1000),
|
|
},
|
|
)
|
|
self._logger.debug(
|
|
f"Backend OK (elapsed_ms={int(elapsed*1000)})",
|
|
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
|
)
|
|
return {
|
|
"raw": text,
|
|
"usage": usage_dict,
|
|
}
|
|
except Exception:
|
|
elapsed = time.time() - start
|
|
self._logger.exception(
|
|
"Error en llamada al backend",
|
|
extra={
|
|
"dpi": '-', "pdf": '-', "radicado": '-',
|
|
"base_url": self.base_url, "model": self.model,
|
|
"timeout_s": settings.api.request_timeout_s,
|
|
"elapsed_ms": int(elapsed * 1000),
|
|
},
|
|
)
|
|
try:
|
|
self._logger.debug(
|
|
f"Backend error tras {int(elapsed*1000)} ms",
|
|
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
async def extract_async(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
"""Construye el prompt multimodal y devuelve el texto crudo del modelo de forma asíncrona."""
|
|
content: List[Dict[str, Any]] = []
|
|
for image_b64 in images_b64:
|
|
content.append({
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
|
})
|
|
content.append({"type": "text", "text": instruction})
|
|
|
|
messages = []
|
|
if history:
|
|
messages.extend(history)
|
|
messages.append({"role": "user", "content": content})
|
|
|
|
start = time.time()
|
|
try:
|
|
# Trazas DEBUG del request (sin exponer imágenes ni texto completo)
|
|
prompt_len = len(instruction) if isinstance(instruction, str) else 0
|
|
# Log INFO con metadatos efectivos de la llamada
|
|
self._logger.info(
|
|
"backend_call_start",
|
|
extra={
|
|
"base_url": self.base_url,
|
|
"model": self.model,
|
|
"timeout_s": settings.api.request_timeout_s,
|
|
"images": len(images_b64),
|
|
"prompt_len": prompt_len,
|
|
"history_len": len(history) if history else 0,
|
|
"dpi": '-', "pdf": '-', "radicado": '-',
|
|
},
|
|
)
|
|
self._logger.debug(
|
|
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
|
|
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
|
)
|
|
text, usage_dict = await self.llm.chat_text_async(
|
|
messages=messages,
|
|
model=self.model,
|
|
timeout_s=settings.api.request_timeout_s,
|
|
component="extractor",
|
|
)
|
|
elapsed = time.time() - start
|
|
self._logger.info(
|
|
"Extracción completada",
|
|
extra={
|
|
"dpi": '-', "pdf": '-', "radicado": '-',
|
|
"base_url": self.base_url, "model": self.model,
|
|
"timeout_s": settings.api.request_timeout_s,
|
|
"elapsed_ms": int(elapsed * 1000),
|
|
},
|
|
)
|
|
self._logger.debug(
|
|
f"Backend OK (elapsed_ms={int(elapsed*1000)})",
|
|
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
|
)
|
|
return {
|
|
"raw": text,
|
|
"usage": usage_dict,
|
|
}
|
|
except Exception:
|
|
elapsed = time.time() - start
|
|
self._logger.exception(
|
|
"Error en llamada al backend",
|
|
extra={
|
|
"dpi": '-', "pdf": '-', "radicado": '-',
|
|
"base_url": self.base_url, "model": self.model,
|
|
"timeout_s": settings.api.request_timeout_s,
|
|
"elapsed_ms": int(elapsed * 1000),
|
|
},
|
|
)
|
|
try:
|
|
self._logger.debug(
|
|
f"Backend error tras {int(elapsed*1000)} ms",
|
|
extra={"dpi": '-', "pdf": '-', "radicado": '-'},
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
def update_config(self, *, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> None:
|
|
"""Actualiza la configuración del cliente y recrea la instancia interna."""
|
|
if api_key is not None:
|
|
self.api_key = api_key
|
|
if base_url is not None:
|
|
self.base_url = base_url
|
|
if model is not None:
|
|
self.model = model
|
|
# actualizar cliente LLM con config actual
|
|
self.llm.update_config(api_key=self.api_key, base_url=self.base_url, default_model=self.model, request_timeout_s=settings.api.request_timeout_s)
|