Canal de IA (Gemini)¶
Un canal REST sin SDK: chat, voz y traducción en directo, con herramientas que se ejecutan en el servidor (function calling).
Visión general¶
Navegador (/me/ai, /me/translate)
│ fetch del mismo origen (cabecera CSRF)
▼
BFF de Next.js /api/ai/{chat,stt,tts,translate} ← valida la forma, adjunta el JWT
│ servidor→servidor
▼
API Go /api/v1/ai/* (JWT + límite ~20/min)
│
▼
usecases/ai ──────────► pkg/gemini ──────► API REST de Gemini
│ ▲ (3 reintentos con backoff ante 429/5xx/red)
│ └─ functionResponse
▼
ToolDef.Execute() ← se ejecuta EN EL BACKEND con el contexto de la petición
├─ search_knowledge → tabla ai_knowledge
└─ get_server_time → herramienta de demostración
Principio clave
El modelo decide qué herramienta llamar; el backend la ejecuta. El modelo nunca ejecuta código. Las herramientas corren con el contexto de la petición, así que la RLS y los tiempos de espera se aplican a todo lo que tocan.
Flujo de chat (bucle de function calling)¶
- Construir
contentsa partir del historial (≤ 20 turnos) más la nueva consigna. Un mensaje de voz llega como parte de audio base64 en línea: Gemini lo entiende directamente, así que no hace falta un paso STT aparte. - Llamar a Gemini con la instrucción de sistema por capas y las declaraciones de herramientas.
- Si la respuesta contiene llamadas a función: ejecutar cada herramienta,
añadir el turno del modelo más un turno
functionResponsey repetir (hastaMaxSteps, 4 por defecto). Cada llamada ejecutada se registra comoStep{Tool, Args, Result}y se devuelve al cliente, de modo que la interfaz puede mostrar «qué hizo la IA». - Si la respuesta es texto: devolverlo.
Semántica de los fallos¶
| Caso | Resultado |
|---|---|
| Fallo transitorio de Gemini (tras los 3 reintentos del cliente) | No es un 5xx: una respuesta de reserva en el idioma del usuario con degraded: true |
Falta GEMINI_API_KEY |
Un error real: 500, con la causa registrada |
| Herramienta desconocida o fallida | Se comunica al modelo como {"error": …}; nunca llega directamente al cliente |
No cambies este comportamiento
El chat debe degradarse con elegancia a la respuesta de reserva ante fallos transitorios de Gemini: no lo conviertas en un 5xx.
Capas de la consigna¶
La consigna de sistema se ensambla en cada petición a partir de tres capas:
- Salvaguardas fijadas en el código: inmutables, nunca configurables.
- Ámbito: de la tabla
ai_prompts, editable por los administradores. - Instrucciones: también configuradas desde la base de datos.
Idioma de respuesta
El frontend envía su idioma de interfaz
(mn/en/ar/zh/fr/ru/es) en el campo lang y el asistente
responde solo en él: ni el idioma en que escribió la persona, ni el
historial, ni la base de conocimiento, ni los resultados de las herramientas
lo anulan (las fuentes en otros idiomas se traducen). La respuesta de reserva
degraded se localiza igual.
Nunca hagas configurable la capa de salvaguardas
Esa capa pertenece únicamente al código. Solo scope e instructions se
gobiernan desde la base de datos.
Añadir una herramienta¶
ai.ToolDef{
Declaration: gemini.FunctionDeclaration{
Name: "my_tool",
Description: "Cuándo debe llamarla el modelo…",
Parameters: map[string]any{ /* JSON Schema */ },
},
Execute: func(ctx context.Context, args map[string]any) (map[string]any, error) {
// se ejecuta en el backend; ctx lleva la identidad de la petición (se aplica RLS)
return map[string]any{"result": "…"}, nil
},
}
Regístrala en cmd/api/server/server.go:
Herramientas incluidas¶
Búsqueda semántica (RAG)
El conocimiento de la plataforma vive en ai_knowledge como unos 58
fragmentos. Las preguntas se vectorizan con Gemini y se emparejan por
similitud del coseno en pgvector, de modo que una pregunta formulada de otra
manera encuentra igualmente el fragmento correcto. Los vectores se calculan
automáticamente al arrancar; Administración → Ajustes ofrece un botón de
reindexación manual.
search_knowledge: búsqueda semántica sobreai_knowledge. La pregunta se vectoriza, se recuperan los 8 mejores resultados de pgvector y se filtran respecto al mejor (se descarta todo lo que quede más de 0,03 por debajo; quedan de 2 a 4). Aquí un umbral fijo no funciona: incluso fragmentos sin relación alcanzan 0,64 de similitud en este corpus. Cuando los vectores no están disponibles, recurre aILIKE, divide la pregunta en palabras y busca las más largas por raíz. Las salvaguardas de base indican al modelo que la llame antes de responder preguntas sobre la plataforma y que diga «no lo sé» en lugar de adivinar cuando no encuentra nada. Amplía el corpus insertando filas: las nuevas se vectorizan automáticamente.get_server_time: una demostración mínima (hora de Ulán Bator), sin dependencias.
Chat público en la portada (sin acceso)
Un widget flotante en la esquina inferior derecha llama a
POST /public/ai/chat sin token. Se ejecuta en una instancia de caso de uso
aparte, cableada solo con la herramienta de base de conocimiento, así que no
puede alcanzar datos de usuario. Unas 6 peticiones/min por IP, mensaje ≤ 1000
caracteres, historial ≤ 6 turnos; la consigna de sistema recibe una
salvaguarda adicional de «visitante anónimo». El widget habla con
POST /public/ai/chat/stream (SSE), de modo que la respuesta aparece según
se escribe. Pulsar para hablar (mantener el botón redondo grande) envía un
clip base64 de unos 250 KB (≈ 15 s) y una sola llamada al modelo devuelve
tanto la transcripción (evento transcript) como la respuesta. Las preguntas
por voz se contestan en voz alta frase a frase mediante
POST /public/ai/tts, así que el habla comienza tras la primera frase.
Voz¶
| Capacidad | Endpoint | Cómo funciona |
|---|---|---|
| Mensaje de chat por voz | POST /ai/chat con audio |
El audio entra directamente en el turno del usuario como dato en línea: el modelo de chat es multimodal |
| Voz a texto | POST /ai/stt |
Llamada única con una instrucción estricta de «transcribe literalmente»; un texto vacío significa que no hubo habla |
| Texto a voz | POST /ai/tts |
Un modelo TTS aparte con responseModalities: ["AUDIO"]; el PCM en bruto (L16/24 kHz) se envuelve en una cabecera WAV para que los navegadores lo reproduzcan |
| Traducción en directo | POST /ai/translate |
Texto → traducir; audio → dos pasos, STT y luego traducción; speak: true añade una síntesis TTS (si el TTS falla, se degrada en silencio: el texto igualmente se devuelve) |
La entrada de audio se filtra por tipo MIME (webm/ogg/wav/mpeg/mp3/mp4/m4a/aac/flac) y se limita a unos 700 KB en base64 (~30 s de opus), tanto en el BFF como en el DTO del backend.
Detalle de la traducción en directo
El micrófono graba segmentos de unos 7 s usando un MediaRecorder nuevo
por segmento: los fragmentos de un timeslice solo llevan la cabecera del
contenedor en el primero, y eso es lo que hace que cada segmento sea válido
por separado. Los segmentos en silencio devuelven campos vacíos y se
descartan en lugar de elevarse como errores.
Limitación de tasa¶
/ai/* está limitado a unas 20 peticiones por minuto y por IP. La traducción
en directo emite cerca de 8 fragmentos por minuto, así que baja ese límite con
cuidado.
El detalle completo está en el repositorio:
backend/docs/AI_PIPELINE.md.