"""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)