From 602fb6897b61e83c03d96f725f7f12d3939f854b Mon Sep 17 00:00:00 2001 From: Haorui Zhou Date: Tue, 9 Dec 2025 21:06:35 -0500 Subject: [PATCH 1/2] Add AI code generation and sandbox services Introduces Cohere-powered code generation and a custom Python sandbox for telemetry analysis. Updates environment variables, documentation, and docker-compose to support new services. Integrates Slackbot with code generator for natural language queries, adds retry logic, and provides setup guides and test scripts. --- installer/.env.example | 17 +- installer/README.md | 35 ++- installer/docker-compose.yml | 48 ++++ installer/sandbox/.gitignore | 16 ++ installer/sandbox/Dockerfile | 20 ++ installer/sandbox/Dockerfile.sandbox | 51 ++++ installer/sandbox/QUICKSTART.md | 247 +++++++++++++++++ installer/sandbox/README.md | 291 +++++++++++++++++++- installer/sandbox/code_generator.py | 299 +++++++++++++++++++++ installer/sandbox/docker-compose.yml | 50 +++- installer/sandbox/output.png | Bin 71263 -> 0 bytes installer/sandbox/prompt-guide.txt.example | 50 ++++ installer/sandbox/requirements-docker.txt | 56 ++++ installer/sandbox/requirements.txt | 5 + installer/sandbox/sandbox_server.py | 123 +++++++++ installer/sandbox/test_code_generator.py | 139 ++++++++++ installer/sandbox/test_terrarium.py | 66 ----- installer/slackbot/Dockerfile | 8 + installer/slackbot/initialbuild.md | 11 - installer/slackbot/slack_bot.py | 211 ++++++++++++--- 20 files changed, 1618 insertions(+), 125 deletions(-) create mode 100644 installer/sandbox/.gitignore create mode 100644 installer/sandbox/Dockerfile create mode 100644 installer/sandbox/Dockerfile.sandbox create mode 100644 installer/sandbox/QUICKSTART.md create mode 100644 installer/sandbox/code_generator.py delete mode 100644 installer/sandbox/output.png create mode 100644 installer/sandbox/prompt-guide.txt.example create mode 100644 installer/sandbox/requirements-docker.txt create mode 100644 installer/sandbox/requirements.txt create mode 100644 installer/sandbox/sandbox_server.py create mode 100644 installer/sandbox/test_code_generator.py delete mode 100644 installer/sandbox/test_terrarium.py delete mode 100644 installer/slackbot/initialbuild.md 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..1542e42 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 @@ -124,6 +126,8 @@ services: exit 0; fi ' + depends_on: + - code-generator networks: - datalink @@ -246,3 +250,47 @@ 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 + 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 ccb0eb48f124f732ba7323e2f7ddc76a764d2596..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71263 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 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() From 346cff16f767d49cab35066276a75183b8bab81d Mon Sep 17 00:00:00 2001 From: "WFR DAQ Server (ovh)" Date: Wed, 10 Dec 2025 02:26:21 +0000 Subject: [PATCH 2/2] prompt guide mounting change --- .gitignore | 1 + installer/docker-compose.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) 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/docker-compose.yml b/installer/docker-compose.yml index 1542e42..0cf0c07 100644 --- a/installer/docker-compose.yml +++ b/installer/docker-compose.yml @@ -119,7 +119,7 @@ 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; @@ -130,6 +130,7 @@ services: - code-generator networks: - datalink + - default lap-detector: build: ./lap-detector @@ -292,5 +293,6 @@ services: - influxdb3 volumes: - ./sandbox/generated:/app/generated + - ./sandbox/prompt-guide.txt:/app/prompt-guide.txt networks: - datalink