Files
soft_usite/rest/controllers/query_runner_controller.go
T
Lizandro GuarnizoandClaude Sonnet 4.6 6062f31c70 feat(ai): integrar Ollama en AiConfig para ia_controller, query_runner y landing
- Agrega provider 'ollama' al panel de AiConfig con badge azul y hint de URL
- ia_controller: reemplaza webhook n8n hardcodeado por llamada directa a Ollama vía AiConfig (módulo 'ia')
- query_runner: agrega soporte explícito de ollama (model fallback gemma3:1b, Basic Auth para URL pública)
- HTML: agrega módulo 'IA / vCard' en el selector de módulos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 09:33:22 -05:00

679 lines
22 KiB
Go

package controllers
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// QueryRunnerPage renderiza la vista del editor SQL.
// GET /app/query-runner?conx_db_id=1
func QueryRunnerPage(c *fiber.Ctx) error {
conxIDStr := c.Query("conx_db_id", "")
var conx models.ConxDb
if conxIDStr != "" {
id, _ := strconv.ParseUint(conxIDStr, 10, 32)
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id)
}
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
"conx_db_id": conxIDStr,
"conx": conx,
}
if err := c.Render("query_runner", data, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return nil
}
// GetConxDbList devuelve todas las conexiones DB disponibles (para el selector).
func GetConxDbList(c *fiber.Ctx) error {
var items []models.ConxDb
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&items)
return c.JSON(fiber.Map{"data": items})
}
// GetDatabases lista las bases de datos de una conexión.
// GET /app/query-runner/databases?conx_db_id=1
func GetDatabases(c *fiber.Ctx) error {
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
dbs, err := services.ListDatabases(conx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": dbs})
}
// GetTables lista las tablas de una base de datos.
// GET /app/query-runner/tables?conx_db_id=1&db=mydb
func GetTables(c *fiber.Ctx) error {
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
dbName := c.Query("db", "")
tables, err := services.ListTables(conx, dbName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": tables})
}
// RunQuery ejecuta una consulta SQL.
// POST /app/query-runner/run
// Body: { conx_db_id, database, sql }
func RunQuery(c *fiber.Ctx) error {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
SQL string `json:"sql"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
}
if strings.TrimSpace(body.SQL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"})
}
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
result := services.ExecuteSQL(conx, body.Database, body.SQL)
return c.JSON(result)
}
// TestConnection verifica que la conexión funciona.
// GET /app/query-runner/test?conx_db_id=1
func TestConnection(c *fiber.Ctx) error {
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
if err := services.TestDBConnection(conx); err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"ok": false, "error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// GetHistory devuelve el historial de una conexión.
// GET /app/query-runner/history?conx_db_id=1&page=1
func GetHistory(c *fiber.Ctx) error {
conxIDStr := c.Query("conx_db_id", "0")
conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 50
offset := (page - 1) * limit
items, total, err := models.GetQueryHistory(uint(conxID), limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
totalPages := int(math.Ceil(float64(total) / float64(limit)))
return c.JSON(fiber.Map{
"data": items,
"total": total,
"totalPages": totalPages,
"page": page,
})
}
// ClearHistory borra el historial de una conexión.
// DELETE /app/query-runner/history?conx_db_id=1
func ClearHistory(c *fiber.Ctx) error {
conxIDStr := c.Query("conx_db_id", "0")
conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
if err := models.DeleteQueryHistory(uint(conxID)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ExportCSV exporta el resultado de una consulta como CSV.
// POST /app/query-runner/export/csv
func ExportCSV(c *fiber.Ctx) error {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
SQL string `json:"sql"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
}
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
result := services.ExecuteSQL(conx, body.Database, body.SQL)
if result.Error != "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
}
var buf bytes.Buffer
w := csv.NewWriter(&buf)
w.Write(result.Columns)
for _, row := range result.Rows {
rec := make([]string, len(result.Columns))
for i, col := range result.Columns {
v := row[col]
if v == nil {
rec[i] = ""
} else {
rec[i] = fmt.Sprintf("%v", v)
}
}
w.Write(rec)
}
w.Flush()
filename := fmt.Sprintf("query_%s.csv", time.Now().Format("20060102_150405"))
c.Set("Content-Disposition", "attachment; filename="+filename)
c.Set("Content-Type", "text/csv; charset=utf-8")
return c.SendStream(bytes.NewReader(buf.Bytes()), buf.Len())
}
// ExportJSON exporta el resultado como JSON.
// POST /app/query-runner/export/json
func ExportJSON(c *fiber.Ctx) error {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
SQL string `json:"sql"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
}
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
result := services.ExecuteSQL(conx, body.Database, body.SQL)
if result.Error != "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
}
data, _ := json.MarshalIndent(result.Rows, "", " ")
filename := fmt.Sprintf("query_%s.json", time.Now().Format("20060102_150405"))
c.Set("Content-Disposition", "attachment; filename="+filename)
c.Set("Content-Type", "application/json; charset=utf-8")
return c.SendStream(bytes.NewReader(data), len(data))
}
// GetTableColumnsHandler devuelve info de columnas de una tabla.
// GET /app/query-runner/columns?conx_db_id=1&db=mydb&table=users
func GetTableColumnsHandler(c *fiber.Ctx) error {
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
dbName := c.Query("db", "")
table := c.Query("table", "")
if table == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "table es requerido"})
}
cols, err := services.GetTableColumns(conx, dbName, table)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": cols})
}
// UpdateCellHandler actualiza una celda específica.
// POST /app/query-runner/update-cell
// Body: { conx_db_id, database, table, column, pk_column, pk_value, value }
func UpdateCellHandler(c *fiber.Ctx) error {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
Table string `json:"table"`
Column string `json:"column"`
PkColumn string `json:"pk_column"`
PkValue string `json:"pk_value"`
Value string `json:"value"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
}
if body.Table == "" || body.Column == "" || body.PkColumn == "" || body.PkValue == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "table, column, pk_column y pk_value son requeridos"})
}
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
driver := strings.ToLower(conx.TipoDb.Nombre)
if strings.Contains(driver, "mongo") || strings.Contains(driver, "redis") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Solo soportado para SQL"})
}
// Construir la consulta de actualización
qCol := quoteIdent(body.Column, driver)
qTable := quoteIdent(body.Table, driver)
qPkCol := quoteIdent(body.PkColumn, driver)
// Determinar si el valor debe ir como string o literal
isNumeric := false
if _, err := strconv.ParseFloat(body.Value, 64); err == nil {
isNumeric = true
}
isBool := strings.ToLower(body.Value) == "true" || strings.ToLower(body.Value) == "false"
isNull := strings.ToLower(body.Value) == "null"
var valueSQL string
switch {
case isNull:
valueSQL = "NULL"
case isNumeric:
valueSQL = body.Value
case isBool:
valueSQL = body.Value
default:
valueSQL = "'" + strings.ReplaceAll(body.Value, "'", "''") + "'"
}
// Determinar si pk_value es numérico para no entrecomillarlo
pkValueSQL := "'" + strings.ReplaceAll(body.PkValue, "'", "''") + "'"
if _, err := strconv.ParseFloat(body.PkValue, 64); err == nil {
pkValueSQL = body.PkValue
}
sqlText := fmt.Sprintf("UPDATE %s SET %s = %s WHERE %s = %s",
qTable, qCol, valueSQL, qPkCol, pkValueSQL)
result := services.ExecuteSQL(conx, body.Database, sqlText)
if result.Error != "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error})
}
return c.JSON(fiber.Map{"ok": true, "affected": result.AffectedRows})
}
// quoteIdent envuelve un identificador con las comillas adecuadas según el driver.
func quoteIdent(name, driver string) string {
d := strings.ToLower(driver)
if strings.Contains(d, "postgres") {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
if strings.Contains(d, "sqlserver") || strings.Contains(d, "mssql") {
return "[" + name + "]"
}
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
// NL2SQLHandler convierte lenguaje natural a SQL usando IA.
// POST /app/query-runner/nl2sql
// Body: { conx_db_id, database, text, schema }
// schema es un array de { table, columns: [{name, type}] } para dar contexto a la IA.
func NL2SQLHandler(c *fiber.Ctx) error {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
Text string `json:"text"`
Schema []struct {
Table string `json:"table"`
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
} `json:"schema"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
}
if strings.TrimSpace(body.Text) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "El texto está vacío"})
}
// Construir representación del esquema para el prompt
var schemaLines []string
for _, t := range body.Schema {
var cols []string
for _, col := range t.Columns {
cols = append(cols, col.Name+" ("+col.Type+")")
}
if len(cols) > 0 {
schemaLines = append(schemaLines, fmt.Sprintf(" %s: %s", t.Table, strings.Join(cols, ", ")))
} else {
schemaLines = append(schemaLines, " "+t.Table)
}
}
schemaStr := ""
if len(schemaLines) > 0 {
schemaStr = "Esquema disponible:\n" + strings.Join(schemaLines, "\n")
}
aiConfig, err := models.GetAiConfigForService("query_runner")
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No hay configuración de IA activa. Configura una en /app/ai-config",
})
}
prompt := fmt.Sprintf(`Eres un experto en SQL. Convierte la siguiente descripción en lenguaje natural a una consulta SQL válida.
%s
Descripción: %s
Reglas:
- Devuelve SOLO el SQL, sin explicaciones ni bloques de código markdown.
- Usa los nombres de tablas y columnas exactamente como aparecen en el esquema.
- Si la base de datos es PostgreSQL, usa comillas dobles para identificadores.
- Termina siempre con punto y coma.
- Si no hay suficiente información para generar el SQL, devuelve el SQL más razonable posible.
SQL:`, schemaStr, body.Text)
result := callAISQLSuggestion(aiConfig, prompt, "nl2sql", body.ConxDbID, body.Database)
if result == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "La IA no pudo generar el SQL"})
}
return c.JSON(result)
}
// CorrectQueryHandler usa IA o heurística para corregir/sugerir queries.
// POST /app/query-runner/suggest
// Body: { conx_db_id, database, sql, action: "correct"|"complete"|"optimize" }
func CorrectQueryHandler(c *fiber.Ctx) error {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
SQL string `json:"sql"`
Action string `json:"action"` // "correct" | "complete" | "optimize"
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
}
if strings.TrimSpace(body.SQL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"})
}
if body.Action == "" {
body.Action = "correct"
}
// 1. Si hay una configuración de IA activa para este servicio, usarla
aiConfig, aiErr := models.GetAiConfigForService("query_runner")
if aiErr == nil && aiConfig != nil {
aiResult := callAISQLSuggestion(aiConfig, body.SQL, body.Action, body.ConxDbID, body.Database)
if aiResult != nil {
return c.JSON(aiResult)
}
}
// 2. Fallback: corrección heurística local
suggestion := heuristicSQLFix(body.SQL)
return c.JSON(fiber.Map{
"suggestion": suggestion,
"source": "local",
})
}
// heuristicSQLFix aplica correcciones básicas sin IA.
func heuristicSQLFix(sql string) string {
original := sql
// Palabras clave de SQL
// nolint:unused
keywords := map[string]string{
"SLECT": "SELECT", "SELCT": "SELECT", "SELCET": "SELECT", "SElECT": "SELECT",
"SELC": "SELECT", "SELEKT": "SELECT",
"FORM": "FROM", "FOM": "FROM", "FRO": "FROM",
"WHER": "WHERE", "WHRE": "WHERE", "WHARE": "WHERE", "WHEERE": "WHERE",
"UPDTE": "UPDATE", "UPDAT": "UPDATE", "UDATE": "UPDATE", "UPDETA": "UPDATE",
"DELTE": "DELETE", "DELET": "DELETE", "DELT": "DELETE",
"INSRT": "INSERT", "INSER": "INSERT", "NSERT": "INSERT",
"INOT": "INTO", "IINTO": "INTO",
"VALUS": "VALUES", "VLAUES": "VALUES",
"GRUP": "GROUP", "GROU": "GROUP", "GRUOP": "GROUP",
"ORDR": "ORDER", "ORDRE": "ORDER", "ORBER": "ORDER",
"HAVNG": "HAVING", "HAVIN": "HAVING", "HVING": "HAVING",
"LIMT": "LIMIT", "LIIMT": "LIMIT",
"JON": "JOIN", "JOUN": "JOIN",
"LEF JOIN": "LEFT JOIN", "LEFTJ OIN": "LEFT JOIN",
"RIGTH": "RIGHT",
"CRATE": "CREATE", "CREARE": "CREATE",
"TABEL": "TABLE", "TBALE": "TABLE",
"ALTR": "ALTER", "ALTE": "ALTER",
"DRO": "DROP", "DROPP": "DROP",
"IDNEX": "INDEX", "INEX": "INDEX",
"PRIMRY": "PRIMARY", "PRIMAR": "PRIMARY", "PRMARY": "PRIMARY",
"FORIGN": "FOREIGN", "FOREIN": "FOREIGN", "FORIEGN": "FOREIGN",
"REFERNCES": "REFERENCES", "REFERECES": "REFERENCES", "REFRENCES": "REFERENCES",
"CONSTRINT": "CONSTRAINT", "CONSTRAIN": "CONSTRAINT",
"TRUNC": "TRUNCATE", "TRUNCAT": "TRUNCATE",
"TRIGER": "TRIGGER", "TRGIGER": "TRIGGER",
"FUNCTON": "FUNCTION", "FUNCTIN": "FUNCTION", "FUNTION": "FUNCTION",
"PROCEDRE": "PROCEDURE", "PROCDURE": "PROCEDURE",
"BEGN": "BEGIN", "BEGIIN": "BEGIN",
"COMIT": "COMMIT", "COMIIT": "COMMIT",
"ROLLBCK": "ROLLBACK", "ROLLBAK": "ROLLBACK", "ROLBACK": "ROLLBACK",
}
// Reemplazar palabras mal escritas (case-insensitive)
words := strings.Fields(sql)
for i, word := range words {
upperWord := strings.ToUpper(word)
if corrected, ok := keywords[upperWord]; ok {
if upperWord != corrected {
// Mantener case original si empezaba con mayúscula
if word != "" && word[0] >= 'A' && word[0] <= 'Z' {
words[i] = corrected
} else {
words[i] = strings.ToLower(corrected)
}
}
}
}
fixed := strings.Join(words, " ")
// Agregar punto y coma si falta
fixed = strings.TrimSpace(fixed)
if !strings.HasSuffix(fixed, ";") && !strings.HasSuffix(fixed, "\n") {
fixed = fixed + ";"
}
if fixed != original {
return fixed
}
return ""
}
// callAISQLSuggestion llama a un LLM para ayudar con SQL.
func callAISQLSuggestion(config *models.AiConfig, sql, action string, conxDbID uint, database string) *fiber.Map {
// Construir prompt según la acción
var prompt string
switch action {
case "correct":
prompt = fmt.Sprintf(`Eres un experto en SQL. Corrige los errores de esta consulta SQL. Devuelve SOLO el SQL corregido, sin explicaciones.
SQL original:
%s
SQL corregido:`, sql)
case "complete":
prompt = fmt.Sprintf(`Eres un experto en SQL. Completa esta consulta SQL. Devuelve SOLO el SQL completo, sin explicaciones.
SQL incompleto:
%s
SQL completo:`, sql)
case "optimize":
prompt = fmt.Sprintf(`Eres un experto en optimización de SQL. Optimiza esta consulta añadiendo índices sugeridos, mejorando JOINs y filtrando mejor. Devuelve SOLO el SQL optimizado, sin explicaciones.
SQL original:
%s
SQL optimizado:`, sql)
case "nl2sql":
// El prompt ya viene construido desde NL2SQLHandler (incluye esquema + descripción)
prompt = sql
default:
prompt = fmt.Sprintf(`Eres un experto en SQL. Ayuda con esta consulta SQL. Devuelve SOLO el SQL resultante, sin explicaciones.
%s`, sql)
}
// Hacer la llamada HTTP al proveedor de IA
client := &http.Client{Timeout: 60 * time.Second}
baseURL := config.BaseURL
if baseURL == "" {
switch config.Provider {
case "openai":
baseURL = "https://api.openai.com/v1"
case "qwen":
baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "anthropic":
baseURL = "https://api.anthropic.com/v1"
default:
log.Printf("[AI_SQL] Provider '%s' requiere BaseURL configurada", config.Provider)
return nil
}
}
modelName := config.ModelName
if modelName == "" {
switch config.Provider {
case "openai":
modelName = "gpt-4o-mini"
case "qwen":
modelName = "qwen2.5-72b-instruct"
case "anthropic":
modelName = "claude-3-haiku-20240307"
case "ollama":
modelName = "gemma3:1b"
default:
modelName = "gpt-4o-mini"
}
}
requestBody := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": "Eres un experto en SQL. Responde SOLO con el SQL, sin explicaciones adicionales."},
{"role": "user", "content": prompt},
},
"temperature": 0.1,
"max_tokens": 1024,
}
jsonBody, _ := json.Marshal(requestBody)
var url string
var authHeader string
switch config.Provider {
case "anthropic":
url = baseURL + "/messages"
authHeader = "x-api-key"
default:
url = baseURL + "/chat/completions"
authHeader = "Authorization"
}
req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
switch config.Provider {
case "anthropic":
req.Header.Set(authHeader, config.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
case "ollama":
// Sin auth para red interna; para URL pública el api_key es "usuario:token"
if config.ApiKey != "" && config.ApiKey != "ollama" {
req.SetBasicAuth("ollama", config.ApiKey)
}
default:
req.Header.Set(authHeader, "Bearer "+config.ApiKey)
}
resp, err := client.Do(req)
if err != nil {
log.Printf("[AI_SQL] Error llamando a %s: %v", config.Provider, err)
return nil
}
defer resp.Body.Close()
var apiResult map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&apiResult); err != nil {
log.Printf("[AI_SQL] Error decodificando respuesta: %v", err)
return nil
}
// Extraer texto de diferentes formatos de respuesta
var suggestion string
if config.Provider == "anthropic" {
if content, ok := apiResult["content"].([]interface{}); ok && len(content) > 0 {
if first, ok := content[0].(map[string]interface{}); ok {
if text, ok := first["text"].(string); ok {
suggestion = text
}
}
}
} else {
if choices, ok := apiResult["choices"].([]interface{}); ok && len(choices) > 0 {
if first, ok := choices[0].(map[string]interface{}); ok {
if msg, ok := first["message"].(map[string]interface{}); ok {
if content, ok := msg["content"].(string); ok {
suggestion = content
}
}
}
}
}
// Limpiar bloques de código markdown
suggestion = strings.TrimSpace(suggestion)
suggestion = strings.TrimPrefix(suggestion, "```sql")
suggestion = strings.TrimPrefix(suggestion, "```")
suggestion = strings.TrimSuffix(suggestion, "```")
suggestion = strings.TrimSpace(suggestion)
if suggestion == "" {
return nil
}
return &fiber.Map{
"suggestion": suggestion,
"source": config.Provider,
}
}
// ── helper ───────────────────────────────────────────────────────────────────
func loadConxDb(idStr string) (models.ConxDb, error) {
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil || id == 0 {
return models.ConxDb{}, fmt.Errorf("conx_db_id inválido")
}
var conx models.ConxDb
if err := app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id).Error; err != nil {
return models.ConxDb{}, fmt.Errorf("conexión no encontrada: %w", err)
}
return conx, nil
}