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) { 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 }