Módulo 1: Introducción a IA Multimodal
8. Proyecto: Clasificador Multimodal
Descripción
Este proyecto cierra el Módulo 1 integrando todo lo que aprendiste: modalidades (visión, audio), formatos (Base64, URLs), APIs, costos, rate limits y troubleshooting. Vas a construir un Clasificador Multimodal — un sistema que recibe cualquier input (texto, ruta a imagen, ruta a audio, URL), detecta la modalidad, valida el formato, recomienda el modelo y proveedor óptimo, estima el costo, y prepara el input en el formato correcto para la API.
No es un toy example. Es el patrón que usan sistemas reales de procesamiento de contenido: un router inteligente que decide qué pipeline aplicar antes de hacer la llamada a la API.
Por qué importa: En producción, los inputs llegan en cualquier formato. Un endpoint recibe una imagen JPEG, un audio MP3, o un texto plano, y debe decidir qué hacer con cada uno. Sin un clasificador previo, terminas con if/elif chains frágiles que fallan con el primer formato inesperado.
Conexión con el módulo: Cada componente del clasificador viene de una cápsula anterior:
- Detección de modalidad → Cápsulas 02 (visión), 03 (audio), 04 (combinaciones)
- Selección de modelo → Cápsula 05 (landscape de modelos)
- Validación de formato y encoding → Cápsula 06 (formatos y APIs)
- Manejo de errores → Cápsula 07 (troubleshooting)
Conexión con la guía: En módulos posteriores, este clasificador evoluciona. En el Módulo 2, el pipeline de visión usa lógica similar para decidir entre GPT-4 Vision, Claude 3 y Gemini. En el Módulo 8 (Document Analyzer), el sistema detecta tipo de documento y enruta al pipeline correcto.
Especificaciones Técnicas
Input
El clasificador acepta un único argumento input_value: str que puede ser:
| Tipo | Ejemplo | Detección |
|---|---|---|
| Texto plano | "Analiza este reporte de ventas" | No es ruta ni URL |
| Ruta a imagen | "./fotos/producto.jpg" | Archivo existe + extensión de imagen |
| Ruta a audio | "./grabaciones/llamada.mp3" | Archivo existe + extensión de audio |
| URL de imagen | "https://cdn.example.com/foto.png" | Empieza con http + extensión de imagen |
| URL de audio | "https://cdn.example.com/audio.mp3" | Empieza con http + extensión de audio |
Output
@dataclass
class ClassificationResult:
modality: str # "text" | "image" | "audio"
source_type: str # "raw" | "local_file" | "url"
recommended_model: str # "gpt-4o" | "gpt-4o-mini" | "whisper-1" | etc.
recommended_provider: str # "openai" | "anthropic" | "google"
estimated_cost_usd: float # Costo estimado de procesar este input
format_valid: bool # Si el formato pasó validación
metadata: dict # Tamaño, formato, dimensiones, duración, etc.
warnings: list[str] # Advertencias (archivo grande, formato subóptimo)
api_payload: dict | None # Payload listo para enviar a la API
Requisitos funcionales
- Detectar modalidad por extensión, URL pattern, o contenido
- Validar formato antes de clasificar (formato real, no solo extensión)
- Recomendar modelo y proveedor según modalidad, tamaño y balance costo/calidad
- Estimar costo basado en tokens de imagen o minutos de audio
- Preparar payload en el formato correcto del proveedor (Base64 para Anthropic, URL o Base64 para OpenAI)
- Manejar errores con mensajes claros (archivo no existe, formato inválido, excede límite de tamaño)
Paso 1: Constantes y Configuración
from dataclasses import dataclass, field
from pathlib import Path
import base64, re, math
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mp4", ".mpeg", ".webm", ".ogg", ".flac"}
MIME_TYPES = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
".ogg": "audio/ogg", ".flac": "audio/flac",
}
SIZE_LIMITS_MB = {
"openai": {"image": 20, "audio": 25},
"anthropic": {"image": 5, "audio": 0},
"google": {"image": 20, "audio": 0},
}
MODEL_PRICING = {
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"whisper-1": {"per_minute": 0.006},
"claude-3.5-sonnet": {"input": 3.00, "output": 15.00},
"gemini-1.5-flash": {"input": 0.075, "output": 0.30},
}
@dataclass
class ClassificationResult:
modality: str = "unknown"
source_type: str = "unknown"
recommended_model: str = ""
recommended_provider: str = ""
estimated_cost_usd: float = 0.0
format_valid: bool = False
metadata: dict = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
api_payload: dict | None = None
error: str | None = None
La separación entre constantes y lógica permite cambiar precios o formatos soportados sin tocar la lógica de clasificación.
Paso 2: Detectar Modalidad
La detección sigue un orden de prioridad: primero verifica si es URL, luego si es archivo local, y como fallback asume texto.
def detect_modality(input_value: str) -> tuple[str, str]:
"""Detecta modalidad y tipo de source.
Returns:
(modality, source_type) donde modality es "text"|"image"|"audio"
y source_type es "raw"|"local_file"|"url"
"""
if re.match(r"https?://", input_value):
ext = _extract_extension_from_url(input_value)
if ext in IMAGE_EXTENSIONS:
return "image", "url"
if ext in AUDIO_EXTENSIONS:
return "audio", "url"
return "text", "url"
path = Path(input_value)
if path.exists() and path.is_file():
ext = path.suffix.lower()
if ext in IMAGE_EXTENSIONS:
return "image", "local_file"
if ext in AUDIO_EXTENSIONS:
return "audio", "local_file"
return "text", "raw"
def _extract_extension_from_url(url: str) -> str:
"""Extrae extensión de una URL, ignorando query params."""
clean = url.split("?")[0].split("#")[0]
path = Path(clean)
return path.suffix.lower()
Decisión de diseño: URLs sin extensión reconocida se clasifican como texto. Esto es intencional — si no puedes determinar la modalidad sin descargar el contenido, es más seguro no asumir. El caller puede forzar la modalidad si tiene información adicional.
Paso 3: Recolectar Metadata
La metadata varía según la modalidad y el tipo de source:
def collect_metadata(input_value: str, modality: str, source_type: str) -> dict:
"""Recolecta metadata relevante según la modalidad."""
if modality == "text":
return {
"length_chars": len(input_value),
"length_words": len(input_value.split()),
"estimated_tokens": len(input_value) // 4,
}
if source_type == "url":
return {"url": input_value, "format": _extract_extension_from_url(input_value)[1:]}
path = Path(input_value)
size_bytes = path.stat().st_size
meta = {
"file_path": str(path),
"file_size_kb": round(size_bytes / 1024, 1),
"file_size_mb": round(size_bytes / (1024 * 1024), 2),
"format": path.suffix[1:].lower(),
}
if modality == "image":
meta.update(_get_image_metadata(path))
elif modality == "audio":
meta.update(_get_audio_metadata(path, size_bytes))
return meta
def _get_image_metadata(path: Path) -> dict:
"""Obtiene dimensiones de la imagen si PIL está disponible."""
try:
from PIL import Image
with Image.open(path) as img:
return {"width": img.width, "height": img.height, "mode": img.mode}
except ImportError:
return {"width": None, "height": None, "note": "PIL no instalado"}
except Exception:
return {"width": None, "height": None, "note": "No se pudo leer dimensiones"}
def _get_audio_metadata(path: Path, size_bytes: int) -> dict:
"""Estima duración del audio por tamaño (heurística sin pydub)."""
format_bitrates_kbps = {"mp3": 128, "wav": 1411, "m4a": 128, "ogg": 112, "flac": 800}
ext = path.suffix[1:].lower()
bitrate = format_bitrates_kbps.get(ext, 128)
estimated_seconds = (size_bytes * 8) / (bitrate * 1000)
return {
"estimated_duration_seconds": round(estimated_seconds, 1),
"estimated_duration_minutes": round(estimated_seconds / 60, 2),
}
Para imágenes intentamos obtener dimensiones con PIL (opcional). Para audio estimamos duración por tamaño — una heurística que funciona razonablemente bien sin depender de pydub o ffprobe.
Paso 4: Validar Formato
La validación verifica que el archivo sea realmente lo que dice la extensión y que no exceda los límites del proveedor:
def validate_format(
input_value: str, modality: str, source_type: str, metadata: dict, provider: str
) -> tuple[bool, list[str]]:
"""Valida formato y retorna (is_valid, warnings)."""
warnings = []
if source_type == "url":
warnings.append("URL no validada localmente — errores se detectarán al llamar la API")
return True, warnings
if source_type == "raw":
return True, warnings
path = Path(input_value)
if not path.exists():
return False, [f"Archivo no encontrado: {input_value}"]
size_mb = metadata.get("file_size_mb", 0)
limit = SIZE_LIMITS_MB.get(provider, {}).get(modality, 20)
if limit == 0 and modality == "audio":
return False, [f"{provider} no soporta audio directamente"]
if size_mb > limit:
return False, [f"Archivo excede límite de {provider}: {size_mb:.1f} MB > {limit} MB"]
if size_mb > limit * 0.8:
warnings.append(f"Archivo cerca del límite ({size_mb:.1f}/{limit} MB) — considera redimensionar")
if modality == "image":
_validate_image_format(path, warnings)
return True, warnings
def _validate_image_format(path: Path, warnings: list[str]):
"""Verifica que el contenido de la imagen coincida con la extensión."""
try:
from PIL import Image
with Image.open(path) as img:
img.verify()
except ImportError:
warnings.append("PIL no instalado — no se verificó integridad de imagen")
except Exception as e:
warnings.append(f"Imagen posiblemente corrupta: {e}")
Punto clave: La validación es conservadora con URLs (no las descarga solo para validar) y estricta con archivos locales (verifica integridad con PIL cuando está disponible).
Paso 5: Recomendar Modelo y Proveedor
La recomendación considera modalidad, tamaño, y balance costo/calidad:
def recommend_model(
modality: str, metadata: dict, priority: str = "balanced"
) -> tuple[str, str, str]:
"""Recomienda modelo y proveedor.
Args:
priority: "quality" | "cost" | "balanced"
Returns:
(model, provider, reason)
"""
if modality == "text":
if priority == "quality":
return "gpt-4o", "openai", "Texto complejo: modelo de alta capacidad"
return "gpt-4o-mini", "openai", "Texto: modelo económico con buena calidad"
if modality == "audio":
return "whisper-1", "openai", "Audio: Whisper es el estándar para STT"
if modality == "image":
return _recommend_vision_model(metadata, priority)
return "gpt-4o-mini", "openai", "Fallback: modalidad no reconocida"
def _recommend_vision_model(metadata: dict, priority: str) -> tuple[str, str, str]:
"""Selecciona modelo de visión según tamaño y prioridad."""
size_mb = metadata.get("file_size_mb", 0)
if priority == "cost":
if size_mb > 5:
return "gemini-1.5-flash", "google", "Imagen grande + prioridad costo: Gemini Flash"
return "gpt-4o-mini", "openai", "Imagen: gpt-4o-mini es 17x más barato que gpt-4o"
if priority == "quality":
return "gpt-4o", "openai", "Imagen + prioridad calidad: GPT-4 Vision"
if size_mb > 10:
return "gpt-4o-mini", "openai", "Imagen grande (>10MB): modelo económico para reducir costo"
return "gpt-4o-mini", "openai", "Imagen estándar: buena calidad a bajo costo"
Decisión de diseño: El parámetro priority permite al caller ajustar la recomendación. Por defecto es "balanced" (gpt-4o-mini para la mayoría de casos), pero el caller puede pedir "quality" para tareas críticas o "cost" para batches grandes.
Paso 6: Estimar Costo
def estimate_cost(modality: str, model: str, metadata: dict) -> float:
"""Estima costo de procesar el input con el modelo recomendado."""
pricing = MODEL_PRICING.get(model)
if not pricing:
return 0.0
if modality == "audio":
minutes = metadata.get("estimated_duration_minutes", 1.0)
return round(minutes * pricing["per_minute"], 6)
if modality == "image":
image_tokens = _estimate_image_tokens(metadata)
prompt_tokens = 50
output_tokens = 150
input_cost = ((image_tokens + prompt_tokens) / 1_000_000) * pricing["input"]
output_cost = (output_tokens / 1_000_000) * pricing["output"]
return round(input_cost + output_cost, 6)
if modality == "text":
input_tokens = metadata.get("estimated_tokens", 100)
output_tokens = min(input_tokens, 500)
input_cost = (input_tokens / 1_000_000) * pricing["input"]
output_cost = (output_tokens / 1_000_000) * pricing["output"]
return round(input_cost + output_cost, 6)
return 0.0
def _estimate_image_tokens(metadata: dict) -> int:
"""Estima tokens de imagen usando el sistema de tiles de OpenAI."""
width = metadata.get("width")
height = metadata.get("height")
if not width or not height:
return 765 # default: 1 tile (cápsula 06)
if max(width, height) <= 512:
return 85 # low detail
scale = min(2048 / max(width, height), 1.0)
w, h = int(width * scale), int(height * scale)
scale_short = 768 / min(w, h)
if scale_short < 1:
w, h = int(w * scale_short), int(h * scale_short)
tiles_w = math.ceil(w / 512)
tiles_h = math.ceil(h / 512)
return 85 + (170 * tiles_w * tiles_h)
Este cálculo de tokens replica exactamente la lógica de la cápsula 06: escalar a 2048px, luego a 768px del lado corto, y contar tiles de 512×512.
Paso 7: Preparar API Payload
def prepare_api_payload(
input_value: str, modality: str, source_type: str, provider: str
) -> dict | None:
"""Prepara el payload en el formato del proveedor."""
if modality == "text":
return {"type": "text", "text": input_value}
if modality == "audio":
if source_type == "local_file":
return {"type": "audio", "file_path": input_value, "model": "whisper-1"}
return None
if modality == "image":
return _prepare_image_payload(input_value, source_type, provider)
return None
def _prepare_image_payload(input_value: str, source_type: str, provider: str) -> dict:
"""Prepara payload de imagen según proveedor."""
if source_type == "url":
if provider == "anthropic":
return {
"type": "image",
"note": "Anthropic requiere Base64 — descarga la URL primero",
"url": input_value,
}
return {"type": "image_url", "image_url": {"url": input_value}}
path = Path(input_value)
with open(path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
mime = MIME_TYPES.get(path.suffix.lower(), "image/jpeg")
if provider == "anthropic":
return {
"type": "image",
"source": {"type": "base64", "media_type": mime, "data": encoded},
}
data_uri = f"data:{mime};base64,{encoded}"
return {"type": "image_url", "image_url": {"url": data_uri}}
El payload respeta las diferencias entre proveedores: Anthropic necesita media_type + data separados; OpenAI acepta data URIs.
Paso 8: Función Principal — Integración
def classify_multimodal(
input_value: str,
priority: str = "balanced",
provider: str | None = None,
prepare_payload: bool = True,
) -> ClassificationResult:
"""Clasifica un input multimodal y produce resultado completo.
Args:
input_value: Texto, ruta a archivo, o URL
priority: "quality" | "cost" | "balanced"
provider: Forzar proveedor específico. Si None, se auto-selecciona.
prepare_payload: Si True, genera el payload listo para la API.
"""
result = ClassificationResult()
modality, source_type = detect_modality(input_value)
result.modality = modality
result.source_type = source_type
result.metadata = collect_metadata(input_value, modality, source_type)
model, rec_provider, reason = recommend_model(modality, result.metadata, priority)
result.recommended_model = model
result.recommended_provider = provider or rec_provider
is_valid, warnings = validate_format(
input_value, modality, source_type, result.metadata, result.recommended_provider
)
result.format_valid = is_valid
result.warnings = warnings
if not is_valid:
result.error = warnings[0] if warnings else "Formato inválido"
return result
result.estimated_cost_usd = estimate_cost(modality, model, result.metadata)
if prepare_payload and source_type != "url":
try:
result.api_payload = prepare_api_payload(
input_value, modality, source_type, result.recommended_provider
)
except Exception as e:
result.warnings.append(f"No se pudo preparar payload: {e}")
return result
Código Completo
El código completo integra todos los pasos anteriores en un único archivo ejecutable. Lo que sigue es la interfaz de uso y un demo:
def print_classification(result: ClassificationResult):
"""Imprime resultado de clasificación de forma legible."""
status = "VÁLIDO" if result.format_valid else "INVÁLIDO"
print(f"\n{'='*60}")
print(f" Modalidad: {result.modality}")
print(f" Source: {result.source_type}")
print(f" Formato: {status}")
print(f" Modelo: {result.recommended_model} ({result.recommended_provider})")
print(f" Costo est.: ${result.estimated_cost_usd:.6f}")
if result.metadata:
print(f" Metadata:")
for k, v in result.metadata.items():
print(f" {k}: {v}")
if result.warnings:
print(f" Warnings:")
for w in result.warnings:
print(f" ⚠ {w}")
if result.error:
print(f" ERROR: {result.error}")
if result.api_payload:
payload_type = result.api_payload.get("type", "?")
print(f" Payload: {payload_type} (listo para API)")
print(f"{'='*60}")
# Demo
inputs = [
"Analiza las tendencias de ventas del Q4",
"./fotos/producto_laptop.jpg",
"./grabaciones/llamada_cliente.mp3",
"https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg",
"./archivo_inexistente.png",
]
for inp in inputs:
result = classify_multimodal(inp, priority="balanced")
print_classification(result)
Salida esperada (esquemática):
============================================================
Modalidad: text
Source: raw
Formato: VÁLIDO
Modelo: gpt-4o-mini (openai)
Costo est.: $0.000005
Metadata:
length_chars: 46
length_words: 8
estimated_tokens: 11
Payload: text (listo para API)
============================================================
============================================================
Modalidad: image
Source: local_file
Formato: VÁLIDO
Modelo: gpt-4o-mini (openai)
Costo est.: $0.000124
Metadata:
file_path: ./fotos/producto_laptop.jpg
file_size_kb: 245.3
file_size_mb: 0.24
format: jpg
width: 1024
height: 768
Payload: image_url (listo para API)
============================================================
Extensión 1: Clasificación en Batch
Para procesar múltiples inputs, agrega tracking de costos acumulados:
@dataclass
class BatchReport:
total: int = 0
by_modality: dict = field(default_factory=lambda: {"text": 0, "image": 0, "audio": 0})
valid: int = 0
invalid: int = 0
total_cost_usd: float = 0.0
results: list[ClassificationResult] = field(default_factory=list)
def classify_batch(
inputs: list[str], priority: str = "balanced", budget: float | None = None
) -> BatchReport:
"""Clasifica múltiples inputs con tracking de costos."""
report = BatchReport()
for inp in inputs:
result = classify_multimodal(inp, priority=priority)
report.total += 1
report.by_modality[result.modality] = report.by_modality.get(result.modality, 0) + 1
if result.format_valid:
report.valid += 1
else:
report.invalid += 1
if budget and (report.total_cost_usd + result.estimated_cost_usd) > budget:
result.warnings.append(f"Presupuesto excedido: ${report.total_cost_usd:.4f}/{budget}")
result.api_payload = None
report.total_cost_usd += result.estimated_cost_usd
report.results.append(result)
return report
def print_batch_report(report: BatchReport):
print(f"\n{'='*60}")
print(f" REPORTE DE BATCH")
print(f"{'='*60}")
print(f" Total inputs: {report.total}")
print(f" Válidos: {report.valid}")
print(f" Inválidos: {report.invalid}")
print(f" Por modalidad: {report.by_modality}")
print(f" Costo total: ${report.total_cost_usd:.6f}")
print(f"{'='*60}")
Extensión 2: Modo Comparativo Multi-Proveedor
Para decidir entre proveedores, clasifica el mismo input con cada uno y compara:
def compare_providers(
input_value: str, providers: list[str] = None
) -> list[dict]:
"""Compara clasificación entre proveedores."""
providers = providers or ["openai", "anthropic", "google"]
comparisons = []
for provider in providers:
result = classify_multimodal(input_value, provider=provider)
comparisons.append({
"provider": provider,
"model": result.recommended_model,
"cost": result.estimated_cost_usd,
"valid": result.format_valid,
"warnings": len(result.warnings),
})
comparisons.sort(key=lambda x: x["cost"])
return comparisons
Uso:
for c in compare_providers("./fotos/producto.jpg"):
status = "✓" if c["valid"] else "✗"
print(f" {status} {c['provider']:<12} {c['model']:<20} ${c['cost']:.6f}")
Troubleshooting del Proyecto
Problema 1: Archivo de imagen con extensión incorrecta
Síntoma: Una imagen .txt que en realidad es JPEG se clasifica como texto.
Solución: Agrega detección por magic bytes como segundo nivel:
MAGIC_BYTES = {
b"\xff\xd8\xff": "image", # JPEG
b"\x89PNG": "image", # PNG
b"GIF8": "image", # GIF
b"RIFF": "audio", # WAV
b"ID3": "audio", # MP3
b"\xff\xfb": "audio", # MP3 (sin ID3)
}
def detect_by_magic_bytes(path: Path) -> str | None:
"""Detecta modalidad por los primeros bytes del archivo."""
try:
with open(path, "rb") as f:
header = f.read(8)
for magic, modality in MAGIC_BYTES.items():
if header.startswith(magic):
return modality
except Exception:
pass
return None
Integra esto en detect_modality como fallback cuando la extensión no es reconocida pero el archivo existe.
Problema 2: URL con query params se clasifica mal
Síntoma: https://images.com/foto.jpg?w=800&h=600 funciona, pero https://api.example.com/image?id=123 no tiene extensión y se clasifica como texto.
Solución: El diseño actual es correcto — sin extensión reconocible, no asumimos modalidad. El caller puede usar el parámetro provider o pasar la URL descargada localmente.
Problema 3: Estimación de costo incorrecta para imágenes sin PIL
Síntoma: Sin PIL instalado, todas las imágenes se estiman con 765 tokens (default de 1 tile).
Solución: Instala pillow (pip install Pillow). Sin PIL, la estimación es conservadora pero imprecisa. Agrega un warning:
if not width or not height:
warnings.append("Sin PIL: costo estimado con 765 tokens (default). Instala Pillow para precisión.")
Problema 4: Audio largo estima mal la duración
Síntoma: Un podcast de 2 horas en MP3 variable bitrate se estima con duración incorrecta.
Solución: La heurística por tamaño asume bitrate constante. Para precisión real, usa pydub:
from pydub import AudioSegment
audio = AudioSegment.from_file(path)
duration_minutes = len(audio) / (1000 * 60)
Pero pydub requiere ffmpeg. La heurística es suficiente para estimaciones de costo (error ≤20%).
Problema 5: Base64 payload es demasiado grande para Anthropic
Síntoma: Imagen de 4 MB genera payload Base64 de ~5.3 MB, excediendo el límite de Anthropic (5 MB).
Solución: La función validate_format ya detecta esto al verificar contra SIZE_LIMITS_MB. Si necesitas enviar la imagen a Anthropic, redimensiona antes:
from PIL import Image
import io
def resize_for_anthropic(image_path: str, max_mb: float = 3.5) -> bytes:
"""Redimensiona para que el Base64 quepa en 5 MB."""
with Image.open(image_path) as img:
img = img.convert("RGB")
quality = 90
while True:
buffer = io.BytesIO()
img.save(buffer, "JPEG", quality=quality, optimize=True)
encoded_size = len(buffer.getvalue()) * 4 / 3
if encoded_size <= max_mb * 1024 * 1024:
return buffer.getvalue()
quality -= 10
if quality < 30:
img = img.resize((img.width // 2, img.height // 2))
quality = 85
Checklist de Completitud
Antes de considerar el proyecto terminado, verifica cada punto:
Funcionalidad core:
- Detecta texto, imagen local, audio local, URL de imagen, URL de audio
- Retorna
ClassificationResultcon todos los campos poblados - Metadata incluye tamaño, formato, y dimensiones/duración cuando es posible
- Recomendación de modelo es coherente con la modalidad y prioridad
- Estimación de costo usa el sistema de tiles para imágenes y por-minuto para audio
Validación:
- Archivo no existente →
format_valid=Falsecon mensaje claro - Archivo que excede límite →
format_valid=Falsecon límite del proveedor - Archivo cerca del límite → warning
- Imagen corrupta → warning (si PIL disponible)
Payload:
- Imagen local → Base64 con data URI (OpenAI) o media_type+data (Anthropic)
- Imagen por URL → URL directo (OpenAI/Google), nota de descarga (Anthropic)
- Audio → file_path para Whisper
- Texto → dict con type "text"
Extensiones (opcionales):
- Batch classification con tracking de costos
- Comparación multi-proveedor
- Detección por magic bytes
Ejercicios
Ejercicio 1: Tests del clasificador (Fácil)
Crea una función test_classifier() que pruebe los 5 tipos de input (texto, imagen local, audio local, URL imagen, URL audio) con asserts. No necesitas archivos reales — para archivos locales, crea temporales con tempfile.
Ver solución
import tempfile, os
def test_classifier():
r = classify_multimodal("Hola mundo")
assert r.modality == "text"
assert r.source_type == "raw"
assert r.format_valid is True
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
img_path = f.name
r = classify_multimodal(img_path)
assert r.modality == "image"
assert r.source_type == "local_file"
os.unlink(img_path)
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"ID3" + b"\x00" * 100)
audio_path = f.name
r = classify_multimodal(audio_path)
assert r.modality == "audio"
assert r.source_type == "local_file"
os.unlink(audio_path)
r = classify_multimodal("https://example.com/photo.png")
assert r.modality == "image"
assert r.source_type == "url"
r = classify_multimodal("https://example.com/audio.mp3")
assert r.modality == "audio"
assert r.source_type == "url"
r = classify_multimodal("./no_existe.jpg")
assert r.modality == "text" # archivo no existe → fallback a texto
print("Todos los tests pasaron")
test_classifier()
Ejercicio 2: Agregar soporte para video (Medio)
Extiende el clasificador para soportar archivos de video (.mp4, .mov, .avi, .mkv). El modelo recomendado para video debe ser gemini-1.5-flash (Google es el único que soporta video directamente). Agrega una estimación de costo basada en duración estimada del video.
Ver solución
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
VIDEO_BITRATES_KBPS = {"mp4": 2500, "mov": 3000, "avi": 2000, "mkv": 2500, "webm": 1500}
def detect_modality_v2(input_value: str) -> tuple[str, str]:
if re.match(r"https?://", input_value):
ext = _extract_extension_from_url(input_value)
if ext in VIDEO_EXTENSIONS:
return "video", "url"
# ... (resto igual)
path = Path(input_value)
if path.exists() and path.is_file():
ext = path.suffix.lower()
if ext in VIDEO_EXTENSIONS:
return "video", "local_file"
# ... (resto igual)
return "text", "raw"
def recommend_model_v2(modality, metadata, priority="balanced"):
if modality == "video":
return "gemini-1.5-flash", "google", "Video: Gemini soporta video nativo"
return recommend_model(modality, metadata, priority)
MODEL_PRICING["gemini-1.5-flash-video"] = {"per_minute": 0.015}
Ejercicio 3: Dashboard de batch con costos acumulados (Medio)
Usando classify_batch, procesa una lista de 20 inputs mixtos y genera un reporte que muestre: distribución por modalidad (gráfico de texto), top 3 inputs más caros, costo total, y porcentaje del presupuesto usado.
Ver solución
def batch_dashboard(inputs: list[str], budget: float = 1.0):
report = classify_batch(inputs, budget=budget)
print("\n📊 DASHBOARD DE CLASIFICACIÓN")
print(f"{'='*50}")
total = report.total
for mod, count in report.by_modality.items():
bar = "█" * int(count / total * 30) if total > 0 else ""
pct = count / total * 100 if total > 0 else 0
print(f" {mod:<8} {bar:<30} {count:>3} ({pct:.0f}%)")
print(f"\n Válidos: {report.valid}/{total}")
print(f" Inválidos: {report.invalid}/{total}")
top_costly = sorted(report.results, key=lambda r: r.estimated_cost_usd, reverse=True)[:3]
print(f"\n Top 3 más caros:")
for i, r in enumerate(top_costly, 1):
path = r.metadata.get("file_path", r.metadata.get("url", "texto"))
print(f" {i}. {path}: ${r.estimated_cost_usd:.6f} ({r.recommended_model})")
pct_budget = (report.total_cost_usd / budget * 100) if budget > 0 else 0
print(f"\n Costo total: ${report.total_cost_usd:.6f} ({pct_budget:.1f}% del presupuesto)")
print(f"{'='*50}")
Resumen
En este proyecto construiste un Clasificador Multimodal completo que:
- Detecta modalidad por extensión de archivo, patrón de URL, o contenido (texto como fallback)
- Valida formato verificando existencia, tamaño contra límites del proveedor, e integridad de imagen
- Recomienda modelo y proveedor según modalidad, tamaño y prioridad (calidad vs costo)
- Estima costo usando el sistema de tiles de OpenAI para imágenes y costo por minuto para audio
- Prepara payloads en el formato correcto de cada proveedor (data URI para OpenAI, media_type+data para Anthropic)
- Maneja errores con mensajes claros y warnings para situaciones ambiguas
Este clasificador es la base para los routers de pipelines que construirás en módulos posteriores. En el Módulo 2 (Vision), la lógica de selección de modelo se expande para incluir GPT-4 Vision, Claude 3 y Gemini con sus capacidades específicas. En el Módulo 8 (Document Analyzer), la detección de modalidad se integra con procesamiento de PDFs, facturas y contratos.
Próximo módulo: Módulo 2 — Vision con LLMs. Ya sabes qué es multimodal; ahora vas a dominar el análisis de imágenes con los principales proveedores.
Recursos Adicionales
- OpenAI Vision Guide — Formatos de input y límites
- OpenAI Pricing — Precios actualizados para estimar costos
- Anthropic Vision Docs — Formato Base64 requerido por Claude
- Google Gemini Vision — Soporte de video nativo
- Python Pathlib — Manejo de rutas y extensiones
- Pillow Documentation — Validación y manipulación de imágenes
- Python dataclasses — Estructuras de datos para resultados