From ac47769c275568188ee73a408537357b1ad604e2 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:07:03 -0500 Subject: [PATCH] =?UTF-8?q?fix(plantillas):=20"Leer=20con=20IA"=20fallaba?= =?UTF-8?q?=20con=20la=20config=20de=20IA=20que=20ya=20est=C3=A1=20en=20pr?= =?UTF-8?q?oducci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escribí una segunda implementación de "llamar al proveedor" en vez de usar la que ya existe (callAI, la del bot de Telegram). Esa copia no hablaba Anthropic —se lo mandaba todo al endpoint de OpenAI— y, si la config tenía la URL base vacía (lo normal: la vista dice que es opcional), armaba una URL relativa que ni siquiera es una petición válida. Cualquiera de las dos cosas terminaba en el 502. Ahora usa callAI, que despacha por proveedor y completa la URL base. El error del proveedor queda en el log del servidor y el mensaje llega entero a la vista; si la respuesta no trae mensaje, la vista muestra el status para distinguir un fallo de la app de uno del proxy. Co-Authored-By: Claude Opus 5 --- pkg/services/ia_service.go | 111 ++++-------------- .../automatizacion/plantillas_documento.html | 5 +- .../plantilla_documento_controller.go | 6 +- 3 files changed, 29 insertions(+), 93 deletions(-) diff --git a/pkg/services/ia_service.go b/pkg/services/ia_service.go index 4a87621..5a44010 100644 --- a/pkg/services/ia_service.go +++ b/pkg/services/ia_service.go @@ -1,111 +1,40 @@ package services import ( - "bytes" - "encoding/json" "fmt" - "io" - "net/http" "strings" - "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) -// CompletarTextoIA hace una llamada simple (sin streaming, sin tools) al -// proveedor configurado para el servicio dado y devuelve el texto de la -// respuesta. Es la contraparte no-streaming de GeneraTextoStream, para los -// casos en que el backend necesita el resultado completo antes de seguir. +// CompletarTextoIA hace una llamada simple (sin tools, sin streaming) al +// proveedor configurado para el servicio dado y devuelve el texto. +// +// Reusa callAI, que es el mismo despachador del bot de Telegram: sabe hablar +// Anthropic y OpenAI-compatible, y completa la URL base cuando la config la +// tiene vacía. Escribir una segunda implementación acá fue un error: no +// soportaba Anthropic y armaba una URL relativa cuando faltaba la base, así +// que fallaba con la config que ya estaba en producción. func CompletarTextoIA(servicio, sistema, usuario string) (string, error) { config, err := models.GetAiConfigForService(servicio) if err != nil { - return "", fmt.Errorf("no hay configuración de IA activa para %q; configurá una en /app/ai-config", servicio) + return "", fmt.Errorf("no hay configuración de IA activa; configurá una en /app/ai-config") } - modelo := config.ModelName - if modelo == "" { - return "", fmt.Errorf("la configuración de IA de %q no tiene modelo definido", servicio) + if strings.TrimSpace(config.ModelName) == "" { + return "", fmt.Errorf("la configuración de IA %q no tiene modelo definido (/app/ai-config)", config.Nombre) } - provider := strings.ToLower(config.Provider) - clave := config.ClaveEnClaro() - baseURL := strings.TrimRight(config.BaseURL, "/") - - var endpoint string - var cuerpo []byte - if provider == "gemini" { - endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelo, clave) - cuerpo, _ = json.Marshal(map[string]any{ - "contents": []map[string]any{ - {"parts": []map[string]string{{"text": sistema + "\n\n" + usuario}}}, - }, - }) - } else { - endpoint = strings.TrimSuffix(baseURL, "/v1") + "/v1/chat/completions" - cuerpo, _ = json.Marshal(map[string]any{ - "model": modelo, - "messages": []map[string]string{ - {"role": "system", "content": sistema}, - {"role": "user", "content": usuario}, - }, - "stream": false, - }) - } - - req, err := http.NewRequest("POST", endpoint, bytes.NewReader(cuerpo)) + msg, _, err := callAI(config, []agentMessage{ + {Role: "system", Content: sistema}, + {Role: "user", Content: usuario}, + }, nil) if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - if provider != "gemini" && clave != "" { - if provider == "ollama" && clave != "ollama" { - req.SetBasicAuth("ollama", clave) - } else if provider != "ollama" { - req.Header.Set("Authorization", "Bearer "+clave) - } + return "", fmt.Errorf("%s (%s): %w", config.Nombre, config.Provider, err) } - // Generar una plantilla entera es lento; el timeout es alto a propósito. - resp, err := (&http.Client{Timeout: 180 * time.Second}).Do(req) - if err != nil { - return "", fmt.Errorf("no se pudo conectar al proveedor de IA: %w", err) + texto, _ := msg.Content.(string) + if strings.TrimSpace(texto) == "" { + return "", fmt.Errorf("%s no devolvió texto", config.Nombre) } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("el proveedor de IA respondió %d: %s", resp.StatusCode, recortar(strings.TrimSpace(string(raw)), 300)) - } - - if provider == "gemini" { - var out struct { - Candidates []struct { - Content struct { - Parts []struct { - Text string `json:"text"` - } `json:"parts"` - } `json:"content"` - } `json:"candidates"` - } - if err := json.Unmarshal(raw, &out); err != nil { - return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300)) - } - if len(out.Candidates) == 0 || len(out.Candidates[0].Content.Parts) == 0 { - return "", fmt.Errorf("el proveedor de IA no devolvió texto") - } - return out.Candidates[0].Content.Parts[0].Text, nil - } - - var out struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - if err := json.Unmarshal(raw, &out); err != nil { - return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300)) - } - if len(out.Choices) == 0 { - return "", fmt.Errorf("el proveedor de IA no devolvió texto") - } - return out.Choices[0].Message.Content, nil + return texto, nil } diff --git a/resources/views/automatizacion/plantillas_documento.html b/resources/views/automatizacion/plantillas_documento.html index e8999fe..bfddd33 100644 --- a/resources/views/automatizacion/plantillas_documento.html +++ b/resources/views/automatizacion/plantillas_documento.html @@ -213,7 +213,10 @@ document.addEventListener('alpine:init', () => { this.avisoImport = data.aviso || 'Listo, revisá el HTML abajo'; } catch(e) { this.errorImport = true; - this.avisoImport = e.response?.data?.error || e.message; + // Sin data.error el fallo no vino de la app sino del proxy + // (timeout, por ejemplo); mostrar el status ayuda a distinguirlo. + this.avisoImport = e.response?.data?.error + || (e.response ? `El servidor respondió ${e.response.status}` : e.message); } this.importando = false; }, diff --git a/rest/controllers/plantilla_documento_controller.go b/rest/controllers/plantilla_documento_controller.go index be8f147..08c0fdc 100644 --- a/rest/controllers/plantilla_documento_controller.go +++ b/rest/controllers/plantilla_documento_controller.go @@ -2,6 +2,7 @@ package controllers import ( "io" + "log" "math" "strconv" "text/template" @@ -135,7 +136,10 @@ func ImportarPlantillaDocumento(c *fiber.Ctx) error { tipo := c.FormValue("tipo", "cotizacion") html, err := services.ConvertirEnPlantilla(tipo, texto) if err != nil { - return c.Status(502).JSON(fiber.Map{"error": err.Error()}) + // Al log también: el mensaje del proveedor es lo único que dice por qué + // falló, y desde el navegador se ve recortado. + log.Printf("[PLANTILLAS] importar (%s, %s): %v", tipo, archivo.Filename, err) + return c.Status(422).JSON(fiber.Map{"error": err.Error()}) } // Si la IA devolvió algo que no compila, es mejor decirlo acá que al guardar. if _, err := template.New("validate").Parse(html); err != nil {