Data Analysis Agent Ollama

Data analysis agent ollama example.

Data Analysis Agent using Ollama (Local LLM) with Pandas.

Run it

python examples/ai/data_analysis_agent_ollama.py
"""
Data Analysis Agent using Ollama (Local LLM) with Pandas.

This example demonstrates how to build an AI agent that can analyze structured data
(CSV, JSON) using pandas for data manipulation and Ollama for natural language insights.

Use cases:
- Business analytics and reporting
- Data exploration and discovery
- Automated insights generation
- Interactive data Q&A

Prerequisites:
    1. Install Ollama: https://ollama.ai
    2. Pull a model: ollama pull llama3.2
    3. Start Ollama service: ollama serve
    4. Install pandas: pip install pandas

Usage:
    # Analyze sample sales data (included)
    flux workflow run data_analysis_agent_ollama '{"file_path": "examples/ai/sample_data/sales_data.csv", "question": "What are the top 5 products by revenue?"}'

    # Ask follow-up questions
    flux workflow resume data_analysis_agent_ollama <execution_id> '{"question": "Which products have declining sales trends?"}'

    # Analyze your own data
    flux workflow run data_analysis_agent_ollama '{"file_path": "/path/to/your/data.csv", "question": "Show me a summary of this data"}'

    # Use a different model
    flux workflow run data_analysis_agent_ollama '{"file_path": "examples/ai/sample_data/sales_data.csv", "question": "Analyze seasonal trends", "model": "qwen2.5:3b"}'
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pandas as pd
from ollama import AsyncClient

from flux import ExecutionContext, task, workflow
from flux.output_storage import LocalFileStorage
from flux.tasks import pause

file_storage = LocalFileStorage()


@task.with_options(output_storage=file_storage)
async def load_data(file_path: str) -> tuple[pd.DataFrame, dict[str, Any]]:
    """
    Load data from CSV or JSON file and generate metadata.

    Args:
        file_path: Path to the data file (CSV or JSON)

    Returns:
        Tuple of (DataFrame, metadata dict with statistics)
    """
    try:
        path = Path(file_path)
        if not path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")

        # Load based on file extension
        if path.suffix.lower() == ".csv":
            df = pd.read_csv(file_path)
        elif path.suffix.lower() == ".json":
            df = pd.read_json(file_path)
        else:
            raise ValueError(f"Unsupported file format: {path.suffix}. Use .csv or .json")

        # Generate metadata about the dataset
        metadata = {
            "file_name": path.name,
            "row_count": len(df),
            "column_count": len(df.columns),
            "columns": list(df.columns),
            "dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
            "missing_values": df.isnull().sum().to_dict(),
            "sample_rows": df.head(3).to_dict(orient="records"),
        }

        # Add basic statistics for numeric columns
        numeric_cols = df.select_dtypes(include=["number"]).columns
        if len(numeric_cols) > 0:
            metadata["statistics"] = df[numeric_cols].describe().to_dict()

        return df, metadata

    except Exception as e:
        raise RuntimeError(f"Failed to load data: {str(e)}") from e


@task
async def analyze_data(df: pd.DataFrame, question: str) -> str:
    """
    Perform data analysis based on the question.

    This task uses pandas to compute statistics, trends, and insights
    that will be provided to the LLM for natural language interpretation.

    Args:
        df: DataFrame to analyze
        question: User's question about the data

    Returns:
        JSON string with analysis results
    """
    try:
        analysis_results = {}

        # Basic statistics
        numeric_cols = df.select_dtypes(include=["number"]).columns
        if len(numeric_cols) > 0:
            analysis_results["basic_stats"] = df[numeric_cols].describe().to_dict()

        # Top/bottom records by numeric columns
        for col in numeric_cols[:3]:  # Limit to first 3 numeric columns
            analysis_results[f"top_5_by_{col}"] = df.nlargest(5, col)[
                [col] + [c for c in df.columns if c != col][:2]
            ].to_dict(orient="records")

        # Value counts for categorical columns (top 10)
        categorical_cols = df.select_dtypes(include=["object"]).columns
        for col in categorical_cols[:3]:  # Limit to first 3 categorical columns
            value_counts = df[col].value_counts().head(10).to_dict()
            analysis_results[f"{col}_distribution"] = value_counts

        # Correlation matrix for numeric columns
        if len(numeric_cols) > 1:
            corr_matrix = df[numeric_cols].corr().to_dict()
            analysis_results["correlations"] = corr_matrix

        return json.dumps(analysis_results, indent=2, default=str)

    except Exception as e:
        raise RuntimeError(f"Failed to analyze data: {str(e)}") from e


@task.with_options(retry_max_attempts=3, retry_delay=1, retry_backoff=2, timeout=60)
async def call_ollama_for_insights(
    question: str,
    metadata: dict[str, Any],
    analysis_results: str,
    conversation_history: list[dict[str, str]],
    model: str,
    ollama_url: str,
) -> str:
    """
    Call Ollama to generate natural language insights from the data analysis.

    Args:
        question: User's question
        metadata: Dataset metadata
        analysis_results: JSON string with analysis results
        conversation_history: Previous conversation turns
        model: Ollama model to use
        ollama_url: Ollama server URL

    Returns:
        LLM response with insights
    """
    try:
        client = AsyncClient(host=ollama_url)

        # Build context about the data
        data_context = f"""
You are a data analyst AI assistant. You have access to a dataset with the following characteristics:

Dataset Information:
- File: {metadata["file_name"]}
- Rows: {metadata["row_count"]:,}
- Columns: {metadata["column_count"]}
- Column names: {", ".join(metadata["columns"])}
- Data types: {json.dumps(metadata["dtypes"], indent=2)}

Sample Data (first 3 rows):
{json.dumps(metadata["sample_rows"], indent=2)}

Analysis Results:
{analysis_results}

Your task is to answer the user's question based on this data analysis. Be specific, cite numbers, and provide actionable insights. If the data doesn't fully answer the question, acknowledge limitations.
"""

        # Build messages
        messages = [{"role": "system", "content": data_context}]
        messages.extend(conversation_history)
        messages.append({"role": "user", "content": question})

        # Call Ollama
        response = await client.chat(model=model, messages=messages)

        return response["message"]["content"]

    except Exception as e:
        raise RuntimeError(
            f"Failed to call Ollama API: {str(e)}. "
            "Make sure Ollama is running (ollama serve) and the model is available.",
        ) from e


@task.with_options(output_storage=file_storage)
async def process_question(
    df: pd.DataFrame,
    metadata: dict[str, Any],
    question: str,
    conversation_history: list[dict[str, str]],
    model: str,
    ollama_url: str,
) -> tuple[list[dict[str, str]], str]:
    """
    Process a single question about the data.

    Args:
        df: DataFrame to analyze
        metadata: Dataset metadata
        question: User's question
        conversation_history: Previous conversation
        model: Ollama model
        ollama_url: Ollama server URL

    Returns:
        Tuple of (updated conversation history, assistant response)
    """
    # Analyze the data
    analysis_results = await analyze_data(df, question)

    # Get LLM insights
    assistant_response = await call_ollama_for_insights(
        question=question,
        metadata=metadata,
        analysis_results=analysis_results,
        conversation_history=conversation_history,
        model=model,
        ollama_url=ollama_url,
    )

    # Update conversation history
    conversation_history.append({"role": "user", "content": question})
    conversation_history.append({"role": "assistant", "content": assistant_response})

    return conversation_history, assistant_response


@workflow
async def data_analysis_agent_ollama(ctx: ExecutionContext[dict[str, Any]]):
    """
    An AI agent that analyzes structured data using pandas and Ollama.

    This workflow loads a CSV or JSON file, performs statistical analysis,
    and answers natural language questions about the data. It supports
    multi-turn conversations for interactive data exploration.

    Initial Input format:
    {
        "file_path": "path/to/data.csv",  # Required: CSV or JSON file
        "question": "What are the key insights?",  # Required: initial question
        "model": "llama3.2",  # Optional: Ollama model (default: llama3.2)
        "max_turns": 10,  # Optional: max conversation turns
        "ollama_url": "http://localhost:11434"  # Optional: Ollama server URL
    }

    Resume Input format:
    {
        "question": "Tell me more about..."  # Next question
    }

    Returns:
    {
        "status": "success" | "conversation_ended",
        "data_summary": {...},  # Dataset metadata
        "conversation_history": [...],  # Full conversation
        "turn_count": 3,
        "execution_id": "..."
    }
    """
    # Get initial configuration
    initial_input = ctx.input or {}
    file_path = initial_input.get("file_path")
    first_question = initial_input.get("question")
    model = initial_input.get("model", "llama3.2")
    max_turns = initial_input.get("max_turns", 10)
    ollama_url = initial_input.get("ollama_url", "http://localhost:11434")

    # Validate required inputs
    if not file_path:
        return {"error": "No file_path provided in input", "execution_id": ctx.execution_id}
    if not first_question:
        return {"error": "No question provided in input", "execution_id": ctx.execution_id}

    # Load the data (once at the start)
    df, metadata = await load_data(file_path)

    # Initialize conversation history
    conversation_history: list[dict[str, str]] = []

    # Process first question
    conversation_history, first_response = await process_question(
        df=df,
        metadata=metadata,
        question=first_question,
        conversation_history=conversation_history,
        model=model,
        ollama_url=ollama_url,
    )

    # Return after first question with option to continue
    initial_result = {
        "status": "success",
        "response": first_response,
        "data_summary": {
            "file_name": metadata["file_name"],
            "rows": metadata["row_count"],
            "columns": metadata["column_count"],
        },
        "turn_count": 1,
        "execution_id": ctx.execution_id,
        "message": "Ask follow-up questions using: flux workflow resume",
    }

    # Main conversation loop - pause between turns
    for turn in range(1, max_turns):
        # Pause and wait for next question
        resume_input = await pause(f"waiting_for_question_turn_{turn}")

        # Get next question from resume input
        next_question = resume_input.get("question", "") if resume_input else ""
        if not next_question:
            return {
                "status": "conversation_ended",
                "reason": "No question provided",
                "data_summary": initial_result["data_summary"],
                "conversation_history": conversation_history,
                "turn_count": len(conversation_history) // 2,
                "execution_id": ctx.execution_id,
            }

        # Process next question
        conversation_history, next_response = await process_question(
            df=df,
            metadata=metadata,
            question=next_question,
            conversation_history=conversation_history,
            model=model,
            ollama_url=ollama_url,
        )

    # Max turns reached
    return {
        "status": "conversation_ended",
        "reason": f"Maximum turns ({max_turns}) reached",
        "data_summary": initial_result["data_summary"],
        "conversation_history": conversation_history,
        "turn_count": len(conversation_history) // 2,
        "execution_id": ctx.execution_id,
    }


if __name__ == "__main__":  # pragma: no cover
    import asyncio

    # Example: Analyze sample sales data
    sample_file = "examples/ai/sample_data/sales_data.csv"
    initial_input = {
        "file_path": sample_file,
        "question": "What are the top 5 products by revenue?",
        "model": "llama3.2",
        "max_turns": 3,
    }

    async def run_example():
        try:
            # Check if sample file exists
            if not Path(sample_file).exists():
                print(f"Sample file not found: {sample_file}")
                print("Create sample data first by running:")
                print("  python examples/ai/create_sample_data.py")
                return

            # Turn 1
            result = data_analysis_agent_ollama.run(initial_input)
            if result.has_failed:
                raise Exception(f"Workflow failed: {result.output}")

            print(f"\nTurn 1 Response:\n{result.output.get('response', '')}\n")

            # Turn 2
            result = data_analysis_agent_ollama.resume(
                result.execution_id,
                {"question": "Which products have the highest profit margins?"},
            )
            if result.has_failed:
                raise Exception(f"Workflow failed: {result.output}")

            print(f"\nTurn 2 Response:\n{result.output.get('response', '')}\n")

            # Turn 3
            result = data_analysis_agent_ollama.resume(
                result.execution_id,
                {"question": "Are there any seasonal trends in the sales?"},
            )
            if result.has_failed:
                raise Exception(f"Workflow failed: {result.output}")

            print(f"\nTurn 3 Response:\n{result.output.get('response', '')}\n")

            # Display full conversation
            print("\n" + "=" * 60)
            print("Full Conversation History:")
            print("=" * 60)
            for msg in result.output.get("conversation_history", []):
                role = msg["role"].upper()
                content = msg["content"]
                print(f"\n{role}:\n{content}")

        except Exception as e:
            print(f"Error: {e}")
            print("\nMake sure:")
            print("  1. Ollama is running: ollama serve")
            print("  2. You have pulled a model: ollama pull llama3.2")
            print("  3. Sample data exists: python examples/ai/create_sample_data.py")

    asyncio.run(run_example())

Last verified against Flux 0.56.0.