fyltr-data-viz / src /visualization_workflow.py
GitHub Action
Deploy data_visualization from GitHub: eef63a23b3bc1ffad4a0dc85f8592e8481938620
7635fd7
Raw
History Blame Contribute Delete
19.8 kB
from typing import TypedDict, List, Dict, Any, Optional, Union, Literal
import re
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from src.visualization_schema import (
VisualizationResult,
EChartsConfig,
SeriesData,
AxisConfig,
ChartType,
FieldInfo
)
import pandas as pd
import numpy as np
import json
import traceback
import time
import os
import io
from collections import Counter
# State definition for visualization processing workflow
class VisualizationProcessingState(TypedDict):
# Input data
query: str
csv_data: str
field_info: List[FieldInfo]
form_title: str
# Processing configuration
processing_method: Literal["structured_llm", "fallback"]
# Intermediate data
parsed_dataframe: Optional[pd.DataFrame]
data_summary: Optional[Dict[str, Any]]
column_analysis: Optional[Dict[str, Any]]
# Results
visualization_result: Optional[VisualizationResult]
error: Optional[str]
success: bool
processing_time: float
# Metadata
processing_complete: bool
usage: Optional[Dict[str, int]]
class VisualizationWorkflow:
"""LangGraph workflow for data visualization generation with structured output."""
def __init__(self):
self.workflow = self._build_workflow()
def _build_workflow(self) -> StateGraph:
"""Build the LangGraph workflow with conditional routing."""
# Create the state graph
workflow = StateGraph(VisualizationProcessingState)
# Add nodes
workflow.add_node("validate_input", self.validate_input_node)
workflow.add_node("parse_data", self.parse_data_node)
workflow.add_node("analyze_data", self.analyze_data_node)
workflow.add_node("generate_visualization", self.generate_visualization_node)
workflow.add_node("finalize_results", self.finalize_results_node)
# Add edges
workflow.add_edge("validate_input", "parse_data")
workflow.add_edge("parse_data", "analyze_data")
workflow.add_edge("analyze_data", "generate_visualization")
workflow.add_edge("generate_visualization", "finalize_results")
workflow.add_edge("finalize_results", END)
# Set entry point
workflow.set_entry_point("validate_input")
return workflow.compile()
def validate_input_node(self, state: VisualizationProcessingState) -> VisualizationProcessingState:
"""Node 1: Validate input and determine processing method."""
try:
# Validate required fields
if not state.get("query") or not state.get("csv_data"):
state["error"] = "Missing required fields: query and csv_data"
state["success"] = False
return state
# Determine processing method
state["processing_method"] = "structured_llm"
print(f"✅ Input validated - Processing method: {state['processing_method']}")
except Exception as e:
state["error"] = f"Input validation error: {str(e)}"
state["success"] = False
print(f"❌ Input validation failed: {str(e)}")
return state
def parse_data_node(self, state: VisualizationProcessingState) -> VisualizationProcessingState:
"""Node 2: Parse CSV data into pandas DataFrame."""
try:
csv_data = state["csv_data"]
# Handle different line endings and encoding issues
csv_data = csv_data.replace('\r\n', '\n').replace('\r', '\n')
df = pd.read_csv(io.StringIO(csv_data))
# Clean column names (remove quotes, spaces)
df.columns = df.columns.str.strip().str.replace('"', '')
# Handle common data type conversions
for col in df.columns:
if df[col].dtype == 'object':
# Try to convert numeric columns
numeric_values = pd.to_numeric(df[col], errors='coerce')
if not numeric_values.isna().all():
df[col] = numeric_values
state["parsed_dataframe"] = df
print(f"✅ Data parsed successfully - Shape: {df.shape}, Columns: {df.columns.tolist()}")
except Exception as e:
error_msg = f"Data parsing error: {str(e)}"
state["error"] = error_msg
state["success"] = False
print(f"❌ {error_msg}")
print(traceback.format_exc())
return state
def analyze_data_node(self, state: VisualizationProcessingState) -> VisualizationProcessingState:
"""Node 3: Analyze the parsed data to understand structure and content."""
try:
df = state["parsed_dataframe"]
if df is None or df.empty:
state["error"] = "No data available for analysis"
state["success"] = False
return state
# Analyze data structure
data_summary = {
"shape": df.shape,
"columns": df.columns.tolist(),
"dtypes": df.dtypes.to_dict(),
"null_counts": df.isnull().sum().to_dict(),
"sample_data": df.head(3).to_dict('records') if len(df) > 0 else []
}
# Analyze each column
column_analysis = {}
for col in df.columns:
col_info = {
"dtype": str(df[col].dtype),
"null_count": df[col].isnull().sum(),
"unique_count": df[col].nunique(),
"sample_values": df[col].dropna().head(5).tolist()
}
# Add statistical info for numeric columns
if pd.api.types.is_numeric_dtype(df[col]):
col_info.update({
"min": float(df[col].min()) if not df[col].isnull().all() else None,
"max": float(df[col].max()) if not df[col].isnull().all() else None,
"mean": float(df[col].mean()) if not df[col].isnull().all() else None,
"std": float(df[col].std()) if not df[col].isnull().all() else None
})
column_analysis[col] = col_info
state["data_summary"] = data_summary
state["column_analysis"] = column_analysis
print(f"✅ Data analysis completed - {len(df.columns)} columns analyzed")
except Exception as e:
error_msg = f"Data analysis error: {str(e)}"
state["error"] = error_msg
state["success"] = False
print(f"❌ {error_msg}")
print(traceback.format_exc())
return state
def generate_visualization_node(self, state: VisualizationProcessingState) -> VisualizationProcessingState:
"""Node 4: Generate visualization using structured LLM output."""
try:
query = state["query"]
df = state["parsed_dataframe"]
data_summary = state["data_summary"]
column_analysis = state["column_analysis"]
field_info = state["field_info"]
form_title = state["form_title"]
if df is None or df.empty:
state["error"] = "No data available for visualization"
state["success"] = False
return state
# Create client for code generation with structured output
client = ChatOpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
model="gpt-5.4-nano",
temperature=0,
max_tokens=3000,
)
# Use structured output to ensure we get only Python code
from pydantic import BaseModel
class CodeResponse(BaseModel):
python_code: str = "Python code that processes the DataFrame and returns result dictionary"
structured_client = client.with_structured_output(CodeResponse, method="function_calling", include_raw=True)
# Generate comprehensive prompt for code generation
prompt = self._generate_code_prompt(
query, df, data_summary, column_analysis, field_info, form_title
)
# Make API call
start_time = time.time()
# invoke returns a dict with 'parsed' (the model) and 'raw' (the generation output) when include_raw=True
response = structured_client.invoke([{"role": "user", "content": prompt}])
processing_time = time.time() - start_time
# Execute the generated code
result = self._execute_generated_code(response['parsed'].python_code, df)
state["visualization_result"] = result
state["processing_time"] = processing_time
state["success"] = result.get('success', False)
# Extract usage
if "raw" in response and hasattr(response["raw"], "usage_metadata"):
usage = response["raw"].usage_metadata
state["usage"] = {
"input_tokens": usage.get("input_tokens", 0),
"output_tokens": usage.get("output_tokens", 0),
"total_tokens": usage.get("total_tokens", 0)
}
print(f"✅ Visualization generation completed in {processing_time:.2f}s")
except Exception as e:
error_msg = f"Visualization generation error: {str(e)}"
state["error"] = error_msg
state["success"] = False
print(f"❌ {error_msg}")
print(traceback.format_exc())
return state
def _generate_code_prompt(
self,
query: str,
df: pd.DataFrame,
data_summary: Dict[str, Any],
column_analysis: Dict[str, Any],
field_info: List[FieldInfo],
form_title: str
) -> str:
"""Generate a prompt for Python code generation that processes data and creates ECharts JSON."""
# Create field type mapping
field_types = {field.label: field.field_type for field in field_info}
# Get sample data for better context
sample_data = df.head(2).to_dict('records') if not df.empty else []
prompt = f"""Generate Python code that processes the existing DataFrame 'df' and returns simple chart data.
QUERY: "{query}"
FORM: {form_title}
DATAFRAME INFO:
- Shape: {df.shape[0]} rows, {df.shape[1]} columns
- Columns: {', '.join(df.columns.tolist())}
- Data types: {df.dtypes.to_dict()}
SAMPLE DATA (first 2 rows):
{json.dumps(sample_data, indent=2, default=str)}
CRITICAL REQUIREMENTS:
1. Use the existing DataFrame variable 'df' (DO NOT create pd.DataFrame or new data)
2. The DataFrame is already loaded and available as 'df'
3. Only use the available columns in the DataFrame
4. Process the data using pandas operations on 'df'
5. Return ONLY the Python code, no explanations
6. DO NOT use import statements - all modules are already available
7. Include ALL variable definitions in your code
AVAILABLE MODULES:
- df: The pandas DataFrame (already loaded)
- pd: pandas module (already imported)
- np: numpy module (already imported)
- Counter: from collections (already imported)
- json: json module (already imported)
CODE REQUIREMENTS:
- Use df.dropna() for missing values
- Use df['column'].str.split(', ') for comma-separated values
- Use .explode() to flatten lists
- Use Counter() for counting (already available)
- Use pd.Series.value_counts() as alternative to Counter
- Limit to top 10-15 items
- Return SIMPLE data structure, not ECharts config
- Define ALL variables before using them in the result
EXAMPLE CODE STRUCTURE:
# Process the data
data_processed = df['column'].dropna()
# ... more processing steps ...
final_data = data_processed.value_counts().head(10)
# Create result with simple data
result = {{
'success': True,
'chart_title': 'Clean Title Here', # NO markdown (#, *, etc.) - just clean text
'chart_type': 'bar', # or 'pie', 'line', 'histogram'
'data': {{
'labels': final_data.index.tolist(),
'values': final_data.values.tolist()
}},
'data_summary': {{'total_records': len(df), 'processed_columns': [...]}},
'reasoning': 'Brief explanation'
}}
CHART TITLE REQUIREMENTS:
- Use clean, simple titles (e.g., "Rating Distribution", "City Preferences")
- NO markdown formatting (#, *, _, etc.)
- NO words like "Chart", "Graph", "Visualization"
- 2-4 words maximum
- Title case format
CRITICAL: Return simple data structure, NOT ECharts configuration. The frontend will handle styling.
Generate the complete Python code:"""
return prompt
def _execute_generated_code(self, code_content: str, df: pd.DataFrame) -> Dict[str, Any]:
"""Execute the generated Python code and return the result."""
try:
# Extract code from markdown if present
if '```python' in code_content:
code = code_content.split('```python')[1].split('```')[0].strip()
elif '```' in code_content:
code = code_content.split('```')[1].strip()
else:
code = code_content.strip()
# Clean up the code by removing explanatory text
lines = code.split('\n')
code_lines = []
for line in lines:
# Skip empty lines and pure explanatory text
if (line.strip() == '' or
line.strip().startswith('Generate') or
line.strip().startswith('Output') or
line.strip().startswith('ai')):
continue
code_lines.append(line)
code = '\n'.join(code_lines).strip()
# Create a safe execution environment
safe_globals = {
'df': df,
'pd': pd,
'np': np,
'json': json,
'Counter': Counter,
'len': len,
'str': str,
'int': int,
'float': float,
'list': list,
'dict': dict,
'sorted': sorted,
'sum': sum,
'max': max,
'min': min,
'round': round,
'zip': zip,
'enumerate': enumerate,
'range': range,
'print': print,
'result': None,
'__builtins__': {
'len': len, 'str': str, 'int': int, 'float': float,
'list': list, 'dict': dict, 'sorted': sorted,
'sum': sum, 'max': max, 'min': min, 'round': round,
'zip': zip, 'enumerate': enumerate, 'range': range, 'print': print
}
}
# Execute the code
exec(code, safe_globals)
# Get the result
if 'result' in safe_globals and safe_globals['result'] is not None:
result = safe_globals['result']
if isinstance(result, dict):
# Convert numpy types to Python native types for JSON serialization
result_str = json.dumps(result, default=str)
result = json.loads(result_str)
return result
else:
return {
'success': False,
'error': 'Generated code did not return a valid result dictionary',
'chart_title': '',
'chart_type': '',
'data': {'labels': [], 'values': []},
'data_summary': {},
'reasoning': ''
}
else:
return {
'success': False,
'error': 'No result variable found in generated code',
'chart_title': '',
'chart_type': '',
'data': {'labels': [], 'values': []},
'data_summary': {},
'reasoning': ''
}
except Exception as e:
return {
'success': False,
'error': f'Code execution error: {str(e)}',
'chart_title': '',
'chart_type': '',
'data': {'labels': [], 'values': []},
'data_summary': {},
'reasoning': ''
}
def finalize_results_node(self, state: VisualizationProcessingState) -> VisualizationProcessingState:
"""Node 5: Finalize and validate results."""
try:
# Clean up chart title - remove markdown formatting
if state.get("visualization_result", {}).get("chart_title"):
title = state["visualization_result"]["chart_title"]
# Remove markdown headers (#, ##, etc.)
title = re.sub(r'^#+\s*', '', title)
# Remove markdown formatting (*, _, etc.)
title = re.sub(r'[*_`]', '', title)
# Clean up extra whitespace
title = title.strip()
# Update the title in the result
if state.get("visualization_result"):
state["visualization_result"]["chart_title"] = title
state["processing_complete"] = True
except Exception as e:
state["error"] = f"Finalization error: {str(e)}"
state["success"] = False
return state
def generate_visualization(
self,
query: str,
csv_data: str,
field_info: List[FieldInfo],
form_title: str
) -> Dict[str, Any]:
"""Run the complete workflow and return results."""
# Initialize state
initial_state = VisualizationProcessingState(
query=query,
csv_data=csv_data,
field_info=field_info,
form_title=form_title,
processing_method="structured_llm",
parsed_dataframe=None,
data_summary=None,
column_analysis=None,
visualization_result=None,
error=None,
success=False,
processing_time=0.0,
processing_complete=False
)
try:
# Run the workflow
final_state = self.workflow.invoke(initial_state)
return {
"success": final_state["success"],
"result": final_state["visualization_result"],
"error": final_state["error"],
"processing_time": final_state["processing_time"],
"usage": final_state.get("usage", {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
}
except Exception as e:
return {
"success": False,
"result": None,
"error": f"Workflow execution failed: {str(e)}",
"processing_time": 0.0
}
# Global workflow instance
workflow_instance = VisualizationWorkflow()