Módulo 6: Functional API

Proyecto Evolutivo: Research Agent Base (v1)

Descripción del proyecto

En las siete cápsulas anteriores aprendiste la Functional API de LangGraph: @entrypoint para definir workflows como funciones, @task para tareas checkpointables, control flow nativo con loops y condicionales, la comparación profunda entre Graph API y Functional API, patterns avanzados como futures para ejecución paralela, y cómo combinar ambas APIs cuando el caso lo requiere. Cada concepto lo viste de forma individual. Ahora vas a combinar todo en un sistema real.

Pero este no es un mini-proyecto más. Este es el inicio del AI Research Assistant — un proyecto que vas a construir, iterar, y escalar durante los próximos 6 módulos. Lo que construyas hoy es v1: un agente funcional que recibe un tema de investigación, lo descompone en sub-preguntas, busca información en múltiples fuentes en paralelo, y genera un reporte estructurado. Es simple, pero funciona end-to-end. Y cada pieza está diseñada para evolucionar.

En el Módulo 7, le agregarás retry logic y error handling robusto. En el Módulo 8, memoria persistente para que recuerde investigaciones anteriores. En el Módulo 9, aprobaciones humanas antes de acciones costosas. En el Módulo 10, múltiples agentes especializados coordinados por un supervisor. En el Módulo 11, planning autónomo con Deep Agents. En el Módulo 12, observabilidad completa con LangSmith. Cada módulo agrega una capa sobre lo que construyes hoy.

Construye la v1 bien. Es la base sobre la que todo lo demás se apoya.


Objetivo del proyecto

Construir un agente de investigación funcional con la Functional API de LangGraph que recibe un tema, lo descompone en sub-queries, ejecuta búsquedas en paralelo en múltiples fuentes, y genera un reporte estructurado con Pydantic.

Al completar este proyecto:

  • 🔧 Sabrás diseñar modelos de datos con Pydantic para structured output de agentes
  • 🔧 Implementarás @task functions para descomposición, búsqueda y síntesis
  • 🔧 Crearás un @entrypoint que orquesta el flujo completo de investigación
  • 🔧 Usarás el pattern de Futures para búsqueda paralela en múltiples fuentes
  • 🔧 Generarás streaming de progreso durante la ejecución
  • 🔧 Construirás un CLI simple para interactuar con el agente

Especificaciones técnicas

Stack tecnológico

ComponenteVersiónPropósito
Python3.11+Runtime
LangChainv1.2+Framework de LLMs
LangGraphv1.0+Functional API (@entrypoint, @task)
langchain-openailatestProveedor de modelos
pydanticv2+Modelos de datos structured
python-dotenvlatestVariables de entorno

Setup inicial

pip install langchain langgraph langchain-openai pydantic python-dotenv

Crea un archivo .env en la raíz de tu proyecto:

# .env
OPENAI_API_KEY=sk-...

Estructura del proyecto

research-assistant/
├── .env                        # API key
├── requirements.txt            # Dependencias
├── agents/
│   └── researcher.py           # @entrypoint — agente principal
├── tools/
│   ├── web_search.py           # Mock web search (@task)
│   └── calculator.py           # Calculator tool (@task)
├── state/
│   └── research_state.py       # Modelos Pydantic para el reporte
├── config/
│   └── settings.py             # Configuración del proyecto
└── main.py                     # CLI entrypoint

Esta estructura parece excesiva para un proyecto v1. No lo es. Cada directorio tiene un propósito que se revelará en módulos posteriores:

  • agents/ — En M10 tendrás analyst.py, writer.py, supervisor.py aquí
  • tools/ — En M7 agregarás document_reader.py con retry logic
  • state/ — En M8 los modelos se extenderán con campos de memoria
  • config/ — En M12 tendrás configuración de LangSmith y rate limiting

Paso 1: Modelos de datos (state/research_state.py)

El reporte de investigación necesita estructura. No es un string libre — es un objeto con campos tipados que cualquier sistema downstream puede consumir. Pydantic te da validación, serialización, y documentación automática.

"""
state/research_state.py
Modelos de datos para el AI Research Assistant.
"""

from pydantic import BaseModel, Field
from datetime import datetime


class Source(BaseModel):
    """Una fuente de información encontrada durante la investigación."""
    name: str = Field(description="Nombre de la fuente")
    source_type: str = Field(description="Tipo: web, academic, news")
    content: str = Field(description="Contenido extraído de la fuente")


class KeyFinding(BaseModel):
    """Un hallazgo clave identificado en la investigación."""
    title: str = Field(description="Título del hallazgo")
    description: str = Field(description="Descripción del hallazgo")
    confidence: float = Field(
        description="Confianza en el hallazgo (0.0 a 1.0)",
        ge=0.0,
        le=1.0,
    )


class ResearchReport(BaseModel):
    """Reporte estructurado de investigación."""
    topic: str = Field(description="Tema investigado")
    summary: str = Field(description="Resumen ejecutivo (2-3 oraciones)")
    key_findings: list[KeyFinding] = Field(
        description="Hallazgos principales",
        min_length=1,
    )
    sources: list[Source] = Field(
        description="Fuentes consultadas",
        min_length=1,
    )
    sub_queries: list[str] = Field(
        description="Sub-preguntas generadas para la investigación",
    )
    confidence: float = Field(
        description="Confianza general del reporte (0.0 a 1.0)",
        ge=0.0,
        le=1.0,
    )
    generated_at: str = Field(
        default_factory=lambda: datetime.now().isoformat(),
        description="Timestamp de generación",
    )


class SubQuery(BaseModel):
    """Una sub-pregunta generada a partir del tema principal."""
    query: str = Field(description="La sub-pregunta")
    rationale: str = Field(description="Por qué esta pregunta es relevante")

Cada modelo tiene un propósito claro. ResearchReport es el output final del agente. Source y KeyFinding son los building blocks del reporte. SubQuery estructura la descomposición del tema.


Paso 2: Configuración (config/settings.py)

"""
config/settings.py
Configuración del AI Research Assistant.
"""

from dotenv import load_dotenv
load_dotenv()

MODEL_NAME = "openai:gpt-4.1-mini"
MODEL_TEMPERATURE = 0.2

MAX_SUB_QUERIES = 4
MAX_SOURCES_PER_QUERY = 3

SEARCH_SOURCES = ["web", "academic", "news"]

Centralizar la configuración hace que los cambios sean fáciles. Cuando en M12 agregues configuración de LangSmith y rate limits, todo estará en un solo lugar.


Paso 3: Tools — búsqueda y cálculo (tools/)

Las tools del agente son @task functions. En v1 usamos búsqueda mock — no dependemos de APIs externas que pueden fallar, cambiar, o costar dinero. En M7 agregarás búsqueda real con retry logic.

tools/web_search.py

"""
tools/web_search.py
Mock web search tool para el AI Research Assistant.
En M7 se reemplazará con búsqueda real + retry logic.
"""

import hashlib
from langgraph.func import task


MOCK_RESULTS = {
    "web": {
        "default": (
            "Múltiples fuentes web coinciden en que {query} es un tema de creciente "
            "interés. Expertos destacan avances significativos en los últimos 2 años. "
            "Las aplicaciones prácticas incluyen automatización, análisis de datos "
            "y generación de contenido."
        ),
    },
    "academic": {
        "default": (
            "Investigación reciente (2025-2026) muestra que {query} tiene fundamentos "
            "teóricos sólidos respaldados por múltiples estudios peer-reviewed. "
            "Los resultados experimentales demuestran mejoras del 40-60% en métricas "
            "clave comparados con métodos tradicionales."
        ),
    },
    "news": {
        "default": (
            "Noticias recientes reportan que {query} está generando impacto en la "
            "industria. Empresas líderes como Google, Microsoft y startups emergentes "
            "están invirtiendo significativamente en esta área. Se esperan desarrollos "
            "importantes para finales de 2026."
        ),
    },
}


def _generate_deterministic_score(query: str, source_type: str) -> float:
    """Genera un score determinístico basado en el hash del input."""
    hash_input = f"{query}:{source_type}"
    hash_value = int(hashlib.md5(hash_input.encode()).hexdigest()[:8], 16)
    return round(0.5 + (hash_value % 50) / 100, 2)


@task
def search_web(query: str) -> dict:
    """Busca información en la web general (mock)."""
    content = MOCK_RESULTS["web"]["default"].format(query=query)
    return {
        "source_name": "Web Search",
        "source_type": "web",
        "content": content,
        "relevance": _generate_deterministic_score(query, "web"),
    }


@task
def search_academic(query: str) -> dict:
    """Busca en fuentes académicas (mock)."""
    content = MOCK_RESULTS["academic"]["default"].format(query=query)
    return {
        "source_name": "Academic Database",
        "source_type": "academic",
        "content": content,
        "relevance": _generate_deterministic_score(query, "academic"),
    }


@task
def search_news(query: str) -> dict:
    """Busca en noticias recientes (mock)."""
    content = MOCK_RESULTS["news"]["default"].format(query=query)
    return {
        "source_name": "News Aggregator",
        "source_type": "news",
        "content": content,
        "relevance": _generate_deterministic_score(query, "news"),
    }


SEARCH_FUNCTIONS = {
    "web": search_web,
    "academic": search_academic,
    "news": search_news,
}

Cada función de búsqueda es un @task — checkpointable y ejecutable en paralelo con futures. El mock usa templates con el query interpolado para que los resultados varíen según la pregunta. El score determinístico basado en hash asegura resultados reproducibles para testing.

tools/calculator.py

"""
tools/calculator.py
Calculator tool para el AI Research Assistant.
Util para análisis que requieren cálculos numéricos.
"""

from langgraph.func import task


@task
def calculate_confidence(
    num_sources: int,
    avg_relevance: float,
    num_findings: int,
) -> float:
    """
    Calcula la confianza general del reporte basado en métricas.
    
    Factores:
    - Más fuentes = más confianza (hasta un punto)
    - Mayor relevancia promedio = más confianza
    - Más hallazgos = más confianza (hasta un punto)
    """
    source_factor = min(num_sources / 5, 1.0) * 0.4
    relevance_factor = avg_relevance * 0.4
    findings_factor = min(num_findings / 5, 1.0) * 0.2

    confidence = source_factor + relevance_factor + findings_factor
    return round(min(confidence, 1.0), 2)

El calculator es simple en v1. En módulos posteriores se extenderá para análisis más complejos (cost estimation, token counting, etc.).


Paso 4: El agente principal (agents/researcher.py)

Este es el corazón del sistema. Un @entrypoint que orquesta todo el flujo de investigación: descomponer el tema → buscar en paralelo → sintetizar → generar reporte structured.

"""
agents/researcher.py
Agente principal del AI Research Assistant (v1).
Usa Functional API de LangGraph: @entrypoint + @task.
"""

import json
from langchain.chat_models import init_chat_model
from langgraph.func import entrypoint, task
from langgraph.checkpoint.memory import MemorySaver

import sys
sys.path.insert(0, ".")

from config.settings import (
    MODEL_NAME,
    MODEL_TEMPERATURE,
    MAX_SUB_QUERIES,
    SEARCH_SOURCES,
)
from state.research_state import (
    ResearchReport,
    Source,
    KeyFinding,
    SubQuery,
)
from tools.web_search import SEARCH_FUNCTIONS
from tools.calculator import calculate_confidence


model = init_chat_model(MODEL_NAME, temperature=MODEL_TEMPERATURE)


# =============================================================================
# TASK: Descomponer tema en sub-queries
# =============================================================================

@task
def decompose_query(topic: str) -> list[dict]:
    """Descompone un tema de investigación en sub-preguntas específicas."""
    response = model.invoke(
        f"Eres un investigador experto. Descompone este tema en "
        f"{MAX_SUB_QUERIES} sub-preguntas específicas e investigables.\n\n"
        f"Tema: {topic}\n\n"
        f"Responde en JSON (sin markdown, sin ```json):\n"
        f'[{{"query": "sub-pregunta", "rationale": "por qué es relevante"}}]\n\n'
        f"Solo el JSON, nada más."
    )

    try:
        queries = json.loads(response.content)
        return queries[:MAX_SUB_QUERIES]
    except json.JSONDecodeError:
        return [
            {"query": topic, "rationale": "Query original como fallback"},
            {"query": f"avances recientes en {topic}", "rationale": "Tendencias actuales"},
            {"query": f"aplicaciones prácticas de {topic}", "rationale": "Uso real"},
        ]


# =============================================================================
# TASK: Buscar en todas las fuentes para una sub-query
# =============================================================================

@task
def search_all_sources(query: str) -> list[dict]:
    """Busca en todas las fuentes configuradas para una query."""
    futures = []
    for source_type in SEARCH_SOURCES:
        search_fn = SEARCH_FUNCTIONS.get(source_type)
        if search_fn:
            futures.append(search_fn(query))

    results = [f.result() for f in futures]
    return results


# =============================================================================
# TASK: Sintetizar hallazgos en key findings
# =============================================================================

@task
def synthesize_findings(topic: str, all_results: list[dict]) -> list[dict]:
    """Sintetiza resultados de búsqueda en hallazgos clave."""
    results_text = ""
    for i, result in enumerate(all_results, 1):
        results_text += (
            f"\nFuente {i} ({result['source_type']}): {result['content']}\n"
        )

    response = model.invoke(
        f"Eres un analista de investigación. Basándote en estas fuentes, "
        f"identifica 3-5 hallazgos clave sobre '{topic}'.\n\n"
        f"Fuentes:\n{results_text}\n\n"
        f"Responde en JSON (sin markdown, sin ```json):\n"
        f'[{{"title": "título corto", "description": "descripción de 1-2 oraciones", '
        f'"confidence": 0.8}}]\n\n'
        f"confidence es de 0.0 a 1.0. Solo el JSON, nada más."
    )

    try:
        findings = json.loads(response.content)
        return findings[:5]
    except json.JSONDecodeError:
        return [{
            "title": "Hallazgo general",
            "description": f"La investigación sobre {topic} muestra resultados relevantes en múltiples fuentes.",
            "confidence": 0.6,
        }]


# =============================================================================
# TASK: Generar resumen ejecutivo
# =============================================================================

@task
def generate_summary(topic: str, findings: list[dict]) -> str:
    """Genera un resumen ejecutivo de 2-3 oraciones."""
    findings_text = "\n".join(
        f"- {f['title']}: {f['description']}" for f in findings
    )

    response = model.invoke(
        f"Genera un resumen ejecutivo de 2-3 oraciones sobre la investigación "
        f"del tema '{topic}'.\n\n"
        f"Hallazgos principales:\n{findings_text}\n\n"
        f"Solo el resumen, sin título ni formato extra."
    )
    return response.content.strip()


# =============================================================================
# ENTRYPOINT: Agente de investigación principal
# =============================================================================

memory = MemorySaver()


@entrypoint(checkpointer=memory)
def research_agent(topic: str) -> dict:
    """
    AI Research Assistant v1.
    Flujo: topic → decompose → search (parallel) → synthesize → report.
    """
    print(f"\n{'=' * 60}")
    print(f"  🔬 AI Research Assistant v1")
    print(f"  Tema: {topic}")
    print(f"{'=' * 60}")

    # --- Paso 1: Descomponer el tema ---
    print(f"\n📋 Paso 1: Descomponiendo tema en sub-queries...")
    sub_queries_raw = decompose_query(topic).result()
    sub_queries = [SubQuery(**sq) for sq in sub_queries_raw]
    print(f"   ✓ {len(sub_queries)} sub-queries generadas:")
    for i, sq in enumerate(sub_queries, 1):
        print(f"     {i}. {sq.query}")

    # --- Paso 2: Buscar en paralelo ---
    print(f"\n🔍 Paso 2: Buscando en {len(SEARCH_SOURCES)} fuentes por sub-query...")
    all_results = []
    search_futures = [
        search_all_sources(sq.query) for sq in sub_queries
    ]

    for i, future in enumerate(search_futures):
        results = future.result()
        all_results.extend(results)
        print(f"   ✓ Sub-query {i + 1}: {len(results)} fuentes consultadas")

    print(f"   Total: {len(all_results)} resultados recopilados")

    # --- Paso 3: Sintetizar hallazgos ---
    print(f"\n🧠 Paso 3: Sintetizando hallazgos...")
    findings_raw = synthesize_findings(topic, all_results).result()
    findings = [KeyFinding(**f) for f in findings_raw]
    print(f"   ✓ {len(findings)} hallazgos identificados:")
    for i, f in enumerate(findings, 1):
        print(f"     {i}. [{f.confidence:.0%}] {f.title}")

    # --- Paso 4: Generar resumen ---
    print(f"\n📝 Paso 4: Generando resumen ejecutivo...")
    summary = generate_summary(topic, findings_raw).result()
    print(f"   ✓ Resumen generado ({len(summary)} chars)")

    # --- Paso 5: Calcular confianza ---
    print(f"\n📊 Paso 5: Calculando confianza del reporte...")
    avg_relevance = (
        sum(r["relevance"] for r in all_results) / len(all_results)
        if all_results
        else 0.5
    )
    confidence = calculate_confidence(
        num_sources=len(all_results),
        avg_relevance=avg_relevance,
        num_findings=len(findings),
    ).result()
    print(f"   ✓ Confianza: {confidence:.0%}")

    # --- Paso 6: Construir reporte structured ---
    print(f"\n📄 Paso 6: Construyendo reporte...")
    sources = [
        Source(
            name=r["source_name"],
            source_type=r["source_type"],
            content=r["content"],
        )
        for r in all_results
    ]

    report = ResearchReport(
        topic=topic,
        summary=summary,
        key_findings=findings,
        sources=sources,
        sub_queries=[sq.query for sq in sub_queries],
        confidence=confidence,
    )

    print(f"   ✓ Reporte generado exitosamente")
    print(f"\n{'=' * 60}")

    return report.model_dump()

Analicemos las decisiones de diseño:

¿Por qué decompose_query pide JSON? Porque necesitamos structured output del LLM. En v1 usamos JSON parsing directo con un fallback. En módulos posteriores podrías usar with_structured_output de LangChain para mayor robustez.

¿Por qué búsqueda en paralelo con futures? Cada search_all_sources lanza las 3 búsquedas (web, academic, news) en paralelo usando futures de @task. Y las búsquedas por sub-query también se lanzan en paralelo. Esto es significativamente más rápido que secuencial.

¿Por qué Pydantic para el reporte? El output structured es fundamental. Cualquier sistema downstream (API, dashboard, base de datos) puede consumir el reporte porque tiene un schema definido. No es un string libre que necesita parsing.

¿Por qué MemorySaver? El checkpointer habilita durabilidad. Si el agente falla a mitad de ejecución, puede resumir desde el último checkpoint. En M8 lo reemplazarás con PostgresSaver para persistencia real.


Paso 5: CLI (main.py)

El CLI es la interfaz de usuario del sistema. En v1 es simple: recibe un tema por input o argumento, ejecuta el agente, y muestra el reporte formateado.

"""
main.py
CLI entrypoint para el AI Research Assistant.
"""

import sys
import json
import uuid

sys.path.insert(0, ".")

from agents.researcher import research_agent


def format_report(report: dict) -> str:
    """Formatea el reporte para display en terminal."""
    lines = []
    lines.append("")
    lines.append("╔" + "═" * 58 + "╗")
    lines.append("║" + "  📄 REPORTE DE INVESTIGACIÓN".center(58) + "║")
    lines.append("╚" + "═" * 58 + "╝")

    lines.append(f"\n📌 Tema: {report['topic']}")
    lines.append(f"📅 Generado: {report['generated_at']}")
    lines.append(f"🎯 Confianza: {report['confidence']:.0%}")

    lines.append(f"\n{'─' * 60}")
    lines.append("📋 RESUMEN EJECUTIVO")
    lines.append(f"{'─' * 60}")
    lines.append(report["summary"])

    lines.append(f"\n{'─' * 60}")
    lines.append("🔍 SUB-QUERIES INVESTIGADAS")
    lines.append(f"{'─' * 60}")
    for i, sq in enumerate(report["sub_queries"], 1):
        lines.append(f"  {i}. {sq}")

    lines.append(f"\n{'─' * 60}")
    lines.append("💡 HALLAZGOS PRINCIPALES")
    lines.append(f"{'─' * 60}")
    for i, finding in enumerate(report["key_findings"], 1):
        conf = finding["confidence"]
        lines.append(f"\n  {i}. {finding['title']} [{conf:.0%} confianza]")
        lines.append(f"     {finding['description']}")

    lines.append(f"\n{'─' * 60}")
    lines.append(f"📚 FUENTES CONSULTADAS ({len(report['sources'])})")
    lines.append(f"{'─' * 60}")
    seen = set()
    for source in report["sources"]:
        key = f"{source['name']}:{source['source_type']}"
        if key not in seen:
            seen.add(key)
            lines.append(f"  • [{source['source_type'].upper()}] {source['name']}")

    lines.append(f"\n{'═' * 60}")

    return "\n".join(lines)


def run_interactive():
    """Modo interactivo: el usuario escribe temas."""
    print("=" * 60)
    print("  🔬 AI Research Assistant v1")
    print("  Escribe un tema para investigar.")
    print("  Comandos: 'salir' para terminar")
    print("=" * 60)

    while True:
        try:
            topic = input("\n🔎 Tema: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\n¡Hasta luego!")
            break

        if not topic:
            continue

        if topic.lower() in ("salir", "exit", "quit"):
            print("\n¡Hasta luego!")
            break

        thread_id = f"research-{uuid.uuid4().hex[:8]}"

        try:
            report = research_agent.invoke(
                topic,
                config={"configurable": {"thread_id": thread_id}},
            )
            print(format_report(report))

        except Exception as e:
            print(f"\n❌ Error durante la investigación: {e}")
            print("   Intenta con otro tema.")


def run_single(topic: str):
    """Ejecuta una sola investigación."""
    thread_id = f"research-{uuid.uuid4().hex[:8]}"

    report = research_agent.invoke(
        topic,
        config={"configurable": {"thread_id": thread_id}},
    )
    print(format_report(report))

    print("\n📦 Reporte JSON:")
    print(json.dumps(report, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    if len(sys.argv) > 1:
        run_single(" ".join(sys.argv[1:]))
    else:
        run_interactive()

El CLI tiene dos modos:

  • Interactivo: python main.py — loop donde el usuario escribe temas
  • Single shot: python main.py "impacto de LLMs en educación" — una investigación y sale

El reporte se muestra formateado para la terminal y también como JSON crudo. El JSON es útil para verificar que el structured output es válido.


Paso 6: requirements.txt

# requirements.txt
langchain>=0.3.0
langgraph>=0.3.0
langchain-openai>=0.3.0
pydantic>=2.0.0
python-dotenv>=1.0.0

Código completo por archivo

Para referencia rápida, aquí están todos los archivos consolidados. Si seguiste los pasos anteriores, ya los tienes. Si prefieres copiar y ejecutar directamente, aquí están.

state/research_state.py

"""
state/research_state.py
Modelos de datos para el AI Research Assistant.
"""

from pydantic import BaseModel, Field
from datetime import datetime


class Source(BaseModel):
    name: str = Field(description="Nombre de la fuente")
    source_type: str = Field(description="Tipo: web, academic, news")
    content: str = Field(description="Contenido extraído de la fuente")


class KeyFinding(BaseModel):
    title: str = Field(description="Título del hallazgo")
    description: str = Field(description="Descripción del hallazgo")
    confidence: float = Field(
        description="Confianza en el hallazgo (0.0 a 1.0)",
        ge=0.0,
        le=1.0,
    )


class ResearchReport(BaseModel):
    topic: str = Field(description="Tema investigado")
    summary: str = Field(description="Resumen ejecutivo (2-3 oraciones)")
    key_findings: list[KeyFinding] = Field(
        description="Hallazgos principales",
        min_length=1,
    )
    sources: list[Source] = Field(
        description="Fuentes consultadas",
        min_length=1,
    )
    sub_queries: list[str] = Field(
        description="Sub-preguntas generadas para la investigación",
    )
    confidence: float = Field(
        description="Confianza general del reporte (0.0 a 1.0)",
        ge=0.0,
        le=1.0,
    )
    generated_at: str = Field(
        default_factory=lambda: datetime.now().isoformat(),
        description="Timestamp de generación",
    )


class SubQuery(BaseModel):
    query: str = Field(description="La sub-pregunta")
    rationale: str = Field(description="Por qué esta pregunta es relevante")

config/settings.py

"""
config/settings.py
Configuración del AI Research Assistant.
"""

from dotenv import load_dotenv
load_dotenv()

MODEL_NAME = "openai:gpt-4.1-mini"
MODEL_TEMPERATURE = 0.2

MAX_SUB_QUERIES = 4
MAX_SOURCES_PER_QUERY = 3

SEARCH_SOURCES = ["web", "academic", "news"]

tools/web_search.py

"""
tools/web_search.py
Mock web search tool para el AI Research Assistant.
"""

import hashlib
from langgraph.func import task


MOCK_RESULTS = {
    "web": {
        "default": (
            "Múltiples fuentes web coinciden en que {query} es un tema de creciente "
            "interés. Expertos destacan avances significativos en los últimos 2 años. "
            "Las aplicaciones prácticas incluyen automatización, análisis de datos "
            "y generación de contenido."
        ),
    },
    "academic": {
        "default": (
            "Investigación reciente (2025-2026) muestra que {query} tiene fundamentos "
            "teóricos sólidos respaldados por múltiples estudios peer-reviewed. "
            "Los resultados experimentales demuestran mejoras del 40-60% en métricas "
            "clave comparados con métodos tradicionales."
        ),
    },
    "news": {
        "default": (
            "Noticias recientes reportan que {query} está generando impacto en la "
            "industria. Empresas líderes como Google, Microsoft y startups emergentes "
            "están invirtiendo significativamente en esta área. Se esperan desarrollos "
            "importantes para finales de 2026."
        ),
    },
}


def _generate_deterministic_score(query: str, source_type: str) -> float:
    hash_input = f"{query}:{source_type}"
    hash_value = int(hashlib.md5(hash_input.encode()).hexdigest()[:8], 16)
    return round(0.5 + (hash_value % 50) / 100, 2)


@task
def search_web(query: str) -> dict:
    """Busca información en la web general (mock)."""
    content = MOCK_RESULTS["web"]["default"].format(query=query)
    return {
        "source_name": "Web Search",
        "source_type": "web",
        "content": content,
        "relevance": _generate_deterministic_score(query, "web"),
    }


@task
def search_academic(query: str) -> dict:
    """Busca en fuentes académicas (mock)."""
    content = MOCK_RESULTS["academic"]["default"].format(query=query)
    return {
        "source_name": "Academic Database",
        "source_type": "academic",
        "content": content,
        "relevance": _generate_deterministic_score(query, "academic"),
    }


@task
def search_news(query: str) -> dict:
    """Busca en noticias recientes (mock)."""
    content = MOCK_RESULTS["news"]["default"].format(query=query)
    return {
        "source_name": "News Aggregator",
        "source_type": "news",
        "content": content,
        "relevance": _generate_deterministic_score(query, "news"),
    }


SEARCH_FUNCTIONS = {
    "web": search_web,
    "academic": search_academic,
    "news": search_news,
}

tools/calculator.py

"""
tools/calculator.py
Calculator tool para el AI Research Assistant.
"""

from langgraph.func import task


@task
def calculate_confidence(
    num_sources: int,
    avg_relevance: float,
    num_findings: int,
) -> float:
    source_factor = min(num_sources / 5, 1.0) * 0.4
    relevance_factor = avg_relevance * 0.4
    findings_factor = min(num_findings / 5, 1.0) * 0.2

    confidence = source_factor + relevance_factor + findings_factor
    return round(min(confidence, 1.0), 2)

agents/researcher.py

"""
agents/researcher.py
Agente principal del AI Research Assistant (v1).
"""

import json
from langchain.chat_models import init_chat_model
from langgraph.func import entrypoint, task
from langgraph.checkpoint.memory import MemorySaver

import sys
sys.path.insert(0, ".")

from config.settings import (
    MODEL_NAME,
    MODEL_TEMPERATURE,
    MAX_SUB_QUERIES,
    SEARCH_SOURCES,
)
from state.research_state import (
    ResearchReport,
    Source,
    KeyFinding,
    SubQuery,
)
from tools.web_search import SEARCH_FUNCTIONS
from tools.calculator import calculate_confidence


model = init_chat_model(MODEL_NAME, temperature=MODEL_TEMPERATURE)


@task
def decompose_query(topic: str) -> list[dict]:
    """Descompone un tema de investigación en sub-preguntas específicas."""
    response = model.invoke(
        f"Eres un investigador experto. Descompone este tema en "
        f"{MAX_SUB_QUERIES} sub-preguntas específicas e investigables.\n\n"
        f"Tema: {topic}\n\n"
        f"Responde en JSON (sin markdown, sin ```json):\n"
        f'[{{"query": "sub-pregunta", "rationale": "por qué es relevante"}}]\n\n'
        f"Solo el JSON, nada más."
    )
    try:
        queries = json.loads(response.content)
        return queries[:MAX_SUB_QUERIES]
    except json.JSONDecodeError:
        return [
            {"query": topic, "rationale": "Query original como fallback"},
            {"query": f"avances recientes en {topic}", "rationale": "Tendencias actuales"},
            {"query": f"aplicaciones prácticas de {topic}", "rationale": "Uso real"},
        ]


@task
def search_all_sources(query: str) -> list[dict]:
    """Busca en todas las fuentes configuradas para una query."""
    futures = []
    for source_type in SEARCH_SOURCES:
        search_fn = SEARCH_FUNCTIONS.get(source_type)
        if search_fn:
            futures.append(search_fn(query))
    results = [f.result() for f in futures]
    return results


@task
def synthesize_findings(topic: str, all_results: list[dict]) -> list[dict]:
    """Sintetiza resultados de búsqueda en hallazgos clave."""
    results_text = ""
    for i, result in enumerate(all_results, 1):
        results_text += (
            f"\nFuente {i} ({result['source_type']}): {result['content']}\n"
        )

    response = model.invoke(
        f"Eres un analista de investigación. Basándote en estas fuentes, "
        f"identifica 3-5 hallazgos clave sobre '{topic}'.\n\n"
        f"Fuentes:\n{results_text}\n\n"
        f"Responde en JSON (sin markdown, sin ```json):\n"
        f'[{{"title": "título corto", "description": "descripción de 1-2 oraciones", '
        f'"confidence": 0.8}}]\n\n'
        f"confidence es de 0.0 a 1.0. Solo el JSON, nada más."
    )
    try:
        findings = json.loads(response.content)
        return findings[:5]
    except json.JSONDecodeError:
        return [{
            "title": "Hallazgo general",
            "description": f"La investigación sobre {topic} muestra resultados relevantes.",
            "confidence": 0.6,
        }]


@task
def generate_summary(topic: str, findings: list[dict]) -> str:
    """Genera un resumen ejecutivo de 2-3 oraciones."""
    findings_text = "\n".join(
        f"- {f['title']}: {f['description']}" for f in findings
    )
    response = model.invoke(
        f"Genera un resumen ejecutivo de 2-3 oraciones sobre la investigación "
        f"del tema '{topic}'.\n\n"
        f"Hallazgos principales:\n{findings_text}\n\n"
        f"Solo el resumen, sin título ni formato extra."
    )
    return response.content.strip()


memory = MemorySaver()


@entrypoint(checkpointer=memory)
def research_agent(topic: str) -> dict:
    """
    AI Research Assistant v1.
    Flujo: topic → decompose → search (parallel) → synthesize → report.
    """
    print(f"\n{'=' * 60}")
    print(f"  🔬 AI Research Assistant v1")
    print(f"  Tema: {topic}")
    print(f"{'=' * 60}")

    # Paso 1: Descomponer
    print(f"\n📋 Paso 1: Descomponiendo tema en sub-queries...")
    sub_queries_raw = decompose_query(topic).result()
    sub_queries = [SubQuery(**sq) for sq in sub_queries_raw]
    print(f"   ✓ {len(sub_queries)} sub-queries generadas:")
    for i, sq in enumerate(sub_queries, 1):
        print(f"     {i}. {sq.query}")

    # Paso 2: Buscar en paralelo
    print(f"\n🔍 Paso 2: Buscando en {len(SEARCH_SOURCES)} fuentes por sub-query...")
    all_results = []
    search_futures = [search_all_sources(sq.query) for sq in sub_queries]

    for i, future in enumerate(search_futures):
        results = future.result()
        all_results.extend(results)
        print(f"   ✓ Sub-query {i + 1}: {len(results)} fuentes consultadas")

    print(f"   Total: {len(all_results)} resultados recopilados")

    # Paso 3: Sintetizar
    print(f"\n🧠 Paso 3: Sintetizando hallazgos...")
    findings_raw = synthesize_findings(topic, all_results).result()
    findings = [KeyFinding(**f) for f in findings_raw]
    print(f"   ✓ {len(findings)} hallazgos identificados:")
    for i, f in enumerate(findings, 1):
        print(f"     {i}. [{f.confidence:.0%}] {f.title}")

    # Paso 4: Resumen
    print(f"\n📝 Paso 4: Generando resumen ejecutivo...")
    summary = generate_summary(topic, findings_raw).result()
    print(f"   ✓ Resumen generado ({len(summary)} chars)")

    # Paso 5: Confianza
    print(f"\n📊 Paso 5: Calculando confianza del reporte...")
    avg_relevance = (
        sum(r["relevance"] for r in all_results) / len(all_results)
        if all_results else 0.5
    )
    confidence = calculate_confidence(
        num_sources=len(all_results),
        avg_relevance=avg_relevance,
        num_findings=len(findings),
    ).result()
    print(f"   ✓ Confianza: {confidence:.0%}")

    # Paso 6: Reporte
    print(f"\n📄 Paso 6: Construyendo reporte...")
    sources = [
        Source(
            name=r["source_name"],
            source_type=r["source_type"],
            content=r["content"],
        )
        for r in all_results
    ]

    report = ResearchReport(
        topic=topic,
        summary=summary,
        key_findings=findings,
        sources=sources,
        sub_queries=[sq.query for sq in sub_queries],
        confidence=confidence,
    )

    print(f"   ✓ Reporte generado exitosamente")
    print(f"\n{'=' * 60}")

    return report.model_dump()

main.py

"""
main.py
CLI entrypoint para el AI Research Assistant.
"""

import sys
import json
import uuid

sys.path.insert(0, ".")

from agents.researcher import research_agent


def format_report(report: dict) -> str:
    lines = []
    lines.append("")
    lines.append("╔" + "═" * 58 + "╗")
    lines.append("║" + "  📄 REPORTE DE INVESTIGACIÓN".center(58) + "║")
    lines.append("╚" + "═" * 58 + "╝")

    lines.append(f"\n📌 Tema: {report['topic']}")
    lines.append(f"📅 Generado: {report['generated_at']}")
    lines.append(f"🎯 Confianza: {report['confidence']:.0%}")

    lines.append(f"\n{'─' * 60}")
    lines.append("📋 RESUMEN EJECUTIVO")
    lines.append(f"{'─' * 60}")
    lines.append(report["summary"])

    lines.append(f"\n{'─' * 60}")
    lines.append("🔍 SUB-QUERIES INVESTIGADAS")
    lines.append(f"{'─' * 60}")
    for i, sq in enumerate(report["sub_queries"], 1):
        lines.append(f"  {i}. {sq}")

    lines.append(f"\n{'─' * 60}")
    lines.append("💡 HALLAZGOS PRINCIPALES")
    lines.append(f"{'─' * 60}")
    for i, finding in enumerate(report["key_findings"], 1):
        conf = finding["confidence"]
        lines.append(f"\n  {i}. {finding['title']} [{conf:.0%} confianza]")
        lines.append(f"     {finding['description']}")

    lines.append(f"\n{'─' * 60}")
    lines.append(f"📚 FUENTES CONSULTADAS ({len(report['sources'])})")
    lines.append(f"{'─' * 60}")
    seen = set()
    for source in report["sources"]:
        key = f"{source['name']}:{source['source_type']}"
        if key not in seen:
            seen.add(key)
            lines.append(f"  • [{source['source_type'].upper()}] {source['name']}")

    lines.append(f"\n{'═' * 60}")

    return "\n".join(lines)


def run_interactive():
    print("=" * 60)
    print("  🔬 AI Research Assistant v1")
    print("  Escribe un tema para investigar.")
    print("  Comandos: 'salir' para terminar")
    print("=" * 60)

    while True:
        try:
            topic = input("\n🔎 Tema: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\n¡Hasta luego!")
            break

        if not topic:
            continue

        if topic.lower() in ("salir", "exit", "quit"):
            print("\n¡Hasta luego!")
            break

        thread_id = f"research-{uuid.uuid4().hex[:8]}"

        try:
            report = research_agent.invoke(
                topic,
                config={"configurable": {"thread_id": thread_id}},
            )
            print(format_report(report))

        except Exception as e:
            print(f"\n❌ Error durante la investigación: {e}")
            print("   Intenta con otro tema.")


def run_single(topic: str):
    thread_id = f"research-{uuid.uuid4().hex[:8]}"

    report = research_agent.invoke(
        topic,
        config={"configurable": {"thread_id": thread_id}},
    )
    print(format_report(report))

    print("\n📦 Reporte JSON:")
    print(json.dumps(report, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    if len(sys.argv) > 1:
        run_single(" ".join(sys.argv[1:]))
    else:
        run_interactive()

Ejecución

Modo single (una investigación)

cd research-assistant
python main.py "impacto de la inteligencia artificial en la educación"

Modo interactivo

cd research-assistant
python main.py

Criterios de éxito

Tu proyecto está completo cuando cumples estos criterios:

  • El agente descompone el tema en 3+ sub-queries — el LLM genera sub-preguntas específicas y relevantes a partir del tema dado
  • Las búsquedas se ejecutan en paralelo — las 3 fuentes (web, academic, news) se buscan simultáneamente para cada sub-query usando el pattern de futures
  • El reporte final es structured (Pydantic) — el output es un ResearchReport válido con todos los campos requeridos: topic, summary, key_findings, sources, sub_queries, confidence
  • El streaming muestra progreso — durante la ejecución ves cada paso completarse: descomposición, búsqueda, síntesis, resumen, confianza, reporte
  • El CLI funciona en ambos modos — interactivo (sin argumentos) y single-shot (con argumento)
  • El JSON del reporte es válido — puedes copiar el output JSON y parsearlo sin errores

Escenarios de prueba

Test 1: Tema técnico

🔎 Tema: machine learning aplicado a diagnóstico médico

============================================================
  🔬 AI Research Assistant v1
  Tema: machine learning aplicado a diagnóstico médico
============================================================

📋 Paso 1: Descomponiendo tema en sub-queries...
   ✓ 4 sub-queries generadas:
     1. ¿Qué algoritmos de ML se usan más en diagnóstico médico?
     2. ¿Cuál es la precisión actual de los modelos de ML en detección de enfermedades?
     3. ¿Qué desafíos éticos plantea el uso de ML en medicina?
     4. ¿Qué hospitales o instituciones están implementando ML en diagnóstico?

🔍 Paso 2: Buscando en 3 fuentes por sub-query...
   ✓ Sub-query 1: 3 fuentes consultadas
   ✓ Sub-query 2: 3 fuentes consultadas
   ✓ Sub-query 3: 3 fuentes consultadas
   ✓ Sub-query 4: 3 fuentes consultadas
   Total: 12 resultados recopilados

🧠 Paso 3: Sintetizando hallazgos...
   ✓ 4 hallazgos identificados:
     1. [85%] Algoritmos predominantes en diagnóstico
     2. [80%] Precisión comparable a especialistas
     3. [75%] Desafíos éticos significativos
     4. [70%] Adopción institucional creciente

📝 Paso 4: Generando resumen ejecutivo...
   ✓ Resumen generado (180 chars)

📊 Paso 5: Calculando confianza del reporte...
   ✓ Confianza: 82%

📄 Paso 6: Construyendo reporte...
   ✓ Reporte generado exitosamente

============================================================

╔══════════════════════════════════════════════════════════╗
║            📄 REPORTE DE INVESTIGACIÓN                  ║
╚══════════════════════════════════════════════════════════╝

📌 Tema: machine learning aplicado a diagnóstico médico
📅 Generado: 2026-03-08T...
🎯 Confianza: 82%

────────────────────────────────────────────────────────────
📋 RESUMEN EJECUTIVO
────────────────────────────────────────────────────────────
La aplicación de machine learning en diagnóstico médico muestra
avances significativos con precisión comparable a especialistas...

────────────────────────────────────────────────────────────
💡 HALLAZGOS PRINCIPALES
────────────────────────────────────────────────────────────

  1. Algoritmos predominantes en diagnóstico [85% confianza]
     Deep learning y redes neuronales convolucionales lideran...

Test 2: Tema general

🔎 Tema: tendencias en trabajo remoto 2026

[El agente genera sub-queries como: regulaciones laborales,
herramientas de productividad, impacto en salud mental,
modelos híbridos, etc. El reporte sintetiza fuentes web,
académicas y de noticias.]

Test 3: Tema corto y vago

🔎 Tema: Python

[El agente descompone "Python" en sub-queries más específicas:
historia y evolución, aplicaciones principales, comparación
con otros lenguajes, ecosistema de librerías. Demuestra que
el agente agrega valor incluso con input vago.]

Test 4: Verificar JSON structured

python main.py "energías renovables" 2>/dev/null | grep -A 999 "Reporte JSON" | python -m json.tool

Si el JSON se parsea sin errores, el structured output es válido.


Errores comunes

1. ModuleNotFoundError: No module named 'config' o 'state' o 'tools'

Causa: Python no encuentra los módulos porque no estás ejecutando desde el directorio correcto.

Solución: Ejecuta siempre desde la raíz del proyecto:

cd research-assistant
python main.py

El sys.path.insert(0, ".") en los archivos asegura que Python busque módulos en el directorio actual. Si ejecutas desde otro directorio, no los encontrará.

2. json.JSONDecodeError al parsear respuesta del LLM

Causa: El LLM a veces envuelve el JSON en markdown (```json ... ```) o agrega texto antes/después. Esto es especialmente común con modelos más pequeños.

Solución: El código ya tiene fallbacks para este caso. Si ocurre frecuentemente, puedes agregar un paso de limpieza:

import re

def clean_json(text: str) -> str:
    """Extrae JSON de una respuesta que puede incluir markdown."""
    match = re.search(r'\[.*\]', text, re.DOTALL)
    if match:
        return match.group(0)
    return text

3. El agente genera solo 1-2 sub-queries en vez de 4

Causa: El LLM puede generar menos queries de las pedidas si considera que el tema es simple, o si el JSON parsing trunca resultados.

Solución: El fallback en decompose_query genera 3 queries por defecto si el parsing falla. Si necesitas exactamente N queries, puedes agregar validación:

while len(queries) < MAX_SUB_QUERIES:
    queries.append({
        "query": f"aspecto adicional de {topic}",
        "rationale": "Completar mínimo de sub-queries",
    })

4. ValidationError de Pydantic al construir el reporte

Causa: Algún campo del reporte no cumple las validaciones (ej: confidence fuera del rango 0-1, key_findings vacío, etc.).

Solución: Revisa qué campo falla en el mensaje de error. Los errores más comunes:

# confidence fuera de rango
confidence = max(0.0, min(1.0, calculated_value))

# key_findings vacío
if not findings:
    findings = [KeyFinding(
        title="Sin hallazgos específicos",
        description="No se identificaron hallazgos con la información disponible.",
        confidence=0.3,
    )]

5. Las búsquedas no se ejecutan en paralelo

Causa: Si el checkpointer no está activo o hay un error en la configuración de futures, las tareas se ejecutan secuencialmente.

Solución: Verifica que estás usando el patrón correcto de futures — lanzar todas las tareas primero, y luego recoger resultados:

# ✅ Correcto: lanzar todas primero
futures = [search_all_sources(sq.query) for sq in sub_queries]
results = [f.result() for f in futures]

# ❌ Incorrecto: resultado inmediato (secuencial)
results = [search_all_sources(sq.query).result() for sq in sub_queries]

6. OPENAI_API_KEY no encontrada

Causa: El archivo .env no existe, no está en el directorio correcto, o la variable tiene un nombre diferente.

Solución: Verifica:

# Verificar que .env existe en el directorio del proyecto
ls -la research-assistant/.env

# Verificar contenido (sin revelar la key completa)
head -c 20 research-assistant/.env

7. El reporte siempre tiene la misma confianza

Causa: La función calculate_confidence usa métricas fijas (número de fuentes, relevancia promedio, número de hallazgos). Con búsquedas mock, estas métricas varían poco.

Solución: Esto es esperado en v1 con mocks. Cuando en M7 reemplaces los mocks con búsqueda real, las métricas variarán naturalmente según la calidad real de los resultados.

8. TypeError: 'Future' object is not subscriptable

Causa: Estás intentando acceder a un campo del resultado antes de llamar .result():

# ❌ Error: future no es un dict
result = search_web(query)
content = result["content"]

# ✅ Correcto: primero .result()
result = search_web(query).result()
content = result["content"]

Lo que viene: la evolución del Research Assistant

Lo que construiste hoy es v1. Funciona end-to-end, pero tiene limitaciones evidentes. Cada módulo siguiente resuelve una:

MóduloLimitación actualQué se agrega
M7: Flujos AvanzadosLas búsquedas mock nunca fallanRetry logic con backoff, error handling robusto, búsqueda real con fallback
M8: MemoriaCada investigación empieza de ceroPersistencia con PostgresSaver, resumir investigaciones largas, memoria de preferencias del usuario
M9: Human-in-the-LoopEl agente actúa sin supervisiónAprobación antes de acciones costosas, review humano de síntesis, editable state
M10: Multi-AgenteUn solo agente hace todo4 agentes especializados (researcher, analyst, writer, supervisor) coordinados
M11: Deep AgentsPlanning manual en el códigoPlanning autónomo con write_todos, filesystem para almacenar investigación, subagent spawning
M12: ProducciónSin observabilidadLangSmith tracing, evaluation datasets, token tracking, rate limiting

La v1 tiene la arquitectura correcta para soportar esta evolución. Los modelos Pydantic se extenderán. Los @task se reemplazarán con versiones más robustas. El @entrypoint eventualmente se convertirá en un sistema híbrido con StateGraph para los pasos que lo necesiten. La estructura de archivos (agents/, tools/, state/, config/) ya anticipa todo lo que viene.

No borres lo que construiste hoy. Iterarás sobre esto durante 6 módulos más.


Recursos del proyecto

  1. LangGraph Functional API Guide — Documentación oficial de @entrypoint y @task
  2. LangGraph Checkpointing — Cómo funciona MemorySaver y checkpointing
  3. Pydantic v2 Documentation — Modelos de datos, validación, serialización
  4. LangChain init_chat_model — Inicialización de modelos
  5. LangGraph How-To Guides — Patterns prácticos para workflows
  6. Python asyncio and Futures — Conceptos de futures en Python (análogos a los futures de LangGraph)

Módulo 6 — LangChain & LangGraph: From Chains to Agents