★
Capacidades implementadas
4 integraciones OpenAI
Chat con streaming
Respuestas en tiempo real vía SSE para consultas nutricionales. Temperatura 0.6 para conversación natural.
gpt-4o
Function Calling
GPT consulta el perfil de la mascota, historial y restricciones en PostgreSQL antes de responder.
tools: auto
Vision — Análisis envase
El dueño sube foto del alimento. GPT-4o extrae ingredientes y valora compatibilidad con la mascota.
gpt-4o vision
Embeddings semánticos
Base de conocimiento veterinaria indexada. Búsqueda por similitud coseno en pgvector.
text-embedding-3-small
A
Arquitectura del flujo
Frontend
Next.js 14
useChat()
useChat()
→
API Route
app/api/chat
StreamingTextResponse
StreamingTextResponse
→
NutriBot
lib/nutribot.ts
gpt-4o + tools
gpt-4o + tools
→
Tools
PostgreSQL
pgvector
pgvector
Flujo tool loop: El usuario pregunta → GPT decide si llamar herramientas → ejecutamos la función → enviamos resultado → GPT genera respuesta final con streaming.
1
Cliente OpenAI singleton
lib/openai.ts
lib/openai.ts
TypeScript
openai · npm i openai
// lib/openai.ts — Cliente OpenAI singleton para NutriPaw import OpenAI from 'openai' // Singleton: un único cliente reutilizado en toda la app const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, // definida en .env.local maxRetries: 3, // reintentos automáticos timeout: 30_000, // 30s timeout }) export default openai // Modelos utilizados en NutriBot export const MODELS = { chat: 'gpt-4o' as const, // chat + vision + function calling embed: 'text-embedding-3-small' as const, // 1536 dims, coste mínimo } as const // Prompts del sistema por modo export const SYSTEM_PROMPTS = { chat: `Eres NutriBot, nutricionista veterinario especializado en alimentación de perros y gatos. Respondes siempre en español, con tono cercano y experto. Antes de dar recomendaciones, consulta el perfil de la mascota con get_pet_profile para conocer especie, edad, peso y alergias. Si el dueño menciona un alimento, usa search_knowledge_base para verificar su idoneidad antes de opinar.`, vision: `Analiza el envase de alimento para mascotas de la imagen. Extrae: nombre del producto, ingredientes principales (top 5), porcentajes proteína/grasa/fibra si son visibles, y evalúa la calidad nutricional del 1 al 10. Responde en JSON estructurado.`, }
2
NutriBot — Chat + Function Calling
lib/nutribot.ts
lib/nutribot.ts
TypeScript
function calling · tool loop
import openai, { MODELS, SYSTEM_PROMPTS } from './openai' import type OpenAI from 'openai' import { db } from './db' // PostgreSQL client import { getEmbedding } from './embeddings' // ── Definición de herramientas (tools) ────────────────────────── const TOOLS: OpenAI.ChatCompletionTool[] = [ { type: 'function', function: { name: 'get_pet_profile', description: 'Obtiene el perfil de la mascota: especie, raza, edad, peso, alergias y condiciones médicas', parameters: { type: 'object', properties: { pet_id: { type: 'string', description: 'ID único de la mascota' }, }, required: ['pet_id'], }, }, }, { type: 'function', function: { name: 'search_knowledge_base', description: 'Busca artículos y fichas en la base de conocimiento veterinaria por similitud semántica', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Consulta a buscar' }, limit: { type: 'number', description: 'Número de resultados (default 3)' }, }, required: ['query'], }, }, }, { type: 'function', function: { name: 'log_recommendation', description: 'Guarda la recomendación nutricional en el historial de la mascota', parameters: { type: 'object', properties: { pet_id: { type: 'string' }, recommendation: { type: 'string' }, food_name: { type: 'string' }, }, required: ['pet_id', 'recommendation'], }, }, }, ] // ── Ejecución de herramientas ─────────────────────────────────── async function executeTool(name: string, args: Record<string, unknown>): Promise<string> { switch (name) { case 'get_pet_profile': { const pet = await db.query( `SELECT * FROM pets WHERE id = $1`, [args.pet_id] ) return JSON.stringify(pet.rows[0] ?? { error: 'Mascota no encontrada' }) } case 'search_knowledge_base': { const embedding = await getEmbedding(args.query as string) const results = await db.query( `SELECT title, content, 1 - (embedding <=> $1::vector) AS score FROM knowledge_base ORDER BY embedding <=> $1::vector LIMIT $2`, [`[${embedding.join(',')}]`, args.limit ?? 3] ) return JSON.stringify(results.rows) } case 'log_recommendation': { await db.query( `INSERT INTO recommendations (pet_id, text, food_name, created_at) VALUES ($1, $2, $3, NOW())`, [args.pet_id, args.recommendation, args.food_name] ) return '{"ok": true}' } default: return '{"error": "herramienta desconocida"}' } } // ── Chat con streaming y tool loop ──────────────────────────── export async function chatStream( messages: OpenAI.ChatCompletionMessageParam[], petId: string ) { const allMessages: OpenAI.ChatCompletionMessageParam[] = [ { role: 'system', content: SYSTEM_PROMPTS.chat + `\nPet ID activo: ${petId}` }, ...messages, ] // Tool loop: GPT puede encadenar múltiples llamadas while (true) { const response = await openai.chat.completions.create({ model: MODELS.chat, messages: allMessages, tools: TOOLS, tool_choice: 'auto', temperature: 0.6, stream: false, // resolvemos tools antes de streamear }) const msg = response.choices[0].message allMessages.push(msg) // Si no hay tool calls → streamear la respuesta final if (!msg.tool_calls?.length) break // Ejecutar todas las tool calls en paralelo const toolResults = await Promise.all( msg.tool_calls.map(async (tc) => ({ role: 'tool' as const, tool_call_id: tc.id, content: await executeTool( tc.function.name, JSON.parse(tc.function.arguments) ), })) ) allMessages.push(...toolResults) } // Stream final al cliente return openai.chat.completions.create({ model: MODELS.chat, messages: allMessages, temperature: 0.6, stream: true, }) }
3
Ruta Next.js — Streaming SSE
app/api/chat/route.ts
app/api/chat/route.ts
TypeScript · Next.js 14
Edge Runtime · SSE
import { OpenAIStream, StreamingTextResponse } from 'ai' import { chatStream } from '@/lib/nutribot' import { analyzeFood } from '@/lib/vision' import type { NextRequest } from 'next/server' export const runtime = 'edge' // Edge Runtime para latencia mínima export async function POST(req: NextRequest) { const { messages, petId, imageBase64 } = await req.json() // Si el usuario sube una imagen de alimento → usar vision if (imageBase64) { const analysis = await analyzeFood(imageBase64) return Response.json({ type: 'food_analysis', data: analysis }) } // Chat normal con streaming const stream = await chatStream(messages, petId) return new StreamingTextResponse(OpenAIStream(stream)) }
4
Vision — Análisis de envase
lib/vision.ts + salida JSON estructurada
lib/vision.ts
output.json (ejemplo)
TypeScript · Structured Outputs
response_format: json_schema
import openai, { MODELS, SYSTEM_PROMPTS } from './openai' const FOOD_SCHEMA: OpenAI.ResponseFormatJSONSchema = { type: 'json_schema', json_schema: { name: 'food_analysis', strict: true, schema: { type: 'object', properties: { product_name: { type: 'string' }, brand: { type: 'string' }, ingredients: { type: 'array', items: { type: 'string' } }, protein_pct: { type: 'number' }, fat_pct: { type: 'number' }, fiber_pct: { type: 'number' }, quality_score: { type: 'number', description: '1-10' }, verdict: { type: 'string', enum: ['recomendado', 'aceptable', 'evitar'] }, notes: { type: 'string' }, }, required: ['product_name', 'brand', 'ingredients', 'quality_score', 'verdict', 'notes'], additionalProperties: false, }, }, } export async function analyzeFood(base64Image: string) { const response = await openai.chat.completions.create({ model: MODELS.chat, messages: [{ role: 'user', content: [ { type: 'text', text: SYSTEM_PROMPTS.vision }, { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${base64Image}` }, }, ], }], temperature: 0, // determinístico para extracción response_format: FOOD_SCHEMA, // garantiza JSON válido }) return JSON.parse(response.choices[0].message.content!) as { product_name: string; brand: string; ingredients: string[] protein_pct: number; fat_pct: number; fiber_pct: number quality_score: number; verdict: 'recomendado' | 'aceptable' | 'evitar'; notes: string } } /* Ejemplo de salida para "Royal Canin Maxi Adult" ────────────── { "product_name": "Maxi Adult", "brand": "Royal Canin", "ingredients": ["maíz","pollo deshidratado","harina de arroz","gluten de maíz","grasa animal"], "protein_pct": 25, "fat_pct": 14, "fiber_pct": 2.4, "quality_score": 7, "verdict": "aceptable", "notes": "Fuente proteica correcta aunque con cereales como primer ingrediente. Adecuado para perros adultos sin restricciones específicas. Evitar en casos de intolerancia al gluten." } ─────────────────────────────────────────────────────────────── */
5
Pipeline de embeddings — Python + pgvector
python/embeddings_pipeline.py
python/embeddings_pipeline.py
Python · text-embedding-3-small
pip install openai psycopg2-binary pgvector
"""Pipeline para indexar la base de conocimiento veterinaria de NutriPaw.""" import os, json from openai import OpenAI import psycopg2 from pgvector.psycopg2 import register_vector client = OpenAI() # lee OPENAI_API_KEY del entorno MODEL = "text-embedding-3-small" # 1536 dims · $0.02 / 1M tokens # ── Conexión PostgreSQL ───────────────────────────────────────── conn = psycopg2.connect(os.environ["DATABASE_URL"]) register_vector(conn) cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS knowledge_base ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, source TEXT, embedding vector(1536) ) """) conn.commit() # ── Función: generar embedding ────────────────────────────────── def get_embedding(text: str) -> list[float]: resp = client.embeddings.create(model=MODEL, input=text) return resp.data[0].embedding # ── Función: batch insert de artículos ───────────────────────── def index_articles(articles: list[dict]) -> None: for batch in [articles[i:i+100] for i in range(0, len(articles), 100)]: # Batch embeddings (hasta 2048 inputs por llamada) texts = [f"{a['title']} {a['content']}" for a in batch] resp = client.embeddings.create(model=MODEL, input=texts) for article, emb_obj in zip(batch, resp.data): cur.execute( """INSERT INTO knowledge_base (title, content, source, embedding) VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING""", (article["title"], article["content"], article.get("source"), emb_obj.embedding) ) conn.commit() print(f"Indexados {len(batch)} artículos · {resp.usage.total_tokens} tokens") # ── Función: búsqueda semántica ──────────────────────────────── def semantic_search(query: str, limit: int = 3) -> list[dict]: q_emb = get_embedding(query) cur.execute(""" SELECT title, content, 1 - (embedding <=> %s::vector) AS score FROM knowledge_base ORDER BY embedding <=> %s::vector LIMIT %s """, (q_emb, q_emb, limit)) return [ {"title": r[0], "content": r[1], "score": round(r[2], 4)} for r in cur.fetchall() ] # ── Ejemplo de uso ────────────────────────────────────────────── if __name__ == "__main__": # Cargar artículos desde JSON exportado del CMS articles = json.load(open("data/vet_articles.json")) index_articles(articles) # Test de búsqueda results = semantic_search("alimentos tóxicos para perros") for r in results: print(f"[{r['score']:.2%}] {r['title']}") # → [97.3%] Alimentos prohibidos en caninos: uvas, chocolate, cebolla # → [94.1%] Toxicología veterinaria: primeros auxilios en intoxicaciones # → [91.8%] Xilitol: el edulcorante peligroso para perros
€
Estimación de costes — 1.000 consultas/mes
Precios OpenAI · junio 2026
| Endpoint | Modelo | Tokens est. | Precio | Coste/mes |
|---|---|---|---|---|
| Chat + function calling | gpt-4o | ~800 input + 400 output | $2.50 / $10 · 1M tok | ~$4.20 |
| Vision análisis envase | gpt-4o vision | ~1.800 (imagen+texto) | $2.50 input · 1M tok | ~$1.80 |
| Embeddings (queries) | text-embedding-3-small | ~200 tok/query | $0.02 / 1M tok | ~$0.004 |
| Embeddings (indexación, 1× inicial) | text-embedding-3-small | ~500.000 tok | $0.02 / 1M tok | $0.01 |
| TOTAL estimado | — | — | — | ~$6 / mes |
Tip de optimización: Usa
gpt-4o-mini para las consultas de bajo riesgo (FAQs genéricas) y reserva gpt-4o para análisis nutricional y function calling. Esto reduce el coste un 60% manteniendo la calidad donde importa.