Files
soft_usite/pkg/services/umind_documento_tools.go
T
Lizandro GuarnizoandClaude Opus 5 b7c52f174a fix(umind): auditoría de las cinco features — cinco defectos y un guard permanente
Revisión de coherencia de todo lo que entró. Cinco cosas mal, ninguna
detectable compilando:

1. LA PUERTA DE TOOLS DECIDÍA AL REVÉS. executeUmindTool encolaba antes de
   clasificar, así que en un canal público CUALQUIER nombre de herramienta
   terminaba como pendiente de aprobación — incluidos los que el modelo
   inventa y los internos, cuyos guards ("solo el dueño") quedaban
   inalcanzables porque vivían más abajo en el flujo. Un visitante del widget
   podía llenarle al dueño la bandeja de solicitudes para ejecutar cosas que
   no existen.

   Ahora se clasifica primero: lectura ejecuta, interna se rechaza si el
   pedido es público, acción se encola, desconocida se rechaza. Verificado
   inyectando la falla: el test falla y vuelve a pasar al arreglarlo.

2. El enlace "Ver el documento" de la bandeja estaba muerto: archivo_id no lo
   escribía nadie. Ahora la acción aprobada engancha el PDF que produjo.

3. Los PDF generados esquivaban la cuota de almacenamiento. El tope del plan
   se evitaba pidiéndole documentos al agente en vez de subiendo archivos.

4. La vista de Consumo no conocía el tipo "documento" — el rubro nuevo salía
   sin nombre ni color. Y el icono que le puse no existía en el set, así que
   habría quedado invisible: el mismo bug de los iconos de hace unas semanas,
   que compila y se ve vacío.

5. Las seis lecturas nuevas se registraron con w() en vez de r(), exigiendo
   SoloAdmin para leer. No era una fuga —era más estricto— pero un usuario
   staff sin admin veía la lista de agentes y 403 en sus avisos.

Y el guard que evita el próximo: TestTodoHandlerUmindValidaAlcance recorre los
65 handlers de uMind y exige que cada uno valide el alcance del tenant, o esté
en una lista de exenciones con el motivo escrito. Los mismos handlers se montan
para staff y para clientes; el middleware de scope deja los tenants permitidos
en el contexto pero no mira el :id de la URL — eso lo hace cada handler, y el
que se olvide compila igual. Al escribirlo el propio test encontró que mi lista
de exenciones sobraba en dos, y verifiqué con un handler con fuga deliberada
que lo detecta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 22:04:03 -05:00

195 lines
6.7 KiB
Go

package services
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
"time"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// Generación de documentos para los agentes: cotizaciones y contratos armados
// desde la conversación, con la plantilla del propio cliente.
// pdfEnCurso acota cuántos Chrome headless corren a la vez. Cada PDF levanta
// un navegador entero: cien clientes generando documentos al mismo tiempo son
// cien Chromes, y eso tira el servidor antes que cualquier consulta a la IA.
var pdfEnCurso = make(chan struct{}, 3)
func umindDocumentoTools() []agentTool {
return []agentTool{{
Type: "function",
Function: agentToolFunc{
Name: "generar_documento",
Description: "Genera un documento en PDF (cotización, contrato, acta o cuenta de cobro) con la plantilla del negocio. " +
"Usalo cuando el cliente pide una cotización o un presupuesto formal.",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"tipo": {Type: "string", Description: "cotizacion | contrato | acta | cuenta_cobro"},
"cliente": {Type: "string", Description: "Nombre de la persona o empresa a la que va dirigido"},
"items": {Type: "string", Description: `Los ítems en JSON: [{"descripcion":"...","cantidad":1,"precio":100000}]`},
"notas": {Type: "string", Description: "Condiciones o aclaraciones, opcional"},
},
Required: []string{"tipo", "cliente"},
},
},
}}
}
func executeUmindDocumentoTool(agenteID uint, args map[string]interface{}) string {
tipo, _ := args["tipo"].(string)
tipo = strings.ToLower(strings.TrimSpace(tipo))
switch tipo {
case "cotizacion", "contrato", "acta", "cuenta_cobro":
default:
return `{"error": "tipo inválido: usá cotizacion, contrato, acta o cuenta_cobro"}`
}
cliente, _ := args["cliente"].(string)
if strings.TrimSpace(cliente) == "" {
return `{"error": "falta a nombre de quién va el documento"}`
}
agente, err := models.GetUmindAgenteByID(agenteID)
if err != nil {
return `{"error": "no se pudo generar el documento"}`
}
tenant, err := models.GetUmindTenantByID(agente.TenantID)
if err != nil {
return `{"error": "no se pudo generar el documento"}`
}
plantilla, err := models.GetPlantillaDocumentoDeTenant(agente.TenantID, tipo)
if err != nil {
return fmt.Sprintf(`{"error": "el negocio todavía no tiene una plantilla de %s cargada"}`, tipo)
}
items, total := parsearItems(args["items"])
notas, _ := args["notas"].(string)
datos := map[string]interface{}{
"Fecha": time.Now().Format("02/01/2006"), "Cliente": cliente,
"EmpresaNombre": tenant.Nombre, "Items": items, "Total": total,
"Notas": notas, "Numero": time.Now().Format("20060102-1504"),
}
pdf, err := renderPDFDesdePlantilla(plantilla.ContenidoHTML, datos)
if err != nil {
models.RegistrarEventoUmind(agenteID, "error", "documento", "No se pudo generar un documento", err.Error())
return `{"error": "no se pudo generar el documento"}`
}
archivo, err := guardarPDFEnRepositorio(agente.TenantID, fmt.Sprintf("%s-%s", tipo, cliente), pdf)
if err != nil {
return `{"error": "el documento se generó pero no se pudo guardar"}`
}
models.RegistrarUsoUmind(agenteID, models.UsoTipoDocumento, 1, "documento")
b, _ := json.Marshal(map[string]interface{}{
"ok": true, "archivo_id": archivo.ID, "nombre": archivo.Nombre, "total": total,
"nota": "El documento quedó guardado en los archivos del negocio.",
})
return string(b)
}
// renderPDFDesdePlantilla ejecuta la plantilla y la manda a Chrome, con el
// semáforo puesto.
func renderPDFDesdePlantilla(contenidoHTML string, datos map[string]interface{}) ([]byte, error) {
tmpl, err := template.New("doc").Parse(contenidoHTML)
if err != nil {
return nil, fmt.Errorf("la plantilla tiene un error de formato: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, datos); err != nil {
return nil, fmt.Errorf("no se pudo completar la plantilla: %w", err)
}
pdfEnCurso <- struct{}{}
defer func() { <-pdfEnCurso }()
return RenderHTMLToPDF(buf.String())
}
// guardarPDFEnRepositorio deja el documento en los archivos del espacio, para
// que quede a la vista y descargable como cualquier otro.
func guardarPDFEnRepositorio(tenantID uint, nombreBase string, pdf []byte) (*models.UmindArchivo, error) {
// Un PDF generado ocupa lo mismo que uno subido: si no contara para la
// cuota, el tope del plan se esquivaría pidiéndole documentos al agente.
if err := models.HayEspacioUmind(tenantID, int64(len(pdf))); err != nil {
return nil, err
}
dir := filepath.Join("uploads", "umind", strconv.FormatUint(uint64(tenantID), 10))
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
seguro := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '-'
}, strings.ToLower(nombreBase))
nombre := fmt.Sprintf("%s-%d.pdf", seguro, time.Now().Unix())
destino := filepath.Join(dir, nombre)
if err := os.WriteFile(destino, pdf, 0o644); err != nil {
return nil, err
}
archivo := &models.UmindArchivo{
TenantID: tenantID, Nombre: nombre, Archivo: destino,
TipoMime: "application/pdf", Tamanio: int64(len(pdf)), Origen: "generado",
}
if err := models.CreateUmindArchivo(archivo); err != nil {
_ = os.Remove(destino)
return nil, err
}
return archivo, nil
}
type itemDocumento struct {
Descripcion string
Cantidad float64
Precio float64
Subtotal float64
}
// parsearItems acepta el JSON que arma el modelo. Si viene mal formado se
// devuelve vacío en vez de fallar: un documento sin la tabla de ítems todavía
// se puede corregir a mano; uno que no se generó hace perder la conversación.
func parsearItems(raw interface{}) ([]itemDocumento, float64) {
s, _ := raw.(string)
if strings.TrimSpace(s) == "" {
return nil, 0
}
var crudos []struct {
Descripcion string `json:"descripcion"`
Cantidad float64 `json:"cantidad"`
Precio float64 `json:"precio"`
}
if err := json.Unmarshal([]byte(s), &crudos); err != nil {
return nil, 0
}
items := make([]itemDocumento, 0, len(crudos))
total := 0.0
for _, c := range crudos {
if c.Cantidad == 0 {
c.Cantidad = 1
}
sub := c.Cantidad * c.Precio
total += sub
items = append(items, itemDocumento{c.Descripcion, c.Cantidad, c.Precio, sub})
}
return items, total
}
// ValidarPlantillaHTML confirma que la plantilla compila antes de guardarla:
// descubrir el error al generar deja al cliente esperando un PDF que nunca
// llega, y del lado de adentro no hay a quién preguntarle.
func ValidarPlantillaHTML(contenido string) error {
if _, err := template.New("v").Parse(contenido); err != nil {
return fmt.Errorf("la plantilla tiene un error de formato: %w", err)
}
return nil
}