diff --git a/gwserver/modules/BAKcoder.py b/gwserver/modules/BAKcoder.py new file mode 100644 index 00000000..c01d7723 --- /dev/null +++ b/gwserver/modules/BAKcoder.py @@ -0,0 +1,1014 @@ +""" +CoderAgent - A unified agent for developing and executing Python code. +Includes code execution capabilities previously in separate modules. +""" + +import logging +import json +import re +import uuid +import traceback +import os +import subprocess +import tempfile +import shutil +import sys +import pandas as pd +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +from modules.agentservice_base import BaseAgent +from modules.agentservice_utils import FileUtils, WorkflowUtils, MessageUtils, LoggingUtils +from connectors.connector_aichat_openai import ChatService +from modules.agentservice_protocol import AgentMessage, AgentCommunicationProtocol + +logger = logging.getLogger(__name__) + +class SimpleCodeExecutor: + """ + A simplified executor that runs Python code in isolated virtual environments. + """ + + # Class variable to store workflow environments for persistence + _workflow_environments = {} + + def __init__(self, + workflow_id: str = None, + timeout: int = 30, + max_memory_mb: int = 512, + requirements: List[str] = None, + blocked_packages: List[str] = None): + """ + Initialize the SimpleCodeExecutor. + + Args: + workflow_id: Optional workflow ID for persistent environments + timeout: Maximum execution time in seconds + max_memory_mb: Maximum memory in MB + requirements: List of packages to install + blocked_packages: List of blocked packages + """ + self.workflow_id = workflow_id + self.timeout = timeout + self.max_memory_mb = max_memory_mb + self.temp_dir = None + self.requirements = requirements or [] + self.blocked_packages = blocked_packages or [ + "cryptography", "flask", "django", "tornado", # Security risks + "tensorflow", "pytorch", "scikit-learn" # Resource intensive + ] + self.is_persistent = workflow_id is not None + + @classmethod + def get_workflow_environment(cls, workflow_id: str) -> Optional[str]: + """Get an existing workflow environment path if it exists.""" + return cls._workflow_environments.get(workflow_id) + + @classmethod + def set_workflow_environment(cls, workflow_id: str, env_path: str) -> None: + """Store a workflow environment path.""" + cls._workflow_environments[workflow_id] = env_path + + def _create_venv(self) -> str: + """Creates a virtual environment and returns the path.""" + # Check for existing environment if using workflow_id + if self.workflow_id: + self.is_persistent = True + existing_env = self.get_workflow_environment(self.workflow_id) + if existing_env and os.path.exists(existing_env): + logger.info(f"Reusing existing virtual environment: {existing_env}") + self.temp_dir = os.path.dirname(existing_env) + return existing_env + else: + logger.info(f"Creating new environment for workflow {self.workflow_id}") + + # Create a new environment + venv_parent_dir = tempfile.mkdtemp(prefix="simple_exec_") + self.temp_dir = venv_parent_dir + venv_path = os.path.join(venv_parent_dir, "venv") + + try: + # Create virtual environment + logger.info(f"Creating new virtual environment in {venv_path}") + subprocess.run([sys.executable, "-m", "venv", venv_path], + check=True, + capture_output=True) + + # Store the environment path if this is for a specific workflow + if self.workflow_id: + logger.info(f"Registering new persistent environment for workflow {self.workflow_id}") + self.set_workflow_environment(self.workflow_id, venv_path) + + return venv_path + except subprocess.CalledProcessError as e: + logger.error(f"Error creating virtual environment: {e}") + raise RuntimeError(f"Could not create venv: {e}") + + def _get_pip_executable(self, venv_path: str) -> str: + """Gets the path to the pip executable in the virtual environment.""" + if os.name == 'nt': # Windows + return os.path.join(venv_path, "Scripts", "pip.exe") + else: # Unix/Linux + return os.path.join(venv_path, "bin", "pip") + + def _get_python_executable(self, venv_path: str) -> str: + """Gets the path to the Python executable in the virtual environment.""" + if os.name == 'nt': # Windows + return os.path.join(venv_path, "Scripts", "python.exe") + else: # Unix/Linux + return os.path.join(venv_path, "bin", "python") + + def _filter_requirements(self, requirements: List[str]) -> List[str]: + """Filter out blocked packages and invalid requirements.""" + if not requirements: + return [] + + filtered_requirements = [] + for req in requirements: + # Skip empty, comment lines, or invalid requirements + req = req.strip() + if not req or req.startswith('#') or '```' in req or req in ['`', '``', '```']: + logging.warning(f"Skipping comment or invalid requirement: {req}") + continue + + # Extract package name from requirement spec + import re + package_name = re.split(r'[=<>]', req)[0].strip().lower() + + if package_name in self.blocked_packages: + logging.warning(f"Blocked package detected: {package_name}") + continue + + filtered_requirements.append(req) + + return filtered_requirements + + def _install_packages(self, venv_path: str, requirements: List[str]) -> bool: + """Install packages in the virtual environment.""" + if not requirements: + return True + + # Filter requirements + filtered_requirements = self._filter_requirements(requirements) + if not filtered_requirements: + logger.info("No allowed packages to install") + return True + + # Get pip executable + pip_executable = self._get_pip_executable(venv_path) + + # Install packages + try: + logger.info(f"Installing packages: {', '.join(filtered_requirements)}") + result = subprocess.run( + [pip_executable, "install"] + filtered_requirements, + check=True, + capture_output=True, + text=True, + timeout=300 + ) + logger.info("Package installation successful") + return True + except subprocess.CalledProcessError as e: + logger.error(f"Error during package installation: {e.stderr}") + return False + except Exception as e: + logger.error(f"Error during package installation: {str(e)}") + return False + + def _extract_required_packages(self, code: str) -> List[str]: + # Extract required packages from requirements comments in the 1st code line + packages = set() + # Check for special REQUIREMENTS comment - specific format we're looking for + first_lines = code.split('\n')[:5] # Only check first few lines + for line in first_lines: + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + for pkg in req_str.split(','): + if pkg.strip(): + packages.add(pkg.strip()) + return list(packages) + + + def execute_code(self, code: str, input_data: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Execute Python code in an isolated environment using a simple approach. + + Args: + code: Python code to execute + input_data: Optional input data for the code + + Returns: + Dictionary with execution results + """ + logger.info(f"Executing code with workflow_id: {self.workflow_id}") + + # Create or reuse virtual environment + venv_path = self._create_venv() #creating self.temp_dir! + + # Create input_data directory for file handling + input_data_dir = os.path.join(self.temp_dir, "input_data") # Temp dir is at root + os.makedirs(input_data_dir, exist_ok=True) + + # Extract and install required packages + all_requirements = [] + + # Add explicitly provided requirements + # if self.requirements: + # all_requirements.extend(self.requirements) + + # Extract requirements from code + extracted_requirements = self._extract_required_packages(code) + if extracted_requirements: + all_requirements.extend(extracted_requirements) + logger.info(f"Extracted required packages from code: {', '.join(extracted_requirements)}") + + # Install packages if needed + if all_requirements: + logger.info(f"Installing {len(all_requirements)} packages") + install_success = self._install_packages(venv_path, all_requirements) + if not install_success: + # Return error if package installation failed + return { + "success": False, + "output": "", + "error": f"Failed to install required packages: {', '.join(all_requirements)}", + "result": None, + "exit_code": -1 + } + + # Process extracted document content if available + if input_data and "extracted_documents" in input_data: + for doc in input_data["extracted_documents"]: + doc_name = doc["name"] + doc_content = doc["content"] + doc_type = doc["type"] + + # Create file path + file_path = os.path.join(input_data_dir, doc_name) + + try: + # Write content to file + with open(file_path, 'w', encoding='utf-8') as f: + f.write(doc_content) + + # Add to files list if not already there + if "files" not in input_data: + input_data["files"] = [] + + input_data["files"].append({ + "id": f"extracted_{doc_name}", + "name": doc_name, + "type": doc_type, + "path": file_path + }) + + logger.info(f"Created file from extracted content: {doc_name}") + except Exception as e: + logger.error(f"Error creating file from extracted content: {str(e)}") + + # Copy input files to input_data directory if provided + if input_data and "files" in input_data: + for file_info in input_data.get("files", []): + # Skip files we just created from extracted content + if file_info.get("id", "").startswith("extracted_"): + continue + + source_path = file_info.get("path", "") + logger.info(f"Attempting to copy file from: {source_path}") + logger.info(f"File exists: {os.path.exists(source_path)}") + if source_path and os.path.exists(source_path): + # Get just the filename + file_name = os.path.basename(source_path) + # Create destination path in input_data directory + dest_path = os.path.join(input_data_dir, file_name) + + try: + # Copy the file + shutil.copy2(source_path, dest_path) + logger.info(f"Copied file to input_data directory: {dest_path}") + except Exception as e: + logger.error(f"Error copying file {source_path}: {str(e)}") + + # Create a file for the code + code_id = uuid.uuid4().hex[:8] + code_file = os.path.join(self.temp_dir, f"code_{code_id}.py") + + # Write the code as-is without injecting additional loader code + with open(code_file, "w", encoding="utf-8") as f: + f.write(code) + + # Get Python executable + python_executable = self._get_python_executable(venv_path) + logger.info(f"Using Python executable: {python_executable}") + + # Execute code + try: + # Run the code from root dir + working_dir = os.path.dirname(code_file) # This should be the project root + logger.info(f"DEBUG PATH Root: {os.getcwd()} Code: {code_file} Working Dir: {working_dir}") + process = subprocess.run( + [python_executable, code_file], + timeout=self.timeout, + capture_output=True, + text=True, + cwd=self.temp_dir + ) + + # Process the output + stdout = process.stdout + stderr = process.stderr + + # Get result from stdout if available + result_data = None + if process.returncode == 0 and stdout: + try: + # Look for the last line that could be JSON + for line in reversed(stdout.strip().split('\n')): + line = line.strip() + if line and line[0] in '{[' and line[-1] in '}]': + try: + result_data = json.loads(line) + # Successfully parsed JSON result, use it + break + except json.JSONDecodeError: + # Not valid JSON, continue to next line + continue + except Exception as e: + logger.warning(f"Failed to parse result from stdout: {str(e)}") + + # Create result dictionary + execution_result = { + "success": process.returncode == 0, + "output": stdout, + "error": stderr if process.returncode != 0 else "", + "result": result_data, + "exit_code": process.returncode + } + + except subprocess.TimeoutExpired: + logger.error(f"Execution timed out after {self.timeout} seconds") + execution_result = { + "success": False, + "output": "", + "error": f"Execution timed out (timeout after {self.timeout} seconds)", + "result": None, + "exit_code": -1 + } + except Exception as e: + logger.error(f"Execution error: {str(e)}") + execution_result = { + "success": False, + "output": "", + "error": f"Execution error: {str(e)} for code {code}", + "result": None, + "exit_code": -1 + } + + # Clean up temporary code file + try: + if os.path.exists(code_file): + os.remove(code_file) + except Exception as e: + logger.warning(f"Error cleaning up temporary code file: {e}") + + return execution_result + + def cleanup(self): + """Clean up temporary resources.""" + # Skip cleanup for persistent environments + if self.is_persistent and self.workflow_id: + logger.info(f"Skipping cleanup for persistent environment of workflow {self.workflow_id}") + return + + # Clean up temporary directory + if self.temp_dir and os.path.exists(self.temp_dir): + try: + shutil.rmtree(self.temp_dir) + logger.info(f"Deleted temporary directory: {self.temp_dir}") + except Exception as e: + logger.warning(f"Could not delete temporary directory {self.temp_dir}: {e}") + + def __del__(self): + """Clean up during garbage collection.""" + self.cleanup() + + +def get_error_recommendation(error_message: str) -> str: + """Generate recommendations based on error message.""" + if "ImportError" in error_message or "ModuleNotFoundError" in error_message: + return """ +### Recommendation +The error indicates a missing Python module. Try using standard libraries or common data analysis modules. +""" + elif "PermissionError" in error_message: + return """ +### Recommendation +The code doesn't have the necessary permissions to access files or directories. +""" + elif "SyntaxError" in error_message: + return """ +### Recommendation +There's a syntax error in the code. Check for missing parentheses, quotes, colons, or indentation errors. +""" + elif "FileNotFoundError" in error_message: + return """ +### Recommendation +A file could not be found. Check the file path and make sure the file exists. +""" + else: + return """ +### Recommendation +To fix the error: +1. Check the exact error message +2. Simplify the code and test step by step +3. Use try/except blocks for error-prone operations +""" + + +class CoderAgent(BaseAgent): + """Agent for developing and executing Python code""" + + def __init__(self): + """Initialize the coder agent with proper type and capabilities""" + super().__init__() + + # Agent metadata + self.id = "coder" + self.type = "coder" + self.name = "Python Code Agent" + self.description = "Develops and executes Python code" + self.capabilities = "code_development,data_processing,file_processing,automation" + self.result_format = "python_code" + + # Add document capabilities + self.supports_documents = True + self.document_capabilities = ["read", "reference", "create"] + self.required_context = ["workflow_id"] + + # Initialize protocol + self.protocol = AgentCommunicationProtocol() + + # Init utilities + self.file_utils = FileUtils() + self.message_utils = MessageUtils() + + # Executor settings + self.executor_timeout = 60 # seconds + self.executor_memory_limit = 512 # MB + + # AI service settings + self.ai_temperature = 0.2 # Lower temperature for more deterministic code generation + self.ai_max_tokens = 2000 # Enough tokens for complex code + + def get_agent_info(self) -> Dict[str, Any]: + """Get agent information for agent registry""" + info = super().get_agent_info() + info.update({ + "metadata": { + "timeout": self.executor_timeout, + "memory_limit": self.executor_memory_limit + } + }) + return info + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Process a message to develop and execute Python code. + + Args: + message: The message to process + context: Additional context information + + Returns: + Response message + """ + # Extract workflow_id from context or message + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Get or create logging_utils + log_func = context.get("log_func") if context else None + logging_utils = LoggingUtils(workflow_id, log_func) + + # Create response message + response = { + "role": "assistant", + "content": "", + "agent_id": self.id, + "agent_type": self.type, + "agent_name": self.name, + "workflow_id": workflow_id, + "documents": [] + } + + try: + # Extract content and documents + content = message.get("content", "") + documents = message.get("documents", []) + + # Extract code from message content + code_blocks = re.findall(r'```(?:python)?\s*([\s\S]*?)```', content) + code_to_execute = None + requirements = [] + + if code_blocks: + # Use the first code block found + code_to_execute = code_blocks[0] + # Clean the code to remove any markdown formatting + code_to_execute = self._clean_code(code_to_execute) + logging_utils.info(f"Code extracted from message ({len(code_to_execute)} characters)", "agents") + else: + # Generate code based on the message content using AI + logging_utils.info("No code found in message, generating new code with AI", "agents") + + # Generate code using AI + code_to_execute, requirements = await self._generate_code_from_prompt(content, documents) + if not code_to_execute: + logging_utils.warning("AI could not generate code", "agents") + response["content"] = "I couldn't generate executable code based on your request. Please provide more detailed instructions." + self.message_utils.finalize_message(response) + return response + logging_utils.info(f"Code generated with AI ({len(code_to_execute)} characters)", "agents") + + # Execute the code + if code_to_execute: + logging_utils.info("Executing code", "execution") + + # Prepare execution context + execution_context = { + "workflow_id": workflow_id, + "documents": documents, + "message": message, + "log_func": log_func + } + + # Send a status update + if log_func: + status_message = self.protocol.create_status_update_message( + status_description="Processing code execution request", + sender_id=self.id, + status="in_progress", + progress=0.5, + context_id=workflow_id + ) + log_func(workflow_id, status_message.content, "info", self.id, self.name) + + # Execute code + result = await self._execute_code(code_to_execute, requirements, execution_context) + + # Prepare response + if result.get("success", False): + print("DEBUG-RESULT:",result,"#END\n") + # Code execution successful + output = result.get("output", "") + execution_result = result.get("result") + logging_utils.info("Code executed successfully", "execution") + + # Format response content + response_content = f"## Code executed successfully\n\n" + + # Include the executed code + response_content += f"### Executed Code\n\n```python\n{code_to_execute}\n```\n\n" + + # Include the output if available + if output: + response_content += f"### Output\n\n```\n{output}\n```\n\n" + # Create document + data_document = self.create_document_from_result(execution_result) + response["documents"].append(data_document) + print("DEBUG DOC:",data_document) + + # Include the execution result if available + if execution_result: + result_str = json.dumps(execution_result, indent=2) if isinstance(execution_result, (dict, list)) else str(execution_result) + response_content += f"### Result\n\n```\n{result_str}\n```\n\n" + + response["content"] = response_content + + # Process any files created by the code + if isinstance(execution_result, dict) and "created_files" in execution_result: + created_files = execution_result.get("created_files", []) + for file_info in created_files: + file_id = file_info.get("id") + if file_id: + logging_utils.info(f"Adding created file {file_info.get('name', file_id)} to documents", "files") + # Add file document to the response + doc = { + "id": f"doc_{uuid.uuid4()}", + "source": file_info, + "type": "file" + } + response["documents"].append(doc) + else: + # Code execution failed + error = result.get("error", "Unknown error") + logging_utils.error(f"Error during code execution: {error}", "execution") + + # Format error response + response_content = f"## Error during code execution\n\n" + response_content += f"### Executed Code\n\n```python\n{code_to_execute}\n```\n\n" + response_content += f"### Error\n\n```\n{error}\n```\n\n" + + # Add recommendation based on error + response_content += get_error_recommendation(error) + + response["content"] = response_content + else: + # No code to execute + response["content"] = "I couldn't find or generate executable code. Please provide Python code or explain your requirements more clearly." + + # Finalize response + self.message_utils.finalize_message(response) + + # Log success + logging_utils.info("CoderAgent has successfully processed the request", "agents") + + print("DEBUG CODE-RESPONSE:",response) + + return response + + except Exception as e: + error_msg = f"Error during processing by the CoderAgent: {str(e)}" + logging_utils.error(error_msg, "error") + + # Create error response + response["content"] = f"## Processing Error\n\n```\n{error_msg}\n\n{traceback.format_exc()}\n```" + self.message_utils.finalize_message(response) + + return response + + def create_document_from_result(execution_result, output_format="json"): + """ + Create a document object from execution results + + Args: + execution_result: The data returned from code execution + output_format: Desired format (json, csv, etc.) + + Returns: + Document object for passing to other agents + """ + doc_id = f"data_{uuid.uuid4()}" + + # Determine filename and content type based on the data + if isinstance(execution_result, pd.DataFrame): + # Handle DataFrame result + filename = "processed_data.csv" + content_type = "text/csv" + content = execution_result.to_csv(index=False) + elif isinstance(execution_result, dict) or isinstance(execution_result, list): + # Handle dictionary or list result + filename = "processed_data.json" + content_type = "application/json" + content = json.dumps(execution_result) + elif isinstance(execution_result, str): + # Try to determine if string is JSON, CSV, or plain text + if execution_result.strip().startswith('{') or execution_result.strip().startswith('['): + filename = "processed_data.json" + content_type = "application/json" + elif ',' in execution_result and '\n' in execution_result: + filename = "processed_data.csv" + content_type = "text/csv" + else: + filename = "processed_data.txt" + content_type = "text/plain" + content = str(execution_result) + else: + # Default case for other types + filename = "processed_data.txt" + content_type = "text/plain" + content = str(execution_result) + + # Create document object + document = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": filename, + "content_type": content_type, + }, + "contents": [{ + "type": "text", + "text": content, + "is_extracted": True + }] + } + + return document + + def _clean_code(self, code: str) -> str: + """ + Clean up code by removing markdown code block markers and other formatting artifacts. + + Args: + code: The code string to clean + + Returns: + Cleaned code string + """ + import re + + # Remove code block markers at beginning/end + code = re.sub(r'^```(?:python)?\s*', '', code) + code = re.sub(r'```\s*$', '', code) + + # Remove any trailing markdown code blocks that might have been added by the AI + lines = code.split('\n') + clean_lines = [] + + # Flag to track if we're in a trailing markdown section + in_trailing_markdown = False + + for line in reversed(lines): + stripped = line.strip() + + # Check if this line contains only backticks (``` or ` or ``) + if re.match(r'^`{1,3}$', stripped): + in_trailing_markdown = True + continue + + # Check if this is a markdown comment or note + if in_trailing_markdown and (stripped.startswith('#') or + stripped.lower().startswith('note:') or + stripped.lower().startswith('example:')): + continue + + # If we've reached actual code, stop considering trailing markdown + if stripped and not in_trailing_markdown: + in_trailing_markdown = False + + # Add this line if it's not part of trailing markdown + if not in_trailing_markdown: + clean_lines.insert(0, line) + + # Join the lines back together + clean_code = '\n'.join(clean_lines) + + # Final cleanup for any stray backticks + clean_code = re.sub(r'`{1,3}\s*$', '', clean_code) + + return clean_code.strip() + + async def _generate_code_from_prompt(self, prompt: str, documents: List[Dict[str, Any]]) -> Tuple[str, List[str]]: + """ + Generate Python code from a prompt using AI service. + + Args: + prompt: The prompt to generate code from + documents: Documents associated with the prompt + + Returns: + Tuple of (generated Python code, required packages) + """ + try: + # Initialize AI service + chat_service = ChatService() + + # Prepare a prompt for code generation + ai_prompt = f"""Generate Python code to solve the following task: +{prompt} + +Available input files: +""" + # Add information about available documents + if documents: + for i, doc in enumerate(documents): + source = doc.get("source", {}) + doc_name = source.get("name", f"Document {i+1}") + doc_type = source.get("content_type", "unknown") + doc_id = source.get("id", "") + + ai_prompt += f"- {doc_name} (type: {doc_type}, id: {doc_id}, path: './input_data/{doc_name}')\n" + else: + ai_prompt += "No input files available.\n" + + ai_prompt += """ +IMPORTANT REQUIREMENTS: +1. Your code MUST define a 'result' variable to store the final output of your code. +2. At the end of your script, it should print or output the result variable. +3. Make your 'result' variable a dictionary or another JSON-serializable data structure that contains all relevant output. +4. Input files are accessible in the './input_data/' directory. +5. Keep code well-documented with comments explaining key operations. +6. Make your code complete and self-contained. +7. Include proper error handling. + +FORMAT INSTRUCTIONS: +- Provide ONLY the Python code without ANY introduction, explanation, or conclusion text +- DO NOT include code block markers like ```python or ``` +- DO NOT explain what the code does before or after it +- DO NOT include any text that is not valid Python code +- Start your response directly with valid Python code +- End your response with valid Python code + +For required packages, place them in a specially formatted comment at the top of your code one one line like this: +# REQUIREMENTS: pandas,numpy,matplotlib,requests + +Your entire response must be valid Python that can be executed without modification. +""" + + # Create messages for the API + messages = [ + {"role": "system", "content": "You are a Python code generator that provides ONLY clean, executable Python code without any explanations, markdown formatting, or non-code text. Your response should be nothing but valid Python code that can be executed directly."}, + {"role": "user", "content": ai_prompt} + ] + + # Call the API + logging.info(f"Calling AI API to generate code") + generated_content = await chat_service.call_api(messages, temperature=self.ai_temperature, max_tokens=self.ai_max_tokens) + + # Clean the generated content to ensure it's only valid Python code + code = self._clean_code(generated_content) + + # Extract requirements from special comment at the top of the code + requirements = [] + for line in code.split('\n'): + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + requirements = [r.strip() for r in req_str.split(',') if r.strip()] + break + + return code, requirements + + except Exception as e: + logging.error(f"Error generating code with AI: {str(e)}", exc_info=True) + # Return basic error handling code and no requirements + error_str = str(e).replace('"', '\\"') + return f""" +# Error during code generation +print(f"An error occurred during code generation: {error_str}") +# Return an error result +result = {{"error": "Code generation failed", "message": "{error_str}"}} +""", [] + + async def _execute_code(self, code: str, requirements: List[str] = None, context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Execute Python code using the SimpleCodeExecutor. + + Args: + code: The Python code to execute + requirements: List of required packages + context: Additional context for execution + + Returns: + Result of code execution + """ + # Get workflow ID and set up logging + workflow_id = context.get("workflow_id", "") if context else "" + logging_utils = None + if "log_func" in context and workflow_id: + logging_utils = LoggingUtils(workflow_id, context.get("log_func")) + + if logging_utils: + logging_utils.info("Executing Python code", "execution") + if requirements: + logging_utils.info(f"Required packages: {', '.join(requirements)}", "execution") + + try: + # List of blocked packages for security + blocked_packages = [ + "cryptography", "flask", "django", "tornado", # Security risks + "tensorflow", "pytorch", "scikit-learn" # Resource intensive + ] + + # Initialize SimpleCodeExecutor with requirements and workflow_id for persistence + executor = SimpleCodeExecutor( + workflow_id=workflow_id, + timeout=self.executor_timeout, + max_memory_mb=self.executor_memory_limit, + requirements=requirements, + blocked_packages=blocked_packages + ) + + # Prepare input data for the code + input_data = {"context": context, "workflow_id": workflow_id} + + # Add file references if available + if context and "documents" in context: + input_data["files"] = [ + { + "id": doc.get("source", {}).get("id", ""), + "name": doc.get("source", {}).get("name", ""), + "type": doc.get("source", {}).get("content_type", ""), + "path": doc.get("source", {}).get("path", "") # Full file path + } + for doc in context.get("documents", []) + if doc.get("source", {}).get("type") == "file" + ] + + # Extract document content from message but don't create files yet + if context and "message" in context and "content" in context["message"]: + message_content = context["message"]["content"] + + # Check if there's extracted document content + if "=== EXTRACTED DOCUMENT CONTENT ===" in message_content: + # Add a special field to input_data for extracted content + input_data["extracted_documents"] = [] + + # Split by the document marker pattern + pattern = r"--- (.*?) ---\s*" + import re + doc_sections = re.split(pattern, message_content) + + # Skip the first section (before any "--- doc ---" marker) + for i in range(1, len(doc_sections), 2): + if i+1 < len(doc_sections): + doc_name = doc_sections[i].strip() + doc_content = doc_sections[i+1].strip() + + # Store the extracted content to be processed by the executor + input_data["extracted_documents"].append({ + "name": doc_name, + "content": doc_content, + "type": "text/csv" if doc_name.endswith(".csv") else "text/plain" + }) + if logging_utils: + logging_utils.info(f"Extracted document content: {doc_name}", "execution") + + + # Execute the code + if logging_utils: + logging_utils.info(f"Executing code with input data containing {len(input_data.get('files', []))} files", "execution") + + result = executor.execute_code(code, input_data) + + # Log the execution results + if logging_utils: + if result.get("success", False): + logging_utils.info("Code executed successfully", "execution") + + # Log a preview of the output + output = result.get("output", "") + if output: + preview = output[:1000] + "..." if len(output) > 1000 else output + logging_utils.info(f"Output preview: {preview}", "execution") + + # Log a preview of the result + execution_result = result.get("result") + if execution_result: + if isinstance(execution_result, (dict, list)): + result_str = json.dumps(execution_result, indent=2) + preview = result_str[:1000] + "..." if len(result_str) > 1000 else result_str + else: + str_result = str(execution_result) + preview = str_result[:1000] + "..." if len(str_result) > 1000 else str_result + + logging_utils.info(f"Result preview: {preview}", "execution") + else: + # Log error information + error = result.get("error", "Unknown error") + logging_utils.error(f"Error during code execution: {error}", "execution") + print("DEBUG CODE-ERROR:",code,"#END") + + # Clean up non-persistent environments + if not executor.is_persistent: + executor.cleanup() + + return result + + except Exception as e: + error_message = f"Error during code execution: {str(e)}\n{traceback.format_exc()}" + + if logging_utils: + logging_utils.error(error_message, "error") + + return { + "success": False, + "output": "", + "error": error_message, + "result": None + } + + + def send_error_message(self, error_description: str, sender_id: str, receiver_id: str = None, context_id: str = None) -> AgentMessage: + """Send an error message using the protocol""" + return self.protocol.create_error_message( + error_description=error_description, + sender_id=sender_id, + receiver_id=receiver_id, + error_type="code_execution", + context_id=context_id + ) + + def send_result_message(self, result_content: str, sender_id: str, receiver_id: str, task_id: str, + output_data: Dict[str, Any] = None, context_id: str = None) -> AgentMessage: + """Send a result message using the protocol""" + return self.protocol.create_result_message( + result_content=result_content, + sender_id=sender_id, + receiver_id=receiver_id, + task_id=task_id, + output_data=output_data, + result_format="python_code", + context_id=context_id + ) + +# Singleton instance +_coder_agent = None + +def get_coder_agent(): + """Returns a singleton instance of the Coder Agent""" + global _coder_agent + if _coder_agent is None: + _coder_agent = CoderAgent() + return _coder_agent \ No newline at end of file diff --git a/gwserver/modules/BAKwebcrawler.py b/gwserver/modules/BAKwebcrawler.py new file mode 100644 index 00000000..dd3beffd --- /dev/null +++ b/gwserver/modules/BAKwebcrawler.py @@ -0,0 +1,613 @@ +""" +WebCrawler-Agent für die Recherche und Beschaffung von Informationen aus dem Web. +Angepasst für das refaktorisierte Core-Modul. +""" + +import json +import logging +import random +import time +import traceback +from typing import List, Dict, Any, Optional, Union +import re +import uuid +from datetime import datetime +from urllib.parse import quote_plus, unquote + +from bs4 import BeautifulSoup +import requests +from modules.agentservice_base import BaseAgent +from connectors.connector_aichat_openai import ChatService +from modules.agentservice_utils import WorkflowUtils, MessageUtils, LoggingUtils +from modules.agentservice_protocol import AgentMessage, AgentCommunicationProtocol + + +logger = logging.getLogger(__name__) + +class WebcrawlerAgent(BaseAgent): + + """Agent für Web-Recherche und Informationsbeschaffung""" + + def __init__(self): + """Initialisiert den WebCrawler-Agenten""" + super().__init__() + self.id = "webcrawler" + self.name = "Webscraper" + self.type = "scraper" + self.description = "Recherchiert Informationen im Web" + self.capabilities = "web_search,information_retrieval,data_collection,source_verification,content_integration" + self.result_format = "SearchResults" + + # Add enhanced document capabilities + self.supports_documents = True + self.document_capabilities = ["read", "create"] + self.required_context = ["workflow_id"] + self.document_handler = None + + # Initialize protocol + self.protocol = AgentCommunicationProtocol() + + # Chat-Service initialisieren + self.chat_service = ChatService() + + # Utility-Klassen initialisieren + self.message_utils = MessageUtils() + + # Web-Crawling-Konfiguration + self.max_url = 3 + self.max_key = 3 + self.max_result = 3 + self.timeout = 10 + self.headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Referer': 'https://www.google.com/', + 'DNT': '1', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + } + self.max_urls = 10 + self.max_content_length = 100000 + + def get_agent_info(self) -> Dict[str, Any]: + """Get agent information for agent registry""" + info = super().get_agent_info() + info.update({ + "metadata": { + "max_url": self.max_url, + "max_result": self.max_result, + "timeout": self.timeout + } + }) + return info + + def set_document_handler(self, document_handler): + """Set the document handler for file operations""" + self.document_handler = document_handler + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Verarbeitet eine Nachricht und führt eine Web-Recherche durch. + + Args: + message: Die zu verarbeitende Nachricht + context: Zusätzlicher Kontext + + Returns: + Die generierte Antwort mit der Web-Recherche + """ + # Extract workflow_id from context or message + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Get or create logging_utils + log_func = context.get("log_func") if context else None + logging_utils = LoggingUtils(workflow_id, log_func) + + # Send status update using protocol + if log_func: + status_message = self.protocol.create_status_update_message( + status_description="Starte Web-Recherche", + sender_id=self.id, + status="in_progress", + progress=0.0, + context_id=workflow_id + ) + log_func(workflow_id, status_message.content, "info", self.id, self.name) + + # Create response structure + response = { + "role": "assistant", + "content": "", + "agent_id": self.id, + "agent_type": self.type, + "agent_name": self.name, + "result_format": self.result_format, + "workflow_id": workflow_id + } + + try: + # Get the query from the message + prompt = await self.get_prompt(message) + logging_utils.info(f"Web-Recherche für: {prompt[:50]}...", "agents") + + # Update progress using protocol + if log_func: + status_message = self.protocol.create_status_update_message( + status_description=f"Recherchiere: {prompt[:30]}...", + sender_id=self.id, + status="in_progress", + progress=0.3, + context_id=workflow_id + ) + log_func(workflow_id, status_message.content, "info", self.id, self.name) + + # Führe die Web-Recherche durch + web_query_result = await self.get_web_query(message) + + # Final status update + if log_func: + status_message = self.protocol.create_status_update_message( + status_description="Web-Recherche abgeschlossen", + sender_id=self.id, + status="completed", + progress=1.0, + context_id=workflow_id + ) + log_func(workflow_id, status_message.content, "info", self.id, self.name) + + # Set the content in the response + response["content"] = web_query_result + + return response + + except Exception as e: + error_msg = f"Fehler bei der Web-Recherche: {str(e)}" + logging_utils.error(error_msg, "error") + + # Create error response using protocol + error_message = self.protocol.create_error_message( + error_description=error_msg, + sender_id=self.id, + error_type="web_search", + error_details={"traceback": traceback.format_exc()}, + context_id=workflow_id + ) + + response["content"] = f"## Fehler bei der Web-Recherche\n\n{error_msg}\n\n```\n{traceback.format_exc()}\n```" + + return response + + def send_document_request(self, document_description: str, sender_id: str, receiver_id: str, filters: Dict[str, Any] = None, context_id: str = None) -> AgentMessage: + """Send a document request using the protocol""" + return self.protocol.create_document_request_message( + document_description=document_description, + sender_id=sender_id, + receiver_id=receiver_id, + filters=filters, + context_id=context_id + ) + + def send_result_message(self, result_content: str, sender_id: str, receiver_id: str, task_id: str, + output_data: Dict[str, Any] = None, context_id: str = None) -> AgentMessage: + """Send a result message using the protocol""" + return self.protocol.create_result_message( + result_content=result_content, + sender_id=sender_id, + receiver_id=receiver_id, + task_id=task_id, + output_data=output_data, + result_format="SearchResults", + context_id=context_id + ) + + async def get_prompt(self, message_context: Dict[str, Any]) -> str: + task = message_context.get("content", "") + return task.strip() + + async def get_web_query(self, message_context: Dict[str, Any]) -> str: + prompt = await self.get_prompt(message_context) + result_json = await self.run_web_query(prompt) + result_data = "" + summary_src = "" + + logger.info(f"Web analysis prompt '{prompt}' delivers {len(result_json)} results.") + if isinstance(result_json, list): + total_tokens = 0 + + for i, result in enumerate(result_json, 1): + # Limit content size for each result + result_data_limited = self.limit_text_for_api(result['data'], max_tokens=15000) # Allow ~15000 tokens per result + + web_answer_instructions = f""" + Fass das Resultat gemäss dem Auftrag zusammen in maximal rund 2000 Zeichen. Auftrag = '{prompt.replace("'","")}' + Fasse die wichtigsten Erkenntnisse zusammen und setze sie in Bezug zur ursprünglichen Anfrage. Die Einleitung kannst Du weglassen. + Achte darauf, nur relevante und qualitativ hochwertige Informationen zu extrahieren, welche einen Bezug zum Auftrag haben, und übersichtlich zu präsentieren. Vermittle ein ausgewogenes Bild der recherchierten Informationen. + + Dies ist das Resultat: + {result_data_limited} + """ + + # Count tokens in the instructions to ensure we don't exceed API limits + instruction_tokens = self.count_tokens(web_answer_instructions) + if total_tokens + instruction_tokens > 60000: + logger.warning(f"Skipping result {i} to avoid exceeding token limit") + break + + total_tokens += instruction_tokens + + # Zusätzliche Anweisungen für Web-Recherche + content_text = await self.chat_service.call_api( + messages=[ + { + "role": "system", + "content": "Du bist ein Informationsanalyst, der Webinhalte präzise und relevant zusammenfasst." + }, + { + "role": "user", + "content": web_answer_instructions + } + ] + ) + + # Create a summary but ensure we stay within token limits + content_summary = content_text[:2000] # Limit to ~2000 characters + result_data += f"\n\n[{i}] {result['title']}\nURL: {result['url']}\nSnippet: {result['snippet']}\nContent: {content_summary}" + summary_src += f"\n{content_summary}" + + # Update token count + total_tokens += self.count_tokens(content_summary) + 100 # Add buffer for formatting + else: + result_data = "no data received" + + logger.info(f"Web analysis result sent {len(result_data)}B") + + # Zusätzliche Zusammenfassung + summary = "" + if len(summary_src) > 1: + # Limit summary source to ensure we don't exceed API limits + summary_src_limited = self.limit_text_for_api(summary_src, max_tokens=10000) + + summary = await self.chat_service.call_api( + messages=[ + { + "role": "system", + "content": "Du erstellst prägnante Zusammenfassungen von Rechercheergbnissen." + }, + { + "role": "user", + "content": f"Bitte fasse diese Erkenntnisse in maximal 5-6 Sätzen zusammen: {summary_src_limited}\n" + } + ] + ) + + # Format the final result + result = f"## Web-Recherche Ergebnisse\n\n### Zusammenfassung\n{summary}\n\n### Detaillierte Ergebnisse{result_data}" + return result + + async def run_web_query(self, prompt: str) -> List[Dict]: + if prompt=="": + return [] + + ptext=f"""Create a comprehensive web research strategy for the task = '{prompt.replace("'","")}'. Return the results as a Python dictionary with these specific keys. If specific url are provided and the task requires analysis only on the provided url, then leave 'skey' open. + + 'url': A list of maximum {self.max_url} specific URLs extracted from the task string. + + 'skey': A list of maximum {self.max_key} key sentences to search for on the web. These should be precise, diverse, and targeted to get the most relevant information. + + Format your response as a valid json object with these two keys. Do not include any explanatory text or markdown outside of the object definition. + """ + + content_text = await self.chat_service.call_api( + messages=[ + { + "role": "system", + "content": "Du bist ein Webrecherche-Experte, der präzise Suchstrategien entwickelt." + }, + { + "role": "user", + "content": ptext + } + ] + ) + # Remove markdown formatting if present + if content_text.startswith("```json"): + # Find the end of the JSON block + end_marker = "```" + end_index = content_text.rfind(end_marker) + if end_index != -1: + # Extract the JSON content without the markdown markers + content_text = content_text[7:end_index].strip() + + # Now parse the JSON + try: + logger.info(f"Valid json received: {str(content_text)}") + pjson = json.loads(content_text) + # Now call scrape_json with the parsed dictionary + result_json = await self.scrape_json(pjson) + return result_json + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON: {e}") + logger.error(f"Cleaned content: {content_text[:100]}...") + return [] + + async def scrape_json(self, research_strategy: Dict[str, List]) -> List[Dict]: + """ + Scrapes web content based on a research strategy JSON. + + Args: + research_strategy: A dictionary containing: + - 'skey': List of search keywords + - 'url': List of direct URLs to scrape + + Returns: + Dictionary with URLs as keys and scraped content as values + """ + + logger.info("Starting JSON-based web scraping") + results = [] + + # Validate input structure + if not isinstance(research_strategy, dict): + logger.error("Invalid research_strategy format: not a dictionary") + return {"error": "Invalid research_strategy format: not a dictionary"} + + keys = research_strategy.get("skey", []) + direct_urls = research_strategy.get("url", []) + + if not isinstance(keys, list) or not isinstance(direct_urls, list): + logger.error("Invalid research_strategy format: keys, or url is not a list") + return {"error": "Invalid research_strategy format: keys, or url is not a list"} + + # Process search keywords through search engine + for keyword in keys: + logger.info(f"Processing keyword: {keyword}") + found_results = self.search_web(keyword) # List with Dict: title,url,snippet,data + logger.info(f"... {len(found_results)} results found") + results.extend(found_results) + + # Process direct URLs + logger.info(f"Processing {len(direct_urls)} direct URLs") + for url in direct_urls: + if url in results: + logger.info(f"Skipping already scraped URL: {url}") + continue + soup=self.read_url(url) + + # Extract title from the page if it exists + if isinstance(soup, BeautifulSoup): + title_tag = soup.find('title') + title = title_tag.text.strip() if title_tag else "No title" + + # Alternative: You could also look for h1 tags if the title tag is missing + if title == "No title": + h1_tag = soup.find('h1') + if h1_tag: + title = h1_tag.text.strip() + else: + # Handle the case where soup is an error message string + title = "Error fetching page" + + results.append(self.parse_result(soup,"No title",url)) + logger.info(f"JSON scraping completed. Scraped {len(results)} URLs in total") + return results + + def extract_main_content(self, soup: BeautifulSoup, max_chars: int = 30000) -> str: + """ + Extract the main content from an HTML page while limiting character count. + + Args: + soup: BeautifulSoup object containing the page content + max_chars: Maximum number of characters to extract + + Returns: + Extracted main content as string + """ + if not isinstance(soup, BeautifulSoup): + return str(soup)[:max_chars] + + # Try to find main content elements in order of priority + main_content = None + for selector in ['main', 'article', '#content', '.content', '#main', '.main']: + content = soup.select_one(selector) + if content: + main_content = content + break + + # If no main content found, use the body + if not main_content: + main_content = soup.find('body') or soup + + # Remove script, style, nav, footer elements that don't contribute to main content + for element in main_content.select('script, style, nav, footer, header, aside, .sidebar, #sidebar, .comments, #comments, .advertisement, .ads, iframe'): + element.extract() + + # Extract text content + text_content = main_content.get_text(separator=' ', strip=True) + + # Limit to max_chars + return text_content[:max_chars] + + def tokenize_for_counting(self, text: str) -> List[str]: + """ + Simple token counter for estimating token usage. + This is an approximation since the exact tokenization depends on the model. + + Args: + text: Input text + + Returns: + List of tokens + """ + # Simple tokenization by splitting on whitespace and punctuation + import re + return re.findall(r'\w+|[^\w\s]', text) + + def count_tokens(self, text: str) -> int: + """ + Count the approximate number of tokens in a text. + + Args: + text: Input text + + Returns: + Estimated token count + """ + tokens = self.tokenize_for_counting(text) + return len(tokens) + + def limit_text_for_api(self, text: str, max_tokens: int = 60000) -> str: + """ + Limit the text to a maximum number of tokens. + + Args: + text: Input text + max_tokens: Maximum number of tokens allowed + + Returns: + Limited text + """ + if not text: + return "" + + tokens = self.tokenize_for_counting(text) + + # If text is already under the limit, return as is + if len(tokens) <= max_tokens: + return text + + # Otherwise, truncate text to max_tokens + return " ".join(tokens[:max_tokens]) + "... [content truncated due to length]" + + def search_web(self, query: str) -> List[Dict]: + formatted_query = quote_plus(query) + url = f"https://html.duckduckgo.com/html/?q={formatted_query}" + + search_results_soup = self.read_url(url) + if not search_results_soup or search_results_soup.select('.result') is None or len(search_results_soup.select('.result')) == 0: + logger.warning(f"Keine Suchergebnisse gefunden für: {query}") + return [] + + # Extract search results + results = [] + + # Find all result containers + result_elements = search_results_soup.select('.result') + + for result in result_elements: + # Extract title + title_element = result.select_one('.result__a') + title = title_element.text.strip() if title_element else 'No title' + + # Extract URL (DuckDuckGo uses redirects, need to extract from href param) + url_element = title_element.get('href') if title_element else '' + extracted_url = 'No URL' + + if url_element: + # Extract the actual URL from DuckDuckGo's redirect + if url_element.startswith('/d.js?q='): + start = url_element.find('?q=') + 3 # Skip '?q=' + end = url_element.find('&', start) if '&' in url_element[start:] else None + extracted_url = unquote(url_element[start:end]) + + # Make sure the URL has the correct protocol prefix + if not extracted_url.startswith(('http://', 'https://')): + if not extracted_url.startswith('//'): + extracted_url = 'https://' + extracted_url + else: + extracted_url = 'https:' + extracted_url + else: + extracted_url = url_element + + # Extract snippet directly from search results page + snippet_element = result.select_one('.result__snippet') + snippet = snippet_element.text.strip() if snippet_element else 'No description' + + # Now fetch the actual page content for the data field + target_page_soup = self.read_url(extracted_url) + + # Use the new content extraction method to limit content size + content = self.extract_main_content(target_page_soup, max_chars=30000) + + results.append({ + 'title': title, + 'url': extracted_url, + 'snippet': snippet, + 'data': content + }) + + # Limit the number of results if needed + if len(results) >= self.max_result: + break + + return results + + def read_url(self, url: str) -> BeautifulSoup: + """ + Liest eine URL und gibt einen BeautifulSoup-Parser für den Inhalt zurück. + Bei Fehlern wird ein leeres BeautifulSoup-Objekt zurückgegeben. + + Args: + url: Die zu lesende URL + + Returns: + BeautifulSoup-Objekt mit dem Inhalt oder leer bei Fehlern + """ + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml', + 'Accept-Language': 'en-US,en;q=0.9', + } + + try: + import time + + # Initialer Request + response = requests.get(url, headers=headers, timeout=10) + + # Polling für Status 202 + if response.status_code == 202: + # Maximal 3 Versuche mit steigenden Intervallen + backoff_times = [0.5, 1.0, 2.0, 5.0] # 0.5s, dann 1s, dann 2s + + for wait_time in backoff_times: + time.sleep(wait_time) # Warten mit steigender Zeit + response = requests.get(url, headers=headers, timeout=10) + + # Wenn kein 202 mehr, dann abbrechen + if response.status_code != 202: + break + + # Für andere Fehler-Status einen Fehler auslösen + response.raise_for_status() + + # HTML parsen + return BeautifulSoup(response.text, 'html.parser') + + except Exception as e: + # Leeres BeautifulSoup-Objekt erstellen + return BeautifulSoup("", 'html.parser') + + def parse_result(self, data: BeautifulSoup, title: str, url: str) -> Dict[str, str]: + # Extract snippet/description + snippet_element = data.select_one('.result__snippet') + snippet = snippet_element.text.strip() if snippet_element else 'No description' + + result={ + 'title': title, + 'url': url, + 'snippet': snippet, + 'data': data.prettify() + } + return result + + +# Singleton-Instanz +_webcrawler_agent = None + +def get_webcrawler_agent(): + """Gibt eine Singleton-Instanz des WebCrawler-Agenten zurück""" + global _webcrawler_agent + if _webcrawler_agent is None: + _webcrawler_agent = WebcrawlerAgent() + return _webcrawler_agent \ No newline at end of file diff --git a/gwserver/modules/agentservice_agent_analyst.py b/gwserver/modules/agentservice_agent_analyst.py index 5d58fd29..7c107e7c 100644 --- a/gwserver/modules/agentservice_agent_analyst.py +++ b/gwserver/modules/agentservice_agent_analyst.py @@ -38,11 +38,15 @@ class AnalystAgent(BaseAgent): self.description = "Analyzes and interprets data" self.capabilities = "data_analysis,pattern_recognition,statistics,visualization,data_interpretation" self.result_format = "AnalysisReport" - + + # Initialize AI service + self.ai_service = None + # Document capabilities self.supports_documents = True self.document_capabilities = ["read", "analyze", "extract"] self.required_context = ["data_source", "analysis_objectives"] + self.document_handler = None # Initialize protocol self.protocol = AgentCommunicationProtocol() @@ -68,7 +72,11 @@ class AnalystAgent(BaseAgent): } }) return info - + + def set_document_handler(self, document_handler): + """Set the document handler for file operations""" + self.document_handler = document_handler + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: """ Process a message and perform data analysis. @@ -277,7 +285,7 @@ class AnalystAgent(BaseAgent): for document in message.get("documents", []): source = document.get("source", {}) filename = source.get("name", "") - file_id = source.get("id", "") + file_id = source.get("id", 0) content_type = source.get("content_type", "") # Skip if not a recognizable data file @@ -288,8 +296,8 @@ class AnalystAgent(BaseAgent): # Try to get file content through document handler first file_content = None if self.document_handler: - file_content = await self.document_handler.get_file_content(file_id) - + file_content = self.document_handler.get_file_content_from_message(message, file_id=file_id) + # Process based on file type if filename.lower().endswith('.csv'): df = self._process_csv(file_content, filename) @@ -323,33 +331,42 @@ class AnalystAgent(BaseAgent): return False - def _process_csv(self, file_content: bytes, filename: str) -> Optional[pd.DataFrame]: + def _process_csv(self, file_content: Union[bytes, str], filename: str) -> Optional[pd.DataFrame]: """Process CSV file content into a pandas DataFrame""" if file_content is None: return None try: - # Try various encodings - for encoding in ['utf-8', 'latin1', 'cp1252']: - try: - # Use StringIO to create a file-like object - text_content = file_content.decode(encoding) - df = pd.read_csv(io.StringIO(text_content)) - - # Basic preprocessing - df = self._preprocess_dataframe(df) - return df - except UnicodeDecodeError: - continue - except Exception as e: - logger.error(f"Error processing CSV with {encoding} encoding: {str(e)}") - - # If all encodings fail, try one more time with errors='replace' - text_content = file_content.decode('utf-8', errors='replace') - df = pd.read_csv(io.StringIO(text_content)) - df = self._preprocess_dataframe(df) - return df - + # Handle the case where file_content is already a string + if isinstance(file_content, str): + text_content = file_content + df = pd.read_csv(io.StringIO(text_content)) + df = self._preprocess_dataframe(df) + return df + + # Handle the case where file_content is bytes + else: + # Try various encodings + for encoding in ['utf-8', 'latin1', 'cp1252']: + try: + # Use StringIO to create a file-like object + text_content = file_content.decode(encoding) + df = pd.read_csv(io.StringIO(text_content)) + + # Basic preprocessing + df = self._preprocess_dataframe(df) + return df + except UnicodeDecodeError: + continue + except Exception as e: + logger.error(f"Error processing CSV with {encoding} encoding: {str(e)}") + + # If all encodings fail, try one more time with errors='replace' + text_content = file_content.decode('utf-8', errors='replace') + df = pd.read_csv(io.StringIO(text_content)) + df = self._preprocess_dataframe(df) + return df + except Exception as e: logger.error(f"Failed to process CSV file {filename}: {str(e)}") return None @@ -1482,6 +1499,7 @@ class AnalystAgent(BaseAgent): Generated analysis """ if not self.ai_service: + logging.warning("AI service not available for analysis generation") return f"## Data Analysis ({analysis_type})\n\nUnable to generate analysis: AI service not available." # Create specialized prompt based on analysis type diff --git a/gwserver/modules/agentservice_agent_coder.py b/gwserver/modules/agentservice_agent_coder.py index e9be8096..3917a447 100644 --- a/gwserver/modules/agentservice_agent_coder.py +++ b/gwserver/modules/agentservice_agent_coder.py @@ -1,6 +1,7 @@ """ CoderAgent - A unified agent for developing and executing Python code. Includes code execution capabilities previously in separate modules. +Enhanced with auto-correction loop for handling execution errors. """ import logging @@ -13,6 +14,7 @@ import subprocess import tempfile import shutil import sys +import pandas as pd from datetime import datetime from typing import List, Dict, Any, Optional, Tuple @@ -23,7 +25,9 @@ from modules.agentservice_protocol import AgentMessage, AgentCommunicationProtoc logger = logging.getLogger(__name__) +# Existing SimpleCodeExecutor class remains unchanged class SimpleCodeExecutor: + # ... existing code ... """ A simplified executor that runs Python code in isolated virtual environments. """ @@ -36,7 +40,8 @@ class SimpleCodeExecutor: timeout: int = 30, max_memory_mb: int = 512, requirements: List[str] = None, - blocked_packages: List[str] = None): + blocked_packages: List[str] = None, + ai_service = None): """ Initialize the SimpleCodeExecutor. @@ -57,6 +62,7 @@ class SimpleCodeExecutor: "tensorflow", "pytorch", "scikit-learn" # Resource intensive ] self.is_persistent = workflow_id is not None + self.ai_service = ai_service @classmethod def get_workflow_environment(cls, workflow_id: str) -> Optional[str]: @@ -67,7 +73,7 @@ class SimpleCodeExecutor: def set_workflow_environment(cls, workflow_id: str, env_path: str) -> None: """Store a workflow environment path.""" cls._workflow_environments[workflow_id] = env_path - + def _create_venv(self) -> str: """Creates a virtual environment and returns the path.""" # Check for existing environment if using workflow_id @@ -176,50 +182,16 @@ class SimpleCodeExecutor: return False def _extract_required_packages(self, code: str) -> List[str]: - """Extract required packages from import statements and requirements comments in the code.""" - import re + # Extract required packages from requirements comments in the 1st code line packages = set() - - # Check for special REQUIREMENTS comment - requirements_match = re.search(r'# REQUIREMENTS:\s*([^\n]+)', code) - if requirements_match: - req_str = requirements_match.group(1).strip() - for pkg in req_str.split(','): - if pkg.strip(): - packages.add(pkg.strip()) - - # Add common base packages - base_packages = [ - "requests", "urllib3", "pydantic", - "pandas", "numpy", "matplotlib" - ] - - for pkg in base_packages: - packages.add(pkg) - - # Detect pip install comments - pip_comments = re.findall(r'#\s*pip\s+install\s+([^#\n]+)', code) - for comment in pip_comments: - for pkg in comment.split(): - if pkg and not pkg.startswith('-'): - packages.add(pkg.strip()) - - # Analyze import statements - import_lines = re.findall(r'^(?:import|from)\s+([^\s.]+)(?:\s+import|\s*$|\.)', code, re.MULTILINE) - - # Standard modules that don't need installation - std_modules = { - 'os', 'sys', 'time', 'datetime', 'math', 're', 'random', 'json', - 'collections', 'itertools', 'functools', 'pathlib', 'shutil', - 'tempfile', 'uuid', 'subprocess', 'threading', 'logging', - 'traceback', 'io', 'copy', 'typing', 'asyncio' - } - - # Process all imports - for module in import_lines: - if module not in std_modules: - packages.add(module) - + # Check for special REQUIREMENTS comment - specific format we're looking for + first_lines = code.split('\n')[:5] # Only check first few lines + for line in first_lines: + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + for pkg in req_str.split(','): + if pkg.strip(): + packages.add(pkg.strip()) return list(packages) @@ -247,8 +219,8 @@ class SimpleCodeExecutor: all_requirements = [] # Add explicitly provided requirements - if self.requirements: - all_requirements.extend(self.requirements) + # if self.requirements: + # all_requirements.extend(self.requirements) # Extract requirements from code extracted_requirements = self._extract_required_packages(code) @@ -340,12 +312,13 @@ class SimpleCodeExecutor: # Run the code from root dir working_dir = os.path.dirname(code_file) # This should be the project root logger.info(f"DEBUG PATH Root: {os.getcwd()} Code: {code_file} Working Dir: {working_dir}") + logger.debug(f"|{code}|") process = subprocess.run( [python_executable, code_file], timeout=self.timeout, capture_output=True, text=True, - cwd=self.temp_dir + cwd=working_dir ) # Process the output @@ -393,7 +366,7 @@ class SimpleCodeExecutor: execution_result = { "success": False, "output": "", - "error": f"Execution error: {str(e)}", + "error": f"Execution error: {str(e)} for code {code}", "result": None, "exit_code": -1 } @@ -426,7 +399,7 @@ class SimpleCodeExecutor: """Clean up during garbage collection.""" self.cleanup() - +# Unchanged error recommendation function def get_error_recommendation(error_message: str) -> str: """Generate recommendations based on error message.""" if "ImportError" in error_message or "ModuleNotFoundError" in error_message: @@ -460,7 +433,7 @@ To fix the error: class CoderAgent(BaseAgent): - """Agent for developing and executing Python code""" + """Agent for developing and executing Python code with auto-correction capabilities""" def __init__(self): """Initialize the coder agent with proper type and capabilities""" @@ -473,11 +446,15 @@ class CoderAgent(BaseAgent): self.description = "Develops and executes Python code" self.capabilities = "code_development,data_processing,file_processing,automation" self.result_format = "python_code" - + + # Initialize AI service + self.ai_service = None + # Add document capabilities self.supports_documents = True self.document_capabilities = ["read", "reference", "create"] self.required_context = ["workflow_id"] + self.document_handler = None # Initialize protocol self.protocol = AgentCommunicationProtocol() @@ -491,8 +468,12 @@ class CoderAgent(BaseAgent): self.executor_memory_limit = 512 # MB # AI service settings - self.ai_temperature = 0.2 # Lower temperature for more deterministic code generation + self.ai_temperature = 0.1 # Lower temperature for more deterministic code generation self.ai_max_tokens = 2000 # Enough tokens for complex code + + # Auto-correction settings (new) + self.max_correction_attempts = 3 # Maximum number of correction attempts + self.correction_temperature = 0.1 # Even lower temperature for corrections def get_agent_info(self) -> Dict[str, Any]: """Get agent information for agent registry""" @@ -500,14 +481,20 @@ class CoderAgent(BaseAgent): info.update({ "metadata": { "timeout": self.executor_timeout, - "memory_limit": self.executor_memory_limit + "memory_limit": self.executor_memory_limit, + "max_correction_attempts": self.max_correction_attempts } }) return info - + + def set_document_handler(self, document_handler): + """Set the document handler for file operations""" + self.document_handler = document_handler + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: """ - Process a message to develop and execute Python code. + Process a message to develop and execute Python code with auto-correction. Args: message: The message to process @@ -539,34 +526,23 @@ class CoderAgent(BaseAgent): content = message.get("content", "") documents = message.get("documents", []) - # Extract code from message content - code_blocks = re.findall(r'```(?:python)?\s*([\s\S]*?)```', content) code_to_execute = None requirements = [] - if code_blocks: - # Use the first code block found - code_to_execute = code_blocks[0] - # Clean the code to remove any markdown formatting - code_to_execute = self._clean_code(code_to_execute) - logging_utils.info(f"Code extracted from message ({len(code_to_execute)} characters)", "agents") - else: - # Generate code based on the message content using AI - logging_utils.info("No code found in message, generating new code with AI", "agents") - - # Generate code using AI - code_to_execute, requirements = await self._generate_code_from_prompt(content, documents) - if not code_to_execute: - logging_utils.warning("AI could not generate code", "agents") - response["content"] = "I couldn't generate executable code based on your request. Please provide more detailed instructions." - self.message_utils.finalize_message(response) - return response - logging_utils.info(f"Code generated with AI ({len(code_to_execute)} characters)", "agents") + # Generate code based on the message content using AI + logging_utils.info("Generating new code with AI", "agents") + + # Generate code using AI + code_to_execute, requirements = await self._generate_code_from_prompt(content, documents) + if not code_to_execute: + logging_utils.warning("AI could not generate code", "agents") + response["content"] = "I couldn't generate executable code based on your request. Please provide more detailed instructions." + self.message_utils.finalize_message(response) + return response + logging_utils.info(f"Code generated with AI ({len(code_to_execute)} characters)", "agents") - # Execute the code + # Execute the code with auto-correction loop if code_to_execute: - logging_utils.info("Executing code", "execution") - # Prepare execution context execution_context = { "workflow_id": workflow_id, @@ -575,21 +551,16 @@ class CoderAgent(BaseAgent): "log_func": log_func } - # Send a status update - if log_func: - status_message = self.protocol.create_status_update_message( - status_description="Processing code execution request", - sender_id=self.id, - status="in_progress", - progress=0.5, - context_id=workflow_id - ) - log_func(workflow_id, status_message.content, "info", self.id, self.name) - - # Execute code - result = await self._execute_code(code_to_execute, requirements, execution_context) + # Enhanced execution with auto-correction + result, attempts_info = await self._execute_with_auto_correction( + code_to_execute, + requirements, + execution_context, + content, # Original prompt/message + logging_utils + ) - # Prepare response + # Prepare response based on the final result (success or failure) if result.get("success", False): # Code execution successful output = result.get("output", "") @@ -597,20 +568,39 @@ class CoderAgent(BaseAgent): logging_utils.info("Code executed successfully", "execution") # Format response content - response_content = f"## Code executed successfully\n\n" + response_content = f"## Code executed successfully" + + # Add correction attempts info if any corrections were made + if attempts_info and len(attempts_info) > 1: + response_content += f" (after {len(attempts_info)-1} correction attempts)" + + response_content += "\n\n" # Include the executed code - response_content += f"### Executed Code\n\n```python\n{code_to_execute}\n```\n\n" + response_content += f"### Final Executed Code\n\n```python\n{attempts_info[-1]['code']}\n```\n\n" # Include the output if available if output: response_content += f"### Output\n\n```\n{output}\n```\n\n" + + # Create document with results + data_document = self._create_document_from_result(execution_result) + if data_document: + response["documents"].append(data_document) # Include the execution result if available if execution_result: result_str = json.dumps(execution_result, indent=2) if isinstance(execution_result, (dict, list)) else str(execution_result) response_content += f"### Result\n\n```\n{result_str}\n```\n\n" + # Include correction history if any corrections were made + if attempts_info and len(attempts_info) > 1: + response_content += f"### Code Correction History\n\n" + for i, attempt in enumerate(attempts_info[:-1], 1): + response_content += f"**Attempt {i}:**\n\n" + response_content += f"```python\n{attempt['code']}\n```\n\n" + response_content += f"**Error:**\n\n```\n{attempt['error']}\n```\n\n" + response["content"] = response_content # Process any files created by the code @@ -628,17 +618,38 @@ class CoderAgent(BaseAgent): } response["documents"].append(doc) else: - # Code execution failed + # Code execution failed after all attempts error = result.get("error", "Unknown error") - logging_utils.error(f"Error during code execution: {error}", "execution") + logging_utils.error(f"Error during code execution after all correction attempts: {error}", "execution") # Format error response response_content = f"## Error during code execution\n\n" - response_content += f"### Executed Code\n\n```python\n{code_to_execute}\n```\n\n" - response_content += f"### Error\n\n```\n{error}\n```\n\n" - # Add recommendation based on error - response_content += get_error_recommendation(error) + # Include correction attempts information + if attempts_info: + response_content += f"I made {len(attempts_info)} attempts to correct the code, but couldn't resolve all issues.\n\n" + + # Add the final attempt + response_content += f"### Final Code Attempt\n\n```python\n{attempts_info[-1]['code']}\n```\n\n" + response_content += f"### Final Error\n\n```\n{attempts_info[-1]['error']}\n```\n\n" + + # Add recommendation based on error + response_content += get_error_recommendation(error) + + # Add correction history + if len(attempts_info) > 1: + response_content += f"\n### Code Correction History\n\n" + for i, attempt in enumerate(attempts_info[:-1], 1): + response_content += f"**Attempt {i}:**\n\n" + response_content += f"```python\n{attempt['code']}\n```\n\n" + response_content += f"**Error:**\n\n```\n{attempt['error']}\n```\n\n" + else: + # Just show the code and error + response_content += f"### Executed Code\n\n```python\n{code_to_execute}\n```\n\n" + response_content += f"### Error\n\n```\n{error}\n```\n\n" + + # Add recommendation based on error + response_content += get_error_recommendation(error) response["content"] = response_content else: @@ -650,7 +661,7 @@ class CoderAgent(BaseAgent): # Log success logging_utils.info("CoderAgent has successfully processed the request", "agents") - + return response except Exception as e: @@ -662,7 +673,253 @@ class CoderAgent(BaseAgent): self.message_utils.finalize_message(response) return response + + def _create_document_from_result(self, execution_result, output_format="json"): + """ + Create a document object from execution results + + Args: + execution_result: The data returned from code execution + output_format: Desired format (json, csv, etc.) + + Returns: + Document object for passing to other agents + """ + if not execution_result: + return None + + doc_id = f"data_{uuid.uuid4()}" + + # Determine filename and content type based on the data + if isinstance(execution_result, pd.DataFrame): + # Handle DataFrame result + filename = "processed_data.csv" + content_type = "text/csv" + content = execution_result.to_csv(index=False) + elif isinstance(execution_result, dict) or isinstance(execution_result, list): + # Handle dictionary or list result + filename = "processed_data.json" + content_type = "application/json" + content = json.dumps(execution_result) + elif isinstance(execution_result, str): + # Try to determine if string is JSON, CSV, or plain text + if execution_result.strip().startswith('{') or execution_result.strip().startswith('['): + filename = "processed_data.json" + content_type = "application/json" + elif ',' in execution_result and '\n' in execution_result: + filename = "processed_data.csv" + content_type = "text/csv" + else: + filename = "processed_data.txt" + content_type = "text/plain" + content = str(execution_result) + else: + # Default case for other types + filename = "processed_data.txt" + content_type = "text/plain" + content = str(execution_result) + + # Create document object + document = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": filename, + "content_type": content_type, + }, + "contents": [{ + "type": "text", + "text": content, + "is_extracted": True + }] + } + + return document + async def _execute_with_auto_correction( + self, + initial_code: str, + requirements: List[str], + context: Dict[str, Any], + original_prompt: str, + logging_utils: LoggingUtils = None + ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + """ + Execute code with automatic error correction and retries. + + Args: + initial_code: The initial Python code to execute + requirements: List of required packages + context: Additional context for execution + original_prompt: The original user request/prompt + logging_utils: Optional logging utility + + Returns: + Tuple of (final execution result, list of attempt info dictionaries) + """ + # Initialize tracking data + current_code = initial_code + current_requirements = requirements.copy() if requirements else [] + attempts_info = [] + + # Execute with correction loop + for attempt in range(1, self.max_correction_attempts + 1): + if logging_utils: + if attempt == 1: + logging_utils.info(f"Executing code (attempt {attempt}/{self.max_correction_attempts})", "execution") + else: + logging_utils.info(f"Executing corrected code (attempt {attempt}/{self.max_correction_attempts})", "execution") + + # Execute the current code version + result = await self._execute_code(current_code, current_requirements, context) + + # Record attempt information + attempts_info.append({ + "attempt": attempt, + "code": current_code, + "error": result.get("error", ""), + "success": result.get("success", False) + }) + + # Check if execution was successful + if result.get("success", False): + # Success! Return the result and attempt info + return result, attempts_info + + # Failed execution - check if we've reached the maximum attempt limit + if attempt >= self.max_correction_attempts: + if logging_utils: + logging_utils.warning(f"Maximum correction attempts ({self.max_correction_attempts}) reached, giving up", "execution") + break + + # Need to correct the code - generate a fix based on the error + error_message = result.get("error", "Unknown error") + + if logging_utils: + logging_utils.info(f"Attempting to fix code error: {error_message[:200]}...", "execution") + + # Generate corrected code + corrected_code, new_requirements = await self._generate_code_correction( + current_code, + error_message, + original_prompt, + current_requirements + ) + + # Update for next attempt + if corrected_code: + current_code = corrected_code + + # Add any new requirements + if new_requirements: + for req in new_requirements: + if req not in current_requirements: + current_requirements.append(req) + if logging_utils: + logging_utils.info(f"Added new requirement: {req}", "execution") + else: + # Could not generate correction, break out of the loop + if logging_utils: + logging_utils.warning("Could not generate code correction, giving up", "execution") + break + + # If we get here, all attempts failed - return the last result and attempt info + return result, attempts_info + + async def _generate_code_correction( + self, + code: str, + error_message: str, + original_prompt: str, + current_requirements: List[str] = None + ) -> Tuple[str, List[str]]: + """ + Generate a corrected version of code based on error messages. + + Args: + code: The code that produced errors + error_message: The error message to fix + original_prompt: The original task/requirements + current_requirements: List of currently required packages + + Returns: + Tuple of (corrected code, new requirements list) + """ + try: + # Create a detailed prompt for code correction + correction_prompt = f"""You need to fix an error in Python code. The code was written for this task: + +ORIGINAL TASK: +{original_prompt} + +CURRENT CODE: +```python +{code} +``` + +ERROR MESSAGE: +``` +{error_message} +``` + +CURRENT REQUIREMENTS: {', '.join(current_requirements) if current_requirements else "None"} + +Your task is to analyze the error and provide a corrected version of the code. +Focus specifically on fixing the error while preserving the original functionality. + +Common fixes might include: +- Fixing syntax errors (missing parentheses, indentation, etc.) +- Resolving import errors by adding appropriate requirements +- Correcting file paths or handling file not found errors +- Adding error handling for specific edge cases +- Fixing logical errors in the code + +FORMAT INSTRUCTIONS: +1. Provide ONLY the complete fixed Python code without ANY explanation +2. DO NOT include code block markers like ```python or ``` +3. DO NOT explain what the code does before or after it +4. DO NOT include any text that is not valid Python code +5. Start your response directly with the valid Python code +6. End your response with valid Python code + +If you need to add new required packages, place them in a specially formatted comment at the top of your code like this: +# REQUIREMENTS: package1,package2,package3 + +Your entire response must be valid Python that can be executed without modification. +""" + + # Create messages for the API + messages = [ + {"role": "system", "content": "You are a Python debugging expert. You provide ONLY clean, fixed Python code without any explanations, markdown formatting, or non-code text. Your response should be nothing but valid, fixed Python code that can be executed directly."}, + {"role": "user", "content": correction_prompt} + ] + + # Call the API with very low temperature for deterministic fixes + generated_content = await self.ai_service.call_api( + messages, + temperature=self.correction_temperature, + max_tokens=self.ai_max_tokens + ) + + # Clean the generated content to ensure it's only valid Python code + fixed_code = self._clean_code(generated_content) + + # Extract requirements from special comment at the top of the code + new_requirements = [] + for line in fixed_code.split('\n'): + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + new_requirements = [r.strip() for r in req_str.split(',') if r.strip()] + break + + return fixed_code, new_requirements + + except Exception as e: + logging.error(f"Error generating code correction: {str(e)}", exc_info=True) + # Return None to indicate failure + return None, [] + def _clean_code(self, code: str) -> str: """ Clean up code by removing markdown code block markers and other formatting artifacts. @@ -728,9 +985,6 @@ class CoderAgent(BaseAgent): Tuple of (generated Python code, required packages) """ try: - # Initialize AI service - chat_service = ChatService() - # Prepare a prompt for code generation ai_prompt = f"""Generate Python code to solve the following task: {prompt} @@ -767,7 +1021,7 @@ FORMAT INSTRUCTIONS: - Start your response directly with valid Python code - End your response with valid Python code -For required packages, place them in a specially formatted comment at the top of your code like this: +For required packages, place them in a specially formatted comment at the top of your code one one line like this: # REQUIREMENTS: pandas,numpy,matplotlib,requests Your entire response must be valid Python that can be executed without modification. @@ -781,7 +1035,7 @@ Your entire response must be valid Python that can be executed without modificat # Call the API logging.info(f"Calling AI API to generate code") - generated_content = await chat_service.call_api(messages, temperature=self.ai_temperature, max_tokens=self.ai_max_tokens) + generated_content = await self.ai_service.call_api(messages, temperature=self.ai_temperature, max_tokens=self.ai_max_tokens) # Clean the generated content to ensure it's only valid Python code code = self._clean_code(generated_content) @@ -843,7 +1097,8 @@ result = {{"error": "Code generation failed", "message": "{error_str}"}} timeout=self.executor_timeout, max_memory_mb=self.executor_memory_limit, requirements=requirements, - blocked_packages=blocked_packages + blocked_packages=blocked_packages, + ai_service = self.ai_service ) # Prepare input data for the code @@ -924,7 +1179,6 @@ result = {{"error": "Code generation failed", "message": "{error_str}"}} # Log error information error = result.get("error", "Unknown error") logging_utils.error(f"Error during code execution: {error}", "execution") - print("DEBUG CODE-ERROR:",code,"#END") # Clean up non-persistent environments if not executor.is_persistent: diff --git a/gwserver/modules/agentservice_agent_documentation.py b/gwserver/modules/agentservice_agent_documentation.py index 380d4e5e..5e6ffbc9 100644 --- a/gwserver/modules/agentservice_agent_documentation.py +++ b/gwserver/modules/agentservice_agent_documentation.py @@ -13,7 +13,6 @@ from datetime import datetime import uuid from modules.agentservice_base import BaseAgent -from connectors.connector_aichat_openai import ChatService from modules.agentservice_utils import WorkflowUtils, MessageUtils, LoggingUtils from modules.agentservice_protocol import AgentMessage, AgentCommunicationProtocol from modules.agentservice_filemanager import FileManager # Import the file manager @@ -63,7 +62,11 @@ class DocumentationAgent(BaseAgent): } }) return info - + + def set_document_handler(self, document_handler): + """Set the document handler for file operations""" + self.document_handler = document_handler + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: """ Process a message and create documentation. diff --git a/gwserver/modules/agentservice_agent_webcrawler.py b/gwserver/modules/agentservice_agent_webcrawler.py index 2b91cc56..6e88dfd8 100644 --- a/gwserver/modules/agentservice_agent_webcrawler.py +++ b/gwserver/modules/agentservice_agent_webcrawler.py @@ -5,21 +5,16 @@ Angepasst für das refaktorisierte Core-Modul. import json import logging -import random import time import traceback -from typing import List, Dict, Any, Optional, Union -import re -import uuid -from datetime import datetime +from typing import List, Dict, Any, Optional from urllib.parse import quote_plus, unquote from bs4 import BeautifulSoup import requests from modules.agentservice_base import BaseAgent -from connectors.connector_aichat_openai import ChatService -from modules.agentservice_utils import WorkflowUtils, MessageUtils, LoggingUtils -from modules.agentservice_protocol import AgentMessage, AgentCommunicationProtocol +from modules.agentservice_utils import MessageUtils, LoggingUtils +from modules.agentservice_protocol import AgentCommunicationProtocol logger = logging.getLogger(__name__) @@ -42,13 +37,14 @@ class WebcrawlerAgent(BaseAgent): self.supports_documents = True self.document_capabilities = ["read", "create"] self.required_context = ["workflow_id"] - + self.document_handler = None + + # Initialize AI service + self.ai_service = None + # Initialize protocol self.protocol = AgentCommunicationProtocol() - # Chat-Service initialisieren - self.chat_service = ChatService() - # Utility-Klassen initialisieren self.message_utils = MessageUtils() @@ -57,17 +53,6 @@ class WebcrawlerAgent(BaseAgent): self.max_key = 3 self.max_result = 3 self.timeout = 10 - self.headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Referer': 'https://www.google.com/', - 'DNT': '1', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1', - } - self.max_urls = 10 - self.max_content_length = 100000 def get_agent_info(self) -> Dict[str, Any]: """Get agent information for agent registry""" @@ -80,7 +65,11 @@ class WebcrawlerAgent(BaseAgent): } }) return info - + + def set_document_handler(self, document_handler): + """Set the document handler for file operations""" + self.document_handler = document_handler + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: """ Verarbeitet eine Nachricht und führt eine Web-Recherche durch. @@ -172,29 +161,6 @@ class WebcrawlerAgent(BaseAgent): response["content"] = f"## Fehler bei der Web-Recherche\n\n{error_msg}\n\n```\n{traceback.format_exc()}\n```" return response - - def send_document_request(self, document_description: str, sender_id: str, receiver_id: str, filters: Dict[str, Any] = None, context_id: str = None) -> AgentMessage: - """Send a document request using the protocol""" - return self.protocol.create_document_request_message( - document_description=document_description, - sender_id=sender_id, - receiver_id=receiver_id, - filters=filters, - context_id=context_id - ) - - def send_result_message(self, result_content: str, sender_id: str, receiver_id: str, task_id: str, - output_data: Dict[str, Any] = None, context_id: str = None) -> AgentMessage: - """Send a result message using the protocol""" - return self.protocol.create_result_message( - result_content=result_content, - sender_id=sender_id, - receiver_id=receiver_id, - task_id=task_id, - output_data=output_data, - result_format="SearchResults", - context_id=context_id - ) async def get_prompt(self, message_context: Dict[str, Any]) -> str: task = message_context.get("content", "") @@ -232,7 +198,7 @@ class WebcrawlerAgent(BaseAgent): total_tokens += instruction_tokens # Zusätzliche Anweisungen für Web-Recherche - content_text = await self.chat_service.call_api( + content_text = await self.ai_service.call_api( messages=[ { "role": "system", @@ -263,7 +229,7 @@ class WebcrawlerAgent(BaseAgent): # Limit summary source to ensure we don't exceed API limits summary_src_limited = self.limit_text_for_api(summary_src, max_tokens=10000) - summary = await self.chat_service.call_api( + summary = await self.ai_service.call_api( messages=[ { "role": "system", @@ -280,322 +246,329 @@ class WebcrawlerAgent(BaseAgent): result = f"## Web-Recherche Ergebnisse\n\n### Zusammenfassung\n{summary}\n\n### Detaillierte Ergebnisse{result_data}" return result - async def run_web_query(self, prompt: str) -> List[Dict]: - if prompt=="": - return [] + async def run_web_query(self, prompt: str) -> List[Dict]: + if prompt=="": + return [] - ptext=f"""Create a comprehensive web research strategy for the task = '{prompt.replace("'","")}'. Return the results as a Python dictionary with these specific keys. If specific url are provided and the task requires analysis only on the provided url, then leave 'skey' open. + ptext=f"""Create a comprehensive web research strategy for the task = '{prompt.replace("'","")}'. Return the results as a Python dictionary with these specific keys. If specific url are provided and the task requires analysis only on the provided url, then leave 'skey' open. - 'url': A list of maximum {self.max_url} specific URLs extracted from the task string. + 'url': A list of maximum {self.max_url} specific URLs extracted from the task string. - 'skey': A list of maximum {self.max_key} key sentences to search for on the web. These should be precise, diverse, and targeted to get the most relevant information. - - Format your response as a valid json object with these two keys. Do not include any explanatory text or markdown outside of the object definition. - """ + 'skey': A list of maximum {self.max_key} key sentences to search for on the web. These should be precise, diverse, and targeted to get the most relevant information. + + Format your response as a valid json object with these two keys. Do not include any explanatory text or markdown outside of the object definition. + """ - content_text = await self.chat_service.call_api( - messages=[ - { - "role": "system", - "content": "Du bist ein Webrecherche-Experte, der präzise Suchstrategien entwickelt." - }, - { - "role": "user", - "content": ptext - } - ] - ) - # Remove markdown formatting if present - if content_text.startswith("```json"): - # Find the end of the JSON block - end_marker = "```" - end_index = content_text.rfind(end_marker) - if end_index != -1: - # Extract the JSON content without the markdown markers - content_text = content_text[7:end_index].strip() - - # Now parse the JSON - try: - logger.info(f"Valid json received: {str(content_text)}") - pjson = json.loads(content_text) - # Now call scrape_json with the parsed dictionary - result_json = await self.scrape_json(pjson) - return result_json - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON: {e}") - logger.error(f"Cleaned content: {content_text[:100]}...") - return [] - - async def scrape_json(self, research_strategy: Dict[str, List]) -> List[Dict]: - """ - Scrapes web content based on a research strategy JSON. - - Args: - research_strategy: A dictionary containing: - - 'skey': List of search keywords - - 'url': List of direct URLs to scrape - - Returns: - Dictionary with URLs as keys and scraped content as values - """ - - logger.info("Starting JSON-based web scraping") - results = [] - - # Validate input structure - if not isinstance(research_strategy, dict): - logger.error("Invalid research_strategy format: not a dictionary") - return {"error": "Invalid research_strategy format: not a dictionary"} - - keys = research_strategy.get("skey", []) - direct_urls = research_strategy.get("url", []) - - if not isinstance(keys, list) or not isinstance(direct_urls, list): - logger.error("Invalid research_strategy format: keys, or url is not a list") - return {"error": "Invalid research_strategy format: keys, or url is not a list"} - - # Process search keywords through search engine - for keyword in keys: - logger.info(f"Processing keyword: {keyword}") - found_results = self.search_web(keyword) # List with Dict: title,url,snippet,data - logger.info(f"... {len(found_results)} results found") - results.extend(found_results) - - # Process direct URLs - logger.info(f"Processing {len(direct_urls)} direct URLs") - for url in direct_urls: - if url in results: - logger.info(f"Skipping already scraped URL: {url}") - continue - soup=self.read_url(url) - - # Extract title from the page if it exists - if isinstance(soup, BeautifulSoup): - title_tag = soup.find('title') - title = title_tag.text.strip() if title_tag else "No title" - - # Alternative: You could also look for h1 tags if the title tag is missing - if title == "No title": - h1_tag = soup.find('h1') - if h1_tag: - title = h1_tag.text.strip() - else: - # Handle the case where soup is an error message string - title = "Error fetching page" - - results.append(self.parse_result(soup,"No title",url)) - logger.info(f"JSON scraping completed. Scraped {len(results)} URLs in total") - return results - - def extract_main_content(self, soup: BeautifulSoup, max_chars: int = 30000) -> str: - """ - Extract the main content from an HTML page while limiting character count. - - Args: - soup: BeautifulSoup object containing the page content - max_chars: Maximum number of characters to extract - - Returns: - Extracted main content as string - """ - if not isinstance(soup, BeautifulSoup): - return str(soup)[:max_chars] - - # Try to find main content elements in order of priority - main_content = None - for selector in ['main', 'article', '#content', '.content', '#main', '.main']: - content = soup.select_one(selector) - if content: - main_content = content - break - - # If no main content found, use the body - if not main_content: - main_content = soup.find('body') or soup - - # Remove script, style, nav, footer elements that don't contribute to main content - for element in main_content.select('script, style, nav, footer, header, aside, .sidebar, #sidebar, .comments, #comments, .advertisement, .ads, iframe'): - element.extract() - - # Extract text content - text_content = main_content.get_text(separator=' ', strip=True) - - # Limit to max_chars - return text_content[:max_chars] - - def tokenize_for_counting(self, text: str) -> List[str]: - """ - Simple token counter for estimating token usage. - This is an approximation since the exact tokenization depends on the model. - - Args: - text: Input text - - Returns: - List of tokens - """ - # Simple tokenization by splitting on whitespace and punctuation - import re - return re.findall(r'\w+|[^\w\s]', text) - - def count_tokens(self, text: str) -> int: - """ - Count the approximate number of tokens in a text. - - Args: - text: Input text - - Returns: - Estimated token count - """ - tokens = self.tokenize_for_counting(text) - return len(tokens) - - def limit_text_for_api(self, text: str, max_tokens: int = 60000) -> str: - """ - Limit the text to a maximum number of tokens. - - Args: - text: Input text - max_tokens: Maximum number of tokens allowed - - Returns: - Limited text - """ - if not text: - return "" - - tokens = self.tokenize_for_counting(text) - - # If text is already under the limit, return as is - if len(tokens) <= max_tokens: - return text - - # Otherwise, truncate text to max_tokens - return " ".join(tokens[:max_tokens]) + "... [content truncated due to length]" - - def search_web(self, query: str) -> List[Dict]: - formatted_query = quote_plus(query) - url = f"https://html.duckduckgo.com/html/?q={formatted_query}" - - search_results_soup = self.read_url(url) - if not search_results_soup or search_results_soup.select('.result') is None or len(search_results_soup.select('.result')) == 0: - logger.warning(f"Keine Suchergebnisse gefunden für: {query}") - return [] - - # Extract search results - results = [] - - # Find all result containers - result_elements = search_results_soup.select('.result') - - for result in result_elements: - # Extract title - title_element = result.select_one('.result__a') - title = title_element.text.strip() if title_element else 'No title' - - # Extract URL (DuckDuckGo uses redirects, need to extract from href param) - url_element = title_element.get('href') if title_element else '' - extracted_url = 'No URL' - - if url_element: - # Extract the actual URL from DuckDuckGo's redirect - if url_element.startswith('/d.js?q='): - start = url_element.find('?q=') + 3 # Skip '?q=' - end = url_element.find('&', start) if '&' in url_element[start:] else None - extracted_url = unquote(url_element[start:end]) - - # Make sure the URL has the correct protocol prefix - if not extracted_url.startswith(('http://', 'https://')): - if not extracted_url.startswith('//'): - extracted_url = 'https://' + extracted_url - else: - extracted_url = 'https:' + extracted_url - else: - extracted_url = url_element - - # Extract snippet directly from search results page - snippet_element = result.select_one('.result__snippet') - snippet = snippet_element.text.strip() if snippet_element else 'No description' - - # Now fetch the actual page content for the data field - target_page_soup = self.read_url(extracted_url) - - # Use the new content extraction method to limit content size - content = self.extract_main_content(target_page_soup, max_chars=30000) - - results.append({ - 'title': title, - 'url': extracted_url, - 'snippet': snippet, - 'data': content - }) - - # Limit the number of results if needed - if len(results) >= self.max_result: - break - - return results - - - def read_url(self, url: str) -> BeautifulSoup: - """ - Liest eine URL und gibt einen BeautifulSoup-Parser für den Inhalt zurück. - Bei Fehlern wird ein leeres BeautifulSoup-Objekt zurückgegeben. - - Args: - url: Die zu lesende URL - - Returns: - BeautifulSoup-Objekt mit dem Inhalt oder leer bei Fehlern - """ - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml', - 'Accept-Language': 'en-US,en;q=0.9', + content_text = await self.ai_service.call_api( + messages=[ + { + "role": "system", + "content": "Du bist ein Webrecherche-Experte, der präzise Suchstrategien entwickelt." + }, + { + "role": "user", + "content": ptext } + ] + ) + # Remove markdown formatting if present + if content_text.startswith("```json"): + # Find the end of the JSON block + end_marker = "```" + end_index = content_text.rfind(end_marker) + if end_index != -1: + # Extract the JSON content without the markdown markers + content_text = content_text[7:end_index].strip() + + # Now parse the JSON + try: + logger.info(f"Valid json received: {str(content_text)}") + pjson = json.loads(content_text) + # Now call scrape_json with the parsed dictionary + result_json = await self.scrape_json(pjson) + return result_json + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON: {e}") + logger.error(f"Cleaned content: {content_text[:100]}...") + return [] + + async def scrape_json(self, research_strategy: Dict[str, List]) -> List[Dict]: + """ + Scrapes web content based on a research strategy JSON. + + Args: + research_strategy: A dictionary containing: + - 'skey': List of search keywords + - 'url': List of direct URLs to scrape - try: - import time + Returns: + Dictionary with URLs as keys and scraped content as values + """ + + logger.info("Starting JSON-based web scraping") + results = [] + + # Validate input structure + if not isinstance(research_strategy, dict): + logger.error("Invalid research_strategy format: not a dictionary") + return {"error": "Invalid research_strategy format: not a dictionary"} + + keys = research_strategy.get("skey", []) + direct_urls = research_strategy.get("url", []) + + if not isinstance(keys, list) or not isinstance(direct_urls, list): + logger.error("Invalid research_strategy format: keys, or url is not a list") + return {"error": "Invalid research_strategy format: keys, or url is not a list"} + + # Process search keywords through search engine + for keyword in keys: + logger.info(f"Processing keyword: {keyword}") + found_results = self.search_web(keyword) # List with Dict: title,url,snippet,data + logger.info(f"... {len(found_results)} results found") + results.extend(found_results) + + # Process direct URLs + logger.info(f"Processing {len(direct_urls)} direct URLs") + for url in direct_urls: + if url in results: + logger.info(f"Skipping already scraped URL: {url}") + continue + soup = self.read_url(url) + + # Extract title from the page if it exists + if isinstance(soup, BeautifulSoup): + title_tag = soup.find('title') + title = title_tag.text.strip() if title_tag else "No title" + + # Alternative: You could also look for h1 tags if the title tag is missing + if title == "No title": + h1_tag = soup.find('h1') + if h1_tag: + title = h1_tag.text.strip() + else: + # Handle the case where soup is an error message string + title = "Error fetching page" + + results.append(self.parse_result(soup, title, url)) + logger.info(f"JSON scraping completed. Scraped {len(results)} URLs in total") + return results + + def extract_main_content(self, soup: BeautifulSoup, max_chars: int = 30000) -> str: + """ + Extract the main content from an HTML page while limiting character count. + + Args: + soup: BeautifulSoup object containing the page content + max_chars: Maximum number of characters to extract + + Returns: + Extracted main content as string + """ + if not isinstance(soup, BeautifulSoup): + return str(soup)[:max_chars] + + # Try to find main content elements in order of priority + main_content = None + for selector in ['main', 'article', '#content', '.content', '#main', '.main']: + content = soup.select_one(selector) + if content: + main_content = content + break + + # If no main content found, use the body + if not main_content: + main_content = soup.find('body') or soup + + # Remove script, style, nav, footer elements that don't contribute to main content + for element in main_content.select('script, style, nav, footer, header, aside, .sidebar, #sidebar, .comments, #comments, .advertisement, .ads, iframe'): + element.extract() + + # Extract text content + text_content = main_content.get_text(separator=' ', strip=True) + + # Limit to max_chars + return text_content[:max_chars] + + def tokenize_for_counting(self, text: str) -> List[str]: + """ + Simple token counter for estimating token usage. + This is an approximation since the exact tokenization depends on the model. + + Args: + text: Input text + + Returns: + List of tokens + """ + # Simple tokenization by splitting on whitespace and punctuation + import re + return re.findall(r'\w+|[^\w\s]', text) + + def count_tokens(self, text: str) -> int: + """ + Count the approximate number of tokens in a text. + + Args: + text: Input text + + Returns: + Estimated token count + """ + tokens = self.tokenize_for_counting(text) + return len(tokens) + + def limit_text_for_api(self, text: str, max_tokens: int = 60000) -> str: + """ + Limit the text to a maximum number of tokens. + + Args: + text: Input text + max_tokens: Maximum number of tokens allowed + + Returns: + Limited text + """ + if not text: + return "" + + tokens = self.tokenize_for_counting(text) + + # If text is already under the limit, return as is + if len(tokens) <= max_tokens: + return text + + # Otherwise, truncate text to max_tokens + return " ".join(tokens[:max_tokens]) + "... [content truncated due to length]" + + def search_web(self, query: str) -> List[Dict]: + formatted_query = quote_plus(query) + url = f"https://html.duckduckgo.com/html/?q={formatted_query}" + + search_results_soup = self.read_url(url) + if not search_results_soup or search_results_soup.select('.result') is None or len(search_results_soup.select('.result')) == 0: + logger.warning(f"Keine Suchergebnisse gefunden für: {query}") + return [] + + # Extract search results + results = [] + + # Find all result containers + result_elements = search_results_soup.select('.result') + + for result in result_elements: + # Extract title + title_element = result.select_one('.result__a') + title = title_element.text.strip() if title_element else 'No title' + + # Extract URL (DuckDuckGo uses redirects, need to extract from href param) + url_element = title_element.get('href') if title_element else '' + extracted_url = 'No URL' + + if url_element: + # Extract the actual URL from DuckDuckGo's redirect + if url_element.startswith('/d.js?q='): + start = url_element.find('?q=') + 3 # Skip '?q=' + end = url_element.find('&', start) if '&' in url_element[start:] else None + extracted_url = unquote(url_element[start:end]) - # Initialer Request + # Make sure the URL has the correct protocol prefix + if not extracted_url.startswith(('http://', 'https://')): + if not extracted_url.startswith('//'): + extracted_url = 'https://' + extracted_url + else: + extracted_url = 'https:' + extracted_url + else: + extracted_url = url_element + + # Extract snippet directly from search results page + snippet_element = result.select_one('.result__snippet') + snippet = snippet_element.text.strip() if snippet_element else 'No description' + + # Now fetch the actual page content for the data field + target_page_soup = self.read_url(extracted_url) + + # Use the new content extraction method to limit content size + content = self.extract_main_content(target_page_soup, max_chars=30000) + + results.append({ + 'title': title, + 'url': extracted_url, + 'snippet': snippet, + 'data': content + }) + + # Limit the number of results if needed + if len(results) >= self.max_result: + break + + return results + + def read_url(self, url: str) -> BeautifulSoup: + """ + Liest eine URL und gibt einen BeautifulSoup-Parser für den Inhalt zurück. + Bei Fehlern wird ein leeres BeautifulSoup-Objekt zurückgegeben. + + Args: + url: Die zu lesende URL + + Returns: + BeautifulSoup-Objekt mit dem Inhalt oder leer bei Fehlern + """ + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml', + 'Accept-Language': 'en-US,en;q=0.9', + } + + try: + # Initialer Request + response = requests.get(url, headers=headers, timeout=10) + + # Polling für Status 202 + if response.status_code == 202: + # Maximal 3 Versuche mit steigenden Intervallen + backoff_times = [0.5, 1.0, 2.0, 5.0] # 0.5s, dann 1s, dann 2s + + for wait_time in backoff_times: + time.sleep(wait_time) # Warten mit steigender Zeit response = requests.get(url, headers=headers, timeout=10) - # Polling für Status 202 - if response.status_code == 202: - # Maximal 3 Versuche mit steigenden Intervallen - backoff_times = [0.5, 1.0, 2.0, 5.0] # 0.5s, dann 1s, dann 2s - - for wait_time in backoff_times: - time.sleep(wait_time) # Warten mit steigender Zeit - response = requests.get(url, headers=headers, timeout=10) - - # Wenn kein 202 mehr, dann abbrechen - if response.status_code != 202: - break - - # Für andere Fehler-Status einen Fehler auslösen - response.raise_for_status() - - # HTML parsen - return BeautifulSoup(response.text, 'html.parser') - - except Exception as e: - # Leeres BeautifulSoup-Objekt erstellen - return BeautifulSoup("", 'html.parser') - - def parse_result(self, data: BeautifulSoup, title: str, url: str) -> Dict[str, str]: - # Extract snippet/description - snippet_element = data.select_one('.result__snippet') - snippet = snippet_element.text.strip() if snippet_element else 'No description' - - result={ - 'title': title, - 'url': url, - 'snippet': snippet, - 'data': data.prettify() - } - return result + # Wenn kein 202 mehr, dann abbrechen + if response.status_code != 202: + break + + # Für andere Fehler-Status einen Fehler auslösen + response.raise_for_status() + + # HTML parsen + return BeautifulSoup(response.text, 'html.parser') + + except Exception as e: + # Leeres BeautifulSoup-Objekt erstellen + return BeautifulSoup("", 'html.parser') + + def parse_result(self, data: BeautifulSoup, title: str, url: str) -> Dict[str, str]: + """ + Parse a BeautifulSoup object into a result dictionary. + + Args: + data: BeautifulSoup object containing the page content + title: Page title + url: Page URL + + Returns: + Dictionary with result data + """ + # Extract content using the main content extraction method + content = self.extract_main_content(data, max_chars=30000) + + result = { + 'title': title, + 'url': url, + 'snippet': 'No description', # Default value + 'data': content + } + return result # Singleton-Instanz diff --git a/gwserver/modules/agentservice_dataextraction.py b/gwserver/modules/agentservice_dataextraction.py index e957027b..18e3a0e2 100644 --- a/gwserver/modules/agentservice_dataextraction.py +++ b/gwserver/modules/agentservice_dataextraction.py @@ -712,7 +712,7 @@ def _extract_document_contents_from_messages(file_id: int, messages: List[Dict[s # Check if file ID matches (handle both string and int comparison) if (source.get("id") == file_id or (isinstance(source.get("id"), str) and source.get("id") == str(file_id)) or - (isinstance(file_id, str) and source.get("id") == int(file_id))): + (isinstance(file_id, str) and source.get("id") == file_id)): # Add contents of the file doc_contents = document.get("contents", []) diff --git a/gwserver/modules/agentservice_registry.py b/gwserver/modules/agentservice_registry.py index a2149902..f9f66e50 100644 --- a/gwserver/modules/agentservice_registry.py +++ b/gwserver/modules/agentservice_registry.py @@ -95,14 +95,18 @@ class AgentRegistry: self.ai_service = ai_service self.document_handler = document_handler self.lucydom_interface = lucydom_interface - - # Update dependencies for all registered agents + # Update all registered agents + self.update_agent_dependencies() + + + def update_agent_dependencies(self): + """Update dependencies for all registered agents""" for agent_id, agent in self.agents.items(): if hasattr(agent, 'set_dependencies'): agent.set_dependencies( - ai_service=ai_service, - document_handler=document_handler, - lucydom_interface=lucydom_interface + ai_service=self.ai_service, + document_handler=self.document_handler, + lucydom_interface=self.lucydom_interface ) def register_agent(self, agent: 'BaseAgent'): diff --git a/gwserver/modules/agentservice_workflow_execution.py b/gwserver/modules/agentservice_workflow_execution.py index 3fd4f1be..acf2f0da 100644 --- a/gwserver/modules/agentservice_workflow_execution.py +++ b/gwserver/modules/agentservice_workflow_execution.py @@ -11,6 +11,7 @@ from datetime import datetime from typing import List, Dict, Any, Optional, Tuple, Union logger = logging.getLogger(__name__) +logging.getLogger('matplotlib.font_manager').setLevel(logging.INFO) class WorkflowExecution: """ @@ -39,10 +40,21 @@ class WorkflowExecution: # Initialize agent registry self.agent_registry = AgentRegistry.get_instance() - + # Set dependencies for agents + # Initialize file manager self.file_manager = get_workflow_file_manager(workflow_id, lucydom_interface) - + + # Import and initialize document handler + from modules.agentservice_document_handler import get_document_handler + self.document_handler = get_document_handler(workflow_id, lucydom_interface, ai_service) + + self.agent_registry.set_dependencies( + ai_service=ai_service, + document_handler=self.document_handler, + lucydom_interface=lucydom_interface + ) + async def execute(self, message: Dict[str, Any], workflow: Dict[str, Any], files: List[Dict[str, Any]] = None, is_user_input: bool = False): """ Execute the workflow with integrated planning and agent selection. @@ -67,6 +79,7 @@ class WorkflowExecution: # 3. Create agent-aware work plan work_plan = await self._create_agent_aware_work_plan(workflow, user_message) self.logging_utils.info(f"Created agent-aware work plan with {len(work_plan)} activities", "planning") + self.logging_utils.debug(f"{work_plan}.", "planning") # 4. Execute the activities in the work plan results = await self._execute_work_plan(workflow, work_plan) @@ -165,7 +178,7 @@ class WorkflowExecution: "content_type": source.get("content_type", "unknown") }) - # Create the planning prompt with agent awareness + # Create the planning prompt with agent awareness and document handling information plan_prompt = f""" As an AI workflow manager, create a detailed agent-aware work plan for the following task: @@ -177,21 +190,25 @@ AVAILABLE AGENTS: AVAILABLE DOCUMENTS: {document_info if document_info else "No documents provided"} +IMPORTANT: Document extraction happens automatically in the workflow. Documents in the message are already available to all agents. DO NOT assign agent_coder or any other agent specifically for just reading or extracting document content. Only assign agents for tasks that require specific processing beyond what the document handler already provides. + The work plan should include a structured list of activities. Each activity should have: 1. title - A short descriptive title for the activity 2. description - What needs to be done in this activity 3. assigned_agents - List of agent IDs that should handle this activity (can be multiple in sequence) 4. agent_prompts - Specific instructions for each agent (matched by index to assigned_agents) -5. document_requirements - Description of which documents are needed for this activity +5. document_requirements - Description of which documents are needed for this activity (these will be automatically extracted) 6. expected_output - The expected output format and content 7. dependencies - List of previous activities this depends on (by index) IMPORTANT GUIDELINES: - Each activity should have clear objectives and be assigned to the most appropriate agent(s) - When multiple agents are assigned to an activity, specify the sequence and how outputs should flow between them -- Documents are processed on-demand, so each activity should specify which documents it requires +- Documents are processed on-demand by the system's document handler, so only specify which documents are needed, not how to extract them +- DO NOT create activities that only read or extract document content - this happens automatically - Create a logical sequence where later activities can use outputs from earlier ones - If no specialized agent is needed for a task, use the default "assistant" agent +- Only use the agent_coder for tasks that require actual coding or complex data analysis, not for simply reading documents Return the work plan as a JSON array of activity objects, each with the above properties. """ @@ -357,6 +374,20 @@ Return the work plan as a JSON array of activity objects, each with the above pr for i, agent_id in enumerate(agent_ids): # Get the agent agent = self.agent_registry.get_agent(agent_id) + if agent: + # Ensure dependencies are set + if hasattr(agent, 'set_dependencies'): + agent.set_dependencies( + ai_service=self.ai_service, + document_handler=self.document_handler, + lucydom_interface=self.lucydom_interface + ) + + # Set document handler if agent supports it + if hasattr(agent, 'set_document_handler') and hasattr(self, 'document_handler'): + agent.set_document_handler(self.document_handler) + + if not agent: self.logging_utils.warning(f"Agent '{agent_id}' not found, using assistant instead", "agents") agent = self.agent_registry.get_agent("assistant") @@ -380,9 +411,18 @@ Return the work plan as a JSON array of activity objects, each with the above pr agent_message = self._create_message(workflow, "user") agent_message["content"] = enhanced_prompt - # Add any documents from previous agent if this is a continuation + # IMPORTANT FIX: Document handling logic + # First, check if we have documents from previous agent if this is a continuation if last_documents and i > 0: agent_message["documents"] = last_documents + # For the first agent, make sure we pass any documents from the most recent user message + elif i == 0: + # Find the most recent user message with documents + for msg in reversed(workflow.get("messages", [])): + if msg.get("role") == "user" and msg.get("documents"): + agent_message["documents"] = msg.get("documents", []) + self.logging_utils.info(f"Passing {len(agent_message['documents'])} documents from user message to {agent_id}", "agents") + break # Log agent execution self.logging_utils.info(f"Executing agent: {agent_id}", "agents") @@ -402,6 +442,7 @@ Return the work plan as a JSON array of activity objects, each with the above pr if "documents" in agent_response: response_message["documents"] = agent_response["documents"] last_documents = agent_response["documents"] + self.logging_utils.info(f"Agent {agent_id} produced {len(last_documents)} documents", "agents") # Add to workflow workflow["messages"].append(response_message) @@ -418,7 +459,8 @@ Return the work plan as a JSON array of activity objects, each with the above pr "content": "No agent response was generated.", "format": "Text" } - + + async def _extract_required_documents(self, workflow: Dict[str, Any], doc_requirements: str) -> Dict[str, Any]: """ Extract required documents based on requirements description. diff --git a/gwserver/workflow_test_result.json b/gwserver/workflow_test_result.json index d83adf12..dde1358d 100644 --- a/gwserver/workflow_test_result.json +++ b/gwserver/workflow_test_result.json @@ -1,12 +1,12 @@ { - "workflow_id": "wf_20250414200154", + "workflow_id": "wf_20250415005950", "status": "completed", "messages": [ { - "id": "msg_f40d3472-69f0-4b68-8c64-a6a4fca9a653", - "workflow_id": "wf_20250414200154", + "id": "msg_329dd203-7e61-4e7a-baab-c3def53a6c82", + "workflow_id": "wf_20250415005950", "parent_message_id": null, - "started_at": "2025-04-14T20:01:57.968766", + "started_at": "2025-04-15T00:59:53.943855", "finished_at": null, "sequence_no": 1, "status": "pending", @@ -19,14 +19,14 @@ }, "documents": [ { - "id": "doc_8ac210d3-5bb7-487d-b7c6-6e8dc3edf6cc", + "id": "doc_92bb92a1-03d0-4fec-af9c-f1cf9592888f", "source": { "type": "file", - "id": "file_cc30a810-e582-4e10-99f5-cf7fdc0aa49f", + "id": "file_fde6ef51-899d-4b9a-a117-9fdfeea6eb5f", "name": "data.csv", "content_type": "application/vnd.ms-excel", "size": 78, - "upload_date": "2025-04-14T20:01:57.970773" + "upload_date": "2025-04-15T00:59:53.945873" }, "contents": [ { @@ -42,10 +42,10 @@ "agent_type": null }, { - "id": "msg_c9a32211-0ef1-4ffb-9360-bc9afe33f4e1", - "workflow_id": "wf_20250414200154", + "id": "msg_bc7d54af-ac57-45e9-8f73-10f0f7e79511", + "workflow_id": "wf_20250415005950", "parent_message_id": null, - "started_at": "2025-04-14T20:03:10.956604", + "started_at": "2025-04-15T01:00:10.364558", "finished_at": null, "sequence_no": 2, "status": "pending", @@ -56,18 +56,53 @@ "bytes_sent": 0, "bytes_received": 0 }, - "documents": [], - "content": "## Code executed successfully\n\n### Executed Code\n\n```python\n# REQUIREMENTS: pandas\n\nimport pandas as pd\nimport os\n\n# Define the path to the CSV file\nfile_path = './input_data/data.csv'\n\n# Initialize the result variable\nresult = {}\n\ntry:\n # Check if the file exists\n if os.path.exists(file_path):\n # Load the CSV file into a DataFrame\n df = pd.read_csv(file_path)\n \n # Convert the DataFrame to a dictionary\n result = df.to_dict(orient='records')\n else:\n result = {\"error\": \"File not found\"}\nexcept Exception as e:\n # Handle any exceptions that occur during file reading or processing\n result = {\"error\": str(e)}\n\n# Output the result\nprint(result)\n```\n\n### Output\n\n```\n[{'name': 'John', 'age': 30, 'location': 'New York'}, {'name': 'Alice', 'age': 25, 'location': 'London'}, {'name': 'Bob', 'age': 35, 'location': 'Paris'}, {'name': 'Emma', 'age': 28, 'location': 'Berlin'}]\n\n```\n\n", - "agent_type": "coder", - "agent_id": "coder", - "agent_name": "Python Code Agent", - "result_format": "AnalysisReport containing the data structure and initial insights." + "documents": [ + { + "id": "viz_stat_dist_c4c14797-a601-4167-af0c-5d3ae100ff3d", + "source": { + "type": "generated", + "id": "viz_stat_dist_c4c14797-a601-4167-af0c-5d3ae100ff3d", + "name": "Statistical Distributions - data.csv", + "content_type": "image/png", + "size": 44596 + }, + "contents": [ + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAABLAAAAMgCAYAAAAz4JsCAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAACCFElEQVR4nOz9eXycdb338b+v2ZckM8lMtrZZuqVQ2pJC2WRRQGU5UrgFD+eI6JFFj2zCzwU8Pg5aj8fq70ZQBBWhR34CHvFGODd45LjdCi7QSilalu5NmjRNk0wmk2T25fr9MclA6EKhSedK+no+HvOYzHVdc+U7Tb6T5p3P93MZpmmaAgAAAAAAACzKVu4BAAAAAAAAAAdDgAUAAAAAAABLI8ACAAAAAACApRFgAQAAAAAAwNIIsAAAAAAAAGBpBFgAAAAAAACwNAIsAAAAAAAAWBoBFgAAAAAAACyNAAsAAAAAAACWRoAFAAAAAAAASyPAAgAAAAAAgKURYAEAAAAAAMDSCLAAAAAAAABgaQRYAAAAb2KaZrmHYIkxAAAAWAUBFgAAmFauvPJKLVq0qHQ75phjtHz5cn3wgx/UQw89pHw+P+H4c845R7fddtshn/+3v/2tbr311rc87rbbbtM555zzjj/PgWQyGa1evVpPPfXUAT+XFdxxxx065ZRT1N7erv/6r/8q93AAAMAM5yj3AAAAAN6uxYsX60tf+pIkKZ/PKxaL6ZlnntHXvvY1rV+/XnfddZcMw5Ak3XPPPaqoqDjkcz/44IOHdNx1112nj370o2977G+lr69PDz74oFavXj3ln+ud2rJli+6//379/d//vS6++GLNmzev3EMCAAAzHAEWAACYdioqKtTe3j5h2znnnKO5c+dq9erVOuecc7Ry5UpJxbBrKjQ3N0/Jecv9uQ7F0NCQJOnv/u7vtGLFivIOBgAAHBVYQggAAGaMK6+8UnV1dfrJT35S2vbmpX2/+MUvtHLlSi1btkynnnqqPvvZz6qvr6/0/HXr1mndunVatGiR1q5dq7Vr12rRokX6yU9+orPPPlvvete79Mc//nG/y/qy2ay++tWv6qSTTtJJJ52kW2+9VYODg6X9+3tOd3e3Fi1apMcff1zd3d0699xzJUlf+MIXSse++Xn5fF6PPPKILrroIi1btkzvec97dMcddyidTk/4XP/0T/+kn/3sZzrvvPO0ZMkSrVy5Us8888xb/jv+4he/0Ac/+EEtX75cp59+um6//XbFYjFJ0ne+8x1deeWVkqSPfexjB13auGnTJt1www069dRTddxxx+nMM8/UV7/6VaVSqdIxo6Ojuv3223Xaaadp+fLluuWWW/Tggw9q0aJFE871m9/8Rh/84Ae1dOlSnX766frqV7+qRCLxlq8FAADMDARYAABgxrDb7TrttNP0t7/9Tblcbp/969ev12c/+1m9//3v1/33368vfOELev755/WZz3xGkvSlL31Jixcv1uLFi/Xoo4/quOOOKz33rrvu0q233qpbb711n+qvcU8//bRefvllff3rX9fnP/95/f73v9d11113yOOvq6vTPffcI0n61Kc+Vfr4zW6//XZ97Wtf0znnnKPvfe97uuKKK/Twww/ruuuum9D8/eWXX9aaNWt000036d5775XD4dBNN91UCqP257vf/a5uueUWHX/88br77rt1/fXX65e//KWuvPJKpVIpfehDH9Ltt99eGseBxtjX16crrrhCyWRSX//613X//ffrggsu0EMPPTRhmeb111+vp59+WjfeeKPuuusuxeNxffOb35xwrqeeekrXX3+95s2bp3vvvVc33HCDnnzyyX1eLwAAmLlYQggAAGaUcDisbDaroaEhhcPhCfvWr18vt9uta6+9Vm63W5IUDAa1ceNGmaapBQsWlPplvTmk+od/+Aedf/75B/3cVVVVeuCBB0rnqK6u1vXXX68//vGPOuOMM95y7C6XS8cee6yk4rLB/S1/3LZtmx577DHdfPPN+tSnPiVJOv3001VXV6fPf/7zevbZZ/Xud79bkjQyMqLHH3+8tATR5/PpIx/5iJ5//nmdd955+5w7Fovpe9/7nj70oQ+VeoxJUltbm6644go9/vjj+vCHP6wFCxZIkhYsWHDAJZpbtmzRscceq29/+9ulf493vetdeu655/SXv/xF//zP/6znnntOzz//vL7zne/o/e9/vyTprLPO0kUXXaRt27ZJKl6N8Y477tCZZ56pO+64o3T+1tZW/dM//ZOeeeYZvec973nLf1sAADC9UYEFAABmpPEm7m900kknKZVK6aKLLtJdd92l9evX64wzztANN9yw3+Pf6M1L2vbn3e9+94SG8eecc46cTqf+/Oc/v/0XcADr1q2TJF100UUTtv/d3/2d7Ha71q5dW9pWU1MzoX9WQ0ODJCmZTO733C+99JIymcw+516xYoVmz5494dxv5YwzztDDDz8st9utnTt36ne/+52+//3va3BwUJlMRpL0/PPPy+l06r3vfW/peTabTRdccEHp8Y4dO9Tb26tzzjlHuVyudDvppJNUUVGhP/3pT4c8JgAAMH1RgQUAAGaUvXv3yuPxKBgM7rNv+fLl+sEPfqAHH3xQa9as0fe//33V1tbq2muv1cc+9rGDnjcUCr3l535zxZfNZlMwGNTw8PDbeg0HM778r7a2dsJ2h8Oh6upqjYyMlLZ5vd4Jx4yHdIVC4aDnfvPrGN/2xnO/lUKhoDvvvFOPPPKIEomEGhsbtWzZslLlmyRFo1EFg0HZbBP/pvrGzz/eMH7VqlVatWrVPp9nvH8ZAACY2QiwAADAjJHP57Vu3TqdcMIJstvt+z3mzDPP1JlnnqlkMqnnn39eP/rRj/S1r31N7e3tOv744w/r8785qMrn84pGo6XwyzAM5fP5Cce83UbkgUBAktTf3685c+aUtmezWUWjUVVXV7+ToU8498DAgObPnz9hX39/v5qamg75XONB4Ze//GWdd955qqyslCRddtllpWPq6+sVjUZVKBQmhFiRSKT0cVVVlSTp85//vE4++eQDjhkAAMxsLCEEAAAzxk9+8hP19fXpH//xH/e7/xvf+IYuu+wymaYpr9ers88+W7feeqskac+ePZK0TzXQ2/HnP/95QvP4X/7yl8rlcjrllFMkSX6/X9FodMLVAl988cUJ5zhQ8DZuPMR56qmnJmz/7//+b+XzeZ144onvePzHH3+8XC7XPud+4YUX1NPToxNOOOGQz7V+/XotWLBAl112WSm82rt3r7Zs2VKqADv55JOVy+X0//7f/5vw3N/85jelj+fNm6dQKKTu7m4tXbq0dGtoaNA3v/lNvfrqq+/05QIAgGmECiwAADDtjI6O6qWXXpJUXKoWjUb1xz/+UY8++qhWrlxZagj+Zqeddpp++MMf6rbbbtPKlSuVzWb1wAMPKBgM6tRTT5VUrPjZsGGDnnvuuQM2KD+QgYEB3XjjjbryyivV0dGhO++8U6effrpOO+00SdLZZ5+thx56SP/yL/+iD33oQ9q6dav+4z/+Y0JoNR72PPfcc5o/f/4+VWELFizQ//pf/0v33HOPUqmUTjnlFL322mu65557dMopp+jMM898W2N+o2AwqE984hO655575HQ6de6556q7u1vf/va3tWDBAn3wgx885HMtW7ZM3/3ud/WDH/xA7e3t6uzs1H333adMJlPqwXXSSSfp9NNP1xe/+EUNDAxo1qxZeuyxx7Rp06bScke73a5bbrlFt99+u+x2u84++2wNDw/ru9/9rvbu3TvhSpEAAGDmIsACAADTzquvvqrLL79cUrFiKhQKae7cufr617++TwPyNzrrrLN0xx136D/+4z9KjdtPPPFE/ehHPyr1zLriiiv08ssv69prr9Xq1atVV1d3yOP6+7//e6VSKV1//fVyuVy66KKL9LnPfa4Uxpx++um69dZb9dBDD+lXv/qVjjvuON1zzz36h3/4h9I5Kioq9PGPf1yPPvqofv/73++3Sfm///u/q6WlRT/72c+0Zs0a1dXV6corr9T1119/WBVkknTjjTcqHA7r4Ycf1v/5P/9HwWBQ559/vm6++eZ9emodzCc/+UlFo1H96Ec/0r333qvGxkZdfPHFMgxD9913n2KxmAKBgO666y59/etf1ze/+U3lcjmde+65+sd//Ef913/9V+lcH/rQh+T3+/XAAw/o0Ucflc/n0wknnKA77rjjbS1rBAAA05dhmqZZ7kEAAADg6LN792699NJLOvfcc+XxeErbb7rpJnV1demJJ54o4+gAAICVUIEFAACAsrDZbLrtttt07rnn6rLLLpPdbtezzz6rX/3qV1q9enW5hwcAACyECiwAAACUzfPPP697771Xr732mnK5nObPn6+Pf/zj+sAHPlDuoQEAAAshwAIAAAAAAIClHV6XTwAAAAAAAGCKEWABAAAAAADA0giwAAAAAAAAYGlH/VUIc7mcYrGY3G63bDbyPAAAAAAAgMlWKBSUTqcVCATkcLz9OOqoD7BisZg6OjrKPQwAAAAAAIAZr7W1VaFQ6G0/76gPsNxut6TiP6DX6y3zaDDT5PN5bdmyRW1tbbLb7eUeDmBpzBfg0DFfgEPDXAEOHfMFUy2ZTKqjo6OUw7xdR32ANb5s0Ov1yufzlXk0mGny+bwkyefz8UMAeAvMF+DQMV+AQ8NcAQ4d8wVHyjtt30TTJwAAAAAAAFgaARYAAAAAAAAsjQALAAAAAAAAlkaABQAAAAAAAEsjwAIAAAAAAIClEWABAAAAAADA0giwAAAAAAAA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u+yStm0LsaISAAAA4D9KU1NTNm3alE6dOv1bxxciwOrVq1cOO+ywXH311bnyyivz+uuv56abbsrw4cO3G3vSSSelpqYmDzzwQI4//vjMmTMnCxcuzGWXXZYkGTZsWKZMmZKjjz46++67b66//vp07do11dXV7/lnt2/fPnvttddHOj8AAACAnd2/c+XVNm1KpVJpB9byb1uzZk2uvPLKLFiwIG3bts0pp5ySCy+8MO3atcvAgQNzxRVX5KSTTkqS1NXV5brrrsvKlSuz9957Z8KECTnmmGOSJKVSKTU1Namtrc1rr72Wgw8+OFdccUX23Xffck4PAAAAgH9TYQIs+KRbsmRJrr322jz33HOpqKjI5z//+Vx88cXp0qVLlixZksmTJ+eZZ55JVVVVhgwZkgkTJqR9+0JcBAkfu9b6Zfbs2Zk6dWpWr16dT33qUznrrLMycuTIcpcMZTN//vz89Kc/zYoVK1JVVZUTTjghEyZMSGVlZZ5++ulcddVVWb58efbcc8+ce+65GTFiRLlLhrJorVceeuih3HTTTXnppZfSuXPnDB06NGPGjHELEXZarfXLNq+88krzhSVDhw4tY7XwDmds2AE2btyYb3/72xk4cGAee+yx3H///Vm3bl0uvfTSvPbaaznrrLNy5JFHZuHChbnrrrsyd+7c/OpXvyp32VAWrfXL0qVLc9lll2Xy5Ml54oknMnny5EyaNCmLFy8ud9lQFq+99lpGjx6dkSNHZvHixbnnnnuycOHC3HrrrVm/fn1GjRqVU045JYsWLcqkSZOa/7MEdjat9cpf/vKXXHTRRTn//POzePHi3HbbbZk5c2buuOOOcpcNZdFav2zT1NSUCy+8MK+//noZK4WWXP4BO0B9fX0OOOCAjB07Nu3atUuHDh3y9a9/PRdddFHuvffe9OrVK6NHj06S7LPPPvnlL3+ZNm08nJadU2v98sILL2Tr1q1pampKqVRKmzZtmsfAzqhLly7585//nN122y2lUinr1q3Lpk2b0qVLl8yZMyedO3fON7/5zSTJEUcckSFDhqS2tjaHHHJImSuHj1drvbJq1aqcdtppzQ956t27d4477rgsWrQoZ599dpkrh49fa/2yzY033pju3bvn05/+dBkrhZYEWLAD7LfffvnFL37RYttDDz2Ugw46KM8880z69u2byy+/PH/84x9TVVWVYcOGNQdasLNprV++8IUvZMCAARk5cmTatWuXxsbGTJw40YdxdmrbbnZ6zDHHZPXq1amurs7QoUPzs5/9bLunLO+///6ZMWNGOcqEsnu/XunYsWMGDx7cPG7jxo2ZO3duhgwZUq5Soezer1+S5PHHH8/s2bNz99136xMKxRJC2MFKpVKuv/76PPLII7nsssuyfv36zJw5M4ccckjmzp2bqVOnZvr06ampqSl3qVB27+6XzZs3Z5999klNTU2efvrp3HLLLZkyZUoee+yxcpcKZTdnzpw8+uijadu2bcaNG5e33norVVVVLcZUVlZmw4YNZaoQiuHdvfLPGhoaMnbs2FRWVuass84qT4FQIO/ul7Vr1+bSSy/Nddddl1133bXc5UELAizYgRoaGjJu3Ljcd999mTZtWvr165cOHTrk4IMPzvDhw1NRUZEDDjggp59+eh588MFylwtl9V79MmXKlHTo0CFHHnlkKioq8sUvfjEnnnhipk+fXu5yoewqKyvTrVu3TJgwIXV1damqqsrGjRtbjNm4caMPHOz03t0r69evT5I8//zzOe2007J169b8+te//lCPcof/FO/ul4suuihnnHFG+vfvX+7SYDsCLNhBVq5cmWHDhqWhoSEzZsxIv379krxzn4XNmze3GLvt/j6ws3q/fqmvr8+WLVtajG3fvn0qKirKUSaU3RNPPJETTjihxc+RzZs3p6KiIvvvv3+WLVvWYvzy5cvTp0+fj7tMKLvWeqWqqirz5s3LiBEjctRRR+X2229Pp06dylgtlNf79UuS/OlPf8qNN96Y6urqVFdXp76+PldccYXbn1AIAizYAdavX58zzzwzhx56aG6//fYWN0AcNmxYli5dmttuuy2NjY3529/+lmnTpuXkk08uY8VQPq31y6BBg/LAAw+krq4upVIpCxcuzKxZs9x/gZ1Wv379snHjxvzkJz/J5s2bs2rVqlx77bUZPnx4Bg8enDVr1uSOO+7Ili1b8vjjj+e+++7LsGHDyl02fOxa65W//vWvGTt2bC655JJMnDgx7du7DTA7t/frl5EjR2bJkiVZvHhx81ePHj3ygx/8ILfccku5y4a0KbkMBD60mpqaXHPNNamqqtru6YJPPvlknn766fz4xz/O0qVLU1lZmZEjR+bcc8/1JEJ2Sv+qX+68885MmzYtr776anr06JFRo0blpJNOKlO1UH7Lly/P1VdfnWeffTa77757hgwZkrFjx6ZDhw559tlnM2nSpCxdujRdunTJmDFjmm/CCzub9+uVcePGZe7cudvdM+6www7b7qEisLNo7WfLPxs0aFC++93v+tlCIQiwAAAAACg0SwgBAAAAKDQBFgAAAACFJsACAAAAoNAEWAAAAAAUmgALAAAAgEITYAEAAABQaAIsAAAAAApNgAUAAABAoQmwAAAAACg0ARYAAAAAhda+3AUAAPB/Hn744dx666158cUXs2HDhhx88MG56qqr0qtXr8yePTs33HBD1q5dm89+9rPp0aNHtmzZkmuuuSalUil33nlnamtrs3bt2vTt2zeXXnpp+vfvX+4pAQB8aK7AAgAoiJdffjnjx4/PqFGjMn/+/MydOzelUik33nhjnnzyyUycODETJ07M448/ntNOOy0zZ85sPvY3v/lNampq8vOf/zzz58/P0KFD861vfStr1qwp44wAAHYMARYAQEF06dIls2fPzqBBg9LQ0JCXX345e+65Z1avXp277747xx9/fAYNGpT27dvnuOOOy7HHHtt8bG1tbUaPHp0DDjggFRUVGT58eHr37p1Zs2aVcUYAADuGJYQAAAVRUVGR+++/P7/73e/Spk2b9O3bNw0NDWnfvn3+8Y9/5MADD2wxvmfPns1XWK1atSrXXnttrrvuuub9W7dutYQQAPiPIMACACiIBx98MNOmTctvf/vbfOYzn0mS/OhHP8rSpUuz9957p76+vsX4+vr6dOjQIUnSvXv3jBs3LieeeGLz/pUrV6Zz584fW/0AAB8VSwgBAArizTffTNu2bVNZWZlSqZRHH3009957b7Zs2ZIRI0bkD3/4Q+rq6tLY2Jh58+Zlzpw5zceeeuqpufnmm7NixYokSV1dXU488cQsWrSoXNMBANhh2pRKpVK5iwAAINm8eXO+//3v5+GHH067du2y33775YgjjkhtbW3q6uoye/bsTJ06Na+//nqqq6tTKpXSvXv3/OhHP0pjY2Nqamry+9//Pq+88kq6deuWc845JyNGjCj3tAAAPjQBFgDAJ8Df//73NDU1pXfv3s3bzjvvvOy333753ve+V8bKAAA+epYQAgB8AixfvjxnnnlmVq5cmSRZsGBB6urqcswxx5S5MgCAj54rsAAAPiFuvvnmTJ8+PevXr8/ee++d0aNHZ8iQIeUuCwDgIyfAAgAAAKDQLCEEAAAAoNAEWAAAAAAUmgALAAAAgEITYAEAAABQaAIsAAAAAApNgAUAAABAoQmwAAAAACg0ARYAAAAAhSbAAgAAAKDQBFgAAAAAFJoACwAAAIBCE2ABAAAAUGgCLAAAAAAKTYAFAAAAQKH9Lx+RR3ahb0d0AAAAAElFTkSuQmCC", + "format": "base64" + } + ] + }, + { + "id": "viz_stat_box_e13cbf0a-902e-4baa-b5f7-2c1527456555", + "source": { + "type": "generated", + "id": "viz_stat_box_e13cbf0a-902e-4baa-b5f7-2c1527456555", + "name": "Box Plots - data.csv", + "content_type": "image/png", + "size": 22148 + }, + "contents": [ + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAABLAAAAMgCAYAAAAz4JsCAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAABAT0lEQVR4nO3dd5iU5d334S8sZSlRxC7oYxTBAihERcGK+pjEYEF5iRqMxhqwKyrmDVY0FtQodsQSjInyoMGCmJjEGDuWEHvX2DEgSnApy75/+LKPSLHs4l7KeR6HR8jszD2/mZ05GD9e1z2NampqagIAAAAAhWrc0AMAAAAAwOIIWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAEuxmpqahh4BvjW8nwBgyRGwAOAzBgwYkE6dOs33z8Ybb5x99tknDz/88Ncyw9ixYxeYYb311ssmm2ySn/3sZ3n00UcXuO4bb7zxhY//4Ycf5vjjj8/EiROXxPjzmTNnToYMGZLu3bune/fuefDBBxe4zkMPPZROnTpl4MCBCz3GV3mMDeGNN95Ip06dMnbs2K90+3HjxqVTp04ZP378Iq9zzTXXpFOnTnnxxRe/6pgZMGBABgwYUO+3ueiii9KpU6evPNcXdcIJJ6R3795L5Nhf5TF8ne8nAFhaNWnoAQCgROuvv35OOumkJEl1dXWmTp2aG264Ifvvv3/Gjh2bddZZ52uZY8SIEVlxxRWTJHPnzs3777+fiy++OD/96U8zZsyYrLvuul/puM8880xuueWW9O3btz7HXah77703Y8eOzcCBA9OzZ8+sv/76i7zu3XffnXHjxmXnnXde4nMtCSuttFJ+//vfZ4011vhKt99xxx1z2mmn5dZbb80PfvCDhV7nD3/4Q7p165YOHTp85Tnnvba/qQYOHJh99tmnoceo9XW+nwBgaSVgAcBCtG7dOhtttNF8l/Xs2TObb755xo4dm+OPP/5rmWO99dZL+/bt57ts/fXXzw477JDf/va3OfXUU7+WOerigw8+SJL07ds3q6+++mKvu8wyy2TYsGHp2bNnVlhhha9huvrVrFmzBV43X0bz5s2z0047ZcyYMfnggw/Spk2b+X7+3HPP5emnn86wYcPqNGdd4lcJvmogBAC+uWwhBIAvqEWLFmnevHkaNWo03+V33HFH+vbtm27duqVXr14ZOnRopk2bliSZPn16evfune9///uZNWtWkk/Ok/Ozn/0sm2++ed5///0vPUf79u2z3HLL5a233lrkde67777stdde+d73vpcePXrkmGOOydtvv53kk+1681av7LPPPrXbwv71r3/l5z//eXr06JENN9ww/fv3zz333LPYWaqrq3P99denT58+6dq1a7bZZpuce+65mTlzZpJPtnqdcMIJSZLtt9/+c7egHXXUUZkxY8bnrhBa1JbC3r17195fknTq1Ck33HBDTjjhhHzve9/LpptumtNPPz1VVVU566yzstlmm6VHjx75xS9+UTtz8slqtyuuuCI77LBDOnfunB133DG/+c1v5ruvAQMG5Nhjj83hhx+e7t2756CDDlroFsLXX389hx9+eDbddNNssskmOfDAA/PCCy8s8rHtsccemT17du68884FfnbLLbekZcuW+eEPf5gkuemmm9K3b99stNFG6dq1a3bZZZfccccd8z1P66+/fm666aZsscUW2WqrrfLCCy8ssB1wypQpOeWUU7Ltttumc+fO2XTTTTNo0KCFbtm8+OKL07Nnz3Tr1i0DBw7Mv/71r0U+liT505/+lL59+6ZLly7p1atXTj/99MyYMaP25zNnzswpp5ySrbbaKp07d873v//9jBo1arHH/OwWwt69e+fCCy/MWWedlZ49e6Zr167Zf//988orryz2ODNnzsyZZ56ZXr16pVu3bhkyZMh8r4N5Fvc8L+r9VF1dnSuuuCI/+tGP0rVr12y00Ub58Y9/nAceeGCxM9XU1OT666/PTjvtlK5du2aHHXbIlVdeWXt+rSlTpuTYY49Nr1690qVLl+yyyy655ZZbkiTvvPNO1ltvvVx77bXzHfPDDz9Mly5dMnLkyMXeNwCUTMACgIWoqanJnDlzMmfOnMyePTuTJ0/Oeeedl1mzZmX33Xevvd4ll1ySo446KhtuuGEuvPDCDBo0KBMmTMiAAQNSVVWV1q1bZ9iwYXn11Vdz2WWXJUl++9vf5r777suwYcO+0iqjqVOnZurUqYtchfKHP/whP/vZz7LyyivnvPPOy5AhQ/L444+nf//++fe//50NNtggQ4cOTZIMHTo0J510UubOnZuDDz44M2bMyNlnn51LLrkkbdq0ycCBA/Paa68tcpahQ4fmjDPOSO/evXPppZdm7733zujRozNw4MDU1NRk4MCB+fnPf57kk+2Qnxem1l577Rx22GH505/+lNtuu+1LPzcLc+6556ZZs2YZMWJEdtlll/zmN7/JrrvumrfffjvnnHNOfvzjH2fMmDHzBaqTTz45F154YXbeeedcdtll+f73v58zzjgjF1988XzHHj9+fJo2bZqLL754oVva3nvvvfTr1y8vv/xyTjrppJx77rmZNm1a9t1330yZMmWh83bu3Dnrrrtuxo0bN9/l1dXVufXWW7PTTjulZcuWuf766zN06NBst912ufzyy3POOeekadOmGTx48Hxxs7q6OpdddllOP/30HHnkkQusvqqpqcnBBx+c++67L8ccc0yuuuqqDBw4MPfff3/t62SeRx99NLfeemuGDh2a008/Pc8++2z23Xff2jj7WbfeemsGDRqUtdZaKxdffHEOPfTQjBs3rvb1kSTDhg3LPffck+OPPz5XXXVVtttuu5x11llf+jxi1113XV5++eWceeaZOf300/Pkk0/OFzMXZvDgwfn973+fAw88MBdccEGmTZuWa665Zr7rfN7zvLD3U/LJ6+7iiy9O//79M3LkyJx66qmZOnVqjjjiiPkC3medd955GTZsWLbeeutceuml6devX84///xccskltTO/+OKLOeWUU3LFFVdk/fXXz/HHH5+HHnooq6yySnr06DFfxEySCRMmZM6cOenTp8+Xek4BoCS2EALAQjzyyCPZYIMNFrj86KOPztprr50kmTZtWu2/YH46zHTs2DF77713xo4dm7322iubb7559tprr1xxxRXZaKONcu6556Z///5f6CTUc+fOzZw5c5J8slrktddeyznnnJPGjRunf//+C73+Oeeck549e+b888+vvbx79+754Q9/mFGjRmXw4MG1EaNDhw7p0KFDJk+enJdeeimHHHJItt566yRJ165dM2LEiIWuSEmSF198MWPGjMmRRx5ZG6l69eqVlVZaKccdd1z+9re/Zeutt64NbQvbDrkw+++/f/74xz/mtNNOy2abbVbnrYRrr7127VbLTTbZJGPGjMns2bNz7rnnpkmTJtlyyy3z5z//OY899liS5JVXXsmNN96Yo48+OgcddFCSZIsttkijRo1y+eWXZ6+99spyyy2XJGncuHFOO+20tGzZMkkWWLF09dVXp6qqKldffXXtuczWW2+99O/fP0888cQiXwO77757zjjjjLz55ptp165dkuTvf/97Jk+enH79+iX5ZMXcz372swwaNKj2du3bt0/fvn3z2GOPZbXVVqu9/JBDDsk222yz0Pt677330qJFixx//PHZeOONkyQ9evTIG2+8kd/97nfzXbdx48a56qqramdae+21s+uuu+bmm29e4PVYU1OTc889N1tuuWXOPffc2svXXHPN7LvvvrnnnnuyzTbb5OGHH07Pnj2z00471d53y5Yta5/jL2qZZZbJJZdckoqKiiSfrHy76KKLMnXq1IUe64UXXsiECRMydOjQ7L333kmSLbfcMn369JnvBPmf9zz/6Ec/WuD9NO95Peqoo+Zb6VZZWZnDDjsszz33XLp167bATB9++GGuvvrqDBgwIMcdd1yST95TU6ZMqf3ihocffjgDBw7M9ttvX/t8tWnTpvZx77LLLjnhhBPyxhtv1L7fbrvttmy22WZZeeWVv9RzCgAlEbAAYCE22GCDnHLKKUk++RfxDz/8MH/7299y/vnnZ8aMGTnqqKPyxBNPZNasWQusath4443Trl27PPTQQ9lrr72SJMcee2zuvffeHHzwwVljjTUyZMiQLzTHDjvssMBl7dq1yznnnLPQb0p75ZVXMnny5Bx99NHzXb7GGmukW7dueeihhxZ6PyussEI6dOiQX/7yl7n//vuz1VZbZYsttljsnPO+kfGzj3+nnXbKkCFD8tBDD9XGsC+joqIiZ555ZnbbbbecfPLJGTFixJc+xqd9OhQ0adIkyy23XDp37pwmTf73Y1CbNm3y0UcfJUkefPDB1NTUpHfv3rXxMEntKrNHH320Nh60b9++Nl4tzKOPPpqNNtqoNl4ln5zo/S9/+ctiZ955551zzjnn5NZbb80hhxyS5JPtgx07dsyGG26YJLWriz766KO8+uqrefXVV2u3p82ePXu+43Xs2HGR97XyyivnuuuuS5K89dZbee211/LSSy/lscceW+A4G220UW28SpJ111037du3z/33379AwHr55Zfzzjvv5OCDD57vedxkk03SunXr3Hfffdlmm23So0eP/O53v8u7776bbbfdNltvvfV8seiL6tKlS23ESZJVVlklSfLxxx8vNGDN+8bA7bbbrvayxo0bZ8cdd5wvYH2Z5/nThg8fnuSTLX+vvfZaXnnllfz5z39e7O2eeOKJzJ49e4H3/adXkvXo0SMXXXRRnn322Wy99dbZaqut5jsn33//93/nlFNOyR133JGDDjookydPzsMPP5wzzzxzkbMCwDeBgAUAC9GqVat06dJlvsu22GKLzJgxIyNHjsw+++xTe56rha0QWmGFFWqDSJK0bNkyO+64Y6688spsttlmadGixRea49JLL62NH02bNs1yyy232FUU806YvqiZnn766YXerlGjRhk1alQuvfTS/PGPf8zNN9+cpk2bZvvtt8/JJ5+8wMnEk9Q+/k/HmeR/I9GnH/+X1aFDhxx66KE577zzcvvtt3/l4ySfnJD/sxb3/M97DuetCPqsd999t/bPn7c67IMPPvhCq84+q02bNtl+++1rA9ZHH32Uu+++e74w+frrr2fo0KF58MEH06RJk6y11lq1UXPe9rx5ll9++cXe37hx43Leeefl7bffTps2bbLuuuumsrJygest7PEuv/zy+fDDDxe4fN7zeMopp9TG4E977733kiS/+MUvssoqq2TcuHG11+vWrVuGDh262G+s/KzP/k4bN/7kTBlz585d6PXnvX7btm073+WffT1/mef50/75z3/mlFNOyT//+c9UVlamQ4cOtfFvUbeb95x9dqZPO//883PZZZdl/PjxufPOO9O4ceP07NkzJ598clZfffW0atUq22+/fW3Auv3229O8efOFxnAA+CYRsADgS1hvvfVy00035Y033siyyy6bJHn//fdrtxXOM3ny5Pm+ce/FF1/Mtddem/XWWy833nhj+vTpU7tda3E6duz4pQLIvNC0sJPDT548ebHbslZeeeWcfPLJOemkk/Lss8/mzjvvzJVXXplll112oQFi3uOfPHnyfDPOnj17kdu2vowDDjggd911V0477bTsv//+8/1s3on0Pxsn/vOf/9TpPpNPtqIlybXXXptWrVot8PNPb837PN/5zncWeq6rBx54IO3bt1/stzLuvvvu2X///fPMM8/kySefTE1NTXbZZZcknzzugw46KE2bNs2NN96Y9ddfP02aNMmLL764wLmzPs/EiRNz/PHH5yc/+Un233//2pVLZ599du22tXkWFqomT5680O1w857H4447LptuuukCP5/3+mnWrFl+/vOf5+c//3neeuut/OUvf8kll1ySY445JuPHj/9Sj+XLmPf6fP/99+f7nc6LSMlXf56nT5+eAw44IJ06dcptt92WtddeO40bN84999yTCRMmLPJ2856zKVOmZK211qq9/O23385rr72W733ve/nOd76TwYMHZ/DgwXn55Zdz991355JLLskpp5xSe5L2XXbZJQcccEBeffXV3H777dl+++0X+loGgG8SJ3EHgC/h8ccfT0VFRVZfffVsuOGGadasWW699db5rjNx4sS89dZb6d69e5Jkzpw5Of7449OuXbvccMMN6dy5c4YMGbLYEzl/Vd/97nez4oorLjDTv/71rzzxxBO1M316q9W8x9WzZ89MmjQpjRo1ynrrrZejjjoqHTt2zDvvvLPQ+5oXJT57X7fffnuqq6vzve99r06PpaKiIr/61a8yffr0XH755fP9bN6qqnnfrJh8smXt0/Hhq9pkk02SfHKy/C5dutT+88EHH+SCCy74Uvex8cYb54knnsi///3v2sumTJmSAw88MHffffdib9uzZ8+0a9cuEyZMyPjx47P99tvXRpepU6fmlVdeyR577JGuXbvWbof829/+lmTRq44W5vHHH8/cuXNz+OGH18ar6urq3H///Qsc6/HHH59vZd2kSZPy5ptvZrPNNlvguGuttVaWX375vPHGG/M9j6usskqGDx+ep59+OlVVVdlxxx1rv3VwtdVWy957752ddtppka+7+jJv5s9+2+Ont3d+0ef5s++nea/FffbZJ+uss07tarDP+/107do1TZs2XeC1ce211+aII47Ie++9l6233rp25rXWWisHHnhgevbsOd/z1bNnz6y44or5zW9+k0mTJtWGTwD4JrMCCwAWYvr06XniiSdq///s2bNz991359Zbb03//v1rt/gcdNBBGTFiRJo2bZrtttsub7zxRn7961+nQ4cO6du3b5Lk8ssvz1NPPZXRo0enRYsWOe2007L77rvn3HPPXeBb3uqqcePGOfroozNkyJAcddRR2XXXXTN16tSMGDEiyy67bPbbb78kn6wMSpK//vWvWXbZZbP++uunsrIyxx13XA477LCssMIKuf/++/PMM88s9Nv1kk+2+e22224ZMWJEqqqq0qNHjzzzzDMZMWJEevTokS233LLOj2edddbJoEGDcsEFF8x3+bxtmL/61a9y5JFH5j//+U9GjBix0K2OX1bHjh2z884755e//GXefPPNdO7cOa+88krOP//8tG/fPmuuueYXPta+++6bW265Jfvvv38OOeSQNG/ePJdffnlWWmml7Lrrrou9bePGjbPbbrvllltuyTvvvJMrrrii9mfLL7982rVrl+uvvz6rrLJKlllmmfz973/Ptddem+ST8z59UV27dk2SnHrqqdl9993z4YcfZvTo0Xn22WeTJDNmzKgNhvNWJB1yyCGZOnVqhg8fXvt8fVZFRUWOOuqoDB06NBUVFdl2223z4Ycf5pJLLsm7776bDTbYIJWVldlggw1q30OdOnXKK6+8kptvvjk77rjjF34MX8V//dd/pX///jn//PMzZ86crLfeevnDH/6Q5557rvY6X/R5/uz76bvf/W5at26dyy67LE2aNEmTJk0yYcKEjBkzZr7bzZo1K08//XRWWWWVrLLKKmnbtm322WefXHvttWnWrFk222yz/POf/8zo0aNz9NFHp127dllllVVy+umnZ/r06VljjTXy5JNP5p577snBBx8833Pfp0+fXHvttVlxxRXTs2fPJfpcAsDXQcACgIV4+umn5zspdfPmzbPGGmvkqKOOmm8727zYM3r06Nx0001p06ZNvv/97+fII49MixYt8uyzz+bSSy/Nj3/849otg+uuu2723XffXHXVVdlhhx2y+eab1+vsffv2TatWrXL55Zdn0KBBad26dbbccsscffTRtef3WWeddfKjH/0o119/fe69997cdtttGTVqVIYPH55hw4blww8/zJprrplTTz21NsQtzLBhw/Jf//Vf+Z//+Z9cddVVWWmllTJgwIAMGjSodtVJXR144IH54x//mKeeeqr2su985zu58MILM3z48AwaNCjt2rXLoYcemltuuaVe7vPMM8/M5Zdfnt/97nd55513svzyy+eHP/xhjjzyyAVW2yzOqquumt/+9rc555xzMmTIkDRr1iybbrppzjnnnC8U2/r27ZuLL744q6222gIR4pJLLsmwYcNywgknpFmzZunQoUMuvfTSnHHGGZk4ceJ83363OD169MjQoUNz9dVX584778wKK6yQHj16ZMSIERk0aFAeffTR2pPxb7vttlljjTUyePDgzJkzJ9tuu21+8YtfpHnz5gs9dr9+/dKqVauMHDkyv//979OyZct079495557bu32yVNPPTUXXHBBRo0alcmTJ2f55ZfPHnvskSOOOOILzV8XJ510Uu37d9q0adlyyy1zyCGHzBdMv8jzvLD30yWXXJKzzz47RxxxRFq1apX11lsvo0ePzoEHHpiJEyemd+/eee+999K/f/8ceuihOeyww5IkgwcPzgorrJAbbrgho0aNSvv27XPiiSfWfiHEiBEjct555+XXv/51pk6dmlVXXTWHHnpo7TdmzrPLLrtk1KhR2Wmnnb7UaxYAStWoZnFnnwQAAACABuYcWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAitakoQeYZ86cOZk2bVqaN2+exo11NQAAAIBvi7lz52bmzJlZdtll06TJl89RxQSsadOm5dVXX23oMQAAAABYQtZcc80sv/zyX/p2xQSs5s2bJ/nkgbRo0aKBpwEAAACgvnz88cd59dVXa/vPl1VMwJq3bbBFixZp2bJlA08DAAAAQH37qqeNcrIpAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUrUlDDwDAkldTU5OqqqqGHgP4lJqamiRJo0aNGngS4NMqKyu9LwEKJGABfMvV1NTksMMOy5NPPtnQowBA8Tp37pyLLrpIxAIojC2EAAAAABTNCiyAb7lGjRrloosusoUQClJVVZXddtstSXLzzTensrKygScC5rGFEKBMAhbAUqBRo0Zp0aJFQ48BLERlZaX3JwDA57CFEAAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUrV4C1gMPPJB+/fqle/fu6dWrV0477bRUVVXNd5333nsvPXv2zNixY+vjLgEAAABYStQ5YE2ZMiUHH3xw9txzz0ycODE333xzHn744VxxxRW115k7d26OPfbYTJ06ta53BwAAAMBSpkldD9C2bdvcf//9ad26dWpqavLBBx9k5syZadu2be11Lr744qyyyipZddVV63p3AAAAACxl6hywkqR169ZJkq233jrvvvtuNt544/Tt2zdJ8uCDD+b222/P//zP/6RPnz6fe6zq6upUV1fXx1gAAEX69Gcdn30AgKVBXT/v1EvAmueuu+7KtGnTcuyxx+bwww/PWWedlRNPPDEXXnhhWrVq9YWO8fzzz9fnSAAAxZk5c2btnydNmpTmzZs34DQAAOWr14BVWVmZysrKDB48OP369ctxxx2XAQMGpHPnzl/4GB07dkzLli3rcywAgKJ8/PHHtX/u2rVrWrRo0YDTAAAseTNmzKjToqU6B6zHHnssJ554YsaNG5dmzZolSWbNmpUkue+++/KPf/wjF198cZJk+vTpOeWUUzJhwoRcfvnlCz1eRUVFKioq6joWAECxPv1Zx2cfAGBpUNfPO3X+FsJOnTqlqqoqw4cPz6xZs/Lmm2/mrLPOyp577plnn302EydOrP1ntdVWy0knnbTIeAUAAAAAn1XngNWqVauMHDkyL7zwQnr16pUBAwakZ8+eOfHEE+tjPgAAAACWcvVyDqwOHTpk1KhRn3u9P//5z/VxdwAAAAAsReq8AgsAAAAAliQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJ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K9OoUaOGHoMGJGBRr6qqqvKDH/ygoccAgG+M3XbbraFHAIDijR8/Pi1atGjoMWhAthACAAAAUDQrsFhipm+0Z2oae4kBwELV1Hzyv7ZDAMBCNZo7J62fuKGhx6AQ6gJLTE3jJklF04YeAwAAgG+gmoYegKLYQggAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQtHoJWA888ED69euX7t27p1evXjnttNNSVVWVJPnHP/6Rfv36pVu3bundu3duuumm+rhLAAAAAJYSdQ5YU6ZMycEHH5w999wzEydOzM0335yHH344V1xxRaZNm5aDDjoou+66ax555JEMGzYsZ555ZiZNmlQfswMAAACwFGhS1wO0bds2999/f1q3bp2ampp88MEHmTlzZtq2bZu77rorbdq0yd57750k2XzzzdOnT59cf/316dq1a52HBwAAAODbr84BK0lat26dJNl6663z7rvvZuONN07fvn1zwQUXpGPHjvNdt0OH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AACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFC0OgesZ599Nvvtt1823XTT9OrVK8cdd1ymTJlS+7Of/vSn6datW3r27Jkzzzwzc+bMqfPQAAAAACw96hSwqqqqcsABB6Rbt275+9//nttuuy0ffPBBTjzxxEyZMiX77rtvevbsmYcffjg33nhj/vrXv+baa6+tr9kBAAAAWAo0qcuN33rrray77roZNGhQKioq0qxZs/Tv3z/HHXdcbrnllqy55po5+OCDkyTt27fPqFGj0qhRo3oZHAAAAIClQ50C1lprrZWRI0fOd9mECROywQYbZNKkSenYsWOGDh2au+++Oy1atMjuu+9eG7QWpbq6OtXV1XUZCwCgaJ/+rOOzDwCwNKjr5506BaxPq6mpyQUXXJC//OUvGT16dH71q1/lT3/6U04++eT88pe/zEsvvZRDDjkkzZo1y/7777/I4zz//PP1NRIAQJFmzpxZ++dJkyalefPmDTgNAED56iVgTZ8+PUOGDMlTTz2V0aNHp1OnTmnWrFm6dOmSPfbYI0my7rrr5ic/+UnGjx+/2IDVsWPHtGzZsj7GAgAo0scff1z7565du6ZFixYNOA0AwJI3Y8aMOi1aqnPAev3113PggQdmtdVWy5gxY9K2bdskydprr52HHnpovuvOnTs3NTU1iz1eRUVFKioq6joWAECxPv1Zx2cfAGBpUNfPO3X6FsJp06blpz/9abp3756rrrqqNl4lye67757nn38+V155Zaqrq/Pcc89l9OjR2WWXXeo0MAAAAABLlzqtwBo7dmzeeuutjB8/Pnfeeed8P3v88cczevTonH322bniiitSWVmZPffcMwMGDKjTwAAAAAAsXeoUsPbbb7/st99+i/z5hhtumOuvv74udwEAAADAUq5OWwgBAAAAYEkTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARWvS0AMAsOTV1NSkqqqqoccA/r9Pvx+9N6EslZWVadSoUUOPAcBnCFgA33I1NTU57LDD8uSTTzb0KMBC7Lbbbg09AvApnTt3zkUXXSRiARTGFkIAAAAAimYFFsC3XKNGjXLRRRfZpgSFqampSRKrPKAwthAClEnAAlgKNGrUKC1atGjoMQAAAL4SWwgBAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACKJmABAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAACiagAUAAABA0QQsAAAAAIomYAEAAABQNAELAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAojVp6AHmmTt3bpLk448/buBJAAAAAKhP83rPvP7zZRUTsGbOnJkkefXVVxt2EAAAAACWiJkzZ6Z169Zf+naNampqapbAPF/anDlzMm3atDRv3jyNG9vZCAAAAPBtMXfu3MycOTPLLrtsmjT58uupiglYAAAAALAwljoBAAAAUDQBCwAAAICiCVgAAAAAFE3AAgAAAKBoAhYAAAAARROwAAAAAChak4YeAABgaffSSy9lzJgxef3119OjR49suOGG2XDDDRt6LACAYliBBQDQgF588cX85Cc/SU1NTTbZZJPccccdOeOMM/LCCy809GgAAMWwAgsAoIHMnj071113XQYMGJCBAwcmSX77299mu+22yzLLLJMpU6akbdu2DTwlAEDDswILAKCBNG3aNG+88UZWWGGFJMmuu+6aLl26ZPDgwdlzzz3z0EMPNfCEAABlELAAAL5mkydPzpNPPpkkadeuXZ5//vn07ds366yzToYPH57GjRtnxRVXzKqrrtrAkwIAlEHAAgD4Gv373//OyJEj8+tf/zrvvvtudt1119xwww2prq7OCSeckCQ59thjU11dnS5dujTwtAAAZWhUU1NT09BDAAAsDZ577rmcffbZ+c9//pNnnnkmvXv3zpAhQ/Lyyy9n2LBhady4cZZZZpnMnTs311xzTZo2bZq5c+emcWP/zREAWLoJWAAAX4N33nknffv2zSGHHJK+ffvm3nvvzZ///OfMnTs3Q4YMSePGjfPWW2+lpqYmG2ywQRo3bpw5c+akSRPfuQMAIGABAHwN7rvvvowaNSpXXXVV7WUPPfRQzj333Ky66qo57LDDss4669T+zMorAID/5VMRAMASNHfu3CRJkyZN8vjjj+f5559PktTU1KRHjx5ZffXV88wzz+Tqq6/OK6+8Uns78QoA4H9Zkw4AsIS89NJLueaaa9KmTZu0b98+2223XW677bb0798/7dq1S5K0bt06W221VV599dU8/vjj+e53v9vAUwMAlMcWQgCAJeC5557L3nvvnZ49e+bdd99Ns2bN0qRJk7Rq1SqzZs3KRhttlKeeeipvv/12xo4dm5NPPjnvv/9+RowY0dCjAwAUx9p0AIB69u9//zvHHntsjjzyyFx44YXZeeedM2fOnFRXV6dt27bZYost8vTTT2ellVbKjTfemCRZdtlls/rqq9duOQQA4H/ZQggAUM/eeeedtGnTJj/5yU/y4Ycf5v77789mm22WF198MU888US6deuWESNG5PXXX89LL72URx55JDfccENGjx7t3FcAAAvhExIAQD2bNWtWqqurM3369Oy3335p27ZtjjjiiKy++uqZPXt2brvttkyZMiW33XZbDj300EyYMCFXX311Onbs2NCjAwAUyTmwAACWgBkzZuQf//hHLr300lx33XVJkmOOOSYbbrhh9tlnnyTJ9OnTU1NTk0aNGqV169YNOS4AQNFsIQQAWAJatmyZJk2a5KmnnsrYsWPzwAMP5NVXX81ZZ52VJKmpqRGtAAC+ICuwAACWkI8++ijDhw/Ps88+m9atW+fSSy9N06ZNU11dnYqKioYeDwDgG0PAAgBYgubMmZOZM2emZcuWadSoUebMmZMmTSyCBwD4MgQsAICvydy5c33LIADAVyBgAQAAAFA0/wkQAAAAgKIJWAAAAAAUTcACAAAAoGgCFgAAAABFE7AAAAAAKJqABQAAAEDRBCwAAAAAiiZgAQAAAFA0AQsAAACAoglYAAAAABRNwAIAAACgaAIWAAAAAEUTsAAAAAAomoAFAAAAQNEELAAAAACK9v8AxASkp7aoyBYAAAAASUVORK5CYII=", + "format": "base64" + } + ] + } + ], + "content": "# Statistical Analysis\n\n```json\n{\n \"format\": \"AnalysisReport\",\n \"content\": \"# Statistical Analysis Report\\n\\n## Summary of the CSV Data\\nThe dataset 'data.csv' consists of 4 rows and 3 columns, namely 'name', 'age', and 'location'. The data provides information about individuals, including their names, ages, and locations.\\n\\n### Data Structure\\n- **Columns**: 3 (name, age, location)\\n- **Rows**: 4\\n\\n### Key Variables\\n- **name**: Categorical variable with 4 unique values.\\n- **age**: Numeric variable.\\n- **location**: Categorical variable with 4 unique values.\\n\\n## Key Findings and Insights\\n\\n### Numeric Column: Age\\n- **Minimum Age**: 25\\n- **Maximum Age**: 35\\n- **Mean Age**: 29.50\\n- **Median Age**: 29.00\\n\\n#### Distribution and Variability\\n- The ages range from 25 to 35, with a mean age of 29.5 years.\\n- The median age is slightly lower than the mean, indicating a slightly left-skewed distribution.\\n- The dataset is small, so variability is limited, but the range of 10 years shows some diversity in age.\\n\\n### Categorical Columns\\n- **Name**: Each name is unique, indicating no repetition in the dataset.\\n- **Location**: Each location is unique, suggesting a diverse geographical distribution.\\n\\n## Supporting Evidence and Calculations\\n\\n### Age Statistics Table\\n| Statistic | Value |\\n|-----------|-------|\\n| Minimum | 25 |\\n| Maximum | 35 |\\n| Mean | 29.50 |\\n| Median | 29.00 |\\n\\n### Categorical Data Summary\\n- **Name**: 4 unique values (John, Alice, Bob, Emma)\\n- **Location**: 4 unique values (New York, London, Paris, Berlin)\\n\\n## Clear Conclusions\\n- The dataset provides a small sample of individuals with unique names and locations, and a moderate range of ages.\\n- The age distribution is relatively balanced with no significant outliers.\\n\\n## Recommendations\\n- For a more comprehensive analysis, a larger dataset would be beneficial to identify broader trends and patterns.\\n- Visualization such as a histogram for age distribution and a bar chart for categorical data could enhance understanding.\\n\"\n}\n```\n\n## Visualizations\n\nThe following visualizations have been created to help understand the data:\n\n1. **Statistical Distributions - data.csv** - Available as an attached document\n2. **Box Plots - data.csv** - Available as an attached document\n", + "agent_type": "analyst_agent", + "agent_id": "analyst_agent", + "agent_name": "Data Analyst", + "result_format": "AnalysisReport" }, { - "id": "msg_92c40d8d-c9d3-4e62-bc68-c1c31bccbc26", - "workflow_id": "wf_20250414200154", + "id": "msg_469729a0-056b-4de3-9f32-79de0783f1da", + "workflow_id": "wf_20250415005950", "parent_message_id": null, - "started_at": "2025-04-14T20:03:10.957618", + "started_at": "2025-04-15T01:00:54.627705", "finished_at": null, "sequence_no": 3, "status": "pending", @@ -79,17 +114,20 @@ "bytes_received": 0 }, "documents": [], - "content": "## Data Analysis Report\n\nNo data documents were provided for analysis. Please attach CSV, Excel, or other data files for me to analyze.", - "agent_type": "analyst_agent", - "agent_id": "analyst_agent", - "agent_name": "Data Analyst", - "result_format": "AnalysisReport" + "content": "## Code executed successfully\n\n### Final Executed Code\n\n```python\n# REQUIREMENTS: pandas\n\nimport pandas as pd\n\n# Load the CSV file\nfile_path = './input_data/data.csv'\ntry:\n data = pd.read_csv(file_path)\nexcept FileNotFoundError:\n result = {\"error\": \"File not found\"}\nexcept pd.errors.EmptyDataError:\n result = {\"error\": \"No data\"}\nexcept pd.errors.ParserError:\n result = {\"error\": \"Parsing error\"}\nelse:\n # Calculate the current sum of ages\n current_age_sum = data['age'].sum()\n\n # Calculate the number of years needed to reach a sum of 200\n years_needed = 200 - current_age_sum\n\n # Assuming the current year is 2023\n current_year = 2023\n target_year = current_year + years_needed\n\n # Store the result\n result = {\"target_year\": target_year}\n\n# Output the result\nprint(result)\n```\n\n### Output\n\n```\n{'target_year': np.float64(2105.0)}\n\n```\n\n", + "agent_type": "coder", + "agent_id": "coder", + "agent_name": "Python Code Agent", + "result_format": { + "format": "python_code", + "content": "Year when the sum of ages equals 200." + } }, { - "id": "msg_c2c52c9f-0c69-4037-9d9a-5d120ccf0770", - "workflow_id": "wf_20250414200154", + "id": "msg_11f0f5a5-73ea-4d1a-b3ec-cd1bf2c52c26", + "workflow_id": "wf_20250415005950", "parent_message_id": null, - "started_at": "2025-04-14T20:03:18.029940", + "started_at": "2025-04-15T01:01:01.714224", "finished_at": null, "sequence_no": 4, "status": "pending", @@ -101,17 +139,20 @@ "bytes_received": 0 }, "documents": [], - "content": "## Code executed successfully\n\n### Executed Code\n\n```python\n# REQUIREMENTS: pandas\n\nimport pandas as pd\n\n# Load the data\nfile_path = './input_data/data.csv'\ntry:\n data = pd.read_csv(file_path)\nexcept FileNotFoundError:\n result = {\"error\": \"File not found\"}\n print(result)\n exit()\n\n# Calculate the sum of ages\ntotal_age = data['age'].sum()\n\n# Initialize variables\nyear = 2025\nage_sum = 0\n\n# Calculate the year when the sum of ages reaches 200\nwhile age_sum < 200:\n age_sum += total_age\n if age_sum >= 200:\n break\n year += 1\n\n# Store the result\nresult = {\"year\": year, \"age_sum\": age_sum}\n\n# Output the result\nprint(result)\n```\n\n### Output\n\n```\n{'year': 2026, 'age_sum': np.float64(236.0)}\n\n```\n\n", + "content": "## Code executed successfully\n\n### Final Executed Code\n\n```python\n# REQUIREMENTS: pandas,numpy\nimport pandas as pd\nimport numpy as np\nimport os\n\n# Load the existing CSV data\nfile_path = './input_data/data.csv'\ntry:\n existing_data = pd.read_csv(file_path)\nexcept FileNotFoundError:\n raise Exception(f\"File not found: {file_path}\")\n\n# Generate 10 additional datasets\nadditional_data = []\nnames = ['Michael', 'Sarah', 'David', 'Laura', 'James', 'Sophia', 'Daniel', 'Olivia', 'Matthew', 'Isabella']\nlocations = ['Tokyo', 'Sydney', 'Toronto', 'Rome', 'Madrid', 'Amsterdam', 'Dubai', 'Singapore', 'Hong Kong', 'San Francisco']\n\nfor i in range(10):\n name = np.random.choice(names)\n age = np.random.randint(20, 40)\n location = np.random.choice(locations)\n additional_data.append({'name': name, 'age': age, 'location': location})\n\n# Convert additional data to DataFrame\nadditional_df = pd.DataFrame(additional_data)\n\n# Integrate additional data into the existing CSV table\nintegrated_data = pd.concat([existing_data, additional_df], ignore_index=True)\n\n# Save the integrated data back to CSV\noutput_file_path = './input_data/integrated_data.csv'\nintegrated_data.to_csv(output_file_path, index=False)\n\n# Prepare the result\nresult = {\n \"status\": \"success\",\n \"message\": \"Integrated data saved successfully.\",\n \"output_file\": output_file_path,\n \"integrated_data_preview\": integrated_data.head().to_dict(orient='records')\n}\n\n# Output the result\nprint(result)\n```\n\n### Output\n\n```\n{'status': 'success', 'message': 'Integrated data saved successfully.', 'output_file': './input_data/integrated_data.csv', 'integrated_data_preview': [{'name': 'John', 'age': 30.0, 'location': 'New York'}, {'name': 'Alice', 'age': 25.0, 'location': 'London'}, {'name': 'Bob', 'age': 35.0, 'location': 'Paris'}, {'name': 'Emma', 'age': 28.0, 'location': 'Berlin'}, {'name': '=== OUTPUTS FROM PREVIOUS ACTIVITIES ===', 'age': nan, 'location': nan}]}\n\n```\n\n", "agent_type": "coder", "agent_id": "coder", "agent_name": "Python Code Agent", - "result_format": "AnalysisReport detailing the year when the sum of ages is 200." + "result_format": { + "format": "python_code", + "content": "Updated CSV table with 10 additional datasets." + } }, { - "id": "msg_4124c5b0-1a43-48e4-af9f-6db183956f14", - "workflow_id": "wf_20250414200154", + "id": "msg_16811e08-f0d2-48c4-9d99-8e8152f08613", + "workflow_id": "wf_20250415005950", "parent_message_id": null, - "started_at": "2025-04-14T20:03:18.029940", + "started_at": "2025-04-15T01:01:15.544641", "finished_at": null, "sequence_no": 5, "status": "pending", @@ -122,18 +163,36 @@ "bytes_sent": 0, "bytes_received": 0 }, - "documents": [], - "content": "## Data Analysis Report\n\nNo data documents were provided for analysis. Please attach CSV, Excel, or other data files for me to analyze.", - "agent_type": "analyst_agent", - "agent_id": "analyst_agent", - "agent_name": "Data Analyst", - "result_format": "AnalysisReport" + "documents": [ + { + "id": "doc_ba686592-9e7e-4053-9784-de64dcfacad9", + "source": { + "type": "generated", + "id": "doc_ba686592-9e7e-4053-9784-de64dcfacad9", + "name": "Comprehensive Analysis and Dataset Update Report: Age Sum Projection and Data Integration", + "content_type": "text/markdown", + "size": 3760 + }, + "contents": [ + { + "type": "text", + "text": "# Comprehensive Analysis and Dataset Update Report: Age Sum Projection and Data Integration\n\n## Introduction\n\nThis report presents a comprehensive analysis of the dataset `data.csv`, which includes information about individuals' names, ages, and locations. The purpose of this report is to summarize the statistical analysis of the dataset, calculate the projected year when the sum of ages reaches 200, and detail the integration of additional data into the existing dataset. This report is structured to provide clear insights and recommendations based on the analysis conducted.\n\n## Analysis Summary\n\n### Dataset Overview\n\nThe dataset `data.csv` consists of 4 records with the following columns:\n- **Name**: Categorical variable with unique values.\n- **Age**: Numeric variable representing the age of individuals.\n- **Location**: Categorical variable with unique values representing geographical locations.\n\n### Key Findings and Insights\n\n#### Age Statistics\n- **Minimum Age**: 25\n- **Maximum Age**: 35\n- **Mean Age**: 29.50\n- **Median Age**: 29.00\n\nThe ages range from 25 to 35, with a mean age of 29.5 years. The median age is slightly lower than the mean, indicating a slightly left-skewed distribution. The dataset is small, with a range of 10 years, showing some diversity in age.\n\n#### Categorical Data\n- **Name**: Each name is unique, indicating no repetition.\n- **Location**: Each location is unique, suggesting a diverse geographical distribution.\n\n### Supporting Evidence\n\n#### Age Statistics Table\n\n| Statistic | Value |\n|-----------|-------|\n| Minimum | 25 |\n| Maximum | 35 |\n| Mean | 29.50 |\n| Median | 29.00 |\n\n#### Categorical Data Summary\n- **Name**: 4 unique values (John, Alice, Bob, Emma)\n- **Location**: 4 unique values (New York, London, Paris, Berlin)\n\n## Age Sum Projection\n\n### Calculation of Target Year\n\nThe current sum of ages in the dataset is calculated, and the number of years needed to reach a sum of 200 is determined. Assuming the current year is 2023, the calculation is as follows:\n\n- **Current Age Sum**: 118\n- **Years Needed**: 200 - 118 = 82\n- **Target Year**: 2023 + 82 = 2105\n\nThus, the projected year when the sum of ages will reach 200 is **2105**.\n\n## Data Integration\n\n### Additional Data Generation\n\nTo enhance the dataset, 10 additional records were generated with random names, ages, and locations. The names and locations were selected from predefined lists, and ages were randomly chosen between 20 and 40.\n\n### Integration Process\n\nThe additional data was integrated into the existing dataset, resulting in a new dataset saved as `integrated_data.csv`. The integration process was successful, and a preview of the integrated data is provided below:\n\n#### Integrated Data Preview\n\n| Name | Age | Location |\n|--------|-----|-------------|\n| John | 30 | New York |\n| Alice | 25 | London |\n| Bob | 35 | Paris |\n| Emma | 28 | Berlin |\n| Michael| 32 | Tokyo |\n| ... | ... | ... |\n\n## Conclusion\n\nThis report provides a detailed analysis of the dataset `data.csv`, including statistical insights, age sum projection, and data integration. The key takeaways are:\n\n- The dataset offers a small but diverse sample of individuals.\n- The projected year when the sum of ages reaches 200 is 2105.\n- The integration of additional data enhances the dataset's comprehensiveness.\n\n### Recommendations\n\n- For more robust analysis, consider expanding the dataset further.\n- Utilize visualizations such as histograms and bar charts to better understand the data distribution.\n\nThis report serves as a foundation for future data analysis and integration efforts, ensuring a comprehensive understanding of the dataset's characteristics and potential growth.", + "is_extracted": true + } + ] + } + ], + "content": "I've created a document titled 'Comprehensive Analysis and Dataset Update Report: Age Sum Projection and Data Integration' that contains the requested information. The document is attached to this message.", + "agent_type": "documentation_agent", + "agent_id": "documentation_agent", + "agent_name": "Documentation Specialist", + "result_format": "FormattedDocument" }, { - "id": "msg_22728edd-a903-454f-9957-453d258bcceb", - "workflow_id": "wf_20250414200154", + "id": "msg_4226abac-835b-4abe-ae47-c70327b21443", + "workflow_id": "wf_20250415005950", "parent_message_id": null, - "started_at": "2025-04-14T20:03:28.462151", + "started_at": "2025-04-15T01:01:28.053678", "finished_at": null, "sequence_no": 6, "status": "pending", @@ -145,73 +204,7 @@ "bytes_received": 0 }, "documents": [], - "content": "## Code executed successfully\n\n### Executed Code\n\n```python\n# REQUIREMENTS: pandas,numpy\n\nimport pandas as pd\nimport numpy as np\nimport os\n\n# Load the original dataset\noriginal_data_path = './input_data/data.csv'\ntry:\n original_data = pd.read_csv(original_data_path)\nexcept FileNotFoundError:\n raise Exception(f\"File not found: {original_data_path}\")\n\n# Function to generate random names\ndef generate_random_name():\n first_names = ['John', 'Alice', 'Bob', 'Emma', 'Mike', 'Sara', 'Tom', 'Lucy', 'Jake', 'Lily']\n last_names = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez']\n return np.random.choice(first_names) + \" \" + np.random.choice(last_names)\n\n# Function to generate random ages\ndef generate_random_age():\n return np.random.randint(20, 60)\n\n# Function to generate random locations\ndef generate_random_location():\n locations = ['New York', 'London', 'Paris', 'Berlin', 'Tokyo', 'Sydney', 'Moscow', 'Toronto', 'Dubai', 'Rome']\n return np.random.choice(locations)\n\n# Generate 10 additional datasets\nadditional_datasets = []\nfor _ in range(10):\n new_data = original_data.copy()\n new_data['name'] = [generate_random_name() for _ in range(len(new_data))]\n new_data['age'] = [generate_random_age() for _ in range(len(new_data))]\n new_data['location'] = [generate_random_location() for _ in range(len(new_data))]\n additional_datasets.append(new_data)\n\n# Store the result in a dictionary\nresult = {'datasets': [df.to_dict(orient='records') for df in additional_datasets]}\n\n# Output the result\nprint(result)\n```\n\n### Output\n\n```\n{'datasets': [[{'name': 'Bob Williams', 'age': 49, 'location': np.str_('New York')}, {'name': 'John Jones', 'age': 27, 'location': np.str_('Sydney')}, {'name': 'Jake Martinez', 'age': 55, 'location': np.str_('Tokyo')}, {'name': 'Emma Johnson', 'age': 28, 'location': np.str_('Moscow')}, {'name': 'Jake Rodriguez', 'age': 55, 'location': np.str_('Berlin')}], [{'name': 'John Johnson', 'age': 58, 'location': np.str_('New York')}, {'name': 'John Jones', 'age': 27, 'location': np.str_('Paris')}, {'name': 'Sara Garcia', 'age': 56, 'location': np.str_('Sydney')}, {'name': 'Sara Rodriguez', 'age': 20, 'location': np.str_('New York')}, {'name': 'Lucy Garcia', 'age': 58, 'location': np.str_('Berlin')}], [{'name': 'Lily Johnson', 'age': 58, 'location': np.str_('Rome')}, {'name': 'Sara Rodriguez', 'age': 52, 'location': np.str_('Sydney')}, {'name': 'Sara Davis', 'age': 43, 'location': np.str_('Moscow')}, {'name': 'Alice Johnson', 'age': 43, 'location': np.str_('Berlin')}, {'name': 'Bob Miller', 'age': 35, 'location': np.str_('London')}], [{'name': 'Mike Brown', 'age': 44, 'location': np.str_('Tokyo')}, {'name': 'Jake Miller', 'age': 49, 'location': np.str_('Paris')}, {'name': 'Sara Rodriguez', 'age': 25, 'location': np.str_('London')}, {'name': 'Jake Johnson', 'age': 22, 'location': np.str_('New York')}, {'name': 'Emma Rodriguez', 'age': 22, 'location': np.str_('Dubai')}], [{'name': 'Emma Jones', 'age': 25, 'location': np.str_('Sydney')}, {'name': 'Sara Martinez', 'age': 59, 'location': np.str_('Toronto')}, {'name': 'Lily Rodriguez', 'age': 50, 'location': np.str_('Dubai')}, {'name': 'Emma Davis', 'age': 24, 'location': np.str_('Moscow')}, {'name': 'Jake Davis', 'age': 52, 'location': np.str_('Berlin')}], [{'name': 'Emma Smith', 'age': 24, 'location': np.str_('Toronto')}, {'name': 'Jake Rodriguez', 'age': 35, 'location': np.str_('Tokyo')}, {'name': 'Alice Garcia', 'age': 46, 'location': np.str_('Sydney')}, {'name': 'John Davis', 'age': 37, 'location': np.str_('New York')}, {'name': 'John Johnson', 'age': 50, 'location': np.str_('London')}], [{'name': 'Lily Jones', 'age': 46, 'location': np.str_('London')}, {'name': 'Alice Garcia', 'age': 42, 'location': np.str_('New York')}, {'name': 'John Garcia', 'age': 20, 'location': np.str_('Moscow')}, {'name': 'Sara Smith', 'age': 40, 'location': np.str_('Moscow')}, {'name': 'Jake Garcia', 'age': 32, 'location': np.str_('Sydney')}], [{'name': 'Jake Garcia', 'age': 41, 'location': np.str_('Tokyo')}, {'name': 'Bob Garcia', 'age': 41, 'location': np.str_('New York')}, {'name': 'Alice Rodriguez', 'age': 53, 'location': np.str_('Rome')}, {'name': 'Lucy Jones', 'age': 48, 'location': np.str_('Paris')}, {'name': 'Bob Williams', 'age': 55, 'location': np.str_('New York')}], [{'name': 'Lily Smith', 'age': 35, 'location': np.str_('Rome')}, {'name': 'Sara Garcia', 'age': 36, 'location': np.str_('New York')}, {'name': 'Jake Miller', 'age': 39, 'location': np.str_('Dubai')}, {'name': 'John Garcia', 'age': 35, 'location': np.str_('Sydney')}, {'name': 'Alice Williams', 'age': 56, 'location': np.str_('New York')}], [{'name': 'Lucy Martinez', 'age': 54, 'location': np.str_('Dubai')}, {'name': 'Lucy Williams', 'age': 52, 'location': np.str_('Toronto')}, {'name': 'Lucy Williams', 'age': 57, 'location': np.str_('Tokyo')}, {'name': 'Alice Williams', 'age': 41, 'location': np.str_('London')}, {'name': 'John Davis', 'age': 28, 'location': np.str_('Sydney')}]]}\n\n```\n\n", - "agent_type": "coder", - "agent_id": "coder", - "agent_name": "Python Code Agent", - "result_format": "python_code that generates 10 additional datasets." - }, - { - "id": "msg_55870e8e-984b-471e-8516-fbec50839090", - "workflow_id": "wf_20250414200154", - "parent_message_id": null, - "started_at": "2025-04-14T20:03:33.323930", - "finished_at": null, - "sequence_no": 7, - "status": "pending", - "role": "assistant", - "data_stats": { - "processing_time": 0.0, - "token_count": 0, - "bytes_sent": 0, - "bytes_received": 0 - }, - "documents": [], - "content": "## Code executed successfully\n\n### Executed Code\n\n```python\n# REQUIREMENTS: pandas,numpy\n\nimport pandas as pd\nimport numpy as np\nimport os\n\n# Load the original dataset\noriginal_data_path = './input_data/data.csv'\ntry:\n original_data = pd.read_csv(original_data_path)\nexcept FileNotFoundError:\n raise Exception(f\"File not found: {original_data_path}\")\n\n# Function to generate random names\ndef generate_random_name():\n first_names = ['John', 'Alice', 'Bob', 'Emma', 'Mike', 'Sara', 'Tom', 'Lucy', 'Jake', 'Lily']\n last_names = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez']\n return np.random.choice(first_names) + \" \" + np.random.choice(last_names)\n\n# Function to generate random ages\ndef generate_random_age():\n return np.random.randint(20, 60)\n\n# Function to generate random locations\ndef generate_random_location():\n locations = ['New York', 'London', 'Paris', 'Berlin', 'Tokyo', 'Sydney', 'Moscow', 'Toronto', 'Dubai', 'Rome']\n return np.random.choice(locations)\n\n# Generate 10 additional datasets\nadditional_datasets = []\nfor _ in range(10):\n new_data = original_data.copy()\n new_data['name'] = [generate_random_name() for _ in range(len(new_data))]\n new_data['age'] = [generate_random_age() for _ in range(len(new_data))]\n new_data['location'] = [generate_random_location() for _ in range(len(new_data))]\n additional_datasets.append(new_data)\n\n# Store the result in a dictionary\nresult = {'datasets': [df.to_dict(orient='records') for df in additional_datasets]}\n\n# Output the result\nprint(result)\n```\n\n### Output\n\n```\n{'datasets': [[{'name': 'John Garcia', 'age': 38, 'location': np.str_('Sydney')}, {'name': 'Sara Smith', 'age': 45, 'location': np.str_('Toronto')}, {'name': 'Lily Rodriguez', 'age': 25, 'location': np.str_('Dubai')}, {'name': 'Lucy Brown', 'age': 44, 'location': np.str_('London')}, {'name': 'John Williams', 'age': 37, 'location': np.str_('Moscow')}], [{'name': 'Lily Jones', 'age': 52, 'location': np.str_('Paris')}, {'name': 'Lucy Martinez', 'age': 54, 'location': np.str_('Tokyo')}, {'name': 'Lucy Jones', 'age': 34, 'location': np.str_('Toronto')}, {'name': 'Emma Jones', 'age': 38, 'location': np.str_('New York')}, {'name': 'John Rodriguez', 'age': 21, 'location': np.str_('Moscow')}], [{'name': 'Mike Williams', 'age': 39, 'location': np.str_('Tokyo')}, {'name': 'Bob Jones', 'age': 54, 'location': np.str_('Berlin')}, {'name': 'Sara Miller', 'age': 20, 'location': np.str_('Toronto')}, {'name': 'Lucy Rodriguez', 'age': 50, 'location': np.str_('Sydney')}, {'name': 'Emma Davis', 'age': 33, 'location': np.str_('Moscow')}], [{'name': 'John Williams', 'age': 23, 'location': np.str_('Rome')}, {'name': 'Tom Davis', 'age': 45, 'location': np.str_('Moscow')}, {'name': 'Mike Martinez', 'age': 44, 'location': np.str_('Toronto')}, {'name': 'Tom Jones', 'age': 46, 'location': np.str_('Sydney')}, {'name': 'Bob Smith', 'age': 36, 'location': np.str_('Paris')}], [{'name': 'Tom Brown', 'age': 45, 'location': np.str_('Tokyo')}, {'name': 'Jake Garcia', 'age': 31, 'location': np.str_('New York')}, {'name': 'Bob Garcia', 'age': 52, 'location': np.str_('Berlin')}, {'name': 'Mike Rodriguez', 'age': 39, 'location': np.str_('Sydney')}, {'name': 'Lily Williams', 'age': 59, 'location': np.str_('Tokyo')}], [{'name': 'Tom Miller', 'age': 26, 'location': np.str_('New York')}, {'name': 'Jake Garcia', 'age': 39, 'location': np.str_('Paris')}, {'name': 'Tom Smith', 'age': 24, 'location': np.str_('New York')}, {'name': 'Lucy Johnson', 'age': 34, 'location': np.str_('Moscow')}, {'name': 'Tom Miller', 'age': 33, 'location': np.str_('New York')}], [{'name': 'John Brown', 'age': 23, 'location': np.str_('Tokyo')}, {'name': 'Sara Miller', 'age': 21, 'location': np.str_('Toronto')}, {'name': 'Emma Brown', 'age': 28, 'location': np.str_('Toronto')}, {'name': 'Mike Johnson', 'age': 27, 'location': np.str_('Dubai')}, {'name': 'Lucy Garcia', 'age': 22, 'location': np.str_('London')}], [{'name': 'Mike Miller', 'age': 24, 'location': np.str_('Moscow')}, {'name': 'Emma Garcia', 'age': 36, 'location': np.str_('Paris')}, {'name': 'Jake Martinez', 'age': 53, 'location': np.str_('Dubai')}, {'name': 'Lucy Miller', 'age': 21, 'location': np.str_('Sydney')}, {'name': 'Lily Jones', 'age': 52, 'location': np.str_('Dubai')}], [{'name': 'Lucy Smith', 'age': 43, 'location': np.str_('New York')}, {'name': 'Tom Martinez', 'age': 25, 'location': np.str_('Moscow')}, {'name': 'Lucy Davis', 'age': 20, 'location': np.str_('Tokyo')}, {'name': 'Lucy Jones', 'age': 44, 'location': np.str_('Toronto')}, {'name': 'Bob Rodriguez', 'age': 46, 'location': np.str_('London')}], [{'name': 'Sara Rodriguez', 'age': 28, 'location': np.str_('Sydney')}, {'name': 'Lily Jones', 'age': 24, 'location': np.str_('Sydney')}, {'name': 'Jake Johnson', 'age': 50, 'location': np.str_('Toronto')}, {'name': 'Lily Johnson', 'age': 49, 'location': np.str_('Berlin')}, {'name': 'Mike Miller', 'age': 33, 'location': np.str_('Tokyo')}]]}\n\n```\n\n", - "agent_type": "coder", - "agent_id": "coder", - "agent_name": "Python Code Agent", - "result_format": "Updated CSV file with the original and additional datasets." - }, - { - "id": "msg_deec0c29-d36f-4de5-87ca-6a877b7eb7cc", - "workflow_id": "wf_20250414200154", - "parent_message_id": null, - "started_at": "2025-04-14T20:03:36.477261", - "finished_at": null, - "sequence_no": 8, - "status": "pending", - "role": "assistant", - "data_stats": { - "processing_time": 0.0, - "token_count": 0, - "bytes_sent": 0, - "bytes_received": 0 - }, - "documents": [], - "content": "# Report Document\n\nUnable to generate complex document: AI service not available.", - "agent_type": "documentation_agent", - "agent_id": "documentation_agent", - "agent_name": "Documentation Specialist", - "result_format": "FormattedDocument" - }, - { - "id": "msg_8ef7db00-b8f6-4495-be6d-cc3f78dac45b", - "workflow_id": "wf_20250414200154", - "parent_message_id": null, - "started_at": "2025-04-14T20:03:46.590203", - "finished_at": null, - "sequence_no": 9, - "status": "pending", - "role": "assistant", - "data_stats": { - "processing_time": 0.0, - "token_count": 0, - "bytes_sent": 0, - "bytes_received": 0 - }, - "documents": [], - "content": "### Summary of Workflow Results\n\n1. **Activity 1: Load and Analyze CSV Data**\n - **Objective:** To load and perform an initial analysis of the CSV data.\n - **Outcome:** No data documents were provided, preventing analysis. Recommendation: Ensure CSV or other data files are attached for analysis.\n\n2. **Activity 2: Calculate Year When Sum of Ages is 200**\n - **Objective:** To determine the year when the sum of ages in the dataset equals 200.\n - **Outcome:** Similar to Activity 1, no data was provided, making the calculation impossible. Recommendation: Attach the necessary data files for accurate analysis.\n\n3. **Activity 3: Generate Additional Datasets**\n - **Objective:** To create 10 additional datasets for integration.\n - **Outcome:** Code executed successfully, indicating datasets were generated. However, the absence of the original dataset limits the context of these additional datasets.\n\n4. **Activity 4: Integrate Additional Datasets**\n - **Objective:** To integrate the newly generated datasets into the original table.\n - **Outcome:** Code executed successfully, suggesting integration was achieved. Yet, without the original dataset, the integration's effectiveness cannot be assessed.\n\n5. **Activity 5: Document the Analysis and Findings**\n - **Objective:** To document the analysis and findings comprehensively.\n - **Outcome:** Documentation was not generated due to AI service unavailability. Recommendation: Address AI service issues for future documentation needs.\n\n### Conclusions and Recommendations\n\n- **Key Findings:** The workflow was hindered by the absence of the original data file, impacting the ability to perform meaningful analysis and calculations.\n- **Recommendations:** Ensure all necessary data files are provided at the start of the workflow to facilitate successful execution of tasks. Additionally, address technical issues related to AI services to enable comprehensive documentation in future projects.", + "content": "**Summary of Workflow Results**\n\n1. **Key Findings and Results:**\n - **Activity 1: Analyze CSV Data**: An initial analysis was conducted on a CSV file named 'data.csv', which contains 4 rows and 3 columns ('name', 'age', 'location'). The dataset provides information about individuals, including their names, ages, and locations.\n - **Activity 2: Determine Year for Age Sum of 200**: A Python script was executed to calculate the year in which the sum of all ages in the dataset will equal 200 years. The code was executed successfully, although the specific year was not detailed in the summary.\n - **Activity 3: Generate Additional Datasets**: Ten additional datasets were generated and integrated into the existing table. This involved creating new entries with names such as 'Michael', 'Sarah', and others.\n - **Activity 4: Compile Final Report**: A comprehensive report titled 'Comprehensive Analysis and Dataset Update Report: Age Sum Projection and Data Integration' was compiled, summarizing the analysis, the projected year for the age sum, and the updated dataset.\n\n2. **Connection to Original Task:**\n - The workflow effectively addressed the original task by analyzing the dataset structure, projecting future age sums, expanding the dataset with additional entries, and compiling a final report that integrates all findings and updates.\n\n3. **Conclusions and Recommendations:**\n - The analysis provided a clear understanding of the dataset's structure and contents, which is crucial for further data-driven decision-making.\n - The projection of the year when the age sum reaches 200 can be useful for planning and forecasting purposes.\n - The generation of additional datasets enhances the dataset's robustness and potential for more comprehensive analysis.\n - It is recommended to review the final report for detailed insights and to ensure that the dataset updates align with organizational goals and data management practices.", "agent_type": "summary", "agent_id": "workflow_summary", "agent_name": "Workflow Summary",