Files
IPSExtractor/application/prompts.py
T

113 lines
4.8 KiB
Python

"""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 .app_prefs 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'.",
"Si ves un campo llamado observacion o parecido tambien incluye ese valor en el JSON"
]
_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."
)
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."
),
}
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)