Files
soft_usite/rest/controllers/query_runner_controller.go
Lizandro GuarnizoandClaude Sonnet 5 96cd24f17d feat(ai-config): cifra la API key en reposo y la acota por tenant
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.

- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
  leen igual y quedan cifradas al primer guardado, sin script ni downtime.
  utils.Decrypt hace panic (no devuelve error) con entrada que no es un
  ciphertext válido, así que el fallback va sobre recover — eso mismo es
  el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
  Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
  sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
  GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:36:47 -05:00

892 lines
28 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/auth"
"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 las conexiones DB disponibles (según el rol del usuario).
// Para API calls (Hermes) sin sesión retorna todas.
func GetConxDbList(c *fiber.Ctx) error {
user, err := auth.User(c)
if err != nil || user == nil {
var all []models.ConxDb
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&all)
return c.JSON(fiber.Map{"data": all})
}
var items []models.ConxDb
if user.IsAdmin {
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&items)
} else {
ids := make([]uint, 0)
for _, db := range user.Role.ConxDBs {
ids = append(ids, db.ID)
}
if len(ids) == 0 {
return c.JSON(fiber.Map{"data": []models.ConxDb{}})
}
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Where("id IN ?", ids).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()})
}
if !usuarioPuedeUsarConx(c, conx.ID) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
}
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()})
}
if !usuarioPuedeUsarConx(c, conx.ID) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
}
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})
}
// GetRelations lista las llaves foráneas de toda una base de datos, para
// poder dibujar el diagrama de relaciones entre tablas.
// GET /app/query-runner/relations?conx_db_id=1&db=mydb
func GetRelations(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 !usuarioPuedeUsarConx(c, conx.ID) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
}
dbName := c.Query("db", "")
relaciones, err := services.GetSchemaRelations(conx, dbName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": relaciones})
}
// usuarioPuedeUsarConx verifica que el usuario autenticado pueda ejecutar SQL
// contra esa conexión: admin ve todas, el resto solo las que su rol tiene
// asignadas en Role.ConxDBs (el mismo criterio que ya usa GetConxDbList para
// decidir qué mostrar en el selector — aquí se hace cumplir de verdad).
//
// Sin este chequeo, cualquiera con acceso al submódulo Query Runner podía
// mandar cualquier conx_db_id por API y ejecutar SQL contra una conexión que
// nunca se le mostró ni se le asignó a su rol.
func usuarioPuedeUsarConx(c *fiber.Ctx, conxDbID uint) bool {
user, err := auth.User(c)
if err != nil || user == nil {
// Llamada sin sesión (Hermes/API con su propia auth): no restringido aquí.
return true
}
if user.IsAdmin {
return true
}
for _, db := range user.Role.ConxDBs {
if db.ID == conxDbID {
return true
}
}
return false
}
// 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"})
}
if !usuarioPuedeUsarConx(c, body.ConxDbID) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
}
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
uid := extractUserID(c)
result := services.ExecuteSQL(conx, body.Database, body.SQL, uid)
return c.JSON(result)
}
// RunBatchQuery ejecuta múltiples consultas en lote.
// POST /app/query-runner/run-batch
// Body: { conx_db_id, database, sqls: ["...", "..."] }
// También soporta multipart/form-data con file .sql
func RunBatchQuery(c *fiber.Ctx) error {
uid := extractUserID(c)
conxDbIDStr := c.FormValue("conx_db_id", c.Query("conx_db_id"))
database := c.FormValue("database", c.Query("database"))
// Obtener statements del body o del archivo subido
var statements []string
// 1. Intentar leer archivo .sql subido
if file, err := c.FormFile("file"); err == nil {
f, err := file.Open()
if err == nil {
defer f.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(f)
content := buf.String()
// Dividir por ;
for _, stmt := range strings.Split(content, ";") {
stmt = strings.TrimSpace(stmt)
if stmt != "" {
statements = append(statements, stmt)
}
}
}
}
// 2. Si no hay archivo, leer del body JSON
if len(statements) == 0 {
var body struct {
ConxDbID uint `json:"conx_db_id"`
Database string `json:"database"`
SQLs []string `json:"sqls"`
}
if err := c.BodyParser(&body); err == nil {
conxDbIDStr = strconv.Itoa(int(body.ConxDbID))
if body.Database != "" {
database = body.Database
}
for _, s := range body.SQLs {
s = strings.TrimSpace(s)
if s != "" {
statements = append(statements, s)
}
}
}
}
if len(statements) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "No hay consultas para ejecutar. Envía sqls[] o un archivo .sql"})
}
if conxIDNum, parseErr := strconv.ParseUint(conxDbIDStr, 10, 32); parseErr == nil {
if !usuarioPuedeUsarConx(c, uint(conxIDNum)) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
}
}
conx, err := loadConxDb(conxDbIDStr)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
type batchResult struct {
Index int `json:"index"`
SQL string `json:"sql"`
Status string `json:"status"`
Duration string `json:"duration"`
Rows int `json:"rows"`
Columns []string `json:"columns,omitempty"`
Data []fiber.Map `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
results := make([]batchResult, 0, len(statements))
for i, stmt := range statements {
r := services.ExecuteSQL(conx, database, stmt, uid)
br := batchResult{
Index: i,
SQL: stmt,
Status: "ok",
Duration: fmt.Sprintf("%dms", r.DurationMs),
Rows: r.RowCount,
}
if r.Error != "" {
br.Status = "error"
br.Error = r.Error
} else {
br.Columns = r.Columns
br.Data = make([]fiber.Map, len(r.Rows))
for j, row := range r.Rows {
br.Data[j] = fiber.Map(row)
}
}
results = append(results, br)
}
return c.JSON(fiber.Map{"results": results, "total": len(results)})
}
// 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
user, _ := auth.User(c)
var items []models.QueryHistory
var total int64
var err error
if user != nil && user.IsAdmin {
items, total, err = models.GetQueryHistoryAdmin(uint(conxID), limit, offset)
} else {
uid := uint(0)
if user != nil {
uid = user.ID
}
items, total, err = models.GetQueryHistory(uint(conxID), uid, 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)
user, _ := auth.User(c)
var err error
if user != nil && user.IsAdmin {
err = models.DeleteQueryHistoryAdmin(uint(conxID))
} else {
uid := uint(0)
if user != nil {
uid = user.ID
}
err = models.DeleteQueryHistory(uint(conxID), uid)
}
if 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()})
}
uid := extractUserID(c)
result := services.ExecuteSQL(conx, body.Database, body.SQL, uid)
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()})
}
uid := extractUserID(c)
result := services.ExecuteSQL(conx, body.Database, body.SQL, uid)
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)
uid := extractUserID(c)
result := services.ExecuteSQL(conx, body.Database, sqlText, uid)
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")
clave := config.ClaveEnClaro()
switch config.Provider {
case "anthropic":
req.Header.Set(authHeader, clave)
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 clave != "" && clave != "ollama" {
req.SetBasicAuth("ollama", clave)
}
default:
req.Header.Set(authHeader, "Bearer "+clave)
}
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
}
func extractUserID(c *fiber.Ctx) uint {
user, err := auth.User(c)
if err != nil || user == nil {
return 0
}
return user.ID
}