Saltar al contenido
Lección 18 de 22

Proyecto: Funcionalidades Potenciadas por IA

17 min read

Funcionalidades de IA en tus Apps

Hay una distinción crucial que muchas personas pasan por alto: usar IA para construir software y construir software que usa IA son dos habilidades diferentes. A lo largo de este bootcamp, has dominado la primera habilidad --- escribir prompts que generan código, iterar con IA para construir funcionalidades, depurar con asistencia de IA. Ahora vamos a aprender la segunda habilidad: integrar capacidades de IA en las aplicaciones con las que interactúan tus usuarios.

FlowTask está a punto de volverse mucho más inteligente. Esto es lo que vamos a agregar:

  • Un chatbot que ayuda a los usuarios a gestionar tareas a través de conversación
  • Resumen de texto que condensa descripciones largas de tareas y notas
  • Búsqueda semántica que encuentra tareas por significado, no solo por coincidencia de palabras clave
  • Generación de contenido que redacta descripciones de tareas, resúmenes de proyectos y borradores de emails
  • Validación de formularios con IA que va más allá de los patrones regex

Cada una de estas funcionalidades sigue la misma arquitectura: tu aplicación envía una solicitud a una API de IA, recibe una respuesta y la presenta al usuario. La complejidad está en los detalles --- streaming, caching, gestión de contexto y control de costos.

Fundamentos de la API de IA

Antes de construir funcionalidades, entendamos cómo funcionan las APIs de IA.

Tu Primera Llamada a la API

La API de Anthropic (Claude) y la API de OpenAI (GPT) siguen patrones similares. Aquí tienes una llamada básica usando el SDK de Anthropic:

import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

const response = await anthropic.messages.create({
  model: "claude-sonnet-4-20250514",
  max_tokens: 1024,
  messages: [
    { role: "user", content: "Summarize this text in one sentence: ..." },
  ],
});

console.log(response.content[0].text);

Y el equivalente con OpenAI:

import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [
    { role: "user", content: "Summarize this text in one sentence: ..." },
  ],
});

console.log(response.choices[0].message.content);

Estructura de Solicitud/Respuesta

Cada llamada a la API de IA incluye:

  • Model: Qué modelo usar (afecta calidad, velocidad y costo)
  • Messages: El historial de conversación (mensajes de system, user y assistant)
  • Max tokens: Limita la longitud de la respuesta
  • Temperature: Controla la aleatoriedad (0 = determinístico, 1 = creativo)

La respuesta incluye el texto generado, estadísticas de uso (tokens consumidos) y metadatos.

Modelos y Precios

La selección del modelo es un equilibrio entre calidad, velocidad y costo:

| Modelo | Ideal Para | Velocidad | Costo | |--------|-----------|-----------|-------| | Claude Haiku / GPT-4o Mini | Tareas simples, clasificación | Rápido | Bajo | | Claude Sonnet / GPT-4o | La mayoría de funcionalidades | Medio | Medio | | Claude Opus / o1 | Razonamiento complejo | Más lento | Mayor |

Para FlowTask, usaremos un modelo de nivel medio para la mayoría de funcionalidades y un modelo más pequeño para tareas simples como validación. Esto mantiene los costos manejables mientras se conserva la calidad.

Configuración del Entorno

Agrega tu API key a .env.local:

ANTHROPIC_API_KEY=sk-ant-your-key-here

Crea un cliente de IA compartido:

// lib/ai.ts
import Anthropic from "@anthropic-ai/sdk";

export const ai = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

Construyendo un Componente de Chatbot

Un chatbot es la funcionalidad de IA más visible que puedes agregar. Los usuarios escriben en lenguaje natural y la IA responde con acciones útiles.

El Prompt

Build a chatbot for FlowTask that helps users manage their tasks through
conversation. It should:

1. Chat UI component:
   - Floating button in the bottom-right corner (chat bubble icon)
   - Clicking it opens a chat panel (400px wide, 500px tall)
   - Message list showing user and assistant messages with different
     styling (user = right-aligned blue, assistant = left-aligned gray)
   - Text input at the bottom with a send button
   - Typing indicator (three animated dots) while waiting for response
   - Close button to minimize the chat

2. Chat API route at /api/chat:
   - Accepts messages array in the request body
   - Sends to Claude API with a system prompt that knows about FlowTask
   - Streams the response back to the client
   - The system prompt should include the user's recent tasks and projects
     as context

3. System prompt:
   "You are FlowTask Assistant, a helpful AI that helps users manage
   their tasks and projects. You can answer questions about their tasks,
   suggest prioritization, and provide productivity tips. The user's
   current data is provided below."

4. Include the user's task summary in the system prompt: total tasks,
   overdue count, list of project names.

Streaming de Respuestas

El streaming es crítico para la experiencia del chatbot. Sin streaming, los usuarios miran una pantalla en blanco durante varios segundos mientras se genera la respuesta completa. Con streaming, ven las palabras aparecer en tiempo real.

La API route transmite la respuesta:

// app/api/chat/route.ts
import { ai } from "@/lib/ai";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

export async function POST(request: NextRequest) {
  const user = await getCurrentUser();
  if (!user) return new NextResponse("Unauthorized", { status: 401 });

  const { messages } = await request.json();

  // Obtener contexto del usuario
  const taskCount = await prisma.task.count({
    where: { project: { userId: user.id } },
  });
  const overdueCount = await prisma.task.count({
    where: {
      project: { userId: user.id },
      status: { not: "DONE" },
      dueDate: { lt: new Date() },
    },
  });
  const projects = await prisma.project.findMany({
    where: { userId: user.id },
    select: { name: true },
  });

  const systemPrompt = `You are FlowTask Assistant. The user has ${taskCount} total tasks, ${overdueCount} overdue. Their projects: ${projects.map((p) => p.name).join(", ")}. Help them manage their work effectively.`;

  const stream = await ai.messages.stream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: systemPrompt,
    messages,
  });

  // Devolver como ReadableStream
  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      for await (const event of stream) {
        if (
          event.type === "content_block_delta" &&
          event.delta.type === "text_delta"
        ) {
          controller.enqueue(encoder.encode(event.delta.text));
        }
      }
      controller.close();
    },
  });

  return new NextResponse(readable, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Consumiendo el Stream en el Cliente

El componente React lee el stream y actualiza la interfaz token por token:

const sendMessage = async (content: string) => {
  const newMessages = [...messages, { role: "user", content }];
  setMessages(newMessages);
  setIsStreaming(true);

  const response = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages: newMessages }),
  });

  const reader = response.body?.getReader();
  const decoder = new TextDecoder();
  let assistantMessage = "";

  while (reader) {
    const { done, value } = await reader.read();
    if (done) break;
    assistantMessage += decoder.decode(value);
    setMessages([
      ...newMessages,
      { role: "assistant", content: assistantMessage },
    ]);
  }

  setIsStreaming(false);
};

Historial de Conversación

El chatbot mantiene el contexto enviando el historial completo de mensajes con cada solicitud. Pero hay un límite --- los modelos tienen una ventana de contexto (número de tokens que pueden procesar). Cuando la conversación se vuelve demasiado larga, necesitas recortar los mensajes más antiguos:

function trimMessages(
  messages: Message[],
  maxTokens: number = 4000
): Message[] {
  // Estimacion aproximada: 1 token ~ 4 caracteres
  let totalChars = messages.reduce((sum, m) => sum + m.content.length, 0);

  while (totalChars > maxTokens * 4 && messages.length > 2) {
    const removed = messages.shift();
    totalChars -= removed!.content.length;
  }

  return messages;
}

Conserva el system prompt y los mensajes más recientes. Descarta los intercambios más antiguos del medio de la conversación.

Funcionalidad de Resumen de Texto

Las descripciones largas de tareas y las notas de proyectos son comunes. El resumen las hace digeribles.

El Prompt

Add a "Summarize" button to task descriptions that are longer than 200
characters. When clicked:

1. Send the description to the Claude API with the prompt: "Summarize
   this task description in 2-3 bullet points. Be concise."
2. Show a loading spinner while processing
3. Display the summary below the original description in a highlighted box
4. Cache the summary in a new field on the Task model (summary: String?)
   so the same description isn't summarized twice
5. Add a "Regenerate" button to create a new summary

El Patrón de la API

Cada solicitud de resumen sigue este patrón:

export async function summarizeText(text: string): Promise<string> {
  const response = await ai.messages.create({
    model: "claude-haiku-4-20250514",
    max_tokens: 256,
    messages: [
      {
        role: "user",
        content: `Summarize this in 2-3 concise bullet points:\n\n${text}`,
      },
    ],
  });

  return response.content[0].text;
}

Observa que usamos el modelo más pequeño y económico aquí. El resumen no requiere razonamiento avanzado --- un modelo rápido y barato lo maneja perfectamente. Esta es una estrategia clave de optimización de costos.

Cacheando Resultados

El caching evita pagar por el mismo resumen dos veces:

async function getOrCreateSummary(taskId: string): Promise<string> {
  const task = await prisma.task.findUnique({
    where: { id: taskId },
    select: { description: true, summary: true },
  });

  if (task?.summary) return task.summary;

  const summary = await summarizeText(task!.description!);

  await prisma.task.update({
    where: { id: taskId },
    data: { summary },
  });

  return summary;
}

Cuando la descripción cambia, invalida el caché estableciendo summary a null en el handler de actualización.

Búsqueda Semántica con Embeddings

La búsqueda por palabras clave encuentra tareas que contienen palabras específicas. La búsqueda semántica encuentra tareas con significado similar. La diferencia es enorme.

Una búsqueda por palabras clave de "deploy the website" no encuentra una tarea titulada "push the app to production." La búsqueda semántica sí la encuentra porque el significado es similar.

Qué son los Embeddings

Un embedding es una lista de números (un vector) que representa el significado de un texto. Significados similares producen vectores similares. El modelo de IA convierte texto en estos vectores, y luego comparas vectores usando distancia matemática.

Piensa en ello como coordenadas en un mapa. "Deploy the website" y "push the app to production" terminan cerca uno del otro en el mapa de significados, aunque no comparten ninguna palabra.

Generando Embeddings

// lib/embeddings.ts
import OpenAI from "openai";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function generateEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });

  return response.data[0].embedding;
}

Usamos el modelo de embeddings de OpenAI aquí porque es ampliamente soportado y costo-efectivo. El vector resultante tiene 1536 dimensiones.

Almacenando Embeddings

Para una implementación simple, almacena los embeddings como JSON en tu base de datos:

Add an embedding field to the Task model as a JSON column. Create a
function that generates and stores embeddings for task titles when tasks
are created or updated. Create a search endpoint that:

1. Takes a search query
2. Generates an embedding for the query
3. Computes cosine similarity against all task embeddings
4. Returns the top 10 most similar tasks

For production, mention that pgvector is the recommended solution.

Similitud del Coseno

La similitud del coseno mide cuán similares son dos vectores. Un valor de 1.0 significa significado idéntico, 0.0 significa completamente no relacionado:

function cosineSimilarity(a: number[], b: number[]): number {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }

  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

El Flujo de Búsqueda

export async function semanticSearch(
  query: string,
  userId: string
): Promise<Task[]> {
  const queryEmbedding = await generateEmbedding(query);

  const tasks = await prisma.task.findMany({
    where: {
      project: { userId },
      embedding: { not: null },
    },
    select: {
      id: true,
      title: true,
      status: true,
      embedding: true,
    },
  });

  const scored = tasks
    .map((task) => ({
      ...task,
      score: cosineSimilarity(queryEmbedding, task.embedding as number[]),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 10);

  return scored;
}

Para producción con miles de tareas, usarías PostgreSQL con la extensión pgvector, que maneja la búsqueda de similitud a nivel de base de datos con indexación adecuada. El prompt para migrar:

Replace the in-memory cosine similarity search with pgvector. Add a vector
column to the Task model, create an ivfflat index, and use the <=> operator
for cosine distance in a raw SQL query.

Generación de Contenido

La IA puede redactar contenido que los usuarios luego refinan. Esto ahorra un tiempo enorme en tareas de escritura repetitivas.

El Prompt

Add content generation features to FlowTask:

1. "Generate Description" button when creating a task: user enters a title,
   clicks the button, and AI generates a detailed task description with
   acceptance criteria.

2. "Generate README" button on the project page: AI analyzes all tasks in
   the project and generates a project overview document.

3. "Draft Email" button on completed tasks: AI drafts a status update email
   to stakeholders based on the task details and completion notes.

Each generated piece of content should appear in an editable text area so
the user can modify it before saving. Include a "Regenerate" button for
each.

Generación con Plantillas

La clave para una generación de contenido útil son buenos prompts con variables de contexto:

export async function generateTaskDescription(
  title: string,
  projectName: string
): Promise<string> {
  const response = await ai.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 512,
    messages: [
      {
        role: "user",
        content: `Generate a detailed task description for a task titled "${title}" in the project "${projectName}". Include:
- A clear objective (1-2 sentences)
- 3-5 acceptance criteria as a checklist
- Any relevant technical notes

Keep it concise and actionable.`,
      },
    ],
  });

  return response.content[0].text;
}

El contenido generado es un punto de partida, no un producto final. Siempre preséntalo en un campo editable para que los usuarios puedan ajustarlo. Esto construye confianza --- los usuarios sienten que tienen el control en lugar de ser reemplazados.

Validación de Formularios con IA

La validación tradicional verifica formato: ¿es este un email válido? ¿Está este campo vacío? La validación con IA verifica significado: ¿este título de tarea realmente describe una tarea? ¿Esta descripción es lo suficientemente clara para ser accionable?

El Prompt

Add AI-powered validation to the task creation form:

1. When the user blurs the title field, send it to a validation endpoint
2. The AI checks: is this a clear, actionable task title? If not, suggest
   a better version.
3. Display the suggestion below the field as a clickable hint:
   "Suggestion: [improved title]" — clicking it replaces the current title
4. Use the smallest model available (Haiku) for speed and cost
5. Debounce the validation to avoid excessive API calls (500ms delay)
6. Show a subtle loading indicator while validating

El Patrón de Validación

export async function validateTaskTitle(title: string): Promise<{
  isValid: boolean;
  suggestion?: string;
}> {
  if (title.length < 5) return { isValid: false };

  const response = await ai.messages.create({
    model: "claude-haiku-4-20250514",
    max_tokens: 100,
    messages: [
      {
        role: "user",
        content: `Is this a clear, actionable task title? "${title}"
Reply with JSON: {"isValid": true/false, "suggestion": "improved version or null"}
Only suggest improvements if the title is vague or unclear.`,
      },
    ],
  });

  return JSON.parse(response.content[0].text);
}

Esta es una funcionalidad ligera pero impresionante. Los usuarios notan cuando una app da sugerencias inteligentes, y cuesta fracciones de centavo por validación.

Streaming de Respuestas en Detalle

El streaming merece una mirada más profunda porque impacta la experiencia de usuario de cada funcionalidad de IA.

Por Qué Importa el Streaming

Sin streaming, el flujo es: el usuario hace click, espera 3-5 segundos sin ver nada, luego la respuesta completa aparece de golpe. Con streaming, el flujo es: el usuario hace click, las primeras palabras aparecen en 200ms, el resto fluye durante 2-3 segundos. La latencia percibida se reduce dramáticamente.

El Patrón de Server-Sent Events

Para funcionalidades más allá del chatbot, puedes usar Server-Sent Events (SSE):

// app/api/generate/route.ts
export async function POST(request: NextRequest) {
  const { prompt } = await request.json();

  const stream = await ai.messages.stream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    messages: [{ role: "user", content: prompt }],
  });

  const encoder = new TextEncoder();

  const readable = new ReadableStream({
    async start(controller) {
      for await (const event of stream) {
        if (
          event.type === "content_block_delta" &&
          event.delta.type === "text_delta"
        ) {
          const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`;
          controller.enqueue(encoder.encode(data));
        }
      }
      controller.enqueue(encoder.encode("data: [DONE]\n\n"));
      controller.close();
    },
  });

  return new NextResponse(readable, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Manejo de Errores en Streams

Los streams pueden fallar a mitad de la entrega. Manéjalos de forma elegante:

const consumeStream = async (response: Response) => {
  const reader = response.body?.getReader();
  const decoder = new TextDecoder();

  try {
    while (reader) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value);
      const lines = chunk.split("\n").filter((line) => line.startsWith("data: "));

      for (const line of lines) {
        const data = line.slice(6); // Remover "data: "
        if (data === "[DONE]") return;

        const parsed = JSON.parse(data);
        setText((prev) => prev + parsed.text);
      }
    }
  } catch (error) {
    setError("Connection lost. Please try again.");
  } finally {
    reader?.releaseLock();
  }
};

Gestión de Costos y Optimización de Tokens

Las llamadas a APIs de IA cuestan dinero. Sin una gestión cuidadosa, los costos pueden dispararse rápidamente.

Entendiendo el Precio por Token

Los tokens son las unidades de texto que procesan los modelos de IA. Aproximadamente, un token equivale a unos cuatro caracteres en inglés o tres cuartas partes de una palabra. Tanto los tokens de entrada (lo que envías) como los tokens de salida (lo que genera el modelo) tienen costo, siendo los tokens de salida típicamente más caros.

Estrategias para Control de Costos

Usa el modelo más pequeño que funcione. El resumen, la clasificación y la extracción simple no necesitan el modelo más poderoso. Usa Haiku o GPT-4o Mini para estas tareas y reserva Sonnet o GPT-4o para razonamiento complejo.

Cachea agresivamente. Si la misma entrada produce la misma salida, cachéala. El resumen de descripciones de tareas es un candidato perfecto --- la descripción rara vez cambia, así que el resumen puede almacenarse.

Trunca el contexto. No envíes todo el historial de tareas del usuario como contexto si la pregunta solo requiere datos recientes. Recorta a lo relevante:

function buildContext(tasks: Task[], maxChars: number = 2000): string {
  let context = "";
  for (const task of tasks) {
    const line = `- ${task.title} (${task.status})\n`;
    if (context.length + line.length > maxChars) break;
    context += line;
  }
  return context;
}

Establece max_tokens apropiadamente. Un resumen que debería producir 2-3 oraciones no necesita max_tokens: 4096. Establécelo en 256 o menos. Esto limita tanto el costo como el tiempo de respuesta.

Monitorea el uso. Rastrea el gasto de API por funcionalidad:

export async function trackUsage(
  feature: string,
  inputTokens: number,
  outputTokens: number
) {
  await prisma.apiUsage.create({
    data: {
      feature,
      inputTokens,
      outputTokens,
      estimatedCost:
        inputTokens * 0.000003 + outputTokens * 0.000015, // Tarifas de ejemplo
      timestamp: new Date(),
    },
  });
}

Construye un dashboard simple mostrando costos diarios por funcionalidad. Esta visibilidad te ayuda a identificar qué funcionalidades son caras y dónde optimizar.

Estableciendo Presupuestos

Implementa un límite de gasto fijo:

async function checkBudget(userId: string): Promise<boolean> {
  const monthStart = new Date();
  monthStart.setDate(1);
  monthStart.setHours(0, 0, 0, 0);

  const usage = await prisma.apiUsage.aggregate({
    where: { userId, timestamp: { gte: monthStart } },
    _sum: { estimatedCost: true },
  });

  const monthlyBudget = 50; // $50 por usuario al mes
  return (usage._sum.estimatedCost || 0) < monthlyBudget;
}

Cuando el presupuesto se excede, degrada graciosamente: deshabilita funcionalidades de IA no esenciales mientras mantienes la funcionalidad central operando.

Qué Sigue

FlowTask ahora es una aplicación inteligente. Tiene un chatbot, resumen de texto, búsqueda semántica, generación de contenido y validación inteligente --- todo potenciado por APIs de IA.

En la próxima lección, cambiamos de marcha: pasamos de construir funcionalidades a optimizar cómo construyes. Vamos a profundizar en técnicas avanzadas de vibe coding: servidores MCP que conectan tu IA a herramientas externas, Plan Mode para funcionalidades complejas, comandos slash personalizados, subagents, hooks y la construcción de tu entorno de desarrollo personal con IA. Estas técnicas harán que cada proyecto futuro sea más rápido.