Módulo 7: Casos de Uso

5. Combinaciones Multi-Modalidad

Descripción

Las cápsulas anteriores cubrieron patrones individuales: Document Q&A, Image Analysis, Video Frames. Pero el verdadero poder de la IA multimodal aparece cuando combinas modalidades en un solo pipeline: audio de una reunión + diapositivas en PDF → acta integrada; documento + pregunta de voz → respuesta en audio; imagen + texto + audio → análisis unificado.

Por qué importa: En producción, rara vez usas una sola modalidad. Un sistema de meeting notes combina transcripción de audio, análisis de presentaciones, y generación textual. Un asistente médico combina imágenes de radiografías, notas del doctor (audio), e historial clínico (texto). Un sistema educativo combina video de clase, slides, y transcripción para generar materiales de estudio. Dominar combinaciones es lo que diferencia un script con una API de un sistema multimodal real.

Conexión con el módulo: Esta cápsula integra lo que viste en cápsulas 02-04 y conecta directamente con el Use Case Selector (cápsula 08), que necesita detectar inputs multi-modales y combinar pipelines.


Taxonomía de Combinaciones

Por tipo de integración

TipoDescripciónEjemplo
Input multi-modalMúltiples modalidades como input a un solo modeloImagen + pregunta → GPT-4o → respuesta
Pipeline secuencialSalida de un modelo como entrada de otroAudio → Whisper → texto → GPT-4o → resumen → TTS → audio
Pipeline paraleloProcesar modalidades en paralelo y combinarAudio → transcripción ∥ PDF → extracción → combinar → LLM
Output multi-modalUn pipeline genera outputs en varias modalidadesDocumento → análisis textual + gráfico + resumen de audio

Los 5 patrones más usados

1. Reunión + Documento  → Acta integrada
2. Imagen + Pregunta    → Respuesta contextual
3. Documento → Texto + Imagen + Audio (output multi-modal)
4. Video + Audio → Análisis completo
5. Tri-modal: Texto + Imagen + Audio → Respuesta unificada

Patrón 1: Reunión + Documento

Un equipo tiene una reunión donde discuten un documento (contrato, propuesta, reporte). El sistema toma la grabación de la reunión + el PDF del documento y genera un acta que conecta ambos.

Pipeline visual

┌─────────────┐                    ┌─────────────┐
│   Audio     │──▶ Whisper ──▶     │             │
│  reunión    │          Transcr.  │   Combinar  │
└─────────────┘                    │   contextos │──▶ LLM ──▶ Acta
┌─────────────┐                    │             │        integrada
│   PDF       │──▶ PyMuPDF ──▶    │             │
│  documento  │      Texto+imgs   └─────────────┘
└─────────────┘

Implementación completa

from openai import OpenAI
import fitz
import base64

client = OpenAI()

def transcribe_audio(audio_path: str) -> str:
    with open(audio_path, "rb") as f:
        response = client.audio.transcriptions.create(
            model="whisper-1",
            file=f,
            response_format="text"
        )
    return response


def extract_text_from_pdf(pdf_path: str) -> str:
    doc = fitz.open(pdf_path)
    text = "\n\n".join(doc[i].get_text() for i in range(len(doc)))
    doc.close()
    return text


def extract_images_from_pdf(pdf_path: str) -> list[str]:
    doc = fitz.open(pdf_path)
    image_descriptions = []

    for page_num in range(len(doc)):
        page = doc[page_num]
        images = page.get_images(full=True)
        for img_info in images:
            xref = img_info[0]
            base_image = doc.extract_image(xref)
            b64 = base64.b64encode(base_image["image"]).decode()

            desc_response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "Describe esta imagen/diagrama en 2-3 oraciones."},
                        {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
                    ]
                }],
                max_tokens=150
            )
            image_descriptions.append(
                f"[Imagen p.{page_num + 1}]: {desc_response.choices[0].message.content}"
            )

    doc.close()
    return image_descriptions


def meeting_with_document(audio_path: str, doc_path: str) -> dict:
    transcript = transcribe_audio(audio_path)
    doc_text = extract_text_from_pdf(doc_path)
    doc_images = extract_images_from_pdf(doc_path)

    images_text = "\n".join(doc_images) if doc_images else "Sin imágenes."

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": (
                    "Genera un acta de reunión profesional. "
                    "Conecta lo discutido en la reunión con el contenido del documento. "
                    "Estructura: Resumen ejecutivo, Puntos discutidos, Decisiones tomadas, "
                    "Acciones pendientes, Referencias al documento."
                )
            },
            {
                "role": "user",
                "content": (
                    f"TRANSCRIPCIÓN DE LA REUNIÓN:\n{transcript[:6000]}\n\n"
                    f"CONTENIDO DEL DOCUMENTO:\n{doc_text[:4000]}\n\n"
                    f"IMÁGENES/DIAGRAMAS DEL DOCUMENTO:\n{images_text[:2000]}"
                )
            }
        ],
        max_tokens=1000,
        temperature=0.3
    )

    return {
        "minutes": response.choices[0].message.content,
        "transcript_length": len(transcript),
        "document_pages": doc_text.count("\n\n"),
        "images_analyzed": len(doc_images)
    }

Uso:

result = meeting_with_document(
    "reunion_equipo.mp3",
    "propuesta_proyecto.pdf"
)
print(result["minutes"])

Patrón 2: Imagen + Pregunta Contextual

Más allá de "describe esta imagen", el usuario da contexto textual que modifica cómo interpretar la imagen.

def contextual_image_qa(
    image_path: str,
    question: str,
    context: str = ""
) -> dict:
    with open(image_path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()

    prompt = question
    if context:
        prompt = f"Contexto: {context}\n\nPregunta: {question}"

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
            ]
        }],
        max_tokens=500,
        temperature=0.2
    )

    return {
        "answer": response.choices[0].message.content,
        "context_used": bool(context)
    }

Con historial de conversación

class ImageConversation:
    def __init__(self, image_path: str):
        self.image_path = image_path
        with open(image_path, "rb") as f:
            self.image_b64 = base64.b64encode(f.read()).decode()
        self.messages: list[dict] = []

    def ask(self, question: str) -> str:
        if not self.messages:
            self.messages.append({
                "role": "user",
                "content": [
                    {"type": "text", "text": question},
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{self.image_b64}"}}
                ]
            })
        else:
            self.messages.append({"role": "user", "content": question})

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=self.messages,
            max_tokens=500
        )

        answer = response.choices[0].message.content
        self.messages.append({"role": "assistant", "content": answer})
        return answer

Patrón 3: Output Multi-Modal (Documento → Texto + Imagen + Audio)

Un documento de entrada genera tres outputs diferentes:

def multimodal_output_pipeline(doc_path: str) -> dict:
    doc_text = extract_text_from_pdf(doc_path)

    summary_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"Resume este documento en 3-5 puntos clave:\n\n{doc_text[:6000]}"
        }],
        max_tokens=400
    )
    text_summary = summary_response.choices[0].message.content

    viz_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": (
                f"Basándote en este resumen, genera un prompt para DALL-E "
                f"que ilustre el concepto principal como infografía profesional:\n\n{text_summary}"
            )
        }],
        max_tokens=200
    )
    image_prompt = viz_response.choices[0].message.content

    image_response = client.images.generate(
        model="dall-e-3",
        prompt=image_prompt,
        size="1024x1024",
        n=1
    )
    image_url = image_response.data[0].url

    audio_response = client.audio.speech.create(
        model="tts-1",
        voice="nova",
        input=text_summary
    )
    audio_path = "/tmp/document_summary.mp3"
    audio_response.stream_to_file(audio_path)

    return {
        "text_summary": text_summary,
        "image_url": image_url,
        "audio_path": audio_path
    }

Patrón 4: Video + Audio Completo

Combina el análisis visual de frames (cápsula 04) con la transcripción del audio del video:

import subprocess

def extract_audio_from_video(video_path: str, output_path: str = "/tmp/video_audio.mp3") -> str:
    subprocess.run([
        "ffmpeg", "-i", video_path, "-vn", "-acodec", "libmp3lame",
        "-y", output_path
    ], capture_output=True, check=True)
    return output_path


def full_video_analysis(video_path: str, target_frames: int = 15) -> dict:
    audio_path = extract_audio_from_video(video_path)
    transcript = transcribe_audio(audio_path)

    import cv2
    cap = cv2.VideoCapture(video_path)
    fps = cap.get(cv2.CAP_PROP_FPS)
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    duration = total_frames / fps
    interval = duration / target_frames

    frames_descriptions = []
    for i in range(target_frames):
        ts = i * interval
        cap.set(cv2.CAP_PROP_POS_FRAMES, int(ts * fps))
        ret, frame = cap.read()
        if not ret:
            break

        frame_path = f"/tmp/full_analysis_frame_{i}.jpg"
        cv2.imwrite(frame_path, frame)

        with open(frame_path, "rb") as f:
            b64 = base64.b64encode(f.read()).decode()

        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe esta escena en 1-2 oraciones."},
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
                ]
            }],
            max_tokens=100
        )

        minutes = int(ts // 60)
        seconds = int(ts % 60)
        frames_descriptions.append(f"{minutes}:{seconds:02d} - {response.choices[0].message.content}")

    cap.release()

    visual_timeline = "\n".join(frames_descriptions)

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": (
                "Genera un análisis completo de este video integrando lo visual con lo hablado.\n\n"
                f"ANÁLISIS VISUAL (frames):\n{visual_timeline}\n\n"
                f"TRANSCRIPCIÓN DEL AUDIO:\n{transcript[:5000]}\n\n"
                "Estructura: Resumen, Timeline con momentos clave, Temas principales, Conclusiones."
            )
        }],
        max_tokens=800
    )

    return {
        "integrated_analysis": response.choices[0].message.content,
        "visual_frames": len(frames_descriptions),
        "transcript_length": len(transcript),
        "duration_seconds": round(duration, 2)
    }

Patrón 5: Tri-Modal (Texto + Imagen + Audio)

El usuario envía tres inputs simultáneos y el sistema integra todo:

def tri_modal_analysis(
    text_input: str,
    image_path: str,
    audio_path: str
) -> dict:
    transcript = transcribe_audio(audio_path)

    with open(image_path, "rb") as f:
        image_b64 = base64.b64encode(f.read()).decode()

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        "Analiza estos tres inputs y genera una respuesta integrada:\n\n"
                        f"TEXTO DEL USUARIO:\n{text_input}\n\n"
                        f"AUDIO TRANSCRITO:\n{transcript}\n\n"
                        "IMAGEN ADJUNTA (ver abajo):\n"
                        "Conecta la información de las tres fuentes en una respuesta coherente."
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
                }
            ]
        }],
        max_tokens=600,
        temperature=0.3
    )

    return {
        "integrated_response": response.choices[0].message.content,
        "inputs": {
            "text_length": len(text_input),
            "transcript_length": len(transcript),
            "image_path": image_path
        }
    }

Pipeline Genérico Multi-Modal

Para no repetir código, un pipeline configurable:

class MultiModalPipeline:
    def __init__(self):
        self.processors = {
            "text": self._process_text,
            "image": self._process_image,
            "audio": self._process_audio,
            "pdf": self._process_pdf
        }

    def process(self, inputs: dict[str, str], task: str = "analyze") -> dict:
        processed = {}
        for input_type, input_path in inputs.items():
            if input_type in self.processors:
                processed[input_type] = self.processors[input_type](input_path)

        return self._combine(processed, task)

    def _process_text(self, text: str) -> str:
        return text

    def _process_image(self, path: str) -> str:
        with open(path, "rb") as f:
            b64 = base64.b64encode(f.read()).decode()
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe esta imagen en detalle."},
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
                ]
            }],
            max_tokens=300
        )
        return response.choices[0].message.content

    def _process_audio(self, path: str) -> str:
        return transcribe_audio(path)

    def _process_pdf(self, path: str) -> str:
        return extract_text_from_pdf(path)

    def _combine(self, processed: dict, task: str) -> dict:
        context_parts = []
        for input_type, content in processed.items():
            context_parts.append(f"[{input_type.upper()}]:\n{content[:3000]}")

        context = "\n\n".join(context_parts)

        task_prompts = {
            "analyze": "Analiza toda la información y genera un resumen integrado.",
            "compare": "Compara la información de las diferentes fuentes.",
            "summarize": "Resume los puntos clave de todas las fuentes.",
            "qa": "Responde cualquier pregunta implícita en los inputs."
        }

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[{
                "role": "user",
                "content": f"{context}\n\n{task_prompts.get(task, task_prompts['analyze'])}"
            }],
            max_tokens=600
        )

        return {
            "result": response.choices[0].message.content,
            "inputs_processed": list(processed.keys()),
            "task": task
        }

Uso:

pipeline = MultiModalPipeline()

result = pipeline.process(
    inputs={
        "audio": "reunion.mp3",
        "pdf": "propuesta.pdf",
        "image": "diagrama.png"
    },
    task="summarize"
)
print(result["result"])

Troubleshooting

Problema 1: Token limit excedido al combinar modalidades

Síntoma: Error maximum context length exceeded al enviar transcripción + documento + imagen.

Solución:

def truncate_inputs(inputs: dict[str, str], max_total_chars: int = 15000) -> dict[str, str]:
    total = sum(len(v) for v in inputs.values())
    if total <= max_total_chars:
        return inputs

    per_input = max_total_chars // len(inputs)
    return {k: v[:per_input] for k, v in inputs.items()}

Problema 2: Audio largo (>25 MB) no se puede transcribir

Síntoma: Whisper rechaza archivos mayores a 25 MB.

Solución: Dividir el audio en segmentos:

from pydub import AudioSegment

def transcribe_long_audio(audio_path: str, segment_minutes: int = 10) -> str:
    audio = AudioSegment.from_file(audio_path)
    segment_ms = segment_minutes * 60 * 1000
    segments = [audio[i:i + segment_ms] for i in range(0, len(audio), segment_ms)]

    transcripts = []
    for i, segment in enumerate(segments):
        segment_path = f"/tmp/audio_segment_{i}.mp3"
        segment.export(segment_path, format="mp3")
        transcript = transcribe_audio(segment_path)
        transcripts.append(transcript)

    return "\n\n".join(transcripts)

Problema 3: Resultados incoherentes al combinar fuentes

Síntoma: El resumen no conecta bien audio con documento.

Solución: Usar un prompt más estructurado:

system_prompt = (
    "Integra información de múltiples fuentes. Para cada punto, indica de qué fuente proviene. "
    "Si hay contradicciones entre fuentes, señálalas explícitamente. "
    "Estructura tu respuesta con: 1) Puntos en común, 2) Información única de cada fuente, "
    "3) Contradicciones (si las hay), 4) Conclusión integrada."
)

Problema 4: Latencia alta en pipelines multi-paso

Síntoma: El pipeline tarda 30+ segundos.

Solución: Paralelizar pasos independientes:

import asyncio
from openai import AsyncOpenAI

async def process_inputs_parallel(audio_path: str, doc_path: str, image_path: str) -> dict:
    aclient = AsyncOpenAI()

    async def transcribe_async():
        with open(audio_path, "rb") as f:
            return await aclient.audio.transcriptions.create(model="whisper-1", file=f, response_format="text")

    async def extract_doc():
        return extract_text_from_pdf(doc_path)

    async def describe_image():
        with open(image_path, "rb") as f:
            b64 = base64.b64encode(f.read()).decode()
        response = await aclient.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe esta imagen."},
                    {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
                ]
            }],
            max_tokens=200
        )
        return response.choices[0].message.content

    transcript, doc_text, img_desc = await asyncio.gather(
        transcribe_async(), extract_doc(), describe_image()
    )

    return {"transcript": transcript, "doc_text": doc_text, "image_description": img_desc}

Ejercicios

Ejercicio 1: Pipeline documento → imagen → análisis

Extrae la primera imagen de un PDF, genera una variación con DALL-E 3, y compara ambas con Vision.

Ver solución
def doc_image_variation_pipeline(pdf_path: str) -> dict:
    doc = fitz.open(pdf_path)
    first_image = None

    for page_num in range(len(doc)):
        images = doc[page_num].get_images(full=True)
        if images:
            xref = images[0][0]
            base_image = doc.extract_image(xref)
            first_image = base_image["image"]
            break
    doc.close()

    if not first_image:
        return {"error": "No se encontraron imágenes en el PDF"}

    b64_original = base64.b64encode(first_image).decode()

    desc_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": "Describe esta imagen para poder recrearla con DALL-E."},
                {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_original}"}}
            ]
        }],
        max_tokens=200
    )
    description = desc_response.choices[0].message.content

    variation = client.images.generate(
        model="dall-e-3",
        prompt=f"Versión mejorada y profesional de: {description}",
        size="1024x1024",
        n=1
    )

    return {
        "original_description": description,
        "variation_url": variation.data[0].url,
        "source_pdf": pdf_path
    }

Ejercicio 2: Sistema de meeting notes completo

Extiende meeting_with_document para que genere: acta (texto), resumen ejecutivo (texto corto), y resumen en audio (TTS).

Ver solución
def complete_meeting_notes(audio_path: str, doc_path: str) -> dict:
    base_result = meeting_with_document(audio_path, doc_path)
    minutes = base_result["minutes"]

    exec_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"Genera un resumen ejecutivo de 3 líneas de esta acta:\n\n{minutes}"
        }],
        max_tokens=150
    )
    executive_summary = exec_response.choices[0].message.content

    audio_response = client.audio.speech.create(
        model="tts-1",
        voice="nova",
        input=executive_summary
    )
    audio_output = "/tmp/meeting_summary.mp3"
    audio_response.stream_to_file(audio_output)

    return {
        "full_minutes": minutes,
        "executive_summary": executive_summary,
        "audio_summary_path": audio_output
    }

Ejercicio 3: Tri-modal con detección automática

Crea una función que reciba una lista de paths, detecte automáticamente el tipo de cada uno (texto, imagen, audio, PDF), y aplique el pipeline multi-modal correspondiente.

Ver solución
from pathlib import Path

def auto_multimodal_pipeline(paths: list[str], task: str = "analyze") -> dict:
    type_map = {
        ".pdf": "pdf",
        ".png": "image", ".jpg": "image", ".jpeg": "image", ".webp": "image",
        ".mp3": "audio", ".wav": "audio", ".m4a": "audio",
        ".txt": "text", ".md": "text"
    }

    inputs = {}
    for path in paths:
        ext = Path(path).suffix.lower()
        input_type = type_map.get(ext)
        if input_type:
            if input_type == "text":
                with open(path, "r") as f:
                    inputs[input_type] = f.read()
            else:
                inputs[input_type] = path

    if not inputs:
        return {"error": "No se detectaron inputs válidos"}

    pipeline = MultiModalPipeline()
    return pipeline.process(inputs, task)

Recursos Adicionales

  1. OpenAI Audio API — Whisper y TTS
  2. OpenAI Vision — Multi-imagen en un request
  3. DALL-E 3 API — Generación de imágenes
  4. FFmpeg Documentation — Extracción de audio de video