diff --git a/.gitignore b/.gitignore index 44d78da..c96a45d 100644 --- a/.gitignore +++ b/.gitignore @@ -202,3 +202,4 @@ installer/*.dbc # Keep example.dbc !installer/example.dbc +installer/slackbot/logs/* diff --git a/installer/.env.example b/installer/.env.example index 16ded94..772fef9 100644 --- a/installer/.env.example +++ b/installer/.env.example @@ -66,4 +66,19 @@ SCAN_INTERVAL_SECONDS=3600 VITE_API_BASE_URL=http://localhost:8000 ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 -# End Data Downloader configuration \ No newline at end of file +# End Data Downloader configuration + +# ------------------------------------------------------------ +# AI Code Generation (Sandbox) configuration +# ------------------------------------------------------------ +# Cohere API key for AI-powered code generation +COHERE_API_KEY=your-cohere-api-key-here + +# Cohere model to use (default: command-r-plus) +COHERE_MODEL=command-r-plus + +# Maximum number of retries when generated code fails (default: 2) +MAX_RETRIES=2 + +# InfluxDB database name for telemetry queries (default: telemetry) +INFLUXDB_DATABASE=telemetry diff --git a/installer/README.md b/installer/README.md index 4bb738d..355cd8e 100644 --- a/installer/README.md +++ b/installer/README.md @@ -56,6 +56,10 @@ All secrets and tokens are defined in `.env`. The defaults provided in `.env.exa | `SLACK_WEBHOOK_URL` | Incoming webhook for notifications (optional) | empty | | `SLACK_DEFAULT_CHANNEL` | Default Slack channel ID for outbound messages | `C0123456789` | | `FILE_UPLOADER_WEBHOOK_URL` | Webhook invoked after uploads complete | inherits `SLACK_WEBHOOK_URL` | +| `COHERE_API_KEY` | Cohere API key for AI-powered code generation | empty | +| `COHERE_MODEL` | Cohere model to use | `command-a-03-2025` | +| `MAX_RETRIES` | Maximum retries for failed code execution | `2` | +| `INFLUXDB_DATABASE` | Database name for telemetry queries | `telemetry` | | `DEBUG` | Enables verbose logging for selected services | `0` | > **Security reminder:** Replace every default value when deploying outside of a local development environment. Generate secure tokens with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`. @@ -69,10 +73,12 @@ All secrets and tokens are defined in `.env`. The defaults provided in `.env.exa | `data-downloader` | `3000` | Periodically downloads CAN CSV archives from the DAQ server. Visual SQL query builder included. | | `telegraf` | n/a | Collects CAN metrics produced by the importer and forwards them to InfluxDB. | | `grafana` | `8087` | Visualises telemetry with pre-provisioned dashboards. | -| `slackbot` | n/a | Socket-mode Slack bot for notifications and automation (optional). | +| `slackbot` | n/a | Socket-mode Slack bot for notifications and automation (optional). Integrates with code-generator for AI queries. | | `lap-detector` | `8050` | Dash-based lap analysis web application. | | `startup-data-loader` | n/a | Seeds InfluxDB with sample CAN frames on first boot. | | `file-uploader` | `8084` | Web UI for uploading CAN CSV archives and streaming them into InfluxDB. | +| `sandbox` | n/a | Custom Python execution environment with internet access for running AI-generated code and InfluxDB queries. | +| `code-generator` | `3030` (internal) | AI-powered code generation service using Cohere. Generates Python code from natural language. | ## Data and DBC files @@ -89,6 +95,33 @@ All secrets and tokens are defined in `.env`. The defaults provided in `.env.exa - **Service fails to connect to InfluxDB** – Confirm the token in `.env` matches `influxdb3-admin-token.json`. Regenerate the volumes with `docker compose down -v` if you rotate credentials. - **Re-import sample data** – Remove the `telegraf-data` volume and rerun the stack. - **Slack services are optional** – Leave Slack variables empty or set `ENABLE_SLACK=false` to skip starting the bot during development. +- **AI code generation not working** – Ensure `COHERE_API_KEY` is set in `.env`. Check logs with `docker compose logs code-generator`. +- **Sandbox execution fails** – Verify sandbox container is running with `docker ps | grep sandbox`. Check logs with `docker compose logs sandbox`. + +## AI-Powered Code Generation + +The stack includes an AI-powered code generation service that allows natural language queries via Slack: + +**Usage:** +``` +!agent plot battery voltage over the last hour +!agent show me motor temperature correlation with RPM +!agent analyze inverter efficiency +``` + +**Features:** +- Automatic code generation from natural language using Cohere AI +- Self-correcting retry mechanism (up to 2 retries on failure) +- Secure sandboxed execution environment +- Auto-generation of plots and visualizations +- Direct InfluxDB access for telemetry queries + +**Setup:** +1. Add `COHERE_API_KEY` to your `.env` file +2. Optional: Configure `COHERE_MODEL` and `MAX_RETRIES` +3. Services start automatically with the stack + +See `sandbox/README.md` for detailed documentation. ## Next steps diff --git a/installer/docker-compose.yml b/installer/docker-compose.yml index a72f412..0cf0c07 100644 --- a/installer/docker-compose.yml +++ b/installer/docker-compose.yml @@ -108,8 +108,10 @@ services: SLACK_APP_TOKEN: ${SLACK_APP_TOKEN:-} SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} SLACK_DEFAULT_CHANNEL: ${SLACK_DEFAULT_CHANNEL:-C0123456789} + ENABLE_SLACK: ${ENABLE_SLACK:-false} INFLUXDB_ADMIN_TOKEN: "${INFLUXDB_ADMIN_TOKEN:-apiv3_dev-influxdb-admin-token}" INFLUXDB_URL: "${INFLUXDB_URL:-http://influxdb3:8181}" + CODE_GENERATOR_URL: "http://code-generator:3030" volumes: - ./slackbot:/app working_dir: /app @@ -117,15 +119,18 @@ services: sh -c ' if [ "$ENABLE_SLACK" = "true" ]; then echo "Slackbot enabled, starting..."; - python slack_bot.py; + python -u slack_bot.py; else echo "Slackbot disabled (ENABLE_SLACK=$ENABLE_SLACK). Exiting."; sleep 5; exit 0; fi ' + depends_on: + - code-generator networks: - datalink + - default lap-detector: build: ./lap-detector @@ -246,3 +251,48 @@ services: depends_on: data-downloader-api: condition: service_started + + # Custom sandbox execution container (with internet access for InfluxDB queries) + sandbox: + build: + context: ./sandbox + dockerfile: Dockerfile.sandbox + container_name: sandbox + restart: unless-stopped + environment: + SANDBOX_PORT: 8080 + SANDBOX_TIMEOUT: 30 + SANDBOX_MAX_FILE_MB: 5 + SANDBOX_MAX_FILES: 10 + INFLUXDB_URL: ${INFLUXDB_URL:-http://influxdb3:8181} + INFLUXDB_ADMIN_TOKEN: ${INFLUXDB_ADMIN_TOKEN} + INFLUXDB_DATABASE: ${INFLUXDB_DATABASE:-telemetry} + depends_on: + - influxdb3 + networks: + - datalink + + # Code generator - Cohere integration for AI-powered code generation + code-generator: + build: + context: ./sandbox + dockerfile: Dockerfile + container_name: code-generator + restart: unless-stopped + environment: + COHERE_API_KEY: ${COHERE_API_KEY} + COHERE_MODEL: ${COHERE_MODEL:-command-r-plus} + SANDBOX_URL: "http://sandbox:8080" + MAX_RETRIES: ${MAX_RETRIES:-2} + CODE_GEN_PORT: 3030 + INFLUXDB_URL: ${INFLUXDB_URL:-http://influxdb3:8181} + INFLUXDB_ADMIN_TOKEN: ${INFLUXDB_ADMIN_TOKEN} + INFLUXDB_DATABASE: ${INFLUXDB_DATABASE:-telemetry} + depends_on: + - sandbox + - influxdb3 + volumes: + - ./sandbox/generated:/app/generated + - ./sandbox/prompt-guide.txt:/app/prompt-guide.txt + networks: + - datalink diff --git a/installer/sandbox/.gitignore b/installer/sandbox/.gitignore new file mode 100644 index 0000000..3dc0258 --- /dev/null +++ b/installer/sandbox/.gitignore @@ -0,0 +1,16 @@ +generated/ +generated_sandbox_code.py +*.pyc +__pycache__/ +.env +*.png +*.jpg +*.jpeg +*.gif +*.svg +*.pdf +*.csv +output.* + +# Custom prompt engineering (use prompt-guide.txt.example as template) +prompt-guide.txt diff --git a/installer/sandbox/Dockerfile b/installer/sandbox/Dockerfile new file mode 100644 index 0000000..0215e02 --- /dev/null +++ b/installer/sandbox/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY code_generator.py . +COPY prompt-guide.txt . + +# Create directory for generated code +RUN mkdir -p /app/generated + +# Expose port +EXPOSE 3030 + +# Run the service +CMD ["python", "code_generator.py"] diff --git a/installer/sandbox/Dockerfile.sandbox b/installer/sandbox/Dockerfile.sandbox new file mode 100644 index 0000000..a9e19e0 --- /dev/null +++ b/installer/sandbox/Dockerfile.sandbox @@ -0,0 +1,51 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# System deps for scientific Python + Chromium (ARM64 compatible) +# Chromium is for Kaleido image export (used by Plotly, 3D plot rendering.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + chromium \ + chromium-sandbox \ + libx11-6 \ + libxext6 \ + libxrender1 \ + libxtst6 \ + libxss1 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements-docker.txt /tmp/requirements-docker.txt +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r /tmp/requirements-docker.txt \ + && rm -rf /root/.cache/pip + +# Tell Kaleido where Chromium lives +ENV CHROME_PATH=/usr/bin/chromium + +# Verify +RUN python3 - <` +2. Review README files in each service directory +3. Test individual components using test scripts +4. Verify environment variables are set correctly diff --git a/installer/sandbox/README.md b/installer/sandbox/README.md index 80d81e6..4cfa537 100644 --- a/installer/sandbox/README.md +++ b/installer/sandbox/README.md @@ -1,25 +1,296 @@ -# Terrarium–Slackbot Integration +# AI-Powered Code Generation & Sandbox Execution -Run Python code from Slack via an Orchestrator and Cohere-Terrarium. +Generate and execute Python code for telemetry analysis using Cohere AI and a custom sandboxed execution environment. Integrated with the Slackbot for natural language queries. +## Architecture + +``` +[User in Slack] → [Slackbot (Lappy)] → [Code Generator] → [Custom Sandbox] + ↑ (Cohere AI) (Python + InfluxDB) + └──────────────────────────── [Results (images, logs, data)] ←──────────┘ +``` + +### Components + +1. **Code Generator** (`code_generator.py`) + - Receives natural language prompts + - Uses Cohere AI to generate Python code + - Implements automatic retry logic (up to 2 retries) + - Appends error messages to prompts on retry for self-correction + +2. **Custom Sandbox** (`sandbox_server.py`) + - HTTP server that executes Python code in isolated environment + - **Has internet access** for InfluxDB queries and API calls + - Supports full Python ecosystem: matplotlib, pandas, numpy, plotly, scikit-learn, influxdb3-python + - Auto-captures output files (images, data) + - Configurable timeout and resource limits + +## Setup + +The sandbox services are automatically started with the main docker-compose stack. + +### Environment Variables + +Add to your `.env` file: + +```bash +# Required: Cohere API key +COHERE_API_KEY=your-cohere-api-key-here + +# Optional: Cohere model (default: command-r-plus) +COHERE_MODEL=command-r-plus + +# Optional: Max retry attempts (default: 2) +MAX_RETRIES=2 + +# Optional: InfluxDB database name (default: telemetry) +INFLUXDB_DATABASE=telemetry +``` + +### Prompt Engineering Setup + +**First time setup:** +```bash +cd installer/sandbox +cp prompt-guide.txt.example prompt-guide.txt +# Edit prompt-guide.txt with your custom prompt engineering +``` + +**Note:** `prompt-guide.txt` is gitignored to keep your custom prompt engineering private. The `.example` file serves as a template and can be committed. + +### Starting the Services + +From the `installer/` directory: + +```bash +# Start all services including sandbox +docker compose up -d + +# Or start only sandbox services +docker compose up -d sandbox code-generator +``` + +### Testing + +Test the code generation API directly: + +```bash +curl -X POST http://localhost:3030/api/generate-code \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Create a scatter plot of random voltage vs current data and save as output.png" + }' +``` + +## Usage from Slack + +Use the `!agent` command in Slack: + +``` +!agent plot inverter voltage vs current from the last hour +!agent show me battery temperature trends over time +!agent analyze motor RPM and torque correlation +``` + +### How It Works + +1. User sends `!agent ` in Slack +2. Slackbot forwards prompt to Code Generator service +3. Code Generator: + - Loads system prompt with InfluxDB connection details + - Calls Cohere AI to generate Python code + - Submits code directly to Custom Sandbox for execution +4. Custom Sandbox: + - Executes Python code with full internet access + - Can query InfluxDB, make API calls, generate plots + - Returns stdout, stderr, and output files +5. If code fails: + - Error message is appended to the prompt + - Cohere generates fixed code + - Process repeats up to MAX_RETRIES times +6. Results sent back to Slack: + - Text output shown in message + - Images uploaded as files + - Success/failure status with retry count + +### Example Interactions + +**Simple Plot:** +``` +User: !agent create a random scatter plot +Bot: 🤖 Processing your request... +Bot: ✅ Code executed successfully! +Bot: 📊 Here's your visualization: [output.png] +``` + +**With Retry:** +``` +User: !agent plot df['time'] data +Bot: 🤖 Processing your request... +Bot: ⚠️ Initial code had errors. Retried 1 time(s) with error feedback. +Bot: ✅ Code executed successfully! (after 1 retry) +Bot: Output: Data plotted successfully +``` + +**Failure After Retries:** +``` +User: !agent impossible task +Bot: 🤖 Processing your request... +Bot: ⚠️ Initial code had errors. Retried 2 time(s) with error feedback. +Bot: ❌ Code execution failed after 2 retries: + ERROR_TRACE: [error details] +``` + +## System Prompt + +The code generator uses a system prompt (`prompt-guide.txt`) to guide Cohere's code generation. + +**Setup:** +1. Copy the template: `cp prompt-guide.txt.example prompt-guide.txt` +2. Edit `prompt-guide.txt` with your custom prompt engineering +3. File is gitignored - your custom prompts stay private + +**The system prompt should include:** +- Available libraries and database connection details +- InfluxDB query examples +- Visualization best practices +- Sandboxed execution rules (no user input, save files) +- Domain-specific guidance for your telemetry data + +**Template file:** `prompt-guide.txt.example` is committed as a starting point for others. + +## API Endpoints + +### Code Generator Service (Port 3030) + +**POST /api/generate-code** +```json +{ + "prompt": "your natural language request" +} ``` -[User in Slack] → [Slackbot (Lappy)] → [Orchestrator] → [Cohere-Terrarium] → [Sandbox Container] - ↑ ↓ - └────────────────────────────── [Results (images, logs)] ←────────────────────────┘ +Response: +```json +{ + "code": "generated Python code", + "result": { + "status": "success", + "output": "stdout text", + "error": "stderr text", + "files": [ + { + "name": "output.png", + "data": "base64-encoded-image", + "type": "image" + } + ] + }, + "retries": [ + { + "attempt": 1, + "error": "previous error message" + } + ] +} ``` -Used for generating telemetry plots (e.g., inverter voltage vs current) directly in Slack. +**GET /api/health** +```json +{ + "status": "ok", + "service": "code-generator" +} +``` -Setup +### Sandbox Runner Service (Port 9090) +**POST /** +```json +{ + "code": "print('hello world')" +} ``` -docker compose up -d terrarium + +Response: +```json +{ + "success": true, + "std_out": "hello world\n", + "std_err": "", + "return_code": 0, + "output_files": [] +} ``` -Test Script +## Monitoring + +View logs for debugging: + +```bash +# All sandbox services +docker compose logs -f sandbox code-generator +# Individual services +docker compose logs -f code-generator +docker compose logs -f sandbox ``` -python test_terrarium.py + +## Security + +- Code executes in isolated subprocess with timeout limits +- **Has internet access** for InfluxDB queries and API calls (unlike Terrarium) +- Limited runtime (30 seconds max, configurable) +- Limited memory and file size +- InfluxDB credentials passed via environment variables +- Generated code is logged for audit purposes + +## Troubleshooting + +**Code Generator can't connect to Cohere:** +- Check `COHERE_API_KEY` is set correctly in `.env` +- Verify API key has sufficient credits + +**Sandbox execution fails:** +- Check sandbox container is running: `docker ps | grep sandbox` +- View sandbox logs: `docker compose logs sandbox` +- Verify Terrarium image pulled successfully + +**Slackbot can't reach Code Generator:** +- Ensure all services on same Docker network (`datalink`) +- Check service names match: `code-generator`, `sandbox` +- Verify CODE_GENERATOR_URL in slackbot environment + +**Generated code keeps failing:** +- Check the system prompt in `prompt-guide.txt` +- Increase MAX_RETRIES if needed +- View generated code in container logs +- Ensure InfluxDB credentials are correct + +## Development + +To modify the code generator locally: + +```bash +cd installer/sandbox + +# Edit code +vim code_generator.py + +# Rebuild and restart +docker compose up -d --build code-generator ``` +## Files + +- `code_generator.py` - Main Cohere integration and retry logic +- `sandbox_server.py` - Custom Python sandbox execution server +- `prompt-guide.txt.example` - Template for system prompt (copy to `prompt-guide.txt`) +- `prompt-guide.txt` - Your custom system prompt (gitignored) +- `Dockerfile` - Code generator container image +- `Dockerfile.sandbox` - Custom sandbox container image +- `requirements.txt` - Python dependencies for code generator +- `requirements-docker.txt` - Python dependencies for sandbox (includes scientific libraries) +- `docker-compose.yml` - Standalone compose file (not used in main stack) + + diff --git a/installer/sandbox/code_generator.py b/installer/sandbox/code_generator.py new file mode 100644 index 0000000..444028f --- /dev/null +++ b/installer/sandbox/code_generator.py @@ -0,0 +1,299 @@ +""" +Code Generation Service - Orchestrator for Cohere + Sandbox execution. +Receives requests from Slackbot, generates code using Cohere, and executes in sandbox. +""" + +from __future__ import annotations + +import os +import base64 +from pathlib import Path +from typing import Dict, Any + +from dotenv import load_dotenv +from flask import Flask, request, jsonify +from flask_cors import CORS +import cohere +import requests + +# Load environment variables +load_dotenv() + +# --------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------- +COHERE_API_KEY = os.getenv("COHERE_API_KEY") +if not COHERE_API_KEY: + raise RuntimeError( + "COHERE_API_KEY not found in environment. Add it to your .env or export it as an env var." + ) + +COHERE_MODEL = os.getenv("COHERE_MODEL", "command-r-plus") +SANDBOX_URL = os.getenv("SANDBOX_URL", "http://sandbox-runner:9090") +MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2")) + +# Configure Cohere client +co = cohere.Client(COHERE_API_KEY) + +# Paths +BASE_DIR = Path(__file__).resolve().parent +PROMPT_GUIDE_PATH = BASE_DIR / "prompt-guide.txt" +GENERATED_CODE_PATH = BASE_DIR / "generated_sandbox_code.py" + +# --------------------------------------------------------------------- +# Flask App Setup +# --------------------------------------------------------------------- +app = Flask(__name__) +CORS(app) + +# --------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------- +def load_prompt_guide() -> str: + """Reads the prompt guide file.""" + if PROMPT_GUIDE_PATH.exists(): + return PROMPT_GUIDE_PATH.read_text().strip() + + # Minimal fallback if file doesn't exist + return """You are an expert Python data analyst. Generate clean, executable Python code. +Rules: +- No user input (no input(), sys.stdin) +- Save visualizations to files (plt.savefig()) +- Include all necessary imports +- Return only executable code""" + + +def extract_python_code(raw_output: str) -> str: + """ + Extract ```python ...``` fenced code if present. + Falls back to raw text if no fence. + """ + text = raw_output.strip() + if "```" not in text: + return text + + segments = text.split("```") + for idx, segment in enumerate(segments): + if idx % 2 == 0: + continue + stripped = segment.strip() + if not stripped: + continue + if stripped.lower().startswith("python"): + lines = stripped.splitlines() + return "\n".join(lines[1:]) if len(lines) > 1 else "" + return stripped + + return text + + +def request_python_code(guide: str, prompt: str) -> str: + """Request Python code from Cohere.""" + # Combine guide and user prompt + full_prompt = f"{guide}\n\n{prompt}" + + response = co.chat( + message=full_prompt, + model=COHERE_MODEL, + temperature=0.2, + ) + + # Extract Python code from response + raw_output = response.text + python_code = extract_python_code(raw_output) + + # Save generated code + GENERATED_CODE_PATH.write_text(python_code, encoding="utf-8") + print(f"Generated code written to {GENERATED_CODE_PATH}") + + return python_code + + +def submit_code_to_sandbox(code: str) -> Dict[str, Any]: + """Submit code to the custom sandbox for execution.""" + try: + response = requests.post( + SANDBOX_URL, + json={"code": code}, + timeout=60 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error submitting to sandbox: {e}") + return { + "ok": False, + "std_err": str(e), + "std_out": "", + "return_code": -1, + "output_files": [] + } + + +def format_error_for_retry(sandbox_result: Dict[str, Any]) -> str: + """Format sandbox error for retry prompt.""" + error_parts = [] + + if sandbox_result.get("std_err"): + error_parts.append(f"ERROR_TRACE: {sandbox_result['std_err'].strip()}") + + if sandbox_result.get("std_out"): + error_parts.append(f"OUTPUT: {sandbox_result['std_out'].strip()}") + + return_code = sandbox_result.get("return_code") + if return_code != 0: + error_parts.insert(0, f"STATUS: ERROR (return code: {return_code})") + + return "\n".join(error_parts) + + +def format_sandbox_result(sandbox_result: Dict[str, Any]) -> Dict[str, Any]: + """Format sandbox result for response.""" + # Process output files from custom sandbox + files_info = [] + for file_data in sandbox_result.get("output_files", []): + file_info = { + "name": file_data.get("filename"), + "data": file_data.get("b64_data"), + "type": "image" if file_data.get("filename", "").endswith((".png", ".jpg", ".jpeg", ".gif", ".svg")) else "file" + } + files_info.append(file_info) + + result = { + "status": "success" if sandbox_result.get("ok") else "error", + "output": sandbox_result.get("std_out", "").strip(), + "error": sandbox_result.get("std_err", "").strip(), + "return_code": sandbox_result.get("return_code"), + "files": files_info + } + return result + + +# --------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------- +@app.route('/api/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({"status": "ok", "service": "code-generator"}) + + +@app.route('/api/generate-code', methods=['POST']) +def generate_code(): + """Generate and execute Python code based on user prompt with automatic retries on failure.""" + try: + data = request.get_json() + user_prompt = data.get('prompt', '').strip() + + if not user_prompt: + return jsonify({"error": "Prompt is required"}), 400 + + # Load the prompt guide + guide = load_prompt_guide() + + retry_info = [] + current_prompt = user_prompt + python_code = None + + # Try up to MAX_RETRIES + 1 times (initial attempt + retries) + for attempt in range(MAX_RETRIES + 1): + print(f"\n{'='*60}") + print(f"Attempt {attempt + 1}/{MAX_RETRIES + 1}") + print(f"{'='*60}\n") + + # Generate Python code using Cohere + python_code = request_python_code(guide, current_prompt) + + # Execute the code in sandbox + sandbox_result = submit_code_to_sandbox(python_code) + + # Check if execution was successful + if sandbox_result.get("ok"): + # Success! Format and return result + result = format_sandbox_result(sandbox_result) + + response = { + "code": python_code, + "result": result + } + + # Include retry information if any retries were made + if retry_info: + response["retries"] = retry_info + print(f"✅ Success after {len(retry_info)} retry/retries") + + return jsonify(response) + + # Execution failed + if attempt < MAX_RETRIES: + # We have retries left + error_message = format_error_for_retry(sandbox_result) + retry_info.append({ + "attempt": attempt + 1, + "error": error_message + }) + + print(f"\n{'='*60}") + print(f"RETRY {attempt + 1}/{MAX_RETRIES} - Code execution failed") + print(f"{'='*60}") + print(error_message) + print(f"\n{'='*60}") + print("Retrying with error feedback...") + print(f"{'='*60}\n") + + # Append error to prompt for retry + current_prompt = f"""{user_prompt} + +The previous code generated had the following error: + +{error_message} + +Please fix the code to address this error.""" + else: + # No more retries left, return the error + print(f"\n{'='*60}") + print(f"❌ All {MAX_RETRIES} retries exhausted - returning error") + print(f"{'='*60}\n") + result = format_sandbox_result(sandbox_result) + + return jsonify({ + "code": python_code, + "result": result, + "retries": retry_info, + "max_retries_reached": True + }) + + except Exception as e: + print(f"Error in generate_code: {e}") + import traceback + traceback.print_exc() + return jsonify({ + "error": str(e), + "code": None, + "result": { + "status": "error", + "error": str(e), + "output": "", + "files": [] + } + }), 500 + + +# --------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------- +def main(): + """Start the code generation service.""" + port = int(os.getenv("CODE_GEN_PORT", "3030")) + debug = os.getenv("DEBUG", "false").lower() == "true" + + print(f"Starting code generation service on http://0.0.0.0:{port}") + print(f"Cohere Model: {COHERE_MODEL}") + print(f"Sandbox URL: {SANDBOX_URL}") + print(f"Max Retries: {MAX_RETRIES}") + + app.run(host='0.0.0.0', port=port, debug=debug) + + +if __name__ == "__main__": + main() diff --git a/installer/sandbox/docker-compose.yml b/installer/sandbox/docker-compose.yml index 1a5cef0..fa85f98 100644 --- a/installer/sandbox/docker-compose.yml +++ b/installer/sandbox/docker-compose.yml @@ -1,9 +1,47 @@ +version: '3.8' + services: - terrarium: - image: ghcr.io/cohere-ai/terrarium:latest - container_name: terrarium + # Custom sandbox execution container (with internet access) + sandbox: + build: + context: . + dockerfile: Dockerfile.sandbox + container_name: sandbox + environment: + SANDBOX_PORT: 8080 + SANDBOX_TIMEOUT: 30 + SANDBOX_MAX_FILE_MB: 5 + SANDBOX_MAX_FILES: 10 + INFLUXDB_URL: ${INFLUXDB_URL:-http://influxdb3:8181} + INFLUXDB_ADMIN_TOKEN: ${INFLUXDB_ADMIN_TOKEN} + INFLUXDB_DATABASE: ${INFLUXDB_DATABASE:-telemetry} + networks: + - datalink + + # Code generator - Cohere integration + code-generator: + build: + context: . + dockerfile: Dockerfile + container_name: code-generator ports: - - "8090:8080" # host:container + - "3030:3030" environment: - TERRARIUM_MAX_RUNTIME: 30 - TERRARIUM_MAX_MEMORY_MB: 1024 \ No newline at end of file + COHERE_API_KEY: ${COHERE_API_KEY} + COHERE_MODEL: ${COHERE_MODEL:-command-r-plus} + SANDBOX_URL: "http://sandbox:8080" + MAX_RETRIES: ${MAX_RETRIES:-2} + CODE_GEN_PORT: 3030 + INFLUXDB_URL: ${INFLUXDB_URL:-http://influxdb3:8181} + INFLUXDB_ADMIN_TOKEN: ${INFLUXDB_ADMIN_TOKEN} + INFLUXDB_DATABASE: ${INFLUXDB_DATABASE:-telemetry} + depends_on: + - sandbox + volumes: + - ./generated:/app/generated + networks: + - datalink + +networks: + datalink: + external: true diff --git a/installer/sandbox/output.png b/installer/sandbox/output.png deleted file mode 100644 index ccb0eb4..0000000 Binary files a/installer/sandbox/output.png and /dev/null differ diff --git a/installer/sandbox/prompt-guide.txt.example b/installer/sandbox/prompt-guide.txt.example new file mode 100644 index 0000000..6e99fdd --- /dev/null +++ b/installer/sandbox/prompt-guide.txt.example @@ -0,0 +1,50 @@ +You are an expert Python data analyst working with telemetry data from a Formula SAE race car. + +CRITICAL RULES: +1. Your code MUST be self-contained and executable in a sandboxed Python environment +2. Do NOT use input(), sys.stdin, or any interactive prompts +3. ALWAYS save visualizations to files (e.g., plt.savefig("output.png")) +4. The InfluxDB connection details will be provided in the environment +5. Use influxdb3-python library for database queries +6. Available libraries: pandas, matplotlib, numpy, plotly, scikit-learn, influxdb3-python + +DATABASE CONNECTION: +```python +from influxdb_client_3 import InfluxDBClient3 +import os + +# Connection is already configured via environment variables +client = InfluxDBClient3( + host=os.getenv("INFLUXDB_URL", "http://influxdb3:8181"), + token=os.getenv("INFLUXDB_ADMIN_TOKEN"), + database=os.getenv("INFLUXDB_DATABASE", "telemetry") +) +``` + +EXAMPLE QUERIES: +```python +# Query telemetry data +query = """ +SELECT time, field1, field2 +FROM measurement_name +WHERE time > now() - 1h +""" +table = client.query(query=query) +df = table.to_pandas() +``` + +VISUALIZATION BEST PRACTICES: +1. Use clear titles and axis labels +2. Save plots with plt.savefig("output.png") or fig.write_image("output.png") +3. Use appropriate figure sizes: plt.figure(figsize=(10, 6)) +4. Include legends when plotting multiple series +5. For time series, format time axis properly + +RESPONSE FORMAT: +- Return ONLY executable Python code +- Include ALL necessary imports at the top +- Add comments explaining key steps +- Ensure the code runs without user input +- Generate meaningful visualizations or analysis output + +Now generate the Python code based on the user's request: diff --git a/installer/sandbox/requirements-docker.txt b/installer/sandbox/requirements-docker.txt new file mode 100644 index 0000000..bd7cf80 --- /dev/null +++ b/installer/sandbox/requirements-docker.txt @@ -0,0 +1,56 @@ +# Docker container requirements for sandbox execution environment +# These are the dependencies needed inside the Docker container + +influxdb3-python +pandas +matplotlib +pyarrow +plotly +python-dateutil +kaleido + +# Load environment variables from .env files +python-dotenv + +# Machine learning libraries +scikit-learn +numpy +scipy + + +# Core +pandas +numpy +scipy +python-dateutil +python-dotenv + +# Visualization +matplotlib +plotly +kaleido +seaborn + +# Modeling +scikit-learn +statsmodels +patsy + +# Data Formats +pyarrow +openpyxl +requests + +# Spatial +geopandas +shapely +pyproj + +# Performance +duckdb +polars + +# Utilities +tqdm +rich +loguru \ No newline at end of file diff --git a/installer/sandbox/requirements.txt b/installer/sandbox/requirements.txt new file mode 100644 index 0000000..2ad5ba6 --- /dev/null +++ b/installer/sandbox/requirements.txt @@ -0,0 +1,5 @@ +flask +flask-cors +python-dotenv +cohere +requests diff --git a/installer/sandbox/sandbox_server.py b/installer/sandbox/sandbox_server.py new file mode 100644 index 0000000..1ffb7a8 --- /dev/null +++ b/installer/sandbox/sandbox_server.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import tempfile +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, List + +SANDBOX_PORT = int(os.getenv("SANDBOX_PORT", "8080")) +SANDBOX_TIMEOUT = int(os.getenv("SANDBOX_TIMEOUT", "30")) +SANDBOX_MAX_FILE_MB = int(os.getenv("SANDBOX_MAX_FILE_MB", "5")) +SANDBOX_MAX_FILES = int(os.getenv("SANDBOX_MAX_FILES", "10")) + + +def _encode_file(path: Path) -> Dict[str, str]: + data = base64.b64encode(path.read_bytes()).decode("ascii") + return {"filename": path.name, "b64_data": data} + + +def _collect_output_files(workdir: Path) -> List[Dict[str, str]]: + files: List[Dict[str, str]] = [] + max_bytes = SANDBOX_MAX_FILE_MB * 1024 * 1024 + + # Recursively find all files (including in subdirectories) + for path in sorted(workdir.rglob("*")): + if not path.is_file(): + continue + if path.name == "snippet.py": + continue + if path.stat().st_size > max_bytes: + continue + files.append(_encode_file(path)) + if len(files) >= SANDBOX_MAX_FILES: + break + return files + + +def run_user_code(code: str) -> Dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="sandbox-") as tmp_dir: + workdir = Path(tmp_dir) + script_path = workdir / "snippet.py" + script_path.write_text(code, encoding="utf-8") + + # Pass through environment variables (InfluxDB credentials, etc.) + # Inherit current process env and allow subprocess to access them + env = os.environ.copy() + + try: + proc = subprocess.run( + ["python3", script_path.name], + cwd=workdir, + capture_output=True, + text=True, + timeout=SANDBOX_TIMEOUT, + env=env, # Pass environment to subprocess + ) + success = proc.returncode == 0 + std_err = proc.stderr + std_out = proc.stdout + except subprocess.TimeoutExpired as exc: + success = False + std_out = exc.stdout or "" + std_err = (exc.stderr or "") + f"\nExecution timed out after {SANDBOX_TIMEOUT}s." + proc = None # type: ignore[assignment] + + output_files = _collect_output_files(workdir) + + return { + "ok": success, + "return_code": getattr(proc, "returncode", None), + "std_out": std_out, + "std_err": std_err, + "output_files": output_files, + } + + +class SandboxHandler(BaseHTTPRequestHandler): + server_version = "SandboxHTTP/1.0" + + def _send_json(self, status: HTTPStatus, payload: Dict[str, Any]) -> None: + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_POST(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API) + if self.path not in ("/", "/execute"): + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown endpoint"}) + return + + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) + try: + payload = json.loads(body) + code = payload.get("code") + if not isinstance(code, str) or not code.strip(): + raise ValueError("Request JSON must include non-empty 'code' field.") + except (json.JSONDecodeError, ValueError) as exc: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + + result = run_user_code(code) + self._send_json(HTTPStatus.OK, result) + + def log_message(self, fmt: str, *args: Any) -> None: + # Keep logs concise for the sandbox container. + print(f"[sandbox] {self.address_string()} - {fmt % args}") + + +def main() -> None: + server = ThreadingHTTPServer(("", SANDBOX_PORT), SandboxHandler) + print(f"Sandbox server listening on port {SANDBOX_PORT} (timeout={SANDBOX_TIMEOUT}s)") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/installer/sandbox/test_code_generator.py b/installer/sandbox/test_code_generator.py new file mode 100644 index 0000000..8d89c71 --- /dev/null +++ b/installer/sandbox/test_code_generator.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Test script for the Code Generator service. +Tests the complete workflow: prompt -> code generation -> sandbox execution -> results. +""" + +import requests +import json +import base64 +from pathlib import Path + +# Service endpoint +CODE_GENERATOR_URL = "http://localhost:3030" + +def test_health_check(): + """Test the health endpoint.""" + print("Testing health check...") + response = requests.get(f"{CODE_GENERATOR_URL}/api/health") + print(f"Status: {response.status_code}") + print(f"Response: {response.json()}\n") + return response.status_code == 200 + +def test_simple_code_generation(): + """Test generating and executing simple Python code.""" + print("Testing simple code generation...") + + prompt = "Create a scatter plot of 50 random voltage (300-400V) vs current (50-150A) points, color by power. Save as output.png" + + print(f"Prompt: {prompt}\n") + + response = requests.post( + f"{CODE_GENERATOR_URL}/api/generate-code", + json={"prompt": prompt}, + timeout=120 + ) + + print(f"Status: {response.status_code}") + result = response.json() + + print(f"\nGenerated Code:") + print("=" * 60) + print(result.get("code", "No code returned")) + print("=" * 60) + + exec_result = result.get("result", {}) + print(f"\nExecution Status: {exec_result.get('status')}") + + if exec_result.get("output"): + print(f"Output: {exec_result['output']}") + + if exec_result.get("error"): + print(f"Error: {exec_result['error']}") + + # Check for retries + retries = result.get("retries", []) + if retries: + print(f"\nRetries: {len(retries)}") + for i, retry in enumerate(retries, 1): + print(f" Attempt {i}: {retry.get('error', '')[:100]}...") + + # Save any generated images + files = exec_result.get("files", []) + if files: + print(f"\nGenerated {len(files)} file(s):") + for file_info in files: + filename = file_info.get("name") + b64_data = file_info.get("data") + + if b64_data: + # Decode and save + image_data = base64.b64decode(b64_data) + output_path = Path(filename) + output_path.write_bytes(image_data) + print(f" ✓ Saved: {filename} ({len(image_data)} bytes)") + + print("\n" + "=" * 60 + "\n") + return exec_result.get("status") == "success" + +def test_error_with_retry(): + """Test that retry mechanism works with intentionally broken code.""" + print("Testing error handling and retry...") + + # This should initially fail but might succeed after retry + prompt = "Print the numbers 1 through 10, one per line" + + print(f"Prompt: {prompt}\n") + + response = requests.post( + f"{CODE_GENERATOR_URL}/api/generate-code", + json={"prompt": prompt}, + timeout=120 + ) + + result = response.json() + exec_result = result.get("result", {}) + + print(f"Status: {exec_result.get('status')}") + print(f"Output: {exec_result.get('output', 'No output')}") + + retries = result.get("retries", []) + if retries: + print(f"\nRetries occurred: {len(retries)}") + else: + print("\nNo retries needed - succeeded on first attempt") + + print("\n" + "=" * 60 + "\n") + return True + +def main(): + """Run all tests.""" + print("=" * 60) + print("Code Generator Service Test Suite") + print("=" * 60 + "\n") + + tests = [ + ("Health Check", test_health_check), + ("Simple Code Generation", test_simple_code_generation), + ("Error Handling", test_error_with_retry), + ] + + results = [] + for test_name, test_func in tests: + try: + success = test_func() + results.append((test_name, "PASS" if success else "FAIL")) + except Exception as e: + print(f"ERROR: {e}\n") + results.append((test_name, "ERROR")) + + print("\n" + "=" * 60) + print("Test Results") + print("=" * 60) + for test_name, status in results: + status_icon = "✓" if status == "PASS" else "✗" + print(f"{status_icon} {test_name}: {status}") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/installer/sandbox/test_terrarium.py b/installer/sandbox/test_terrarium.py deleted file mode 100644 index 1eb7dc9..0000000 --- a/installer/sandbox/test_terrarium.py +++ /dev/null @@ -1,66 +0,0 @@ -import requests, base64, json - -TERRARIUM_URL = "http://localhost:8090" # Endpoint for Terrarium service - -sandbox_code = r""" -import matplotlib.pyplot as plt -import numpy as np - -n = 100 -voltage = np.random.uniform(300, 400, n) -current = np.random.uniform(50, 150, n) -power = voltage * current - -plt.figure(figsize=(6,4)) -sc = plt.scatter(voltage, current, c=power, cmap='viridis', s=40) -plt.colorbar(sc, label='Power (W)') -plt.title('Random Voltage–Current–Power Scatter') -plt.xlabel('Voltage (V)') -plt.ylabel('Current (A)') -plt.tight_layout() -plt.savefig("output.png") -print("Saved scatterplot as output.png") -""" - -payload = {"code": sandbox_code} - -print("Submitting code to Terrarium...") -resp = requests.post(TERRARIUM_URL, json=payload) -resp.raise_for_status() - -result = resp.json() -print("Response received!\n") -print(json.dumps(result, indent=2)) - -# Extract file (json dict) -""" - -{ - "success": true, - "output_files": [ - { - "filename": "output.png", - "b64_data": "iVBORw0KGgoAAAAN... (truncated for brevity) ... " - -""" - -output_files = result.get("output_files", []) -if output_files: - file_info = output_files[0] # Get the first file dict - filename = file_info["filename"] - b64_data = file_info["b64_data"] - - # Remove all whitespace from base64 string - b64_data = ''.join(b64_data.split()) - - # Decode and save - decoded_data = base64.b64decode(b64_data) - - with open(filename, "wb") as f: - f.write(decoded_data) - print(f"Image saved locally as: {filename}") -else: - print("No output files returned.") - -print("\nSTDOUT:\n", result.get("std_out", "")) -print("STDERR:\n", result.get("std_err", "")) \ No newline at end of file diff --git a/installer/slackbot/Dockerfile b/installer/slackbot/Dockerfile index a964915..5532783 100644 --- a/installer/slackbot/Dockerfile +++ b/installer/slackbot/Dockerfile @@ -3,6 +3,14 @@ FROM python:3.12-slim # Set working dir WORKDIR /app +RUN mkdir -p /app/logs + +# Install CA certificates and SSL dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + # Install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/installer/slackbot/initialbuild.md b/installer/slackbot/initialbuild.md deleted file mode 100644 index be44df5..0000000 --- a/installer/slackbot/initialbuild.md +++ /dev/null @@ -1,11 +0,0 @@ -``` -docker run -d \ - --name slackbot \ - --restart unless-stopped \ - --cpus="1.0" \ - --memory="700m" \ - --memory-swap="1.2g" \ - -v ~/slackbot:/app \ - slackbot -``` - diff --git a/installer/slackbot/slack_bot.py b/installer/slackbot/slack_bot.py index 4d42d40..9186c7d 100644 --- a/installer/slackbot/slack_bot.py +++ b/installer/slackbot/slack_bot.py @@ -1,6 +1,9 @@ import os import shlex import subprocess +import base64 +import json +import datetime from pathlib import Path from threading import Event @@ -12,17 +15,31 @@ processed_messages = set() +# --- Logging Configuration --- +LOG_DIR = Path("/app/logs") +LOG_DIR.mkdir(parents=True, exist_ok=True) + # --- Slack App Configuration --- app_token = os.environ["SLACK_APP_TOKEN"] bot_token = os.environ["SLACK_BOT_TOKEN"] +print(f"DEBUG: Loaded SLACK_APP_TOKEN: {app_token[:9]}...{app_token[-4:]} (Length: {len(app_token)})") +print(f"DEBUG: Loaded SLACK_BOT_TOKEN: {bot_token[:9]}...{bot_token[-4:]} (Length: {len(bot_token)})") + web_client = WebClient(token=bot_token) -socket_client = SocketModeClient(app_token=app_token, web_client=web_client) +socket_client = SocketModeClient( + app_token=app_token, + web_client=web_client, + trace_enabled=True, # Enable debug logging + ping_interval=30, # Send ping every 30 seconds + auto_reconnect_enabled=True +) WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL") DEFAULT_CHANNEL = os.environ.get("SLACK_DEFAULT_CHANNEL", "C08NTG6CXL5") AGENT_PAYLOAD_PATH = Path(os.environ.get("AGENT_PAYLOAD_PATH", "agent_payload.txt")) AGENT_TRIGGER_COMMAND = os.environ.get("AGENT_TRIGGER_COMMAND") +CODE_GENERATOR_URL = os.environ.get("CODE_GENERATOR_URL", "http://code-generator:3030") DEFAULT_AGENT_COMMAND = [ "python3", "-c", @@ -47,6 +64,43 @@ def send_slack_image(channel: str, file_path: str, **kwargs): upload_kwargs.update(kwargs) return web_client.files_upload_v2(**upload_kwargs) +def log_interaction(user, instructions, result, status, error=None): + """Log the entire interaction to a file.""" + timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_entry_dir = LOG_DIR / f"{timestamp}_{user}" + log_entry_dir.mkdir(parents=True, exist_ok=True) + + log_data = { + "timestamp": timestamp, + "user": user, + "instructions": instructions, + "status": status, + "error": error, + "generated_code": result.get("generated_code", ""), + "output": result.get("result", {}).get("output", "") + } + + # Save textual log + with open(log_entry_dir / "interaction.json", "w") as f: + json.dump(log_data, f, indent=4) + + # Save generated files (images) + exec_result = result.get("result", {}) + files = exec_result.get("files", []) + for file_info in files: + filename = file_info.get("name") + b64_data = file_info.get("data") + file_type = file_info.get("type") + + if file_type == "image" and b64_data: + try: + image_data = base64.b64decode(b64_data) + (log_entry_dir / filename).write_bytes(image_data) + except Exception as e: + print(f"Error saving log image {filename}: {e}") + + print(f"📝 Logged interaction to {log_entry_dir}") + # --- Slack Command Handlers --- # Not currently used: handle_location @@ -91,6 +145,10 @@ def handle_testimage(user): def handle_agent(user, command_full): + """ + Handle !agent command - sends request to code-generator service. + Supports AI-powered code generation and execution. + """ parts = command_full.split(maxsplit=1) instructions = parts[1].strip() if len(parts) > 1 else "" if not instructions: @@ -100,43 +158,116 @@ def handle_agent(user, command_full): ) return + # Send initial acknowledgment + send_slack_message( + DEFAULT_CHANNEL, + text=f"🤖 <@{user}> Processing your request: `{instructions[:100]}...`\nGenerating code with AI...", + ) + try: - AGENT_PAYLOAD_PATH.parent.mkdir(parents=True, exist_ok=True) - AGENT_PAYLOAD_PATH.write_text(instructions + "\n", encoding="utf-8") - except OSError as exc: - print("Error writing agent payload:", exc) + # Call code-generator service + response = requests.post( + f"{CODE_GENERATOR_URL}/api/generate-code", + json={"prompt": instructions}, + timeout=120 # Allow up to 2 minutes for code generation + retries + ) + response.raise_for_status() + result = response.json() + + # Check if retries occurred + retries = result.get("retries", []) + if retries: + retry_msg = f"⚠️ Initial code had errors. Retried {len(retries)} time(s) with error feedback." + send_slack_message(DEFAULT_CHANNEL, text=retry_msg) + + # Get execution result + exec_result = result.get("result", {}) + status = exec_result.get("status", "unknown") + + if status == "success": + # Success - send output and files + output = exec_result.get("output", "") + files = exec_result.get("files", []) + + success_msg = f"✅ <@{user}> Code executed successfully!" + if retries: + success_msg += f" (after {len(retries)} retry/retries)" + + send_slack_message(DEFAULT_CHANNEL, text=success_msg) + + # Send output if any + if output: + output_msg = f"**Output:**\n```\n{output[:2000]}\n```" + send_slack_message(DEFAULT_CHANNEL, text=output_msg) + + # Send generated files (images, etc.) + for file_info in files: + filename = file_info.get("name") + b64_data = file_info.get("data") + file_type = file_info.get("type") + + if file_type == "image" and b64_data: + try: + # Decode base64 and save temporarily + temp_path = Path(f"/tmp/{filename}") + image_data = base64.b64decode(b64_data) + temp_path.write_bytes(image_data) + + # Upload to Slack + send_slack_image( + DEFAULT_CHANNEL, + str(temp_path), + title=f"Generated: {filename}", + initial_comment=f"📊 <@{user}> Here's your visualization:" + ) + + # Clean up + temp_path.unlink() + except Exception as e: + print(f"Error uploading image {filename}: {e}") + send_slack_message( + DEFAULT_CHANNEL, + text=f"⚠️ Could not upload image {filename}: {e}" + ) + + # Log successful interaction + log_interaction(user, instructions, result, "success") + + else: + # Execution failed + error = exec_result.get("error", "Unknown error") + max_retries_reached = result.get("max_retries_reached", False) + + error_msg = f"❌ <@{user}> Code execution failed" + if max_retries_reached: + error_msg += f" after {len(retries)} retries" + error_msg += f":\n```\n{error[:1500]}\n```" + + send_slack_message(DEFAULT_CHANNEL, text=error_msg) + + # Log failed interaction + log_interaction(user, instructions, result, "failed", error) + + except requests.exceptions.Timeout: send_slack_message( DEFAULT_CHANNEL, - text=f"❌ <@{user}> Unable to write agent payload. Error: {exc}", + text=f"⏱️ <@{user}> Request timed out. The task might be too complex or the service is busy.", ) - return - #TODO: Add AI + Terrarium integration here - - try: - result = subprocess.run( - AGENT_COMMAND, - capture_output=True, - text=True, - check=True, - ) - output = (result.stdout or "Command executed with no output").strip() + log_interaction(user, instructions, {}, "timeout", "Request timed out") + except requests.exceptions.RequestException as e: send_slack_message( DEFAULT_CHANNEL, - text=( - f"✅ <@{user}> Agent instructions saved to `{AGENT_PAYLOAD_PATH}`." - " Placeholder trigger output:\n```${output[:2000]}```" - ), + text=f"❌ <@{user}> Failed to connect to code generation service: {e}", ) - except subprocess.CalledProcessError as exc: - error_text = (exc.stderr or str(exc)).strip() - print("Agent trigger command failed:", error_text) + log_interaction(user, instructions, {}, "connection_error", str(e)) + except Exception as e: + import traceback + traceback.print_exc() send_slack_message( DEFAULT_CHANNEL, - text=( - f"❌ <@{user}> Agent trigger command failed with exit code {exc.returncode}." - f" Details:\n```${error_text[:2000]}```" - ), + text=f"❌ <@{user}> Unexpected error: {e}", ) + log_interaction(user, instructions, {}, "unexpected_error", str(e)) def handle_help(user): @@ -146,8 +277,9 @@ def handle_help(user): "!help - Show this help message.\n" "!location - Show the current :daqcar: location.\n" "!testimage - Upload the bundled Lappy test image.\n" - "!agent - Save instructions to the agent text file and trigger\n" - " the placeholder command.\n" + "!agent - Generate and execute Python code using AI.\n" + " Example: !agent plot inverter voltage vs current\n" + " Automatically retries up to 2 times if code fails.\n" "```" ) send_slack_message(DEFAULT_CHANNEL, text=help_text) @@ -213,8 +345,20 @@ def process_events(client: SocketModeClient, req: SocketModeRequest): # --- Main Execution --- if __name__ == "__main__": print("🟢 Bot attempting to connect...") + print("🔍 Testing Slack API connectivity...") + + # Test basic connectivity first + try: + response = requests.get("https://slack.com/api/api.test", timeout=10) + print(f"✓ Slack API reachable: {response.status_code}") + except requests.exceptions.Timeout: + print("❌ Timeout connecting to Slack API - check firewall/network") + except Exception as e: + print(f"❌ Cannot reach Slack API: {e}") + socket_client.socket_mode_request_listeners.append(process_events) try: + print("🔌 Connecting to Slack WebSocket...") socket_client.connect() if WEBHOOK_URL: requests.post( @@ -226,8 +370,15 @@ def process_events(client: SocketModeClient, req: SocketModeRequest): print("⚠️ SLACK_WEBHOOK_URL not configured - skipping webhook notification") print("🟢 Bot connected and listening for messages.") Event().wait() + except KeyboardInterrupt: + print("\n🛑 Bot shutting down...") + socket_client.close() except Exception as exc: print(f"🔴 Bot failed to connect: {exc}") + print("💡 Possible causes:") + print(" - Firewall blocking outbound WebSocket connections (port 443)") + print(" - Invalid tokens (check SLACK_APP_TOKEN and SLACK_BOT_TOKEN)") + print(" - Network connectivity issues") import traceback traceback.print_exc()