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