Saltar al contenido
Lección 18 de 22

Proyecto: Funcionalidades Potenciadas por IA

17 min read

Funcionalidades de IA en tus Apps

Hay una distincion 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 codigo, 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 interactuan tus usuarios.

FlowTask esta a punto de volverse mucho mas inteligente. Esto es lo que vamos a agregar:

  • Un chatbot que ayuda a los usuarios a gestionar tareas a traves de conversacion
  • Resumen de texto que condensa descripciones largas de tareas y notas
  • Busqueda semantica que encuentra tareas por significado, no solo por coincidencia de palabras clave
  • Generacion de contenido que redacta descripciones de tareas, resumenes de proyectos y borradores de emails
  • Validacion de formularios con IA que va mas alla de los patrones regex

Cada una de estas funcionalidades sigue la misma arquitectura: tu aplicacion envia una solicitud a una API de IA, recibe una respuesta y la presenta al usuario. La complejidad esta en los detalles --- streaming, caching, gestion de contexto y control de costos.

Fundamentos de la API de IA

Antes de construir funcionalidades, entendamos como 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. Aqui tienes una llamada basica 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: Que modelo usar (afecta calidad, velocidad y costo)
  • Messages: El historial de conversacion (mensajes de system, user y assistant)
  • Max tokens: Limita la longitud de la respuesta
  • Temperature: Controla la aleatoriedad (0 = deterministico, 1 = creativo)

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

Modelos y Precios

La seleccion del modelo es un equilibrio entre calidad, velocidad y costo:

| Modelo | Ideal Para | Velocidad | Costo | |--------|-----------|-----------|-------| | Claude Haiku / GPT-4o Mini | Tareas simples, clasificacion | Rapido | Bajo | | Claude Sonnet / GPT-4o | La mayoria de funcionalidades | Medio | Medio | | Claude Opus / o1 | Razonamiento complejo | Mas lento | Mayor |

Para FlowTask, usaremos un modelo de nivel medio para la mayoria de funcionalidades y un modelo mas pequeno para tareas simples como validacion. Esto mantiene los costos manejables mientras se conserva la calidad.

Configuracion 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 mas visible que puedes agregar. Los usuarios escriben en lenguaje natural y la IA responde con acciones utiles.

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 critico 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 Conversacion

El chatbot mantiene el contexto enviando el historial completo de mensajes con cada solicitud. Pero hay un limite --- los modelos tienen una ventana de contexto (numero de tokens que pueden procesar). Cuando la conversacion se vuelve demasiado larga, necesitas recortar los mensajes mas 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 mas recientes. Descarta los intercambios mas antiguos del medio de la conversacion.

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 Patron de la API

Cada solicitud de resumen sigue este patron:

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 mas pequeno y economico aqui. El resumen no requiere razonamiento avanzado --- un modelo rapido y barato lo maneja perfectamente. Esta es una estrategia clave de optimizacion 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 descripcion cambia, invalida el cache estableciendo summary a null en el handler de actualizacion.

Busqueda Semantica con Embeddings

La busqueda por palabras clave encuentra tareas que contienen palabras especificas. La busqueda semantica encuentra tareas con significado similar. La diferencia es enorme.

Una busqueda por palabras clave de "deploy the website" no encuentra una tarea titulada "push the app to production." La busqueda semantica si la encuentra porque el significado es similar.

Que son los Embeddings

Un embedding es una lista de numeros (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 matematica.

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 aqui porque es ampliamente soportado y costo-efectivo. El vector resultante tiene 1536 dimensiones.

Almacenando Embeddings

Para una implementacion 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 cuan similares son dos vectores. Un valor de 1.0 significa significado identico, 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 Busqueda

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 produccion con miles de tareas, usarias PostgreSQL con la extension pgvector, que maneja la busqueda de similitud a nivel de base de datos con indexacion 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.

Generacion 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.

Generacion con Plantillas

La clave para una generacion de contenido util 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 presentalo 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.

Validacion de Formularios con IA

La validacion tradicional verifica formato: es este un email valido? Esta este campo vacio? La validacion con IA verifica significado: este titulo de tarea realmente describe una tarea? Esta descripcion 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 Patron de Validacion

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 validacion.

Streaming de Respuestas en Detalle

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

Por Que 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 dramaticamente.

El Patron de Server-Sent Events

Para funcionalidades mas alla 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. Manejalos 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();
  }
};

Gestion de Costos y Optimizacion de Tokens

Las llamadas a APIs de IA cuestan dinero. Sin una gestion cuidadosa, los costos pueden dispararse rapidamente.

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 ingles o tres cuartas partes de una palabra. Tanto los tokens de entrada (lo que envias) como los tokens de salida (lo que genera el modelo) tienen costo, siendo los tokens de salida tipicamente mas caros.

Estrategias para Control de Costos

Usa el modelo mas pequeno que funcione. El resumen, la clasificacion y la extraccion simple no necesitan el modelo mas 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, cacheala. El resumen de descripciones de tareas es un candidato perfecto --- la descripcion rara vez cambia, asi que el resumen puede almacenarse.

Trunca el contexto. No envies 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 deberia producir 2-3 oraciones no necesita max_tokens: 4096. Establecelo 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 que funcionalidades son caras y donde optimizar.

Estableciendo Presupuestos

Implementa un limite 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.

Que Sigue

FlowTask ahora es una aplicacion inteligente. Tiene un chatbot, resumen de texto, busqueda semantica, generacion de contenido y validacion inteligente --- todo potenciado por APIs de IA.

En la proxima leccion, cambiamos de marcha: pasamos de construir funcionalidades a optimizar como construyes. Vamos a profundizar en tecnicas 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 construccion de tu entorno de desarrollo personal con IA. Estas tecnicas haran que cada proyecto futuro sea mas rapido.