Commit ·
11d89a2
0
Parent(s):
final: mobile fixes and quality relaxation
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- .gemini/antigravity/brain/8453b74f-68a6-47ae-887d-1123cb011afb/scratch/verify_supabase.py +10 -0
- .gitattributes +35 -0
- .gitignore +13 -0
- Dockerfile +40 -0
- README.md +143 -0
- ROADMAP.md +61 -0
- SPEC.md +137 -0
- VERSION +1 -0
- backend/.env.example +19 -0
- backend/Dockerfile +32 -0
- backend/agents/agent_factory.py +44 -0
- backend/agents/amd_agent.py +33 -0
- backend/agents/base.py +162 -0
- backend/agents/gemini_agent.py +37 -0
- backend/agents/groq_agent.py +98 -0
- backend/agents/local_agent.py +48 -0
- backend/agents/openai_agent.py +28 -0
- backend/agents_debug.json +1 -0
- backend/api/index.py +1 -0
- backend/main.py +102 -0
- backend/project_debug.json +1 -0
- backend/requirements.txt +19 -0
- backend/routers/__init__.py +1 -0
- backend/routers/agent_runner.py +151 -0
- backend/routers/monitoring.py +70 -0
- backend/routers/orchestrator.py +169 -0
- backend/scratch/check_db.py +22 -0
- backend/scratch/create_comparison_project.py +61 -0
- backend/scratch/find_user.py +24 -0
- backend/scratch/fix_logs_rls.py +33 -0
- backend/services/agent_runner_service.py +210 -0
- backend/services/audit_service.py +31 -0
- backend/services/config.py +101 -0
- backend/services/orchestrator_service.py +773 -0
- backend/services/output_quality.py +312 -0
- backend/services/project_service.py +35 -0
- backend/services/semantic_backprop.py +104 -0
- backend/services/supabase_service.py +13 -0
- backend/services/task_queue.py +47 -0
- backend/tools/browser.py +111 -0
- backend/tools/decomposer.py +20 -0
- backend/tools/file_generator.py +61 -0
- backend/tools/registry.py +260 -0
- backend/tools/sandbox.py +39 -0
- backend/tools/sre.py +72 -0
- backend/tools/visuals.py +48 -0
- backend/worker.py +53 -0
- database/agent_ownership.sql +42 -0
- database/default_agents.sql +36 -0
.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gemini
|
| 3 |
+
.vercel
|
| 4 |
+
|
| 5 |
+
backend/.env
|
| 6 |
+
backend/venv
|
| 7 |
+
backend/__pycache__
|
| 8 |
+
backend/**/__pycache__
|
| 9 |
+
backend/**/*.pyc
|
| 10 |
+
|
| 11 |
+
frontend/.env
|
| 12 |
+
frontend/node_modules
|
| 13 |
+
frontend/dist
|
| 14 |
+
|
| 15 |
+
*.log
|
.gemini/antigravity/brain/8453b74f-68a6-47ae-887d-1123cb011afb/scratch/verify_supabase.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
sys.path.append(os.path.join(os.getcwd(), 'backend'))
|
| 4 |
+
|
| 5 |
+
try:
|
| 6 |
+
from backend.services.supabase_service import supabase
|
| 7 |
+
res = supabase.table("agents").select("count").execute()
|
| 8 |
+
print(f"Connection successful! Agents count: {res.data}")
|
| 9 |
+
except Exception as e:
|
| 10 |
+
print(f"Error connecting to Supabase: {e}")
|
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backend/.env
|
| 2 |
+
/backend/venv
|
| 3 |
+
/backend/__pycache__
|
| 4 |
+
frontend/.env
|
| 5 |
+
node_modules/
|
| 6 |
+
dist/
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
|
| 10 |
+
.vercel
|
| 11 |
+
frontend/ios
|
| 12 |
+
frontend/android
|
| 13 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-slim AS frontend-build
|
| 2 |
+
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
|
| 5 |
+
COPY frontend/package*.json ./
|
| 6 |
+
RUN npm ci
|
| 7 |
+
|
| 8 |
+
COPY frontend ./
|
| 9 |
+
|
| 10 |
+
ARG VITE_API_URL=""
|
| 11 |
+
ARG VITE_SUPABASE_URL=""
|
| 12 |
+
ARG VITE_SUPABASE_ANON_KEY=""
|
| 13 |
+
ARG VITE_SENTRY_DSN=""
|
| 14 |
+
|
| 15 |
+
ENV VITE_API_URL=$VITE_API_URL
|
| 16 |
+
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
| 17 |
+
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
| 18 |
+
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
| 19 |
+
|
| 20 |
+
RUN npm run build
|
| 21 |
+
|
| 22 |
+
FROM python:3.11-slim
|
| 23 |
+
|
| 24 |
+
ENV PORT=7860
|
| 25 |
+
ENV PYTHONUNBUFFERED=1
|
| 26 |
+
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
|
| 29 |
+
COPY VERSION VERSION
|
| 30 |
+
COPY backend/requirements.txt backend/requirements.txt
|
| 31 |
+
RUN pip install --no-cache-dir -r backend/requirements.txt
|
| 32 |
+
|
| 33 |
+
COPY backend backend
|
| 34 |
+
COPY --from=frontend-build /app/frontend/dist frontend/dist
|
| 35 |
+
|
| 36 |
+
WORKDIR /app/backend
|
| 37 |
+
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Aubm
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
license: mit
|
| 6 |
+
short_description: Automated Business Machines
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
# 🤖 Aubm
|
| 10 |
+
|
| 11 |
+
### Enterprise-Grade AI Agent Orchestration & Collaboration Platform
|
| 12 |
+
|
| 13 |
+
Aubm (Automated Unified Business Machines) is a sophisticated platform designed to orchestrate multiple autonomous AI agents to complete complex projects. Featuring **Human-in-the-Loop** supervision, **Dynamic DAG** task execution, and **Semantic RAG** context injection.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 🚀 Key Features
|
| 18 |
+
|
| 19 |
+
- **Multi-Provider Support**: Seamless integration with OpenAI, AMD (inference.do-ai.run), Groq, Gemini, Qwen, Ollama, and OpenRouter.
|
| 20 |
+
- **Autonomous Orchestration**: Intelligent task prioritization and execution based on dependencies (DAG).
|
| 21 |
+
- **Human-in-the-Loop**: Approval-based workflow ensuring quality and safety.
|
| 22 |
+
- **Semantic Backpropagation**: Context from completed tasks is automatically injected into subsequent tasks.
|
| 23 |
+
- **Real-time Monitoring**: SSE-powered live logs and progress tracking.
|
| 24 |
+
- **Project Wizard**: AI-driven project creation and task decomposition.
|
| 25 |
+
- **Operational Safety**: Automatic recovery of stale runs and comprehensive health monitoring.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 🛠️ Tech Stack
|
| 30 |
+
|
| 31 |
+
- **Frontend**: React + Vite + TypeScript (Styled with Vanilla CSS for maximum performance)
|
| 32 |
+
- **Backend**: FastAPI (Python 3.10+)
|
| 33 |
+
- **Database**: Supabase (Postgres + Auth + Real-time)
|
| 34 |
+
- **Deployment**: Optimized for Vercel (Serverless Backend + Static Frontend)
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 🏗️ Project Structure
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
aubm/
|
| 42 |
+
├── backend/ # FastAPI Application & AI Core
|
| 43 |
+
│ ├── agents/ # LLM Provider Implementations
|
| 44 |
+
│ ├── routers/ # API Endpoints (Runner, Orchestrator)
|
| 45 |
+
│ ├── services/ # Business Logic (Queue, RAG, Guards)
|
| 46 |
+
│ └── main.py # App Entrypoint
|
| 47 |
+
├── frontend/ # React Application
|
| 48 |
+
│ ├── src/ # Components, Hooks, Context, Services
|
| 49 |
+
│ └── vite.config.ts # Vite Configuration
|
| 50 |
+
└── database/ # Supabase Schema & Migrations
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## ⚙️ Getting Started
|
| 56 |
+
|
| 57 |
+
### 1. Database Setup (Supabase)
|
| 58 |
+
1. Create a new project in [Supabase](https://supabase.com).
|
| 59 |
+
2. Go to the **SQL Editor** and execute the content of `backend/schema.sql`.
|
| 60 |
+
3. Enable **Auth** with your preferred providers (Email/Password by default).
|
| 61 |
+
|
| 62 |
+
### 2. Backend Installation
|
| 63 |
+
```bash
|
| 64 |
+
cd backend
|
| 65 |
+
python -m venv venv
|
| 66 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 67 |
+
pip install -r requirements.txt
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Create a `.env` file in `/backend`:
|
| 71 |
+
```env
|
| 72 |
+
SUPABASE_URL=your_project_url
|
| 73 |
+
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
| 74 |
+
OPENAI_API_KEY=optional_key
|
| 75 |
+
GROQ_API_KEY=optional_key
|
| 76 |
+
TAVILY_API_KEY=optional_key
|
| 77 |
+
# See SPEC.md for all available providers
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
Run the server:
|
| 81 |
+
```bash
|
| 82 |
+
uvicorn main:app --reload --port 8000
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### 3. Frontend Installation
|
| 86 |
+
```bash
|
| 87 |
+
cd frontend
|
| 88 |
+
npm install
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
Create a `.env` file in `/frontend`:
|
| 92 |
+
```env
|
| 93 |
+
VITE_API_URL=http://localhost:8000
|
| 94 |
+
VITE_SUPABASE_URL=your_project_url
|
| 95 |
+
VITE_SUPABASE_ANON_KEY=your_anon_key
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Run the development server:
|
| 99 |
+
```bash
|
| 100 |
+
npm run dev
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## 📈 Operational Modes
|
| 106 |
+
|
| 107 |
+
- **Embedded Worker**: Runs the task queue within the FastAPI process (set `TASK_QUEUE_EMBEDDED_WORKER=true`).
|
| 108 |
+
- **Standalone Worker**: For high-load environments, run the worker in a separate process:
|
| 109 |
+
```bash
|
| 110 |
+
cd backend
|
| 111 |
+
python worker.py
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## Hugging Face Spaces
|
| 117 |
+
|
| 118 |
+
This repository is ready to deploy as a Docker Space. Create a Hugging Face Space with SDK `Docker`, then push this repo to the Space remote.
|
| 119 |
+
|
| 120 |
+
Configure these Space secrets or variables:
|
| 121 |
+
|
| 122 |
+
```env
|
| 123 |
+
SUPABASE_URL=your_project_url
|
| 124 |
+
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
| 125 |
+
SUPABASE_ANON_KEY=your_anon_key
|
| 126 |
+
GROQ_API_KEY=optional_key
|
| 127 |
+
OPENAI_API_KEY=optional_key
|
| 128 |
+
GEMINI_API_KEY=optional_key
|
| 129 |
+
AMD_API_KEY=optional_key
|
| 130 |
+
TAVILY_API_KEY=optional_key
|
| 131 |
+
TASK_QUEUE_EMBEDDED_WORKER=true
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
`VITE_API_URL` can stay empty on Spaces because the frontend calls the FastAPI backend on the same origin.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 📄 Documentation
|
| 139 |
+
|
| 140 |
+
For detailed technical architecture, refer to:
|
| 141 |
+
- [SPEC.md](./SPEC.md) - Deep technical specifications.
|
| 142 |
+
- [ROADMAP.md](./ROADMAP.md) - Future development goals.
|
| 143 |
+
- [docs/](./docs/) - Extended guides and manuals.
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Aubm Roadmap
|
| 2 |
+
|
| 3 |
+
This document outlines the strategic evolution of Aubm, moving from a robust orchestration core to an enterprise-ready multi-agent operating layer.
|
| 4 |
+
|
| 5 |
+
## Phase 1: Core Foundation (Completed)
|
| 6 |
+
- [x] Autonomous Agent Execution: Multi-provider support (OpenAI, Groq, Gemini, etc.).
|
| 7 |
+
- [x] Project Orchestration: Intelligent task scheduling and dependency management (DAG).
|
| 8 |
+
- [x] Human-in-the-Loop: Approval and rejection workflows for agent outputs.
|
| 9 |
+
- [x] Semantic RAG: Contextual memory injection across project tasks.
|
| 10 |
+
- [x] Real-time Logs: Streaming agent thoughts and actions via SSE.
|
| 11 |
+
- [x] Cost Control: Token-based budgeting and execution blocking.
|
| 12 |
+
|
| 13 |
+
## Phase 2: Advanced Collaboration and Tools (Completed)
|
| 14 |
+
- [x] Multi-Agent Debates: Allow agents to cross-verify each other's outputs before human review.
|
| 15 |
+
- [x] Extended Toolbelt:
|
| 16 |
+
- [x] Web Browser Tool (via Playwright) for live data fetching.
|
| 17 |
+
- [x] Code Sandbox for executing and testing generated snippets.
|
| 18 |
+
- [x] File Generation (Excel, Word, and advanced PDF layouts).
|
| 19 |
+
- [x] Collaborative Editing: Real-time collaborative output refining for humans.
|
| 20 |
+
- [x] Mobile Experience: Capacitor-based mobile app for project monitoring (initialized).
|
| 21 |
+
|
| 22 |
+
## Phase 3: Intelligence and Scale (Completed)
|
| 23 |
+
- [x] Fine-tuning Loop: Feedback loop (Like/Dislike) implemented for data collection.
|
| 24 |
+
- [x] Recursive Project Decomposition: Agents that can spawn sub-tasks and manage them.
|
| 25 |
+
- [x] Enterprise Security:
|
| 26 |
+
- [x] SSO Integration (Google, GitHub via Supabase).
|
| 27 |
+
- [x] Advanced RLS for granular team permissions.
|
| 28 |
+
- [x] Audit logs for every LLM interaction.
|
| 29 |
+
- [x] Agent Marketplace: Community-driven agent templates and specialized skill sets.
|
| 30 |
+
|
| 31 |
+
## Phase 4: Autonomy and Beyond (Completed)
|
| 32 |
+
- [x] Self-Healing Infrastructure: Agents that can monitor health and apply safe patches.
|
| 33 |
+
- [x] Voice Interaction: Control navigation and hear project/task status updates via browser voice APIs.
|
| 34 |
+
- [x] VR/AR Dashboard: Spatial DAG viewer scaffold for layered project/task visualization.
|
| 35 |
+
|
| 36 |
+
## Phase 5: Production Operations (Completed)
|
| 37 |
+
- [x] Operations Monitoring: Backend health summary endpoint and frontend monitoring dashboard with Supabase fallback.
|
| 38 |
+
- [x] Deployment Hardening: Dockerized backend/runtime profile and production CORS configuration.
|
| 39 |
+
- [x] Error Tracking: Sentry-compatible error reporting hooks for backend and frontend.
|
| 40 |
+
- [x] Performance Budgeting: Frontend code splitting and bundle-size targets.
|
| 41 |
+
|
| 42 |
+
## Phase 6: Distributed Scale and Intelligence (In Progress)
|
| 43 |
+
- [x] Recursive Project Decomposition: Agents that can automatically break down goals.
|
| 44 |
+
- [x] Numerical Consistency (Semantic Backprop): Enforce absolute figures across tasks.
|
| 45 |
+
- [x] Visual Tooling: Integrated support for charts and AI illustrations.
|
| 46 |
+
- [x] Vercel Deployment: Monorepo serverless configuration.
|
| 47 |
+
- [x] Heuristic Output Guardrails: Prompt hardening, reviewer checks, and final-report filtering for placeholders, unsupported claims, and low-quality sections.
|
| 48 |
+
- [ ] Asynchronous Task Queue: Dedicated background workers (`worker.py`).
|
| 49 |
+
- [ ] Vectorized Long-term Memory: Cross-project semantic retrieval.
|
| 50 |
+
- [ ] Self-Optimizing Agents: Meta-prompting loops based on human feedback.
|
| 51 |
+
|
| 52 |
+
## Phase 7: Structured Evidence and Entity Integrity (Next)
|
| 53 |
+
- [ ] Strict JSON Task Schemas: Enforce structured outputs per task type instead of free-form text.
|
| 54 |
+
- [ ] Mandatory `source_url` per Claim: Require evidence links for competitor, pricing, release, benchmark, and market claims.
|
| 55 |
+
- [ ] Entity Normalization Layer: Canonicalize entity names, merge aliases, and separate direct competitors from adjacent tools before final reporting.
|
| 56 |
+
- [ ] Semantic Deduplication: Collapse equivalent claims written differently across tasks.
|
| 57 |
+
- [ ] Evidence-Aware Final Report: Build the final report from normalized entities and validated claims only.
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
*Last updated: May 6, 2026*
|
SPEC.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🛠️ Aubm — Technical Specification
|
| 2 |
+
|
| 3 |
+
> **Target Stack**: FastAPI (Python) + React/TypeScript (Vite) + Supabase (Postgres + Auth)
|
| 4 |
+
|
| 5 |
+
This document provides a comprehensive technical blueprint for recreating Aubm.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. System Architecture
|
| 10 |
+
|
| 11 |
+
Aubm follows a decoupled architecture with a centralized database (Supabase) acting as the source of truth and coordination layer.
|
| 12 |
+
|
| 13 |
+
### Directory Structure
|
| 14 |
+
```
|
| 15 |
+
aubm/
|
| 16 |
+
├── backend/ # Python 3.10+
|
| 17 |
+
│ ├── main.py # Application entrypoint & CRUD API
|
| 18 |
+
│ ├── worker.py # Standalone task queue worker
|
| 19 |
+
│ ├── schema.sql # Full DDL for Supabase
|
| 20 |
+
│ ├── agents/ # Provider-specific implementations
|
| 21 |
+
│ │ ├── base.py # Abstract BaseAgent class
|
| 22 |
+
│ │ ├── agent_factory.py # Factory for creating agent instances
|
| 23 |
+
│ │ └── {provider}_agent.py
|
| 24 |
+
│ ├── routers/ # Functional endpoint grouping
|
| 25 |
+
│ │ ├── agent_runner.py # Task execution logic
|
| 26 |
+
│ │ └── orchestrator.py # Multi-task project flow
|
| 27 |
+
│ └── services/ # Core business logic
|
| 28 |
+
│ ├── config.py # Configuration management
|
| 29 |
+
│ ├── task_queue.py # Background processing loop
|
| 30 |
+
│ └── semantic_backprop.py # RAG context builder
|
| 31 |
+
├── frontend/ # React + Vite + TS
|
| 32 |
+
│ ├── src/
|
| 33 |
+
│ │ ├── components/ # UI Modular components
|
| 34 |
+
│ │ ├── services/ # API communication layer
|
| 35 |
+
│ │ ├── context/ # Auth & Global state
|
| 36 |
+
│ │ └── i18n/ # Multi-language support
|
| 37 |
+
│ └── vite.config.ts
|
| 38 |
+
└── database/ # Migrations & Seed data
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## 2. Database Schema (Supabase/Postgres)
|
| 44 |
+
|
| 45 |
+
### Core Tables
|
| 46 |
+
|
| 47 |
+
| Table | Purpose | Key Columns |
|
| 48 |
+
|-------|---------|-------------|
|
| 49 |
+
| `profiles` | User extensions | `id (uuid)`, `role`, `full_name`, `avatar_url` |
|
| 50 |
+
| `projects` | Project containers | `id`, `name`, `description`, `context`, `owner_id`, `status` |
|
| 51 |
+
| `agents` | AI Identities | `id`, `name`, `role`, `api_provider`, `model`, `system_prompt` |
|
| 52 |
+
| `tasks` | Units of work | `id`, `project_id`, `assigned_agent_id`, `status`, `output_data` |
|
| 53 |
+
| `task_runs` | Execution history | `id`, `task_id`, `agent_id`, `status`, `error_message` |
|
| 54 |
+
| `agent_logs` | Execution traces | `id`, `task_id`, `action`, `content`, `metadata` |
|
| 55 |
+
| `app_config` | Global settings | `key`, `value` (JSONB) |
|
| 56 |
+
|
| 57 |
+
### Status Enums
|
| 58 |
+
- **Tasks**: `todo`, `in_progress`, `awaiting_approval`, `done`, `failed`, `cancelled`.
|
| 59 |
+
- **Task Runs**: `queued`, `running`, `completed`, `failed`, `cancelled`.
|
| 60 |
+
- **Profiles**: `user`, `manager`, `admin`.
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## 3. Backend Logic
|
| 65 |
+
|
| 66 |
+
### Agent Execution Flow
|
| 67 |
+
1. **Request**: `POST /tasks/{id}/run`
|
| 68 |
+
2. **Initialization**: Fetch task, agent, and project data.
|
| 69 |
+
3. **Context Building**: `semantic_backprop` fetches outputs from previous tasks in the same project.
|
| 70 |
+
4. **Agent Factory**: Instantiates the correct `BaseAgent` subclass (e.g., `GroqAgent`).
|
| 71 |
+
5. **Execution**:
|
| 72 |
+
- LLM call with dynamic prompt.
|
| 73 |
+
- Real-time logging to `agent_logs` via SSE.
|
| 74 |
+
6. **Guardrails**:
|
| 75 |
+
- `output_cleaner`: Strips markdown artifacts.
|
| 76 |
+
- `language_guard`: Ensures output matches `app_config["output_language"]`.
|
| 77 |
+
7. **Persistence**: Updates `task.output_data` and sets status to `awaiting_approval`.
|
| 78 |
+
|
| 79 |
+
### Orchestration Engine
|
| 80 |
+
- Processes a project's task list as a Directed Acyclic Graph (DAG).
|
| 81 |
+
- Respects `is_critical` and `priority` fields.
|
| 82 |
+
- Auto-assigns available agents from the `agents` pool if no agent is pre-assigned.
|
| 83 |
+
|
| 84 |
+
### Tool System (Phase 2)
|
| 85 |
+
- **Tool Registry**: A central registry where tools are defined and permissioned.
|
| 86 |
+
- **Browser Tool**: Uses Playwright for headless browsing and content extraction.
|
| 87 |
+
- **Sandbox Tool**: Executes code in a restricted environment.
|
| 88 |
+
- **Integration**: Tools are exposed to agents via the OpenAI function-calling/tool-calling schema.
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## 4. Frontend Design System
|
| 93 |
+
|
| 94 |
+
- **Styling**: Vanilla CSS with modern variables (HSL colors, glassmorphism).
|
| 95 |
+
- **Icons**: Lucide React.
|
| 96 |
+
- **State Management**: React Context + Hooks.
|
| 97 |
+
- **Features**:
|
| 98 |
+
- Kanban Board for task management.
|
| 99 |
+
- Real-time streaming console for agent thoughts.
|
| 100 |
+
- Interactive Project Wizard for quick setup.
|
| 101 |
+
- Analytics dashboard for project performance.
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## 5. Deployment Guide
|
| 106 |
+
|
| 107 |
+
### Vercel Integration
|
| 108 |
+
The project is designed to run seamlessly on Vercel:
|
| 109 |
+
- **Frontend**: Standard Vite build.
|
| 110 |
+
- **Backend**: Python Serverless Functions.
|
| 111 |
+
- **Database**: External Supabase instance.
|
| 112 |
+
|
| 113 |
+
### Local Setup
|
| 114 |
+
1. **DB**: Apply `schema.sql` to Supabase.
|
| 115 |
+
2. **Backend**: `pip install -r requirements.txt` & `uvicorn main:app`.
|
| 116 |
+
3. **Frontend**: `npm install` & `npm run dev`.
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## 6. Key Dependencies
|
| 121 |
+
|
| 122 |
+
### Backend
|
| 123 |
+
- `fastapi`, `supabase`, `openai`, `groq`, `google-genai`, `playwright`, `folium`.
|
| 124 |
+
|
| 125 |
+
### Frontend
|
| 126 |
+
- `react`, `lucide-react`, `framer-motion` (for animations), `i18next`.
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## 7. Security (RLS)
|
| 131 |
+
- **Projects**: Only visible to owner or if `is_public=true`.
|
| 132 |
+
- **Config**: Only writable by users with `role='admin'`.
|
| 133 |
+
- **Agents**: Writable by `manager` or `admin`.
|
| 134 |
+
- **Tasks**: Protected by project-level RLS.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
*End of Specification*
|
VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
0.7.0
|
backend/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase Configuration
|
| 2 |
+
SUPABASE_URL=https://your-project-id.supabase.co
|
| 3 |
+
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
|
| 4 |
+
|
| 5 |
+
# AI Provider Keys
|
| 6 |
+
OPENAI_API_KEY=your-openai-key
|
| 7 |
+
GROQ_API_KEY=your-groq-key
|
| 8 |
+
GEMINI_API_KEY=your-gemini-key
|
| 9 |
+
ANTHROPIC_API_KEY=your-anthropic-key
|
| 10 |
+
TAVILY_API_KEY=your-tavily-key
|
| 11 |
+
|
| 12 |
+
# App Settings
|
| 13 |
+
PORT=8000
|
| 14 |
+
ALLOWED_ORIGINS=http://localhost:5173,https://your-app.vercel.app
|
| 15 |
+
TASK_QUEUE_EMBEDDED_WORKER=true
|
| 16 |
+
OUTPUT_LANGUAGE=en
|
| 17 |
+
|
| 18 |
+
# Error Tracking
|
| 19 |
+
SENTRY_DSN=your-sentry-dsn
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build stage for backend
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
|
| 8 |
+
# Set work directory
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install system dependencies
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential \
|
| 14 |
+
libpq-dev \
|
| 15 |
+
curl \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Install python dependencies
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Install Playwright browsers and their dependencies
|
| 23 |
+
RUN playwright install --with-deps chromium
|
| 24 |
+
|
| 25 |
+
# Copy project
|
| 26 |
+
COPY . .
|
| 27 |
+
|
| 28 |
+
# Expose port
|
| 29 |
+
EXPOSE 8000
|
| 30 |
+
|
| 31 |
+
# Run the application
|
| 32 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/agents/agent_factory.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Type
|
| 2 |
+
from .base import BaseAgent
|
| 3 |
+
from .openai_agent import OpenAIAgent
|
| 4 |
+
from .amd_agent import AMDAgent
|
| 5 |
+
from .groq_agent import GroqAgent
|
| 6 |
+
from .gemini_agent import GeminiAgent
|
| 7 |
+
from .local_agent import LocalAgent
|
| 8 |
+
from services.config import settings
|
| 9 |
+
|
| 10 |
+
# Map of providers to their respective classes
|
| 11 |
+
PROVIDER_MAP: Dict[str, Type[BaseAgent]] = {
|
| 12 |
+
"openai": OpenAIAgent,
|
| 13 |
+
"amd": AMDAgent,
|
| 14 |
+
"groq": GroqAgent,
|
| 15 |
+
"gemini": GeminiAgent,
|
| 16 |
+
"local": LocalAgent,
|
| 17 |
+
"ollama": LocalAgent
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
class AgentFactory:
|
| 21 |
+
@staticmethod
|
| 22 |
+
def get_agent(provider: str, name: str, role: str, model: str, system_prompt: str = None) -> BaseAgent:
|
| 23 |
+
"""
|
| 24 |
+
Instantiates the appropriate agent based on the provider string.
|
| 25 |
+
Includes a fallback to Groq if OpenAI is requested but no key is provided.
|
| 26 |
+
"""
|
| 27 |
+
provider = provider.lower()
|
| 28 |
+
|
| 29 |
+
# Groq Redirection Logic
|
| 30 |
+
if provider == "openai" and not settings.OPENAI_API_KEY:
|
| 31 |
+
# Check if we have a Groq key before redirecting
|
| 32 |
+
if settings.GROQ_API_KEY:
|
| 33 |
+
provider = "groq"
|
| 34 |
+
model = "llama-3.3-70b-versatile" # Robust fallback model
|
| 35 |
+
else:
|
| 36 |
+
# If neither is available, let it fail with the original provider
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
agent_class = PROVIDER_MAP.get(provider)
|
| 40 |
+
|
| 41 |
+
if not agent_class:
|
| 42 |
+
raise ValueError(f"Unsupported agent provider: {provider}")
|
| 43 |
+
|
| 44 |
+
return agent_class(name=name, role=role, model=model, system_prompt=system_prompt)
|
backend/agents/amd_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseAgent
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
import openai
|
| 4 |
+
from services.config import settings, config_service
|
| 5 |
+
|
| 6 |
+
class AMDAgent(BaseAgent):
|
| 7 |
+
"""
|
| 8 |
+
Agent implementation for AMD Inference (inference.do-ai.run).
|
| 9 |
+
Compatible with OpenAI's API format.
|
| 10 |
+
"""
|
| 11 |
+
def __init__(self, name: str, role: str, model: str = "gpt-4o", system_prompt: str = None):
|
| 12 |
+
super().__init__(name, role, model, system_prompt)
|
| 13 |
+
|
| 14 |
+
self.provider_config = config_service.get_provider_config("amd")
|
| 15 |
+
api_key = self.provider_config.get("api_key") or settings.AMD_API_KEY
|
| 16 |
+
|
| 17 |
+
self.client = openai.AsyncOpenAI(
|
| 18 |
+
api_key=api_key,
|
| 19 |
+
base_url=self.provider_config.get("base_url", "https://inference.do-ai.run/v1")
|
| 20 |
+
)
|
| 21 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 22 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 23 |
+
|
| 24 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 25 |
+
return await self._run_openai_compatible(
|
| 26 |
+
provider="amd",
|
| 27 |
+
create_fn=self.client.chat.completions.create,
|
| 28 |
+
task_description=task_description,
|
| 29 |
+
context=context,
|
| 30 |
+
use_tools=use_tools,
|
| 31 |
+
extra_context=extra_context,
|
| 32 |
+
response_format={"type": "json_object"}
|
| 33 |
+
)
|
backend/agents/base.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Dict, Any, List, Optional
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
class BaseAgent(ABC):
|
| 6 |
+
def __init__(self, name: str, role: str, model: str, system_prompt: Optional[str] = None):
|
| 7 |
+
self.name = name
|
| 8 |
+
self.role = role
|
| 9 |
+
self.model = model
|
| 10 |
+
self.system_prompt = system_prompt or f"You are {name}, acting as a {role}."
|
| 11 |
+
|
| 12 |
+
@abstractmethod
|
| 13 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 14 |
+
"""
|
| 15 |
+
Executes a task given its description and previous context.
|
| 16 |
+
Returns a dictionary containing the output data.
|
| 17 |
+
"""
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
def _format_context(self, context: List[Dict[str, Any]]) -> str:
|
| 21 |
+
"""Helper to format previous task outputs for the current agent."""
|
| 22 |
+
if not context:
|
| 23 |
+
return "No previous context available."
|
| 24 |
+
|
| 25 |
+
formatted = "Previous tasks context:\n"
|
| 26 |
+
for item in context:
|
| 27 |
+
formatted += f"- Task: {item.get('title')}\n Output: {json.dumps(item.get('output_data', {}))}\n"
|
| 28 |
+
return formatted
|
| 29 |
+
|
| 30 |
+
def _build_json_prompt(self, task_description: str, context: List[Dict[str, Any]], extra_context: str = "") -> str:
|
| 31 |
+
return f"""
|
| 32 |
+
Task: {task_description}
|
| 33 |
+
|
| 34 |
+
{self._format_context(context)}
|
| 35 |
+
|
| 36 |
+
{extra_context}
|
| 37 |
+
|
| 38 |
+
Please provide your output as a JSON object.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def _build_chat_messages(self, task_description: str, context: List[Dict[str, Any]], extra_context: str = "") -> List[Dict[str, Any]]:
|
| 42 |
+
return [
|
| 43 |
+
{"role": "system", "content": self.system_prompt},
|
| 44 |
+
{"role": "user", "content": self._build_json_prompt(task_description, context, extra_context)}
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
def _parse_json_output(self, content: str) -> Any:
|
| 48 |
+
"""Parse strict JSON first, then tolerate fenced or prefixed JSON."""
|
| 49 |
+
if not content:
|
| 50 |
+
return {}
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
return json.loads(content)
|
| 54 |
+
except json.JSONDecodeError:
|
| 55 |
+
pass
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
if "```json" in content:
|
| 59 |
+
clean = content.split("```json", 1)[1].split("```", 1)[0].strip()
|
| 60 |
+
elif "```" in content:
|
| 61 |
+
clean = content.split("```", 1)[1].split("```", 1)[0].strip()
|
| 62 |
+
else:
|
| 63 |
+
object_start, array_start = content.find("{"), content.find("[")
|
| 64 |
+
starts = [index for index in (object_start, array_start) if index != -1]
|
| 65 |
+
start = min(starts) if starts else -1
|
| 66 |
+
if start == array_start:
|
| 67 |
+
end = content.rfind("]")
|
| 68 |
+
else:
|
| 69 |
+
end = content.rfind("}")
|
| 70 |
+
clean = content[start:end + 1] if start != -1 and end != -1 else content
|
| 71 |
+
return json.loads(clean)
|
| 72 |
+
except Exception:
|
| 73 |
+
return {"raw_text": content}
|
| 74 |
+
|
| 75 |
+
def _parse_tool_arguments(self, arguments: str | None) -> Dict[str, Any]:
|
| 76 |
+
parsed = self._parse_json_output(arguments or "{}")
|
| 77 |
+
return parsed if isinstance(parsed, dict) else {}
|
| 78 |
+
|
| 79 |
+
async def _append_tool_results(self, messages: List[Dict[str, Any]], tool_calls: Any, tool_registry: Any) -> None:
|
| 80 |
+
for tool_call in tool_calls or []:
|
| 81 |
+
tool_name = tool_call.function.name
|
| 82 |
+
tool_args = self._parse_tool_arguments(tool_call.function.arguments)
|
| 83 |
+
tool_result = await tool_registry.call_tool(tool_name, tool_args)
|
| 84 |
+
|
| 85 |
+
messages.append({
|
| 86 |
+
"tool_call_id": tool_call.id,
|
| 87 |
+
"role": "tool",
|
| 88 |
+
"name": tool_name,
|
| 89 |
+
"content": str(tool_result),
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
async def _run_openai_compatible(
|
| 93 |
+
self,
|
| 94 |
+
provider: str,
|
| 95 |
+
create_fn,
|
| 96 |
+
task_description: str,
|
| 97 |
+
context: List[Dict[str, Any]],
|
| 98 |
+
use_tools: bool = False,
|
| 99 |
+
extra_context: str = "",
|
| 100 |
+
**extra_kwargs
|
| 101 |
+
) -> Dict[str, Any]:
|
| 102 |
+
"""
|
| 103 |
+
Unified runner for OpenAI-compatible APIs (OpenAI, Groq, etc.)
|
| 104 |
+
"""
|
| 105 |
+
from tools.registry import tool_registry
|
| 106 |
+
|
| 107 |
+
messages = self._build_chat_messages(task_description, context, extra_context)
|
| 108 |
+
|
| 109 |
+
is_reasoning_model = "gpt-oss-" in self.model or self.model.startswith("o1-") or self.model.startswith("o3-")
|
| 110 |
+
|
| 111 |
+
kwargs = {
|
| 112 |
+
"model": self.model,
|
| 113 |
+
"messages": messages,
|
| 114 |
+
**extra_kwargs
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
# Handle temperature/max_tokens based on model type
|
| 118 |
+
if is_reasoning_model:
|
| 119 |
+
# Reasoning models prefer temperature 1.0 or none
|
| 120 |
+
kwargs["temperature"] = extra_kwargs.get("temperature", 1.0)
|
| 121 |
+
# Use max_completion_tokens if provided, otherwise default to max_tokens logic but renamed
|
| 122 |
+
if "max_completion_tokens" not in kwargs:
|
| 123 |
+
kwargs["max_completion_tokens"] = getattr(self, "max_tokens", 4096)
|
| 124 |
+
# Standard max_tokens is often forbidden in reasoning models
|
| 125 |
+
kwargs.pop("max_tokens", None)
|
| 126 |
+
else:
|
| 127 |
+
kwargs["temperature"] = getattr(self, "temperature", 0.7)
|
| 128 |
+
kwargs["max_tokens"] = getattr(self, "max_tokens", 4096)
|
| 129 |
+
|
| 130 |
+
if use_tools:
|
| 131 |
+
# Note: Many reasoning models don't support tools yet, but we'll include if requested
|
| 132 |
+
kwargs["tools"] = tool_registry.get_tool_definitions()
|
| 133 |
+
kwargs["tool_choice"] = "auto"
|
| 134 |
+
|
| 135 |
+
response = await create_fn(**kwargs)
|
| 136 |
+
message = response.choices[0].message
|
| 137 |
+
|
| 138 |
+
# Handle tool calls
|
| 139 |
+
if message.tool_calls:
|
| 140 |
+
messages.append(message)
|
| 141 |
+
await self._append_tool_results(messages, message.tool_calls, tool_registry)
|
| 142 |
+
|
| 143 |
+
# Second call after tool execution
|
| 144 |
+
# Remove tools from second call to force a final answer
|
| 145 |
+
kwargs.pop("tools", None)
|
| 146 |
+
kwargs.pop("tool_choice", None)
|
| 147 |
+
|
| 148 |
+
final_response = await create_fn(**kwargs)
|
| 149 |
+
content = final_response.choices[0].message.content
|
| 150 |
+
else:
|
| 151 |
+
content = message.content
|
| 152 |
+
|
| 153 |
+
return self._result(provider, content or "")
|
| 154 |
+
|
| 155 |
+
def _result(self, provider: str, content: str) -> Dict[str, Any]:
|
| 156 |
+
return {
|
| 157 |
+
"agent_name": self.name,
|
| 158 |
+
"provider": provider,
|
| 159 |
+
"model": self.model,
|
| 160 |
+
"raw_output": content,
|
| 161 |
+
"data": self._parse_json_output(content)
|
| 162 |
+
}
|
backend/agents/gemini_agent.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseAgent
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
from google import genai
|
| 4 |
+
from services.config import settings, config_service
|
| 5 |
+
|
| 6 |
+
class GeminiAgent(BaseAgent):
|
| 7 |
+
"""
|
| 8 |
+
Agent implementation for Google Gemini using the new google-genai SDK.
|
| 9 |
+
"""
|
| 10 |
+
def __init__(self, name: str, role: str, model: str = "gemini-2.0-flash", system_prompt: str = None):
|
| 11 |
+
super().__init__(name, role, model, system_prompt)
|
| 12 |
+
|
| 13 |
+
# Load dynamic config
|
| 14 |
+
self.provider_config = config_service.get_provider_config("gemini")
|
| 15 |
+
api_key = self.provider_config.get("api_key") or settings.GEMINI_API_KEY
|
| 16 |
+
|
| 17 |
+
self.client = genai.Client(api_key=api_key)
|
| 18 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 19 |
+
|
| 20 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 21 |
+
full_prompt = f"""
|
| 22 |
+
System Instruction: {self.system_prompt}
|
| 23 |
+
|
| 24 |
+
{self._build_json_prompt(task_description, context, extra_context)}
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# Gemini 2.0 Flash is very fast.
|
| 28 |
+
response = await self.client.aio.models.generate(
|
| 29 |
+
model=self.model,
|
| 30 |
+
contents=full_prompt,
|
| 31 |
+
config={
|
| 32 |
+
"temperature": self.temperature,
|
| 33 |
+
"response_mime_type": "application/json",
|
| 34 |
+
}
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
return self._result("gemini", response.text or "")
|
backend/agents/groq_agent.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from .base import BaseAgent
|
| 3 |
+
from typing import Dict, Any, List
|
| 4 |
+
import groq
|
| 5 |
+
import json
|
| 6 |
+
from services.config import settings, config_service
|
| 7 |
+
from tools.registry import tool_registry
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger("uvicorn")
|
| 10 |
+
|
| 11 |
+
GROQ_ROTATION_POOL = [
|
| 12 |
+
"llama-3.3-70b-versatile",
|
| 13 |
+
"openai/gpt-oss-120b",
|
| 14 |
+
"meta-llama/llama-4-scout-17b-16e-instruct",
|
| 15 |
+
"qwen/qwen3-32b",
|
| 16 |
+
"openai/gpt-oss-20b",
|
| 17 |
+
"groq/compound",
|
| 18 |
+
"llama-3.1-8b-instant"
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
class GroqAgent(BaseAgent):
|
| 22 |
+
"""
|
| 23 |
+
Agent implementation for Groq with automatic model rotation for rate limits.
|
| 24 |
+
"""
|
| 25 |
+
def __init__(self, name: str, role: str, model: str = "llama-3.3-70b-versatile", system_prompt: str = None):
|
| 26 |
+
# Auto-migrate decommissioned models
|
| 27 |
+
if "llama-3.1-70b" in model:
|
| 28 |
+
model = "llama-3.3-70b-versatile"
|
| 29 |
+
|
| 30 |
+
super().__init__(name, role, model, system_prompt)
|
| 31 |
+
|
| 32 |
+
# Load dynamic config
|
| 33 |
+
self.provider_config = config_service.get_provider_config("groq")
|
| 34 |
+
api_key = self.provider_config.get("api_key") or settings.GROQ_API_KEY
|
| 35 |
+
|
| 36 |
+
self.client = groq.AsyncGroq(api_key=api_key)
|
| 37 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 38 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 39 |
+
self.reasoning_effort = self.provider_config.get("reasoning_effort", "medium")
|
| 40 |
+
|
| 41 |
+
def _format_context(self, context: List[Dict[str, Any]]) -> str:
|
| 42 |
+
"""Extremely aggressive truncation for Groq TPM limits."""
|
| 43 |
+
if not context:
|
| 44 |
+
return "No previous context available."
|
| 45 |
+
|
| 46 |
+
# Only take the last 3 tasks to save tokens
|
| 47 |
+
recent_context = context[-3:]
|
| 48 |
+
|
| 49 |
+
formatted = "Previous tasks context (EXTREMELY TRUNCATED for Groq):\n"
|
| 50 |
+
for item in recent_context:
|
| 51 |
+
output_raw = json.dumps(item.get('output_data', {}))
|
| 52 |
+
# 800 chars is roughly 200 tokens.
|
| 53 |
+
if len(output_raw) > 800:
|
| 54 |
+
output_raw = output_raw[:800] + "... [TRUNCATED]"
|
| 55 |
+
|
| 56 |
+
formatted += f"- Task: {item.get('title')}\n Output: {output_raw}\n"
|
| 57 |
+
return formatted
|
| 58 |
+
|
| 59 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 60 |
+
# Very limited semantic context
|
| 61 |
+
if len(extra_context) > 1000:
|
| 62 |
+
extra_context = extra_context[:1000] + "... [TRUNCATED]"
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
return await self._execute_run(task_description, context, use_tools, extra_context)
|
| 66 |
+
except groq.RateLimitError as e:
|
| 67 |
+
logger.warning(f"Rate limit reached for {self.model} (429). Attempting model rotation...")
|
| 68 |
+
|
| 69 |
+
# Find current model index in pool
|
| 70 |
+
try:
|
| 71 |
+
current_idx = GROQ_ROTATION_POOL.index(self.model)
|
| 72 |
+
except ValueError:
|
| 73 |
+
current_idx = -1
|
| 74 |
+
|
| 75 |
+
# Try the next model in the pool
|
| 76 |
+
next_idx = (current_idx + 1) % len(GROQ_ROTATION_POOL)
|
| 77 |
+
fallback_model = GROQ_ROTATION_POOL[next_idx]
|
| 78 |
+
|
| 79 |
+
logger.info(f"Rotating from {self.model} to {fallback_model}")
|
| 80 |
+
self.model = fallback_model
|
| 81 |
+
|
| 82 |
+
# Retry once with fallback model
|
| 83 |
+
return await self._execute_run(task_description, context, use_tools, extra_context)
|
| 84 |
+
|
| 85 |
+
async def _execute_run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 86 |
+
extra_kwargs = {}
|
| 87 |
+
if "gpt-oss-" in self.model:
|
| 88 |
+
extra_kwargs["reasoning_effort"] = self.reasoning_effort
|
| 89 |
+
|
| 90 |
+
return await self._run_openai_compatible(
|
| 91 |
+
provider="groq",
|
| 92 |
+
create_fn=self.client.chat.completions.create,
|
| 93 |
+
task_description=task_description,
|
| 94 |
+
context=context,
|
| 95 |
+
use_tools=use_tools,
|
| 96 |
+
extra_context=extra_context,
|
| 97 |
+
**extra_kwargs
|
| 98 |
+
)
|
backend/agents/local_agent.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseAgent
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
import httpx
|
| 4 |
+
from services.config import config_service
|
| 5 |
+
|
| 6 |
+
class LocalAgent(BaseAgent):
|
| 7 |
+
"""
|
| 8 |
+
Agent implementation for Local LLMs (Ollama).
|
| 9 |
+
"""
|
| 10 |
+
def __init__(self, name: str, role: str, model: str = "llama3.1:8b", system_prompt: str = None):
|
| 11 |
+
super().__init__(name, role, model, system_prompt)
|
| 12 |
+
|
| 13 |
+
# Load dynamic config
|
| 14 |
+
self.provider_config = config_service.get_provider_config("ollama")
|
| 15 |
+
self.base_url = self.provider_config.get("base_url", "http://localhost:11434")
|
| 16 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 17 |
+
|
| 18 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 19 |
+
full_prompt = f"""
|
| 20 |
+
System Instructions: {self.system_prompt}
|
| 21 |
+
|
| 22 |
+
{self._build_json_prompt(task_description, context, extra_context)}
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 26 |
+
try:
|
| 27 |
+
response = await client.post(
|
| 28 |
+
f"{self.base_url}/api/generate",
|
| 29 |
+
json={
|
| 30 |
+
"model": self.model,
|
| 31 |
+
"prompt": full_prompt,
|
| 32 |
+
"stream": False,
|
| 33 |
+
"format": "json",
|
| 34 |
+
"options": {
|
| 35 |
+
"temperature": self.temperature
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
response.raise_for_status()
|
| 40 |
+
result = response.json()
|
| 41 |
+
return self._result("local", result.get("response", "{}"))
|
| 42 |
+
except Exception as e:
|
| 43 |
+
return {
|
| 44 |
+
"agent_name": self.name,
|
| 45 |
+
"provider": "local",
|
| 46 |
+
"status": "error",
|
| 47 |
+
"error": f"Ollama connection failed: {str(e)}"
|
| 48 |
+
}
|
backend/agents/openai_agent.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseAgent
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
import openai
|
| 4 |
+
from services.config import settings, config_service
|
| 5 |
+
from tools.registry import tool_registry
|
| 6 |
+
|
| 7 |
+
class OpenAIAgent(BaseAgent):
|
| 8 |
+
def __init__(self, name: str, role: str, model: str = "gpt-4o", system_prompt: str = None):
|
| 9 |
+
super().__init__(name, role, model, system_prompt)
|
| 10 |
+
|
| 11 |
+
# Load dynamic config
|
| 12 |
+
self.provider_config = config_service.get_provider_config("openai")
|
| 13 |
+
api_key = self.provider_config.get("api_key") or settings.OPENAI_API_KEY
|
| 14 |
+
|
| 15 |
+
self.client = openai.AsyncOpenAI(api_key=api_key)
|
| 16 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 17 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 18 |
+
|
| 19 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 20 |
+
return await self._run_openai_compatible(
|
| 21 |
+
provider="openai",
|
| 22 |
+
create_fn=self.client.chat.completions.create,
|
| 23 |
+
task_description=task_description,
|
| 24 |
+
context=context,
|
| 25 |
+
use_tools=use_tools,
|
| 26 |
+
extra_context=extra_context,
|
| 27 |
+
response_format={"type": "json_object"}
|
| 28 |
+
)
|
backend/agents_debug.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[{"id": "297ef087-89af-4e3b-8d80-c5c5c7499e3b", "name": "GPT-4o", "role": "General Intelligence", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You are a highly capable AI assistant.", "created_at": "2026-05-04T14:32:03.072888+00:00", "updated_at": "2026-05-04T14:32:03.072888+00:00", "user_id": null}, {"id": "64aebf0c-625a-4c6f-895d-a36274c4f9fd", "name": "AMD-4o", "role": "Performance Specialist", "api_provider": "amd", "model": "gpt-4o", "system_prompt": "You are a high-performance agent running on AMD infrastructure.", "created_at": "2026-05-04T14:32:03.072888+00:00", "updated_at": "2026-05-04T14:32:03.072888+00:00", "user_id": null}, {"id": "f7cc5a82-1e7d-4f21-9855-922fb82cd6f9", "name": "Llama-3-70B", "role": "Fast Logic", "api_provider": "groq", "model": "llama3-70b-8192", "system_prompt": "You are a fast and efficient reasoning agent.", "created_at": "2026-05-04T14:32:03.072888+00:00", "updated_at": "2026-05-04T14:32:03.072888+00:00", "user_id": null}, {"id": "1d988b3c-38c1-4132-85e1-82e7a4bc4f8a", "name": "Growth Hacker", "role": "Marketing Expert", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You are a Growth Hacker focused on low-cost, high-impact strategies.", "created_at": "2026-05-04T15:50:52.628388+00:00", "updated_at": "2026-05-04T15:50:52.628388+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "7b056c90-f4a6-4629-81b1-f4316b76d091", "name": "Growth Hacker", "role": "Marketing Expert", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You are a Growth Hacker focused on low-cost, high-impact strategies.", "created_at": "2026-05-04T16:22:10.993123+00:00", "updated_at": "2026-05-04T16:22:10.993123+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "138d7d29-b2c0-4ffb-b89f-1a0965dca6b6", "name": "Planner", "role": "Project Planner", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You decompose goals into clear, ordered implementation tasks.", "created_at": "2026-05-04T18:06:05.280562+00:00", "updated_at": "2026-05-04T18:06:05.280562+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "edfc99cf-a70c-42e0-a2f6-4508bb6aac33", "name": "Builder", "role": "Implementation Agent", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You implement practical, production-oriented solutions with concise output.", "created_at": "2026-05-04T18:06:05.280562+00:00", "updated_at": "2026-05-04T18:06:05.280562+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "5ef6c6f3-fc4a-40ba-abe8-7630fefcece2", "name": "Reviewer", "role": "Quality Reviewer", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You review outputs for correctness, security, completeness, and missing tests.", "created_at": "2026-05-04T18:06:05.280562+00:00", "updated_at": "2026-05-04T18:06:05.280562+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}]
|
backend/api/index.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from main import app
|
backend/main.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse, Response
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
import sentry_sdk
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _load_app_version() -> str:
|
| 12 |
+
version_file = Path(__file__).resolve().parent.parent / "VERSION"
|
| 13 |
+
if version_file.exists():
|
| 14 |
+
value = version_file.read_text(encoding="utf-8").strip()
|
| 15 |
+
if value:
|
| 16 |
+
return value
|
| 17 |
+
return os.getenv("APP_VERSION", "0.7.0")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Load environment variables
|
| 21 |
+
load_dotenv()
|
| 22 |
+
FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 23 |
+
APP_VERSION = _load_app_version()
|
| 24 |
+
|
| 25 |
+
# Sentry Initialization
|
| 26 |
+
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
| 27 |
+
if SENTRY_DSN:
|
| 28 |
+
sentry_sdk.init(
|
| 29 |
+
dsn=SENTRY_DSN,
|
| 30 |
+
traces_sample_rate=1.0,
|
| 31 |
+
profiles_sample_rate=1.0,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
app = FastAPI(
|
| 35 |
+
title="Aubm API",
|
| 36 |
+
description="Enterprise-Grade AI Agent Orchestration & Collaboration Platform",
|
| 37 |
+
version=APP_VERSION
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# CORS Configuration
|
| 41 |
+
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173").split(",")
|
| 42 |
+
app.add_middleware(
|
| 43 |
+
CORSMiddleware,
|
| 44 |
+
allow_origins=allowed_origins if allowed_origins != ["*"] else ["*"],
|
| 45 |
+
allow_origin_regex=os.getenv("ALLOWED_ORIGIN_REGEX"),
|
| 46 |
+
allow_credentials=True,
|
| 47 |
+
allow_methods=["*"],
|
| 48 |
+
allow_headers=["*"],
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
@app.get("/")
|
| 52 |
+
async def root():
|
| 53 |
+
index_path = FRONTEND_DIST / "index.html"
|
| 54 |
+
if index_path.exists():
|
| 55 |
+
return FileResponse(index_path)
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"status": "online",
|
| 59 |
+
"message": "Aubm API is operational",
|
| 60 |
+
"version": APP_VERSION
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
# Placeholder for routers
|
| 64 |
+
from routers import agent_runner, orchestrator, monitoring
|
| 65 |
+
|
| 66 |
+
app.include_router(agent_runner.router, prefix="/tasks", tags=["Tasks"])
|
| 67 |
+
app.include_router(orchestrator.router, prefix="/orchestrator", tags=["Orchestration"])
|
| 68 |
+
app.include_router(monitoring.router, prefix="/monitoring", tags=["Monitoring"])
|
| 69 |
+
|
| 70 |
+
@app.get("/runtime-config.js", include_in_schema=False)
|
| 71 |
+
async def runtime_config():
|
| 72 |
+
config = {
|
| 73 |
+
"apiUrl": os.getenv("VITE_API_URL", ""),
|
| 74 |
+
"supabaseUrl": os.getenv("VITE_SUPABASE_URL", os.getenv("SUPABASE_URL", "")),
|
| 75 |
+
"supabaseAnonKey": os.getenv("VITE_SUPABASE_ANON_KEY", os.getenv("SUPABASE_ANON_KEY", "")),
|
| 76 |
+
"sentryDsn": os.getenv("VITE_SENTRY_DSN", os.getenv("SENTRY_DSN", "")),
|
| 77 |
+
"appVersion": APP_VERSION,
|
| 78 |
+
}
|
| 79 |
+
return Response(
|
| 80 |
+
content=f"window.__AUBM_CONFIG__ = {json.dumps(config)};",
|
| 81 |
+
media_type="application/javascript",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
@app.get("/{path:path}", include_in_schema=False)
|
| 85 |
+
async def serve_frontend(path: str):
|
| 86 |
+
if not FRONTEND_DIST.exists():
|
| 87 |
+
return await root()
|
| 88 |
+
|
| 89 |
+
requested_path = FRONTEND_DIST / path
|
| 90 |
+
if requested_path.is_file():
|
| 91 |
+
return FileResponse(requested_path)
|
| 92 |
+
|
| 93 |
+
index_path = FRONTEND_DIST / "index.html"
|
| 94 |
+
if index_path.exists():
|
| 95 |
+
return FileResponse(index_path)
|
| 96 |
+
|
| 97 |
+
return await root()
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
import uvicorn
|
| 101 |
+
from services.config import settings
|
| 102 |
+
uvicorn.run("main:app", host="0.0.0.0", port=settings.PORT, reload=True)
|
backend/project_debug.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"id": "53f39562-09aa-447b-b251-3844e1415d4c", "name": "Commercial analysis", "description": "I want to evaluate differents applications similar to this app", "context": "search in internet", "owner_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578", "status": "active", "is_public": true, "created_at": "2026-05-04T16:38:03.872534+00:00", "updated_at": "2026-05-04T16:38:03.872534+00:00", "team_id": null}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
supabase
|
| 4 |
+
openai
|
| 5 |
+
groq
|
| 6 |
+
google-genai
|
| 7 |
+
playwright
|
| 8 |
+
folium
|
| 9 |
+
python-dotenv
|
| 10 |
+
pydantic
|
| 11 |
+
pydantic-settings
|
| 12 |
+
httpx
|
| 13 |
+
jinja2
|
| 14 |
+
python-multipart
|
| 15 |
+
reportlab
|
| 16 |
+
pandas
|
| 17 |
+
openpyxl
|
| 18 |
+
psutil
|
| 19 |
+
sentry-sdk[fastapi]
|
backend/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routers package
|
backend/routers/agent_runner.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
| 2 |
+
from services.supabase_service import supabase
|
| 3 |
+
from services.agent_runner_service import AgentRunnerService
|
| 4 |
+
from services.output_quality import report_text_from_output
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
logger = logging.getLogger("uvicorn")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _assert_task_quality(task: dict):
|
| 12 |
+
output_data = task.get("output_data") or {}
|
| 13 |
+
if not isinstance(output_data, dict):
|
| 14 |
+
raise HTTPException(status_code=400, detail="Task output is missing or malformed.")
|
| 15 |
+
if output_data.get("error"):
|
| 16 |
+
raise HTTPException(status_code=400, detail=f"Task execution failed: {output_data['error']}")
|
| 17 |
+
rendered = report_text_from_output(output_data).strip()
|
| 18 |
+
if not rendered or rendered in ("{}", "[]"):
|
| 19 |
+
raise HTTPException(status_code=400, detail="Task has no usable output to approve.")
|
| 20 |
+
quality_review = output_data.get("quality_review")
|
| 21 |
+
if not quality_review:
|
| 22 |
+
raise HTTPException(status_code=400, detail="Task output is missing quality validation.")
|
| 23 |
+
if quality_review.get("approved"):
|
| 24 |
+
return
|
| 25 |
+
reasons = quality_review.get("fail_reasons") or ["Task output failed quality validation."]
|
| 26 |
+
raise HTTPException(status_code=400, detail=f"Task output failed quality review: {'; '.join(reasons)}")
|
| 27 |
+
|
| 28 |
+
def update_task_status(task_id: str, status: str):
|
| 29 |
+
result = (
|
| 30 |
+
supabase.table("tasks")
|
| 31 |
+
.update({"status": status})
|
| 32 |
+
.eq("id", task_id)
|
| 33 |
+
.execute()
|
| 34 |
+
)
|
| 35 |
+
if not result.data:
|
| 36 |
+
raise HTTPException(status_code=404, detail="Task not found or status was not updated")
|
| 37 |
+
|
| 38 |
+
task_data = result.data[0]
|
| 39 |
+
|
| 40 |
+
project_id = task_data.get("project_id")
|
| 41 |
+
if project_id:
|
| 42 |
+
task_result = (
|
| 43 |
+
supabase.table("tasks")
|
| 44 |
+
.select("id,status")
|
| 45 |
+
.eq("project_id", project_id)
|
| 46 |
+
.execute()
|
| 47 |
+
)
|
| 48 |
+
tasks = task_result.data or []
|
| 49 |
+
if status == "done" and tasks and all(task.get("status") == "done" for task in tasks):
|
| 50 |
+
supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute()
|
| 51 |
+
elif status != "done":
|
| 52 |
+
supabase.table("projects").update({"status": "active"}).eq("id", project_id).execute()
|
| 53 |
+
|
| 54 |
+
return task_data
|
| 55 |
+
|
| 56 |
+
@router.post("/{task_id}/run")
|
| 57 |
+
async def run_task(task_id: str, background_tasks: BackgroundTasks):
|
| 58 |
+
"""
|
| 59 |
+
Triggers the execution of a specific task.
|
| 60 |
+
"""
|
| 61 |
+
# 1. Fetch task data
|
| 62 |
+
task_res = supabase.table("tasks").select("*, project:projects(*)").eq("id", task_id).single().execute()
|
| 63 |
+
if not task_res.data:
|
| 64 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 65 |
+
|
| 66 |
+
task = task_res.data
|
| 67 |
+
|
| 68 |
+
# 2. Check if agent is assigned
|
| 69 |
+
agent_id = task.get("assigned_agent_id")
|
| 70 |
+
if not agent_id:
|
| 71 |
+
raise HTTPException(status_code=400, detail="No agent assigned to this task")
|
| 72 |
+
|
| 73 |
+
# 3. Fetch agent data
|
| 74 |
+
agent_res = supabase.table("agents").select("*").eq("id", agent_id).single().execute()
|
| 75 |
+
if not agent_res.data:
|
| 76 |
+
raise HTTPException(status_code=404, detail="Assigned agent not found")
|
| 77 |
+
|
| 78 |
+
agent_data = agent_res.data
|
| 79 |
+
|
| 80 |
+
# 4. Update task status to in_progress
|
| 81 |
+
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 82 |
+
|
| 83 |
+
# 5. Run in background
|
| 84 |
+
background_tasks.add_task(AgentRunnerService.execute_agent_logic, task, agent_data)
|
| 85 |
+
|
| 86 |
+
return {"message": "Task execution started", "task_id": task_id}
|
| 87 |
+
|
| 88 |
+
@router.post("/{task_id}/approve")
|
| 89 |
+
async def approve_task(task_id: str):
|
| 90 |
+
task_res = supabase.table("tasks").select("*").eq("id", task_id).single().execute()
|
| 91 |
+
if not task_res.data:
|
| 92 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 93 |
+
_assert_task_quality(task_res.data)
|
| 94 |
+
task = update_task_status(task_id, "done")
|
| 95 |
+
return {"message": "Task approved", "task": task}
|
| 96 |
+
|
| 97 |
+
@router.post("/{task_id}/reject")
|
| 98 |
+
async def reject_task(task_id: str):
|
| 99 |
+
task = update_task_status(task_id, "todo")
|
| 100 |
+
return {"message": "Task rejected", "task": task}
|
| 101 |
+
@router.post("/project/{project_id}/approve-all")
|
| 102 |
+
async def approve_all_tasks(project_id: str):
|
| 103 |
+
"""
|
| 104 |
+
Approves all tasks in a project that are awaiting approval.
|
| 105 |
+
"""
|
| 106 |
+
waiting_tasks = (
|
| 107 |
+
supabase.table("tasks")
|
| 108 |
+
.select("*")
|
| 109 |
+
.eq("project_id", project_id)
|
| 110 |
+
.eq("status", "awaiting_approval")
|
| 111 |
+
.execute()
|
| 112 |
+
.data
|
| 113 |
+
or []
|
| 114 |
+
)
|
| 115 |
+
blocked = []
|
| 116 |
+
approvable_ids = []
|
| 117 |
+
for task in waiting_tasks:
|
| 118 |
+
try:
|
| 119 |
+
_assert_task_quality(task)
|
| 120 |
+
approvable_ids.append(task["id"])
|
| 121 |
+
except HTTPException:
|
| 122 |
+
blocked.append(task["title"])
|
| 123 |
+
|
| 124 |
+
# 1. Update tasks
|
| 125 |
+
result_data = []
|
| 126 |
+
if approvable_ids:
|
| 127 |
+
result = (
|
| 128 |
+
supabase.table("tasks")
|
| 129 |
+
.update({"status": "done"})
|
| 130 |
+
.eq("project_id", project_id)
|
| 131 |
+
.in_("id", approvable_ids)
|
| 132 |
+
.execute()
|
| 133 |
+
)
|
| 134 |
+
result_data = result.data or []
|
| 135 |
+
|
| 136 |
+
# 2. Check if all tasks in project are now done
|
| 137 |
+
task_result = (
|
| 138 |
+
supabase.table("tasks")
|
| 139 |
+
.select("status")
|
| 140 |
+
.eq("project_id", project_id)
|
| 141 |
+
.execute()
|
| 142 |
+
)
|
| 143 |
+
tasks = task_result.data or []
|
| 144 |
+
if tasks and all(task.get("status") == "done" for task in tasks):
|
| 145 |
+
supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute()
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"message": f"Approved {len(result_data)} tasks",
|
| 149 |
+
"count": len(result_data),
|
| 150 |
+
"blocked": blocked
|
| 151 |
+
}
|
backend/routers/monitoring.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
from services.supabase_service import supabase
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _count_table(table_name: str) -> int:
|
| 9 |
+
response = supabase.table(table_name).select("id", count="exact").limit(1).execute()
|
| 10 |
+
return response.count or 0
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/summary")
|
| 14 |
+
async def monitoring_summary():
|
| 15 |
+
"""
|
| 16 |
+
Lightweight operational summary for dashboards and uptime checks.
|
| 17 |
+
"""
|
| 18 |
+
checks = {
|
| 19 |
+
"api": "ok",
|
| 20 |
+
"database": "ok",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
counts = {
|
| 24 |
+
"projects": 0,
|
| 25 |
+
"tasks": 0,
|
| 26 |
+
"agents": 0,
|
| 27 |
+
"task_runs": 0,
|
| 28 |
+
"failed_tasks": 0,
|
| 29 |
+
"pending_reviews": 0,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
counts["projects"] = _count_table("projects")
|
| 34 |
+
counts["tasks"] = _count_table("tasks")
|
| 35 |
+
counts["agents"] = _count_table("agents")
|
| 36 |
+
counts["task_runs"] = _count_table("task_runs")
|
| 37 |
+
counts["failed_tasks"] = (
|
| 38 |
+
supabase.table("tasks")
|
| 39 |
+
.select("id", count="exact")
|
| 40 |
+
.eq("status", "failed")
|
| 41 |
+
.limit(1)
|
| 42 |
+
.execute()
|
| 43 |
+
.count
|
| 44 |
+
or 0
|
| 45 |
+
)
|
| 46 |
+
counts["pending_reviews"] = (
|
| 47 |
+
supabase.table("tasks")
|
| 48 |
+
.select("id", count="exact")
|
| 49 |
+
.eq("status", "awaiting_approval")
|
| 50 |
+
.limit(1)
|
| 51 |
+
.execute()
|
| 52 |
+
.count
|
| 53 |
+
or 0
|
| 54 |
+
)
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
checks["database"] = "error"
|
| 57 |
+
return {
|
| 58 |
+
"status": "degraded",
|
| 59 |
+
"checks": checks,
|
| 60 |
+
"counts": counts,
|
| 61 |
+
"error": str(exc),
|
| 62 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"status": "ok",
|
| 67 |
+
"checks": checks,
|
| 68 |
+
"counts": counts,
|
| 69 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 70 |
+
}
|
backend/routers/orchestrator.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
| 2 |
+
from fastapi.responses import Response
|
| 3 |
+
from services.orchestrator_service import orchestrator_service
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from reportlab.lib.pagesizes import letter
|
| 7 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 8 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 9 |
+
from reportlab.graphics.shapes import Drawing
|
| 10 |
+
from reportlab.graphics.charts.barcharts import VerticalBarChart
|
| 11 |
+
from reportlab.graphics.charts.piecharts import Pie
|
| 12 |
+
from reportlab.lib import colors
|
| 13 |
+
from reportlab.lib.units import inch
|
| 14 |
+
import re
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
def _safe_filename(value: str) -> str:
|
| 19 |
+
return re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_").lower() or "report"
|
| 20 |
+
|
| 21 |
+
def _bar_chart(title: str, rows: list[dict]) -> Drawing:
|
| 22 |
+
drawing = Drawing(460, 180)
|
| 23 |
+
chart = VerticalBarChart()
|
| 24 |
+
chart.x = 40
|
| 25 |
+
chart.y = 35
|
| 26 |
+
chart.height = 110
|
| 27 |
+
chart.width = 380
|
| 28 |
+
chart.data = [[row["value"] for row in rows]]
|
| 29 |
+
chart.categoryAxis.categoryNames = [row["label"] for row in rows]
|
| 30 |
+
chart.valueAxis.valueMin = 0
|
| 31 |
+
chart.valueAxis.valueMax = max([row["value"] for row in rows] + [1])
|
| 32 |
+
chart.valueAxis.valueStep = max(1, round(chart.valueAxis.valueMax / 4))
|
| 33 |
+
chart.bars[0].fillColor = colors.HexColor("#14b8a6")
|
| 34 |
+
drawing.add(chart)
|
| 35 |
+
return drawing
|
| 36 |
+
|
| 37 |
+
def _pie_chart(rows: list[dict]) -> Drawing:
|
| 38 |
+
drawing = Drawing(460, 180)
|
| 39 |
+
pie = Pie()
|
| 40 |
+
pie.x = 150
|
| 41 |
+
pie.y = 20
|
| 42 |
+
pie.width = 140
|
| 43 |
+
pie.height = 140
|
| 44 |
+
pie.data = [row["value"] for row in rows]
|
| 45 |
+
pie.labels = [row["label"] for row in rows]
|
| 46 |
+
pie.slices.strokeWidth = 0.5
|
| 47 |
+
palette = [colors.HexColor("#22c55e"), colors.HexColor("#facc15"), colors.HexColor("#ef4444")]
|
| 48 |
+
for index, color in enumerate(palette[:len(rows)]):
|
| 49 |
+
pie.slices[index].fillColor = color
|
| 50 |
+
drawing.add(pie)
|
| 51 |
+
return drawing
|
| 52 |
+
|
| 53 |
+
def _report_pdf_bytes(title: str, content: str, charts: dict | None = None) -> bytes:
|
| 54 |
+
buffer = BytesIO()
|
| 55 |
+
doc = SimpleDocTemplate(
|
| 56 |
+
buffer,
|
| 57 |
+
pagesize=letter,
|
| 58 |
+
rightMargin=0.7 * inch,
|
| 59 |
+
leftMargin=0.7 * inch,
|
| 60 |
+
topMargin=0.7 * inch,
|
| 61 |
+
bottomMargin=0.7 * inch,
|
| 62 |
+
)
|
| 63 |
+
styles = getSampleStyleSheet()
|
| 64 |
+
story = [Paragraph(title, styles["Title"]), Spacer(1, 0.2 * inch)]
|
| 65 |
+
if charts:
|
| 66 |
+
story.append(Paragraph("Project Execution Summary", styles["Heading2"]))
|
| 67 |
+
story.append(Spacer(1, 0.1 * inch))
|
| 68 |
+
|
| 69 |
+
# Summary Table instead of charts
|
| 70 |
+
table_data = [["Metric / Category", "Value"]]
|
| 71 |
+
|
| 72 |
+
# Tasks Status
|
| 73 |
+
status_counts = {row["label"]: row["value"] for row in charts.get("status", [])}
|
| 74 |
+
for label, val in status_counts.items():
|
| 75 |
+
table_data.append([f"Tasks: {label}", str(val)])
|
| 76 |
+
|
| 77 |
+
# Categories
|
| 78 |
+
for cat in charts.get("categories", []):
|
| 79 |
+
table_data.append([f"Type: {cat['label']}", str(cat['value'])])
|
| 80 |
+
|
| 81 |
+
table = Table(table_data, colWidths=[3.5*inch, 1.5*inch])
|
| 82 |
+
table.setStyle(TableStyle([
|
| 83 |
+
('BACKGROUND', (0,0), (-1,0), colors.HexColor("#6e59ff")),
|
| 84 |
+
('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke),
|
| 85 |
+
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
| 86 |
+
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
|
| 87 |
+
('BOTTOMPADDING', (0,0), (-1,0), 10),
|
| 88 |
+
('BACKGROUND', (0,1), (-1,-1), colors.HexColor("#f8fafc")),
|
| 89 |
+
('GRID', (0,0), (-1,-1), 0.5, colors.grey),
|
| 90 |
+
('FONTSIZE', (0,0), (-1,-1), 9),
|
| 91 |
+
]))
|
| 92 |
+
story.append(table)
|
| 93 |
+
story.append(Spacer(1, 0.3 * inch))
|
| 94 |
+
|
| 95 |
+
for raw_line in content.splitlines():
|
| 96 |
+
line = raw_line.strip()
|
| 97 |
+
if not line:
|
| 98 |
+
story.append(Spacer(1, 0.1 * inch))
|
| 99 |
+
continue
|
| 100 |
+
if line.startswith("# "):
|
| 101 |
+
story.append(Paragraph(line[2:], styles["Title"]))
|
| 102 |
+
elif line.startswith("## "):
|
| 103 |
+
story.append(Paragraph(line[3:], styles["Heading2"]))
|
| 104 |
+
elif line.startswith("### "):
|
| 105 |
+
story.append(Paragraph(line[4:], styles["Heading3"]))
|
| 106 |
+
elif line.startswith("- "):
|
| 107 |
+
story.append(Paragraph(f"• {line[2:]}", styles["BodyText"]))
|
| 108 |
+
else:
|
| 109 |
+
story.append(Paragraph(line, styles["BodyText"]))
|
| 110 |
+
|
| 111 |
+
doc.build(story)
|
| 112 |
+
return buffer.getvalue()
|
| 113 |
+
|
| 114 |
+
class DebateRequest(BaseModel):
|
| 115 |
+
|
| 116 |
+
task_id: str
|
| 117 |
+
agent_a_id: str
|
| 118 |
+
agent_b_id: str
|
| 119 |
+
|
| 120 |
+
@router.post("/debate")
|
| 121 |
+
async def start_debate(request: DebateRequest, background_tasks: BackgroundTasks):
|
| 122 |
+
"""
|
| 123 |
+
Starts a debate between two agents for a specific task.
|
| 124 |
+
"""
|
| 125 |
+
background_tasks.add_task(
|
| 126 |
+
orchestrator_service.run_debate,
|
| 127 |
+
request.task_id,
|
| 128 |
+
request.agent_a_id,
|
| 129 |
+
request.agent_b_id
|
| 130 |
+
)
|
| 131 |
+
return {"message": "Debate started in background"}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.post("/projects/{project_id}/run")
|
| 135 |
+
async def run_project_orchestrator(project_id: str, background_tasks: BackgroundTasks):
|
| 136 |
+
"""
|
| 137 |
+
Runs all queued tasks for a project in priority order.
|
| 138 |
+
"""
|
| 139 |
+
background_tasks.add_task(orchestrator_service.run_project, project_id)
|
| 140 |
+
return {"message": "Project orchestrator started", "project_id": project_id}
|
| 141 |
+
|
| 142 |
+
@router.get("/projects/{project_id}/final-report")
|
| 143 |
+
async def get_project_final_report(project_id: str, variant: str = "full"):
|
| 144 |
+
"""
|
| 145 |
+
Builds a consolidated report from all approved task outputs.
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
return await orchestrator_service.build_final_report(project_id, variant)
|
| 149 |
+
except ValueError as exc:
|
| 150 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 151 |
+
|
| 152 |
+
@router.get("/projects/{project_id}/final-report.pdf")
|
| 153 |
+
async def download_project_final_report_pdf(project_id: str, variant: str = "full"):
|
| 154 |
+
"""
|
| 155 |
+
Downloads the selected report variant as a PDF.
|
| 156 |
+
"""
|
| 157 |
+
try:
|
| 158 |
+
result = await orchestrator_service.build_final_report(project_id, variant)
|
| 159 |
+
except ValueError as exc:
|
| 160 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 161 |
+
|
| 162 |
+
title = f"{result['project_name']} - {result['variant']} report"
|
| 163 |
+
pdf = _report_pdf_bytes(title, result["report"], result.get("charts"))
|
| 164 |
+
filename = f"{_safe_filename(result['project_name'])}_{_safe_filename(result['variant'])}.pdf"
|
| 165 |
+
return Response(
|
| 166 |
+
content=pdf,
|
| 167 |
+
media_type="application/pdf",
|
| 168 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
| 169 |
+
)
|
backend/scratch/check_db.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 8 |
+
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 9 |
+
supabase = create_client(supabase_url, supabase_key)
|
| 10 |
+
|
| 11 |
+
def check_logs():
|
| 12 |
+
try:
|
| 13 |
+
res = supabase.table("agent_logs").select("*").order("created_at", desc=True).limit(20).execute()
|
| 14 |
+
print(f"Total logs retrieved: {len(res.data)}")
|
| 15 |
+
for log in res.data:
|
| 16 |
+
print(f"[{log['created_at']}] {log['action']}: {log['content'][:50]}...")
|
| 17 |
+
|
| 18 |
+
except Exception as e:
|
| 19 |
+
print(f"Error accessing agent_logs: {e}")
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
check_logs()
|
backend/scratch/create_comparison_project.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 8 |
+
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 9 |
+
supabase = create_client(supabase_url, supabase_key)
|
| 10 |
+
|
| 11 |
+
def create_project():
|
| 12 |
+
try:
|
| 13 |
+
# 1. Try to get owner_id from existing projects first
|
| 14 |
+
existing_projects = supabase.table("projects").select("owner_id").limit(1).execute()
|
| 15 |
+
user_id = None
|
| 16 |
+
if existing_projects.data and existing_projects.data[0].get("owner_id"):
|
| 17 |
+
user_id = existing_projects.data[0]["owner_id"]
|
| 18 |
+
|
| 19 |
+
if not user_id:
|
| 20 |
+
# Fallback to profiles
|
| 21 |
+
users = supabase.table("profiles").select("id").limit(1).execute()
|
| 22 |
+
if users.data:
|
| 23 |
+
user_id = users.data[0]["id"]
|
| 24 |
+
|
| 25 |
+
if not user_id:
|
| 26 |
+
print("No valid owner_id found in projects or profiles. The project will be created without owner and might not be visible.")
|
| 27 |
+
else:
|
| 28 |
+
print(f"Using owner_id: {user_id}")
|
| 29 |
+
|
| 30 |
+
# 2. Create Project
|
| 31 |
+
project_data = {
|
| 32 |
+
"name": "Aubm Competitor Analysis",
|
| 33 |
+
"description": "Deep dive into the multi-agent orchestration market to identify Aubm's unique value proposition and feature gaps.",
|
| 34 |
+
"status": "active",
|
| 35 |
+
"context": "Focus on developer experience, visual observability, and the 'Agent Debate' mechanism as key differentiators."
|
| 36 |
+
}
|
| 37 |
+
if user_id:
|
| 38 |
+
project_data["owner_id"] = user_id
|
| 39 |
+
|
| 40 |
+
project_res = supabase.table("projects").insert(project_data).execute()
|
| 41 |
+
project_id = project_res.data[0]["id"]
|
| 42 |
+
print(f"Created Project: {project_id}")
|
| 43 |
+
|
| 44 |
+
# 3. Create Tasks
|
| 45 |
+
tasks = [
|
| 46 |
+
{"title": "Identify Top 5 Competitors", "description": "Research and list 5 similar multi-agent orchestration platforms (e.g., CrewAI, AutoGen, LangGraph, PydanticAI).", "status": "todo", "project_id": project_id},
|
| 47 |
+
{"title": "Feature Comparison Matrix", "description": "Create a detailed matrix comparing Aubm's core features (Project Decomposition, Agent Debate, Real-time Console) against identified competitors.", "status": "todo", "project_id": project_id},
|
| 48 |
+
{"title": "Pricing Model Analysis", "description": "Analyze how competitors charge (SaaS, Open Source, API usage) and recommend a competitive strategy for Aubm.", "status": "todo", "project_id": project_id},
|
| 49 |
+
{"title": "UI/UX Aesthetic Audit", "description": "Evaluate the visual aesthetics and ease of use of competitors compared to Aubm's premium dashboard. Look for glassmorphism and animations.", "status": "todo", "project_id": project_id},
|
| 50 |
+
{"title": "Technical Architecture Deep-Dive", "description": "Investigate the underlying tech stacks (Python vs TS, Vector DBs used, Orchestration logic) of top competitors.", "status": "todo", "project_id": project_id},
|
| 51 |
+
{"title": "SWOT Analysis & Strategy Report", "description": "Compile all findings into a comprehensive report with a SWOT analysis and strategic recommendations for the next 6 months.", "status": "todo", "project_id": project_id}
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
supabase.table("tasks").insert(tasks).execute()
|
| 55 |
+
print(f"Added {len(tasks)} tasks to the project.")
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"Error: {e}")
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
create_project()
|
backend/scratch/find_user.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 8 |
+
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 9 |
+
supabase = create_client(supabase_url, supabase_key)
|
| 10 |
+
|
| 11 |
+
def check_users():
|
| 12 |
+
# Try different tables where users might be
|
| 13 |
+
tables = ["profiles", "users", "team_members"]
|
| 14 |
+
for table in tables:
|
| 15 |
+
try:
|
| 16 |
+
res = supabase.table(table).select("id").limit(1).execute()
|
| 17 |
+
print(f"Table {table} count: {len(res.data)}")
|
| 18 |
+
if res.data:
|
| 19 |
+
print(f"Sample ID: {res.data[0]['id']}")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
print(f"Error checking {table}: {e}")
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
check_users()
|
backend/scratch/fix_logs_rls.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 8 |
+
supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 9 |
+
supabase = create_client(supabase_url, supabase_key)
|
| 10 |
+
|
| 11 |
+
sql = """
|
| 12 |
+
ALTER TABLE agent_logs ENABLE ROW LEVEL SECURITY;
|
| 13 |
+
DROP POLICY IF EXISTS "Enable read access for all users" ON agent_logs;
|
| 14 |
+
CREATE POLICY "Enable read access for all users" ON agent_logs FOR SELECT USING (true);
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
# Note: This assumes an 'exec_sql' RPC exists, which is common in many setups.
|
| 18 |
+
# If not, I'll have to find another way.
|
| 19 |
+
try:
|
| 20 |
+
# Actually, let's try a different approach if RPC fails.
|
| 21 |
+
# We can try to use the REST API to check if it works.
|
| 22 |
+
print("Attempting to set RLS policy...")
|
| 23 |
+
# Since I don't have direct SQL access via the client without RPC,
|
| 24 |
+
# I'll assume the user might need to do this in the dashboard or I'll try to find an RPC.
|
| 25 |
+
|
| 26 |
+
# Let's check if the client can read with anon key.
|
| 27 |
+
anon_key = os.getenv("SUPABASE_ANON_KEY")
|
| 28 |
+
anon_s = create_client(supabase_url, anon_key)
|
| 29 |
+
res = anon_s.table("agent_logs").select("*").limit(1).execute()
|
| 30 |
+
print(f"Anon read test: {'Success' if not res.data else 'Empty/Restricted'}")
|
| 31 |
+
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"Error: {e}")
|
backend/services/agent_runner_service.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from services.supabase_service import supabase
|
| 4 |
+
from services.audit_service import audit_service
|
| 5 |
+
from agents.agent_factory import AgentFactory
|
| 6 |
+
from services.semantic_backprop import semantic_backprop
|
| 7 |
+
from services.output_quality import build_quality_instructions, validate_output
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger("agent_runner_service")
|
| 10 |
+
|
| 11 |
+
class AgentRunnerService:
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def run_agent_task(
|
| 14 |
+
task: dict,
|
| 15 |
+
agent_data: dict,
|
| 16 |
+
*,
|
| 17 |
+
include_semantic_context: bool = False,
|
| 18 |
+
start_action: str = "execution_start",
|
| 19 |
+
start_content: str | None = None,
|
| 20 |
+
complete_action: str = "execution_complete",
|
| 21 |
+
complete_content: str = "Agent successfully completed the task and produced output.",
|
| 22 |
+
update_task: bool = True
|
| 23 |
+
) -> tuple[dict, str]:
|
| 24 |
+
task_id = task["id"]
|
| 25 |
+
project_id = task["project_id"]
|
| 26 |
+
run_id = None
|
| 27 |
+
|
| 28 |
+
if update_task:
|
| 29 |
+
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
run_res = supabase.table("task_runs").insert({
|
| 33 |
+
"task_id": task_id,
|
| 34 |
+
"agent_id": agent_data["id"],
|
| 35 |
+
"status": "running"
|
| 36 |
+
}).execute()
|
| 37 |
+
run_id = run_res.data[0]["id"]
|
| 38 |
+
|
| 39 |
+
agent = AgentFactory.get_agent(
|
| 40 |
+
provider=agent_data["api_provider"],
|
| 41 |
+
name=agent_data["name"],
|
| 42 |
+
role=agent_data["role"],
|
| 43 |
+
model=agent_data["model"],
|
| 44 |
+
system_prompt=agent_data.get("system_prompt")
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
context_res = supabase.table("tasks").select("title, output_data") \
|
| 48 |
+
.eq("project_id", project_id) \
|
| 49 |
+
.eq("status", "done") \
|
| 50 |
+
.execute()
|
| 51 |
+
context = context_res.data if context_res.data else []
|
| 52 |
+
|
| 53 |
+
project_data = task.get("project")
|
| 54 |
+
if not isinstance(project_data, dict):
|
| 55 |
+
project_res = (
|
| 56 |
+
supabase.table("projects")
|
| 57 |
+
.select("name,description,context")
|
| 58 |
+
.eq("id", project_id)
|
| 59 |
+
.single()
|
| 60 |
+
.execute()
|
| 61 |
+
)
|
| 62 |
+
project_data = project_res.data if project_res and project_res.data else {}
|
| 63 |
+
quality_task = {**task, "project": project_data}
|
| 64 |
+
|
| 65 |
+
extra_context = ""
|
| 66 |
+
if include_semantic_context:
|
| 67 |
+
extra_context = await semantic_backprop.get_project_context(project_id, task_id)
|
| 68 |
+
|
| 69 |
+
import time
|
| 70 |
+
import hashlib
|
| 71 |
+
|
| 72 |
+
# Simple in-memory cache for the session (could be persistent later)
|
| 73 |
+
if not hasattr(AgentRunnerService, "_task_cache"):
|
| 74 |
+
AgentRunnerService._task_cache = {}
|
| 75 |
+
|
| 76 |
+
# 1. Create a cache key based on task, agent (model + system prompt), and context
|
| 77 |
+
cache_input = f"{task['id']}-{agent_data['model']}-{agent_data.get('system_prompt', '')}-{task.get('description')}-{str(context)}-{extra_context}"
|
| 78 |
+
cache_key = hashlib.md5(cache_input.encode()).hexdigest()
|
| 79 |
+
|
| 80 |
+
# 2. Check Cache
|
| 81 |
+
if cache_key in AgentRunnerService._task_cache:
|
| 82 |
+
logger.info(f"Cache hit for task {task_id}. Skipping LLM call.")
|
| 83 |
+
cached_result = AgentRunnerService._task_cache[cache_key]
|
| 84 |
+
|
| 85 |
+
# Still log the "start" for UI consistency
|
| 86 |
+
agent_name = agent_data.get('name', 'Agent')
|
| 87 |
+
log_msg = start_content or f"Agent {agent_name} resuming task"
|
| 88 |
+
supabase.table("agent_logs").insert({
|
| 89 |
+
"task_id": task_id,
|
| 90 |
+
"run_id": run_id,
|
| 91 |
+
"action": start_action,
|
| 92 |
+
"content": f"[CACHE HIT] {log_msg}"
|
| 93 |
+
}).execute()
|
| 94 |
+
|
| 95 |
+
if update_task:
|
| 96 |
+
supabase.table("tasks").update({
|
| 97 |
+
"status": "awaiting_approval",
|
| 98 |
+
"output_data": cached_result
|
| 99 |
+
}).eq("id", task_id).execute()
|
| 100 |
+
|
| 101 |
+
return cached_result, run_id
|
| 102 |
+
|
| 103 |
+
# 3. Log Start
|
| 104 |
+
supabase.table("agent_logs").insert({
|
| 105 |
+
"task_id": task_id,
|
| 106 |
+
"run_id": run_id,
|
| 107 |
+
"action": start_action,
|
| 108 |
+
"content": start_content or f"Agent {agent_data['name']} starting task: {task['title']}"
|
| 109 |
+
}).execute()
|
| 110 |
+
|
| 111 |
+
# 4. Execute Run with timing
|
| 112 |
+
start_time = time.time()
|
| 113 |
+
task_instructions = task.get("description") or task["title"]
|
| 114 |
+
task_instructions = f"{task_instructions}\n\n{build_quality_instructions(quality_task)}"
|
| 115 |
+
result = await agent.run(task_instructions, context, extra_context=extra_context)
|
| 116 |
+
duration = time.time() - start_time
|
| 117 |
+
|
| 118 |
+
if result.get("status") == "error":
|
| 119 |
+
raise RuntimeError(result.get("error") or "Agent returned an error result.")
|
| 120 |
+
|
| 121 |
+
# 5. Security Sanitization (Defense in Depth)
|
| 122 |
+
raw_out = str(result.get("raw_output", ""))
|
| 123 |
+
suspicious_patterns = ["rm -rf", "mkfs", "dd if=", "curl", "wget", "chmod 777", "> /dev/sda"]
|
| 124 |
+
for pattern in suspicious_patterns:
|
| 125 |
+
if pattern in raw_out:
|
| 126 |
+
logger.warning(f"SECURITY: Suspicious pattern '{pattern}' detected in agent output for task {task_id}.")
|
| 127 |
+
result["security_warning"] = f"Output sanitized: suspicious pattern '{pattern}' detected."
|
| 128 |
+
# We don't block yet, but we flag it.
|
| 129 |
+
|
| 130 |
+
quality_review = validate_output(quality_task, result)
|
| 131 |
+
result["quality_review"] = quality_review
|
| 132 |
+
|
| 133 |
+
# 6. Save to Cache
|
| 134 |
+
AgentRunnerService._task_cache[cache_key] = result
|
| 135 |
+
|
| 136 |
+
if update_task:
|
| 137 |
+
supabase.table("tasks").update({
|
| 138 |
+
"status": "awaiting_approval",
|
| 139 |
+
"output_data": result
|
| 140 |
+
}).eq("id", task_id).execute()
|
| 141 |
+
|
| 142 |
+
# 7. Update Run Status
|
| 143 |
+
supabase.table("task_runs").update({
|
| 144 |
+
"status": "completed",
|
| 145 |
+
"finished_at": datetime.now(timezone.utc).isoformat(),
|
| 146 |
+
"duration_seconds": round(duration, 2)
|
| 147 |
+
}).eq("id", run_id).execute()
|
| 148 |
+
|
| 149 |
+
# 8. Log Completion with Metrics
|
| 150 |
+
supabase.table("agent_logs").insert({
|
| 151 |
+
"task_id": task_id,
|
| 152 |
+
"run_id": run_id,
|
| 153 |
+
"action": complete_action,
|
| 154 |
+
"content": f"{complete_content} (Execution time: {duration:.2f}s)"
|
| 155 |
+
}).execute()
|
| 156 |
+
|
| 157 |
+
if not quality_review["approved"]:
|
| 158 |
+
supabase.table("agent_logs").insert({
|
| 159 |
+
"task_id": task_id,
|
| 160 |
+
"run_id": run_id,
|
| 161 |
+
"action": "quality_review_failed",
|
| 162 |
+
"content": f"Quality review failed: {', '.join(quality_review['fail_reasons'])}"
|
| 163 |
+
}).execute()
|
| 164 |
+
|
| 165 |
+
return result, run_id
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Error executing task {task_id}: {str(e)}")
|
| 169 |
+
if run_id:
|
| 170 |
+
supabase.table("task_runs").update({
|
| 171 |
+
"status": "failed",
|
| 172 |
+
"finished_at": datetime.now(timezone.utc).isoformat()
|
| 173 |
+
}).eq("id", run_id).execute()
|
| 174 |
+
|
| 175 |
+
if update_task:
|
| 176 |
+
supabase.table("tasks").update({
|
| 177 |
+
"status": "failed",
|
| 178 |
+
"output_data": {"error": str(e)}
|
| 179 |
+
}).eq("id", task_id).execute()
|
| 180 |
+
|
| 181 |
+
# LOG ERROR TO AGENT CONSOLE
|
| 182 |
+
supabase.table("agent_logs").insert({
|
| 183 |
+
"task_id": task_id,
|
| 184 |
+
"run_id": run_id,
|
| 185 |
+
"action": "execution_failed",
|
| 186 |
+
"content": f"ERROR: {str(e)}"
|
| 187 |
+
}).execute()
|
| 188 |
+
|
| 189 |
+
raise e
|
| 190 |
+
|
| 191 |
+
@staticmethod
|
| 192 |
+
async def execute_agent_logic(task: dict, agent_data: dict):
|
| 193 |
+
task_id = task["id"]
|
| 194 |
+
try:
|
| 195 |
+
await AgentRunnerService.run_agent_task(
|
| 196 |
+
task,
|
| 197 |
+
agent_data,
|
| 198 |
+
include_semantic_context=True
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
await audit_service.log_action(
|
| 202 |
+
user_id=None,
|
| 203 |
+
action="agent_task_completed",
|
| 204 |
+
agent_id=agent_data["id"],
|
| 205 |
+
task_id=task_id,
|
| 206 |
+
metadata={"model": agent_data["model"]}
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
except Exception:
|
| 210 |
+
raise
|
backend/services/audit_service.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from services.supabase_service import supabase
|
| 2 |
+
from typing import Dict, Any, Optional
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("uvicorn")
|
| 6 |
+
|
| 7 |
+
class AuditService:
|
| 8 |
+
@staticmethod
|
| 9 |
+
async def log_action(
|
| 10 |
+
user_id: Optional[str],
|
| 11 |
+
action: str,
|
| 12 |
+
agent_id: Optional[str] = None,
|
| 13 |
+
task_id: Optional[str] = None,
|
| 14 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 15 |
+
):
|
| 16 |
+
"""
|
| 17 |
+
Records an action in the audit_logs table.
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
data = {
|
| 21 |
+
"user_id": user_id,
|
| 22 |
+
"action": action,
|
| 23 |
+
"agent_id": agent_id,
|
| 24 |
+
"task_id": task_id,
|
| 25 |
+
"metadata": metadata or {}
|
| 26 |
+
}
|
| 27 |
+
supabase.table("audit_logs").insert(data).execute()
|
| 28 |
+
except Exception as e:
|
| 29 |
+
logger.error(f"AuditService error: {str(e)}")
|
| 30 |
+
|
| 31 |
+
audit_service = AuditService()
|
backend/services/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pydantic_settings import BaseSettings
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
from supabase import create_client, Client
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
# Supabase
|
| 8 |
+
SUPABASE_URL: str = ""
|
| 9 |
+
SUPABASE_SERVICE_ROLE_KEY: str = ""
|
| 10 |
+
|
| 11 |
+
# AI Providers
|
| 12 |
+
OPENAI_API_KEY: Optional[str] = None
|
| 13 |
+
GROQ_API_KEY: Optional[str] = None
|
| 14 |
+
GEMINI_API_KEY: Optional[str] = None
|
| 15 |
+
ANTHROPIC_API_KEY: Optional[str] = None
|
| 16 |
+
AMD_API_KEY: Optional[str] = None
|
| 17 |
+
TAVILY_API_KEY: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
# App Config
|
| 20 |
+
TASK_QUEUE_EMBEDDED_WORKER: bool = True
|
| 21 |
+
OUTPUT_LANGUAGE: str = "en"
|
| 22 |
+
PORT: int = 8000
|
| 23 |
+
SENTRY_DSN: Optional[str] = None
|
| 24 |
+
|
| 25 |
+
model_config = {
|
| 26 |
+
"env_file": ".env",
|
| 27 |
+
"extra": "ignore"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
settings = Settings()
|
| 31 |
+
|
| 32 |
+
class ConfigService:
|
| 33 |
+
"""
|
| 34 |
+
Manages application-wide settings stored in Supabase with local fallback defaults.
|
| 35 |
+
Borrowed from AgentCollab for enhanced flexibility.
|
| 36 |
+
"""
|
| 37 |
+
_cache: Dict[str, Any] = {}
|
| 38 |
+
_supabase: Client = None
|
| 39 |
+
|
| 40 |
+
@classmethod
|
| 41 |
+
def _get_supabase(cls):
|
| 42 |
+
if not cls._supabase:
|
| 43 |
+
if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_ROLE_KEY:
|
| 44 |
+
return None
|
| 45 |
+
cls._supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY)
|
| 46 |
+
return cls._supabase
|
| 47 |
+
|
| 48 |
+
# Defaults used when DB has no config entry for a provider
|
| 49 |
+
_DEFAULTS: Dict[str, Any] = {
|
| 50 |
+
"groq": {"enabled": True, "default_model": "llama-3.3-70b-versatile", "temperature": 0.7, "max_tokens": 4096},
|
| 51 |
+
"openai": {"enabled": True, "default_model": "gpt-4o", "temperature": 0.7, "max_tokens": 4096},
|
| 52 |
+
"openrouter": {"enabled": True, "default_model": "google/gemini-2.0-flash", "temperature": 0.7, "max_tokens": 8192},
|
| 53 |
+
"gemini": {"enabled": True, "default_model": "gemini-2.0-flash", "temperature": 0.7, "max_tokens": 8192},
|
| 54 |
+
"amd": {"enabled": True, "default_model": "gpt-4o", "temperature": 0.7, "max_tokens": 4096, "base_url": "https://inference.do-ai.run/v1"},
|
| 55 |
+
"ollama": {"enabled": True, "default_model": "llama3.1:8b", "temperature": 0.7, "base_url": "http://localhost:11434"},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
@classmethod
|
| 59 |
+
def get_provider_config(cls, provider: str) -> Dict[str, Any]:
|
| 60 |
+
"""Returns config for a provider from cache, DB, then defaults."""
|
| 61 |
+
cache_key = f"provider:{provider}"
|
| 62 |
+
if cache_key in cls._cache:
|
| 63 |
+
return cls._cache[cache_key]
|
| 64 |
+
|
| 65 |
+
db = cls._get_supabase()
|
| 66 |
+
if db:
|
| 67 |
+
try:
|
| 68 |
+
resp = db.table("app_config").select("*").eq("key", provider).execute()
|
| 69 |
+
if resp.data and len(resp.data) > 0:
|
| 70 |
+
cls._cache[cache_key] = resp.data[0]["value"]
|
| 71 |
+
return cls._cache[cache_key]
|
| 72 |
+
except Exception:
|
| 73 |
+
pass # Fall through to defaults
|
| 74 |
+
|
| 75 |
+
result = cls._DEFAULTS.get(provider, {})
|
| 76 |
+
cls._cache[cache_key] = result
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def get_global_setting(cls, key: str, default: Any = None) -> Any:
|
| 81 |
+
cache_key = f"global:{key}"
|
| 82 |
+
if cache_key in cls._cache:
|
| 83 |
+
return cls._cache[cache_key]
|
| 84 |
+
|
| 85 |
+
db = cls._get_supabase()
|
| 86 |
+
if db:
|
| 87 |
+
try:
|
| 88 |
+
resp = db.table("app_config").select("*").eq("key", key).execute()
|
| 89 |
+
if resp.data and len(resp.data) > 0:
|
| 90 |
+
cls._cache[cache_key] = resp.data[0]["value"]
|
| 91 |
+
return cls._cache[cache_key]
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
return default
|
| 96 |
+
|
| 97 |
+
@classmethod
|
| 98 |
+
def invalidate_cache(cls) -> None:
|
| 99 |
+
cls._cache.clear()
|
| 100 |
+
|
| 101 |
+
config_service = ConfigService()
|
backend/services/orchestrator_service.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from services.supabase_service import supabase
|
| 2 |
+
from agents.agent_factory import AgentFactory
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
from services.config import settings
|
| 7 |
+
from services.agent_runner_service import AgentRunnerService
|
| 8 |
+
from services.output_quality import clean_report_text, dedupe_lines, filter_report_sections, validate_output
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("uvicorn")
|
| 11 |
+
|
| 12 |
+
NOISY_REPORT_KEYS = {
|
| 13 |
+
"raw_text",
|
| 14 |
+
"sampleBackendCode",
|
| 15 |
+
"sampleUploadSnippet",
|
| 16 |
+
"sampleSearchEndpoint",
|
| 17 |
+
"sampleRedisCartHelper",
|
| 18 |
+
"sampleWebhookHandler",
|
| 19 |
+
"sampleStateMachine",
|
| 20 |
+
"repositoryStructure",
|
| 21 |
+
"wireframes",
|
| 22 |
+
"dataModel",
|
| 23 |
+
"userStories",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
def _humanize_key(key: str) -> str:
|
| 27 |
+
return key.replace("_", " ").replace("-", " ").strip().title()
|
| 28 |
+
|
| 29 |
+
def _format_value_for_report(value, level: int = 0) -> list[str]:
|
| 30 |
+
if value is None:
|
| 31 |
+
return ["Not specified."]
|
| 32 |
+
|
| 33 |
+
if isinstance(value, (str, int, float, bool)):
|
| 34 |
+
return [str(value)]
|
| 35 |
+
|
| 36 |
+
if isinstance(value, list):
|
| 37 |
+
lines: list[str] = []
|
| 38 |
+
for item in value:
|
| 39 |
+
if isinstance(item, dict):
|
| 40 |
+
item_lines = _format_value_for_report(item, level + 1)
|
| 41 |
+
if item_lines:
|
| 42 |
+
lines.append(f"- {item_lines[0]}")
|
| 43 |
+
lines.extend(f" {line}" for line in item_lines[1:])
|
| 44 |
+
elif isinstance(item, list):
|
| 45 |
+
nested = _format_value_for_report(item, level + 1)
|
| 46 |
+
lines.extend(f"- {line}" for line in nested)
|
| 47 |
+
else:
|
| 48 |
+
lines.append(f"- {item}")
|
| 49 |
+
return lines or ["No items."]
|
| 50 |
+
|
| 51 |
+
if isinstance(value, dict):
|
| 52 |
+
lines: list[str] = []
|
| 53 |
+
for key, item in value.items():
|
| 54 |
+
if str(key) in NOISY_REPORT_KEYS:
|
| 55 |
+
continue
|
| 56 |
+
title = _humanize_key(str(key))
|
| 57 |
+
if isinstance(item, dict):
|
| 58 |
+
lines.append(f"{title}:")
|
| 59 |
+
lines.extend(f" {line}" for line in _format_value_for_report(item, level + 1))
|
| 60 |
+
elif isinstance(item, list):
|
| 61 |
+
lines.append(f"{title}:")
|
| 62 |
+
lines.extend(f" {line}" for line in _format_value_for_report(item, level + 1))
|
| 63 |
+
else:
|
| 64 |
+
lines.append(f"{title}: {item}")
|
| 65 |
+
return lines or ["No details."]
|
| 66 |
+
|
| 67 |
+
return [str(value)]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _extract_json_payload(text: str):
|
| 71 |
+
stripped = text.strip()
|
| 72 |
+
if stripped.startswith("```"):
|
| 73 |
+
stripped = stripped.strip("`")
|
| 74 |
+
if stripped.lower().startswith("json"):
|
| 75 |
+
stripped = stripped[4:].strip()
|
| 76 |
+
try:
|
| 77 |
+
return json.loads(stripped)
|
| 78 |
+
except Exception:
|
| 79 |
+
match = re.search(r"```json\s*(.*?)\s*```", text, re.IGNORECASE | re.DOTALL)
|
| 80 |
+
if match:
|
| 81 |
+
try:
|
| 82 |
+
return json.loads(match.group(1))
|
| 83 |
+
except Exception:
|
| 84 |
+
return None
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
def _format_output_for_report(output_data) -> str:
|
| 88 |
+
if not output_data:
|
| 89 |
+
return "No approved output was saved for this task."
|
| 90 |
+
|
| 91 |
+
if isinstance(output_data, dict):
|
| 92 |
+
primary = (
|
| 93 |
+
output_data.get("data")
|
| 94 |
+
or output_data.get("final")
|
| 95 |
+
or output_data.get("raw_output")
|
| 96 |
+
or output_data
|
| 97 |
+
)
|
| 98 |
+
else:
|
| 99 |
+
primary = output_data
|
| 100 |
+
|
| 101 |
+
if isinstance(primary, str):
|
| 102 |
+
parsed = _extract_json_payload(primary)
|
| 103 |
+
if parsed is not None:
|
| 104 |
+
return clean_report_text(dedupe_lines("\n".join(_format_value_for_report(parsed))))
|
| 105 |
+
return clean_report_text(dedupe_lines(primary))
|
| 106 |
+
|
| 107 |
+
return clean_report_text(dedupe_lines("\n".join(_format_value_for_report(primary))))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _is_empty_curated_text(text: str) -> bool:
|
| 111 |
+
normalized = (text or "").strip().lower()
|
| 112 |
+
return normalized in {
|
| 113 |
+
"",
|
| 114 |
+
"no approved output was saved for this task.",
|
| 115 |
+
"{}",
|
| 116 |
+
"[]",
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _format_conclusion_payload(data: dict) -> str:
|
| 121 |
+
conclusion = data.get("strategicConclusion") or data.get("conclusion") or data.get("content") or ""
|
| 122 |
+
next_steps = data.get("nextSteps") or data.get("next_steps") or []
|
| 123 |
+
|
| 124 |
+
lines: list[str] = []
|
| 125 |
+
if isinstance(conclusion, str) and conclusion.strip():
|
| 126 |
+
lines.append(conclusion.strip())
|
| 127 |
+
|
| 128 |
+
if isinstance(next_steps, list) and next_steps:
|
| 129 |
+
lines.append("")
|
| 130 |
+
lines.append("Next steps:")
|
| 131 |
+
for step in next_steps[:5]:
|
| 132 |
+
if isinstance(step, str) and step.strip():
|
| 133 |
+
lines.append(f"- {step.strip()}")
|
| 134 |
+
|
| 135 |
+
return "\n".join(lines).strip() or "\n".join(_format_value_for_report(data))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _has_usable_output(output_data) -> bool:
|
| 139 |
+
if not output_data:
|
| 140 |
+
return False
|
| 141 |
+
if isinstance(output_data, dict):
|
| 142 |
+
if output_data.get("error"):
|
| 143 |
+
return False
|
| 144 |
+
primary = output_data.get("data")
|
| 145 |
+
if primary in (None, "", [], {}):
|
| 146 |
+
return False
|
| 147 |
+
return True
|
| 148 |
+
|
| 149 |
+
def _output_text(output_data) -> str:
|
| 150 |
+
return _format_output_for_report(output_data).lower()
|
| 151 |
+
|
| 152 |
+
def _build_report_charts(tasks: list[dict]) -> dict:
|
| 153 |
+
total = len(tasks)
|
| 154 |
+
done = sum(1 for task in tasks if task.get("status") == "done")
|
| 155 |
+
failed = sum(1 for task in tasks if task.get("status") == "failed")
|
| 156 |
+
pending = max(total - done - failed, 0)
|
| 157 |
+
|
| 158 |
+
priority_counts: dict[str, int] = {}
|
| 159 |
+
for task in tasks:
|
| 160 |
+
priority = str(task.get("priority") if task.get("priority") is not None else 0)
|
| 161 |
+
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
| 162 |
+
|
| 163 |
+
categories = {
|
| 164 |
+
"Market": ("market", "competitor", "customer", "segment", "demand"),
|
| 165 |
+
"Product": ("product", "mvp", "feature", "design", "scope"),
|
| 166 |
+
"Revenue": ("revenue", "price", "pricing", "margin", "commission"),
|
| 167 |
+
"Operations": ("operation", "process", "logistic", "support", "fulfillment"),
|
| 168 |
+
"Risk": ("risk", "threat", "failure", "weak", "mitigation")
|
| 169 |
+
}
|
| 170 |
+
category_counts = {name: 0 for name in categories}
|
| 171 |
+
risk_mentions = 0
|
| 172 |
+
|
| 173 |
+
for task in tasks:
|
| 174 |
+
text = f"{task.get('title', '')} {task.get('description', '')} {_output_text(task.get('output_data'))}"
|
| 175 |
+
risk_mentions += sum(text.count(term) for term in categories["Risk"])
|
| 176 |
+
for category, terms in categories.items():
|
| 177 |
+
if any(term in text for term in terms):
|
| 178 |
+
category_counts[category] += 1
|
| 179 |
+
|
| 180 |
+
opportunity_score = 85 if total and done == total else round((done / total) * 85) if total else 0
|
| 181 |
+
risk_score = min(95, 35 + risk_mentions * 3)
|
| 182 |
+
readiness_score = round((done / total) * 100) if total else 0
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
"status": [
|
| 186 |
+
{"label": "Approved", "value": done},
|
| 187 |
+
{"label": "Pending", "value": pending},
|
| 188 |
+
{"label": "Failed", "value": failed}
|
| 189 |
+
],
|
| 190 |
+
"priorities": [
|
| 191 |
+
{"label": f"Priority {key}", "value": value}
|
| 192 |
+
for key, value in sorted(priority_counts.items(), key=lambda item: int(item[0]) if item[0].isdigit() else 0, reverse=True)
|
| 193 |
+
],
|
| 194 |
+
"categories": [
|
| 195 |
+
{"label": label, "value": value}
|
| 196 |
+
for label, value in category_counts.items()
|
| 197 |
+
],
|
| 198 |
+
"scores": [
|
| 199 |
+
{"label": "Readiness", "value": readiness_score},
|
| 200 |
+
{"label": "Opportunity", "value": opportunity_score},
|
| 201 |
+
{"label": "Risk", "value": risk_score}
|
| 202 |
+
]
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
REPORT_VARIANTS = {
|
| 206 |
+
"full": {
|
| 207 |
+
"title": "Final Report",
|
| 208 |
+
"agent_terms": [],
|
| 209 |
+
"fallback_heading": "Approved Work Summary",
|
| 210 |
+
"prompt": ""
|
| 211 |
+
},
|
| 212 |
+
"brief": {
|
| 213 |
+
"title": "Short Brief",
|
| 214 |
+
"agent_terms": ["brief", "summary", "writer"],
|
| 215 |
+
"fallback_heading": "Short Brief",
|
| 216 |
+
"prompt": (
|
| 217 |
+
"Create a concise executive brief from the approved project work. "
|
| 218 |
+
"Use plain English, no JSON, no code blocks. Include: objective, main findings, recommended next steps, and key risks. "
|
| 219 |
+
"Keep it short and decision-oriented. Do not invent entities, metrics, or placeholders."
|
| 220 |
+
)
|
| 221 |
+
},
|
| 222 |
+
"pessimistic": {
|
| 223 |
+
"title": "Pessimistic Analysis",
|
| 224 |
+
"agent_terms": ["pessimistic", "risk", "critic", "reviewer"],
|
| 225 |
+
"fallback_heading": "Pessimistic Analysis",
|
| 226 |
+
"prompt": (
|
| 227 |
+
"Create a skeptical, downside-focused analysis from the approved project work. "
|
| 228 |
+
"Use plain English, no JSON, no code blocks. Focus on what can fail, weak assumptions, operational risks, market risks, "
|
| 229 |
+
"financial risks, execution gaps, and mitigation priorities. Do not invent entities, metrics, or placeholders."
|
| 230 |
+
)
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
class OrchestratorService:
|
| 235 |
+
"""
|
| 236 |
+
Handles complex multi-agent workflows like Debates and Peer Reviews.
|
| 237 |
+
"""
|
| 238 |
+
|
| 239 |
+
async def run_debate(self, task_id: str, agent_a_id: str, agent_b_id: str):
|
| 240 |
+
"""
|
| 241 |
+
Executes a debate between two agents for a specific task.
|
| 242 |
+
"""
|
| 243 |
+
try:
|
| 244 |
+
# 1. Fetch task and agents
|
| 245 |
+
task = supabase.table("tasks").select("*").eq("id", task_id).single().execute().data
|
| 246 |
+
agent_a_data = supabase.table("agents").select("*").eq("id", agent_a_id).single().execute().data
|
| 247 |
+
agent_b_data = supabase.table("agents").select("*").eq("id", agent_b_id).single().execute().data
|
| 248 |
+
|
| 249 |
+
if not task or not agent_a_data or not agent_b_data:
|
| 250 |
+
raise ValueError("Task or agents not found for debate.")
|
| 251 |
+
|
| 252 |
+
# Update status to in_progress
|
| 253 |
+
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 254 |
+
|
| 255 |
+
# 2. Agent A generates initial response
|
| 256 |
+
initial_res, _ = await AgentRunnerService.run_agent_task(
|
| 257 |
+
task,
|
| 258 |
+
agent_a_data,
|
| 259 |
+
start_action="debate_initial_start",
|
| 260 |
+
start_content=f"Debate Step 1: {agent_a_data['name']} generating initial proposal.",
|
| 261 |
+
complete_action="debate_initial_complete",
|
| 262 |
+
update_task=False
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# 3. Agent B reviews and critiques
|
| 266 |
+
# We temporarily modify the task description for this run
|
| 267 |
+
task_critique = task.copy()
|
| 268 |
+
task_critique["description"] = f"Review the following output for the task: '{task['description']}'. Provide constructive critique and identify errors.\n\nOutput: {json.dumps(initial_res['data'])}"
|
| 269 |
+
|
| 270 |
+
critique_res, _ = await AgentRunnerService.run_agent_task(
|
| 271 |
+
task_critique,
|
| 272 |
+
agent_b_data,
|
| 273 |
+
start_action="debate_critique_start",
|
| 274 |
+
start_content=f"Debate Step 2: {agent_b_data['name']} critiquing the proposal.",
|
| 275 |
+
complete_action="debate_critique_complete",
|
| 276 |
+
update_task=False
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# 4. Agent A refines based on critique
|
| 280 |
+
task_refinement = task.copy()
|
| 281 |
+
task_refinement["description"] = f"Refine your initial output for the task: '{task['description']}' based on this critique: {json.dumps(critique_res['data'])}"
|
| 282 |
+
|
| 283 |
+
final_res, _ = await AgentRunnerService.run_agent_task(
|
| 284 |
+
task_refinement,
|
| 285 |
+
agent_a_data,
|
| 286 |
+
start_action="debate_refinement_start",
|
| 287 |
+
start_content=f"Debate Step 3: {agent_a_data['name']} refining proposal based on feedback.",
|
| 288 |
+
complete_action="debate_refinement_complete",
|
| 289 |
+
update_task=False
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# 5. Save consolidated result and mark for approval
|
| 293 |
+
consolidated_output = {
|
| 294 |
+
"agent_name": agent_a_data["name"],
|
| 295 |
+
"provider": agent_a_data["api_provider"],
|
| 296 |
+
"model": agent_a_data["model"],
|
| 297 |
+
"is_debate": True,
|
| 298 |
+
"data": final_res["data"],
|
| 299 |
+
"debate_history": {
|
| 300 |
+
"initial": initial_res["data"],
|
| 301 |
+
"critique": critique_res["data"],
|
| 302 |
+
"final": final_res["data"]
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
supabase.table("tasks").update({
|
| 307 |
+
"status": "awaiting_approval",
|
| 308 |
+
"output_data": consolidated_output
|
| 309 |
+
}).eq("id", task_id).execute()
|
| 310 |
+
|
| 311 |
+
logger.info(f"Debate completed for task {task_id}")
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
logger.error(f"Debate failed: {str(e)}")
|
| 315 |
+
supabase.table("tasks").update({
|
| 316 |
+
"status": "failed",
|
| 317 |
+
"output_data": {"error": str(e)}
|
| 318 |
+
}).eq("id", task_id).execute()
|
| 319 |
+
|
| 320 |
+
# LOG ERROR TO AGENT CONSOLE
|
| 321 |
+
supabase.table("agent_logs").insert({
|
| 322 |
+
"task_id": task_id,
|
| 323 |
+
"action": "debate_failed",
|
| 324 |
+
"content": f"DEBATE ERROR: {str(e)}"
|
| 325 |
+
}).execute()
|
| 326 |
+
|
| 327 |
+
async def run_project(self, project_id: str):
|
| 328 |
+
"""
|
| 329 |
+
Runs queued tasks in a project sequentially. Unassigned tasks are assigned
|
| 330 |
+
to the first available project-owner or global agent.
|
| 331 |
+
"""
|
| 332 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 333 |
+
if not project:
|
| 334 |
+
raise ValueError(f"Project not found: {project_id}")
|
| 335 |
+
|
| 336 |
+
owner_id = project.get("owner_id")
|
| 337 |
+
tasks = (
|
| 338 |
+
supabase.table("tasks")
|
| 339 |
+
.select("*")
|
| 340 |
+
.eq("project_id", project_id)
|
| 341 |
+
.eq("status", "todo")
|
| 342 |
+
.order("priority", desc=True)
|
| 343 |
+
.order("created_at", desc=False)
|
| 344 |
+
.execute()
|
| 345 |
+
.data
|
| 346 |
+
or []
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Check if ANY tasks exist for this project (regardless of status) to avoid re-decomposing
|
| 350 |
+
all_tasks_res = supabase.table("tasks").select("id", count="exact").eq("project_id", project_id).limit(1).execute()
|
| 351 |
+
has_any_tasks = all_tasks_res.count > 0 if all_tasks_res.count is not None else len(all_tasks_res.data) > 0
|
| 352 |
+
|
| 353 |
+
# Automatic Decomposition: Only if no tasks exist AT ALL
|
| 354 |
+
if not has_any_tasks:
|
| 355 |
+
logger.info(f"No tasks found for project {project_id}. Triggering auto-decomposition.")
|
| 356 |
+
await self.decompose_project(project_id)
|
| 357 |
+
# Re-fetch tasks after decomposition
|
| 358 |
+
tasks = (
|
| 359 |
+
supabase.table("tasks")
|
| 360 |
+
.select("*")
|
| 361 |
+
.eq("project_id", project_id)
|
| 362 |
+
.in_("status", ["todo", "failed"])
|
| 363 |
+
.order("priority", desc=True)
|
| 364 |
+
.order("created_at", desc=False)
|
| 365 |
+
.execute()
|
| 366 |
+
.data
|
| 367 |
+
or []
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 371 |
+
available_agents = [
|
| 372 |
+
agent for agent in agents
|
| 373 |
+
if agent.get("user_id") in (None, owner_id)
|
| 374 |
+
]
|
| 375 |
+
|
| 376 |
+
completed = 0
|
| 377 |
+
failed = 0
|
| 378 |
+
|
| 379 |
+
for task in tasks:
|
| 380 |
+
try:
|
| 381 |
+
agent_data = self._resolve_agent(task, available_agents)
|
| 382 |
+
if not agent_data:
|
| 383 |
+
raise ValueError("No available agent for task")
|
| 384 |
+
|
| 385 |
+
if not task.get("assigned_agent_id"):
|
| 386 |
+
supabase.table("tasks").update({
|
| 387 |
+
"assigned_agent_id": agent_data["id"]
|
| 388 |
+
}).eq("id", task["id"]).execute()
|
| 389 |
+
task["assigned_agent_id"] = agent_data["id"]
|
| 390 |
+
|
| 391 |
+
await self._run_task(task, agent_data)
|
| 392 |
+
completed += 1
|
| 393 |
+
except Exception as exc:
|
| 394 |
+
failed += 1
|
| 395 |
+
logger.error(f"Project orchestration task failed: {str(exc)}")
|
| 396 |
+
supabase.table("tasks").update({
|
| 397 |
+
"status": "failed",
|
| 398 |
+
"output_data": {"error": str(exc)}
|
| 399 |
+
}).eq("id", task["id"]).execute()
|
| 400 |
+
|
| 401 |
+
return {
|
| 402 |
+
"project_id": project_id,
|
| 403 |
+
"queued_tasks": len(tasks),
|
| 404 |
+
"completed": completed,
|
| 405 |
+
"failed": failed,
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
def _select_report_agent(self, project: dict, variant: str):
|
| 409 |
+
config = REPORT_VARIANTS.get(variant, REPORT_VARIANTS["full"])
|
| 410 |
+
terms = config["agent_terms"]
|
| 411 |
+
if not terms:
|
| 412 |
+
return None
|
| 413 |
+
|
| 414 |
+
owner_id = project.get("owner_id")
|
| 415 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 416 |
+
available_agents = [
|
| 417 |
+
agent for agent in agents
|
| 418 |
+
if agent.get("user_id") in (None, owner_id)
|
| 419 |
+
]
|
| 420 |
+
|
| 421 |
+
return next(
|
| 422 |
+
(
|
| 423 |
+
agent for agent in available_agents
|
| 424 |
+
if any(term in f"{agent.get('name', '')} {agent.get('role', '')}".lower() for term in terms)
|
| 425 |
+
),
|
| 426 |
+
available_agents[0] if available_agents else None
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
async def _generate_report_variant_with_agent(self, project: dict, report: str, variant: str):
|
| 430 |
+
agent_data = self._select_report_agent(project, variant)
|
| 431 |
+
if not agent_data:
|
| 432 |
+
return None
|
| 433 |
+
|
| 434 |
+
config = REPORT_VARIANTS[variant]
|
| 435 |
+
agent = AgentFactory.get_agent(
|
| 436 |
+
provider=agent_data["api_provider"],
|
| 437 |
+
name=agent_data["name"],
|
| 438 |
+
role=agent_data["role"],
|
| 439 |
+
model=agent_data["model"],
|
| 440 |
+
system_prompt=agent_data.get("system_prompt")
|
| 441 |
+
)
|
| 442 |
+
result = await agent.run(f"{config['prompt']}\n\nApproved project material:\n{report}", [])
|
| 443 |
+
if result.get("status") == "error":
|
| 444 |
+
raise RuntimeError(result.get("error") or "Report agent returned an error.")
|
| 445 |
+
|
| 446 |
+
data = result.get("data")
|
| 447 |
+
if isinstance(data, dict):
|
| 448 |
+
for key in ("brief", "analysis", "report", "summary", "content"):
|
| 449 |
+
if isinstance(data.get(key), str):
|
| 450 |
+
return data[key]
|
| 451 |
+
return "\n".join(_format_value_for_report(data))
|
| 452 |
+
if isinstance(data, str):
|
| 453 |
+
return data
|
| 454 |
+
return result.get("raw_output")
|
| 455 |
+
|
| 456 |
+
def _build_fallback_variant(self, project: dict, tasks: list[dict], variant: str):
|
| 457 |
+
config = REPORT_VARIANTS[variant]
|
| 458 |
+
lines = [
|
| 459 |
+
f"# {config['title']}: {project['name']}",
|
| 460 |
+
"",
|
| 461 |
+
"## Project Brief",
|
| 462 |
+
project.get("description") or "No project description provided.",
|
| 463 |
+
"",
|
| 464 |
+
f"## {config['fallback_heading']}"
|
| 465 |
+
]
|
| 466 |
+
|
| 467 |
+
if variant == "brief":
|
| 468 |
+
lines.extend([
|
| 469 |
+
f"All {len(tasks)} approved tasks have been consolidated.",
|
| 470 |
+
"The project is ready for decision review based on the approved task outputs.",
|
| 471 |
+
"",
|
| 472 |
+
"Recommended next steps:",
|
| 473 |
+
"- Validate the highest-impact assumptions with real users or customers.",
|
| 474 |
+
"- Prioritize the smallest launch scope that proves demand.",
|
| 475 |
+
"- Convert approved outputs into an execution backlog with owners and dates."
|
| 476 |
+
])
|
| 477 |
+
return "\n".join(lines)
|
| 478 |
+
|
| 479 |
+
if variant == "pessimistic":
|
| 480 |
+
lines.extend([
|
| 481 |
+
"This project can still fail even with all tasks approved.",
|
| 482 |
+
"",
|
| 483 |
+
"Primary downside risks:",
|
| 484 |
+
"- Approved task outputs may be internally consistent but unvalidated by the market.",
|
| 485 |
+
"- Revenue, conversion, operational, and adoption assumptions may be too optimistic.",
|
| 486 |
+
"- Execution scope can expand faster than the team can deliver.",
|
| 487 |
+
"- Competitors can respond with pricing, distribution, or trust advantages.",
|
| 488 |
+
"",
|
| 489 |
+
"Mitigation priorities:",
|
| 490 |
+
"- Validate demand before building broad feature scope.",
|
| 491 |
+
"- Stress-test unit economics and support costs.",
|
| 492 |
+
"- Define kill criteria before committing more resources."
|
| 493 |
+
])
|
| 494 |
+
return "\n".join(lines)
|
| 495 |
+
|
| 496 |
+
return None
|
| 497 |
+
|
| 498 |
+
def _quality_approved_tasks(self, tasks: list[dict], project: dict) -> tuple[list[dict], list[dict]]:
|
| 499 |
+
approved: list[dict] = []
|
| 500 |
+
excluded: list[dict] = []
|
| 501 |
+
for task in tasks:
|
| 502 |
+
output_data = task.get("output_data") or {}
|
| 503 |
+
if not _has_usable_output(output_data):
|
| 504 |
+
excluded.append({
|
| 505 |
+
"title": task.get("title", "Untitled task"),
|
| 506 |
+
"reasons": ["Task has no usable approved output."]
|
| 507 |
+
})
|
| 508 |
+
continue
|
| 509 |
+
task_with_project = {**task, "project": project}
|
| 510 |
+
quality_review = output_data.get("quality_review") if isinstance(output_data, dict) else None
|
| 511 |
+
if not quality_review and isinstance(output_data, dict):
|
| 512 |
+
quality_review = validate_output(task_with_project, output_data)
|
| 513 |
+
if quality_review and not quality_review.get("approved", False):
|
| 514 |
+
excluded.append({
|
| 515 |
+
"title": task.get("title", "Untitled task"),
|
| 516 |
+
"reasons": quality_review.get("fail_reasons") or ["Failed quality review."]
|
| 517 |
+
})
|
| 518 |
+
continue
|
| 519 |
+
approved.append(task)
|
| 520 |
+
return approved, excluded
|
| 521 |
+
|
| 522 |
+
def _curate_task_output(self, output_data) -> tuple[str, list[str]]:
|
| 523 |
+
text = _format_output_for_report(output_data)
|
| 524 |
+
text = clean_report_text(dedupe_lines(text))
|
| 525 |
+
text, excluded_lines = filter_report_sections(text)
|
| 526 |
+
return text or "No approved output was saved for this task.", excluded_lines
|
| 527 |
+
|
| 528 |
+
async def build_final_report(self, project_id: str, variant: str = "full"):
|
| 529 |
+
variant = variant if variant in REPORT_VARIANTS else "full"
|
| 530 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 531 |
+
if not project:
|
| 532 |
+
raise ValueError(f"Project not found: {project_id}")
|
| 533 |
+
|
| 534 |
+
tasks = (
|
| 535 |
+
supabase.table("tasks")
|
| 536 |
+
.select("title,description,status,priority,output_data,created_at")
|
| 537 |
+
.eq("project_id", project_id)
|
| 538 |
+
.order("priority", desc=True)
|
| 539 |
+
.order("created_at", desc=False)
|
| 540 |
+
.execute()
|
| 541 |
+
.data
|
| 542 |
+
or []
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
if not tasks:
|
| 546 |
+
raise ValueError("Project has no tasks to summarize.")
|
| 547 |
+
|
| 548 |
+
incomplete = [task for task in tasks if task.get("status") != "done"]
|
| 549 |
+
if incomplete:
|
| 550 |
+
raise ValueError(f"Final report is available after all tasks are approved. Pending tasks: {len(incomplete)}")
|
| 551 |
+
|
| 552 |
+
curated_tasks, excluded_tasks = self._quality_approved_tasks(tasks, project)
|
| 553 |
+
if not curated_tasks:
|
| 554 |
+
raise ValueError("No approved task outputs passed quality validation for final reporting.")
|
| 555 |
+
|
| 556 |
+
# 0. Header and Description
|
| 557 |
+
report_title = REPORT_VARIANTS[variant]["title"]
|
| 558 |
+
lines = [
|
| 559 |
+
f"# {report_title}: {project['name']}",
|
| 560 |
+
"",
|
| 561 |
+
"## Project Overview",
|
| 562 |
+
project.get("description") or "No description provided.",
|
| 563 |
+
""
|
| 564 |
+
]
|
| 565 |
+
|
| 566 |
+
# Add Context if exists
|
| 567 |
+
if project.get("context"):
|
| 568 |
+
lines.extend(["## Context", project["context"], ""])
|
| 569 |
+
|
| 570 |
+
lines.extend(["## Execution Summary", ""])
|
| 571 |
+
|
| 572 |
+
# We will add the tabular summary later in the UI or via charts,
|
| 573 |
+
# but for the text report, we include the approved work summary.
|
| 574 |
+
lines.extend(["## Approved Work Summary", ""])
|
| 575 |
+
|
| 576 |
+
report_exclusions: list[str] = []
|
| 577 |
+
kept_task_count = 0
|
| 578 |
+
for task in curated_tasks:
|
| 579 |
+
curated_text, excluded_lines = self._curate_task_output(task.get("output_data"))
|
| 580 |
+
report_exclusions.extend(excluded_lines)
|
| 581 |
+
if _is_empty_curated_text(curated_text):
|
| 582 |
+
excluded_tasks.append({
|
| 583 |
+
"title": task.get("title", "Untitled task"),
|
| 584 |
+
"reasons": ["Task output became empty after quality filtering."]
|
| 585 |
+
})
|
| 586 |
+
continue
|
| 587 |
+
kept_task_count += 1
|
| 588 |
+
lines.extend([
|
| 589 |
+
f"### {kept_task_count}. {task['title']}",
|
| 590 |
+
task.get("description") or "No task description provided.",
|
| 591 |
+
"",
|
| 592 |
+
curated_text,
|
| 593 |
+
""
|
| 594 |
+
])
|
| 595 |
+
|
| 596 |
+
if excluded_tasks or report_exclusions:
|
| 597 |
+
lines.extend(["## Excluded Content", ""])
|
| 598 |
+
for excluded in excluded_tasks:
|
| 599 |
+
lines.append(f"- Excluded task output: {excluded['title']} ({'; '.join(excluded['reasons'])})")
|
| 600 |
+
for excluded_line in list(dict.fromkeys(report_exclusions))[:10]:
|
| 601 |
+
if excluded_line:
|
| 602 |
+
lines.append(f"- {excluded_line}")
|
| 603 |
+
lines.append("")
|
| 604 |
+
|
| 605 |
+
# Final Conclusion Generation
|
| 606 |
+
conclusion = (
|
| 607 |
+
"Based on the approved task outputs, the project has successfully established a foundational framework. "
|
| 608 |
+
"The key findings suggest a viable path forward by focusing on the identified entry wedge and "
|
| 609 |
+
"mitigating primary risks through phased execution."
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
if variant == "full":
|
| 613 |
+
try:
|
| 614 |
+
# Use the 'Brief Writer' or any available agent to summarize a conclusion
|
| 615 |
+
agent_data = self._select_report_agent(project, "brief")
|
| 616 |
+
if agent_data:
|
| 617 |
+
agent = AgentFactory.get_agent(
|
| 618 |
+
provider=agent_data["api_provider"],
|
| 619 |
+
name=agent_data["name"],
|
| 620 |
+
role=agent_data["role"],
|
| 621 |
+
model=agent_data["model"],
|
| 622 |
+
system_prompt="You write a 2-3 sentence strategic conclusion and 3 actionable next steps for a project report. Never introduce placeholders or unsupported facts."
|
| 623 |
+
)
|
| 624 |
+
report_so_far = "\n".join(lines)
|
| 625 |
+
res = await agent.run(f"Based on this project report, write a final strategic conclusion and 3 next steps:\n\n{report_so_far}", [])
|
| 626 |
+
if res.get("status") != "error":
|
| 627 |
+
data = res.get("data")
|
| 628 |
+
if isinstance(data, str):
|
| 629 |
+
conclusion = data
|
| 630 |
+
elif isinstance(data, dict):
|
| 631 |
+
conclusion = _format_conclusion_payload(data)
|
| 632 |
+
except Exception as exc:
|
| 633 |
+
logger.warning(f"Failed to generate dynamic conclusion: {exc}")
|
| 634 |
+
|
| 635 |
+
lines.extend([
|
| 636 |
+
"## Strategic Conclusion",
|
| 637 |
+
conclusion,
|
| 638 |
+
"",
|
| 639 |
+
"## Completion Status",
|
| 640 |
+
f"{len(tasks)} tasks reached done status. {kept_task_count} task outputs were included in the final report. {len(excluded_tasks)} task outputs were excluded from the final report."
|
| 641 |
+
])
|
| 642 |
+
|
| 643 |
+
supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute()
|
| 644 |
+
report = "\n".join(lines)
|
| 645 |
+
|
| 646 |
+
if variant != "full":
|
| 647 |
+
try:
|
| 648 |
+
generated = await self._generate_report_variant_with_agent(project, report, variant)
|
| 649 |
+
report = generated or self._build_fallback_variant(project, tasks, variant) or report
|
| 650 |
+
except Exception as exc:
|
| 651 |
+
logger.warning(f"Report variant generation failed: {exc}")
|
| 652 |
+
report = self._build_fallback_variant(project, tasks, variant) or report
|
| 653 |
+
|
| 654 |
+
return {
|
| 655 |
+
"project_id": project_id,
|
| 656 |
+
"project_name": project["name"],
|
| 657 |
+
"task_count": kept_task_count,
|
| 658 |
+
"variant": variant,
|
| 659 |
+
"report": clean_report_text(dedupe_lines(report)),
|
| 660 |
+
"charts": _build_report_charts(curated_tasks)
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
async def decompose_project(self, project_id: str):
|
| 664 |
+
"""
|
| 665 |
+
Uses a Planner agent to decompose a project into discrete tasks.
|
| 666 |
+
"""
|
| 667 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 668 |
+
owner_id = project.get("owner_id")
|
| 669 |
+
|
| 670 |
+
# Find a Planner agent, prioritizing Groq as requested
|
| 671 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 672 |
+
|
| 673 |
+
# 1. Try to find an existing Groq Planner
|
| 674 |
+
planner_agent_data = next(
|
| 675 |
+
(a for a in agents if "Planner" in a["name"] and a.get("api_provider") == "groq"),
|
| 676 |
+
None
|
| 677 |
+
)
|
| 678 |
+
|
| 679 |
+
# 2. If not found, try any Planner
|
| 680 |
+
if not planner_agent_data:
|
| 681 |
+
planner_agent_data = next(
|
| 682 |
+
(a for a in agents if "Planner" in a["name"] and a.get("user_id") in (None, owner_id)),
|
| 683 |
+
next((a for a in agents if a.get("user_id") in (None, owner_id)), None)
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
# 3. If still no agent, or it's OpenAI but we want Groq, create a temporary one
|
| 687 |
+
if not planner_agent_data or (planner_agent_data.get("api_provider") == "openai" and not settings.OPENAI_API_KEY):
|
| 688 |
+
logger.info("Using default Groq Planner for decomposition.")
|
| 689 |
+
planner = AgentFactory.get_agent(
|
| 690 |
+
provider="groq",
|
| 691 |
+
name="System Planner",
|
| 692 |
+
role="Project Decomposer",
|
| 693 |
+
model="llama-3.3-70b-versatile",
|
| 694 |
+
system_prompt="You decompose goals into clear, ordered implementation tasks."
|
| 695 |
+
)
|
| 696 |
+
else:
|
| 697 |
+
planner = AgentFactory.get_agent(
|
| 698 |
+
provider=planner_agent_data["api_provider"],
|
| 699 |
+
name=planner_agent_data["name"],
|
| 700 |
+
role=planner_agent_data["role"],
|
| 701 |
+
model=planner_agent_data["model"],
|
| 702 |
+
system_prompt=planner_agent_data.get("system_prompt")
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
prompt = f"""Decompose the following project into 3-5 clear, actionable implementation tasks.
|
| 706 |
+
Project Name: {project['name']}
|
| 707 |
+
Description: {project['description']}
|
| 708 |
+
Context: {project.get('context', 'None')}
|
| 709 |
+
|
| 710 |
+
### Output Requirements:
|
| 711 |
+
You MUST return a valid JSON array of objects. Each object represents a task.
|
| 712 |
+
Do not include any conversational text, markdown formatting outside of the JSON, or explanations.
|
| 713 |
+
|
| 714 |
+
### JSON Schema:
|
| 715 |
+
[
|
| 716 |
+
{{
|
| 717 |
+
"title": "string (The name of the task)",
|
| 718 |
+
"description": "string (Detailed instructions for the agent)",
|
| 719 |
+
"priority": "integer (1-5, where 5 is highest priority)"
|
| 720 |
+
}}
|
| 721 |
+
]
|
| 722 |
+
|
| 723 |
+
IMPORTANT: Return a flat array. Do not wrap it in a parent 'tasks' object.
|
| 724 |
+
Do not use placeholder names or generic filler tasks. Every task title must be concrete and directly relevant to the stated project.
|
| 725 |
+
"""
|
| 726 |
+
|
| 727 |
+
try:
|
| 728 |
+
result = await planner.run(prompt, [])
|
| 729 |
+
tasks_data = result.get("data")
|
| 730 |
+
|
| 731 |
+
# Handle common LLM wrapping patterns
|
| 732 |
+
if isinstance(tasks_data, dict):
|
| 733 |
+
if "tasks" in tasks_data and isinstance(tasks_data["tasks"], list):
|
| 734 |
+
tasks_data = tasks_data["tasks"]
|
| 735 |
+
else:
|
| 736 |
+
tasks_data = [tasks_data]
|
| 737 |
+
|
| 738 |
+
if not isinstance(tasks_data, list):
|
| 739 |
+
raise ValueError(f"Agent returned invalid format: {type(tasks_data)}. Expected list or dict.")
|
| 740 |
+
|
| 741 |
+
# Filter out invalid tasks
|
| 742 |
+
valid_tasks = [
|
| 743 |
+
t for t in tasks_data
|
| 744 |
+
if isinstance(t, dict) and t.get("title")
|
| 745 |
+
]
|
| 746 |
+
|
| 747 |
+
if not valid_tasks:
|
| 748 |
+
raise ValueError("No valid tasks extracted from agent output.")
|
| 749 |
+
|
| 750 |
+
# Insert tasks
|
| 751 |
+
from .project_service import project_service
|
| 752 |
+
await project_service.add_tasks_to_project(project_id, valid_tasks)
|
| 753 |
+
logger.info(f"Auto-decomposed project {project_id} into {len(valid_tasks)} tasks.")
|
| 754 |
+
except Exception as e:
|
| 755 |
+
logger.error(f"Project decomposition failed: {e}")
|
| 756 |
+
|
| 757 |
+
def _resolve_agent(self, task: dict, available_agents: list[dict]):
|
| 758 |
+
assigned_agent_id = task.get("assigned_agent_id")
|
| 759 |
+
if assigned_agent_id:
|
| 760 |
+
return next((agent for agent in available_agents if agent["id"] == assigned_agent_id), None)
|
| 761 |
+
return available_agents[0] if available_agents else None
|
| 762 |
+
|
| 763 |
+
async def _run_task(self, task: dict, agent_data: dict):
|
| 764 |
+
await AgentRunnerService.run_agent_task(
|
| 765 |
+
task,
|
| 766 |
+
agent_data,
|
| 767 |
+
start_action="orchestrator_execution_start",
|
| 768 |
+
start_content=f"Orchestrator assigned {agent_data['name']} to task: {task['title']}",
|
| 769 |
+
complete_action="orchestrator_execution_complete",
|
| 770 |
+
complete_content="Task completed and is awaiting approval."
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
orchestrator_service = OrchestratorService()
|
backend/services/output_quality.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from collections import OrderedDict
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
PLACEHOLDER_PATTERNS = [
|
| 7 |
+
r"\bCompetitor\s+[A-Z]\b",
|
| 8 |
+
r"\bDashboard\s+[A-Z]\b",
|
| 9 |
+
r"\bProduct\s+[A-Z]\b",
|
| 10 |
+
r"\bCompany\s+[A-Z]\b",
|
| 11 |
+
r"\bOur Company\b",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
GENERIC_FILLER_PATTERNS = [
|
| 15 |
+
r"\bsustainable products?\b",
|
| 16 |
+
r"\bdigital marketing\b",
|
| 17 |
+
r"\bcustomer segments?\b",
|
| 18 |
+
r"\bdemographics\b",
|
| 19 |
+
r"\bpsychographics\b",
|
| 20 |
+
r"\bdistribution channels?\b",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
SENSITIVE_FACT_PATTERNS = [
|
| 24 |
+
r"\bmarket share\b",
|
| 25 |
+
r"\brevenue\b",
|
| 26 |
+
r"\barr\b",
|
| 27 |
+
r"\bpricing\b",
|
| 28 |
+
r"\bprice\b",
|
| 29 |
+
r"\blatest release version\b",
|
| 30 |
+
r"\bprofit\b",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
RAW_DUMP_PATTERNS = [
|
| 34 |
+
r"```(?:json)?",
|
| 35 |
+
r'"raw_text"\s*:',
|
| 36 |
+
r'"projectoverview"\s*:',
|
| 37 |
+
r'"projectoverview"\s*:',
|
| 38 |
+
r'"userstories"\s*:',
|
| 39 |
+
r'"datamodel"\s*:',
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
LATAM_HINTS = [
|
| 43 |
+
"mercadolibre",
|
| 44 |
+
"mercado libre",
|
| 45 |
+
"latam",
|
| 46 |
+
"latin america",
|
| 47 |
+
"argentina",
|
| 48 |
+
"mexico",
|
| 49 |
+
"brazil",
|
| 50 |
+
"brasil",
|
| 51 |
+
"chile",
|
| 52 |
+
"colombia",
|
| 53 |
+
"peru",
|
| 54 |
+
"uruguay",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
SEA_HINTS = [
|
| 58 |
+
"indonesia",
|
| 59 |
+
"yogyakarta",
|
| 60 |
+
"bali",
|
| 61 |
+
"southeast asia",
|
| 62 |
+
"tokopedia",
|
| 63 |
+
"shopee",
|
| 64 |
+
"jakarta",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
STRICT_TASK_PATTERNS = [
|
| 68 |
+
r"\bresearch\b",
|
| 69 |
+
r"\banaly[sz]e\b",
|
| 70 |
+
r"\banalysis\b",
|
| 71 |
+
r"\bcompetitor\b",
|
| 72 |
+
r"\bpricing\b",
|
| 73 |
+
r"\bmarket\b",
|
| 74 |
+
r"\baudit\b",
|
| 75 |
+
r"\breport\b",
|
| 76 |
+
r"\bcompare\b",
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _stringify_payload(value: Any) -> str:
|
| 81 |
+
if value is None:
|
| 82 |
+
return ""
|
| 83 |
+
if isinstance(value, str):
|
| 84 |
+
return value
|
| 85 |
+
try:
|
| 86 |
+
return json.dumps(value, ensure_ascii=True)
|
| 87 |
+
except Exception:
|
| 88 |
+
return str(value)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_quality_instructions(task: dict) -> str:
|
| 92 |
+
project_text = _project_text(task)
|
| 93 |
+
task_text = f"{task.get('title', '')}\n{task.get('description', '')}\n{project_text}".lower()
|
| 94 |
+
strict_mode = any(re.search(pattern, task_text, re.IGNORECASE) for pattern in STRICT_TASK_PATTERNS)
|
| 95 |
+
|
| 96 |
+
base = [
|
| 97 |
+
"Output quality rules:",
|
| 98 |
+
"- Never use placeholder names like Competitor A, Dashboard B, Product C, or Our Company.",
|
| 99 |
+
"- If a real named entity cannot be identified with confidence, return unknown instead of inventing one.",
|
| 100 |
+
"- Keep the output strictly within the requested scope.",
|
| 101 |
+
"- Stay aligned with the project's stated geography, competitors, and market context. Do not switch regions or industries unless the task explicitly requires it.",
|
| 102 |
+
"- Do not include generic filler sections that were not requested.",
|
| 103 |
+
"- Use clean UTF-8/ASCII friendly text. Do not output corrupted characters.",
|
| 104 |
+
"- Do not return raw JSON dumps, code blocks, repository scaffolds, or intermediate planning artifacts unless the task explicitly asks for them.",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
if strict_mode:
|
| 108 |
+
base.extend(
|
| 109 |
+
[
|
| 110 |
+
"- Return structured JSON where possible.",
|
| 111 |
+
"- For factual claims about competitors, products, pricing, versions, revenue, market share, or benchmarks, include source_url when available.",
|
| 112 |
+
"- Do not invent pricing, release versions, market share, revenue, ARR impact, or benchmarks.",
|
| 113 |
+
"- If a sensitive fact cannot be verified, omit it or mark it unknown.",
|
| 114 |
+
]
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
return "\n".join(base)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _project_text(task: dict) -> str:
|
| 121 |
+
project = task.get("project")
|
| 122 |
+
if isinstance(project, dict):
|
| 123 |
+
return "\n".join(
|
| 124 |
+
str(project.get(key, "") or "")
|
| 125 |
+
for key in ("name", "description", "context")
|
| 126 |
+
)
|
| 127 |
+
return str(task.get("project_context") or "")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _contains_any(text: str, terms: list[str]) -> bool:
|
| 131 |
+
lowered = text.lower()
|
| 132 |
+
return any(term in lowered for term in terms)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _looks_like_raw_dump(text: str) -> bool:
|
| 136 |
+
# Extremely relaxed check: Only flag as raw dump if it contains internal system keys
|
| 137 |
+
# that indicate it's a raw unformatted API response rather than a report.
|
| 138 |
+
internal_keys = [r'"raw_text"\s*:', r'"internal_status"\s*:', r'"debug_info"\s*:']
|
| 139 |
+
if any(re.search(pattern, text, re.IGNORECASE) for pattern in internal_keys):
|
| 140 |
+
return True
|
| 141 |
+
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _is_context_drift(task_text: str, output_text: str) -> bool:
|
| 146 |
+
task_lower = task_text.lower()
|
| 147 |
+
output_lower = output_text.lower()
|
| 148 |
+
|
| 149 |
+
if _contains_any(task_lower, LATAM_HINTS) and _contains_any(output_lower, SEA_HINTS):
|
| 150 |
+
return True
|
| 151 |
+
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def validate_output(task: dict, result: dict) -> dict:
|
| 156 |
+
raw_text = _stringify_payload(result.get("raw_output"))
|
| 157 |
+
data_text = _stringify_payload(result.get("data"))
|
| 158 |
+
combined = "\n".join(part for part in [raw_text, data_text] if part).strip()
|
| 159 |
+
task_text = "\n".join(
|
| 160 |
+
[
|
| 161 |
+
str(task.get("title", "") or ""),
|
| 162 |
+
str(task.get("description", "") or ""),
|
| 163 |
+
_project_text(task),
|
| 164 |
+
]
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
fail_reasons: list[str] = []
|
| 168 |
+
must_fix: list[str] = []
|
| 169 |
+
placeholder_entities: list[str] = []
|
| 170 |
+
unsupported_claims: list[str] = []
|
| 171 |
+
duplicate_claims: list[str] = []
|
| 172 |
+
encoding_issues: list[str] = []
|
| 173 |
+
|
| 174 |
+
if not combined:
|
| 175 |
+
fail_reasons.append("Empty output.")
|
| 176 |
+
|
| 177 |
+
for pattern in PLACEHOLDER_PATTERNS:
|
| 178 |
+
matches = re.findall(pattern, combined, re.IGNORECASE)
|
| 179 |
+
placeholder_entities.extend(matches)
|
| 180 |
+
|
| 181 |
+
if placeholder_entities:
|
| 182 |
+
# We don't add to fail_reasons anymore, just let the score reduction handle it
|
| 183 |
+
pass
|
| 184 |
+
|
| 185 |
+
if "■" in combined:
|
| 186 |
+
encoding_issues.append("Found corrupted character '■'.")
|
| 187 |
+
|
| 188 |
+
if encoding_issues:
|
| 189 |
+
fail_reasons.append("Output contains encoding corruption.")
|
| 190 |
+
must_fix.append("Remove corrupted characters and normalize text encoding.")
|
| 191 |
+
|
| 192 |
+
if _looks_like_raw_dump(combined):
|
| 193 |
+
fail_reasons.append("Output contains raw JSON/code dump instead of a usable task result.")
|
| 194 |
+
must_fix.append("Convert intermediate JSON/code output into the requested final artifact.")
|
| 195 |
+
|
| 196 |
+
if _is_context_drift(task_text, combined):
|
| 197 |
+
fail_reasons.append("Output drifted away from the project's stated geography or market context.")
|
| 198 |
+
must_fix.append("Regenerate the output using the project's explicit region, competitor set, and business context.")
|
| 199 |
+
|
| 200 |
+
for pattern in GENERIC_FILLER_PATTERNS:
|
| 201 |
+
if re.search(pattern, combined, re.IGNORECASE):
|
| 202 |
+
unsupported_claims.append(pattern.replace("\\b", "").replace("?", ""))
|
| 203 |
+
|
| 204 |
+
if unsupported_claims:
|
| 205 |
+
fail_reasons.append("Output contains generic filler outside the likely project scope.")
|
| 206 |
+
must_fix.append("Remove generic business-analysis filler not tied to the requested task.")
|
| 207 |
+
|
| 208 |
+
has_source_url = bool(re.search(r"https?://", combined, re.IGNORECASE))
|
| 209 |
+
for pattern in SENSITIVE_FACT_PATTERNS:
|
| 210 |
+
if re.search(pattern, combined, re.IGNORECASE) and not has_source_url:
|
| 211 |
+
unsupported_claims.append(f"Sensitive fact without source: {pattern}")
|
| 212 |
+
|
| 213 |
+
if any(item.startswith("Sensitive fact without source:") for item in unsupported_claims):
|
| 214 |
+
# We don't add to fail_reasons anymore, just let the score reduction handle it
|
| 215 |
+
pass
|
| 216 |
+
|
| 217 |
+
normalized_lines = []
|
| 218 |
+
seen_lines: set[str] = set()
|
| 219 |
+
for line in combined.splitlines():
|
| 220 |
+
normalized = re.sub(r"\s+", " ", line).strip().lower()
|
| 221 |
+
if len(normalized) < 20:
|
| 222 |
+
continue
|
| 223 |
+
if normalized in seen_lines:
|
| 224 |
+
duplicate_claims.append(line.strip())
|
| 225 |
+
else:
|
| 226 |
+
seen_lines.add(normalized)
|
| 227 |
+
normalized_lines.append(normalized)
|
| 228 |
+
|
| 229 |
+
if duplicate_claims:
|
| 230 |
+
# Just let the score reduction handle it
|
| 231 |
+
pass
|
| 232 |
+
|
| 233 |
+
score = 100
|
| 234 |
+
if placeholder_entities:
|
| 235 |
+
score = min(score, 20)
|
| 236 |
+
if _looks_like_raw_dump(combined):
|
| 237 |
+
score = min(score, 20)
|
| 238 |
+
if _is_context_drift(task_text, combined):
|
| 239 |
+
score = min(score, 20)
|
| 240 |
+
if any(item.startswith("Sensitive fact without source:") for item in unsupported_claims):
|
| 241 |
+
score = min(score, 30)
|
| 242 |
+
if duplicate_claims:
|
| 243 |
+
score = min(score, 50)
|
| 244 |
+
if unsupported_claims and not any(item.startswith("Sensitive fact without source:") for item in unsupported_claims):
|
| 245 |
+
score = min(score, 60)
|
| 246 |
+
if encoding_issues:
|
| 247 |
+
score = min(score, 60)
|
| 248 |
+
if not combined:
|
| 249 |
+
score = 0
|
| 250 |
+
|
| 251 |
+
approved = score >= 20
|
| 252 |
+
return {
|
| 253 |
+
"approved": approved,
|
| 254 |
+
"score": score,
|
| 255 |
+
"fail_reasons": fail_reasons,
|
| 256 |
+
"must_fix": must_fix,
|
| 257 |
+
"duplicate_claims": list(OrderedDict.fromkeys(duplicate_claims))[:10],
|
| 258 |
+
"unsupported_claims": list(OrderedDict.fromkeys(unsupported_claims))[:10],
|
| 259 |
+
"placeholder_entities": list(OrderedDict.fromkeys(placeholder_entities))[:10],
|
| 260 |
+
"encoding_issues": encoding_issues,
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def report_text_from_output(output_data: Any) -> str:
|
| 265 |
+
if not output_data:
|
| 266 |
+
return ""
|
| 267 |
+
if isinstance(output_data, dict):
|
| 268 |
+
primary = output_data.get("data") or output_data.get("final") or output_data.get("raw_output") or output_data
|
| 269 |
+
else:
|
| 270 |
+
primary = output_data
|
| 271 |
+
return _stringify_payload(primary)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def clean_report_text(text: str) -> str:
|
| 275 |
+
cleaned = text.replace("■", "-").replace("\u25A0", "-")
|
| 276 |
+
cleaned = re.sub(r"[ \t]+", " ", cleaned)
|
| 277 |
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
| 278 |
+
return cleaned.strip()
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def dedupe_lines(text: str) -> str:
|
| 282 |
+
lines = text.splitlines()
|
| 283 |
+
kept: list[str] = []
|
| 284 |
+
seen: set[str] = set()
|
| 285 |
+
for line in lines:
|
| 286 |
+
normalized = re.sub(r"\s+", " ", line).strip().lower()
|
| 287 |
+
if normalized and len(normalized) > 15 and normalized in seen:
|
| 288 |
+
continue
|
| 289 |
+
if normalized:
|
| 290 |
+
seen.add(normalized)
|
| 291 |
+
kept.append(line)
|
| 292 |
+
return "\n".join(kept).strip()
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def filter_report_sections(text: str) -> tuple[str, list[str]]:
|
| 296 |
+
excluded: list[str] = []
|
| 297 |
+
kept_lines: list[str] = []
|
| 298 |
+
for line in text.splitlines():
|
| 299 |
+
lowered = line.lower()
|
| 300 |
+
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in PLACEHOLDER_PATTERNS):
|
| 301 |
+
excluded.append("Removed placeholder content.")
|
| 302 |
+
continue
|
| 303 |
+
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in GENERIC_FILLER_PATTERNS):
|
| 304 |
+
excluded.append("Removed generic filler outside the requested scope.")
|
| 305 |
+
continue
|
| 306 |
+
if _looks_like_raw_dump(line):
|
| 307 |
+
excluded.append("Removed raw JSON/code dump content.")
|
| 308 |
+
continue
|
| 309 |
+
kept_lines.append(line)
|
| 310 |
+
return "\n".join(kept_lines).strip(), excluded
|
| 311 |
+
|
| 312 |
+
|
backend/services/project_service.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from services.supabase_service import supabase
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("uvicorn")
|
| 6 |
+
|
| 7 |
+
class ProjectService:
|
| 8 |
+
"""
|
| 9 |
+
Handles the creation and management of projects and their constituent tasks.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def create_project(title: str, description: str, user_id: str) -> Dict[str, Any]:
|
| 14 |
+
res = supabase.table("projects").insert({
|
| 15 |
+
"title": title,
|
| 16 |
+
"description": description,
|
| 17 |
+
"user_id": user_id,
|
| 18 |
+
"status": "active"
|
| 19 |
+
}).execute()
|
| 20 |
+
return res.data[0]
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
async def add_tasks_to_project(project_id: str, tasks: List[Dict[str, Any]]):
|
| 24 |
+
"""
|
| 25 |
+
Adds a list of tasks to a project.
|
| 26 |
+
tasks: [{"title": "...", "description": "...", "assigned_agent_id": "..."}]
|
| 27 |
+
"""
|
| 28 |
+
formatted_tasks = [
|
| 29 |
+
{**task, "project_id": project_id, "status": "todo"}
|
| 30 |
+
for task in tasks
|
| 31 |
+
]
|
| 32 |
+
supabase.table("tasks").insert(formatted_tasks).execute()
|
| 33 |
+
logger.info(f"Added {len(tasks)} tasks to project {project_id}")
|
| 34 |
+
|
| 35 |
+
project_service = ProjectService()
|
backend/services/semantic_backprop.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
from services.supabase_service import supabase
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("uvicorn")
|
| 7 |
+
|
| 8 |
+
class SemanticBackpropService:
|
| 9 |
+
"""
|
| 10 |
+
Ensures numerical consistency across agent tasks by extracting 'Canonical Numbers'
|
| 11 |
+
from previous task outputs.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def get_project_context(project_id: str, current_task_id: str) -> str:
|
| 16 |
+
"""
|
| 17 |
+
Fetches and extracts canonical figures from all completed sibling tasks.
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
resp = supabase.table("tasks") \
|
| 21 |
+
.select("title, output_data") \
|
| 22 |
+
.eq("project_id", project_id) \
|
| 23 |
+
.eq("status", "done") \
|
| 24 |
+
.neq("id", current_task_id) \
|
| 25 |
+
.execute()
|
| 26 |
+
|
| 27 |
+
if not resp.data:
|
| 28 |
+
return ""
|
| 29 |
+
|
| 30 |
+
canonical_blocks = []
|
| 31 |
+
topic_blocks = []
|
| 32 |
+
|
| 33 |
+
for task in resp.data:
|
| 34 |
+
output = task.get("output_data") or {}
|
| 35 |
+
# Handle different output formats (raw string or dict with 'result')
|
| 36 |
+
result_text = ""
|
| 37 |
+
if isinstance(output, dict):
|
| 38 |
+
result_text = output.get("result", "") or output.get("raw_output", "")
|
| 39 |
+
elif isinstance(output, str):
|
| 40 |
+
result_text = output
|
| 41 |
+
|
| 42 |
+
if not result_text:
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
# Extract financial and numerical lines
|
| 46 |
+
lines = result_text.splitlines()
|
| 47 |
+
financial_lines = []
|
| 48 |
+
|
| 49 |
+
# Keywords that often indicate a 'canonical' number
|
| 50 |
+
keywords = [
|
| 51 |
+
"$", "%", "USD", "MRR", "ARR", "ROI", "cost", "budget",
|
| 52 |
+
"revenue", "price", "fee", "estimate", "total", "quota"
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
for line in lines:
|
| 56 |
+
if any(k.lower() in line.lower() for k in keywords):
|
| 57 |
+
if len(line.strip()) > 5: # Ignore very short lines
|
| 58 |
+
financial_lines.append(line.strip())
|
| 59 |
+
|
| 60 |
+
if financial_lines:
|
| 61 |
+
# De-duplicate similar lines
|
| 62 |
+
seen = set()
|
| 63 |
+
unique_fin = []
|
| 64 |
+
for fl in financial_lines:
|
| 65 |
+
key = fl[:50]
|
| 66 |
+
if key not in seen:
|
| 67 |
+
seen.add(key)
|
| 68 |
+
unique_fin.append(fl)
|
| 69 |
+
|
| 70 |
+
canonical_blocks.append(
|
| 71 |
+
f"Source Task: **{task['title']}**\n" +
|
| 72 |
+
"\n".join(f" • {fl}" for fl in unique_fin[:8])
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Also track what topics were covered to avoid repetition
|
| 76 |
+
topic_blocks.append(f"- **{task['title']}**: (Covered in previous step)")
|
| 77 |
+
|
| 78 |
+
if not canonical_blocks and not topic_blocks:
|
| 79 |
+
return ""
|
| 80 |
+
|
| 81 |
+
context = "\n---\n"
|
| 82 |
+
if canonical_blocks:
|
| 83 |
+
context += (
|
| 84 |
+
"### ⚠️ CANONICAL FIGURES — PREVIOUSLY ESTABLISHED\n"
|
| 85 |
+
"> **MANDATORY RULE**: The following numbers and figures were established by agents\n"
|
| 86 |
+
"> responsible for those domains. You MUST use these exact values if you reference them.\n"
|
| 87 |
+
"> DO NOT re-calculate or propose alternative values for these specific items.\n\n"
|
| 88 |
+
)
|
| 89 |
+
context += "\n\n".join(canonical_blocks) + "\n\n"
|
| 90 |
+
|
| 91 |
+
if topic_blocks:
|
| 92 |
+
context += (
|
| 93 |
+
"### 📋 PREVIOUSLY COVERED TOPICS\n"
|
| 94 |
+
"> Do not repeat the analysis of these topics. Focus only on your specific task.\n"
|
| 95 |
+
)
|
| 96 |
+
context += "\n".join(topic_blocks) + "\n"
|
| 97 |
+
|
| 98 |
+
return context
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"Semantic Backprop failed: {e}")
|
| 102 |
+
return ""
|
| 103 |
+
|
| 104 |
+
semantic_backprop = SemanticBackpropService()
|
backend/services/supabase_service.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from supabase import create_client, Client
|
| 2 |
+
from services.config import settings
|
| 3 |
+
|
| 4 |
+
def get_supabase_client() -> Client:
|
| 5 |
+
"""
|
| 6 |
+
Initializes and returns a Supabase client.
|
| 7 |
+
"""
|
| 8 |
+
if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_ROLE_KEY:
|
| 9 |
+
raise ValueError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in environment.")
|
| 10 |
+
|
| 11 |
+
return create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY)
|
| 12 |
+
|
| 13 |
+
supabase: Client = get_supabase_client()
|
backend/services/task_queue.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from .supabase_service import supabase
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class TaskQueueService:
|
| 8 |
+
@staticmethod
|
| 9 |
+
async def queue_task(task_id: str):
|
| 10 |
+
"""
|
| 11 |
+
Marks a task as 'queued' in the database.
|
| 12 |
+
"""
|
| 13 |
+
try:
|
| 14 |
+
result = supabase.table("tasks").update({"status": "queued"}).eq("id", task_id).execute()
|
| 15 |
+
return result
|
| 16 |
+
except Exception as e:
|
| 17 |
+
logger.error(f"Error queueing task {task_id}: {e}")
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
async def get_next_queued_task():
|
| 22 |
+
"""
|
| 23 |
+
Fetches the next available task from the queue.
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
# Fetch one task that is in 'queued' status, ordered by priority and created_at
|
| 27 |
+
result = supabase.table("tasks") \
|
| 28 |
+
.select("*") \
|
| 29 |
+
.eq("status", "queued") \
|
| 30 |
+
.order("priority", desc=True) \
|
| 31 |
+
.order("created_at") \
|
| 32 |
+
.limit(1) \
|
| 33 |
+
.execute()
|
| 34 |
+
|
| 35 |
+
if result.data:
|
| 36 |
+
return result.data[0]
|
| 37 |
+
return None
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.error(f"Error fetching next queued task: {e}")
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
async def mark_in_progress(task_id: str):
|
| 44 |
+
"""
|
| 45 |
+
Marks a task as 'in_progress'.
|
| 46 |
+
"""
|
| 47 |
+
return supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
backend/tools/browser.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
from playwright.async_api import async_playwright
|
| 6 |
+
|
| 7 |
+
from services.config import settings
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger("uvicorn")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BrowserTool:
|
| 13 |
+
"""
|
| 14 |
+
Tools for live web search and direct URL extraction.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
self.tavily_api_key = settings.TAVILY_API_KEY
|
| 19 |
+
|
| 20 |
+
async def search_and_extract(self, url: str) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Navigates to a URL and returns the page text content.
|
| 23 |
+
"""
|
| 24 |
+
logger.info("BrowserTool: Navigating to %s", url)
|
| 25 |
+
async with async_playwright() as playwright:
|
| 26 |
+
browser = await playwright.chromium.launch(headless=True)
|
| 27 |
+
page = await browser.new_page()
|
| 28 |
+
try:
|
| 29 |
+
await page.goto(url, wait_until="networkidle", timeout=30000)
|
| 30 |
+
title = await page.title()
|
| 31 |
+
content = await page.inner_text("body")
|
| 32 |
+
combined = f"Title: {title}\nURL: {url}\n\n{content}".strip()
|
| 33 |
+
return combined[:12000]
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
logger.error("BrowserTool extract error for %s: %s", url, exc)
|
| 36 |
+
return f"Error accessing {url}: {exc}"
|
| 37 |
+
finally:
|
| 38 |
+
await browser.close()
|
| 39 |
+
|
| 40 |
+
async def web_search(self, query: str, topic: str = "general", max_results: int = 5) -> str:
|
| 41 |
+
"""
|
| 42 |
+
Searches the public web with Tavily and returns LLM-friendly results.
|
| 43 |
+
"""
|
| 44 |
+
if not self.tavily_api_key:
|
| 45 |
+
return (
|
| 46 |
+
"Web search is unavailable: TAVILY_API_KEY is not configured. "
|
| 47 |
+
"Add it to the backend environment to enable internet search."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
payload = {
|
| 51 |
+
"query": query,
|
| 52 |
+
"topic": topic if topic in {"general", "news", "finance"} else "general",
|
| 53 |
+
"search_depth": "advanced",
|
| 54 |
+
"max_results": max(1, min(max_results, 10)),
|
| 55 |
+
"include_answer": "advanced",
|
| 56 |
+
"include_raw_content": False,
|
| 57 |
+
"include_images": False,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
headers = {
|
| 61 |
+
"Authorization": f"Bearer {self.tavily_api_key}",
|
| 62 |
+
"Content-Type": "application/json",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 67 |
+
response = await client.post(
|
| 68 |
+
"https://api.tavily.com/search",
|
| 69 |
+
headers=headers,
|
| 70 |
+
json=payload,
|
| 71 |
+
)
|
| 72 |
+
response.raise_for_status()
|
| 73 |
+
except httpx.HTTPStatusError as exc:
|
| 74 |
+
detail = exc.response.text[:500] if exc.response is not None else str(exc)
|
| 75 |
+
logger.error("Tavily HTTP error: %s", detail)
|
| 76 |
+
return f"Tavily search failed with status {exc.response.status_code}: {detail}"
|
| 77 |
+
except Exception as exc:
|
| 78 |
+
logger.error("Tavily request error: %s", exc)
|
| 79 |
+
return f"Tavily search failed: {exc}"
|
| 80 |
+
|
| 81 |
+
data = response.json()
|
| 82 |
+
return self._format_tavily_results(query, data)
|
| 83 |
+
|
| 84 |
+
def _format_tavily_results(self, query: str, data: dict[str, Any]) -> str:
|
| 85 |
+
answer = data.get("answer")
|
| 86 |
+
results = data.get("results") or []
|
| 87 |
+
|
| 88 |
+
lines = [f"Search query: {query}"]
|
| 89 |
+
if answer:
|
| 90 |
+
lines.extend(["", "Answer:", str(answer).strip()])
|
| 91 |
+
|
| 92 |
+
if not results:
|
| 93 |
+
lines.extend(["", "No search results returned."])
|
| 94 |
+
return "\n".join(lines)
|
| 95 |
+
|
| 96 |
+
lines.extend(["", "Sources:"])
|
| 97 |
+
for index, result in enumerate(results, start=1):
|
| 98 |
+
title = result.get("title") or "Untitled"
|
| 99 |
+
url = result.get("url") or ""
|
| 100 |
+
snippet = (result.get("content") or "").strip()
|
| 101 |
+
score = result.get("score")
|
| 102 |
+
|
| 103 |
+
lines.append(f"{index}. {title}")
|
| 104 |
+
if url:
|
| 105 |
+
lines.append(f" URL: {url}")
|
| 106 |
+
if score is not None:
|
| 107 |
+
lines.append(f" Score: {score}")
|
| 108 |
+
if snippet:
|
| 109 |
+
lines.append(f" Snippet: {snippet[:900]}")
|
| 110 |
+
|
| 111 |
+
return "\n".join(lines)[:12000]
|
backend/tools/decomposer.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from services.project_service import project_service
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("uvicorn")
|
| 6 |
+
|
| 7 |
+
class DecompositionTool:
|
| 8 |
+
"""
|
| 9 |
+
A tool that allows agents to break down complex goals into actionable tasks.
|
| 10 |
+
"""
|
| 11 |
+
async def create_subtasks(self, project_id: str, tasks: List[Dict[str, Any]]) -> str:
|
| 12 |
+
"""
|
| 13 |
+
Takes a list of task definitions and adds them to the database for the given project.
|
| 14 |
+
"""
|
| 15 |
+
logger.info(f"DecompositionTool: Creating {len(tasks)} subtasks for project {project_id}")
|
| 16 |
+
try:
|
| 17 |
+
await project_service.add_tasks_to_project(project_id, tasks)
|
| 18 |
+
return f"Successfully created {len(tasks)} subtasks."
|
| 19 |
+
except Exception as e:
|
| 20 |
+
return f"Failed to create subtasks: {str(e)}"
|
backend/tools/file_generator.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from reportlab.lib.pagesizes import letter
|
| 4 |
+
from reportlab.pdfgen import canvas
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("uvicorn")
|
| 9 |
+
|
| 10 |
+
class FileGeneratorTool:
|
| 11 |
+
"""
|
| 12 |
+
A tool that allows agents to generate PDF and Excel files.
|
| 13 |
+
"""
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.output_dir = "outputs"
|
| 16 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
def _generate_filename(self, extension: str) -> str:
|
| 19 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 20 |
+
return os.path.join(self.output_dir, f"report_{timestamp}.{extension}")
|
| 21 |
+
|
| 22 |
+
async def generate_pdf(self, title: str, content: str) -> str:
|
| 23 |
+
"""
|
| 24 |
+
Generates a PDF document with the provided title and content.
|
| 25 |
+
"""
|
| 26 |
+
filename = self._generate_filename("pdf")
|
| 27 |
+
logger.info(f"FileGenerator: Generating PDF {filename}")
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
c = canvas.Canvas(filename, pagesize=letter)
|
| 31 |
+
width, height = letter
|
| 32 |
+
|
| 33 |
+
# Title
|
| 34 |
+
c.setFont("Helvetica-Bold", 16)
|
| 35 |
+
c.drawString(72, height - 72, title)
|
| 36 |
+
|
| 37 |
+
# Content (very basic wrapping/split)
|
| 38 |
+
c.setFont("Helvetica", 12)
|
| 39 |
+
text_object = c.beginText(72, height - 100)
|
| 40 |
+
for line in content.split('\n'):
|
| 41 |
+
text_object.textLine(line)
|
| 42 |
+
c.drawText(text_object)
|
| 43 |
+
|
| 44 |
+
c.save()
|
| 45 |
+
return f"PDF generated successfully: {filename}"
|
| 46 |
+
except Exception as e:
|
| 47 |
+
return f"Failed to generate PDF: {str(e)}"
|
| 48 |
+
|
| 49 |
+
async def generate_excel(self, data: list) -> str:
|
| 50 |
+
"""
|
| 51 |
+
Generates an Excel file from a list of dictionaries.
|
| 52 |
+
"""
|
| 53 |
+
filename = self._generate_filename("xlsx")
|
| 54 |
+
logger.info(f"FileGenerator: Generating Excel {filename}")
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
df = pd.DataFrame(data)
|
| 58 |
+
df.to_excel(filename, index=False)
|
| 59 |
+
return f"Excel generated successfully: {filename}"
|
| 60 |
+
except Exception as e:
|
| 61 |
+
return f"Failed to generate Excel: {str(e)}"
|
backend/tools/registry.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .file_generator import FileGeneratorTool
|
| 2 |
+
from .decomposer import DecompositionTool
|
| 3 |
+
from .sre import SRETool
|
| 4 |
+
from .browser import BrowserTool
|
| 5 |
+
from .sandbox import CodeSandboxTool
|
| 6 |
+
from .visuals import VisualsTool
|
| 7 |
+
from typing import Any, Dict, List
|
| 8 |
+
|
| 9 |
+
class ToolRegistry:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.browser = BrowserTool()
|
| 12 |
+
self.sandbox = CodeSandboxTool()
|
| 13 |
+
self.file_gen = FileGeneratorTool()
|
| 14 |
+
self.decomposer = DecompositionTool()
|
| 15 |
+
self.sre = SRETool()
|
| 16 |
+
self.visuals = VisualsTool()
|
| 17 |
+
self.tools = {
|
| 18 |
+
"web_search": {
|
| 19 |
+
"func": self.browser.web_search,
|
| 20 |
+
"description": "Searches the public web using Tavily and returns summarized results with source URLs."
|
| 21 |
+
},
|
| 22 |
+
"extract_url": {
|
| 23 |
+
"func": self.browser.search_and_extract,
|
| 24 |
+
"description": "Extracts text content from a specific URL."
|
| 25 |
+
},
|
| 26 |
+
"execute_python": {
|
| 27 |
+
"func": self.sandbox.execute_python,
|
| 28 |
+
"description": "Executes Python code and returns the output."
|
| 29 |
+
},
|
| 30 |
+
"generate_pdf": {
|
| 31 |
+
"func": self.file_gen.generate_pdf,
|
| 32 |
+
"description": "Generates a PDF document."
|
| 33 |
+
},
|
| 34 |
+
"generate_excel": {
|
| 35 |
+
"func": self.file_gen.generate_excel,
|
| 36 |
+
"description": "Generates an Excel spreadsheet from structured data."
|
| 37 |
+
},
|
| 38 |
+
"create_subtasks": {
|
| 39 |
+
"func": self.decomposer.create_subtasks,
|
| 40 |
+
"description": "Breaks down a goal into a list of actionable tasks."
|
| 41 |
+
},
|
| 42 |
+
"generate_chart": {
|
| 43 |
+
"func": self.visuals.generate_chart,
|
| 44 |
+
"description": "Generates a chart image (bar, line, pie) from a JSON config."
|
| 45 |
+
},
|
| 46 |
+
"generate_illustration": {
|
| 47 |
+
"func": self.visuals.generate_illustration,
|
| 48 |
+
"description": "Generates an AI illustration or drawing based on a text prompt."
|
| 49 |
+
},
|
| 50 |
+
"get_system_health": {
|
| 51 |
+
"func": self.sre.get_system_health,
|
| 52 |
+
"description": "Returns system health metrics (CPU, Memory, Disk)."
|
| 53 |
+
},
|
| 54 |
+
"check_service_status": {
|
| 55 |
+
"func": self.sre.check_service_status,
|
| 56 |
+
"description": "Checks if a specific service or process is running."
|
| 57 |
+
},
|
| 58 |
+
"run_patch_command": {
|
| 59 |
+
"func": self.sre.run_patch_command,
|
| 60 |
+
"description": "Executes a safe system patch command (e.g., git pull, npm install)."
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
def get_tool_definitions(self) -> List[Dict[str, Any]]:
|
| 65 |
+
"""
|
| 66 |
+
Returns OpenAI-style tool definitions.
|
| 67 |
+
"""
|
| 68 |
+
return [
|
| 69 |
+
{
|
| 70 |
+
"type": "function",
|
| 71 |
+
"function": {
|
| 72 |
+
"name": "web_search",
|
| 73 |
+
"description": "Search the public web for information using Tavily. Use this when the task requires current external information.",
|
| 74 |
+
"parameters": {
|
| 75 |
+
"type": "object",
|
| 76 |
+
"properties": {
|
| 77 |
+
"query": {"type": "string", "description": "The search query"},
|
| 78 |
+
"topic": {
|
| 79 |
+
"type": "string",
|
| 80 |
+
"enum": ["general", "news", "finance"],
|
| 81 |
+
"description": "The search category. Use news for recent events and finance for market/company financial queries."
|
| 82 |
+
},
|
| 83 |
+
"max_results": {
|
| 84 |
+
"type": "integer",
|
| 85 |
+
"description": "Maximum number of results to return. Keep this small to control context size.",
|
| 86 |
+
"default": 5
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
"required": ["query"]
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"type": "function",
|
| 95 |
+
"function": {
|
| 96 |
+
"name": "extract_url",
|
| 97 |
+
"description": "Extract text content from a URL",
|
| 98 |
+
"parameters": {
|
| 99 |
+
"type": "object",
|
| 100 |
+
"properties": {
|
| 101 |
+
"url": {"type": "string", "description": "The URL to extract from"}
|
| 102 |
+
},
|
| 103 |
+
"required": ["url"]
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"type": "function",
|
| 109 |
+
"function": {
|
| 110 |
+
"name": "execute_python",
|
| 111 |
+
"description": "Execute Python code to perform calculations, data analysis, or logic verification.",
|
| 112 |
+
"parameters": {
|
| 113 |
+
"type": "object",
|
| 114 |
+
"properties": {
|
| 115 |
+
"code": {"type": "string", "description": "The Python code to execute"}
|
| 116 |
+
},
|
| 117 |
+
"required": ["code"]
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"type": "function",
|
| 123 |
+
"function": {
|
| 124 |
+
"name": "generate_pdf",
|
| 125 |
+
"description": "Create a professional PDF report",
|
| 126 |
+
"parameters": {
|
| 127 |
+
"type": "object",
|
| 128 |
+
"properties": {
|
| 129 |
+
"title": {"type": "string", "description": "The title of the report"},
|
| 130 |
+
"content": {"type": "string", "description": "The text content of the report"}
|
| 131 |
+
},
|
| 132 |
+
"required": ["title", "content"]
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"type": "function",
|
| 138 |
+
"function": {
|
| 139 |
+
"name": "generate_excel",
|
| 140 |
+
"description": "Create an Excel spreadsheet from data",
|
| 141 |
+
"parameters": {
|
| 142 |
+
"type": "object",
|
| 143 |
+
"properties": {
|
| 144 |
+
"data": {
|
| 145 |
+
"type": "array",
|
| 146 |
+
"items": {"type": "object"},
|
| 147 |
+
"description": "List of rows as objects"
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
+
"required": ["data"]
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"type": "function",
|
| 156 |
+
"function": {
|
| 157 |
+
"name": "create_subtasks",
|
| 158 |
+
"description": "Break down a complex goal into smaller, actionable tasks for other agents.",
|
| 159 |
+
"parameters": {
|
| 160 |
+
"type": "object",
|
| 161 |
+
"properties": {
|
| 162 |
+
"project_id": {"type": "string", "description": "The current project UUID"},
|
| 163 |
+
"tasks": {
|
| 164 |
+
"type": "array",
|
| 165 |
+
"items": {
|
| 166 |
+
"type": "object",
|
| 167 |
+
"properties": {
|
| 168 |
+
"title": {"type": "string", "description": "Clear title of the subtask"},
|
| 169 |
+
"description": {"type": "string", "description": "Detailed instructions for the next agent"},
|
| 170 |
+
"assigned_agent_id": {"type": "string", "description": "The UUID of the agent to handle this task"}
|
| 171 |
+
},
|
| 172 |
+
"required": ["title", "description", "assigned_agent_id"]
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
},
|
| 176 |
+
"required": ["project_id", "tasks"]
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"type": "function",
|
| 182 |
+
"function": {
|
| 183 |
+
"name": "generate_chart",
|
| 184 |
+
"description": "Generate a visual chart image (bar, line, pie, etc.) using QuickChart.io.",
|
| 185 |
+
"parameters": {
|
| 186 |
+
"type": "object",
|
| 187 |
+
"properties": {
|
| 188 |
+
"chart_type": {"type": "string", "enum": ["bar", "line", "pie", "doughnut"], "description": "Type of chart"},
|
| 189 |
+
"chart_config": {"type": "string", "description": "The JSON configuration for QuickChart (e.g., {type: 'bar', data: {...}})"}
|
| 190 |
+
},
|
| 191 |
+
"required": ["chart_type", "chart_config"]
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"type": "function",
|
| 197 |
+
"function": {
|
| 198 |
+
"name": "generate_illustration",
|
| 199 |
+
"description": "Generate an AI illustration or drawing based on a prompt using Pollinations.ai.",
|
| 200 |
+
"parameters": {
|
| 201 |
+
"type": "object",
|
| 202 |
+
"properties": {
|
| 203 |
+
"prompt": {"type": "string", "description": "Detailed description of the illustration to generate"}
|
| 204 |
+
},
|
| 205 |
+
"required": ["prompt"]
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"type": "function",
|
| 211 |
+
"function": {
|
| 212 |
+
"name": "get_system_health",
|
| 213 |
+
"description": "Monitor server vital signs like CPU usage, memory availability, and disk space.",
|
| 214 |
+
"parameters": {
|
| 215 |
+
"type": "object",
|
| 216 |
+
"properties": {}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"type": "function",
|
| 222 |
+
"function": {
|
| 223 |
+
"name": "check_service_status",
|
| 224 |
+
"description": "Verify if a critical service or process is currently active on the host.",
|
| 225 |
+
"parameters": {
|
| 226 |
+
"type": "object",
|
| 227 |
+
"properties": {
|
| 228 |
+
"service_name": {"type": "string", "description": "The name of the process or service to check"}
|
| 229 |
+
},
|
| 230 |
+
"required": ["service_name"]
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"type": "function",
|
| 236 |
+
"function": {
|
| 237 |
+
"name": "run_patch_command",
|
| 238 |
+
"description": "Apply a system patch or update. Limited to safe commands like 'git pull' or 'npm install'.",
|
| 239 |
+
"parameters": {
|
| 240 |
+
"type": "object",
|
| 241 |
+
"properties": {
|
| 242 |
+
"command": {"type": "string", "description": "The restricted command to execute"}
|
| 243 |
+
},
|
| 244 |
+
"required": ["command"]
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
]
|
| 249 |
+
|
| 250 |
+
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
|
| 251 |
+
"""
|
| 252 |
+
Executes a tool by name with provided arguments.
|
| 253 |
+
"""
|
| 254 |
+
if name not in self.tools:
|
| 255 |
+
raise ValueError(f"Tool {name} not found")
|
| 256 |
+
|
| 257 |
+
func = self.tools[name]["func"]
|
| 258 |
+
return await func(**arguments)
|
| 259 |
+
|
| 260 |
+
tool_registry = ToolRegistry()
|
backend/tools/sandbox.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import io
|
| 3 |
+
import contextlib
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("uvicorn")
|
| 7 |
+
|
| 8 |
+
class CodeSandboxTool:
|
| 9 |
+
"""
|
| 10 |
+
A tool that allows agents to execute Python code and see the output.
|
| 11 |
+
"""
|
| 12 |
+
async def execute_python(self, code: str) -> str:
|
| 13 |
+
"""
|
| 14 |
+
Executes the provided Python code and returns the stdout/stderr.
|
| 15 |
+
"""
|
| 16 |
+
logger.info("CodeSandboxTool: Executing Python code")
|
| 17 |
+
|
| 18 |
+
# Capture stdout and stderr
|
| 19 |
+
stdout = io.StringIO()
|
| 20 |
+
stderr = io.StringIO()
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
| 24 |
+
# Using a fresh globals dictionary for each execution
|
| 25 |
+
exec_globals = {}
|
| 26 |
+
exec(code, exec_globals)
|
| 27 |
+
|
| 28 |
+
output = stdout.getvalue()
|
| 29 |
+
errors = stderr.getvalue()
|
| 30 |
+
|
| 31 |
+
if errors:
|
| 32 |
+
return f"Output:\n{output}\nErrors:\n{errors}"
|
| 33 |
+
return output if output else "Execution successful (no output)."
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
return f"Execution failed: {str(e)}"
|
| 37 |
+
finally:
|
| 38 |
+
stdout.close()
|
| 39 |
+
stderr.close()
|
backend/tools/sre.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import psutil
|
| 2 |
+
import os
|
| 3 |
+
import platform
|
| 4 |
+
from typing import Dict, Any
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("uvicorn")
|
| 8 |
+
|
| 9 |
+
class SRETool:
|
| 10 |
+
"""
|
| 11 |
+
A toolset for Site Reliability Engineering (SRE) agents to monitor and manage system health.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
async def get_system_health(self) -> Dict[str, Any]:
|
| 15 |
+
"""
|
| 16 |
+
Returns real-time system health metrics (CPU, RAM, Disk).
|
| 17 |
+
"""
|
| 18 |
+
logger.info("SRETool: Gathering system health metrics")
|
| 19 |
+
return {
|
| 20 |
+
"cpu_percent": psutil.cpu_percent(interval=1),
|
| 21 |
+
"memory": {
|
| 22 |
+
"total": psutil.virtual_memory().total,
|
| 23 |
+
"available": psutil.virtual_memory().available,
|
| 24 |
+
"percent": psutil.virtual_memory().percent
|
| 25 |
+
},
|
| 26 |
+
"disk": {
|
| 27 |
+
"total": psutil.disk_usage('/').total,
|
| 28 |
+
"used": psutil.disk_usage('/').used,
|
| 29 |
+
"percent": psutil.disk_usage('/').percent
|
| 30 |
+
},
|
| 31 |
+
"os": platform.system(),
|
| 32 |
+
"uptime": self._get_uptime()
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async def check_service_status(self, service_name: str) -> str:
|
| 36 |
+
"""
|
| 37 |
+
Checks if a specific service/process is running.
|
| 38 |
+
"""
|
| 39 |
+
logger.info(f"SRETool: Checking status of {service_name}")
|
| 40 |
+
for proc in psutil.process_iter(['name']):
|
| 41 |
+
if service_name.lower() in proc.info['name'].lower():
|
| 42 |
+
return f"Service '{service_name}' is RUNNING."
|
| 43 |
+
return f"Service '{service_name}' is NOT running."
|
| 44 |
+
|
| 45 |
+
def _get_uptime(self) -> str:
|
| 46 |
+
# Simple uptime calculation
|
| 47 |
+
import time
|
| 48 |
+
boot_time = psutil.boot_time()
|
| 49 |
+
uptime_seconds = time.time() - boot_time
|
| 50 |
+
return f"{int(uptime_seconds // 3600)}h {int((uptime_seconds % 3600) // 60)}m"
|
| 51 |
+
|
| 52 |
+
async def run_patch_command(self, command: str) -> str:
|
| 53 |
+
"""
|
| 54 |
+
Executes a restricted set of patching commands.
|
| 55 |
+
"""
|
| 56 |
+
logger.warning(f"SRETool: Attempting to run patch command: {command}")
|
| 57 |
+
|
| 58 |
+
# Restricted whitelist for security
|
| 59 |
+
whitelist = ["npm install", "pip install", "git pull", "npm audit fix"]
|
| 60 |
+
|
| 61 |
+
is_safe = any(command.startswith(safe) for safe in whitelist)
|
| 62 |
+
if not is_safe:
|
| 63 |
+
return f"Command '{command}' is not in the safety whitelist. Patch rejected."
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
import subprocess
|
| 67 |
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=60)
|
| 68 |
+
if result.returncode == 0:
|
| 69 |
+
return f"Patch successful: {result.stdout}"
|
| 70 |
+
return f"Patch failed: {result.stderr}"
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return f"Error executing patch: {str(e)}"
|
backend/tools/visuals.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import urllib.parse
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("uvicorn")
|
| 6 |
+
|
| 7 |
+
class VisualsTool:
|
| 8 |
+
"""
|
| 9 |
+
Provides visual generation capabilities like charts and illustrations.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
async def generate_chart(self, chart_type: str, chart_config: str) -> str:
|
| 13 |
+
"""
|
| 14 |
+
Generates a chart using QuickChart.io.
|
| 15 |
+
chart_type: 'bar', 'line', 'pie', 'doughnut'
|
| 16 |
+
chart_config: A JSON string containing the QuickChart configuration.
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
# If chart_config is already a dict, convert to JSON
|
| 20 |
+
if isinstance(chart_config, dict):
|
| 21 |
+
config_str = json.dumps(chart_config)
|
| 22 |
+
else:
|
| 23 |
+
# Try to parse it to validate
|
| 24 |
+
config_str = chart_config
|
| 25 |
+
json.loads(config_str)
|
| 26 |
+
|
| 27 |
+
encoded_config = urllib.parse.quote(config_str)
|
| 28 |
+
url = f"https://quickchart.io/chart?c={encoded_config}"
|
| 29 |
+
|
| 30 |
+
logger.info(f"Generated chart URL: {url}")
|
| 31 |
+
return f"Chart generated successfully: {url}. You should include this URL in your markdown output as an image: "
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Failed to generate chart: {e}")
|
| 34 |
+
return f"Error generating chart: {str(e)}. Please ensure your chart_config is a valid JSON string."
|
| 35 |
+
|
| 36 |
+
async def generate_illustration(self, prompt: str) -> str:
|
| 37 |
+
"""
|
| 38 |
+
Generates an illustration using Pollinations.ai.
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
encoded_prompt = urllib.parse.quote(prompt)
|
| 42 |
+
url = f"https://pollinations.ai/p/{encoded_prompt}?width=1024&height=1024&seed=42&model=flux"
|
| 43 |
+
|
| 44 |
+
logger.info(f"Generated illustration URL: {url}")
|
| 45 |
+
return f"Illustration generated successfully: {url}. You should include this URL in your markdown output as an image: "
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.error(f"Failed to generate illustration: {e}")
|
| 48 |
+
return f"Error generating illustration: {str(e)}"
|
backend/worker.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import signal
|
| 4 |
+
from services.task_queue import TaskQueueService
|
| 5 |
+
from services.supabase_service import supabase
|
| 6 |
+
from services.agent_runner_service import AgentRunnerService
|
| 7 |
+
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger("worker")
|
| 10 |
+
|
| 11 |
+
class AubmWorker:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.running = True
|
| 14 |
+
|
| 15 |
+
async def start(self):
|
| 16 |
+
logger.info("Aubm Background Worker started.")
|
| 17 |
+
while self.running:
|
| 18 |
+
task = await TaskQueueService.get_next_queued_task()
|
| 19 |
+
|
| 20 |
+
if task:
|
| 21 |
+
task_id = task['id']
|
| 22 |
+
logger.info(f"Processing task: {task_id}")
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
# Fetch agent data for this task
|
| 26 |
+
agent_res = supabase.table("agents").select("*").eq("id", task["assigned_agent_id"]).single().execute()
|
| 27 |
+
if agent_res.data:
|
| 28 |
+
await AgentRunnerService.execute_agent_logic(task, agent_res.data)
|
| 29 |
+
logger.info(f"Task {task_id} completed successfully.")
|
| 30 |
+
else:
|
| 31 |
+
logger.error(f"No agent found for task {task_id}")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Failed to process task {task_id}: {e}")
|
| 34 |
+
else:
|
| 35 |
+
# No tasks, sleep for a bit
|
| 36 |
+
await asyncio.sleep(5)
|
| 37 |
+
|
| 38 |
+
def stop(self):
|
| 39 |
+
logger.info("Stopping worker...")
|
| 40 |
+
self.running = False
|
| 41 |
+
|
| 42 |
+
async def main():
|
| 43 |
+
worker = AubmWorker()
|
| 44 |
+
|
| 45 |
+
# Handle shutdown signals
|
| 46 |
+
loop = asyncio.get_running_loop()
|
| 47 |
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 48 |
+
loop.add_signal_handler(sig, worker.stop)
|
| 49 |
+
|
| 50 |
+
await worker.start()
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
asyncio.run(main())
|
database/agent_ownership.sql
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Agent ownership and marketplace deploy policies
|
| 2 |
+
-- Apply this migration to existing Supabase projects after schema.sql.
|
| 3 |
+
|
| 4 |
+
ALTER TABLE public.agents
|
| 5 |
+
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users ON DELETE CASCADE;
|
| 6 |
+
|
| 7 |
+
ALTER TABLE public.agents ENABLE ROW LEVEL SECURITY;
|
| 8 |
+
|
| 9 |
+
DO $$
|
| 10 |
+
BEGIN
|
| 11 |
+
IF NOT EXISTS (
|
| 12 |
+
SELECT 1 FROM pg_policies
|
| 13 |
+
WHERE schemaname = 'public'
|
| 14 |
+
AND tablename = 'agents'
|
| 15 |
+
AND policyname = 'Users can create own agents'
|
| 16 |
+
) THEN
|
| 17 |
+
CREATE POLICY "Users can create own agents" ON public.agents
|
| 18 |
+
FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 19 |
+
END IF;
|
| 20 |
+
|
| 21 |
+
IF NOT EXISTS (
|
| 22 |
+
SELECT 1 FROM pg_policies
|
| 23 |
+
WHERE schemaname = 'public'
|
| 24 |
+
AND tablename = 'agents'
|
| 25 |
+
AND policyname = 'Users can update own agents'
|
| 26 |
+
) THEN
|
| 27 |
+
CREATE POLICY "Users can update own agents" ON public.agents
|
| 28 |
+
FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 29 |
+
END IF;
|
| 30 |
+
|
| 31 |
+
IF NOT EXISTS (
|
| 32 |
+
SELECT 1 FROM pg_policies
|
| 33 |
+
WHERE schemaname = 'public'
|
| 34 |
+
AND tablename = 'agents'
|
| 35 |
+
AND policyname = 'Users can delete own agents'
|
| 36 |
+
) THEN
|
| 37 |
+
CREATE POLICY "Users can delete own agents" ON public.agents
|
| 38 |
+
FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 39 |
+
END IF;
|
| 40 |
+
END $$;
|
| 41 |
+
|
| 42 |
+
NOTIFY pgrst, 'reload schema';
|
database/default_agents.sql
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Global default agents
|
| 2 |
+
-- These agents are readable by authenticated users and usable by the backend
|
| 3 |
+
-- orchestrator as fallback agents. They are not owned by a specific user.
|
| 4 |
+
|
| 5 |
+
INSERT INTO public.agents (name, role, api_provider, model, system_prompt)
|
| 6 |
+
SELECT
|
| 7 |
+
'Planner',
|
| 8 |
+
'Project Planner',
|
| 9 |
+
'openai',
|
| 10 |
+
'gpt-4o',
|
| 11 |
+
'You decompose goals into clear, ordered implementation tasks.'
|
| 12 |
+
WHERE NOT EXISTS (
|
| 13 |
+
SELECT 1 FROM public.agents WHERE user_id IS NULL AND name = 'Planner'
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
INSERT INTO public.agents (name, role, api_provider, model, system_prompt)
|
| 17 |
+
SELECT
|
| 18 |
+
'Builder',
|
| 19 |
+
'Implementation Agent',
|
| 20 |
+
'openai',
|
| 21 |
+
'gpt-4o',
|
| 22 |
+
'You implement practical, production-oriented solutions with concise output.'
|
| 23 |
+
WHERE NOT EXISTS (
|
| 24 |
+
SELECT 1 FROM public.agents WHERE user_id IS NULL AND name = 'Builder'
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
INSERT INTO public.agents (name, role, api_provider, model, system_prompt)
|
| 28 |
+
SELECT
|
| 29 |
+
'Reviewer',
|
| 30 |
+
'Quality Reviewer',
|
| 31 |
+
'openai',
|
| 32 |
+
'gpt-4o',
|
| 33 |
+
'You review outputs for correctness, security, completeness, and missing tests.'
|
| 34 |
+
WHERE NOT EXISTS (
|
| 35 |
+
SELECT 1 FROM public.agents WHERE user_id IS NULL AND name = 'Reviewer'
|
| 36 |
+
);
|