diff --git a/pkg/services/ia_service.go b/pkg/services/ia_service.go new file mode 100644 index 0000000..4a87621 --- /dev/null +++ b/pkg/services/ia_service.go @@ -0,0 +1,111 @@ +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. +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) + } + modelo := config.ModelName + if modelo == "" { + return "", fmt.Errorf("la configuración de IA de %q no tiene modelo definido", servicio) + } + + 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)) + 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) + } + } + + // 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) + } + 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 +} diff --git a/pkg/services/plantilla_import_service.go b/pkg/services/plantilla_import_service.go new file mode 100644 index 0000000..4fcac37 --- /dev/null +++ b/pkg/services/plantilla_import_service.go @@ -0,0 +1,144 @@ +package services + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "path/filepath" + "regexp" + "strings" +) + +// variablesPorTipo documenta, para la IA, qué campos recibe cada plantilla al +// renderizarse. Sale de DatosBaseDocumento + lo que arma cada generador +// (ver CrearCotizacion, contrato_documento_service, cuenta_cobro_documento_service). +var variablesPorTipo = map[string]string{ + "cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Cliente.Email}}, {{.Cliente.Telefono}}, +{{.Alcance}}, {{.TipoProyecto}}, {{.Total}}, +{{range .Items}} … {{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}} … {{end}}`, + "contrato": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`, + "acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`, + "cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`, +} + +const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}` + +// ExtraerTextoDePlantilla saca el texto de un archivo subido para usarlo como +// referencia. Los formatos de texto se leen directo; el .docx es un zip con XML +// adentro (stdlib alcanza) y las imágenes pasan por OCR. +func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) { + ext := strings.ToLower(filepath.Ext(nombreArchivo)) + switch ext { + case ".html", ".htm", ".txt", ".md": + return string(datos), nil + case ".docx": + return textoDeDocx(datos) + case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff": + mime := "image/png" + if ext == ".jpg" || ext == ".jpeg" { + mime = "image/jpeg" + } else if ext != ".png" { + mime = "image/" + strings.TrimPrefix(ext, ".") + } + // agenteID 0: es una acción del staff, no se le cobra a ningún cliente. + return ExtraerTextoOCR(0, datos, mime) + case ".pdf": + return "", fmt.Errorf("el PDF todavía no se puede leer acá; exportalo a .docx o subí una captura de pantalla del documento") + default: + return "", fmt.Errorf("formato %s no soportado: subí .docx, .html, .txt o una imagen del documento", ext) + } +} + +var etiquetaXML = regexp.MustCompile(`<[^>]+>`) + +// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende +// conservar el formato: la IA solo necesita el contenido y el orden. +func textoDeDocx(datos []byte) (string, error) { + zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos))) + if err != nil { + return "", fmt.Errorf("el .docx no se pudo abrir: %w", err) + } + for _, f := range zr.File { + if f.Name != "word/document.xml" { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20)) + if err != nil { + return "", err + } + s := string(xmlBytes) + // Un párrafo y un salto de línea explícito valen como salto de línea; + // el resto de las etiquetas se descarta. + s = strings.ReplaceAll(s, "", "\n") + s = strings.ReplaceAll(s, "", "\n") + s = strings.ReplaceAll(s, "", "\n") + s = strings.ReplaceAll(s, "", "\t") + s = etiquetaXML.ReplaceAllString(s, "") + s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").Replace(s) + return strings.TrimSpace(s), nil + } + return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)") +} + +// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla +// HTML con las variables Go que usa el generador. Lo que devuelve va al editor +// para que el admin lo revise antes de guardar: no se guarda solo. +func ConvertirEnPlantilla(tipo, textoDocumento string) (string, error) { + if strings.TrimSpace(textoDocumento) == "" { + return "", fmt.Errorf("no se pudo leer texto del archivo") + } + if len(textoDocumento) > 20000 { + textoDocumento = textoDocumento[:20000] + } + vars := variablesPorTipo[tipo] + if vars == "" { + vars = variablesComunes + } + + sistema := `Sos un asistente que convierte documentos en plantillas HTML para Go text/template. +Reglas: +- Devolvé SOLO el HTML de la plantilla, sin explicaciones y sin bloques de código markdown. +- Reemplazá los datos concretos del documento (nombres, NITs, fechas, montos, ítems) por las variables de la lista. Lo que sea texto fijo del formato se deja tal cual. +- Si el documento tiene una tabla de ítems, usá {{range .Items}}…{{end}} para las filas. +- Usá estilos inline (style="…"), sin CSS externo ni