118 lines
5.5 KiB
Python
118 lines
5.5 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'.",
|
|
"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)
|
|
|