OpenAI SDK TypeScript · Python Next.js 14 IA-Ingeniería-MLOps

NutriBot — Integración OpenAI SDK

Asistente veterinario-nutricional con streaming, function calling, vision y embeddings semánticos

Cliente: NutriPaw · Stack Next.js 14 + FastAPI + pgvector
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()
API Route
app/api/chat
StreamingTextResponse
NutriBot
lib/nutribot.ts
gpt-4o + tools
Tools
PostgreSQL
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.