"""Builder para prompts de extracción. Centraliza la construcción de instrucciones reducidas en base a: - selección de campos (paths de schema) - schema de referencia (ruta->tipo) - perfil (invoice/prenuevaeps) """ from __future__ import annotations import json from typing import Dict, List, Any from .profiles import get_profile from .config import get_allowed_document_types def _build_instructions() -> List[str]: tipos_documento = ", ".join(get_allowed_document_types()) return [ "Extrae únicamente los campos solicitados respetando jerarquía y listas", "Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional", "No inventes claves nuevas ni cambies nombres existentes", "Si un campo no existe, usa null o [] según corresponda", "Ten especial cuidado en no confundir el número de autorización con el código", f"Los únicos tipos de documento válidos para 'documento_forzate' son: {tipos_documento}. NIT no es válido para ese campo.", "Si entre los valores visibles aparece uno o varios tipos de documento válidos seguidos por su número (por ejemplo: 'CC 123456789' o 'EPT 123456789'), aunque no estén escritos con formato 'clave: valor', y existe el campo solicitado 'documento_forzate', guarda cada valor completo con su sigla exacta.", "Respeta la sigla exacta del documento encontrado. Ejemplo: si el documento dice 'EPT', devuelve 'EPT 123456789' y no lo conviertas a 'PT'.", "Solo si una ruta de observación, nota o texto aclaratorio figura explícitamente en 'campos_requeridos' (con el nombre exacto indicado), " "extrae el texto completo visible bajo su etiqueta en el documento (p. ej. línea «Observación:» …); no añadas ese campo si no está listado en 'campos_requeridos'.", ] _REGLAS_NOMENCLATURA = [ "Usa EXACTAMENTE los nombres listados en 'campos_requeridos'", "No utilices sinónimos ni variantes (no cambies tildes ni mayúsculas/minúsculas)", "Mantén la estructura de rutas: 'items/*/subcampo' => {'items': [{'subcampo': ...}]}", "No cambies nombres de claves por 'equivalentes'", "Si un campo no existe, devuelve null (o []) usando el MISMO nombre solicitado", ] _NOTA_LISTAS = "Si se pide 'items/*/subtotal', la salida debe ser {'items': [{'subtotal': ...}, {'subtotal': ...}]}" class PromptBuilder: @staticmethod def _base_payload( profile_name: str, fields: List[str], schema: Dict[str, str], extra_meta: Dict[str, Any] | None = None, ) -> Dict[str, Any]: profile = get_profile(profile_name or "invoice") meta: Dict[str, Any] = { "perfil": profile.get("name", "invoice"), "descripcion": "Extracción estructurada desde OCR de facturas (sin datos personales reales)", } if extra_meta: meta.update(extra_meta) return { "meta": meta, "instrucciones": _build_instructions(), "campos_requeridos": fields, "schema_referencia": {k: schema[k] for k in fields if k in schema}, "reglas_estrictas_nomenclatura": _REGLAS_NOMENCLATURA, "salida": {"tipo": "json_object", "raiz": None}, "nota_listas": _NOTA_LISTAS, } @staticmethod def build(selected_fields: List[str], schema: Dict[str, str], profile_name: str | None = None) -> str: payload = PromptBuilder._base_payload(profile_name or "invoice", selected_fields, schema) return json.dumps(payload, indent=2, ensure_ascii=False) @staticmethod def build_normalization_messages(profile_name: str, template: Dict[str, Any], source_json: Dict[str, Any]) -> List[Dict[str, str]]: system_txt = ( "Eres un asistente que normaliza un JSON a un ESQUEMA CANÓNICO dado. " "Debes devolver EXCLUSIVAMENTE un JSON que respete exactamente la estructura y nombres del 'template'. " "Reglas: (1) No inventes claves; (2) No uses sinónimos ni variantes; (3) Respeta listas (si 'items' es lista, devuelve un array de objetos); " "(4) Si falta un valor, usa null o [] según corresponda; (5) No incluyas texto extra ni comentarios. " "(6) Los campos de texto largo (notas, observaciones, leyendas, pie de página, etc.): copia el texto COMPLETO " "del source_json sin resumir, sin omitir tramos ni acortar; respeta tildes y caracteres tal cual en el valor de origen." ) user_obj = { "perfil": profile_name, "template": template, "source_json": source_json, "instrucciones": ( "Mapea los valores del 'source_json' al 'template'. Si hay claves equivalentes en el source, " "elige siempre los nombres del template. No devuelvas rutas con '/', sino objetos anidados. " "Para cualquier cadena larga del documento (p. ej. notas legales o bloques de observación), " "transcribe el contenido literal completo; no uses elipsis ni versiones abreviadas." ), } return [ {"role": "system", "content": system_txt}, {"role": "user", "content": json.dumps(user_obj, ensure_ascii=False)}, ] @staticmethod def build_for_type( doc_type_name: str, selected_paths_for_type: List[str], schema_for_type: Dict[str, str], profile_name: str | None = None, ) -> str: """Construye un prompt reducido para un Tipo de Documento específico. doc_type_name se usa en meta para trazabilidad; no afecta el esquema. """ payload = PromptBuilder._base_payload( profile_name or "invoice", selected_paths_for_type, schema_for_type, extra_meta={"tipo_documento": doc_type_name}, ) return json.dumps(payload, indent=2, ensure_ascii=False)