refactor: Introduce accumulate_usage function for token aggregation, enhance alias map expansion in BatchProcessor, and streamline error handling in PreclassificationService. Update PDF processing utilities for improved image conversion and error logging.

This commit is contained in:
erik
2026-03-01 20:55:52 -05:00
parent 3c258ebf12
commit e7fde25227
29 changed files with 321 additions and 1206 deletions
+57 -144
View File
@@ -27,177 +27,90 @@ class InvoiceExtractorStrategy(ABC):
raise NotImplementedError
_LOG_EXTRA_BASE = {"dpi": '-', "pdf": '-', "radicado": '-'}
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 _build_messages(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
content: List[Dict[str, Any]] = [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img}"}}
for img in images_b64
]
content.append({"type": "text", "text": instruction})
messages = list(history) if history else []
messages.append({"role": "user", "content": content})
return messages
def _log_call_start(self, images_b64: List[str], instruction: str, history: Optional[List[Dict[str, Any]]]) -> None:
prompt_len = len(instruction) if isinstance(instruction, str) else 0
self._logger.info(
"backend_call_start",
extra={**_LOG_EXTRA_BASE, "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},
)
self._logger.debug(
f"Llamada backend: images={len(images_b64)}, prompt_len={prompt_len}, history_len={len(history) if history else 0}",
extra=_LOG_EXTRA_BASE,
)
def _log_call_ok(self, elapsed: float) -> None:
self._logger.info(
"Extracción completada",
extra={**_LOG_EXTRA_BASE, "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=_LOG_EXTRA_BASE)
def _log_call_error(self, elapsed: float) -> None:
self._logger.exception(
"Error en llamada al backend",
extra={**_LOG_EXTRA_BASE, "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 error tras {int(elapsed*1000)} ms", extra=_LOG_EXTRA_BASE)
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})
messages = self._build_messages(images_b64, instruction, history)
self._log_call_start(images_b64, instruction, history)
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",
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,
}
self._log_call_ok(time.time() - start)
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
self._log_call_error(time.time() - start)
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})
"""Versión asíncrona de extract."""
messages = self._build_messages(images_b64, instruction, history)
self._log_call_start(images_b64, instruction, history)
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",
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,
}
self._log_call_ok(time.time() - start)
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
self._log_call_error(time.time() - start)
raise
def update_config(self, *, api_key: str | None = None, base_url: str | None = None, model: str | None = None) -> None: