feat(plantillas): subir el documento y que la IA lo devuelva como plantilla editable

Hasta ahora crear una plantilla era escribir HTML con variables de Go a mano.
Ahora se sube el documento que ya existe (.docx, .html, .txt o una foto del
papel), se lee —docx con stdlib, imágenes por OCR— y la IA lo devuelve armado
como plantilla con las variables del generador puestas donde iban los datos.

No se guarda solo: el HTML cae en el editor para revisarlo, y si la IA devolvió
sintaxis de plantilla inválida se avisa antes de guardar.

PDF queda afuera por ahora; el mensaje de error dice qué hacer en su lugar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 18:39:11 -05:00
co-authored by Claude Opus 5
parent 8c3ab79e88
commit e681f41639
6 changed files with 410 additions and 0 deletions
+111
View File
@@ -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
}
+144
View File
@@ -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, "</w:p>", "\n")
s = strings.ReplaceAll(s, "<w:br/>", "\n")
s = strings.ReplaceAll(s, "</w:tr>", "\n")
s = strings.ReplaceAll(s, "</w:tc>", "\t")
s = etiquetaXML.ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").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 <script>: el HTML se convierte a PDF.
- No inventes variables que no estén en la lista.`
usuario := fmt.Sprintf(`Tipo de documento: %s
Variables disponibles:
%s
%s
Documento de referencia:
---
%s
---`, tipo, variablesComunes, vars, textoDocumento)
salida, err := CompletarTextoIA("ia", sistema, usuario)
if err != nil {
return "", err
}
return limpiarCercaDeCodigo(salida), nil
}
// limpiarCercaDeCodigo saca el ```html … ``` que los modelos agregan aunque se
// les pida que no lo hagan.
func limpiarCercaDeCodigo(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
if i := strings.Index(s, "\n"); i >= 0 {
s = s[i+1:]
}
if i := strings.LastIndex(s, "```"); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s)
}
+70
View File
@@ -0,0 +1,70 @@
package services
import (
"archive/zip"
"bytes"
"strings"
"testing"
)
func docxDePrueba(t *testing.T, documentXML string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(documentXML)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtraerTextoDePlantillaDocx(t *testing.T) {
xml := `<?xml version="1.0"?><w:document><w:body>` +
`<w:p><w:r><w:t>COTIZACI&amp;Oacute;N</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Cliente: </w:t></w:r><w:r><w:t>Acme SAS</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Total: $1.500.000</w:t></w:r></w:p>` +
`</w:body></w:document>`
got, err := ExtraerTextoDePlantilla("cotizacion.docx", docxDePrueba(t, xml))
if err != nil {
t.Fatalf("ExtraerTextoDePlantilla: %v", err)
}
// Los runs de un mismo párrafo quedan pegados; los párrafos, en líneas.
if !strings.Contains(got, "Cliente: Acme SAS") {
t.Errorf("falta la línea del cliente en:\n%s", got)
}
if !strings.Contains(got, "Total: $1.500.000") {
t.Errorf("falta el total en:\n%s", got)
}
if strings.Contains(got, "<w:") {
t.Errorf("quedaron etiquetas XML en:\n%s", got)
}
}
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
if _, err := ExtraerTextoDePlantilla("plantilla.pdf", []byte("x")); err == nil {
t.Error("el PDF debería devolver error explicando la alternativa")
}
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error")
}
}
func TestLimpiarCercaDeCodigo(t *testing.T) {
casos := map[string]string{
"```html\n<p>hola</p>\n```": "<p>hola</p>",
"```\n<p>hola</p>\n```": "<p>hola</p>",
"<p>hola</p>": "<p>hola</p>",
}
for in, want := range casos {
if got := limpiarCercaDeCodigo(in); got != want {
t.Errorf("limpiarCercaDeCodigo(%q) = %q, want %q", in, got, want)
}
}
}