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 {