"""Normalizador LLM: adapta un JSON fuente al esquema canónico de rutas. Usa la API de OpenAI (Chat Completions) para transformar el JSON devuelto por el extractor a un objeto que respete exactamente las rutas/campos canónicos seleccionados por el usuario (incluyendo listas items/*). """ from __future__ import annotations import logging import time import os from typing import Any, Dict, List, Optional from .config import settings from .prompts import PromptBuilder from .llm_client import LLMClient logger = logging.getLogger('application.normalizer') _llm_client = LLMClient() def _ensure_dict_at_path(root: Dict[str, Any], key: str) -> Dict[str, Any]: val = root.get(key) if not isinstance(val, dict): val = {} root[key] = val return val def _ensure_list_with_object(root: Dict[str, Any], key: str) -> Dict[str, Any]: val = root.get(key) if not isinstance(val, list) or not val or not isinstance(val[0], dict): root[key] = [{}] return root[key][0] return val[0] def build_canonical_template(paths: List[str]) -> Dict[str, Any]: """Convierte rutas 'a/b/*/c' en un objeto anidado con listas y nulls. - Siempre incluye 'radicado' en la raíz. - Para 'x/*/y' crea 'x': [{ 'y': null }]. Si hay más subniveles, anida en ese objeto. """ template: Dict[str, Any] = {"radicado": None} for raw_path in paths or []: if not raw_path: continue segments = [s for s in raw_path.split('/') if s] if not segments: continue i = 0 cur: Dict[str, Any] = template while i < len(segments): name = segments[i] is_last = (i == len(segments) - 1) # próximo segmento indica lista if i + 1 < len(segments) and segments[i + 1] == '*': # asegurar lista con un objeto cur = _ensure_list_with_object(cur, name) i += 2 # saltar el '*' # si terminamos después del '*', no hay campo que asignar aquí if i >= len(segments): break # continuar con el objeto dentro de la lista continue # segmento normal if is_last: cur[name] = None else: cur = _ensure_dict_at_path(cur, name) i += 1 return template def normalize_to_schema( data: Dict[str, Any], paths: List[str], profile_name: str, model: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Usa un LLM para mapear `data` a un objeto que siga el template canónico. Retorna None si no hay API Key o si el LLM falla. """ try: t0 = time.perf_counter() template = build_canonical_template(paths) # Construcción centralizada de mensajes messages = PromptBuilder.build_normalization_messages(profile_name, template, data) # Forzar modelo del normalizador a gpt-5-mini eff_model = "gpt-4.1-mini" eff_base_url = settings.api.base_url or "https://api.openai.com/v1" eff_timeout = getattr(settings.api, "request_timeout_s", 30) # log inicio try: logger.info( "llm_normalize_start", extra={ "model": eff_model, "base_url": eff_base_url, "timeout_s": eff_timeout, "paths_count": len(paths or []), "template_fields": list(template.keys()), "radicado": (data.get("radicado") if isinstance(data, dict) else None), }, ) except Exception: pass # Llamada centralizada vía LLMClient (JSON estricto) try: normalized_obj, usage = _llm_client.chat_json( messages=messages, model=eff_model, response_format="json_object", timeout_s=float(eff_timeout), component="normalizer", ) except Exception: normalized_obj = None usage = None if not isinstance(normalized_obj, dict): try: dt_ms = int((time.perf_counter() - t0) * 1000) logger.info( "llm_normalize_end", extra={ "normalized": False, "duration_ms": dt_ms, "model": eff_model, "base_url": eff_base_url, "radicado": (data.get("radicado") if isinstance(data, dict) else None), }, ) except Exception: pass return None # asegurar radicado en raíz si existía en data rad = None try: rad = data.get("radicado") except Exception: rad = None if rad is not None and isinstance(normalized_obj, dict): normalized_obj["radicado"] = rad try: dt_ms = int((time.perf_counter() - t0) * 1000) logger.info( "llm_normalize_end", extra={ "normalized": True, "duration_ms": dt_ms, "model": eff_model, "base_url": eff_base_url, "radicado": rad, }, ) except Exception: pass # Devolver solo el objeto normalizado (compatibilidad) cuando lo llame alguien antiguo return {"normalized": normalized_obj, "_usage": usage} except Exception as ex: try: dt_ms = int((time.perf_counter() - t0) * 1000) if 't0' in locals() else None logger.info( "llm_normalize_end", extra={ "normalized": False, "error": str(ex), "duration_ms": dt_ms, "model": (eff_model if 'eff_model' in locals() else os.environ.get("NORMALIZE_MODEL", model or settings.api.model or "gpt-4.1-nano")), "base_url": (eff_base_url if 'eff_base_url' in locals() else (settings.api.base_url or "https://api.openai.com/v1")), "radicado": (data.get("radicado") if isinstance(data, dict) else None), }, ) except Exception: pass return None