Commit ·
372477f
0
Parent(s):
Production deployment with AMD priority and stability fixes
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 +199 -0
- ROADMAP.md +72 -0
- SPEC.md +200 -0
- VERSION +1 -0
- backend/.env.example +19 -0
- backend/Dockerfile +32 -0
- backend/agents/agent_factory.py +45 -0
- backend/agents/amd_agent.py +42 -0
- backend/agents/base.py +179 -0
- backend/agents/digitalocean_agent.py +62 -0
- backend/agents/gemini_agent.py +37 -0
- backend/agents/groq_agent.py +107 -0
- backend/agents/local_agent.py +48 -0
- backend/agents/openai_agent.py +37 -0
- backend/agents_debug.json +1 -0
- backend/api/index.py +1 -0
- backend/main.py +199 -0
- backend/project_debug.json +1 -0
- backend/requirements.txt +19 -0
- backend/routers/__init__.py +1 -0
- backend/routers/agent_runner.py +483 -0
- backend/routers/generator.py +109 -0
- backend/routers/monitoring.py +121 -0
- backend/routers/orchestrator.py +233 -0
- backend/scratch/check_db.py +22 -0
- backend/scratch/create_comparison_project.py +168 -0
- backend/scratch/find_user.py +24 -0
- backend/scratch/fix_logs_rls.py +33 -0
- backend/services/agent_runner_service.py +399 -0
- backend/services/audit_service.py +31 -0
- backend/services/budget_service.py +208 -0
- backend/services/config.py +107 -0
- backend/services/embedding_service.py +87 -0
- backend/services/evidence_service.py +315 -0
- backend/services/infrastructure_service.py +97 -0
- backend/services/memory_service.py +174 -0
- backend/services/orchestrator_service.py +1059 -0
- backend/services/output_quality.py +325 -0
- backend/services/project_service.py +52 -0
- backend/services/semantic_backprop.py +104 -0
- backend/services/supabase_service.py +13 -0
- backend/services/task_queue.py +235 -0
- backend/services/task_schemas.py +218 -0
- backend/services/utils.py +26 -0
- backend/tests/conftest.py +35 -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,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 and collaboration platform.
|
| 12 |
+
|
| 13 |
+
Aubm turns complex goals into supervised multi-agent workflows: projects, context, agents, tasks, dependencies, human approvals, reports, and operational monitoring in one workspace.
|
| 14 |
+
|
| 15 |
+
## Key Features
|
| 16 |
+
|
| 17 |
+
- Multi-provider LLM support through backend provider adapters.
|
| 18 |
+
- Project wizard for Guided and Expert creation flows.
|
| 19 |
+
- Agent marketplace for deploying reusable specialist agents.
|
| 20 |
+
- Task orchestration with priorities, dependencies, retries, and human approval.
|
| 21 |
+
- Multi-agent debate for cross-reviewing task outputs.
|
| 22 |
+
- Final reports: full report, short brief, pessimistic analysis, and PDF export.
|
| 23 |
+
- Project roadmap view inferred from task status, priority, and dependencies.
|
| 24 |
+
- Completed project locking: completed projects become read-only in the UI and backend mutation endpoints.
|
| 25 |
+
- Monitoring dashboard with backend health and Supabase fallback metrics.
|
| 26 |
+
- Voice control and spatial task visualization for expert workflows.
|
| 27 |
+
- Sentry-compatible error tracking hooks for backend and frontend.
|
| 28 |
+
|
| 29 |
+
See [ROADMAP.md](./ROADMAP.md) for the current implementation status. The roadmap is intentionally conservative and separates completed, partial, in-progress, and next work.
|
| 30 |
+
|
| 31 |
+
## Tech Stack
|
| 32 |
+
|
| 33 |
+
- Frontend: React + Vite + TypeScript + vanilla CSS.
|
| 34 |
+
- Backend: FastAPI on Python 3.10+.
|
| 35 |
+
- Database/Auth: Supabase Postgres + Supabase Auth.
|
| 36 |
+
- Deployment: Docker, Hugging Face Spaces, and Vercel configuration.
|
| 37 |
+
|
| 38 |
+
## Project Structure
|
| 39 |
+
|
| 40 |
+
```text
|
| 41 |
+
aubm/
|
| 42 |
+
backend/ FastAPI app, agents, routers, services, worker
|
| 43 |
+
database/ Supabase schema and migrations
|
| 44 |
+
docs/ Operating guide, audit notes, task plan, sales one-pager
|
| 45 |
+
frontend/ React/Vite app
|
| 46 |
+
ROADMAP.md Current product roadmap and status
|
| 47 |
+
SPEC.md Technical specification
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Database Setup
|
| 51 |
+
|
| 52 |
+
For a fresh Supabase project, apply:
|
| 53 |
+
|
| 54 |
+
```text
|
| 55 |
+
database/schema.sql
|
| 56 |
+
database/seed.sql
|
| 57 |
+
database/phase3_updates.sql
|
| 58 |
+
database/marketplace.sql
|
| 59 |
+
database/enterprise_security.sql
|
| 60 |
+
database/add_team_permissions.sql
|
| 61 |
+
database/agent_ownership.sql
|
| 62 |
+
database/task_owner_policies.sql
|
| 63 |
+
database/default_agents.sql
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
For existing projects, also apply any migration that matches your current error or missing capability:
|
| 67 |
+
|
| 68 |
+
```text
|
| 69 |
+
database/add_task_run_duration.sql
|
| 70 |
+
database/add_task_queued_status.sql
|
| 71 |
+
database/add_task_queue_leasing.sql
|
| 72 |
+
database/add_task_queue_retry_backoff.sql
|
| 73 |
+
database/add_worker_heartbeats.sql
|
| 74 |
+
database/add_audit_mutation_triggers.sql
|
| 75 |
+
database/add_task_claims.sql
|
| 76 |
+
database/add_profile_manager_role.sql
|
| 77 |
+
database/fix_profiles_rls_final.sql
|
| 78 |
+
database/fix_profiles_recursion.sql
|
| 79 |
+
database/add_team_permissions.sql
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
After schema changes, reload PostgREST when the migration includes:
|
| 83 |
+
|
| 84 |
+
```sql
|
| 85 |
+
NOTIFY pgrst, 'reload schema';
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
## Backend Setup
|
| 89 |
+
|
| 90 |
+
```powershell
|
| 91 |
+
cd backend
|
| 92 |
+
python -m venv venv
|
| 93 |
+
.\venv\Scripts\activate
|
| 94 |
+
pip install -r requirements.txt
|
| 95 |
+
uvicorn main:app --reload --port 8000
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Create `backend/.env`:
|
| 99 |
+
|
| 100 |
+
```env
|
| 101 |
+
SUPABASE_URL=your_project_url
|
| 102 |
+
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
| 103 |
+
OPENAI_API_KEY=optional_key
|
| 104 |
+
GROQ_API_KEY=optional_key
|
| 105 |
+
GEMINI_API_KEY=optional_key
|
| 106 |
+
AMD_API_KEY=optional_key
|
| 107 |
+
TAVILY_API_KEY=optional_key
|
| 108 |
+
SENTRY_DSN=optional_dsn
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## Frontend Setup
|
| 112 |
+
|
| 113 |
+
```powershell
|
| 114 |
+
cd frontend
|
| 115 |
+
npm install
|
| 116 |
+
npm run dev
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Create `frontend/.env`:
|
| 120 |
+
|
| 121 |
+
```env
|
| 122 |
+
VITE_API_URL=http://127.0.0.1:8000
|
| 123 |
+
VITE_SUPABASE_URL=your_project_url
|
| 124 |
+
VITE_SUPABASE_ANON_KEY=your_anon_key
|
| 125 |
+
VITE_SENTRY_DSN=optional_dsn
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Validation:
|
| 129 |
+
|
| 130 |
+
```powershell
|
| 131 |
+
cd frontend
|
| 132 |
+
npm run lint
|
| 133 |
+
npm run build
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
## Worker
|
| 137 |
+
|
| 138 |
+
A lightweight worker scaffold exists:
|
| 139 |
+
|
| 140 |
+
```powershell
|
| 141 |
+
cd backend
|
| 142 |
+
python worker.py
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
The worker uses `tasks.status = 'queued'` and atomically claims jobs with `claim_next_queued_task`. Existing databases must apply:
|
| 146 |
+
|
| 147 |
+
```text
|
| 148 |
+
database/add_task_queued_status.sql
|
| 149 |
+
database/add_task_queue_leasing.sql
|
| 150 |
+
database/add_task_queue_retry_backoff.sql
|
| 151 |
+
database/add_worker_heartbeats.sql
|
| 152 |
+
database/add_audit_mutation_triggers.sql
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
Worker retry behavior can be tuned with:
|
| 156 |
+
|
| 157 |
+
```env
|
| 158 |
+
AUBM_WORKER_MAX_ATTEMPTS=3
|
| 159 |
+
AUBM_WORKER_RETRY_DELAY_SECONDS=30
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
To route task/project execution through the worker, set:
|
| 163 |
+
|
| 164 |
+
```env
|
| 165 |
+
TASK_EXECUTION_MODE=queue
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
With `TASK_QUEUE_EMBEDDED_WORKER=true` (the default), the FastAPI process starts an embedded worker when queue mode is enabled. Set `TASK_QUEUE_EMBEDDED_WORKER=false` when running separate worker processes with `python worker.py`.
|
| 169 |
+
|
| 170 |
+
Without queue mode, execution remains direct/background for local development. Individual calls can opt into queue mode with `?use_queue=true`.
|
| 171 |
+
|
| 172 |
+
## Hugging Face Spaces
|
| 173 |
+
|
| 174 |
+
This repo can run as a Docker Space. Create a Hugging Face Space with SDK `Docker`, push this repo, and configure secrets:
|
| 175 |
+
|
| 176 |
+
```env
|
| 177 |
+
SUPABASE_URL=your_project_url
|
| 178 |
+
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
| 179 |
+
SUPABASE_ANON_KEY=your_anon_key
|
| 180 |
+
GROQ_API_KEY=optional_key
|
| 181 |
+
OPENAI_API_KEY=optional_key
|
| 182 |
+
GEMINI_API_KEY=optional_key
|
| 183 |
+
AMD_API_KEY=optional_key
|
| 184 |
+
TAVILY_API_KEY=optional_key
|
| 185 |
+
SENTRY_DSN=optional_dsn
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
`VITE_API_URL` can stay empty on Spaces when the frontend and FastAPI backend share the same origin.
|
| 189 |
+
|
| 190 |
+
## Documentation
|
| 191 |
+
|
| 192 |
+
- [SPEC.md](./SPEC.md): Technical architecture and contracts.
|
| 193 |
+
- [ROADMAP.md](./ROADMAP.md): Current implementation status and next work.
|
| 194 |
+
- [docs/OPERATING_GUIDE.md](./docs/OPERATING_GUIDE.md): Operational usage and setup.
|
| 195 |
+
- [docs/AUTH_MODEL.md](./docs/AUTH_MODEL.md): Enterprise authentication and OAuth policy.
|
| 196 |
+
- [docs/TASK_SCHEMAS.md](./docs/TASK_SCHEMAS.md): Structured task output schema rules.
|
| 197 |
+
- [docs/MIGRATION_GUIDE.md](./docs/MIGRATION_GUIDE.md): Existing Supabase project migrations.
|
| 198 |
+
- [docs/TASKS.md](./docs/TASKS.md): Implementation task tracker.
|
| 199 |
+
- [docs/AUDIT.md](./docs/AUDIT.md): Stability and risk audit.
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Aubm Roadmap
|
| 2 |
+
|
| 3 |
+
This document tracks the practical evolution of Aubm from a working multi-agent orchestrator into an enterprise-ready operating layer. Status is intentionally conservative:
|
| 4 |
+
|
| 5 |
+
- Completed: implemented and visible in the product or backend.
|
| 6 |
+
- Partial: scaffolded or implemented in a limited form, but not production-complete.
|
| 7 |
+
- Next: planned work with no complete implementation yet.
|
| 8 |
+
|
| 9 |
+
## Phase 1: Core Foundation (Completed)
|
| 10 |
+
- [x] Autonomous Agent Execution: Multi-provider support for configured LLM providers.
|
| 11 |
+
- [x] Project Orchestration: Project-level task execution with dependency-aware planning support.
|
| 12 |
+
- [x] Human-in-the-Loop: Approval and rejection workflows for agent outputs.
|
| 13 |
+
- [x] Project Context Injection: Project descriptions, context, notes, files, and links are passed into planning/execution.
|
| 14 |
+
- [x] Final Reporting: Full, brief, pessimistic, and PDF report flows.
|
| 15 |
+
|
| 16 |
+
## Phase 2: Collaboration and Operator Workflow (Completed)
|
| 17 |
+
- [x] Multi-Agent Debates: Agents can cross-review and refine task output before human review.
|
| 18 |
+
- [x] Agent Marketplace: Deploy reusable agent templates into a user's workspace.
|
| 19 |
+
- [x] Voice Interaction: Browser voice APIs can control navigation and read project/task status.
|
| 20 |
+
- [x] Spatial Dashboard: Layered project/task visualization for DAG-style inspection.
|
| 21 |
+
- [x] Guided and Expert Creation Wizard: Step-by-step project creation with explanations.
|
| 22 |
+
- [x] Project Roadmap View: Read-only roadmap modal inferred from task status, priority, and dependencies.
|
| 23 |
+
|
| 24 |
+
## Phase 3: Production Operations (Completed)
|
| 25 |
+
- [x] Operations Monitoring: Backend health endpoint and frontend monitoring dashboard with Supabase fallback.
|
| 26 |
+
- [x] Deployment Hardening: Dockerized backend/runtime profile and production CORS configuration.
|
| 27 |
+
- [x] Error Tracking Hooks: Sentry-compatible backend and frontend initialization.
|
| 28 |
+
- [x] Performance Budgeting: Frontend code splitting and bundle-size-aware build output.
|
| 29 |
+
- [x] Completed Project Locking: Completed projects are read-only in the UI and guarded by backend mutation checks.
|
| 30 |
+
|
| 31 |
+
## Phase 4: Security, Governance, and Data Quality (Partial)
|
| 32 |
+
- [x] Row-Level Security: Core Supabase RLS policies for projects, tasks, agents, profiles, marketplace templates, and admin access.
|
| 33 |
+
- [x] Admin and Manager Roles: Profile role support includes user, manager, and admin.
|
| 34 |
+
- [x] Profile Role Protection: Final profile RLS migration uses non-recursive admin checks and a trigger to block non-admin role escalation.
|
| 35 |
+
- [x] Audit Log Schema: Audit table and service exist.
|
| 36 |
+
- [/] Audit Log Coverage: Backend task runs, queue retries, approvals, debates, decomposition, and report generation write audit events; a trigger migration covers direct project, task, agent, and profile mutations.
|
| 37 |
+
- [/] Team Permissions: `teams`, `team_members`, project `team_id`, owner-or-team RLS policies, and team-aware evidence reads are available through migration; frontend/backend workflows still need full team-aware UX/API coverage.
|
| 38 |
+
- [x] SSO State: Google/GitHub buttons remain hidden by default, and the enterprise auth model is documented in `docs/AUTH_MODEL.md`.
|
| 39 |
+
|
| 40 |
+
## Phase 5: Async Execution and Scale (Complete)
|
| 41 |
+
- [x] Worker Scaffold: `backend/worker.py` and `TaskQueueService` exist.
|
| 42 |
+
- [x] Queued Task Status: `tasks.status` now supports `queued` for background workers.
|
| 43 |
+
- [x] Queue Safety: Workers claim queued tasks through an atomic Postgres lease function.
|
| 44 |
+
- [x] Worker Observability: Worker heartbeats, queue depth, stale leases, and active worker counts are visible in Monitoring.
|
| 45 |
+
- [x] Retry Policy: Queue attempts, exponential backoff, delayed retries, and terminal failure reasons are stored.
|
| 46 |
+
- [x] Worker Integration: Task and project run endpoints can route work to the queue with `TASK_EXECUTION_MODE=queue` or `use_queue=true`.
|
| 47 |
+
- [x] Queue Default: Sync execution is now fallback; queue mode is default in development and production.
|
| 48 |
+
|
| 49 |
+
## Phase 6: Evidence and Entity Integrity (Complete)
|
| 50 |
+
- [x] Strict JSON Task Schemas: Backend classifies structured task types, prompts for JSON, and blocks approval when required fields are missing.
|
| 51 |
+
- [x] Semantic Deduplication: Extracted claims use normalized text hashes and embedding-based semantic merging to avoid duplicates per project.
|
| 52 |
+
- [x] Mandatory `source_url` per Claim: Structured factual/comparison outputs require source URLs and extracted claims are stored in `task_claims`; approval is blocked if sources are missing for sensitive schemas.
|
| 53 |
+
- [x] Entity Normalization Layer: `task_claims` stores normalized `entity_key` values; new `EvidenceView` component provides a unified UI for semantic findings and entity intelligence.
|
| 54 |
+
- [x] Evidence-Aware Final Report: Final reports now consume consolidated claims from `task_claims` using semantic merging for high-accuracy strategic conclusions.
|
| 55 |
+
|
| 56 |
+
## Phase 7: Intelligence and Memory (Next)
|
| 57 |
+
- [x] Vectorized Long-Term Memory: Cross-project semantic retrieval over approved outputs and source material; implemented via `project_memory` and `match_project_memory` RPC.
|
| 58 |
+
- [x] Self-Optimizing Agents: Meta-prompting loops based on human feedback and task quality outcomes; rejections trigger intelligent analysis to generate 'Lessons Learned' for retries.
|
| 59 |
+
- [x] Cost Control: Project budgets, estimated usage events, and pre-run execution blocking are implemented; provider-native token usage tracking ensures billing-grade pricing reconciliation.
|
| 60 |
+
- [x] Real-Time Logs: Backend SSE stream for `agent_logs`, frontend console integration, project/task stream filters, and Supabase-token authorization are implemented.
|
| 61 |
+
- [x] Collaborative Editing: Manual output editing and human review sessions for generated outputs; implemented via `PATCH /tasks/{id}/output`.
|
| 62 |
+
|
| 63 |
+
## Phase 8: Enterprise Multi-Tenancy & Governance (Complete)
|
| 64 |
+
- [x] Team Management UI: Full interface for creating teams, inviting members, and assigning roles (admin, editor, viewer).
|
| 65 |
+
- [x] Team-Aware Project Creation: Select team workspaces during project setup to enable shared context and RLS-enforced collaboration.
|
| 66 |
+
- [x] Audit Explorer: Searchable and filterable UI for system-wide audit logs, including metadata inspection and deep links.
|
| 67 |
+
- [x] Bulk Audit Export: Download audit logs as CSV for compliance and external reporting.
|
| 68 |
+
- [x] Role-Based Marketplace: Teams can publish and share internal agent templates within their own workspace; implemented via `team_id` on templates and AgentsView sharing.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
*Last updated: May 7, 2026*
|
SPEC.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Aubm Technical Specification
|
| 2 |
+
|
| 3 |
+
Target stack: FastAPI + React/TypeScript + Supabase.
|
| 4 |
+
|
| 5 |
+
This document describes the current product architecture and the contracts that matter for development. For status and sequencing, see [ROADMAP.md](./ROADMAP.md).
|
| 6 |
+
|
| 7 |
+
## 1. Architecture
|
| 8 |
+
|
| 9 |
+
Aubm uses Supabase as the source of truth for users, projects, agents, tasks, templates, and execution records.
|
| 10 |
+
|
| 11 |
+
```text
|
| 12 |
+
backend/
|
| 13 |
+
main.py FastAPI entrypoint
|
| 14 |
+
worker.py Polling worker scaffold for queued tasks
|
| 15 |
+
agents/ LLM provider adapters
|
| 16 |
+
routers/
|
| 17 |
+
agent_runner.py Task run, approve, reject, approve-all
|
| 18 |
+
orchestrator.py Debate, project run, report, PDF export
|
| 19 |
+
services/
|
| 20 |
+
orchestrator_service.py Project orchestration and report building
|
| 21 |
+
agent_runner_service.py Task execution and task_runs persistence
|
| 22 |
+
task_queue.py Lightweight queued-task helper
|
| 23 |
+
output_quality.py Heuristic output quality checks
|
| 24 |
+
semantic_backprop.py Prior completed-output context builder
|
| 25 |
+
tools/ Tool registry and tool implementations
|
| 26 |
+
|
| 27 |
+
frontend/
|
| 28 |
+
src/components/ Dashboard, project detail, marketplace, settings, monitoring
|
| 29 |
+
src/services/ Supabase, runtime config, LLM defaults, UI mode
|
| 30 |
+
src/context/ Auth context
|
| 31 |
+
|
| 32 |
+
database/
|
| 33 |
+
schema.sql Baseline schema
|
| 34 |
+
*.sql Idempotent migrations and seed files
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
## 2. Database
|
| 38 |
+
|
| 39 |
+
### Core Tables
|
| 40 |
+
|
| 41 |
+
| Table | Purpose |
|
| 42 |
+
| --- | --- |
|
| 43 |
+
| `profiles` | User metadata and role: `user`, `manager`, `admin`. |
|
| 44 |
+
| `projects` | Project containers with owner, context, status, visibility. |
|
| 45 |
+
| `agents` | Deployed agents owned by users or global templates. |
|
| 46 |
+
| `agent_templates` | Marketplace agent templates. |
|
| 47 |
+
| `tasks` | Units of work with status, priority, assigned agent, output data. |
|
| 48 |
+
| `task_runs` | Execution history, status, errors, duration. |
|
| 49 |
+
| `agent_logs` | Execution traces. |
|
| 50 |
+
| `task_dependencies` | Task dependency edges. |
|
| 51 |
+
| `audit_logs` | Governance trail. Coverage is partial and should be expanded. |
|
| 52 |
+
| `task_feedback` | Like/dislike feedback for future optimization. |
|
| 53 |
+
| `worker_heartbeats` | Background worker status and processing counters. |
|
| 54 |
+
|
| 55 |
+
### Status Values
|
| 56 |
+
|
| 57 |
+
Projects:
|
| 58 |
+
|
| 59 |
+
```text
|
| 60 |
+
active, archived, completed
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
Tasks:
|
| 64 |
+
|
| 65 |
+
```text
|
| 66 |
+
todo, queued, in_progress, awaiting_approval, done, failed, cancelled
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Task runs:
|
| 70 |
+
|
| 71 |
+
```text
|
| 72 |
+
queued, running, completed, failed, cancelled
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Completed projects are locked by frontend controls and backend mutation checks. Reports remain available.
|
| 76 |
+
|
| 77 |
+
## 3. Backend Contracts
|
| 78 |
+
|
| 79 |
+
### Task Execution
|
| 80 |
+
|
| 81 |
+
`POST /tasks/{task_id}/run`
|
| 82 |
+
|
| 83 |
+
Optional query:
|
| 84 |
+
|
| 85 |
+
```text
|
| 86 |
+
use_queue=true
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
1. Load task and assigned agent.
|
| 90 |
+
2. Reject execution if the parent project is completed.
|
| 91 |
+
3. If `use_queue=true` or `TASK_EXECUTION_MODE=queue`, set task to `queued` for worker execution.
|
| 92 |
+
4. Otherwise set task to `in_progress` and execute through `AgentRunnerService`.
|
| 93 |
+
5. Write `task_runs`, `agent_logs`, and task output.
|
| 94 |
+
6. Set task to `awaiting_approval` or `failed`.
|
| 95 |
+
|
| 96 |
+
### Task Review
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
POST /tasks/{task_id}/approve
|
| 100 |
+
POST /tasks/{task_id}/reject
|
| 101 |
+
POST /tasks/project/{project_id}/approve-all
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Approval runs output quality checks before moving a task to `done`. Rejection moves the task back to `todo`. These mutations are blocked when the project is completed.
|
| 105 |
+
|
| 106 |
+
### Project Orchestration
|
| 107 |
+
|
| 108 |
+
`POST /orchestrator/projects/{project_id}/run`
|
| 109 |
+
|
| 110 |
+
Runs `todo` and `failed` tasks in priority order and assigns available agents when needed. If the project has no tasks, it can decompose the project into tasks. Completed projects are not mutable and cannot be orchestrated again.
|
| 111 |
+
|
| 112 |
+
Queue mode:
|
| 113 |
+
|
| 114 |
+
- `TASK_EXECUTION_MODE=queue`, or
|
| 115 |
+
- `POST /orchestrator/projects/{project_id}/run?use_queue=true`
|
| 116 |
+
|
| 117 |
+
In queue mode, runnable tasks are assigned and moved to `queued` for `backend/worker.py`.
|
| 118 |
+
|
| 119 |
+
### Reports
|
| 120 |
+
|
| 121 |
+
```text
|
| 122 |
+
GET /orchestrator/projects/{project_id}/final-report?variant=full|brief|pessimistic
|
| 123 |
+
GET /orchestrator/projects/{project_id}/final-report.pdf?variant=full|brief|pessimistic
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
Reports are built from approved task output. Full report generation marks the project completed.
|
| 127 |
+
|
| 128 |
+
### Queue Worker
|
| 129 |
+
|
| 130 |
+
`backend/worker.py` polls `tasks.status = 'queued'` through `TaskQueueService`.
|
| 131 |
+
|
| 132 |
+
Current state:
|
| 133 |
+
|
| 134 |
+
- Worker scaffold exists.
|
| 135 |
+
- `queued` task status is supported by schema/migration.
|
| 136 |
+
- Task and project run endpoints can opt into queue mode.
|
| 137 |
+
- Workers claim tasks through `claim_next_queued_task`, an atomic Postgres function using `FOR UPDATE SKIP LOCKED`.
|
| 138 |
+
- Queue attempts, delayed retry time, and terminal failure text are stored on `tasks`.
|
| 139 |
+
- Worker heartbeat, active worker count, queue depth, delayed retry count, and stale lease metrics are exposed in Monitoring.
|
| 140 |
+
|
| 141 |
+
## 4. Frontend
|
| 142 |
+
|
| 143 |
+
### Primary Views
|
| 144 |
+
|
| 145 |
+
- Dashboard: project cards, search, filters, status/progress sorting.
|
| 146 |
+
- New Project: wizard available in Guided and Expert modes.
|
| 147 |
+
- Project Detail: task management, guided workflow, reports, roadmap modal.
|
| 148 |
+
- Marketplace: agent template search and deploy.
|
| 149 |
+
- Agents: custom agent management.
|
| 150 |
+
- Debate: two-agent review flow.
|
| 151 |
+
- Monitoring: backend-first health summary with Supabase fallback.
|
| 152 |
+
- Voice Control: browser speech navigation/status.
|
| 153 |
+
- Spatial View: DAG-style task visualization.
|
| 154 |
+
- Settings: provider defaults, UI mode, user role management.
|
| 155 |
+
|
| 156 |
+
### UI Modes
|
| 157 |
+
|
| 158 |
+
Guided:
|
| 159 |
+
|
| 160 |
+
- Simplified navigation and workflows.
|
| 161 |
+
- Project wizard steps: Basics, Context, Sources, Review.
|
| 162 |
+
|
| 163 |
+
Expert:
|
| 164 |
+
|
| 165 |
+
- Advanced tools and settings.
|
| 166 |
+
- Project wizard steps: Basics, Context, Sources, Access, Review.
|
| 167 |
+
|
| 168 |
+
## 5. Security
|
| 169 |
+
|
| 170 |
+
- Supabase Auth is used for authentication.
|
| 171 |
+
- Email/password is the visible login method in the current UI.
|
| 172 |
+
- Google/GitHub OAuth buttons are hidden. If OAuth is enabled in Supabase, follow `docs/AUTH_MODEL.md` before exposing OAuth buttons again.
|
| 173 |
+
- RLS policies protect project ownership, tasks, agents, templates, and profiles.
|
| 174 |
+
- Admin profile checks use a SECURITY DEFINER helper to avoid recursive RLS policies.
|
| 175 |
+
- Manager role is supported in profile constraints and admin tooling.
|
| 176 |
+
|
| 177 |
+
## 6. Current Gaps
|
| 178 |
+
|
| 179 |
+
- Audit log coverage is incomplete.
|
| 180 |
+
- Real-time logs are persisted, but true SSE/WebSocket streaming is not complete.
|
| 181 |
+
- Cost control exists only as provider token configuration, not persisted budget enforcement.
|
| 182 |
+
- Structured task schemas and `task_claims` evidence extraction exist for common task types. Extracted claims include normalized entity keys and claim hashes. Final reports include normalized evidence summaries, but they are not yet built exclusively from normalized evidence.
|
| 183 |
+
- Worker queue has atomic leasing, retry backoff, and heartbeat monitoring. Queue mode remains opt-in until it is made the default execution path.
|
| 184 |
+
|
| 185 |
+
## 7. Validation
|
| 186 |
+
|
| 187 |
+
Frontend:
|
| 188 |
+
|
| 189 |
+
```powershell
|
| 190 |
+
cd frontend
|
| 191 |
+
npm run lint
|
| 192 |
+
npm run build
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
Backend syntax spot checks:
|
| 196 |
+
|
| 197 |
+
```powershell
|
| 198 |
+
python -m py_compile backend\worker.py backend\services\task_queue.py
|
| 199 |
+
python -m py_compile backend\routers\agent_runner.py backend\routers\orchestrator.py
|
| 200 |
+
```
|
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,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 .digitalocean_agent import DigitalOceanAgent
|
| 9 |
+
from services.config import settings
|
| 10 |
+
|
| 11 |
+
# Map of providers to their respective classes
|
| 12 |
+
PROVIDER_MAP: Dict[str, Type[BaseAgent]] = {
|
| 13 |
+
"openai": OpenAIAgent,
|
| 14 |
+
"amd": AMDAgent,
|
| 15 |
+
"groq": GroqAgent,
|
| 16 |
+
"gemini": GeminiAgent,
|
| 17 |
+
"local": LocalAgent,
|
| 18 |
+
"ollama": LocalAgent,
|
| 19 |
+
"digitalocean": DigitalOceanAgent
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
class AgentFactory:
|
| 23 |
+
@staticmethod
|
| 24 |
+
def get_agent(provider: str, name: str, role: str, model: str, system_prompt: str = None) -> BaseAgent:
|
| 25 |
+
"""
|
| 26 |
+
Instantiates the appropriate agent based on the provider string.
|
| 27 |
+
Includes a fallback to Groq if OpenAI is requested but no key is provided.
|
| 28 |
+
"""
|
| 29 |
+
provider = provider.lower()
|
| 30 |
+
|
| 31 |
+
# Fallback Logic: OpenAI -> AMD -> Groq
|
| 32 |
+
if provider == "openai" and not settings.OPENAI_API_KEY:
|
| 33 |
+
if settings.AMD_API_KEY:
|
| 34 |
+
provider = "amd"
|
| 35 |
+
model = "llama-3.3-70b-instruct"
|
| 36 |
+
elif settings.GROQ_API_KEY:
|
| 37 |
+
provider = "groq"
|
| 38 |
+
model = "llama-3.3-70b-versatile"
|
| 39 |
+
|
| 40 |
+
agent_class = PROVIDER_MAP.get(provider)
|
| 41 |
+
|
| 42 |
+
if not agent_class:
|
| 43 |
+
raise ValueError(f"Unsupported agent provider: {provider}")
|
| 44 |
+
|
| 45 |
+
return agent_class(name=name, role=role, model=model, system_prompt=system_prompt)
|
backend/agents/amd_agent.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = None
|
| 18 |
+
if api_key:
|
| 19 |
+
self.client = openai.AsyncOpenAI(
|
| 20 |
+
api_key=api_key,
|
| 21 |
+
base_url=self.provider_config.get("base_url", "https://inference.do-ai.run/v1")
|
| 22 |
+
)
|
| 23 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 24 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 25 |
+
|
| 26 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 27 |
+
if not self.client:
|
| 28 |
+
return {
|
| 29 |
+
"agent_name": self.name,
|
| 30 |
+
"provider": "amd",
|
| 31 |
+
"raw_output": "Error: AMD API Key not configured.",
|
| 32 |
+
"data": {"error": "Missing credentials"}
|
| 33 |
+
}
|
| 34 |
+
return await self._run_openai_compatible(
|
| 35 |
+
provider="amd",
|
| 36 |
+
create_fn=self.client.chat.completions.create,
|
| 37 |
+
task_description=task_description,
|
| 38 |
+
context=context,
|
| 39 |
+
use_tools=use_tools,
|
| 40 |
+
extra_context=extra_context,
|
| 41 |
+
response_format={"type": "json_object"}
|
| 42 |
+
)
|
backend/agents/base.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
usage = getattr(response, "usage", None)
|
| 138 |
+
|
| 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 |
+
final_usage = getattr(final_response, "usage", None)
|
| 150 |
+
if usage and final_usage:
|
| 151 |
+
usage.prompt_tokens += final_usage.prompt_tokens
|
| 152 |
+
usage.completion_tokens += final_usage.completion_tokens
|
| 153 |
+
usage.total_tokens += final_usage.total_tokens
|
| 154 |
+
elif final_usage:
|
| 155 |
+
usage = final_usage
|
| 156 |
+
|
| 157 |
+
content = final_response.choices[0].message.content
|
| 158 |
+
else:
|
| 159 |
+
content = message.content
|
| 160 |
+
|
| 161 |
+
usage_dict = None
|
| 162 |
+
if usage:
|
| 163 |
+
usage_dict = {
|
| 164 |
+
"prompt_tokens": getattr(usage, "prompt_tokens", 0),
|
| 165 |
+
"completion_tokens": getattr(usage, "completion_tokens", 0),
|
| 166 |
+
"total_tokens": getattr(usage, "total_tokens", 0)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return self._result(provider, content or "", usage=usage_dict)
|
| 170 |
+
|
| 171 |
+
def _result(self, provider: str, content: str, usage: Optional[Dict[str, int]] = None) -> Dict[str, Any]:
|
| 172 |
+
return {
|
| 173 |
+
"agent_name": self.name,
|
| 174 |
+
"provider": provider,
|
| 175 |
+
"model": self.model,
|
| 176 |
+
"raw_output": content,
|
| 177 |
+
"usage": usage,
|
| 178 |
+
"data": self._parse_json_output(content)
|
| 179 |
+
}
|
backend/agents/digitalocean_agent.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 DigitalOceanAgent(BaseAgent):
|
| 7 |
+
"""
|
| 8 |
+
Agent provider using DigitalOcean's Gradient Inference API.
|
| 9 |
+
Supports both Serverless Inference and dedicated Agent Inference endpoints.
|
| 10 |
+
"""
|
| 11 |
+
def __init__(self, name: str, role: str, model: str = "llama-3.3-70b-instruct", system_prompt: str = None):
|
| 12 |
+
super().__init__(name, role, model, system_prompt)
|
| 13 |
+
|
| 14 |
+
# Load dynamic config
|
| 15 |
+
self.provider_config = config_service.get_provider_config("digitalocean")
|
| 16 |
+
|
| 17 |
+
# Priority: Agent Access Key -> Inference Key -> AMD Key -> DO Token
|
| 18 |
+
api_key = (
|
| 19 |
+
self.provider_config.get("agent_access_key") or
|
| 20 |
+
settings.DO_AGENT_ACCESS_KEY or
|
| 21 |
+
self.provider_config.get("api_key") or
|
| 22 |
+
settings.DO_INFERENCE_KEY or
|
| 23 |
+
settings.AMD_API_KEY or
|
| 24 |
+
settings.DO_API_TOKEN
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Priority: Agent Endpoint -> Default Serverless Endpoint
|
| 28 |
+
base_url = (
|
| 29 |
+
self.provider_config.get("base_url") or
|
| 30 |
+
settings.DO_AGENT_ENDPOINT or
|
| 31 |
+
"https://inference.do-ai.run/v1"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Ensure base_url has the correct suffix if it's a raw agent URL
|
| 35 |
+
if ".agents.do-ai.run" in base_url and not base_url.endswith("/v1"):
|
| 36 |
+
base_url = f"{base_url.rstrip('/')}/v1"
|
| 37 |
+
elif "api.digitalocean.com" not in base_url and "do-ai.run" not in base_url:
|
| 38 |
+
# Fallback logic for potentially missing /v1 in custom domains
|
| 39 |
+
if not base_url.endswith("/v1"):
|
| 40 |
+
base_url = f"{base_url.rstrip('/')}/v1"
|
| 41 |
+
|
| 42 |
+
self.client = openai.AsyncOpenAI(
|
| 43 |
+
api_key=api_key,
|
| 44 |
+
base_url=base_url
|
| 45 |
+
)
|
| 46 |
+
self.is_agent_endpoint = "agents.do-ai.run" in base_url or settings.DO_AGENT_ENDPOINT is not None
|
| 47 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 48 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 49 |
+
|
| 50 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 51 |
+
# DigitalOcean Agent Inference requires ?agent=true
|
| 52 |
+
extra_query = {"agent": "true"} if self.is_agent_endpoint else {}
|
| 53 |
+
|
| 54 |
+
return await self._run_openai_compatible(
|
| 55 |
+
provider="digitalocean",
|
| 56 |
+
create_fn=self.client.chat.completions.create,
|
| 57 |
+
task_description=task_description,
|
| 58 |
+
context=context,
|
| 59 |
+
use_tools=use_tools,
|
| 60 |
+
extra_context=extra_context,
|
| 61 |
+
extra_query=extra_query
|
| 62 |
+
)
|
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,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 or "llama3-70b-8192" 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 = None
|
| 37 |
+
if api_key:
|
| 38 |
+
self.client = groq.AsyncGroq(api_key=api_key)
|
| 39 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 40 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 41 |
+
self.reasoning_effort = self.provider_config.get("reasoning_effort", "medium")
|
| 42 |
+
|
| 43 |
+
def _format_context(self, context: List[Dict[str, Any]]) -> str:
|
| 44 |
+
"""Extremely aggressive truncation for Groq TPM limits."""
|
| 45 |
+
if not context:
|
| 46 |
+
return "No previous context available."
|
| 47 |
+
|
| 48 |
+
# Only take the last 3 tasks to save tokens
|
| 49 |
+
recent_context = context[-3:]
|
| 50 |
+
|
| 51 |
+
formatted = "Previous tasks context (EXTREMELY TRUNCATED for Groq):\n"
|
| 52 |
+
for item in recent_context:
|
| 53 |
+
output_raw = json.dumps(item.get('output_data', {}))
|
| 54 |
+
# 800 chars is roughly 200 tokens.
|
| 55 |
+
if len(output_raw) > 800:
|
| 56 |
+
output_raw = output_raw[:800] + "... [TRUNCATED]"
|
| 57 |
+
|
| 58 |
+
formatted += f"- Task: {item.get('title')}\n Output: {output_raw}\n"
|
| 59 |
+
return formatted
|
| 60 |
+
|
| 61 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 62 |
+
# Very limited semantic context
|
| 63 |
+
if len(extra_context) > 1000:
|
| 64 |
+
extra_context = extra_context[:1000] + "... [TRUNCATED]"
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
return await self._execute_run(task_description, context, use_tools, extra_context)
|
| 68 |
+
except groq.RateLimitError as e:
|
| 69 |
+
logger.warning(f"Rate limit reached for {self.model} (429). Attempting model rotation...")
|
| 70 |
+
|
| 71 |
+
# Find current model index in pool
|
| 72 |
+
try:
|
| 73 |
+
current_idx = GROQ_ROTATION_POOL.index(self.model)
|
| 74 |
+
except ValueError:
|
| 75 |
+
current_idx = -1
|
| 76 |
+
|
| 77 |
+
# Try the next model in the pool
|
| 78 |
+
next_idx = (current_idx + 1) % len(GROQ_ROTATION_POOL)
|
| 79 |
+
fallback_model = GROQ_ROTATION_POOL[next_idx]
|
| 80 |
+
|
| 81 |
+
logger.info(f"Rotating from {self.model} to {fallback_model}")
|
| 82 |
+
self.model = fallback_model
|
| 83 |
+
|
| 84 |
+
# Retry once with fallback model
|
| 85 |
+
return await self._execute_run(task_description, context, use_tools, extra_context)
|
| 86 |
+
|
| 87 |
+
async def _execute_run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 88 |
+
if not self.client:
|
| 89 |
+
return {
|
| 90 |
+
"agent_name": self.name,
|
| 91 |
+
"provider": "groq",
|
| 92 |
+
"raw_output": "Error: Groq API Key not configured.",
|
| 93 |
+
"data": {"error": "Missing credentials"}
|
| 94 |
+
}
|
| 95 |
+
extra_kwargs = {}
|
| 96 |
+
if "gpt-oss-" in self.model:
|
| 97 |
+
extra_kwargs["reasoning_effort"] = self.reasoning_effort
|
| 98 |
+
|
| 99 |
+
return await self._run_openai_compatible(
|
| 100 |
+
provider="groq",
|
| 101 |
+
create_fn=self.client.chat.completions.create,
|
| 102 |
+
task_description=task_description,
|
| 103 |
+
context=context,
|
| 104 |
+
use_tools=use_tools,
|
| 105 |
+
extra_context=extra_context,
|
| 106 |
+
**extra_kwargs
|
| 107 |
+
)
|
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,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = None
|
| 16 |
+
if api_key:
|
| 17 |
+
self.client = openai.AsyncOpenAI(api_key=api_key)
|
| 18 |
+
self.temperature = self.provider_config.get("temperature", 0.7)
|
| 19 |
+
self.max_tokens = self.provider_config.get("max_tokens", 4096)
|
| 20 |
+
|
| 21 |
+
async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]:
|
| 22 |
+
if not self.client:
|
| 23 |
+
return {
|
| 24 |
+
"agent_name": self.name,
|
| 25 |
+
"provider": "openai",
|
| 26 |
+
"raw_output": "Error: OpenAI API Key not configured.",
|
| 27 |
+
"data": {"error": "Missing credentials"}
|
| 28 |
+
}
|
| 29 |
+
return await self._run_openai_compatible(
|
| 30 |
+
provider="openai",
|
| 31 |
+
create_fn=self.client.chat.completions.create,
|
| 32 |
+
task_description=task_description,
|
| 33 |
+
context=context,
|
| 34 |
+
use_tools=use_tools,
|
| 35 |
+
extra_context=extra_context,
|
| 36 |
+
response_format={"type": "json_object"}
|
| 37 |
+
)
|
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,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse, Response, JSONResponse
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
import sentry_sdk
|
| 11 |
+
from services.orchestrator_service import orchestrator_service
|
| 12 |
+
from services.infrastructure_service import infrastructure_service
|
| 13 |
+
from services.config import settings
|
| 14 |
+
from worker import AubmWorker
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _load_app_version() -> str:
|
| 18 |
+
version_file = Path(__file__).resolve().parent.parent / "VERSION"
|
| 19 |
+
if version_file.exists():
|
| 20 |
+
value = version_file.read_text(encoding="utf-8").strip()
|
| 21 |
+
if value:
|
| 22 |
+
return value
|
| 23 |
+
return os.getenv("APP_VERSION", "0.7.0")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Load environment variables
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
# Silence noisy libraries
|
| 30 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 31 |
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 32 |
+
logging.getLogger("supabase").setLevel(logging.WARNING)
|
| 33 |
+
logging.getLogger("postgrest").setLevel(logging.WARNING)
|
| 34 |
+
|
| 35 |
+
FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 36 |
+
APP_VERSION = _load_app_version()
|
| 37 |
+
logger = logging.getLogger("aubm.api")
|
| 38 |
+
embedded_worker: AubmWorker | None = None
|
| 39 |
+
embedded_worker_task: asyncio.Task | None = None
|
| 40 |
+
|
| 41 |
+
# Sentry Initialization
|
| 42 |
+
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
| 43 |
+
if SENTRY_DSN:
|
| 44 |
+
sentry_sdk.init(
|
| 45 |
+
dsn=SENTRY_DSN,
|
| 46 |
+
traces_sample_rate=1.0,
|
| 47 |
+
profiles_sample_rate=1.0,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
app = FastAPI(
|
| 51 |
+
title="Aubm API",
|
| 52 |
+
description="Enterprise-Grade AI Agent Orchestration & Collaboration Platform",
|
| 53 |
+
version=APP_VERSION
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# CORS Configuration
|
| 57 |
+
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173").split(",")
|
| 58 |
+
app.add_middleware(
|
| 59 |
+
CORSMiddleware,
|
| 60 |
+
allow_origins=allowed_origins if allowed_origins != ["*"] else ["*"],
|
| 61 |
+
allow_origin_regex=os.getenv("ALLOWED_ORIGIN_REGEX"),
|
| 62 |
+
allow_credentials=True,
|
| 63 |
+
allow_methods=["*"],
|
| 64 |
+
allow_headers=["*"],
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _log_embedded_worker_result(task: asyncio.Task) -> None:
|
| 69 |
+
if task.cancelled():
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
exc = task.exception()
|
| 73 |
+
if exc:
|
| 74 |
+
logger.error(
|
| 75 |
+
"Embedded worker stopped unexpectedly",
|
| 76 |
+
exc_info=(type(exc), exc, exc.__traceback__),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.on_event("startup")
|
| 81 |
+
async def start_embedded_worker() -> None:
|
| 82 |
+
global embedded_worker, embedded_worker_task
|
| 83 |
+
|
| 84 |
+
if settings.TASK_EXECUTION_MODE != "queue" or not settings.TASK_QUEUE_EMBEDDED_WORKER:
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
if embedded_worker_task and not embedded_worker_task.done():
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
embedded_worker = AubmWorker()
|
| 91 |
+
embedded_worker_task = asyncio.create_task(embedded_worker.start())
|
| 92 |
+
embedded_worker_task.add_done_callback(_log_embedded_worker_result)
|
| 93 |
+
logger.info("Embedded task worker started: %s", embedded_worker.worker_id)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@app.on_event("shutdown")
|
| 97 |
+
async def stop_embedded_worker() -> None:
|
| 98 |
+
global embedded_worker, embedded_worker_task
|
| 99 |
+
|
| 100 |
+
if not embedded_worker or not embedded_worker_task:
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
embedded_worker.stop()
|
| 104 |
+
try:
|
| 105 |
+
await asyncio.wait_for(embedded_worker_task, timeout=10)
|
| 106 |
+
await embedded_worker.heartbeat("stopping")
|
| 107 |
+
except asyncio.TimeoutError:
|
| 108 |
+
embedded_worker_task.cancel()
|
| 109 |
+
logger.warning("Embedded task worker did not stop before timeout")
|
| 110 |
+
finally:
|
| 111 |
+
embedded_worker = None
|
| 112 |
+
embedded_worker_task = None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.get("/")
|
| 116 |
+
async def root():
|
| 117 |
+
index_path = FRONTEND_DIST / "index.html"
|
| 118 |
+
if index_path.exists():
|
| 119 |
+
return FileResponse(index_path)
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"status": "online",
|
| 123 |
+
"message": "Aubm API is operational",
|
| 124 |
+
"version": APP_VERSION
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
# Placeholder for routers
|
| 128 |
+
from routers import orchestrator, monitoring, agent_runner, generator
|
| 129 |
+
|
| 130 |
+
app.include_router(agent_runner.router, prefix="/api/tasks", tags=["Tasks"])
|
| 131 |
+
app.include_router(orchestrator.router, prefix="/api/orchestrator", tags=["orchestrator"])
|
| 132 |
+
app.include_router(generator.router, prefix="/api/generator", tags=["generator"])
|
| 133 |
+
app.include_router(monitoring.router, prefix="/api/monitoring", tags=["Monitoring"])
|
| 134 |
+
|
| 135 |
+
@app.get("/runtime-config.js", include_in_schema=False)
|
| 136 |
+
async def runtime_config():
|
| 137 |
+
config = {
|
| 138 |
+
"apiUrl": os.getenv("VITE_API_URL", ""),
|
| 139 |
+
"supabaseUrl": os.getenv("VITE_SUPABASE_URL", os.getenv("SUPABASE_URL", "")),
|
| 140 |
+
"supabaseAnonKey": os.getenv("VITE_SUPABASE_ANON_KEY", os.getenv("SUPABASE_ANON_KEY", "")),
|
| 141 |
+
"sentryDsn": os.getenv("VITE_SENTRY_DSN", os.getenv("SENTRY_DSN", "")),
|
| 142 |
+
"appVersion": APP_VERSION,
|
| 143 |
+
}
|
| 144 |
+
return Response(
|
| 145 |
+
content=f"window.__AUBM_CONFIG__ = {json.dumps(config)};",
|
| 146 |
+
media_type="application/javascript",
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
@app.get("/{path:path}", include_in_schema=False)
|
| 150 |
+
async def serve_frontend(path: str):
|
| 151 |
+
if not FRONTEND_DIST.exists():
|
| 152 |
+
return await root()
|
| 153 |
+
|
| 154 |
+
requested_path = FRONTEND_DIST / path
|
| 155 |
+
if requested_path.is_file():
|
| 156 |
+
return FileResponse(requested_path)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# For SPA routing, serve index.html for all other paths,
|
| 161 |
+
# but NOT for paths starting with api/ (which should have been caught by routers)
|
| 162 |
+
if path.startswith("api/"):
|
| 163 |
+
return JSONResponse(status_code=404, content={"detail": f"API route not found: /{path}"})
|
| 164 |
+
|
| 165 |
+
index_path = FRONTEND_DIST / "index.html"
|
| 166 |
+
if index_path.exists():
|
| 167 |
+
return FileResponse(index_path)
|
| 168 |
+
|
| 169 |
+
return await root()
|
| 170 |
+
|
| 171 |
+
# --- Infrastructure Management ---
|
| 172 |
+
|
| 173 |
+
@app.post("/infrastructure/nodes/provision")
|
| 174 |
+
async def provision_node(name: str = "aubm-inference-node", size: str = "s-4vcpu-8gb-amd"):
|
| 175 |
+
"""Creates a new inference node on DigitalOcean."""
|
| 176 |
+
node = await infrastructure_service.create_inference_node(name, size)
|
| 177 |
+
if not node:
|
| 178 |
+
raise HTTPException(status_code=500, detail="Failed to initiate node provisioning.")
|
| 179 |
+
return node
|
| 180 |
+
|
| 181 |
+
@app.get("/infrastructure/nodes/{droplet_id}/ip")
|
| 182 |
+
async def get_node_ip(droplet_id: int):
|
| 183 |
+
"""Wait and return the public IP of a node."""
|
| 184 |
+
ip = await infrastructure_service.wait_for_ip(droplet_id)
|
| 185 |
+
if not ip:
|
| 186 |
+
raise HTTPException(status_code=404, detail="IP not assigned or timed out.")
|
| 187 |
+
return {"ip": ip}
|
| 188 |
+
|
| 189 |
+
@app.delete("/infrastructure/nodes/{droplet_id}")
|
| 190 |
+
async def terminate_node(droplet_id: int):
|
| 191 |
+
"""Destroy an inference node."""
|
| 192 |
+
success = await infrastructure_service.terminate_node(droplet_id)
|
| 193 |
+
if not success:
|
| 194 |
+
raise HTTPException(status_code=500, detail="Failed to terminate node.")
|
| 195 |
+
return {"status": "termination_requested"}
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
import uvicorn
|
| 199 |
+
uvicorn.run(app, host="0.0.0.0", port=int(settings.PORT))
|
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,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from services.supabase_service import supabase
|
| 4 |
+
from services.agent_runner_service import AgentRunnerService
|
| 5 |
+
from services.config import settings
|
| 6 |
+
from services.audit_service import audit_service
|
| 7 |
+
from services.output_quality import report_text_from_output
|
| 8 |
+
from services.task_queue import TaskQueueService
|
| 9 |
+
from services.memory_service import memory_service
|
| 10 |
+
from services.project_service import project_service
|
| 11 |
+
from services.utils import log_async_task_result
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
logger = logging.getLogger("uvicorn")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _assert_task_quality(task: dict):
|
| 21 |
+
output_data = task.get("output_data") or {}
|
| 22 |
+
if not isinstance(output_data, dict):
|
| 23 |
+
raise HTTPException(status_code=400, detail="Task output is missing or malformed.")
|
| 24 |
+
if output_data.get("error"):
|
| 25 |
+
raise HTTPException(status_code=400, detail=f"Task execution failed: {output_data['error']}")
|
| 26 |
+
rendered = report_text_from_output(output_data).strip()
|
| 27 |
+
if not rendered or rendered in ("{}", "[]"):
|
| 28 |
+
raise HTTPException(status_code=400, detail="Task has no usable output to approve.")
|
| 29 |
+
quality_review = output_data.get("quality_review")
|
| 30 |
+
if not quality_review:
|
| 31 |
+
raise HTTPException(status_code=400, detail="Task output is missing quality validation.")
|
| 32 |
+
if quality_review.get("approved"):
|
| 33 |
+
return
|
| 34 |
+
reasons = quality_review.get("fail_reasons") or ["Task output failed quality validation."]
|
| 35 |
+
raise HTTPException(status_code=400, detail=f"Task output failed quality review: {'; '.join(reasons)}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _assert_task_project_is_mutable(task: dict):
|
| 39 |
+
project_id = task.get("project_id")
|
| 40 |
+
if project_id:
|
| 41 |
+
project_service.ensure_project_is_mutable(project_id)
|
| 42 |
+
|
| 43 |
+
def update_task_status(task_id: str, status: str):
|
| 44 |
+
task_res = supabase.table("tasks").select("project_id").eq("id", task_id).single().execute()
|
| 45 |
+
if not task_res.data:
|
| 46 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 47 |
+
_assert_task_project_is_mutable(task_res.data)
|
| 48 |
+
|
| 49 |
+
result = (
|
| 50 |
+
supabase.table("tasks")
|
| 51 |
+
.update({"status": status})
|
| 52 |
+
.eq("id", task_id)
|
| 53 |
+
.execute()
|
| 54 |
+
)
|
| 55 |
+
if not result.data:
|
| 56 |
+
raise HTTPException(status_code=404, detail="Task not found or status was not updated")
|
| 57 |
+
|
| 58 |
+
task_data = result.data[0]
|
| 59 |
+
|
| 60 |
+
project_id = task_data.get("project_id")
|
| 61 |
+
if project_id:
|
| 62 |
+
task_result = (
|
| 63 |
+
supabase.table("tasks")
|
| 64 |
+
.select("id,status")
|
| 65 |
+
.eq("project_id", project_id)
|
| 66 |
+
.execute()
|
| 67 |
+
)
|
| 68 |
+
tasks = task_result.data or []
|
| 69 |
+
if status == "done" and tasks and all(t.get("status") == "done" for t in tasks):
|
| 70 |
+
supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute()
|
| 71 |
+
elif status != "done":
|
| 72 |
+
supabase.table("projects").update({"status": "active"}).eq("id", project_id).execute()
|
| 73 |
+
|
| 74 |
+
return task_data
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _sse_event(event: str, data: dict, event_id: str | None = None) -> str:
|
| 78 |
+
lines = []
|
| 79 |
+
if event_id:
|
| 80 |
+
lines.append(f"id: {event_id}")
|
| 81 |
+
lines.append(f"event: {event}")
|
| 82 |
+
payload = json.dumps(data, default=str)
|
| 83 |
+
for line in payload.splitlines() or ["{}"]:
|
| 84 |
+
lines.append(f"data: {line}")
|
| 85 |
+
return "\n".join(lines) + "\n\n"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _project_task_ids(project_id: str) -> list[str]:
|
| 89 |
+
rows = (
|
| 90 |
+
supabase.table("tasks")
|
| 91 |
+
.select("id")
|
| 92 |
+
.eq("project_id", project_id)
|
| 93 |
+
.execute()
|
| 94 |
+
.data
|
| 95 |
+
or []
|
| 96 |
+
)
|
| 97 |
+
return [row["id"] for row in rows if row.get("id")]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _user_id_from_access_token(access_token: str | None) -> str:
|
| 101 |
+
if not access_token:
|
| 102 |
+
raise HTTPException(status_code=401, detail="Missing access token")
|
| 103 |
+
try:
|
| 104 |
+
auth_user = supabase.auth.get_user(access_token)
|
| 105 |
+
user = getattr(auth_user, "user", None)
|
| 106 |
+
user_id = getattr(user, "id", None)
|
| 107 |
+
if not user_id and isinstance(auth_user, dict):
|
| 108 |
+
user_id = auth_user.get("user", {}).get("id")
|
| 109 |
+
except Exception as exc:
|
| 110 |
+
logger.warning("Could not validate log stream access token: %s", exc)
|
| 111 |
+
raise HTTPException(status_code=401, detail="Invalid access token") from exc
|
| 112 |
+
if not user_id:
|
| 113 |
+
raise HTTPException(status_code=401, detail="Invalid access token")
|
| 114 |
+
return user_id
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _team_ids_for_user(user_id: str) -> list[str]:
|
| 118 |
+
try:
|
| 119 |
+
rows = (
|
| 120 |
+
supabase.table("team_members")
|
| 121 |
+
.select("team_id")
|
| 122 |
+
.eq("user_id", user_id)
|
| 123 |
+
.execute()
|
| 124 |
+
.data
|
| 125 |
+
or []
|
| 126 |
+
)
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
logger.warning("Team membership lookup unavailable for log stream: %s", exc)
|
| 129 |
+
return []
|
| 130 |
+
return [row["team_id"] for row in rows if row.get("team_id")]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _project_ids_for_user(user_id: str) -> list[str]:
|
| 134 |
+
project_ids: set[str] = set()
|
| 135 |
+
|
| 136 |
+
owned = (
|
| 137 |
+
supabase.table("projects")
|
| 138 |
+
.select("id")
|
| 139 |
+
.eq("owner_id", user_id)
|
| 140 |
+
.execute()
|
| 141 |
+
.data
|
| 142 |
+
or []
|
| 143 |
+
)
|
| 144 |
+
project_ids.update(row["id"] for row in owned if row.get("id"))
|
| 145 |
+
|
| 146 |
+
public = (
|
| 147 |
+
supabase.table("projects")
|
| 148 |
+
.select("id")
|
| 149 |
+
.eq("is_public", True)
|
| 150 |
+
.execute()
|
| 151 |
+
.data
|
| 152 |
+
or []
|
| 153 |
+
)
|
| 154 |
+
project_ids.update(row["id"] for row in public if row.get("id"))
|
| 155 |
+
|
| 156 |
+
team_ids = _team_ids_for_user(user_id)
|
| 157 |
+
if team_ids:
|
| 158 |
+
team_projects = (
|
| 159 |
+
supabase.table("projects")
|
| 160 |
+
.select("id")
|
| 161 |
+
.in_("team_id", team_ids)
|
| 162 |
+
.execute()
|
| 163 |
+
.data
|
| 164 |
+
or []
|
| 165 |
+
)
|
| 166 |
+
project_ids.update(row["id"] for row in team_projects if row.get("id"))
|
| 167 |
+
|
| 168 |
+
return list(project_ids)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _can_view_project_for_user(project_id: str, user_id: str) -> bool:
|
| 172 |
+
if not project_id:
|
| 173 |
+
return False
|
| 174 |
+
if project_id in _project_ids_for_user(user_id):
|
| 175 |
+
return True
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _authorized_task_ids(user_id: str, project_id: str | None = None, task_id: str | None = None) -> list[str]:
|
| 180 |
+
if task_id:
|
| 181 |
+
task = supabase.table("tasks").select("id,project_id").eq("id", task_id).single().execute().data
|
| 182 |
+
if not task or not _can_view_project_for_user(task.get("project_id"), user_id):
|
| 183 |
+
raise HTTPException(status_code=403, detail="Task logs are not visible to this user")
|
| 184 |
+
return [task_id]
|
| 185 |
+
|
| 186 |
+
if project_id:
|
| 187 |
+
if not _can_view_project_for_user(project_id, user_id):
|
| 188 |
+
raise HTTPException(status_code=403, detail="Project logs are not visible to this user")
|
| 189 |
+
return _project_task_ids(project_id)
|
| 190 |
+
|
| 191 |
+
project_ids = _project_ids_for_user(user_id)
|
| 192 |
+
if not project_ids:
|
| 193 |
+
return []
|
| 194 |
+
rows = (
|
| 195 |
+
supabase.table("tasks")
|
| 196 |
+
.select("id")
|
| 197 |
+
.in_("project_id", project_ids)
|
| 198 |
+
.execute()
|
| 199 |
+
.data
|
| 200 |
+
or []
|
| 201 |
+
)
|
| 202 |
+
return [row["id"] for row in rows if row.get("id")]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _fetch_recent_logs(
|
| 206 |
+
limit: int = 50,
|
| 207 |
+
after_created_at: str | None = None,
|
| 208 |
+
*,
|
| 209 |
+
task_ids: list[str],
|
| 210 |
+
) -> list[dict]:
|
| 211 |
+
if not task_ids:
|
| 212 |
+
return []
|
| 213 |
+
query = (
|
| 214 |
+
supabase.table("agent_logs")
|
| 215 |
+
.select("id,task_id,run_id,action,content,metadata,created_at")
|
| 216 |
+
.order("created_at", desc=after_created_at is None)
|
| 217 |
+
.limit(limit)
|
| 218 |
+
.in_("task_id", task_ids)
|
| 219 |
+
)
|
| 220 |
+
if after_created_at:
|
| 221 |
+
query = query.gt("created_at", after_created_at)
|
| 222 |
+
rows = query.execute().data or []
|
| 223 |
+
return rows if after_created_at else list(reversed(rows))
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.get("/logs/stream")
|
| 227 |
+
async def stream_agent_logs(
|
| 228 |
+
request: Request,
|
| 229 |
+
limit: int = 50,
|
| 230 |
+
project_id: str | None = None,
|
| 231 |
+
task_id: str | None = None,
|
| 232 |
+
access_token: str | None = None,
|
| 233 |
+
):
|
| 234 |
+
"""
|
| 235 |
+
Streams agent log inserts as Server-Sent Events.
|
| 236 |
+
"""
|
| 237 |
+
if project_id and task_id:
|
| 238 |
+
raise HTTPException(status_code=400, detail="Use either project_id or task_id, not both.")
|
| 239 |
+
user_id = _user_id_from_access_token(access_token)
|
| 240 |
+
task_ids = _authorized_task_ids(user_id, project_id=project_id, task_id=task_id)
|
| 241 |
+
|
| 242 |
+
async def event_generator():
|
| 243 |
+
last_created_at = None
|
| 244 |
+
sent_ids: set[str] = set()
|
| 245 |
+
yield _sse_event("ready", {
|
| 246 |
+
"message": "Agent log stream connected",
|
| 247 |
+
"project_id": project_id,
|
| 248 |
+
"task_id": task_id,
|
| 249 |
+
"user_id": user_id,
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
while not await request.is_disconnected():
|
| 253 |
+
try:
|
| 254 |
+
rows = _fetch_recent_logs(
|
| 255 |
+
limit=max(1, min(limit, 100)),
|
| 256 |
+
after_created_at=last_created_at,
|
| 257 |
+
task_ids=task_ids,
|
| 258 |
+
)
|
| 259 |
+
for row in rows:
|
| 260 |
+
row_id = row.get("id")
|
| 261 |
+
if row_id in sent_ids:
|
| 262 |
+
continue
|
| 263 |
+
sent_ids.add(row_id)
|
| 264 |
+
if len(sent_ids) > 500:
|
| 265 |
+
sent_ids = set(list(sent_ids)[-250:])
|
| 266 |
+
last_created_at = row.get("created_at") or last_created_at
|
| 267 |
+
yield _sse_event("log", row, row_id)
|
| 268 |
+
except Exception as exc:
|
| 269 |
+
logger.warning("Agent log SSE stream failed to fetch logs: %s", exc)
|
| 270 |
+
yield _sse_event("error", {"message": str(exc)})
|
| 271 |
+
|
| 272 |
+
yield ": keep-alive\n\n"
|
| 273 |
+
await asyncio.sleep(1)
|
| 274 |
+
|
| 275 |
+
return StreamingResponse(
|
| 276 |
+
event_generator(),
|
| 277 |
+
media_type="text/event-stream",
|
| 278 |
+
headers={
|
| 279 |
+
"Cache-Control": "no-cache",
|
| 280 |
+
"Connection": "keep-alive",
|
| 281 |
+
"X-Accel-Buffering": "no",
|
| 282 |
+
},
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@router.post("/{task_id}/run")
|
| 287 |
+
async def run_task(task_id: str, background_tasks: BackgroundTasks, use_queue: bool | None = None):
|
| 288 |
+
"""
|
| 289 |
+
Triggers the execution of a specific task.
|
| 290 |
+
"""
|
| 291 |
+
# 1. Fetch task data
|
| 292 |
+
task_res = supabase.table("tasks").select("*, project:projects(*)").eq("id", task_id).single().execute()
|
| 293 |
+
if not task_res.data:
|
| 294 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 295 |
+
|
| 296 |
+
task = task_res.data
|
| 297 |
+
_assert_task_project_is_mutable(task)
|
| 298 |
+
|
| 299 |
+
# 2. Check if agent is assigned
|
| 300 |
+
agent_id = task.get("assigned_agent_id")
|
| 301 |
+
if not agent_id:
|
| 302 |
+
raise HTTPException(status_code=400, detail="No agent assigned to this task")
|
| 303 |
+
|
| 304 |
+
# 3. Fetch agent data
|
| 305 |
+
agent_res = supabase.table("agents").select("*").eq("id", agent_id).single().execute()
|
| 306 |
+
if not agent_res.data:
|
| 307 |
+
raise HTTPException(status_code=404, detail="Assigned agent not found")
|
| 308 |
+
|
| 309 |
+
agent_data = agent_res.data
|
| 310 |
+
|
| 311 |
+
should_queue = use_queue if use_queue is not None else False
|
| 312 |
+
if should_queue:
|
| 313 |
+
queued = await TaskQueueService.queue_task(task_id)
|
| 314 |
+
if not queued or not queued.data:
|
| 315 |
+
raise HTTPException(status_code=500, detail="Task could not be queued")
|
| 316 |
+
await audit_service.log_action(
|
| 317 |
+
user_id=task.get("project", {}).get("owner_id"),
|
| 318 |
+
action="task_queued",
|
| 319 |
+
agent_id=agent_id,
|
| 320 |
+
task_id=task_id,
|
| 321 |
+
metadata={"project_id": task.get("project_id"), "source": "task_run_endpoint"},
|
| 322 |
+
)
|
| 323 |
+
return {"message": "Task queued for worker execution", "task_id": task_id, "mode": "queue"}
|
| 324 |
+
|
| 325 |
+
# 4. Update task status to in_progress
|
| 326 |
+
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 327 |
+
await audit_service.log_action(
|
| 328 |
+
user_id=task.get("project", {}).get("owner_id"),
|
| 329 |
+
action="task_run_started",
|
| 330 |
+
agent_id=agent_id,
|
| 331 |
+
task_id=task_id,
|
| 332 |
+
metadata={"project_id": task.get("project_id"), "mode": "direct"},
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# 5. Run in background
|
| 336 |
+
runner_task = asyncio.create_task(AgentRunnerService.execute_agent_logic(task, agent_data))
|
| 337 |
+
runner_task.add_done_callback(lambda current: log_async_task_result(current, f"run_task({task_id})"))
|
| 338 |
+
|
| 339 |
+
return {"message": "Task execution started", "task_id": task_id}
|
| 340 |
+
|
| 341 |
+
@router.patch("/{task_id}/output")
|
| 342 |
+
async def update_task_output(task_id: str, payload: dict):
|
| 343 |
+
"""
|
| 344 |
+
Updates the output_data of a task. Allows for manual human corrections.
|
| 345 |
+
"""
|
| 346 |
+
if "output_data" not in payload:
|
| 347 |
+
raise HTTPException(status_code=400, detail="Missing output_data in payload")
|
| 348 |
+
|
| 349 |
+
# Verify task existence and project state
|
| 350 |
+
task_res = supabase.table("tasks").select("id, project_id").eq("id", task_id).single().execute()
|
| 351 |
+
if not task_res.data:
|
| 352 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 353 |
+
_assert_task_project_is_mutable(task_res.data)
|
| 354 |
+
|
| 355 |
+
result = supabase.table("tasks").update({
|
| 356 |
+
"output_data": payload["output_data"]
|
| 357 |
+
}).eq("id", task_id).execute()
|
| 358 |
+
|
| 359 |
+
if not result.data:
|
| 360 |
+
raise HTTPException(status_code=500, detail="Failed to update task output")
|
| 361 |
+
|
| 362 |
+
await audit_service.log_action(
|
| 363 |
+
user_id=None,
|
| 364 |
+
action="task_output_manually_edited",
|
| 365 |
+
task_id=task_id,
|
| 366 |
+
metadata={"project_id": task_res.data["project_id"]}
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
return {"message": "Task output updated", "task": result.data[0]}
|
| 370 |
+
|
| 371 |
+
@router.post("/{task_id}/approve")
|
| 372 |
+
async def approve_task(task_id: str, background_tasks: BackgroundTasks):
|
| 373 |
+
task_res = supabase.table("tasks").select("*").eq("id", task_id).single().execute()
|
| 374 |
+
if not task_res.data:
|
| 375 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 376 |
+
_assert_task_project_is_mutable(task_res.data)
|
| 377 |
+
_assert_task_quality(task_res.data)
|
| 378 |
+
task = update_task_status(task_id, "done")
|
| 379 |
+
|
| 380 |
+
# Index for Long-Term Memory
|
| 381 |
+
background_tasks.add_task(memory_service.index_task_output, task)
|
| 382 |
+
|
| 383 |
+
await audit_service.log_action(
|
| 384 |
+
user_id=None,
|
| 385 |
+
action="task_approved",
|
| 386 |
+
agent_id=task.get("assigned_agent_id"),
|
| 387 |
+
task_id=task_id,
|
| 388 |
+
metadata={"project_id": task.get("project_id")},
|
| 389 |
+
)
|
| 390 |
+
return {"message": "Task approved", "task": task}
|
| 391 |
+
|
| 392 |
+
@router.post("/{task_id}/reject")
|
| 393 |
+
async def reject_task(task_id: str, background_tasks: BackgroundTasks, feedback: str | None = None):
|
| 394 |
+
task = update_task_status(task_id, "todo")
|
| 395 |
+
|
| 396 |
+
# Trigger Self-Optimization Loop
|
| 397 |
+
background_tasks.add_task(
|
| 398 |
+
memory_service.analyze_rejection,
|
| 399 |
+
task_id=task_id,
|
| 400 |
+
feedback=feedback
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
await audit_service.log_action(
|
| 404 |
+
user_id=None,
|
| 405 |
+
action="task_rejected",
|
| 406 |
+
agent_id=task.get("assigned_agent_id"),
|
| 407 |
+
task_id=task_id,
|
| 408 |
+
metadata={"project_id": task.get("project_id")},
|
| 409 |
+
)
|
| 410 |
+
return {"message": "Task rejected", "task": task}
|
| 411 |
+
|
| 412 |
+
@router.post("/project/{project_id}/approve-all")
|
| 413 |
+
async def approve_all_tasks(project_id: str, background_tasks: BackgroundTasks):
|
| 414 |
+
"""
|
| 415 |
+
Approves all tasks in a project that are awaiting approval.
|
| 416 |
+
"""
|
| 417 |
+
project_service.ensure_project_is_mutable(project_id)
|
| 418 |
+
waiting_tasks = (
|
| 419 |
+
supabase.table("tasks")
|
| 420 |
+
.select("*")
|
| 421 |
+
.eq("project_id", project_id)
|
| 422 |
+
.eq("status", "awaiting_approval")
|
| 423 |
+
.execute()
|
| 424 |
+
.data
|
| 425 |
+
or []
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
blocked = []
|
| 429 |
+
approvable_ids = []
|
| 430 |
+
|
| 431 |
+
for task in waiting_tasks:
|
| 432 |
+
try:
|
| 433 |
+
_assert_task_quality(task)
|
| 434 |
+
approvable_ids.append(task["id"])
|
| 435 |
+
except HTTPException as exc:
|
| 436 |
+
blocked.append({
|
| 437 |
+
"task_id": task["id"],
|
| 438 |
+
"title": task.get("title", "Untitled Task"),
|
| 439 |
+
"reason": exc.detail
|
| 440 |
+
})
|
| 441 |
+
|
| 442 |
+
# 1. Update tasks
|
| 443 |
+
result_data = []
|
| 444 |
+
if approvable_ids:
|
| 445 |
+
result = (
|
| 446 |
+
supabase.table("tasks")
|
| 447 |
+
.update({"status": "done"})
|
| 448 |
+
.eq("project_id", project_id)
|
| 449 |
+
.in_("id", approvable_ids)
|
| 450 |
+
.execute()
|
| 451 |
+
)
|
| 452 |
+
result_data = result.data or []
|
| 453 |
+
|
| 454 |
+
# Index all approved tasks for Long-Term Memory
|
| 455 |
+
for approved_task in result_data:
|
| 456 |
+
background_tasks.add_task(memory_service.index_task_output, approved_task)
|
| 457 |
+
|
| 458 |
+
# 2. Check if all tasks in project are now done
|
| 459 |
+
task_result = (
|
| 460 |
+
supabase.table("tasks")
|
| 461 |
+
.select("status")
|
| 462 |
+
.eq("project_id", project_id)
|
| 463 |
+
.execute()
|
| 464 |
+
)
|
| 465 |
+
tasks = task_result.data or []
|
| 466 |
+
if tasks and all(t.get("status") == "done" for t in tasks):
|
| 467 |
+
supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute()
|
| 468 |
+
|
| 469 |
+
await audit_service.log_action(
|
| 470 |
+
user_id=None,
|
| 471 |
+
action="tasks_approved_bulk",
|
| 472 |
+
metadata={
|
| 473 |
+
"project_id": project_id,
|
| 474 |
+
"approved_count": len(result_data),
|
| 475 |
+
"blocked_count": len(blocked),
|
| 476 |
+
},
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
return {
|
| 480 |
+
"message": f"Approved {len(result_data)} tasks. {len(blocked)} tasks were blocked due to quality issues.",
|
| 481 |
+
"count": len(result_data),
|
| 482 |
+
"blocked": blocked
|
| 483 |
+
}
|
backend/routers/generator.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import groq
|
| 6 |
+
from services.supabase_service import supabase
|
| 7 |
+
from services.config import settings, config_service
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
logger = logging.getLogger("aubm.generator")
|
| 12 |
+
|
| 13 |
+
def _parse_json_output(content: str):
|
| 14 |
+
"""Robust JSON parsing from LLM output."""
|
| 15 |
+
if not content:
|
| 16 |
+
return {}
|
| 17 |
+
try:
|
| 18 |
+
return json.loads(content)
|
| 19 |
+
except json.JSONDecodeError:
|
| 20 |
+
pass
|
| 21 |
+
try:
|
| 22 |
+
if "```json" in content:
|
| 23 |
+
clean = content.split("```json", 1)[1].split("```", 1)[0].strip()
|
| 24 |
+
elif "```" in content:
|
| 25 |
+
clean = content.split("```", 1)[1].split("```", 1)[0].strip()
|
| 26 |
+
else:
|
| 27 |
+
object_start = content.find("{")
|
| 28 |
+
end = content.rfind("}")
|
| 29 |
+
clean = content[object_start:end + 1] if object_start != -1 and end != -1 else content
|
| 30 |
+
return json.loads(clean)
|
| 31 |
+
except Exception:
|
| 32 |
+
return {"name": "Generation Failed", "description": content, "context": ""}
|
| 33 |
+
|
| 34 |
+
@router.post("/generate-project")
|
| 35 |
+
async def generate_project(
|
| 36 |
+
prompt: str = Form(...),
|
| 37 |
+
files: List[UploadFile] = File(None)
|
| 38 |
+
):
|
| 39 |
+
"""
|
| 40 |
+
Generates a project structure from a natural language prompt and reference files.
|
| 41 |
+
"""
|
| 42 |
+
logger.info("Generating project structure for prompt: %s", prompt[:50])
|
| 43 |
+
|
| 44 |
+
# 1. Extract context from files
|
| 45 |
+
file_contexts = []
|
| 46 |
+
if files:
|
| 47 |
+
for file in files:
|
| 48 |
+
content = await file.read()
|
| 49 |
+
try:
|
| 50 |
+
text = content.decode("utf-8")
|
| 51 |
+
file_contexts.append(f"File: {file.filename}\nContent:\n{text}")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.warning("Could not decode file %s: %s", file.filename, e)
|
| 54 |
+
|
| 55 |
+
full_context = "\n\n".join(file_contexts)
|
| 56 |
+
|
| 57 |
+
# 2. Prepare LLM prompt
|
| 58 |
+
system_prompt = """
|
| 59 |
+
You are an expert Project Architect for the Aubm platform.
|
| 60 |
+
Your goal is to take a user prompt and reference documents to create a structured project definition.
|
| 61 |
+
|
| 62 |
+
Return ONLY a valid JSON object with the following keys:
|
| 63 |
+
{
|
| 64 |
+
"name": "Short Professional Name",
|
| 65 |
+
"description": "High level summary",
|
| 66 |
+
"context": "Detailed constraints, objectives, and requirements extracted from docs.",
|
| 67 |
+
"sources": [{"kind": "note", "label": "Analysis Note", "content": "..."}]
|
| 68 |
+
}
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
user_message = f"User Prompt: {prompt}\n\nReference Context:\n{full_context}"
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
# 3. Call Groq
|
| 75 |
+
provider_config = config_service.get_provider_config("groq")
|
| 76 |
+
api_key = provider_config.get("api_key") or settings.GROQ_API_KEY
|
| 77 |
+
|
| 78 |
+
if not api_key:
|
| 79 |
+
logger.error("GROQ_API_KEY is missing in settings and config")
|
| 80 |
+
raise HTTPException(status_code=500, detail="GROQ_API_KEY not configured")
|
| 81 |
+
|
| 82 |
+
client = groq.AsyncGroq(api_key=api_key)
|
| 83 |
+
|
| 84 |
+
# Use llama-3.3-70b-versatile to match GroqAgent.py
|
| 85 |
+
model_name = provider_config.get("default_model") or "llama-3.3-70b-versatile"
|
| 86 |
+
logger.info("Calling Groq with model: %s (Key: %s...)", model_name, api_key[:8] if api_key else "None")
|
| 87 |
+
|
| 88 |
+
response = await client.chat.completions.create(
|
| 89 |
+
model=model_name,
|
| 90 |
+
messages=[
|
| 91 |
+
{"role": "system", "content": system_prompt},
|
| 92 |
+
{"role": "user", "content": user_message}
|
| 93 |
+
],
|
| 94 |
+
temperature=0.3,
|
| 95 |
+
max_tokens=2048
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
response_text = response.choices[0].message.content
|
| 99 |
+
logger.info("Groq raw response received (%d chars)", len(response_text) if response_text else 0)
|
| 100 |
+
data = _parse_json_output(response_text)
|
| 101 |
+
return data
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.exception("Project generation failed")
|
| 105 |
+
error_type = type(e).__name__
|
| 106 |
+
error_msg = str(e)
|
| 107 |
+
if "401" in error_msg:
|
| 108 |
+
error_msg = "Invalid API Key - Please check your Groq Dashboard and .env"
|
| 109 |
+
raise HTTPException(status_code=500, detail=f"AI Error ({error_type}): {error_msg}")
|
backend/routers/monitoring.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, 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 |
+
def _count_tasks_by_status(status: str) -> int:
|
| 14 |
+
return (
|
| 15 |
+
supabase.table("tasks")
|
| 16 |
+
.select("id", count="exact")
|
| 17 |
+
.eq("status", status)
|
| 18 |
+
.limit(1)
|
| 19 |
+
.execute()
|
| 20 |
+
.count
|
| 21 |
+
or 0
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/summary")
|
| 26 |
+
async def monitoring_summary():
|
| 27 |
+
"""
|
| 28 |
+
Lightweight operational summary for dashboards and uptime checks.
|
| 29 |
+
"""
|
| 30 |
+
checks = {
|
| 31 |
+
"api": "ok",
|
| 32 |
+
"database": "ok",
|
| 33 |
+
"workers": "checking",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
counts = {
|
| 37 |
+
"projects": 0,
|
| 38 |
+
"tasks": 0,
|
| 39 |
+
"agents": 0,
|
| 40 |
+
"task_runs": 0,
|
| 41 |
+
"failed_tasks": 0,
|
| 42 |
+
"pending_reviews": 0,
|
| 43 |
+
"queued_tasks": 0,
|
| 44 |
+
"in_progress_tasks": 0,
|
| 45 |
+
"stale_leases": 0,
|
| 46 |
+
"delayed_retries": 0,
|
| 47 |
+
"active_workers": 0,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
counts["projects"] = _count_table("projects")
|
| 52 |
+
counts["tasks"] = _count_table("tasks")
|
| 53 |
+
counts["agents"] = _count_table("agents")
|
| 54 |
+
counts["task_runs"] = _count_table("task_runs")
|
| 55 |
+
counts["failed_tasks"] = _count_tasks_by_status("failed")
|
| 56 |
+
counts["pending_reviews"] = _count_tasks_by_status("awaiting_approval")
|
| 57 |
+
counts["queued_tasks"] = _count_tasks_by_status("queued")
|
| 58 |
+
counts["in_progress_tasks"] = _count_tasks_by_status("in_progress")
|
| 59 |
+
|
| 60 |
+
now = datetime.now(timezone.utc)
|
| 61 |
+
counts["stale_leases"] = (
|
| 62 |
+
supabase.table("tasks")
|
| 63 |
+
.select("id", count="exact")
|
| 64 |
+
.eq("status", "in_progress")
|
| 65 |
+
.lt("lease_expires_at", now.isoformat())
|
| 66 |
+
.limit(1)
|
| 67 |
+
.execute()
|
| 68 |
+
.count
|
| 69 |
+
or 0
|
| 70 |
+
)
|
| 71 |
+
counts["delayed_retries"] = (
|
| 72 |
+
supabase.table("tasks")
|
| 73 |
+
.select("id", count="exact")
|
| 74 |
+
.eq("status", "queued")
|
| 75 |
+
.gt("next_attempt_at", now.isoformat())
|
| 76 |
+
.limit(1)
|
| 77 |
+
.execute()
|
| 78 |
+
.count
|
| 79 |
+
or 0
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
active_since = now - timedelta(minutes=2)
|
| 84 |
+
counts["active_workers"] = (
|
| 85 |
+
supabase.table("worker_heartbeats")
|
| 86 |
+
.select("worker_id", count="exact")
|
| 87 |
+
.gte("last_seen_at", active_since.isoformat())
|
| 88 |
+
.neq("status", "stopping")
|
| 89 |
+
.limit(1)
|
| 90 |
+
.execute()
|
| 91 |
+
.count
|
| 92 |
+
or 0
|
| 93 |
+
)
|
| 94 |
+
checks["workers"] = "ok" if counts["active_workers"] > 0 or counts["queued_tasks"] == 0 else "warning"
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
checks["workers"] = "unavailable"
|
| 97 |
+
counts["active_workers"] = 0
|
| 98 |
+
worker_error = str(exc)
|
| 99 |
+
else:
|
| 100 |
+
worker_error = None
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
checks["database"] = "error"
|
| 103 |
+
return {
|
| 104 |
+
"status": "degraded",
|
| 105 |
+
"checks": checks,
|
| 106 |
+
"counts": counts,
|
| 107 |
+
"error": str(exc),
|
| 108 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
error = None
|
| 112 |
+
if worker_error:
|
| 113 |
+
error = f"Worker heartbeat table unavailable: {worker_error}"
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
"status": "ok" if checks["workers"] in ("ok", "unavailable") and counts["stale_leases"] == 0 else "degraded",
|
| 117 |
+
"checks": checks,
|
| 118 |
+
"counts": counts,
|
| 119 |
+
"error": error,
|
| 120 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 121 |
+
}
|
backend/routers/orchestrator.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
| 4 |
+
from fastapi.responses import Response
|
| 5 |
+
from services.orchestrator_service import orchestrator_service
|
| 6 |
+
from services.supabase_service import supabase
|
| 7 |
+
from services.config import settings
|
| 8 |
+
from services.budget_service import budget_service
|
| 9 |
+
from services.evidence_service import evidence_service
|
| 10 |
+
from services.project_service import project_service
|
| 11 |
+
from services.utils import log_async_task_result
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from io import BytesIO
|
| 14 |
+
from reportlab.lib.pagesizes import letter
|
| 15 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 16 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 17 |
+
from reportlab.lib import colors
|
| 18 |
+
from reportlab.lib.units import inch
|
| 19 |
+
from xml.sax.saxutils import escape
|
| 20 |
+
import re
|
| 21 |
+
|
| 22 |
+
router = APIRouter()
|
| 23 |
+
logger = logging.getLogger("uvicorn")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _safe_filename(value: str) -> str:
|
| 29 |
+
return re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_").lower() or "report"
|
| 30 |
+
|
| 31 |
+
def _pdf_text(value: str) -> str:
|
| 32 |
+
return escape(str(value))
|
| 33 |
+
|
| 34 |
+
def _report_body_without_execution_summary(content: str) -> list[str]:
|
| 35 |
+
lines: list[str] = []
|
| 36 |
+
skipping = False
|
| 37 |
+
for raw_line in content.splitlines():
|
| 38 |
+
if raw_line.startswith("## Execution Summary"):
|
| 39 |
+
skipping = True
|
| 40 |
+
continue
|
| 41 |
+
if skipping and raw_line.startswith("## "):
|
| 42 |
+
skipping = False
|
| 43 |
+
if not skipping:
|
| 44 |
+
lines.append(raw_line)
|
| 45 |
+
return lines
|
| 46 |
+
|
| 47 |
+
def _report_pdf_bytes(title: str, content: str, charts: dict | None = None) -> bytes:
|
| 48 |
+
buffer = BytesIO()
|
| 49 |
+
doc = SimpleDocTemplate(
|
| 50 |
+
buffer,
|
| 51 |
+
pagesize=letter,
|
| 52 |
+
rightMargin=0.7 * inch,
|
| 53 |
+
leftMargin=0.7 * inch,
|
| 54 |
+
topMargin=0.7 * inch,
|
| 55 |
+
bottomMargin=0.7 * inch,
|
| 56 |
+
)
|
| 57 |
+
styles = getSampleStyleSheet()
|
| 58 |
+
story = [Paragraph(_pdf_text(title), styles["Title"]), Spacer(1, 0.2 * inch)]
|
| 59 |
+
if charts:
|
| 60 |
+
story.append(Paragraph("Project Execution Summary", styles["Heading2"]))
|
| 61 |
+
story.append(Spacer(1, 0.1 * inch))
|
| 62 |
+
|
| 63 |
+
# Summary Table instead of charts
|
| 64 |
+
table_data = [["Metric / Category", "Value"]]
|
| 65 |
+
|
| 66 |
+
# Tasks Status
|
| 67 |
+
status_counts = {row["label"]: row["value"] for row in charts.get("status", [])}
|
| 68 |
+
for label, val in status_counts.items():
|
| 69 |
+
table_data.append([f"Tasks: {label}", str(val)])
|
| 70 |
+
|
| 71 |
+
# Categories
|
| 72 |
+
for cat in charts.get("categories", []):
|
| 73 |
+
table_data.append([f"Type: {cat['label']}", str(cat["value"])])
|
| 74 |
+
|
| 75 |
+
# Priorities
|
| 76 |
+
for priority in charts.get("priorities", []):
|
| 77 |
+
table_data.append([priority["label"], str(priority["value"])])
|
| 78 |
+
|
| 79 |
+
# Scores
|
| 80 |
+
for score in charts.get("scores", []):
|
| 81 |
+
table_data.append([f"Score: {score['label']}", str(score["value"])])
|
| 82 |
+
|
| 83 |
+
table = Table(table_data, colWidths=[3.5*inch, 1.5*inch])
|
| 84 |
+
table.setStyle(TableStyle([
|
| 85 |
+
('BACKGROUND', (0,0), (-1,0), colors.HexColor("#6e59ff")),
|
| 86 |
+
('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke),
|
| 87 |
+
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
| 88 |
+
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
|
| 89 |
+
('BOTTOMPADDING', (0,0), (-1,0), 10),
|
| 90 |
+
('BACKGROUND', (0,1), (-1,-1), colors.HexColor("#f8fafc")),
|
| 91 |
+
('GRID', (0,0), (-1,-1), 0.5, colors.grey),
|
| 92 |
+
('FONTSIZE', (0,0), (-1,-1), 9),
|
| 93 |
+
]))
|
| 94 |
+
story.append(table)
|
| 95 |
+
story.append(Spacer(1, 0.3 * inch))
|
| 96 |
+
|
| 97 |
+
for raw_line in _report_body_without_execution_summary(content):
|
| 98 |
+
line = raw_line.strip()
|
| 99 |
+
if not line:
|
| 100 |
+
story.append(Spacer(1, 0.1 * inch))
|
| 101 |
+
continue
|
| 102 |
+
if line.startswith("# "):
|
| 103 |
+
story.append(Paragraph(_pdf_text(line[2:]), styles["Title"]))
|
| 104 |
+
elif line.startswith("## "):
|
| 105 |
+
story.append(Paragraph(_pdf_text(line[3:]), styles["Heading2"]))
|
| 106 |
+
elif line.startswith("### "):
|
| 107 |
+
story.append(Paragraph(_pdf_text(line[4:]), styles["Heading3"]))
|
| 108 |
+
elif line.startswith("- "):
|
| 109 |
+
story.append(Paragraph(f"• {_pdf_text(line[2:])}", styles["BodyText"]))
|
| 110 |
+
else:
|
| 111 |
+
story.append(Paragraph(_pdf_text(line), styles["BodyText"]))
|
| 112 |
+
|
| 113 |
+
doc.build(story)
|
| 114 |
+
return buffer.getvalue()
|
| 115 |
+
|
| 116 |
+
class DebateRequest(BaseModel):
|
| 117 |
+
|
| 118 |
+
task_id: str
|
| 119 |
+
agent_a_id: str
|
| 120 |
+
agent_b_id: str
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ProjectBudgetRequest(BaseModel):
|
| 124 |
+
enabled: bool = True
|
| 125 |
+
token_budget: int | None = None
|
| 126 |
+
cost_budget: float | None = None
|
| 127 |
+
currency: str = "USD"
|
| 128 |
+
|
| 129 |
+
@router.post("/debate")
|
| 130 |
+
async def start_debate(request: DebateRequest, background_tasks: BackgroundTasks):
|
| 131 |
+
"""
|
| 132 |
+
Starts a debate between two agents for a specific task.
|
| 133 |
+
"""
|
| 134 |
+
background_tasks.add_task(
|
| 135 |
+
orchestrator_service.run_debate,
|
| 136 |
+
request.task_id,
|
| 137 |
+
request.agent_a_id,
|
| 138 |
+
request.agent_b_id
|
| 139 |
+
)
|
| 140 |
+
return {"message": "Debate started in background"}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.post("/projects/{project_id}/run")
|
| 144 |
+
async def run_project_orchestrator(project_id: str, background_tasks: BackgroundTasks, use_queue: bool | None = None):
|
| 145 |
+
"""
|
| 146 |
+
Runs all queued tasks for a project in priority order.
|
| 147 |
+
"""
|
| 148 |
+
project_service.ensure_project_is_mutable(project_id)
|
| 149 |
+
should_queue = use_queue if use_queue is not None else False
|
| 150 |
+
if should_queue:
|
| 151 |
+
try:
|
| 152 |
+
result = await orchestrator_service.queue_project(project_id)
|
| 153 |
+
except ValueError as exc:
|
| 154 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 155 |
+
return {"message": "Project tasks queued for worker execution", **result}
|
| 156 |
+
|
| 157 |
+
task = asyncio.create_task(orchestrator_service.run_project(project_id))
|
| 158 |
+
task.add_done_callback(lambda current: log_async_task_result(current, f"run_project({project_id})"))
|
| 159 |
+
return {"message": "Project orchestrator started", "project_id": project_id, "mode": "direct"}
|
| 160 |
+
|
| 161 |
+
@router.get("/projects/{project_id}/final-report")
|
| 162 |
+
async def get_project_final_report(project_id: str, variant: str = "full"):
|
| 163 |
+
"""
|
| 164 |
+
Builds a consolidated report from all approved task outputs.
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
return await orchestrator_service.build_final_report(project_id, variant)
|
| 168 |
+
except ValueError as exc:
|
| 169 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@router.get("/projects/{project_id}/evidence")
|
| 173 |
+
async def get_project_evidence(project_id: str, merge: bool = False):
|
| 174 |
+
"""
|
| 175 |
+
Returns normalized claims extracted from structured task outputs.
|
| 176 |
+
Can optionally merge semantically similar claims.
|
| 177 |
+
"""
|
| 178 |
+
project = supabase.table("projects").select("id").eq("id", project_id).single().execute().data
|
| 179 |
+
if not project:
|
| 180 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 181 |
+
|
| 182 |
+
if merge:
|
| 183 |
+
claims = await evidence_service.merge_project_claims(project_id)
|
| 184 |
+
else:
|
| 185 |
+
claims = evidence_service.load_project_claims(project_id)
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
"project_id": project_id,
|
| 189 |
+
"merged": merge,
|
| 190 |
+
"summary": evidence_service.summarize_claims(claims),
|
| 191 |
+
"claims": claims,
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.get("/projects/{project_id}/budget")
|
| 196 |
+
async def get_project_budget(project_id: str):
|
| 197 |
+
project_service.get_project_or_404(project_id)
|
| 198 |
+
return budget_service.project_budget_status(project_id)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@router.put("/projects/{project_id}/budget")
|
| 202 |
+
async def update_project_budget(project_id: str, request: ProjectBudgetRequest):
|
| 203 |
+
project = supabase.table("projects").select("id").eq("id", project_id).single().execute().data
|
| 204 |
+
if not project:
|
| 205 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 206 |
+
budget_service.upsert_project_budget(
|
| 207 |
+
project_id=project_id,
|
| 208 |
+
enabled=request.enabled,
|
| 209 |
+
token_budget=request.token_budget,
|
| 210 |
+
cost_budget=request.cost_budget,
|
| 211 |
+
currency=request.currency,
|
| 212 |
+
)
|
| 213 |
+
return budget_service.project_budget_status(project_id)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@router.get("/projects/{project_id}/final-report.pdf")
|
| 217 |
+
async def download_project_final_report_pdf(project_id: str, variant: str = "full"):
|
| 218 |
+
"""
|
| 219 |
+
Downloads the selected report variant as a PDF.
|
| 220 |
+
"""
|
| 221 |
+
try:
|
| 222 |
+
result = await orchestrator_service.build_final_report(project_id, variant)
|
| 223 |
+
except ValueError as exc:
|
| 224 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 225 |
+
|
| 226 |
+
title = f"{result['project_name']} - {result['variant']} report"
|
| 227 |
+
pdf = _report_pdf_bytes(title, result["report"], result.get("charts"))
|
| 228 |
+
filename = f"{_safe_filename(result['project_name'])}_{_safe_filename(result['variant'])}.pdf"
|
| 229 |
+
return Response(
|
| 230 |
+
content=pdf,
|
| 231 |
+
media_type="application/pdf",
|
| 232 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
| 233 |
+
)
|
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,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
EXAMPLE_PROJECTS = [
|
| 12 |
+
{
|
| 13 |
+
"project": {
|
| 14 |
+
"name": "Aubm Competitor Analysis",
|
| 15 |
+
"description": "Deep dive into the multi-agent orchestration market to identify Aubm's unique value proposition and feature gaps.",
|
| 16 |
+
"status": "active",
|
| 17 |
+
"context": "Focus on developer experience, visual observability, and the 'Agent Debate' mechanism as key differentiators."
|
| 18 |
+
},
|
| 19 |
+
"tasks": [
|
| 20 |
+
{"title": "Identify Top 5 Competitors", "description": "Research and list 5 similar multi-agent orchestration platforms (e.g., CrewAI, AutoGen, LangGraph, PydanticAI).", "status": "todo"},
|
| 21 |
+
{"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"},
|
| 22 |
+
{"title": "Pricing Model Analysis", "description": "Analyze how competitors charge (SaaS, Open Source, API usage) and recommend a competitive strategy for Aubm.", "status": "todo"},
|
| 23 |
+
{"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"},
|
| 24 |
+
{"title": "Technical Architecture Deep-Dive", "description": "Investigate the underlying tech stacks (Python vs TS, Vector DBs used, Orchestration logic) of top competitors.", "status": "todo"},
|
| 25 |
+
{"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"}
|
| 26 |
+
]
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"project": {
|
| 30 |
+
"name": "AI Support Automation Pilot",
|
| 31 |
+
"description": "Design a pilot that routes inbound support tickets through specialized AI agents while keeping human approval for risky replies.",
|
| 32 |
+
"status": "active",
|
| 33 |
+
"context": "Use this as a customer operations example. Emphasize ticket triage, escalation policies, response quality, and measurable SLA impact."
|
| 34 |
+
},
|
| 35 |
+
"tasks": [
|
| 36 |
+
{"title": "Map Support Ticket Categories", "description": "Identify the main ticket categories, escalation triggers, and data needed by each support agent role.", "status": "todo", "priority": 5},
|
| 37 |
+
{"title": "Define Human Approval Rules", "description": "Specify which replies can be automated and which require human review based on customer risk and account tier.", "status": "todo", "priority": 4},
|
| 38 |
+
{"title": "Design Agent Workflow", "description": "Create a multi-agent workflow for triage, answer drafting, policy checking, and final approval.", "status": "todo", "priority": 4},
|
| 39 |
+
{"title": "Create Pilot Success Metrics", "description": "Define SLA, CSAT, deflection, review time, and error-rate metrics for a 30-day pilot.", "status": "todo", "priority": 3},
|
| 40 |
+
{"title": "Draft Rollout Plan", "description": "Prepare a phased rollout plan with risks, staffing requirements, and customer communication steps.", "status": "todo", "priority": 3}
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"project": {
|
| 45 |
+
"name": "FinOps Cloud Cost Review",
|
| 46 |
+
"description": "Analyze cloud infrastructure spend and propose agent-assisted monitoring workflows to reduce waste without hurting reliability.",
|
| 47 |
+
"status": "active",
|
| 48 |
+
"context": "Use this as an operations and finance example. Focus on anomaly detection, rightsizing, reserved capacity, and stakeholder reporting."
|
| 49 |
+
},
|
| 50 |
+
"tasks": [
|
| 51 |
+
{"title": "Inventory Cost Drivers", "description": "Break down the main cloud cost drivers across compute, storage, networking, databases, and third-party services.", "status": "todo", "priority": 5},
|
| 52 |
+
{"title": "Identify Waste Patterns", "description": "Find common waste patterns such as idle resources, oversized instances, orphaned volumes, and expensive data transfer paths.", "status": "todo", "priority": 5},
|
| 53 |
+
{"title": "Design Alerting Workflow", "description": "Create an agent workflow that detects spend anomalies, explains likely causes, and proposes owner-specific actions.", "status": "todo", "priority": 4},
|
| 54 |
+
{"title": "Build Savings Roadmap", "description": "Prioritize savings opportunities by expected impact, risk, engineering effort, and time to value.", "status": "todo", "priority": 4},
|
| 55 |
+
{"title": "Prepare Executive Summary", "description": "Summarize recommended actions, estimated savings ranges, risks, and governance changes for leadership.", "status": "todo", "priority": 3}
|
| 56 |
+
]
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"project": {
|
| 60 |
+
"name": "Healthcare Intake Risk Triage",
|
| 61 |
+
"description": "Prototype an AI-assisted intake workflow that summarizes patient requests, flags urgency, and routes cases to the correct care team.",
|
| 62 |
+
"status": "active",
|
| 63 |
+
"context": "Use this as a regulated-industry example. Emphasize auditability, privacy, safety checks, and clear human-in-the-loop boundaries."
|
| 64 |
+
},
|
| 65 |
+
"tasks": [
|
| 66 |
+
{"title": "Define Intake Data Requirements", "description": "List required patient request fields, optional context, privacy constraints, and data that must never be generated by the system.", "status": "todo", "priority": 5},
|
| 67 |
+
{"title": "Specify Risk Triage Rules", "description": "Define urgency categories, red-flag symptoms, routing criteria, and cases that must bypass automation.", "status": "todo", "priority": 5},
|
| 68 |
+
{"title": "Design Audit Trail", "description": "Create an auditable record structure for summaries, agent reasoning, routing decisions, reviewer overrides, and timestamps.", "status": "todo", "priority": 4},
|
| 69 |
+
{"title": "Review Compliance Risks", "description": "Identify privacy, consent, medical safety, bias, and operational risks with mitigation recommendations.", "status": "todo", "priority": 4},
|
| 70 |
+
{"title": "Create Pilot Validation Plan", "description": "Define how clinicians will evaluate accuracy, escalation safety, workload impact, and patient experience before rollout.", "status": "todo", "priority": 3}
|
| 71 |
+
]
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"project": {
|
| 75 |
+
"name": "Legal Contract Review Automation",
|
| 76 |
+
"description": "Create an agent-assisted workflow that reviews vendor contracts, flags risky clauses, and prepares negotiation notes for legal approval.",
|
| 77 |
+
"status": "active",
|
| 78 |
+
"context": "Use this as a legal operations example. Focus on contract risk, clause extraction, redlines, escalation thresholds, and attorney review."
|
| 79 |
+
},
|
| 80 |
+
"tasks": [
|
| 81 |
+
{"title": "Define Contract Review Scope", "description": "Identify contract types, clause families, review boundaries, and documents that must always be escalated to counsel.", "status": "todo", "priority": 5},
|
| 82 |
+
{"title": "Build Clause Risk Taxonomy", "description": "Classify indemnity, limitation of liability, termination, data protection, payment, jurisdiction, and renewal risks.", "status": "todo", "priority": 5},
|
| 83 |
+
{"title": "Design Legal Review Workflow", "description": "Create a multi-agent workflow for clause extraction, risk scoring, fallback research, negotiation notes, and final attorney approval.", "status": "todo", "priority": 4},
|
| 84 |
+
{"title": "Draft Approval Checklist", "description": "Prepare a checklist for legal reviewers covering unacceptable terms, missing clauses, confidence levels, and required evidence.", "status": "todo", "priority": 4},
|
| 85 |
+
{"title": "Prepare Pilot Metrics", "description": "Define cycle time, review accuracy, escalation rate, reviewer override rate, and business stakeholder satisfaction metrics.", "status": "todo", "priority": 3}
|
| 86 |
+
]
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"project": {
|
| 90 |
+
"name": "Regulatory Compliance Monitoring",
|
| 91 |
+
"description": "Design a legal monitoring workflow that tracks regulatory changes, summarizes business impact, and routes obligations to owners.",
|
| 92 |
+
"status": "active",
|
| 93 |
+
"context": "Use this as a compliance example. Emphasize source traceability, jurisdiction filters, obligation mapping, audit logs, and risk-based prioritization."
|
| 94 |
+
},
|
| 95 |
+
"tasks": [
|
| 96 |
+
{"title": "Map Regulatory Sources", "description": "List official regulators, legal update feeds, jurisdictions, business units, and source reliability rules.", "status": "todo", "priority": 5},
|
| 97 |
+
{"title": "Define Obligation Categories", "description": "Create categories for reporting, privacy, security, employment, financial controls, retention, and customer disclosure obligations.", "status": "todo", "priority": 5},
|
| 98 |
+
{"title": "Design Change Detection Workflow", "description": "Create an agent workflow that detects changes, summarizes impact, links evidence, and assigns obligations to owners.", "status": "todo", "priority": 4},
|
| 99 |
+
{"title": "Create Audit Evidence Model", "description": "Specify how the system stores source URLs, timestamps, summaries, reviewer decisions, owner acknowledgements, and completion proof.", "status": "todo", "priority": 4},
|
| 100 |
+
{"title": "Prioritize Compliance Rollout", "description": "Rank jurisdictions and obligation types by legal exposure, operational complexity, and implementation effort.", "status": "todo", "priority": 3}
|
| 101 |
+
]
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"project": {
|
| 105 |
+
"name": "Litigation Discovery Triage",
|
| 106 |
+
"description": "Prototype an AI-assisted discovery workflow that groups documents, identifies privilege risks, and prepares review batches for legal teams.",
|
| 107 |
+
"status": "active",
|
| 108 |
+
"context": "Use this as a litigation support example. Focus on defensibility, privilege review, chain of custody, reviewer queues, and evidence traceability."
|
| 109 |
+
},
|
| 110 |
+
"tasks": [
|
| 111 |
+
{"title": "Define Discovery Data Inputs", "description": "Identify document sources, metadata fields, custodians, date ranges, file types, and chain-of-custody requirements.", "status": "todo", "priority": 5},
|
| 112 |
+
{"title": "Specify Privilege Screening Rules", "description": "Define attorney-client, work product, confidentiality, and sensitive data indicators that require legal review.", "status": "todo", "priority": 5},
|
| 113 |
+
{"title": "Design Review Batch Workflow", "description": "Create an agent workflow for deduplication, clustering, privilege flagging, relevance summaries, and reviewer queue assignment.", "status": "todo", "priority": 4},
|
| 114 |
+
{"title": "Create Defensibility Controls", "description": "Specify audit logs, reviewer overrides, confidence thresholds, sampled quality checks, and exportable decision records.", "status": "todo", "priority": 4},
|
| 115 |
+
{"title": "Prepare Discovery Summary Report", "description": "Draft the report structure for document volumes, risk categories, review progress, escalations, and unresolved issues.", "status": "todo", "priority": 3}
|
| 116 |
+
]
|
| 117 |
+
}
|
| 118 |
+
]
|
| 119 |
+
|
| 120 |
+
def resolve_owner_id():
|
| 121 |
+
existing_projects = supabase.table("projects").select("owner_id").limit(1).execute()
|
| 122 |
+
if existing_projects.data and existing_projects.data[0].get("owner_id"):
|
| 123 |
+
return existing_projects.data[0]["owner_id"]
|
| 124 |
+
|
| 125 |
+
users = supabase.table("profiles").select("id").limit(1).execute()
|
| 126 |
+
if users.data:
|
| 127 |
+
return users.data[0]["id"]
|
| 128 |
+
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
def create_project(project_data, tasks, owner_id):
|
| 132 |
+
existing = (
|
| 133 |
+
supabase.table("projects")
|
| 134 |
+
.select("id")
|
| 135 |
+
.eq("name", project_data["name"])
|
| 136 |
+
.limit(1)
|
| 137 |
+
.execute()
|
| 138 |
+
)
|
| 139 |
+
if existing.data:
|
| 140 |
+
print(f"Skipping existing project: {project_data['name']}")
|
| 141 |
+
return
|
| 142 |
+
|
| 143 |
+
payload = project_data.copy()
|
| 144 |
+
if owner_id:
|
| 145 |
+
payload["owner_id"] = owner_id
|
| 146 |
+
|
| 147 |
+
project_res = supabase.table("projects").insert(payload).execute()
|
| 148 |
+
project_id = project_res.data[0]["id"]
|
| 149 |
+
task_rows = [{**task, "project_id": project_id} for task in tasks]
|
| 150 |
+
supabase.table("tasks").insert(task_rows).execute()
|
| 151 |
+
print(f"Created project: {project_data['name']} ({len(task_rows)} tasks)")
|
| 152 |
+
|
| 153 |
+
def create_projects():
|
| 154 |
+
try:
|
| 155 |
+
owner_id = resolve_owner_id()
|
| 156 |
+
if not owner_id:
|
| 157 |
+
print("No valid owner_id found in projects or profiles. The project will be created without owner and might not be visible.")
|
| 158 |
+
else:
|
| 159 |
+
print(f"Using owner_id: {owner_id}")
|
| 160 |
+
|
| 161 |
+
for example in EXAMPLE_PROJECTS:
|
| 162 |
+
create_project(example["project"], example["tasks"], owner_id)
|
| 163 |
+
|
| 164 |
+
except Exception as e:
|
| 165 |
+
print(f"Error: {e}")
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
create_projects()
|
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,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 services.budget_service import BudgetExceededError, budget_service
|
| 6 |
+
from services.evidence_service import evidence_service
|
| 7 |
+
from agents.agent_factory import AgentFactory
|
| 8 |
+
from services.semantic_backprop import semantic_backprop
|
| 9 |
+
from services.output_quality import build_quality_instructions, validate_output
|
| 10 |
+
from services.memory_service import memory_service
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("agent_runner_service")
|
| 13 |
+
|
| 14 |
+
def _update_task_run(run_id: str, payload: dict):
|
| 15 |
+
try:
|
| 16 |
+
return supabase.table("task_runs").update(payload).eq("id", run_id).execute()
|
| 17 |
+
except Exception as exc:
|
| 18 |
+
if "duration_seconds" in payload and "duration_seconds" in str(exc) and "schema cache" in str(exc):
|
| 19 |
+
fallback_payload = {key: value for key, value in payload.items() if key != "duration_seconds"}
|
| 20 |
+
logger.warning("task_runs.duration_seconds is missing in Supabase schema; retrying run update without duration.")
|
| 21 |
+
return supabase.table("task_runs").update(fallback_payload).eq("id", run_id).execute()
|
| 22 |
+
raise
|
| 23 |
+
|
| 24 |
+
class AgentRunnerService:
|
| 25 |
+
@staticmethod
|
| 26 |
+
async def run_agent_task(
|
| 27 |
+
task: dict,
|
| 28 |
+
agent_data: dict,
|
| 29 |
+
*,
|
| 30 |
+
include_semantic_context: bool = False,
|
| 31 |
+
start_action: str = "execution_start",
|
| 32 |
+
start_content: str | None = None,
|
| 33 |
+
complete_action: str = "execution_complete",
|
| 34 |
+
complete_content: str = "Agent successfully completed the task and produced output.",
|
| 35 |
+
update_task: bool = True
|
| 36 |
+
) -> tuple[dict, str]:
|
| 37 |
+
task_id = task["id"]
|
| 38 |
+
project_id = task["project_id"]
|
| 39 |
+
run_id = None
|
| 40 |
+
|
| 41 |
+
if update_task:
|
| 42 |
+
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 43 |
+
await audit_service.log_action(
|
| 44 |
+
user_id=None,
|
| 45 |
+
action="task_status_changed",
|
| 46 |
+
agent_id=agent_data.get("id"),
|
| 47 |
+
task_id=task_id,
|
| 48 |
+
metadata={"project_id": project_id, "status": "in_progress"},
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
run_res = supabase.table("task_runs").insert({
|
| 53 |
+
"task_id": task_id,
|
| 54 |
+
"agent_id": agent_data["id"],
|
| 55 |
+
"status": "running"
|
| 56 |
+
}).execute()
|
| 57 |
+
run_id = run_res.data[0]["id"]
|
| 58 |
+
await audit_service.log_action(
|
| 59 |
+
user_id=None,
|
| 60 |
+
action="task_run_created",
|
| 61 |
+
agent_id=agent_data.get("id"),
|
| 62 |
+
task_id=task_id,
|
| 63 |
+
metadata={"project_id": project_id, "run_id": run_id, "status": "running"},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Emergency Model Override for decommissioned Groq models
|
| 67 |
+
model_to_use = agent_data["model"]
|
| 68 |
+
if "llama3-70b-8192" in model_to_use:
|
| 69 |
+
model_to_use = "llama-3.3-70b-versatile"
|
| 70 |
+
logger.warning(f"Overriding decommissioned model {agent_data['model']} with {model_to_use}")
|
| 71 |
+
|
| 72 |
+
agent = AgentFactory.get_agent(
|
| 73 |
+
provider=agent_data["api_provider"],
|
| 74 |
+
name=agent_data["name"],
|
| 75 |
+
role=agent_data["role"],
|
| 76 |
+
model=model_to_use,
|
| 77 |
+
system_prompt=agent_data.get("system_prompt")
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
context_res = supabase.table("tasks").select("title, output_data") \
|
| 81 |
+
.eq("project_id", project_id) \
|
| 82 |
+
.eq("status", "done") \
|
| 83 |
+
.execute()
|
| 84 |
+
context = context_res.data if context_res.data else []
|
| 85 |
+
|
| 86 |
+
project_data = task.get("project")
|
| 87 |
+
if not isinstance(project_data, dict):
|
| 88 |
+
project_res = (
|
| 89 |
+
supabase.table("projects")
|
| 90 |
+
.select("name,description,context")
|
| 91 |
+
.eq("id", project_id)
|
| 92 |
+
.single()
|
| 93 |
+
.execute()
|
| 94 |
+
)
|
| 95 |
+
project_data = project_res.data if project_res and project_res.data else {}
|
| 96 |
+
quality_task = {**task, "project": project_data}
|
| 97 |
+
|
| 98 |
+
extra_context = ""
|
| 99 |
+
if include_semantic_context:
|
| 100 |
+
extra_context = await semantic_backprop.get_project_context(project_id, task_id)
|
| 101 |
+
# Fetch Long-Term Memory (Cross-project)
|
| 102 |
+
memories = await memory_service.search_memory(
|
| 103 |
+
query=task.get("description") or task["title"],
|
| 104 |
+
limit=3,
|
| 105 |
+
threshold=0.72
|
| 106 |
+
)
|
| 107 |
+
if memories:
|
| 108 |
+
memory_header = "\n\n### RELEVANT HISTORICAL CONTEXT (CROSS-PROJECT)\n"
|
| 109 |
+
memory_blocks = []
|
| 110 |
+
for m in memories:
|
| 111 |
+
memory_blocks.append(f"- Memory: {m['content']}")
|
| 112 |
+
extra_context += memory_header + "\n".join(memory_blocks)
|
| 113 |
+
|
| 114 |
+
# Fetch Self-Optimization Lessons for this specific task
|
| 115 |
+
lessons_res = supabase.table("project_memory") \
|
| 116 |
+
.select("content") \
|
| 117 |
+
.eq("task_id", task_id) \
|
| 118 |
+
.eq("memory_type", "self_optimization_lesson") \
|
| 119 |
+
.order("created_at", desc=True) \
|
| 120 |
+
.limit(1) \
|
| 121 |
+
.execute()
|
| 122 |
+
|
| 123 |
+
if lessons_res.data:
|
| 124 |
+
lesson = lessons_res.data[0]["content"]
|
| 125 |
+
extra_context += f"\n\n### CRITICAL LESSON FROM PREVIOUS ATTEMPT\n{lesson}\n"
|
| 126 |
+
|
| 127 |
+
import time
|
| 128 |
+
import hashlib
|
| 129 |
+
|
| 130 |
+
# Simple in-memory cache for the session (could be persistent later)
|
| 131 |
+
if not hasattr(AgentRunnerService, "_task_cache"):
|
| 132 |
+
AgentRunnerService._task_cache = {}
|
| 133 |
+
|
| 134 |
+
# 1. Create a cache key based on task, agent (model + system prompt), and context
|
| 135 |
+
cache_input = f"{task['id']}-{agent_data['model']}-{agent_data.get('system_prompt', '')}-{task.get('description')}-{str(context)}-{extra_context}"
|
| 136 |
+
cache_key = hashlib.md5(cache_input.encode()).hexdigest()
|
| 137 |
+
|
| 138 |
+
# 2. Check Cache
|
| 139 |
+
if cache_key in AgentRunnerService._task_cache:
|
| 140 |
+
logger.info(f"Cache hit for task {task_id}. Skipping LLM call.")
|
| 141 |
+
cached_result = AgentRunnerService._task_cache[cache_key]
|
| 142 |
+
claims_count = await evidence_service.replace_task_claims(task, cached_result)
|
| 143 |
+
|
| 144 |
+
# Still log the "start" for UI consistency
|
| 145 |
+
agent_name = agent_data.get('name', 'Agent')
|
| 146 |
+
log_msg = start_content or f"Agent {agent_name} resuming task"
|
| 147 |
+
supabase.table("agent_logs").insert({
|
| 148 |
+
"task_id": task_id,
|
| 149 |
+
"run_id": run_id,
|
| 150 |
+
"action": start_action,
|
| 151 |
+
"content": f"[CACHE HIT] {log_msg}"
|
| 152 |
+
}).execute()
|
| 153 |
+
|
| 154 |
+
if update_task:
|
| 155 |
+
supabase.table("tasks").update({
|
| 156 |
+
"status": "awaiting_approval",
|
| 157 |
+
"output_data": cached_result
|
| 158 |
+
}).eq("id", task_id).execute()
|
| 159 |
+
await audit_service.log_action(
|
| 160 |
+
user_id=None,
|
| 161 |
+
action="task_status_changed",
|
| 162 |
+
agent_id=agent_data.get("id"),
|
| 163 |
+
task_id=task_id,
|
| 164 |
+
metadata={
|
| 165 |
+
"project_id": project_id,
|
| 166 |
+
"run_id": run_id,
|
| 167 |
+
"status": "awaiting_approval",
|
| 168 |
+
"cache_hit": True,
|
| 169 |
+
"claims_count": claims_count,
|
| 170 |
+
},
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
_update_task_run(run_id, {
|
| 174 |
+
"status": "completed",
|
| 175 |
+
"finished_at": datetime.now(timezone.utc).isoformat()
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
return cached_result, run_id
|
| 179 |
+
|
| 180 |
+
# 3. Log Start
|
| 181 |
+
supabase.table("agent_logs").insert({
|
| 182 |
+
"task_id": task_id,
|
| 183 |
+
"run_id": run_id,
|
| 184 |
+
"action": start_action,
|
| 185 |
+
"content": start_content or f"Agent {agent_data['name']} starting task: {task['title']}"
|
| 186 |
+
}).execute()
|
| 187 |
+
|
| 188 |
+
# 4. Execute Run with timing
|
| 189 |
+
start_time = time.time()
|
| 190 |
+
task_instructions = task.get("description") or task["title"]
|
| 191 |
+
task_instructions = f"{task_instructions}\n\n{build_quality_instructions(quality_task)}"
|
| 192 |
+
prompt_tokens = budget_service.estimate_prompt_tokens(
|
| 193 |
+
task_instructions=task_instructions,
|
| 194 |
+
context=context,
|
| 195 |
+
extra_context=extra_context,
|
| 196 |
+
system_prompt=agent_data.get("system_prompt"),
|
| 197 |
+
)
|
| 198 |
+
max_completion_tokens = int(getattr(agent, "max_tokens", 0) or 0)
|
| 199 |
+
estimated_preflight_cost = budget_service.estimate_cost(
|
| 200 |
+
agent_data.get("api_provider"),
|
| 201 |
+
agent_data.get("model"),
|
| 202 |
+
prompt_tokens,
|
| 203 |
+
max_completion_tokens,
|
| 204 |
+
)
|
| 205 |
+
budget_service.check_before_run(
|
| 206 |
+
project_id=project_id,
|
| 207 |
+
estimated_tokens=prompt_tokens + max_completion_tokens,
|
| 208 |
+
estimated_cost=estimated_preflight_cost,
|
| 209 |
+
)
|
| 210 |
+
result = await agent.run(task_instructions, context, extra_context=extra_context)
|
| 211 |
+
duration = time.time() - start_time
|
| 212 |
+
|
| 213 |
+
if result.get("status") == "error":
|
| 214 |
+
raise RuntimeError(result.get("error") or "Agent returned an error result.")
|
| 215 |
+
|
| 216 |
+
# 5. Security Sanitization (Defense in Depth)
|
| 217 |
+
raw_out = str(result.get("raw_output", ""))
|
| 218 |
+
suspicious_patterns = ["rm -rf", "mkfs", "dd if=", "curl", "wget", "chmod 777", "> /dev/sda"]
|
| 219 |
+
for pattern in suspicious_patterns:
|
| 220 |
+
if pattern in raw_out:
|
| 221 |
+
logger.warning(f"SECURITY: Suspicious pattern '{pattern}' detected in agent output for task {task_id}.")
|
| 222 |
+
result["security_warning"] = f"Output sanitized: suspicious pattern '{pattern}' detected."
|
| 223 |
+
|
| 224 |
+
quality_review = validate_output(quality_task, result)
|
| 225 |
+
result["quality_review"] = quality_review
|
| 226 |
+
claims_count = await evidence_service.replace_task_claims(task, result)
|
| 227 |
+
|
| 228 |
+
# Use actual usage if provided by agent, otherwise fallback to estimation
|
| 229 |
+
usage = result.get("usage") or {}
|
| 230 |
+
actual_prompt_tokens = usage.get("prompt_tokens") or prompt_tokens
|
| 231 |
+
actual_completion_tokens = usage.get("completion_tokens") or budget_service.estimate_completion_tokens(result)
|
| 232 |
+
|
| 233 |
+
actual_cost = budget_service.estimate_cost(
|
| 234 |
+
agent_data.get("api_provider"),
|
| 235 |
+
agent_data.get("model"),
|
| 236 |
+
actual_prompt_tokens,
|
| 237 |
+
actual_completion_tokens,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
budget_service.record_usage(
|
| 241 |
+
project_id=project_id,
|
| 242 |
+
task_id=task_id,
|
| 243 |
+
run_id=run_id,
|
| 244 |
+
agent_id=agent_data.get("id"),
|
| 245 |
+
provider=agent_data.get("api_provider"),
|
| 246 |
+
model=agent_data.get("model"),
|
| 247 |
+
prompt_tokens=actual_prompt_tokens,
|
| 248 |
+
completion_tokens=actual_completion_tokens,
|
| 249 |
+
estimated_cost=actual_cost,
|
| 250 |
+
metadata={"duration_seconds": round(duration, 2), "claims_count": claims_count, "usage_source": "api" if result.get("usage") else "estimation"},
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# 6. Save to Cache
|
| 254 |
+
AgentRunnerService._task_cache[cache_key] = result
|
| 255 |
+
|
| 256 |
+
if update_task:
|
| 257 |
+
supabase.table("tasks").update({
|
| 258 |
+
"status": "awaiting_approval",
|
| 259 |
+
"output_data": result
|
| 260 |
+
}).eq("id", task_id).execute()
|
| 261 |
+
await audit_service.log_action(
|
| 262 |
+
user_id=None,
|
| 263 |
+
action="task_status_changed",
|
| 264 |
+
agent_id=agent_data.get("id"),
|
| 265 |
+
task_id=task_id,
|
| 266 |
+
metadata={
|
| 267 |
+
"project_id": project_id,
|
| 268 |
+
"run_id": run_id,
|
| 269 |
+
"status": "awaiting_approval",
|
| 270 |
+
"quality_approved": quality_review["approved"],
|
| 271 |
+
"claims_count": claims_count,
|
| 272 |
+
"estimated_tokens": actual_prompt_tokens + actual_completion_tokens,
|
| 273 |
+
"estimated_cost": float(actual_cost),
|
| 274 |
+
},
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
# 7. Update Run Status
|
| 278 |
+
_update_task_run(run_id, {
|
| 279 |
+
"status": "completed",
|
| 280 |
+
"finished_at": datetime.now(timezone.utc).isoformat(),
|
| 281 |
+
"duration_seconds": round(duration, 2)
|
| 282 |
+
})
|
| 283 |
+
|
| 284 |
+
# 8. Log Completion with Metrics
|
| 285 |
+
supabase.table("agent_logs").insert({
|
| 286 |
+
"task_id": task_id,
|
| 287 |
+
"run_id": run_id,
|
| 288 |
+
"action": complete_action,
|
| 289 |
+
"content": f"{complete_content} (Execution time: {duration:.2f}s)"
|
| 290 |
+
}).execute()
|
| 291 |
+
|
| 292 |
+
if not quality_review["approved"]:
|
| 293 |
+
supabase.table("agent_logs").insert({
|
| 294 |
+
"task_id": task_id,
|
| 295 |
+
"run_id": run_id,
|
| 296 |
+
"action": "quality_review_failed",
|
| 297 |
+
"content": f"Quality review failed: {', '.join(quality_review['fail_reasons'])}"
|
| 298 |
+
}).execute()
|
| 299 |
+
await audit_service.log_action(
|
| 300 |
+
user_id=None,
|
| 301 |
+
action="task_quality_review_failed",
|
| 302 |
+
agent_id=agent_data.get("id"),
|
| 303 |
+
task_id=task_id,
|
| 304 |
+
metadata={
|
| 305 |
+
"project_id": project_id,
|
| 306 |
+
"run_id": run_id,
|
| 307 |
+
"fail_reasons": quality_review.get("fail_reasons", []),
|
| 308 |
+
},
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
return result, run_id
|
| 312 |
+
|
| 313 |
+
except BudgetExceededError as e:
|
| 314 |
+
logger.warning(f"Budget blocked task {task_id}: {str(e)}")
|
| 315 |
+
if run_id:
|
| 316 |
+
_update_task_run(run_id, {
|
| 317 |
+
"status": "cancelled",
|
| 318 |
+
"error_message": str(e),
|
| 319 |
+
"finished_at": datetime.now(timezone.utc).isoformat()
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
if update_task:
|
| 323 |
+
supabase.table("tasks").update({
|
| 324 |
+
"status": "failed",
|
| 325 |
+
"output_data": {"error": str(e), "budget_blocked": True}
|
| 326 |
+
}).eq("id", task_id).execute()
|
| 327 |
+
await audit_service.log_action(
|
| 328 |
+
user_id=None,
|
| 329 |
+
action="task_budget_blocked",
|
| 330 |
+
agent_id=agent_data.get("id"),
|
| 331 |
+
task_id=task_id,
|
| 332 |
+
metadata={"project_id": project_id, "run_id": run_id, "error": str(e)},
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
supabase.table("agent_logs").insert({
|
| 336 |
+
"task_id": task_id,
|
| 337 |
+
"run_id": run_id,
|
| 338 |
+
"action": "budget_blocked",
|
| 339 |
+
"content": f"Budget blocked execution: {str(e)}"
|
| 340 |
+
}).execute()
|
| 341 |
+
|
| 342 |
+
raise e
|
| 343 |
+
|
| 344 |
+
except Exception as e:
|
| 345 |
+
logger.error(f"Error executing task {task_id}: {str(e)}")
|
| 346 |
+
if run_id:
|
| 347 |
+
_update_task_run(run_id, {
|
| 348 |
+
"status": "failed",
|
| 349 |
+
"finished_at": datetime.now(timezone.utc).isoformat()
|
| 350 |
+
})
|
| 351 |
+
|
| 352 |
+
if update_task:
|
| 353 |
+
supabase.table("tasks").update({
|
| 354 |
+
"status": "failed",
|
| 355 |
+
"output_data": {"error": str(e)}
|
| 356 |
+
}).eq("id", task_id).execute()
|
| 357 |
+
await audit_service.log_action(
|
| 358 |
+
user_id=None,
|
| 359 |
+
action="task_status_changed",
|
| 360 |
+
agent_id=agent_data.get("id"),
|
| 361 |
+
task_id=task_id,
|
| 362 |
+
metadata={
|
| 363 |
+
"project_id": project_id,
|
| 364 |
+
"run_id": run_id,
|
| 365 |
+
"status": "failed",
|
| 366 |
+
"error": str(e),
|
| 367 |
+
},
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
# LOG ERROR TO AGENT CONSOLE
|
| 371 |
+
supabase.table("agent_logs").insert({
|
| 372 |
+
"task_id": task_id,
|
| 373 |
+
"run_id": run_id,
|
| 374 |
+
"action": "execution_failed",
|
| 375 |
+
"content": f"ERROR: {str(e)}"
|
| 376 |
+
}).execute()
|
| 377 |
+
|
| 378 |
+
raise e
|
| 379 |
+
|
| 380 |
+
@staticmethod
|
| 381 |
+
async def execute_agent_logic(task: dict, agent_data: dict):
|
| 382 |
+
task_id = task["id"]
|
| 383 |
+
try:
|
| 384 |
+
await AgentRunnerService.run_agent_task(
|
| 385 |
+
task,
|
| 386 |
+
agent_data,
|
| 387 |
+
include_semantic_context=True
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
await audit_service.log_action(
|
| 391 |
+
user_id=None,
|
| 392 |
+
action="agent_task_completed",
|
| 393 |
+
agent_id=agent_data["id"],
|
| 394 |
+
task_id=task_id,
|
| 395 |
+
metadata={"model": agent_data["model"]}
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
except Exception:
|
| 399 |
+
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/budget_service.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from decimal import Decimal
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from services.config import config_service
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("budget_service")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _estimate_tokens(value: Any) -> int:
|
| 11 |
+
text = str(value or "")
|
| 12 |
+
if not text.strip():
|
| 13 |
+
return 0
|
| 14 |
+
return max(1, len(text) // 4)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _safe_decimal(value: Any) -> Decimal:
|
| 18 |
+
try:
|
| 19 |
+
return Decimal(str(value or "0"))
|
| 20 |
+
except Exception:
|
| 21 |
+
return Decimal("0")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class BudgetExceededError(RuntimeError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class BudgetService:
|
| 29 |
+
@staticmethod
|
| 30 |
+
def estimate_prompt_tokens(
|
| 31 |
+
*,
|
| 32 |
+
task_instructions: str,
|
| 33 |
+
context: list[dict],
|
| 34 |
+
extra_context: str,
|
| 35 |
+
system_prompt: str | None,
|
| 36 |
+
) -> int:
|
| 37 |
+
return (
|
| 38 |
+
_estimate_tokens(task_instructions)
|
| 39 |
+
+ _estimate_tokens(context)
|
| 40 |
+
+ _estimate_tokens(extra_context)
|
| 41 |
+
+ _estimate_tokens(system_prompt)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
def estimate_completion_tokens(result: dict) -> int:
|
| 46 |
+
if not isinstance(result, dict):
|
| 47 |
+
return _estimate_tokens(result)
|
| 48 |
+
return _estimate_tokens(result.get("raw_output") or result.get("data") or result)
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def estimate_cost(provider: str | None, model: str | None, prompt_tokens: int, completion_tokens: int) -> Decimal:
|
| 52 |
+
pricing = config_service.get_global_setting("model_pricing", {}) or {}
|
| 53 |
+
keys = [
|
| 54 |
+
f"{provider}:{model}" if provider and model else None,
|
| 55 |
+
str(model) if model else None,
|
| 56 |
+
str(provider) if provider else None,
|
| 57 |
+
]
|
| 58 |
+
price = next((pricing.get(key) for key in keys if key and key in pricing), None)
|
| 59 |
+
if not isinstance(price, dict):
|
| 60 |
+
return Decimal("0")
|
| 61 |
+
|
| 62 |
+
input_per_1k = _safe_decimal(price.get("input_per_1k"))
|
| 63 |
+
output_per_1k = _safe_decimal(price.get("output_per_1k"))
|
| 64 |
+
return (
|
| 65 |
+
(Decimal(prompt_tokens) / Decimal(1000)) * input_per_1k
|
| 66 |
+
+ (Decimal(completion_tokens) / Decimal(1000)) * output_per_1k
|
| 67 |
+
).quantize(Decimal("0.000001"))
|
| 68 |
+
|
| 69 |
+
@staticmethod
|
| 70 |
+
def _load_budget(project_id: str) -> dict | None:
|
| 71 |
+
try:
|
| 72 |
+
from services.supabase_service import supabase
|
| 73 |
+
|
| 74 |
+
response = supabase.table("project_budgets").select("*").eq("project_id", project_id).execute()
|
| 75 |
+
return response.data[0] if response.data else None
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
logger.warning("Could not load project budget for %s: %s", project_id, exc)
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def _usage_totals(project_id: str) -> dict:
|
| 82 |
+
try:
|
| 83 |
+
from services.supabase_service import supabase
|
| 84 |
+
|
| 85 |
+
rows = (
|
| 86 |
+
supabase.table("project_usage_events")
|
| 87 |
+
.select("total_tokens,estimated_cost")
|
| 88 |
+
.eq("project_id", project_id)
|
| 89 |
+
.execute()
|
| 90 |
+
.data
|
| 91 |
+
or []
|
| 92 |
+
)
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.warning("Could not load project usage for %s: %s", project_id, exc)
|
| 95 |
+
return {"total_tokens": 0, "estimated_cost": Decimal("0")}
|
| 96 |
+
|
| 97 |
+
return {
|
| 98 |
+
"total_tokens": sum(int(row.get("total_tokens") or 0) for row in rows),
|
| 99 |
+
"estimated_cost": sum((_safe_decimal(row.get("estimated_cost")) for row in rows), Decimal("0")),
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def project_budget_status(cls, project_id: str) -> dict:
|
| 104 |
+
budget = cls._load_budget(project_id)
|
| 105 |
+
usage = cls._usage_totals(project_id)
|
| 106 |
+
token_budget = int(budget["token_budget"]) if budget and budget.get("token_budget") is not None else None
|
| 107 |
+
cost_budget = _safe_decimal(budget.get("cost_budget")) if budget and budget.get("cost_budget") is not None else None
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
"project_id": project_id,
|
| 111 |
+
"budget": budget,
|
| 112 |
+
"usage": {
|
| 113 |
+
"total_tokens": usage["total_tokens"],
|
| 114 |
+
"estimated_cost": float(usage["estimated_cost"]),
|
| 115 |
+
},
|
| 116 |
+
"remaining": {
|
| 117 |
+
"tokens": max(token_budget - usage["total_tokens"], 0) if token_budget is not None else None,
|
| 118 |
+
"cost": float(max(cost_budget - usage["estimated_cost"], Decimal("0"))) if cost_budget is not None else None,
|
| 119 |
+
},
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
@staticmethod
|
| 123 |
+
def upsert_project_budget(
|
| 124 |
+
*,
|
| 125 |
+
project_id: str,
|
| 126 |
+
enabled: bool = True,
|
| 127 |
+
token_budget: int | None = None,
|
| 128 |
+
cost_budget: float | None = None,
|
| 129 |
+
currency: str = "USD",
|
| 130 |
+
) -> dict:
|
| 131 |
+
try:
|
| 132 |
+
from services.supabase_service import supabase
|
| 133 |
+
|
| 134 |
+
payload = {
|
| 135 |
+
"project_id": project_id,
|
| 136 |
+
"enabled": enabled,
|
| 137 |
+
"token_budget": token_budget,
|
| 138 |
+
"cost_budget": cost_budget,
|
| 139 |
+
"currency": currency or "USD",
|
| 140 |
+
}
|
| 141 |
+
response = supabase.table("project_budgets").upsert(payload, on_conflict="project_id").execute()
|
| 142 |
+
return response.data[0] if response.data else payload
|
| 143 |
+
except Exception as exc:
|
| 144 |
+
logger.warning("Could not upsert project budget for %s: %s", project_id, exc)
|
| 145 |
+
raise
|
| 146 |
+
|
| 147 |
+
@classmethod
|
| 148 |
+
def check_before_run(
|
| 149 |
+
cls,
|
| 150 |
+
*,
|
| 151 |
+
project_id: str,
|
| 152 |
+
estimated_tokens: int,
|
| 153 |
+
estimated_cost: Decimal,
|
| 154 |
+
) -> dict:
|
| 155 |
+
budget = cls._load_budget(project_id)
|
| 156 |
+
if not budget or not budget.get("enabled", True):
|
| 157 |
+
return {"allowed": True, "budget": budget, "usage": None}
|
| 158 |
+
|
| 159 |
+
usage = cls._usage_totals(project_id)
|
| 160 |
+
token_budget = budget.get("token_budget")
|
| 161 |
+
if token_budget is not None and usage["total_tokens"] + estimated_tokens > int(token_budget):
|
| 162 |
+
raise BudgetExceededError(
|
| 163 |
+
f"Project token budget exceeded: {usage['total_tokens']} used + {estimated_tokens} estimated > {token_budget}."
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
cost_budget = budget.get("cost_budget")
|
| 167 |
+
if cost_budget is not None and usage["estimated_cost"] + estimated_cost > _safe_decimal(cost_budget):
|
| 168 |
+
raise BudgetExceededError(
|
| 169 |
+
f"Project cost budget exceeded: {usage['estimated_cost']} used + {estimated_cost} estimated > {cost_budget}."
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
return {"allowed": True, "budget": budget, "usage": usage}
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
def record_usage(
|
| 176 |
+
*,
|
| 177 |
+
project_id: str,
|
| 178 |
+
task_id: str,
|
| 179 |
+
run_id: str | None,
|
| 180 |
+
agent_id: str | None,
|
| 181 |
+
provider: str | None,
|
| 182 |
+
model: str | None,
|
| 183 |
+
prompt_tokens: int,
|
| 184 |
+
completion_tokens: int,
|
| 185 |
+
estimated_cost: Decimal,
|
| 186 |
+
metadata: dict | None = None,
|
| 187 |
+
) -> None:
|
| 188 |
+
try:
|
| 189 |
+
from services.supabase_service import supabase
|
| 190 |
+
|
| 191 |
+
supabase.table("project_usage_events").insert({
|
| 192 |
+
"project_id": project_id,
|
| 193 |
+
"task_id": task_id,
|
| 194 |
+
"run_id": run_id,
|
| 195 |
+
"agent_id": agent_id,
|
| 196 |
+
"provider": provider,
|
| 197 |
+
"model": model,
|
| 198 |
+
"prompt_tokens": prompt_tokens,
|
| 199 |
+
"completion_tokens": completion_tokens,
|
| 200 |
+
"total_tokens": prompt_tokens + completion_tokens,
|
| 201 |
+
"estimated_cost": float(estimated_cost),
|
| 202 |
+
"metadata": metadata or {},
|
| 203 |
+
}).execute()
|
| 204 |
+
except Exception as exc:
|
| 205 |
+
logger.warning("Could not record project usage for task %s: %s", task_id, exc)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
budget_service = BudgetService()
|
backend/services/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# Infrastructure (DigitalOcean)
|
| 20 |
+
DO_API_TOKEN: Optional[str] = None
|
| 21 |
+
DO_INFERENCE_KEY: Optional[str] = None
|
| 22 |
+
DO_AGENT_ACCESS_KEY: Optional[str] = None
|
| 23 |
+
DO_AGENT_ENDPOINT: Optional[str] = None
|
| 24 |
+
DO_REGION: str = "nyc3"
|
| 25 |
+
|
| 26 |
+
# App Config
|
| 27 |
+
TASK_QUEUE_EMBEDDED_WORKER: bool = True
|
| 28 |
+
TASK_QUEUE_HEARTBEAT_ENABLED: bool = True
|
| 29 |
+
TASK_EXECUTION_MODE: str = "queue" # direct | queue
|
| 30 |
+
TASK_QUEUE_IDLE_POLL_SECONDS: int = 60
|
| 31 |
+
OUTPUT_LANGUAGE: str = "en"
|
| 32 |
+
PORT: int = 8000
|
| 33 |
+
SENTRY_DSN: Optional[str] = None
|
| 34 |
+
|
| 35 |
+
model_config = {
|
| 36 |
+
"env_file": ".env",
|
| 37 |
+
"extra": "ignore"
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
settings = Settings()
|
| 41 |
+
|
| 42 |
+
class ConfigService:
|
| 43 |
+
"""
|
| 44 |
+
Manages application-wide settings stored in Supabase with local fallback defaults.
|
| 45 |
+
Borrowed from AgentCollab for enhanced flexibility.
|
| 46 |
+
"""
|
| 47 |
+
_cache: Dict[str, Any] = {}
|
| 48 |
+
_supabase: Client = None
|
| 49 |
+
|
| 50 |
+
@classmethod
|
| 51 |
+
def _get_supabase(cls):
|
| 52 |
+
if not cls._supabase:
|
| 53 |
+
if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_ROLE_KEY:
|
| 54 |
+
return None
|
| 55 |
+
cls._supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY)
|
| 56 |
+
return cls._supabase
|
| 57 |
+
|
| 58 |
+
# Defaults used when DB has no config entry for a provider
|
| 59 |
+
_DEFAULTS: Dict[str, Any] = {
|
| 60 |
+
"groq": {"enabled": True, "default_model": "llama-3.3-70b-versatile", "temperature": 0.7, "max_tokens": 4096},
|
| 61 |
+
"openai": {"enabled": True, "default_model": "gpt-4o", "temperature": 0.7, "max_tokens": 4096},
|
| 62 |
+
"openrouter": {"enabled": True, "default_model": "google/gemini-2.0-flash", "temperature": 0.7, "max_tokens": 8192},
|
| 63 |
+
"gemini": {"enabled": True, "default_model": "gemini-2.0-flash", "temperature": 0.7, "max_tokens": 8192},
|
| 64 |
+
"amd": {"enabled": True, "default_model": "llama-3.3-70b-instruct", "temperature": 0.7, "max_tokens": 4096, "base_url": "https://inference.do-ai.run/v1"},
|
| 65 |
+
"ollama": {"enabled": True, "default_model": "llama3.1:8b", "temperature": 0.7, "base_url": "http://localhost:11434"},
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
@classmethod
|
| 69 |
+
def get_provider_config(cls, provider: str) -> Dict[str, Any]:
|
| 70 |
+
"""Returns config for a provider from cache, DB, then defaults."""
|
| 71 |
+
cache_key = f"provider:{provider}"
|
| 72 |
+
if cache_key in cls._cache:
|
| 73 |
+
return cls._cache[cache_key]
|
| 74 |
+
|
| 75 |
+
db = cls._get_supabase()
|
| 76 |
+
if db:
|
| 77 |
+
try:
|
| 78 |
+
resp = db.table("app_config").select("*").eq("key", provider).execute()
|
| 79 |
+
if resp.data and len(resp.data) > 0:
|
| 80 |
+
cls._cache[cache_key] = resp.data[0]["value"]
|
| 81 |
+
return cls._cache[cache_key]
|
| 82 |
+
except Exception:
|
| 83 |
+
pass # Fall through to defaults
|
| 84 |
+
|
| 85 |
+
result = cls._DEFAULTS.get(provider, {})
|
| 86 |
+
cls._cache[cache_key] = result
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
@classmethod
|
| 90 |
+
def get_global_setting(cls, key: str, default: Any = None) -> Any:
|
| 91 |
+
cache_key = f"global:{key}"
|
| 92 |
+
if cache_key in cls._cache:
|
| 93 |
+
return cls._cache[cache_key]
|
| 94 |
+
|
| 95 |
+
db = cls._get_supabase()
|
| 96 |
+
if db:
|
| 97 |
+
try:
|
| 98 |
+
resp = db.table("app_config").select("*").eq("key", key).execute()
|
| 99 |
+
if resp.data and len(resp.data) > 0:
|
| 100 |
+
cls._cache[cache_key] = resp.data[0]["value"]
|
| 101 |
+
return cls._cache[cache_key]
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
return default
|
| 106 |
+
|
| 107 |
+
config_service = ConfigService()
|
backend/services/embedding_service.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import numpy as np
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
import openai
|
| 5 |
+
from services.config import settings
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("embedding_service")
|
| 8 |
+
|
| 9 |
+
class EmbeddingService:
|
| 10 |
+
"""
|
| 11 |
+
Handles text vectorization for semantic deduplication and retrieval.
|
| 12 |
+
"""
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.client = None
|
| 15 |
+
self.model = "text-embedding-3-small"
|
| 16 |
+
if settings.OPENAI_API_KEY:
|
| 17 |
+
try:
|
| 18 |
+
self.client = openai.AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
| 19 |
+
except Exception as e:
|
| 20 |
+
logger.error(f"Failed to initialize OpenAI client for embeddings: {e}")
|
| 21 |
+
|
| 22 |
+
async def get_embeddings(self, texts: List[str]) -> List[List[float]]:
|
| 23 |
+
"""
|
| 24 |
+
Batch fetches embeddings for a list of strings.
|
| 25 |
+
"""
|
| 26 |
+
if not settings.OPENAI_API_KEY or not self.client:
|
| 27 |
+
logger.debug("OpenAI embeddings not available (missing key or initialization failed).")
|
| 28 |
+
return []
|
| 29 |
+
|
| 30 |
+
if not texts:
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
# Cleanup texts to avoid API errors on empty/null inputs
|
| 35 |
+
clean_texts = [str(t)[:8000] for t in texts if t]
|
| 36 |
+
if not clean_texts:
|
| 37 |
+
return []
|
| 38 |
+
|
| 39 |
+
response = await self.client.embeddings.create(
|
| 40 |
+
input=clean_texts,
|
| 41 |
+
model=self.model
|
| 42 |
+
)
|
| 43 |
+
return [data.embedding for data in response.data]
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error(f"Failed to fetch embeddings: {e}")
|
| 46 |
+
return []
|
| 47 |
+
|
| 48 |
+
def cosine_similarity(self, vec_a: List[float], vec_b: List[float]) -> float:
|
| 49 |
+
"""
|
| 50 |
+
Calculates cosine similarity between two vectors.
|
| 51 |
+
"""
|
| 52 |
+
a = np.array(vec_a)
|
| 53 |
+
b = np.array(vec_b)
|
| 54 |
+
if not a.any() or not b.any():
|
| 55 |
+
return 0.0
|
| 56 |
+
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
|
| 57 |
+
|
| 58 |
+
async def find_duplicates(self, new_claims: List[str], existing_claims: List[str], threshold: float = 0.85) -> List[Optional[int]]:
|
| 59 |
+
"""
|
| 60 |
+
For each new claim, finds the index of a semantically similar existing claim.
|
| 61 |
+
Returns a list of indices or None if no match found.
|
| 62 |
+
"""
|
| 63 |
+
if not new_claims or not existing_claims:
|
| 64 |
+
return [None] * len(new_claims)
|
| 65 |
+
|
| 66 |
+
new_vecs = await self.get_embeddings(new_claims)
|
| 67 |
+
existing_vecs = await self.get_embeddings(existing_claims)
|
| 68 |
+
|
| 69 |
+
if not new_vecs or not existing_vecs:
|
| 70 |
+
return [None] * len(new_claims)
|
| 71 |
+
|
| 72 |
+
results = []
|
| 73 |
+
for n_vec in new_vecs:
|
| 74 |
+
best_idx = None
|
| 75 |
+
best_score = -1.0
|
| 76 |
+
|
| 77 |
+
for idx, e_vec in enumerate(existing_vecs):
|
| 78 |
+
score = self.cosine_similarity(n_vec, e_vec)
|
| 79 |
+
if score > threshold and score > best_score:
|
| 80 |
+
best_score = score
|
| 81 |
+
best_idx = idx
|
| 82 |
+
|
| 83 |
+
results.append(best_idx)
|
| 84 |
+
|
| 85 |
+
return results
|
| 86 |
+
|
| 87 |
+
embedding_service = EmbeddingService()
|
backend/services/evidence_service.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import hashlib
|
| 3 |
+
import re
|
| 4 |
+
import unicodedata
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from services.task_schemas import parse_structured_payload
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger("evidence_service")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _primary_payload(output_data: dict) -> Any:
|
| 13 |
+
data = output_data.get("data")
|
| 14 |
+
if data not in (None, "", [], {}):
|
| 15 |
+
return parse_structured_payload(data) if isinstance(data, str) else data
|
| 16 |
+
return parse_structured_payload(output_data.get("raw_output"))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _clean_text(value: Any) -> str:
|
| 20 |
+
return str(value or "").strip()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def normalize_entity_key(value: Any) -> str | None:
|
| 24 |
+
text = _clean_text(value)
|
| 25 |
+
if not text:
|
| 26 |
+
return None
|
| 27 |
+
normalized = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
|
| 28 |
+
normalized = normalized.lower()
|
| 29 |
+
normalized = re.sub(r"\b(inc|llc|ltd|corp|corporation|company|co|sa|s\.a\.)\b", "", normalized)
|
| 30 |
+
normalized = re.sub(r"[^a-z0-9]+", " ", normalized)
|
| 31 |
+
normalized = re.sub(r"\s+", " ", normalized).strip()
|
| 32 |
+
return normalized or None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def normalize_claim_text(value: Any) -> str:
|
| 36 |
+
text = unicodedata.normalize("NFKD", _clean_text(value)).encode("ascii", "ignore").decode("ascii")
|
| 37 |
+
text = text.lower()
|
| 38 |
+
text = re.sub(r"https?://\S+", "", text)
|
| 39 |
+
text = re.sub(r"[^a-z0-9]+", " ", text)
|
| 40 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def claim_hash(
|
| 44 |
+
project_id: str | None,
|
| 45 |
+
claim_text: str,
|
| 46 |
+
entity_name: str | None = None,
|
| 47 |
+
entity_key: str | None = None,
|
| 48 |
+
) -> str:
|
| 49 |
+
key = "|".join([
|
| 50 |
+
project_id or "",
|
| 51 |
+
entity_key or normalize_entity_key(entity_name) or "",
|
| 52 |
+
normalize_claim_text(claim_text),
|
| 53 |
+
])
|
| 54 |
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _claim_row(
|
| 58 |
+
*,
|
| 59 |
+
project_id: str | None,
|
| 60 |
+
task_id: str | None,
|
| 61 |
+
claim_text: str,
|
| 62 |
+
claim_type: str,
|
| 63 |
+
entity_name: str | None = None,
|
| 64 |
+
source_url: str | None = None,
|
| 65 |
+
confidence: str = "unknown",
|
| 66 |
+
metadata: dict | None = None,
|
| 67 |
+
alias_map: dict[str, str] | None = None,
|
| 68 |
+
) -> dict:
|
| 69 |
+
raw_entity_key = normalize_entity_key(entity_name)
|
| 70 |
+
entity_key = (alias_map or {}).get(raw_entity_key or "", raw_entity_key)
|
| 71 |
+
return {
|
| 72 |
+
"project_id": project_id,
|
| 73 |
+
"task_id": task_id,
|
| 74 |
+
"claim_text": claim_text,
|
| 75 |
+
"claim_type": claim_type,
|
| 76 |
+
"entity_name": entity_name,
|
| 77 |
+
"entity_key": entity_key,
|
| 78 |
+
"claim_hash": claim_hash(project_id, claim_text, entity_name, entity_key),
|
| 79 |
+
"source_url": source_url,
|
| 80 |
+
"confidence": confidence,
|
| 81 |
+
"metadata": metadata or {},
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class EvidenceService:
|
| 86 |
+
@staticmethod
|
| 87 |
+
def load_alias_map(project_id: str | None) -> dict[str, str]:
|
| 88 |
+
if not project_id:
|
| 89 |
+
return {}
|
| 90 |
+
try:
|
| 91 |
+
from services.supabase_service import supabase
|
| 92 |
+
|
| 93 |
+
rows = (
|
| 94 |
+
supabase.table("project_entity_aliases")
|
| 95 |
+
.select("alias_key,canonical_key")
|
| 96 |
+
.eq("project_id", project_id)
|
| 97 |
+
.execute()
|
| 98 |
+
.data
|
| 99 |
+
or []
|
| 100 |
+
)
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
logger.warning("Could not load entity aliases for project %s: %s", project_id, exc)
|
| 103 |
+
return {}
|
| 104 |
+
|
| 105 |
+
aliases: dict[str, str] = {}
|
| 106 |
+
for row in rows:
|
| 107 |
+
alias_key = row.get("alias_key")
|
| 108 |
+
canonical_key = row.get("canonical_key")
|
| 109 |
+
if alias_key and canonical_key:
|
| 110 |
+
aliases[alias_key] = canonical_key
|
| 111 |
+
return aliases
|
| 112 |
+
|
| 113 |
+
@staticmethod
|
| 114 |
+
def load_project_claims(project_id: str) -> list[dict]:
|
| 115 |
+
try:
|
| 116 |
+
from services.supabase_service import supabase
|
| 117 |
+
|
| 118 |
+
return (
|
| 119 |
+
supabase.table("task_claims")
|
| 120 |
+
.select("claim_text,claim_type,entity_name,entity_key,source_url,confidence,task_id,created_at")
|
| 121 |
+
.eq("project_id", project_id)
|
| 122 |
+
.order("created_at", desc=False)
|
| 123 |
+
.execute()
|
| 124 |
+
.data
|
| 125 |
+
or []
|
| 126 |
+
)
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
logger.warning("Could not load task claims for project %s: %s", project_id, exc)
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
@staticmethod
|
| 132 |
+
def summarize_claims(claims: list[dict]) -> dict:
|
| 133 |
+
by_type: dict[str, int] = {}
|
| 134 |
+
by_entity: dict[str, int] = {}
|
| 135 |
+
sourced_count = 0
|
| 136 |
+
|
| 137 |
+
for claim in claims:
|
| 138 |
+
claim_type = claim.get("claim_type") or "unknown"
|
| 139 |
+
by_type[claim_type] = by_type.get(claim_type, 0) + 1
|
| 140 |
+
|
| 141 |
+
entity = claim.get("entity_name") or claim.get("entity_key") or "Unassigned"
|
| 142 |
+
by_entity[entity] = by_entity.get(entity, 0) + 1
|
| 143 |
+
|
| 144 |
+
source_url = claim.get("source_url")
|
| 145 |
+
if isinstance(source_url, str) and source_url.startswith(("http://", "https://")):
|
| 146 |
+
sourced_count += 1
|
| 147 |
+
|
| 148 |
+
total_count = len(claims)
|
| 149 |
+
return {
|
| 150 |
+
"claim_count": total_count,
|
| 151 |
+
"sourced_claim_count": sourced_count,
|
| 152 |
+
"unsourced_claim_count": max(total_count - sourced_count, 0),
|
| 153 |
+
"source_coverage": round(sourced_count / total_count, 4) if total_count else 0,
|
| 154 |
+
"by_type": dict(sorted(by_type.items())),
|
| 155 |
+
"by_entity": dict(sorted(by_entity.items(), key=lambda item: item[1], reverse=True)),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@staticmethod
|
| 159 |
+
def extract_claims(task: dict, output_data: dict) -> list[dict]:
|
| 160 |
+
payload = _primary_payload(output_data)
|
| 161 |
+
if not isinstance(payload, dict):
|
| 162 |
+
return []
|
| 163 |
+
|
| 164 |
+
project_id = task.get("project_id")
|
| 165 |
+
task_id = task.get("id")
|
| 166 |
+
alias_map = EvidenceService.load_alias_map(project_id)
|
| 167 |
+
claims: list[dict] = []
|
| 168 |
+
|
| 169 |
+
for finding in payload.get("findings") or []:
|
| 170 |
+
if not isinstance(finding, dict):
|
| 171 |
+
continue
|
| 172 |
+
claim_text = _clean_text(finding.get("claim"))
|
| 173 |
+
if not claim_text:
|
| 174 |
+
continue
|
| 175 |
+
claims.append(_claim_row(
|
| 176 |
+
project_id=project_id,
|
| 177 |
+
task_id=task_id,
|
| 178 |
+
claim_text=claim_text,
|
| 179 |
+
claim_type="finding",
|
| 180 |
+
entity_name=_clean_text(finding.get("entity")) or None,
|
| 181 |
+
source_url=_clean_text(finding.get("source_url")) or None,
|
| 182 |
+
confidence=finding.get("confidence") if finding.get("confidence") in ("low", "medium", "high") else "unknown",
|
| 183 |
+
metadata={"schema_source": "findings"},
|
| 184 |
+
alias_map=alias_map,
|
| 185 |
+
))
|
| 186 |
+
|
| 187 |
+
for entity in payload.get("entities") or []:
|
| 188 |
+
if not isinstance(entity, dict):
|
| 189 |
+
continue
|
| 190 |
+
entity_name = _clean_text(entity.get("name"))
|
| 191 |
+
source_url = _clean_text(entity.get("source_url")) or None
|
| 192 |
+
for key, claim_type in (("strengths", "entity_strength"), ("weaknesses", "entity_weakness")):
|
| 193 |
+
for item in entity.get(key) or []:
|
| 194 |
+
claim_text = _clean_text(item)
|
| 195 |
+
if not claim_text:
|
| 196 |
+
continue
|
| 197 |
+
claims.append(_claim_row(
|
| 198 |
+
project_id=project_id,
|
| 199 |
+
task_id=task_id,
|
| 200 |
+
claim_text=claim_text,
|
| 201 |
+
claim_type=claim_type,
|
| 202 |
+
entity_name=entity_name or None,
|
| 203 |
+
source_url=source_url,
|
| 204 |
+
confidence="unknown",
|
| 205 |
+
metadata={"schema_source": f"entities.{key}", "category": entity.get("category")},
|
| 206 |
+
alias_map=alias_map,
|
| 207 |
+
))
|
| 208 |
+
|
| 209 |
+
for recommendation in payload.get("recommendations") or []:
|
| 210 |
+
if not isinstance(recommendation, dict):
|
| 211 |
+
continue
|
| 212 |
+
claim_text = _clean_text(recommendation.get("title") or recommendation.get("rationale"))
|
| 213 |
+
if not claim_text:
|
| 214 |
+
continue
|
| 215 |
+
claims.append(_claim_row(
|
| 216 |
+
project_id=project_id,
|
| 217 |
+
task_id=task_id,
|
| 218 |
+
claim_text=claim_text,
|
| 219 |
+
claim_type="recommendation",
|
| 220 |
+
metadata=recommendation,
|
| 221 |
+
))
|
| 222 |
+
|
| 223 |
+
for risk in payload.get("risks") or []:
|
| 224 |
+
claim_text = _clean_text(risk)
|
| 225 |
+
if not claim_text:
|
| 226 |
+
continue
|
| 227 |
+
claims.append(_claim_row(
|
| 228 |
+
project_id=project_id,
|
| 229 |
+
task_id=task_id,
|
| 230 |
+
claim_text=claim_text,
|
| 231 |
+
claim_type="risk",
|
| 232 |
+
metadata={"schema_source": "risks"},
|
| 233 |
+
))
|
| 234 |
+
|
| 235 |
+
deduped: dict[str, dict] = {}
|
| 236 |
+
for claim in claims:
|
| 237 |
+
deduped.setdefault(claim["claim_hash"], claim)
|
| 238 |
+
return list(deduped.values())
|
| 239 |
+
|
| 240 |
+
@staticmethod
|
| 241 |
+
async def replace_task_claims(task: dict, output_data: dict) -> int:
|
| 242 |
+
task_id = task.get("id")
|
| 243 |
+
if not task_id:
|
| 244 |
+
return 0
|
| 245 |
+
|
| 246 |
+
claims = EvidenceService.extract_claims(task, output_data)
|
| 247 |
+
try:
|
| 248 |
+
from services.supabase_service import supabase
|
| 249 |
+
|
| 250 |
+
supabase.table("task_claims").delete().eq("task_id", task_id).execute()
|
| 251 |
+
if claims:
|
| 252 |
+
supabase.table("task_claims").upsert(
|
| 253 |
+
claims,
|
| 254 |
+
on_conflict="project_id,claim_hash",
|
| 255 |
+
).execute()
|
| 256 |
+
return len(claims)
|
| 257 |
+
except Exception as exc:
|
| 258 |
+
logger.warning("Could not persist task claims for %s: %s", task_id, exc)
|
| 259 |
+
return 0
|
| 260 |
+
|
| 261 |
+
@staticmethod
|
| 262 |
+
async def merge_project_claims(project_id: str, threshold: float = 0.88) -> list[dict]:
|
| 263 |
+
"""
|
| 264 |
+
Groups similar claims within a project and returns a consolidated set.
|
| 265 |
+
"""
|
| 266 |
+
from services.embedding_service import embedding_service
|
| 267 |
+
|
| 268 |
+
claims = EvidenceService.load_project_claims(project_id)
|
| 269 |
+
if len(claims) < 2:
|
| 270 |
+
return claims
|
| 271 |
+
|
| 272 |
+
# Extract texts for embedding
|
| 273 |
+
texts = [c["claim_text"] for c in claims]
|
| 274 |
+
embeddings = await embedding_service.get_embeddings(texts)
|
| 275 |
+
if not embeddings:
|
| 276 |
+
return claims
|
| 277 |
+
|
| 278 |
+
merged: list[dict] = []
|
| 279 |
+
used_indices: set[int] = set()
|
| 280 |
+
|
| 281 |
+
for i in range(len(claims)):
|
| 282 |
+
if i in used_indices:
|
| 283 |
+
continue
|
| 284 |
+
|
| 285 |
+
base_claim = claims[i].copy()
|
| 286 |
+
used_indices.add(i)
|
| 287 |
+
|
| 288 |
+
# Look for matches in the rest of the claims
|
| 289 |
+
for j in range(i + 1, len(claims)):
|
| 290 |
+
if j in used_indices:
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
similarity = embedding_service.cosine_similarity(embeddings[i], embeddings[j])
|
| 294 |
+
if similarity >= threshold:
|
| 295 |
+
used_indices.add(j)
|
| 296 |
+
# Merge logic: Append sources, keep longest text, etc.
|
| 297 |
+
other_claim = claims[j]
|
| 298 |
+
if len(other_claim["claim_text"]) > len(base_claim["claim_text"]):
|
| 299 |
+
base_claim["claim_text"] = other_claim["claim_text"]
|
| 300 |
+
|
| 301 |
+
# Consolidate sources (metadata)
|
| 302 |
+
if other_claim.get("source_url") and not base_claim.get("source_url"):
|
| 303 |
+
base_claim["source_url"] = other_claim["source_url"]
|
| 304 |
+
|
| 305 |
+
# Track that this claim was merged
|
| 306 |
+
if "merged_count" not in base_claim:
|
| 307 |
+
base_claim["merged_count"] = 1
|
| 308 |
+
base_claim["merged_count"] += 1
|
| 309 |
+
|
| 310 |
+
merged.append(base_claim)
|
| 311 |
+
|
| 312 |
+
return merged
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
evidence_service = EvidenceService()
|
backend/services/infrastructure_service.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import logging
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import Optional, Dict, Any
|
| 5 |
+
from .config import settings
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("infrastructure")
|
| 8 |
+
|
| 9 |
+
class InfrastructureService:
|
| 10 |
+
"""
|
| 11 |
+
Manages on-the-fly compute resources on DigitalOcean for AI inference.
|
| 12 |
+
"""
|
| 13 |
+
API_URL = "https://api.digitalocean.com/v2"
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.headers = {
|
| 17 |
+
"Authorization": f"Bearer {settings.DO_API_TOKEN}",
|
| 18 |
+
"Content-Type": "application/json"
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async def create_inference_node(self, name: str, size: str = "s-4vcpu-8gb-amd") -> Optional[Dict[str, Any]]:
|
| 22 |
+
"""
|
| 23 |
+
Provision a new AMD-based droplet with Ollama pre-installed.
|
| 24 |
+
Default size is a capable AMD-based node.
|
| 25 |
+
"""
|
| 26 |
+
if not settings.DO_API_TOKEN:
|
| 27 |
+
logger.error("DO_API_TOKEN not configured.")
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
# Cloud-init script to setup the inference environment
|
| 31 |
+
user_data = """#cloud-config
|
| 32 |
+
runcmd:
|
| 33 |
+
- curl -fsSL https://get.docker.com | sh
|
| 34 |
+
- docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama -e OLLAMA_HOST=0.0.0.0 ollama/ollama
|
| 35 |
+
- sleep 10
|
| 36 |
+
- docker exec ollama ollama pull llama3
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
payload = {
|
| 40 |
+
"name": name,
|
| 41 |
+
"region": settings.DO_REGION,
|
| 42 |
+
"size": size,
|
| 43 |
+
"image": "ubuntu-22-04-x64",
|
| 44 |
+
"user_data": user_data,
|
| 45 |
+
"tags": ["aubm-worker", "inference-node"]
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async with httpx.AsyncClient() as client:
|
| 49 |
+
try:
|
| 50 |
+
response = await client.post(f"{self.API_URL}/droplets", headers=self.headers, json=payload)
|
| 51 |
+
response.raise_for_status()
|
| 52 |
+
data = response.json()
|
| 53 |
+
droplet_id = data["droplet"]["id"]
|
| 54 |
+
logger.info(f"Inference node creation initiated: {name} (ID: {droplet_id})")
|
| 55 |
+
return data["droplet"]
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Failed to create droplet: {e}")
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
async def wait_for_ip(self, droplet_id: int, timeout: int = 300) -> Optional[str]:
|
| 61 |
+
"""
|
| 62 |
+
Polls the API until the droplet has a public IP assigned.
|
| 63 |
+
"""
|
| 64 |
+
start_time = asyncio.get_event_loop().time()
|
| 65 |
+
async with httpx.AsyncClient() as client:
|
| 66 |
+
while (asyncio.get_event_loop().time() - start_time) < timeout:
|
| 67 |
+
try:
|
| 68 |
+
response = await client.get(f"{self.API_URL}/droplets/{droplet_id}", headers=self.headers)
|
| 69 |
+
response.raise_for_status()
|
| 70 |
+
droplet = response.json()["droplet"]
|
| 71 |
+
|
| 72 |
+
networks = droplet.get("networks", {}).get("v4", [])
|
| 73 |
+
for nw in networks:
|
| 74 |
+
if nw["type"] == "public":
|
| 75 |
+
return nw["ip_address"]
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.warning(f"Error polling droplet {droplet_id}: {e}")
|
| 79 |
+
|
| 80 |
+
await asyncio.sleep(10)
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
async def terminate_node(self, droplet_id: int):
|
| 84 |
+
"""
|
| 85 |
+
Destroy the inference node to stop billing.
|
| 86 |
+
"""
|
| 87 |
+
async with httpx.AsyncClient() as client:
|
| 88 |
+
try:
|
| 89 |
+
response = await client.delete(f"{self.API_URL}/droplets/{droplet_id}", headers=self.headers)
|
| 90 |
+
response.raise_for_status()
|
| 91 |
+
logger.info(f"Inference node {droplet_id} termination requested.")
|
| 92 |
+
return True
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"Failed to terminate droplet {droplet_id}: {e}")
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
infrastructure_service = InfrastructureService()
|
backend/services/memory_service.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
from services.supabase_service import supabase
|
| 4 |
+
from services.embedding_service import embedding_service
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("uvicorn")
|
| 7 |
+
|
| 8 |
+
class MemoryService:
|
| 9 |
+
"""
|
| 10 |
+
Handles vectorized long-term memory for Aubm projects.
|
| 11 |
+
Allows agents to retrieve context from past projects and approved work.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
async def save_memory(
|
| 15 |
+
self,
|
| 16 |
+
project_id: str,
|
| 17 |
+
content: str,
|
| 18 |
+
task_id: Optional[str] = None,
|
| 19 |
+
memory_type: str = "approved_output",
|
| 20 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 21 |
+
) -> bool:
|
| 22 |
+
"""
|
| 23 |
+
Vectorizes content and saves it to project_memory.
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
if not content or len(content.strip()) < 10:
|
| 27 |
+
return False
|
| 28 |
+
|
| 29 |
+
embedding = await embedding_service.get_embedding(content)
|
| 30 |
+
|
| 31 |
+
data = {
|
| 32 |
+
"project_id": project_id,
|
| 33 |
+
"task_id": task_id,
|
| 34 |
+
"content": content,
|
| 35 |
+
"embedding": embedding,
|
| 36 |
+
"memory_type": memory_type,
|
| 37 |
+
"metadata": metadata or {}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
result = supabase.table("project_memory").insert(data).execute()
|
| 41 |
+
return len(result.data) > 0
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Failed to save memory: {e}")
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
async def search_memory(
|
| 47 |
+
self,
|
| 48 |
+
query: str,
|
| 49 |
+
limit: int = 5,
|
| 50 |
+
threshold: float = 0.7,
|
| 51 |
+
project_id: Optional[str] = None
|
| 52 |
+
) -> List[Dict[str, Any]]:
|
| 53 |
+
"""
|
| 54 |
+
Performs semantic search across project memory.
|
| 55 |
+
If project_id is provided, filters memory to that project only (short-term).
|
| 56 |
+
If project_id is None, searches cross-project (long-term).
|
| 57 |
+
"""
|
| 58 |
+
try:
|
| 59 |
+
query_embedding = await embedding_service.get_embedding(query)
|
| 60 |
+
|
| 61 |
+
# Use the match_project_memory RPC function defined in add_vector_memory.sql
|
| 62 |
+
rpc_params = {
|
| 63 |
+
"query_embedding": query_embedding,
|
| 64 |
+
"match_threshold": threshold,
|
| 65 |
+
"match_count": limit,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if project_id:
|
| 69 |
+
rpc_params["filter_project_id"] = project_id
|
| 70 |
+
|
| 71 |
+
result = supabase.rpc("match_project_memory", rpc_params).execute()
|
| 72 |
+
return result.data or []
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Failed to search memory: {e}")
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
async def index_task_output(self, task: Dict[str, Any]) -> bool:
|
| 78 |
+
"""
|
| 79 |
+
Specialized indexer for approved task outputs.
|
| 80 |
+
"""
|
| 81 |
+
output_data = task.get("output_data")
|
| 82 |
+
if not output_data:
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
# Extract meaningful text from output
|
| 86 |
+
content = ""
|
| 87 |
+
if isinstance(output_data, str):
|
| 88 |
+
content = output_data
|
| 89 |
+
elif isinstance(output_data, dict):
|
| 90 |
+
# Try to get the primary content
|
| 91 |
+
content = (
|
| 92 |
+
output_data.get("data") or
|
| 93 |
+
output_data.get("strategicConclusion") or
|
| 94 |
+
output_data.get("raw_output") or
|
| 95 |
+
str(output_data)
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
if not content:
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
return await self.save_memory(
|
| 102 |
+
project_id=task.get("project_id"),
|
| 103 |
+
task_id=task.get("id"),
|
| 104 |
+
content=str(content),
|
| 105 |
+
memory_type="approved_output",
|
| 106 |
+
metadata={
|
| 107 |
+
"task_title": task.get("title"),
|
| 108 |
+
"agent_id": task.get("assigned_agent_id")
|
| 109 |
+
}
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
async def analyze_rejection(self, task_id: str, feedback: Optional[str] = None):
|
| 113 |
+
"""
|
| 114 |
+
Analyzes a task rejection to generate a 'Self-Optimization Lesson'.
|
| 115 |
+
Triggered when a human rejects an agent's output.
|
| 116 |
+
"""
|
| 117 |
+
try:
|
| 118 |
+
# 1. Fetch task and its failed output
|
| 119 |
+
task_res = supabase.table("tasks").select("*, projects(name, description)").eq("id", task_id).single().execute()
|
| 120 |
+
if not task_res.data:
|
| 121 |
+
return
|
| 122 |
+
|
| 123 |
+
task = task_res.data
|
| 124 |
+
output = task.get("output_data") or {}
|
| 125 |
+
|
| 126 |
+
# 2. Get an analyst agent
|
| 127 |
+
from agents.agent_factory import AgentFactory
|
| 128 |
+
from services.llm_config import getDefaultProvider, getDefaultModel
|
| 129 |
+
|
| 130 |
+
provider = getDefaultProvider()
|
| 131 |
+
model = getDefaultModel(provider)
|
| 132 |
+
|
| 133 |
+
analyst = AgentFactory.get_agent(
|
| 134 |
+
provider=provider,
|
| 135 |
+
name="Optimization Analyst",
|
| 136 |
+
role="Self-Optimization Expert",
|
| 137 |
+
model=model,
|
| 138 |
+
system_prompt="You analyze task rejections. Your goal is to produce a single, concise 'Lesson Learned' that the next agent should follow to avoid repeating the mistake. Focus on the core reason for rejection."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# 3. Construct prompt for analysis
|
| 142 |
+
analysis_prompt = f"""
|
| 143 |
+
TASK: {task.get('title')}
|
| 144 |
+
DESCRIPTION: {task.get('description')}
|
| 145 |
+
|
| 146 |
+
REJECTED OUTPUT:
|
| 147 |
+
{str(output)[:2000]}
|
| 148 |
+
|
| 149 |
+
HUMAN FEEDBACK: {feedback or "No explicit feedback provided, but the output did not meet quality standards."}
|
| 150 |
+
|
| 151 |
+
Provide a concise 'LESSON LEARNED' for the next agent. Start with 'Next time, you must...'
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
result = await analyst.run(analysis_prompt, [])
|
| 155 |
+
lesson_text = result.get("raw_output") or result.get("data")
|
| 156 |
+
|
| 157 |
+
if lesson_text:
|
| 158 |
+
await self.save_memory(
|
| 159 |
+
project_id=task.get("project_id"),
|
| 160 |
+
task_id=task_id,
|
| 161 |
+
content=f"Optimization Lesson for '{task.get('title')}': {lesson_text}",
|
| 162 |
+
memory_type="self_optimization_lesson",
|
| 163 |
+
metadata={
|
| 164 |
+
"original_task_id": task_id,
|
| 165 |
+
"was_rejected": True,
|
| 166 |
+
"feedback": feedback
|
| 167 |
+
}
|
| 168 |
+
)
|
| 169 |
+
logger.info(f"Saved self-optimization lesson for task {task_id}")
|
| 170 |
+
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"Failed to analyze rejection for task {task_id}: {e}")
|
| 173 |
+
|
| 174 |
+
memory_service = MemoryService()
|
backend/services/orchestrator_service.py
ADDED
|
@@ -0,0 +1,1059 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.audit_service import audit_service
|
| 9 |
+
from services.evidence_service import evidence_service
|
| 10 |
+
from services.output_quality import clean_report_text, dedupe_lines, filter_report_sections, validate_output
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("uvicorn")
|
| 13 |
+
|
| 14 |
+
NOISY_REPORT_KEYS = {
|
| 15 |
+
"raw_text",
|
| 16 |
+
"sampleBackendCode",
|
| 17 |
+
"sampleUploadSnippet",
|
| 18 |
+
"sampleSearchEndpoint",
|
| 19 |
+
"sampleRedisCartHelper",
|
| 20 |
+
"sampleWebhookHandler",
|
| 21 |
+
"sampleStateMachine",
|
| 22 |
+
"repositoryStructure",
|
| 23 |
+
"wireframes",
|
| 24 |
+
"dataModel",
|
| 25 |
+
"userStories",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
def _humanize_key(key: str) -> str:
|
| 29 |
+
return key.replace("_", " ").replace("-", " ").strip().title()
|
| 30 |
+
|
| 31 |
+
def _format_value_for_report(value, level: int = 0) -> list[str]:
|
| 32 |
+
if value is None:
|
| 33 |
+
return ["Not specified."]
|
| 34 |
+
|
| 35 |
+
if isinstance(value, (str, int, float, bool)):
|
| 36 |
+
return [str(value)]
|
| 37 |
+
|
| 38 |
+
if isinstance(value, list):
|
| 39 |
+
lines: list[str] = []
|
| 40 |
+
for item in value:
|
| 41 |
+
if isinstance(item, dict):
|
| 42 |
+
item_lines = _format_value_for_report(item, level + 1)
|
| 43 |
+
if item_lines:
|
| 44 |
+
lines.append(f"- {item_lines[0]}")
|
| 45 |
+
lines.extend(f" {line}" for line in item_lines[1:])
|
| 46 |
+
elif isinstance(item, list):
|
| 47 |
+
nested = _format_value_for_report(item, level + 1)
|
| 48 |
+
lines.extend(f"- {line}" for line in nested)
|
| 49 |
+
else:
|
| 50 |
+
lines.append(f"- {item}")
|
| 51 |
+
return lines or ["No items."]
|
| 52 |
+
|
| 53 |
+
if isinstance(value, dict):
|
| 54 |
+
lines: list[str] = []
|
| 55 |
+
for key, item in value.items():
|
| 56 |
+
if str(key) in NOISY_REPORT_KEYS:
|
| 57 |
+
continue
|
| 58 |
+
title = _humanize_key(str(key))
|
| 59 |
+
if isinstance(item, dict):
|
| 60 |
+
lines.append(f"{title}:")
|
| 61 |
+
lines.extend(f" {line}" for line in _format_value_for_report(item, level + 1))
|
| 62 |
+
elif isinstance(item, list):
|
| 63 |
+
lines.append(f"{title}:")
|
| 64 |
+
lines.extend(f" {line}" for line in _format_value_for_report(item, level + 1))
|
| 65 |
+
else:
|
| 66 |
+
lines.append(f"{title}: {item}")
|
| 67 |
+
return lines or ["No details."]
|
| 68 |
+
|
| 69 |
+
return [str(value)]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _extract_json_payload(text: str):
|
| 73 |
+
if not text:
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
stripped = text.strip()
|
| 77 |
+
|
| 78 |
+
# 1. Try standard block extraction
|
| 79 |
+
if stripped.startswith("```"):
|
| 80 |
+
cleaned = stripped.strip("`")
|
| 81 |
+
if cleaned.lower().startswith("json"):
|
| 82 |
+
cleaned = cleaned[4:].strip()
|
| 83 |
+
try:
|
| 84 |
+
return json.loads(cleaned)
|
| 85 |
+
except Exception:
|
| 86 |
+
pass # Fallback to regex
|
| 87 |
+
|
| 88 |
+
# 2. Try direct parsing
|
| 89 |
+
try:
|
| 90 |
+
return json.loads(stripped)
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
# 3. Robust Regex Search (find content between first { and last })
|
| 95 |
+
# This is the "Repair Layer" for noisy LLM outputs
|
| 96 |
+
try:
|
| 97 |
+
# Search for anything starting with { and ending with }
|
| 98 |
+
# across multiple lines
|
| 99 |
+
match = re.search(r'(\{.*\})', stripped, re.DOTALL)
|
| 100 |
+
if match:
|
| 101 |
+
return json.loads(match.group(1))
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
# 4. Specific Markdown Block Search
|
| 106 |
+
match = re.search(r"```json\s*(.*?)\s*```", text, re.IGNORECASE | re.DOTALL)
|
| 107 |
+
if match:
|
| 108 |
+
try:
|
| 109 |
+
return json.loads(match.group(1))
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
def _format_output_for_report(output_data) -> str:
|
| 116 |
+
if not output_data:
|
| 117 |
+
return "No approved output was saved for this task."
|
| 118 |
+
|
| 119 |
+
if isinstance(output_data, dict):
|
| 120 |
+
primary = (
|
| 121 |
+
output_data.get("data")
|
| 122 |
+
or output_data.get("final")
|
| 123 |
+
or output_data.get("raw_output")
|
| 124 |
+
or output_data
|
| 125 |
+
)
|
| 126 |
+
else:
|
| 127 |
+
primary = output_data
|
| 128 |
+
|
| 129 |
+
if isinstance(primary, str):
|
| 130 |
+
parsed = _extract_json_payload(primary)
|
| 131 |
+
if parsed is not None:
|
| 132 |
+
return clean_report_text(dedupe_lines("\n".join(_format_value_for_report(parsed))))
|
| 133 |
+
return clean_report_text(dedupe_lines(primary))
|
| 134 |
+
|
| 135 |
+
return clean_report_text(dedupe_lines("\n".join(_format_value_for_report(primary))))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _is_empty_curated_text(text: str) -> bool:
|
| 139 |
+
normalized = (text or "").strip().lower()
|
| 140 |
+
return normalized in {
|
| 141 |
+
"",
|
| 142 |
+
"no approved output was saved for this task.",
|
| 143 |
+
"{}",
|
| 144 |
+
"[]",
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _is_empty_report_variant(text: str | None) -> bool:
|
| 149 |
+
normalized = clean_report_text(dedupe_lines(text or "")).strip()
|
| 150 |
+
content_words = re.findall(r"[A-Za-z0-9_]+", normalized)
|
| 151 |
+
lower = normalized.lower()
|
| 152 |
+
return (
|
| 153 |
+
len(content_words) < 20
|
| 154 |
+
or lower in {"{}", "[]", "null", "none", "no details.", "not specified."}
|
| 155 |
+
or lower.startswith("```")
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _format_conclusion_payload(data: dict) -> str:
|
| 160 |
+
conclusion = data.get("strategicConclusion") or data.get("conclusion") or data.get("content") or ""
|
| 161 |
+
next_steps = data.get("nextSteps") or data.get("next_steps") or []
|
| 162 |
+
|
| 163 |
+
lines: list[str] = []
|
| 164 |
+
if isinstance(conclusion, str) and conclusion.strip():
|
| 165 |
+
lines.append(conclusion.strip())
|
| 166 |
+
|
| 167 |
+
usable_steps = [
|
| 168 |
+
step.strip()
|
| 169 |
+
for step in next_steps
|
| 170 |
+
if isinstance(step, str) and step.strip()
|
| 171 |
+
] if isinstance(next_steps, list) else []
|
| 172 |
+
|
| 173 |
+
if usable_steps:
|
| 174 |
+
lines.append("")
|
| 175 |
+
lines.append("Next steps:")
|
| 176 |
+
for step in usable_steps[:5]:
|
| 177 |
+
lines.append(f"- {step}")
|
| 178 |
+
|
| 179 |
+
return "\n".join(lines).strip() or "\n".join(_format_value_for_report(data))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _has_usable_output(output_data) -> bool:
|
| 183 |
+
if not output_data:
|
| 184 |
+
return False
|
| 185 |
+
if isinstance(output_data, dict):
|
| 186 |
+
if output_data.get("error"):
|
| 187 |
+
return False
|
| 188 |
+
primary = output_data.get("data")
|
| 189 |
+
if primary in (None, "", [], {}):
|
| 190 |
+
return False
|
| 191 |
+
return True
|
| 192 |
+
|
| 193 |
+
def _output_text(output_data) -> str:
|
| 194 |
+
return _format_output_for_report(output_data).lower()
|
| 195 |
+
|
| 196 |
+
def _build_report_charts(tasks: list[dict]) -> dict:
|
| 197 |
+
total = len(tasks)
|
| 198 |
+
done = sum(1 for task in tasks if task.get("status") == "done")
|
| 199 |
+
failed = sum(1 for task in tasks if task.get("status") == "failed")
|
| 200 |
+
pending = max(total - done - failed, 0)
|
| 201 |
+
|
| 202 |
+
priority_counts: dict[str, int] = {}
|
| 203 |
+
for task in tasks:
|
| 204 |
+
priority = str(task.get("priority") if task.get("priority") is not None else 0)
|
| 205 |
+
priority_counts[priority] = priority_counts.get(priority, 0) + 1
|
| 206 |
+
|
| 207 |
+
categories = {
|
| 208 |
+
"Market": ("market", "competitor", "customer", "segment", "demand"),
|
| 209 |
+
"Product": ("product", "mvp", "feature", "design", "scope"),
|
| 210 |
+
"Revenue": ("revenue", "price", "pricing", "margin", "commission"),
|
| 211 |
+
"Operations": ("operation", "process", "logistic", "support", "fulfillment"),
|
| 212 |
+
"Risk": ("risk", "threat", "failure", "weak", "mitigation")
|
| 213 |
+
}
|
| 214 |
+
category_counts = {name: 0 for name in categories}
|
| 215 |
+
risk_mentions = 0
|
| 216 |
+
|
| 217 |
+
for task in tasks:
|
| 218 |
+
text = f"{task.get('title', '')} {task.get('description', '')} {_output_text(task.get('output_data'))}".lower()
|
| 219 |
+
risk_mentions += sum(text.count(term) for term in categories["Risk"])
|
| 220 |
+
for category, terms in categories.items():
|
| 221 |
+
if any(term in text for term in terms):
|
| 222 |
+
category_counts[category] += 1
|
| 223 |
+
|
| 224 |
+
opportunity_score = 85 if total and done == total else round((done / total) * 85) if total else 0
|
| 225 |
+
risk_score = min(95, 35 + risk_mentions * 3)
|
| 226 |
+
readiness_score = round((done / total) * 100) if total else 0
|
| 227 |
+
|
| 228 |
+
return {
|
| 229 |
+
"status": [
|
| 230 |
+
{"label": "Approved", "value": done},
|
| 231 |
+
{"label": "Pending", "value": pending},
|
| 232 |
+
{"label": "Failed", "value": failed}
|
| 233 |
+
],
|
| 234 |
+
"priorities": [
|
| 235 |
+
{"label": f"Priority {key}", "value": value}
|
| 236 |
+
for key, value in sorted(priority_counts.items(), key=lambda item: int(item[0]) if item[0].isdigit() else 0, reverse=True)
|
| 237 |
+
],
|
| 238 |
+
"categories": [
|
| 239 |
+
{"label": label, "value": value}
|
| 240 |
+
for label, value in category_counts.items()
|
| 241 |
+
],
|
| 242 |
+
"scores": [
|
| 243 |
+
{"label": "Readiness", "value": readiness_score},
|
| 244 |
+
{"label": "Opportunity", "value": opportunity_score},
|
| 245 |
+
{"label": "Risk", "value": risk_score}
|
| 246 |
+
]
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
def _format_chart_rows(title: str, rows: list[dict]) -> list[str]:
|
| 250 |
+
if not rows:
|
| 251 |
+
return [f"### {title}", "No data available.", ""]
|
| 252 |
+
|
| 253 |
+
lines = [f"### {title}"]
|
| 254 |
+
lines.extend(f"- {row['label']}: {row['value']}" for row in rows)
|
| 255 |
+
lines.append("")
|
| 256 |
+
return lines
|
| 257 |
+
|
| 258 |
+
def _format_execution_summary(charts: dict, total_tasks: int, kept_task_count: int, excluded_count: int) -> list[str]:
|
| 259 |
+
lines = [
|
| 260 |
+
f"- Total tasks: {total_tasks}",
|
| 261 |
+
f"- Included outputs: {kept_task_count}",
|
| 262 |
+
f"- Excluded outputs: {excluded_count}",
|
| 263 |
+
"",
|
| 264 |
+
]
|
| 265 |
+
lines.extend(_format_chart_rows("Scores", charts.get("scores", [])))
|
| 266 |
+
lines.extend(_format_chart_rows("Task Categories", charts.get("categories", [])))
|
| 267 |
+
lines.extend(_format_chart_rows("Priorities", charts.get("priorities", [])))
|
| 268 |
+
return lines
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def _format_evidence_summary(project_id: str, claims: list[dict]) -> list[str]:
|
| 275 |
+
if not claims:
|
| 276 |
+
return []
|
| 277 |
+
|
| 278 |
+
# Get semantically merged claims for the "Strategic Findings" section
|
| 279 |
+
merged_claims = await evidence_service.merge_project_claims(project_id, threshold=0.88)
|
| 280 |
+
summary = evidence_service.summarize_claims(claims)
|
| 281 |
+
|
| 282 |
+
lines = [
|
| 283 |
+
"## Strategic Findings & Evidence",
|
| 284 |
+
f"The analysis has consolidated **{summary['claim_count']}** unique data points into **{len(merged_claims)}** strategic findings.",
|
| 285 |
+
f"Source coverage: **{summary['source_coverage']:.0%}** (Claims backed by external evidence).",
|
| 286 |
+
"",
|
| 287 |
+
"### Key Consolidated Findings",
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
+
# Show merged claims with their confidence and sources
|
| 291 |
+
for claim in merged_claims[:15]:
|
| 292 |
+
text = claim.get("claim_text")
|
| 293 |
+
entity = claim.get("entity_name")
|
| 294 |
+
source = claim.get("source_url")
|
| 295 |
+
confidence = claim.get("confidence", "unknown")
|
| 296 |
+
merged_count = claim.get("merged_count", 1)
|
| 297 |
+
|
| 298 |
+
prefix = f"**[{entity}]** " if entity else ""
|
| 299 |
+
source_suffix = f" [Source: {source}]" if source else " [Internal Analysis]"
|
| 300 |
+
repetition_suffix = f" (Verified by {merged_count} sources)" if merged_count > 1 else ""
|
| 301 |
+
|
| 302 |
+
lines.append(f"- {prefix}{text}{repetition_suffix}{source_suffix}")
|
| 303 |
+
|
| 304 |
+
if summary["by_entity"]:
|
| 305 |
+
lines.append("")
|
| 306 |
+
lines.append("### Entity Analysis Coverage")
|
| 307 |
+
for entity, count in list(summary["by_entity"].items())[:8]:
|
| 308 |
+
lines.append(f"- **{entity}**: {count} supporting claims identified.")
|
| 309 |
+
|
| 310 |
+
lines.append("")
|
| 311 |
+
return lines
|
| 312 |
+
|
| 313 |
+
REPORT_VARIANTS = {
|
| 314 |
+
"full": {
|
| 315 |
+
"title": "Final Report",
|
| 316 |
+
"agent_terms": [],
|
| 317 |
+
"fallback_heading": "Approved Work Summary",
|
| 318 |
+
"prompt": ""
|
| 319 |
+
},
|
| 320 |
+
"brief": {
|
| 321 |
+
"title": "Short Brief",
|
| 322 |
+
"agent_terms": ["brief", "summary", "writer"],
|
| 323 |
+
"fallback_heading": "Short Brief",
|
| 324 |
+
"prompt": (
|
| 325 |
+
"Create a concise executive brief from the approved project work. "
|
| 326 |
+
"Use plain English, no JSON, no code blocks. Include: objective, main findings, recommended next steps, and key risks. "
|
| 327 |
+
"Keep it short and decision-oriented. Do not invent entities, metrics, or placeholders."
|
| 328 |
+
)
|
| 329 |
+
},
|
| 330 |
+
"pessimistic": {
|
| 331 |
+
"title": "Pessimistic Analysis",
|
| 332 |
+
"agent_terms": ["pessimistic", "risk", "critic", "reviewer"],
|
| 333 |
+
"fallback_heading": "Pessimistic Analysis",
|
| 334 |
+
"prompt": (
|
| 335 |
+
"Create a skeptical, downside-focused analysis from the approved project work. "
|
| 336 |
+
"Use plain English, no JSON, no code blocks. Focus on what can fail, weak assumptions, operational risks, market risks, "
|
| 337 |
+
"financial risks, execution gaps, and mitigation priorities. Do not invent entities, metrics, or placeholders."
|
| 338 |
+
)
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
class OrchestratorService:
|
| 343 |
+
"""
|
| 344 |
+
Handles complex multi-agent workflows like Debates and Peer Reviews.
|
| 345 |
+
"""
|
| 346 |
+
|
| 347 |
+
async def run_debate(self, task_id: str, agent_a_id: str, agent_b_id: str):
|
| 348 |
+
"""
|
| 349 |
+
Executes a debate between two agents for a specific task.
|
| 350 |
+
"""
|
| 351 |
+
try:
|
| 352 |
+
# 1. Fetch task and agents
|
| 353 |
+
task = supabase.table("tasks").select("*").eq("id", task_id).single().execute().data
|
| 354 |
+
agent_a_data = supabase.table("agents").select("*").eq("id", agent_a_id).single().execute().data
|
| 355 |
+
agent_b_data = supabase.table("agents").select("*").eq("id", agent_b_id).single().execute().data
|
| 356 |
+
|
| 357 |
+
if not task or not agent_a_data or not agent_b_data:
|
| 358 |
+
raise ValueError("Task or agents not found for debate.")
|
| 359 |
+
|
| 360 |
+
# Update status to in_progress
|
| 361 |
+
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 362 |
+
await audit_service.log_action(
|
| 363 |
+
user_id=None,
|
| 364 |
+
action="debate_started",
|
| 365 |
+
agent_id=agent_a_id,
|
| 366 |
+
task_id=task_id,
|
| 367 |
+
metadata={"agent_b_id": agent_b_id, "project_id": task.get("project_id")},
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
# 2. Agent A generates initial response
|
| 371 |
+
initial_res, _ = await AgentRunnerService.run_agent_task(
|
| 372 |
+
task,
|
| 373 |
+
agent_a_data,
|
| 374 |
+
start_action="debate_initial_start",
|
| 375 |
+
start_content=f"Debate Step 1: {agent_a_data['name']} generating initial proposal.",
|
| 376 |
+
complete_action="debate_initial_complete",
|
| 377 |
+
update_task=False
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# 3. Agent B reviews and critiques
|
| 381 |
+
# We temporarily modify the task description for this run
|
| 382 |
+
task_critique = task.copy()
|
| 383 |
+
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'])}"
|
| 384 |
+
|
| 385 |
+
critique_res, _ = await AgentRunnerService.run_agent_task(
|
| 386 |
+
task_critique,
|
| 387 |
+
agent_b_data,
|
| 388 |
+
start_action="debate_critique_start",
|
| 389 |
+
start_content=f"Debate Step 2: {agent_b_data['name']} critiquing the proposal.",
|
| 390 |
+
complete_action="debate_critique_complete",
|
| 391 |
+
update_task=False
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
# 4. Agent A refines based on critique
|
| 395 |
+
task_refinement = task.copy()
|
| 396 |
+
task_refinement["description"] = f"Refine your initial output for the task: '{task['description']}' based on this critique: {json.dumps(critique_res['data'])}"
|
| 397 |
+
|
| 398 |
+
final_res, _ = await AgentRunnerService.run_agent_task(
|
| 399 |
+
task_refinement,
|
| 400 |
+
agent_a_data,
|
| 401 |
+
start_action="debate_refinement_start",
|
| 402 |
+
start_content=f"Debate Step 3: {agent_a_data['name']} refining proposal based on feedback.",
|
| 403 |
+
complete_action="debate_refinement_complete",
|
| 404 |
+
update_task=False
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
# 5. Save consolidated result and mark for approval
|
| 408 |
+
consolidated_output = {
|
| 409 |
+
"agent_name": agent_a_data["name"],
|
| 410 |
+
"provider": agent_a_data["api_provider"],
|
| 411 |
+
"model": agent_a_data["model"],
|
| 412 |
+
"is_debate": True,
|
| 413 |
+
"data": final_res["data"],
|
| 414 |
+
"debate_history": {
|
| 415 |
+
"initial": initial_res["data"],
|
| 416 |
+
"critique": critique_res["data"],
|
| 417 |
+
"final": final_res["data"]
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
supabase.table("tasks").update({
|
| 422 |
+
"status": "awaiting_approval",
|
| 423 |
+
"output_data": consolidated_output
|
| 424 |
+
}).eq("id", task_id).execute()
|
| 425 |
+
claims_count = await evidence_service.replace_task_claims(task, consolidated_output)
|
| 426 |
+
await audit_service.log_action(
|
| 427 |
+
user_id=None,
|
| 428 |
+
action="debate_completed",
|
| 429 |
+
agent_id=agent_a_id,
|
| 430 |
+
task_id=task_id,
|
| 431 |
+
metadata={"agent_b_id": agent_b_id, "project_id": task.get("project_id"), "claims_count": claims_count},
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
logger.info(f"Debate completed for task {task_id}")
|
| 435 |
+
|
| 436 |
+
except Exception as e:
|
| 437 |
+
logger.error(f"Debate failed: {str(e)}")
|
| 438 |
+
supabase.table("tasks").update({
|
| 439 |
+
"status": "failed",
|
| 440 |
+
"output_data": {"error": str(e)}
|
| 441 |
+
}).eq("id", task_id).execute()
|
| 442 |
+
await audit_service.log_action(
|
| 443 |
+
user_id=None,
|
| 444 |
+
action="debate_failed",
|
| 445 |
+
agent_id=agent_a_id,
|
| 446 |
+
task_id=task_id,
|
| 447 |
+
metadata={"agent_b_id": agent_b_id, "error": str(e)},
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
# LOG ERROR TO AGENT CONSOLE
|
| 451 |
+
supabase.table("agent_logs").insert({
|
| 452 |
+
"task_id": task_id,
|
| 453 |
+
"action": "debate_failed",
|
| 454 |
+
"content": f"DEBATE ERROR: {str(e)}"
|
| 455 |
+
}).execute()
|
| 456 |
+
|
| 457 |
+
async def run_project(self, project_id: str):
|
| 458 |
+
"""
|
| 459 |
+
Runs queued tasks in a project sequentially. Unassigned tasks are assigned
|
| 460 |
+
to the first available project-owner or global agent.
|
| 461 |
+
"""
|
| 462 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 463 |
+
if not project:
|
| 464 |
+
raise ValueError(f"Project not found: {project_id}")
|
| 465 |
+
|
| 466 |
+
owner_id = project.get("owner_id")
|
| 467 |
+
tasks = (
|
| 468 |
+
supabase.table("tasks")
|
| 469 |
+
.select("*")
|
| 470 |
+
.eq("project_id", project_id)
|
| 471 |
+
.in_("status", ["todo", "failed", "queued"])
|
| 472 |
+
.order("priority", desc=True)
|
| 473 |
+
.order("created_at", desc=False)
|
| 474 |
+
.execute()
|
| 475 |
+
.data
|
| 476 |
+
or []
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
# Check if ANY tasks exist for this project (regardless of status) to avoid re-decomposing
|
| 480 |
+
all_tasks_res = supabase.table("tasks").select("id", count="exact").eq("project_id", project_id).limit(1).execute()
|
| 481 |
+
has_any_tasks = all_tasks_res.count > 0 if all_tasks_res.count is not None else len(all_tasks_res.data) > 0
|
| 482 |
+
|
| 483 |
+
# Automatic Decomposition: Only if no tasks exist AT ALL
|
| 484 |
+
if not has_any_tasks:
|
| 485 |
+
logger.info(f"No tasks found for project {project_id}. Triggering auto-decomposition.")
|
| 486 |
+
await self.decompose_project(project_id)
|
| 487 |
+
# Re-fetch tasks after decomposition
|
| 488 |
+
tasks = (
|
| 489 |
+
supabase.table("tasks")
|
| 490 |
+
.select("*")
|
| 491 |
+
.eq("project_id", project_id)
|
| 492 |
+
.in_("status", ["todo", "failed", "queued"])
|
| 493 |
+
.order("priority", desc=True)
|
| 494 |
+
.order("created_at", desc=False)
|
| 495 |
+
.execute()
|
| 496 |
+
.data
|
| 497 |
+
or []
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 501 |
+
available_agents = [
|
| 502 |
+
agent for agent in agents
|
| 503 |
+
if agent.get("user_id") in (None, owner_id) or agent.get("id") in {t.get("assigned_agent_id") for t in tasks if t.get("assigned_agent_id")}
|
| 504 |
+
]
|
| 505 |
+
|
| 506 |
+
completed = 0
|
| 507 |
+
failed = 0
|
| 508 |
+
|
| 509 |
+
for task in tasks:
|
| 510 |
+
try:
|
| 511 |
+
agent_data = self._resolve_agent(task, available_agents)
|
| 512 |
+
if not agent_data:
|
| 513 |
+
raise ValueError("No available agent for task")
|
| 514 |
+
|
| 515 |
+
if not task.get("assigned_agent_id"):
|
| 516 |
+
supabase.table("tasks").update({
|
| 517 |
+
"assigned_agent_id": agent_data["id"]
|
| 518 |
+
}).eq("id", task["id"]).execute()
|
| 519 |
+
task["assigned_agent_id"] = agent_data["id"]
|
| 520 |
+
|
| 521 |
+
await self._run_task(task, agent_data)
|
| 522 |
+
completed += 1
|
| 523 |
+
except Exception as exc:
|
| 524 |
+
failed += 1
|
| 525 |
+
logger.error(f"Project orchestration task failed: {str(exc)}")
|
| 526 |
+
supabase.table("tasks").update({
|
| 527 |
+
"status": "failed",
|
| 528 |
+
"output_data": {"error": str(exc)}
|
| 529 |
+
}).eq("id", task["id"]).execute()
|
| 530 |
+
|
| 531 |
+
return {
|
| 532 |
+
"project_id": project_id,
|
| 533 |
+
"queued_tasks": len(tasks),
|
| 534 |
+
"completed": completed,
|
| 535 |
+
"failed": failed,
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
async def queue_project(self, project_id: str):
|
| 539 |
+
"""
|
| 540 |
+
Assigns available agents and queues runnable project tasks for worker execution.
|
| 541 |
+
"""
|
| 542 |
+
from services.task_queue import TaskQueueService
|
| 543 |
+
|
| 544 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 545 |
+
if not project:
|
| 546 |
+
raise ValueError(f"Project not found: {project_id}")
|
| 547 |
+
if project.get("status") == "completed":
|
| 548 |
+
raise ValueError("Completed projects are locked and cannot be modified.")
|
| 549 |
+
|
| 550 |
+
owner_id = project.get("owner_id")
|
| 551 |
+
tasks = (
|
| 552 |
+
supabase.table("tasks")
|
| 553 |
+
.select("*")
|
| 554 |
+
.eq("project_id", project_id)
|
| 555 |
+
.in_("status", ["todo", "failed", "queued"])
|
| 556 |
+
.order("priority", desc=True)
|
| 557 |
+
.order("created_at", desc=False)
|
| 558 |
+
.execute()
|
| 559 |
+
.data
|
| 560 |
+
or []
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
all_tasks_res = supabase.table("tasks").select("id", count="exact").eq("project_id", project_id).limit(1).execute()
|
| 564 |
+
has_any_tasks = all_tasks_res.count > 0 if all_tasks_res.count is not None else len(all_tasks_res.data) > 0
|
| 565 |
+
|
| 566 |
+
if not has_any_tasks:
|
| 567 |
+
logger.info(f"No tasks found for project {project_id}. Triggering auto-decomposition before queueing.")
|
| 568 |
+
await self.decompose_project(project_id)
|
| 569 |
+
tasks = (
|
| 570 |
+
supabase.table("tasks")
|
| 571 |
+
.select("*")
|
| 572 |
+
.eq("project_id", project_id)
|
| 573 |
+
.in_("status", ["todo", "failed", "queued"])
|
| 574 |
+
.order("priority", desc=True)
|
| 575 |
+
.order("created_at", desc=False)
|
| 576 |
+
.execute()
|
| 577 |
+
.data
|
| 578 |
+
or []
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 582 |
+
assigned_ids = {t.get("assigned_agent_id") for t in tasks if t.get("assigned_agent_id")}
|
| 583 |
+
available_agents = [
|
| 584 |
+
agent for agent in agents
|
| 585 |
+
if agent.get("user_id") in (None, owner_id) or agent.get("id") in assigned_ids
|
| 586 |
+
]
|
| 587 |
+
|
| 588 |
+
queued = 0
|
| 589 |
+
failed = 0
|
| 590 |
+
skipped = 0
|
| 591 |
+
|
| 592 |
+
for task in tasks:
|
| 593 |
+
try:
|
| 594 |
+
agent_data = self._resolve_agent(task, available_agents)
|
| 595 |
+
if not agent_data:
|
| 596 |
+
raise ValueError("No available agent for task")
|
| 597 |
+
|
| 598 |
+
if not task.get("assigned_agent_id"):
|
| 599 |
+
supabase.table("tasks").update({
|
| 600 |
+
"assigned_agent_id": agent_data["id"]
|
| 601 |
+
}).eq("id", task["id"]).execute()
|
| 602 |
+
|
| 603 |
+
result = await TaskQueueService.queue_task(task["id"])
|
| 604 |
+
if result and result.data:
|
| 605 |
+
queued += 1
|
| 606 |
+
else:
|
| 607 |
+
skipped += 1
|
| 608 |
+
except Exception as exc:
|
| 609 |
+
failed += 1
|
| 610 |
+
logger.error(f"Project queueing task failed: {str(exc)}")
|
| 611 |
+
supabase.table("tasks").update({
|
| 612 |
+
"status": "failed",
|
| 613 |
+
"last_error": str(exc),
|
| 614 |
+
"output_data": {"error": str(exc)}
|
| 615 |
+
}).eq("id", task["id"]).execute()
|
| 616 |
+
await audit_service.log_action(
|
| 617 |
+
user_id=owner_id,
|
| 618 |
+
action="task_queue_failed",
|
| 619 |
+
task_id=task.get("id"),
|
| 620 |
+
metadata={"project_id": project_id, "error": str(exc)},
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
await audit_service.log_action(
|
| 624 |
+
user_id=owner_id,
|
| 625 |
+
action="project_queued",
|
| 626 |
+
metadata={
|
| 627 |
+
"project_id": project_id,
|
| 628 |
+
"queued_tasks": queued,
|
| 629 |
+
"failed": failed,
|
| 630 |
+
"skipped": skipped,
|
| 631 |
+
},
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
return {
|
| 635 |
+
"project_id": project_id,
|
| 636 |
+
"queued_tasks": queued,
|
| 637 |
+
"failed": failed,
|
| 638 |
+
"skipped": skipped,
|
| 639 |
+
"mode": "queue",
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
def _select_report_agent(self, project: dict, variant: str):
|
| 643 |
+
config = REPORT_VARIANTS.get(variant, REPORT_VARIANTS["full"])
|
| 644 |
+
terms = config["agent_terms"]
|
| 645 |
+
if not terms:
|
| 646 |
+
return None
|
| 647 |
+
|
| 648 |
+
owner_id = project.get("owner_id")
|
| 649 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 650 |
+
available_agents = [
|
| 651 |
+
agent for agent in agents
|
| 652 |
+
if agent.get("user_id") in (None, owner_id)
|
| 653 |
+
]
|
| 654 |
+
|
| 655 |
+
return next(
|
| 656 |
+
(
|
| 657 |
+
agent for agent in available_agents
|
| 658 |
+
if any(term in f"{agent.get('name', '')} {agent.get('role', '')}".lower() for term in terms)
|
| 659 |
+
),
|
| 660 |
+
available_agents[0] if available_agents else None
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
async def _generate_report_variant_with_agent(self, project: dict, report: str, variant: str):
|
| 664 |
+
agent_data = self._select_report_agent(project, variant)
|
| 665 |
+
if not agent_data:
|
| 666 |
+
return None
|
| 667 |
+
|
| 668 |
+
config = REPORT_VARIANTS[variant]
|
| 669 |
+
agent = AgentFactory.get_agent(
|
| 670 |
+
provider=agent_data["api_provider"],
|
| 671 |
+
name=agent_data["name"],
|
| 672 |
+
role=agent_data["role"],
|
| 673 |
+
model=agent_data["model"],
|
| 674 |
+
system_prompt=agent_data.get("system_prompt")
|
| 675 |
+
)
|
| 676 |
+
result = await agent.run(f"{config['prompt']}\n\nApproved project material:\n{report}", [])
|
| 677 |
+
if result.get("status") == "error":
|
| 678 |
+
raise RuntimeError(result.get("error") or "Report agent returned an error.")
|
| 679 |
+
|
| 680 |
+
data = result.get("data")
|
| 681 |
+
if isinstance(data, dict):
|
| 682 |
+
for key in ("brief", "analysis", "report", "summary", "content"):
|
| 683 |
+
value = data.get(key)
|
| 684 |
+
if isinstance(value, str) and not _is_empty_report_variant(value):
|
| 685 |
+
return value
|
| 686 |
+
formatted = "\n".join(_format_value_for_report(data))
|
| 687 |
+
return None if _is_empty_report_variant(formatted) else formatted
|
| 688 |
+
if isinstance(data, str):
|
| 689 |
+
return None if _is_empty_report_variant(data) else data
|
| 690 |
+
raw_output = result.get("raw_output")
|
| 691 |
+
return None if _is_empty_report_variant(raw_output) else raw_output
|
| 692 |
+
|
| 693 |
+
def _build_fallback_variant(self, project: dict, tasks: list[dict], variant: str):
|
| 694 |
+
config = REPORT_VARIANTS[variant]
|
| 695 |
+
lines = [
|
| 696 |
+
f"# {config['title']}: {project['name']}",
|
| 697 |
+
"",
|
| 698 |
+
"## Project Brief",
|
| 699 |
+
project.get("description") or "No project description provided.",
|
| 700 |
+
"",
|
| 701 |
+
f"## {config['fallback_heading']}"
|
| 702 |
+
]
|
| 703 |
+
|
| 704 |
+
if variant == "brief":
|
| 705 |
+
lines.extend([
|
| 706 |
+
f"All {len(tasks)} approved tasks have been consolidated.",
|
| 707 |
+
"The project is ready for decision review based on the approved task outputs.",
|
| 708 |
+
"",
|
| 709 |
+
"Recommended next steps:",
|
| 710 |
+
"- Validate the highest-impact assumptions with real users or customers.",
|
| 711 |
+
"- Prioritize the smallest launch scope that proves demand.",
|
| 712 |
+
"- Convert approved outputs into an execution backlog with owners and dates."
|
| 713 |
+
])
|
| 714 |
+
return "\n".join(lines)
|
| 715 |
+
|
| 716 |
+
if variant == "pessimistic":
|
| 717 |
+
lines.extend([
|
| 718 |
+
"This project can still fail even with all tasks approved.",
|
| 719 |
+
"",
|
| 720 |
+
"Primary downside risks:",
|
| 721 |
+
"- Approved task outputs may be internally consistent but unvalidated by the market.",
|
| 722 |
+
"- Revenue, conversion, operational, and adoption assumptions may be too optimistic.",
|
| 723 |
+
"- Execution scope can expand faster than the team can deliver.",
|
| 724 |
+
"- Competitors can respond with pricing, distribution, or trust advantages.",
|
| 725 |
+
"",
|
| 726 |
+
"Mitigation priorities:",
|
| 727 |
+
"- Validate demand before building broad feature scope.",
|
| 728 |
+
"- Stress-test unit economics and support costs.",
|
| 729 |
+
"- Define kill criteria before committing more resources."
|
| 730 |
+
])
|
| 731 |
+
return "\n".join(lines)
|
| 732 |
+
|
| 733 |
+
return None
|
| 734 |
+
|
| 735 |
+
def _quality_approved_tasks(self, tasks: list[dict], project: dict) -> tuple[list[dict], list[dict]]:
|
| 736 |
+
approved: list[dict] = []
|
| 737 |
+
excluded: list[dict] = []
|
| 738 |
+
for task in tasks:
|
| 739 |
+
output_data = task.get("output_data") or {}
|
| 740 |
+
if not _has_usable_output(output_data):
|
| 741 |
+
excluded.append({
|
| 742 |
+
"title": task.get("title", "Untitled task"),
|
| 743 |
+
"reasons": ["Task has no usable approved output."]
|
| 744 |
+
})
|
| 745 |
+
continue
|
| 746 |
+
task_with_project = {**task, "project": project}
|
| 747 |
+
quality_review = output_data.get("quality_review") if isinstance(output_data, dict) else None
|
| 748 |
+
if not quality_review and isinstance(output_data, dict):
|
| 749 |
+
quality_review = validate_output(task_with_project, output_data)
|
| 750 |
+
if quality_review and not quality_review.get("approved", False):
|
| 751 |
+
excluded.append({
|
| 752 |
+
"title": task.get("title", "Untitled task"),
|
| 753 |
+
"reasons": quality_review.get("fail_reasons") or ["Failed quality review."]
|
| 754 |
+
})
|
| 755 |
+
continue
|
| 756 |
+
approved.append(task)
|
| 757 |
+
return approved, excluded
|
| 758 |
+
|
| 759 |
+
def _curate_task_output(self, output_data) -> tuple[str, list[str]]:
|
| 760 |
+
text = _format_output_for_report(output_data)
|
| 761 |
+
text = clean_report_text(dedupe_lines(text))
|
| 762 |
+
text, excluded_lines = filter_report_sections(text)
|
| 763 |
+
return text or "No approved output was saved for this task.", excluded_lines
|
| 764 |
+
|
| 765 |
+
async def build_final_report(self, project_id: str, variant: str = "full"):
|
| 766 |
+
variant = variant if variant in REPORT_VARIANTS else "full"
|
| 767 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 768 |
+
if not project:
|
| 769 |
+
raise ValueError(f"Project not found: {project_id}")
|
| 770 |
+
|
| 771 |
+
tasks = (
|
| 772 |
+
supabase.table("tasks")
|
| 773 |
+
.select("title,description,status,priority,output_data,created_at")
|
| 774 |
+
.eq("project_id", project_id)
|
| 775 |
+
.order("priority", desc=True)
|
| 776 |
+
.order("created_at", desc=False)
|
| 777 |
+
.execute()
|
| 778 |
+
.data
|
| 779 |
+
or []
|
| 780 |
+
)
|
| 781 |
+
|
| 782 |
+
if not tasks:
|
| 783 |
+
raise ValueError("Project has no tasks to summarize.")
|
| 784 |
+
|
| 785 |
+
incomplete = [task for task in tasks if task.get("status") != "done"]
|
| 786 |
+
if incomplete:
|
| 787 |
+
raise ValueError(f"Final report is available after all tasks are approved. Pending tasks: {len(incomplete)}")
|
| 788 |
+
|
| 789 |
+
curated_tasks, excluded_tasks = self._quality_approved_tasks(tasks, project)
|
| 790 |
+
if not curated_tasks:
|
| 791 |
+
# Fallback: if no tasks pass the strict quality review, include all 'done' tasks
|
| 792 |
+
# so the user can at least see a draft report.
|
| 793 |
+
logger.warning(f"Project {project_id}: No tasks passed quality review. Falling back to all tasks.")
|
| 794 |
+
curated_tasks = tasks
|
| 795 |
+
|
| 796 |
+
# Load raw claims for statistics, and we will use semantic merging inside _format_evidence_summary
|
| 797 |
+
all_raw_claims = evidence_service.load_project_claims(project_id)
|
| 798 |
+
merged_claims = await evidence_service.merge_project_claims(project_id)
|
| 799 |
+
|
| 800 |
+
# 0. Header and Description
|
| 801 |
+
report_title = REPORT_VARIANTS[variant]["title"]
|
| 802 |
+
lines = [
|
| 803 |
+
f"# {report_title}: {project['name']}",
|
| 804 |
+
"",
|
| 805 |
+
"## Project Overview",
|
| 806 |
+
project.get("description") or "No description provided.",
|
| 807 |
+
""
|
| 808 |
+
]
|
| 809 |
+
|
| 810 |
+
# Add Context if exists
|
| 811 |
+
if project.get("context"):
|
| 812 |
+
lines.extend(["## Context", project["context"], ""])
|
| 813 |
+
|
| 814 |
+
approved_work_lines = ["## Approved Work Summary", ""]
|
| 815 |
+
|
| 816 |
+
report_exclusions: list[str] = []
|
| 817 |
+
included_tasks: list[dict] = []
|
| 818 |
+
kept_task_count = 0
|
| 819 |
+
for task in curated_tasks:
|
| 820 |
+
curated_text, excluded_lines = self._curate_task_output(task.get("output_data"))
|
| 821 |
+
report_exclusions.extend(excluded_lines)
|
| 822 |
+
if _is_empty_curated_text(curated_text):
|
| 823 |
+
excluded_tasks.append({
|
| 824 |
+
"title": task.get("title", "Untitled task"),
|
| 825 |
+
"reasons": ["Task output became empty after quality filtering."]
|
| 826 |
+
})
|
| 827 |
+
continue
|
| 828 |
+
kept_task_count += 1
|
| 829 |
+
included_tasks.append(task)
|
| 830 |
+
approved_work_lines.extend([
|
| 831 |
+
f"### {kept_task_count}. {task['title']}",
|
| 832 |
+
task.get("description") or "No task description provided.",
|
| 833 |
+
"",
|
| 834 |
+
curated_text,
|
| 835 |
+
""
|
| 836 |
+
])
|
| 837 |
+
|
| 838 |
+
charts = _build_report_charts(included_tasks)
|
| 839 |
+
lines.extend(["## Execution Summary", ""])
|
| 840 |
+
lines.extend(_format_execution_summary(charts, len(tasks), kept_task_count, len(excluded_tasks)))
|
| 841 |
+
|
| 842 |
+
# New Evidence-Aware Strategic Findings Section
|
| 843 |
+
evidence_section = await _format_evidence_summary(project_id, all_raw_claims)
|
| 844 |
+
lines.extend(evidence_section)
|
| 845 |
+
|
| 846 |
+
lines.extend(approved_work_lines)
|
| 847 |
+
|
| 848 |
+
if excluded_tasks or report_exclusions:
|
| 849 |
+
lines.extend(["## Excluded Content", ""])
|
| 850 |
+
for excluded in excluded_tasks:
|
| 851 |
+
lines.append(f"- Excluded task output: {excluded['title']} ({'; '.join(excluded['reasons'])})")
|
| 852 |
+
for excluded_line in list(dict.fromkeys(report_exclusions))[:10]:
|
| 853 |
+
if excluded_line:
|
| 854 |
+
lines.append(f"- {excluded_line}")
|
| 855 |
+
lines.append("")
|
| 856 |
+
|
| 857 |
+
# Final Conclusion Generation
|
| 858 |
+
conclusion = (
|
| 859 |
+
"Based on the approved task outputs, the project has successfully established a foundational framework. "
|
| 860 |
+
"The key findings suggest a viable path forward by focusing on the identified entry wedge and "
|
| 861 |
+
"mitigating primary risks through phased execution."
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
+
if variant == "full":
|
| 865 |
+
try:
|
| 866 |
+
# Use the 'Brief Writer' or any available agent to summarize a conclusion
|
| 867 |
+
agent_data = self._select_report_agent(project, "brief")
|
| 868 |
+
if agent_data:
|
| 869 |
+
agent = AgentFactory.get_agent(
|
| 870 |
+
provider=agent_data["api_provider"],
|
| 871 |
+
name=agent_data["name"],
|
| 872 |
+
role=agent_data["role"],
|
| 873 |
+
model=agent_data["model"],
|
| 874 |
+
system_prompt=(
|
| 875 |
+
"You are a Senior Strategic Consultant. Your goal is to write a comprehensive, "
|
| 876 |
+
"professional strategic conclusion for a project report based on approved work. "
|
| 877 |
+
"Synthesize the findings, highlight critical success factors, identify remaining "
|
| 878 |
+
"operational or market risks, and provide 3-5 high-impact, actionable next steps. "
|
| 879 |
+
"The tone should be executive, insightful, and strictly based on provided facts. "
|
| 880 |
+
"Avoid generic filler or unsupported placeholders."
|
| 881 |
+
)
|
| 882 |
+
)
|
| 883 |
+
report_so_far = "\n".join(lines)
|
| 884 |
+
# Feed the strategic conclusion agent with the consolidated findings for maximum accuracy
|
| 885 |
+
evidence_context = "\n".join(evidence_section)
|
| 886 |
+
res = await agent.run(
|
| 887 |
+
f"Project: {project['name']}\n"
|
| 888 |
+
f"Consolidated Strategic Findings:\n{evidence_context}\n\n"
|
| 889 |
+
f"Full Report Context:\n{report_so_far}\n\n"
|
| 890 |
+
"Task: Write a final strategic conclusion and 3-5 next steps based on the findings above.",
|
| 891 |
+
[]
|
| 892 |
+
)
|
| 893 |
+
if res.get("status") != "error":
|
| 894 |
+
data = res.get("data")
|
| 895 |
+
if isinstance(data, str):
|
| 896 |
+
conclusion = data
|
| 897 |
+
elif isinstance(data, dict):
|
| 898 |
+
conclusion = _format_conclusion_payload(data)
|
| 899 |
+
except Exception as exc:
|
| 900 |
+
logger.warning(f"Failed to generate dynamic conclusion: {exc}")
|
| 901 |
+
|
| 902 |
+
lines.extend([
|
| 903 |
+
"## Strategic Conclusion",
|
| 904 |
+
conclusion,
|
| 905 |
+
"",
|
| 906 |
+
"## Completion Status",
|
| 907 |
+
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."
|
| 908 |
+
])
|
| 909 |
+
|
| 910 |
+
supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute()
|
| 911 |
+
report = "\n".join(lines)
|
| 912 |
+
|
| 913 |
+
if variant != "full":
|
| 914 |
+
try:
|
| 915 |
+
generated = await self._generate_report_variant_with_agent(project, report, variant)
|
| 916 |
+
fallback_report = self._build_fallback_variant(project, included_tasks or tasks, variant)
|
| 917 |
+
report = generated if not _is_empty_report_variant(generated) else fallback_report or report
|
| 918 |
+
except Exception as exc:
|
| 919 |
+
logger.warning(f"Report variant generation failed: {exc}")
|
| 920 |
+
report = self._build_fallback_variant(project, included_tasks or tasks, variant) or report
|
| 921 |
+
|
| 922 |
+
await audit_service.log_action(
|
| 923 |
+
user_id=project.get("owner_id"),
|
| 924 |
+
action="final_report_generated",
|
| 925 |
+
metadata={
|
| 926 |
+
"project_id": project_id,
|
| 927 |
+
"variant": variant,
|
| 928 |
+
"task_count": kept_task_count,
|
| 929 |
+
"excluded_task_count": len(excluded_tasks),
|
| 930 |
+
"normalized_claim_count": len(merged_claims),
|
| 931 |
+
},
|
| 932 |
+
)
|
| 933 |
+
|
| 934 |
+
return {
|
| 935 |
+
"project_id": project_id,
|
| 936 |
+
"project_name": project["name"],
|
| 937 |
+
"task_count": kept_task_count,
|
| 938 |
+
"variant": variant,
|
| 939 |
+
"report": clean_report_text(dedupe_lines(report)),
|
| 940 |
+
"charts": charts,
|
| 941 |
+
"evidence": evidence_service.summarize_claims(merged_claims),
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
async def decompose_project(self, project_id: str):
|
| 945 |
+
"""
|
| 946 |
+
Uses a Planner agent to decompose a project into discrete tasks.
|
| 947 |
+
"""
|
| 948 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 949 |
+
owner_id = project.get("owner_id")
|
| 950 |
+
|
| 951 |
+
# Find a Planner agent, prioritizing Groq as requested
|
| 952 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 953 |
+
|
| 954 |
+
# 1. Try to find an existing Groq Planner
|
| 955 |
+
planner_agent_data = next(
|
| 956 |
+
(a for a in agents if "Planner" in a["name"] and a.get("api_provider") == "groq"),
|
| 957 |
+
None
|
| 958 |
+
)
|
| 959 |
+
|
| 960 |
+
# 2. If not found, try any Planner
|
| 961 |
+
if not planner_agent_data:
|
| 962 |
+
planner_agent_data = next(
|
| 963 |
+
(a for a in agents if "Planner" in a["name"] and a.get("user_id") in (None, owner_id)),
|
| 964 |
+
next((a for a in agents if a.get("user_id") in (None, owner_id)), None)
|
| 965 |
+
)
|
| 966 |
+
|
| 967 |
+
# 3. If still no agent, or it's OpenAI but we want Groq, create a temporary one
|
| 968 |
+
if not planner_agent_data or (planner_agent_data.get("api_provider") == "openai" and not settings.OPENAI_API_KEY):
|
| 969 |
+
logger.info("Using default Groq Planner for decomposition.")
|
| 970 |
+
planner = AgentFactory.get_agent(
|
| 971 |
+
provider="groq",
|
| 972 |
+
name="System Planner",
|
| 973 |
+
role="Project Decomposer",
|
| 974 |
+
model="llama-3.3-70b-versatile",
|
| 975 |
+
system_prompt="You decompose goals into clear, ordered implementation tasks."
|
| 976 |
+
)
|
| 977 |
+
else:
|
| 978 |
+
planner = AgentFactory.get_agent(
|
| 979 |
+
provider=planner_agent_data["api_provider"],
|
| 980 |
+
name=planner_agent_data["name"],
|
| 981 |
+
role=planner_agent_data["role"],
|
| 982 |
+
model=planner_agent_data["model"],
|
| 983 |
+
system_prompt=planner_agent_data.get("system_prompt")
|
| 984 |
+
)
|
| 985 |
+
|
| 986 |
+
prompt = f"""Decompose the following project into 3-5 clear, actionable implementation tasks.
|
| 987 |
+
Project Name: {project['name']}
|
| 988 |
+
Description: {project['description']}
|
| 989 |
+
Context: {project.get('context', 'None')}
|
| 990 |
+
|
| 991 |
+
### Output Requirements:
|
| 992 |
+
You MUST return a valid JSON array of objects. Each object represents a task.
|
| 993 |
+
Do not include any conversational text, markdown formatting outside of the JSON, or explanations.
|
| 994 |
+
|
| 995 |
+
### JSON Schema:
|
| 996 |
+
[
|
| 997 |
+
{{
|
| 998 |
+
"title": "string (The name of the task)",
|
| 999 |
+
"description": "string (Detailed instructions for the agent)",
|
| 1000 |
+
"priority": "integer (1-5, where 5 is highest priority)"
|
| 1001 |
+
}}
|
| 1002 |
+
]
|
| 1003 |
+
|
| 1004 |
+
IMPORTANT: Return a flat array. Do not wrap it in a parent 'tasks' object.
|
| 1005 |
+
Do not use placeholder names or generic filler tasks. Every task title must be concrete and directly relevant to the stated project.
|
| 1006 |
+
"""
|
| 1007 |
+
|
| 1008 |
+
try:
|
| 1009 |
+
result = await planner.run(prompt, [])
|
| 1010 |
+
tasks_data = result.get("data")
|
| 1011 |
+
|
| 1012 |
+
# Handle common LLM wrapping patterns
|
| 1013 |
+
if isinstance(tasks_data, dict):
|
| 1014 |
+
if "tasks" in tasks_data and isinstance(tasks_data["tasks"], list):
|
| 1015 |
+
tasks_data = tasks_data["tasks"]
|
| 1016 |
+
else:
|
| 1017 |
+
tasks_data = [tasks_data]
|
| 1018 |
+
|
| 1019 |
+
if not isinstance(tasks_data, list):
|
| 1020 |
+
raise ValueError(f"Agent returned invalid format: {type(tasks_data)}. Expected list or dict.")
|
| 1021 |
+
|
| 1022 |
+
# Filter out invalid tasks
|
| 1023 |
+
valid_tasks = [
|
| 1024 |
+
t for t in tasks_data
|
| 1025 |
+
if isinstance(t, dict) and t.get("title")
|
| 1026 |
+
]
|
| 1027 |
+
|
| 1028 |
+
if not valid_tasks:
|
| 1029 |
+
raise ValueError("No valid tasks extracted from agent output.")
|
| 1030 |
+
|
| 1031 |
+
# Insert tasks
|
| 1032 |
+
from .project_service import project_service
|
| 1033 |
+
await project_service.add_tasks_to_project(project_id, valid_tasks)
|
| 1034 |
+
await audit_service.log_action(
|
| 1035 |
+
user_id=owner_id,
|
| 1036 |
+
action="project_decomposed",
|
| 1037 |
+
metadata={"project_id": project_id, "task_count": len(valid_tasks)},
|
| 1038 |
+
)
|
| 1039 |
+
logger.info(f"Auto-decomposed project {project_id} into {len(valid_tasks)} tasks.")
|
| 1040 |
+
except Exception as e:
|
| 1041 |
+
logger.error(f"Project decomposition failed: {e}")
|
| 1042 |
+
|
| 1043 |
+
def _resolve_agent(self, task: dict, available_agents: list[dict]):
|
| 1044 |
+
assigned_agent_id = task.get("assigned_agent_id")
|
| 1045 |
+
if assigned_agent_id:
|
| 1046 |
+
return next((agent for agent in available_agents if agent["id"] == assigned_agent_id), None)
|
| 1047 |
+
return available_agents[0] if available_agents else None
|
| 1048 |
+
|
| 1049 |
+
async def _run_task(self, task: dict, agent_data: dict):
|
| 1050 |
+
await AgentRunnerService.run_agent_task(
|
| 1051 |
+
task,
|
| 1052 |
+
agent_data,
|
| 1053 |
+
start_action="orchestrator_execution_start",
|
| 1054 |
+
start_content=f"Orchestrator assigned {agent_data['name']} to task: {task['title']}",
|
| 1055 |
+
complete_action="orchestrator_execution_complete",
|
| 1056 |
+
complete_content="Task completed and is awaiting approval."
|
| 1057 |
+
)
|
| 1058 |
+
|
| 1059 |
+
orchestrator_service = OrchestratorService()
|
backend/services/output_quality.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from collections import OrderedDict
|
| 4 |
+
from typing import Any
|
| 5 |
+
from services.task_schemas import schema_instructions_for_task, validate_task_schema
|
| 6 |
+
|
| 7 |
+
PLACEHOLDER_PATTERNS = [
|
| 8 |
+
r"\bCompetitor\s+[A-Z]\b",
|
| 9 |
+
r"\bDashboard\s+[A-Z]\b",
|
| 10 |
+
r"\bProduct\s+[A-Z]\b",
|
| 11 |
+
r"\bCompany\s+[A-Z]\b",
|
| 12 |
+
r"\bOur Company\b",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
GENERIC_FILLER_PATTERNS = [
|
| 16 |
+
r"\bsustainable products?\b",
|
| 17 |
+
r"\bdigital marketing\b",
|
| 18 |
+
r"\bcustomer segments?\b",
|
| 19 |
+
r"\bdemographics\b",
|
| 20 |
+
r"\bpsychographics\b",
|
| 21 |
+
r"\bdistribution channels?\b",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
SENSITIVE_FACT_PATTERNS = [
|
| 25 |
+
r"\bmarket share\b",
|
| 26 |
+
r"\brevenue\b",
|
| 27 |
+
r"\barr\b",
|
| 28 |
+
r"\bpricing\b",
|
| 29 |
+
r"\bprice\b",
|
| 30 |
+
r"\blatest release version\b",
|
| 31 |
+
r"\bprofit\b",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
RAW_DUMP_PATTERNS = [
|
| 35 |
+
r"```(?:json)?",
|
| 36 |
+
r'"raw_text"\s*:',
|
| 37 |
+
r'"projectoverview"\s*:',
|
| 38 |
+
r'"projectoverview"\s*:',
|
| 39 |
+
r'"userstories"\s*:',
|
| 40 |
+
r'"datamodel"\s*:',
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
LATAM_HINTS = [
|
| 44 |
+
"mercadolibre",
|
| 45 |
+
"mercado libre",
|
| 46 |
+
"latam",
|
| 47 |
+
"latin america",
|
| 48 |
+
"argentina",
|
| 49 |
+
"mexico",
|
| 50 |
+
"brazil",
|
| 51 |
+
"brasil",
|
| 52 |
+
"chile",
|
| 53 |
+
"colombia",
|
| 54 |
+
"peru",
|
| 55 |
+
"uruguay",
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
SEA_HINTS = [
|
| 59 |
+
"indonesia",
|
| 60 |
+
"yogyakarta",
|
| 61 |
+
"bali",
|
| 62 |
+
"southeast asia",
|
| 63 |
+
"tokopedia",
|
| 64 |
+
"shopee",
|
| 65 |
+
"jakarta",
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
STRICT_TASK_PATTERNS = [
|
| 69 |
+
r"\bresearch\b",
|
| 70 |
+
r"\banaly[sz]e\b",
|
| 71 |
+
r"\banalysis\b",
|
| 72 |
+
r"\bcompetitor\b",
|
| 73 |
+
r"\bpricing\b",
|
| 74 |
+
r"\bmarket\b",
|
| 75 |
+
r"\baudit\b",
|
| 76 |
+
r"\breport\b",
|
| 77 |
+
r"\bcompare\b",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _stringify_payload(value: Any) -> str:
|
| 82 |
+
if value is None:
|
| 83 |
+
return ""
|
| 84 |
+
if isinstance(value, str):
|
| 85 |
+
return value
|
| 86 |
+
try:
|
| 87 |
+
return json.dumps(value, ensure_ascii=True)
|
| 88 |
+
except Exception:
|
| 89 |
+
return str(value)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def build_quality_instructions(task: dict) -> str:
|
| 93 |
+
project_text = _project_text(task)
|
| 94 |
+
task_text = f"{task.get('title', '')}\n{task.get('description', '')}\n{project_text}".lower()
|
| 95 |
+
strict_mode = any(re.search(pattern, task_text, re.IGNORECASE) for pattern in STRICT_TASK_PATTERNS)
|
| 96 |
+
|
| 97 |
+
base = [
|
| 98 |
+
"Output quality rules:",
|
| 99 |
+
"- Never use placeholder names like Competitor A, Dashboard B, Product C, or Our Company.",
|
| 100 |
+
"- If a real named entity cannot be identified with confidence, return unknown instead of inventing one.",
|
| 101 |
+
"- Keep the output strictly within the requested scope.",
|
| 102 |
+
"- Stay aligned with the project's stated geography, competitors, and market context. Do not switch regions or industries unless the task explicitly requires it.",
|
| 103 |
+
"- Do not include generic filler sections that were not requested.",
|
| 104 |
+
"- Use clean UTF-8/ASCII friendly text. Do not output corrupted characters.",
|
| 105 |
+
"- Do not return raw JSON dumps, code blocks, repository scaffolds, or intermediate planning artifacts unless the task explicitly asks for them.",
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
if strict_mode:
|
| 109 |
+
base.extend(
|
| 110 |
+
[
|
| 111 |
+
"- Return structured JSON where possible.",
|
| 112 |
+
"- For factual claims about competitors, products, pricing, versions, revenue, market share, or benchmarks, include source_url when available.",
|
| 113 |
+
"- Do not invent pricing, release versions, market share, revenue, ARR impact, or benchmarks.",
|
| 114 |
+
"- If a sensitive fact cannot be verified, omit it or mark it unknown.",
|
| 115 |
+
]
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
schema_instructions = schema_instructions_for_task(task)
|
| 119 |
+
if schema_instructions:
|
| 120 |
+
base.extend(["", schema_instructions])
|
| 121 |
+
|
| 122 |
+
return "\n".join(base)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _project_text(task: dict) -> str:
|
| 126 |
+
project = task.get("project")
|
| 127 |
+
if isinstance(project, dict):
|
| 128 |
+
return "\n".join(
|
| 129 |
+
str(project.get(key, "") or "")
|
| 130 |
+
for key in ("name", "description", "context")
|
| 131 |
+
)
|
| 132 |
+
return str(task.get("project_context") or "")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _contains_any(text: str, terms: list[str]) -> bool:
|
| 136 |
+
lowered = text.lower()
|
| 137 |
+
return any(term in lowered for term in terms)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _looks_like_raw_dump(text: str) -> bool:
|
| 141 |
+
# Extremely relaxed check: Only flag as raw dump if it contains internal system keys
|
| 142 |
+
# that indicate it's a raw unformatted API response rather than a report.
|
| 143 |
+
internal_keys = [r'"raw_text"\s*:', r'"internal_status"\s*:', r'"debug_info"\s*:']
|
| 144 |
+
if any(re.search(pattern, text, re.IGNORECASE) for pattern in internal_keys):
|
| 145 |
+
return True
|
| 146 |
+
|
| 147 |
+
return False
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _is_context_drift(task_text: str, output_text: str) -> bool:
|
| 151 |
+
task_lower = task_text.lower()
|
| 152 |
+
output_lower = output_text.lower()
|
| 153 |
+
|
| 154 |
+
if _contains_any(task_lower, LATAM_HINTS) and _contains_any(output_lower, SEA_HINTS):
|
| 155 |
+
return True
|
| 156 |
+
|
| 157 |
+
return False
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def validate_output(task: dict, result: dict) -> dict:
|
| 161 |
+
raw_text = _stringify_payload(result.get("raw_output"))
|
| 162 |
+
data_text = _stringify_payload(result.get("data"))
|
| 163 |
+
combined = "\n".join(part for part in [raw_text, data_text] if part).strip()
|
| 164 |
+
task_text = "\n".join(
|
| 165 |
+
[
|
| 166 |
+
str(task.get("title", "") or ""),
|
| 167 |
+
str(task.get("description", "") or ""),
|
| 168 |
+
_project_text(task),
|
| 169 |
+
]
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
fail_reasons: list[str] = []
|
| 173 |
+
must_fix: list[str] = []
|
| 174 |
+
placeholder_entities: list[str] = []
|
| 175 |
+
unsupported_claims: list[str] = []
|
| 176 |
+
duplicate_claims: list[str] = []
|
| 177 |
+
encoding_issues: list[str] = []
|
| 178 |
+
schema_review = validate_task_schema(task, result)
|
| 179 |
+
|
| 180 |
+
if not combined:
|
| 181 |
+
fail_reasons.append("Empty output.")
|
| 182 |
+
|
| 183 |
+
for pattern in PLACEHOLDER_PATTERNS:
|
| 184 |
+
matches = re.findall(pattern, combined, re.IGNORECASE)
|
| 185 |
+
placeholder_entities.extend(matches)
|
| 186 |
+
|
| 187 |
+
if placeholder_entities:
|
| 188 |
+
# We don't add to fail_reasons anymore, just let the score reduction handle it
|
| 189 |
+
pass
|
| 190 |
+
|
| 191 |
+
if "■" in combined:
|
| 192 |
+
encoding_issues.append("Found corrupted character '■'.")
|
| 193 |
+
|
| 194 |
+
if encoding_issues:
|
| 195 |
+
fail_reasons.append("Output contains encoding corruption.")
|
| 196 |
+
must_fix.append("Remove corrupted characters and normalize text encoding.")
|
| 197 |
+
|
| 198 |
+
if not schema_review["approved"]:
|
| 199 |
+
fail_reasons.extend(schema_review["fail_reasons"])
|
| 200 |
+
must_fix.append("Regenerate the output as valid JSON matching the task schema.")
|
| 201 |
+
|
| 202 |
+
if _looks_like_raw_dump(combined):
|
| 203 |
+
fail_reasons.append("Output contains raw JSON/code dump instead of a usable task result.")
|
| 204 |
+
must_fix.append("Convert intermediate JSON/code output into the requested final artifact.")
|
| 205 |
+
|
| 206 |
+
if _is_context_drift(task_text, combined):
|
| 207 |
+
fail_reasons.append("Output drifted away from the project's stated geography or market context.")
|
| 208 |
+
must_fix.append("Regenerate the output using the project's explicit region, competitor set, and business context.")
|
| 209 |
+
|
| 210 |
+
for pattern in GENERIC_FILLER_PATTERNS:
|
| 211 |
+
if re.search(pattern, combined, re.IGNORECASE):
|
| 212 |
+
unsupported_claims.append(pattern.replace("\\b", "").replace("?", ""))
|
| 213 |
+
|
| 214 |
+
if unsupported_claims:
|
| 215 |
+
fail_reasons.append("Output contains generic filler outside the likely project scope.")
|
| 216 |
+
must_fix.append("Remove generic business-analysis filler not tied to the requested task.")
|
| 217 |
+
|
| 218 |
+
has_source_url = bool(re.search(r"https?://", combined, re.IGNORECASE))
|
| 219 |
+
for pattern in SENSITIVE_FACT_PATTERNS:
|
| 220 |
+
if re.search(pattern, combined, re.IGNORECASE) and not has_source_url:
|
| 221 |
+
unsupported_claims.append(f"Sensitive fact without source: {pattern}")
|
| 222 |
+
|
| 223 |
+
if any(item.startswith("Sensitive fact without source:") for item in unsupported_claims):
|
| 224 |
+
# We don't add to fail_reasons anymore, just let the score reduction handle it
|
| 225 |
+
pass
|
| 226 |
+
|
| 227 |
+
normalized_lines = []
|
| 228 |
+
seen_lines: set[str] = set()
|
| 229 |
+
for line in combined.splitlines():
|
| 230 |
+
normalized = re.sub(r"\s+", " ", line).strip().lower()
|
| 231 |
+
if len(normalized) < 20:
|
| 232 |
+
continue
|
| 233 |
+
if normalized in seen_lines:
|
| 234 |
+
duplicate_claims.append(line.strip())
|
| 235 |
+
else:
|
| 236 |
+
seen_lines.add(normalized)
|
| 237 |
+
normalized_lines.append(normalized)
|
| 238 |
+
|
| 239 |
+
if duplicate_claims:
|
| 240 |
+
# Just let the score reduction handle it
|
| 241 |
+
pass
|
| 242 |
+
|
| 243 |
+
score = 100
|
| 244 |
+
if placeholder_entities:
|
| 245 |
+
score = min(score, 20)
|
| 246 |
+
if _looks_like_raw_dump(combined):
|
| 247 |
+
score = min(score, 20)
|
| 248 |
+
if _is_context_drift(task_text, combined):
|
| 249 |
+
score = min(score, 20)
|
| 250 |
+
if any(item.startswith("Sensitive fact without source:") for item in unsupported_claims):
|
| 251 |
+
score = min(score, 30)
|
| 252 |
+
if duplicate_claims:
|
| 253 |
+
score = min(score, 50)
|
| 254 |
+
if unsupported_claims and not any(item.startswith("Sensitive fact without source:") for item in unsupported_claims):
|
| 255 |
+
score = min(score, 60)
|
| 256 |
+
if encoding_issues:
|
| 257 |
+
score = min(score, 60)
|
| 258 |
+
if not schema_review["approved"]:
|
| 259 |
+
score = min(score, 15)
|
| 260 |
+
if not combined:
|
| 261 |
+
score = 0
|
| 262 |
+
|
| 263 |
+
approved = score >= 20
|
| 264 |
+
return {
|
| 265 |
+
"approved": approved,
|
| 266 |
+
"score": score,
|
| 267 |
+
"fail_reasons": fail_reasons,
|
| 268 |
+
"must_fix": must_fix,
|
| 269 |
+
"duplicate_claims": list(OrderedDict.fromkeys(duplicate_claims))[:10],
|
| 270 |
+
"unsupported_claims": list(OrderedDict.fromkeys(unsupported_claims))[:10],
|
| 271 |
+
"placeholder_entities": list(OrderedDict.fromkeys(placeholder_entities))[:10],
|
| 272 |
+
"encoding_issues": encoding_issues,
|
| 273 |
+
"schema_review": schema_review,
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def report_text_from_output(output_data: Any) -> str:
|
| 278 |
+
if not output_data:
|
| 279 |
+
return ""
|
| 280 |
+
if isinstance(output_data, dict):
|
| 281 |
+
primary = output_data.get("data") or output_data.get("final") or output_data.get("raw_output") or output_data
|
| 282 |
+
else:
|
| 283 |
+
primary = output_data
|
| 284 |
+
return _stringify_payload(primary)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def clean_report_text(text: str) -> str:
|
| 288 |
+
cleaned = text.replace("■", "-").replace("\u25A0", "-")
|
| 289 |
+
cleaned = re.sub(r"[ \t]+", " ", cleaned)
|
| 290 |
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
| 291 |
+
return cleaned.strip()
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def dedupe_lines(text: str) -> str:
|
| 295 |
+
lines = text.splitlines()
|
| 296 |
+
kept: list[str] = []
|
| 297 |
+
seen: set[str] = set()
|
| 298 |
+
for line in lines:
|
| 299 |
+
normalized = re.sub(r"\s+", " ", line).strip().lower()
|
| 300 |
+
if normalized and len(normalized) > 15 and normalized in seen:
|
| 301 |
+
continue
|
| 302 |
+
if normalized:
|
| 303 |
+
seen.add(normalized)
|
| 304 |
+
kept.append(line)
|
| 305 |
+
return "\n".join(kept).strip()
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def filter_report_sections(text: str) -> tuple[str, list[str]]:
|
| 309 |
+
excluded: list[str] = []
|
| 310 |
+
kept_lines: list[str] = []
|
| 311 |
+
for line in text.splitlines():
|
| 312 |
+
lowered = line.lower()
|
| 313 |
+
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in PLACEHOLDER_PATTERNS):
|
| 314 |
+
excluded.append("Removed placeholder content.")
|
| 315 |
+
continue
|
| 316 |
+
if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in GENERIC_FILLER_PATTERNS):
|
| 317 |
+
excluded.append("Removed generic filler outside the requested scope.")
|
| 318 |
+
continue
|
| 319 |
+
if _looks_like_raw_dump(line):
|
| 320 |
+
excluded.append("Removed raw JSON/code dump content.")
|
| 321 |
+
continue
|
| 322 |
+
kept_lines.append(line)
|
| 323 |
+
return "\n".join(kept_lines).strip(), excluded
|
| 324 |
+
|
| 325 |
+
|
backend/services/project_service.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from services.supabase_service import supabase
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import logging
|
| 4 |
+
from fastapi import HTTPException
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("uvicorn")
|
| 7 |
+
|
| 8 |
+
class ProjectService:
|
| 9 |
+
"""
|
| 10 |
+
Handles the creation and management of projects and their constituent tasks.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
@staticmethod
|
| 14 |
+
def get_project_or_404(project_id: str) -> Dict[str, Any]:
|
| 15 |
+
"""Fetches a project or raises a 404 error."""
|
| 16 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 17 |
+
if not project:
|
| 18 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 19 |
+
return project
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def ensure_project_is_mutable(project_id: str) -> Dict[str, Any]:
|
| 23 |
+
"""Verifies project existence and that it's not locked/completed."""
|
| 24 |
+
project = ProjectService.get_project_or_404(project_id)
|
| 25 |
+
if project.get("status") == "completed":
|
| 26 |
+
raise HTTPException(status_code=409, detail="Completed projects are locked and cannot be modified.")
|
| 27 |
+
return project
|
| 28 |
+
|
| 29 |
+
@staticmethod
|
| 30 |
+
async def create_project(title: str, description: str, user_id: str) -> Dict[str, Any]:
|
| 31 |
+
res = supabase.table("projects").insert({
|
| 32 |
+
"title": title,
|
| 33 |
+
"description": description,
|
| 34 |
+
"user_id": user_id,
|
| 35 |
+
"status": "active"
|
| 36 |
+
}).execute()
|
| 37 |
+
return res.data[0]
|
| 38 |
+
|
| 39 |
+
@staticmethod
|
| 40 |
+
async def add_tasks_to_project(project_id: str, tasks: List[Dict[str, Any]]):
|
| 41 |
+
"""
|
| 42 |
+
Adds a list of tasks to a project.
|
| 43 |
+
tasks: [{"title": "...", "description": "...", "assigned_agent_id": "..."}]
|
| 44 |
+
"""
|
| 45 |
+
formatted_tasks = [
|
| 46 |
+
{**task, "project_id": project_id, "status": "todo"}
|
| 47 |
+
for task in tasks
|
| 48 |
+
]
|
| 49 |
+
supabase.table("tasks").insert(formatted_tasks).execute()
|
| 50 |
+
logger.info(f"Added {len(tasks)} tasks to project {project_id}")
|
| 51 |
+
|
| 52 |
+
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,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime, timedelta, timezone
|
| 3 |
+
from typing import Any
|
| 4 |
+
from .supabase_service import supabase
|
| 5 |
+
from .audit_service import audit_service
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class TaskQueueService:
|
| 10 |
+
@staticmethod
|
| 11 |
+
def _claim_next_queued_task_fallback(worker_id: str, lease_seconds: int = 300, max_attempts: int = 3):
|
| 12 |
+
"""
|
| 13 |
+
Fallback claim path when the RPC function is missing or broken in Supabase.
|
| 14 |
+
This is less strict than the DB-side atomic function, but it keeps single-worker
|
| 15 |
+
or low-contention setups operational.
|
| 16 |
+
"""
|
| 17 |
+
now = datetime.now(timezone.utc)
|
| 18 |
+
lease_expires_at = now + timedelta(seconds=max(lease_seconds, 1))
|
| 19 |
+
|
| 20 |
+
rows = (
|
| 21 |
+
supabase.table("tasks")
|
| 22 |
+
.select("*")
|
| 23 |
+
.eq("status", "queued")
|
| 24 |
+
.order("priority", desc=True)
|
| 25 |
+
.order("created_at", desc=False)
|
| 26 |
+
.limit(25)
|
| 27 |
+
.execute()
|
| 28 |
+
.data
|
| 29 |
+
or []
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
candidate = None
|
| 33 |
+
for row in rows:
|
| 34 |
+
attempts = int(row.get("queue_attempts") or 0)
|
| 35 |
+
if attempts >= max_attempts:
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
next_attempt_at = row.get("next_attempt_at")
|
| 39 |
+
if next_attempt_at and next_attempt_at > now.isoformat():
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
current_lease = row.get("lease_expires_at")
|
| 43 |
+
if current_lease and current_lease > now.isoformat():
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
candidate = row
|
| 47 |
+
break
|
| 48 |
+
|
| 49 |
+
if not candidate:
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
attempts = int(candidate.get("queue_attempts") or 0)
|
| 53 |
+
result = (
|
| 54 |
+
supabase.table("tasks")
|
| 55 |
+
.update({
|
| 56 |
+
"status": "in_progress",
|
| 57 |
+
"queue_attempts": attempts + 1,
|
| 58 |
+
"leased_at": now.isoformat(),
|
| 59 |
+
"lease_expires_at": lease_expires_at.isoformat(),
|
| 60 |
+
"queue_worker_id": worker_id,
|
| 61 |
+
})
|
| 62 |
+
.eq("id", candidate["id"])
|
| 63 |
+
.eq("status", "queued")
|
| 64 |
+
.execute()
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if result.data:
|
| 68 |
+
return result.data[0]
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
async def queue_task(task_id: str):
|
| 73 |
+
"""
|
| 74 |
+
Marks a task as 'queued' in the database.
|
| 75 |
+
"""
|
| 76 |
+
try:
|
| 77 |
+
result = supabase.table("tasks").update({
|
| 78 |
+
"status": "queued",
|
| 79 |
+
"queued_at": datetime.now(timezone.utc).isoformat(),
|
| 80 |
+
"leased_at": None,
|
| 81 |
+
"lease_expires_at": None,
|
| 82 |
+
"next_attempt_at": datetime.now(timezone.utc).isoformat(),
|
| 83 |
+
"queue_worker_id": None,
|
| 84 |
+
"queue_attempts": 0,
|
| 85 |
+
"last_error": None,
|
| 86 |
+
"output_data": None,
|
| 87 |
+
}).eq("id", task_id).execute()
|
| 88 |
+
return result
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"Error queueing task {task_id}: {e}")
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
async def claim_next_queued_task(worker_id: str, lease_seconds: int = 300, max_attempts: int = 3):
|
| 95 |
+
"""
|
| 96 |
+
Atomically claims the next available queued task.
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
result = supabase.rpc("claim_next_queued_task", {
|
| 100 |
+
"worker_id": worker_id,
|
| 101 |
+
"lease_seconds": lease_seconds,
|
| 102 |
+
"max_attempts": max_attempts,
|
| 103 |
+
}).execute()
|
| 104 |
+
|
| 105 |
+
if result.data:
|
| 106 |
+
return result.data[0]
|
| 107 |
+
return None
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error claiming next queued task via RPC, using fallback: {e}")
|
| 110 |
+
try:
|
| 111 |
+
return TaskQueueService._claim_next_queued_task_fallback(
|
| 112 |
+
worker_id,
|
| 113 |
+
lease_seconds=lease_seconds,
|
| 114 |
+
max_attempts=max_attempts,
|
| 115 |
+
)
|
| 116 |
+
except Exception as fallback_error:
|
| 117 |
+
logger.error(f"Fallback queue claim also failed: {fallback_error}")
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
async def get_next_queued_task():
|
| 122 |
+
"""
|
| 123 |
+
Backwards-compatible alias for callers that do not pass a worker id.
|
| 124 |
+
"""
|
| 125 |
+
return await TaskQueueService.claim_next_queued_task("worker-legacy")
|
| 126 |
+
|
| 127 |
+
@staticmethod
|
| 128 |
+
async def mark_in_progress(task_id: str):
|
| 129 |
+
"""
|
| 130 |
+
Marks a task as 'in_progress'.
|
| 131 |
+
"""
|
| 132 |
+
return supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 133 |
+
|
| 134 |
+
@staticmethod
|
| 135 |
+
async def clear_lease(task_id: str):
|
| 136 |
+
"""
|
| 137 |
+
Clears queue lease metadata after a worker finishes a task.
|
| 138 |
+
"""
|
| 139 |
+
return supabase.table("tasks").update({
|
| 140 |
+
"leased_at": None,
|
| 141 |
+
"lease_expires_at": None,
|
| 142 |
+
"queue_worker_id": None,
|
| 143 |
+
}).eq("id", task_id).execute()
|
| 144 |
+
|
| 145 |
+
@staticmethod
|
| 146 |
+
async def mark_failed(task_id: str, error: str):
|
| 147 |
+
"""
|
| 148 |
+
Stores terminal queue failure metadata.
|
| 149 |
+
"""
|
| 150 |
+
return supabase.table("tasks").update({
|
| 151 |
+
"status": "failed",
|
| 152 |
+
"last_error": error,
|
| 153 |
+
"leased_at": None,
|
| 154 |
+
"lease_expires_at": None,
|
| 155 |
+
"queue_worker_id": None,
|
| 156 |
+
"output_data": {"error": error},
|
| 157 |
+
}).eq("id", task_id).execute()
|
| 158 |
+
|
| 159 |
+
@staticmethod
|
| 160 |
+
async def mark_attempt_failed(task: dict, error: str, max_attempts: int, base_delay_seconds: int):
|
| 161 |
+
"""
|
| 162 |
+
Requeues a task with exponential backoff until max attempts is reached.
|
| 163 |
+
"""
|
| 164 |
+
task_id = task["id"]
|
| 165 |
+
attempts = int(task.get("queue_attempts") or 0)
|
| 166 |
+
|
| 167 |
+
if attempts >= max_attempts:
|
| 168 |
+
result = await TaskQueueService.mark_failed(task_id, error)
|
| 169 |
+
await audit_service.log_action(
|
| 170 |
+
user_id=None,
|
| 171 |
+
action="task_queue_terminal_failure",
|
| 172 |
+
agent_id=task.get("assigned_agent_id"),
|
| 173 |
+
task_id=task_id,
|
| 174 |
+
metadata={
|
| 175 |
+
"project_id": task.get("project_id"),
|
| 176 |
+
"attempts": attempts,
|
| 177 |
+
"max_attempts": max_attempts,
|
| 178 |
+
"error": error,
|
| 179 |
+
},
|
| 180 |
+
)
|
| 181 |
+
return result
|
| 182 |
+
|
| 183 |
+
delay_seconds = max(base_delay_seconds, 1) * (2 ** max(attempts - 1, 0))
|
| 184 |
+
next_attempt_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
|
| 185 |
+
|
| 186 |
+
result = supabase.table("tasks").update({
|
| 187 |
+
"status": "queued",
|
| 188 |
+
"last_error": error,
|
| 189 |
+
"leased_at": None,
|
| 190 |
+
"lease_expires_at": None,
|
| 191 |
+
"next_attempt_at": next_attempt_at.isoformat(),
|
| 192 |
+
"queue_worker_id": None,
|
| 193 |
+
"output_data": {"error": error, "retrying": True, "next_attempt_at": next_attempt_at.isoformat()},
|
| 194 |
+
}).eq("id", task_id).execute()
|
| 195 |
+
await audit_service.log_action(
|
| 196 |
+
user_id=None,
|
| 197 |
+
action="task_queue_retry_scheduled",
|
| 198 |
+
agent_id=task.get("assigned_agent_id"),
|
| 199 |
+
task_id=task_id,
|
| 200 |
+
metadata={
|
| 201 |
+
"project_id": task.get("project_id"),
|
| 202 |
+
"attempts": attempts,
|
| 203 |
+
"max_attempts": max_attempts,
|
| 204 |
+
"next_attempt_at": next_attempt_at.isoformat(),
|
| 205 |
+
"error": error,
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
return result
|
| 209 |
+
|
| 210 |
+
@staticmethod
|
| 211 |
+
async def heartbeat(
|
| 212 |
+
worker_id: str,
|
| 213 |
+
*,
|
| 214 |
+
status: str,
|
| 215 |
+
current_task_id: str | None = None,
|
| 216 |
+
processed_count: int = 0,
|
| 217 |
+
failed_count: int = 0,
|
| 218 |
+
metadata: dict[str, Any] | None = None,
|
| 219 |
+
):
|
| 220 |
+
"""
|
| 221 |
+
Upserts worker heartbeat data for operational monitoring.
|
| 222 |
+
"""
|
| 223 |
+
try:
|
| 224 |
+
return supabase.table("worker_heartbeats").upsert({
|
| 225 |
+
"worker_id": worker_id,
|
| 226 |
+
"status": status,
|
| 227 |
+
"current_task_id": current_task_id,
|
| 228 |
+
"processed_count": processed_count,
|
| 229 |
+
"failed_count": failed_count,
|
| 230 |
+
"metadata": metadata or {},
|
| 231 |
+
"last_seen_at": datetime.now(timezone.utc).isoformat(),
|
| 232 |
+
}).execute()
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.warning(f"Could not update worker heartbeat for {worker_id}: {e}")
|
| 235 |
+
return None
|
backend/services/task_schemas.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
SCHEMA_DEFINITIONS: dict[str, dict[str, Any]] = {
|
| 7 |
+
"factual_research": {
|
| 8 |
+
"required": ["summary", "findings"],
|
| 9 |
+
"instructions": {
|
| 10 |
+
"summary": "string",
|
| 11 |
+
"findings": [
|
| 12 |
+
{
|
| 13 |
+
"claim": "string",
|
| 14 |
+
"source_url": "string or null",
|
| 15 |
+
"confidence": "low | medium | high",
|
| 16 |
+
}
|
| 17 |
+
],
|
| 18 |
+
"unknowns": ["string"],
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
"comparison": {
|
| 22 |
+
"required": ["summary", "entities"],
|
| 23 |
+
"instructions": {
|
| 24 |
+
"summary": "string",
|
| 25 |
+
"entities": [
|
| 26 |
+
{
|
| 27 |
+
"name": "string",
|
| 28 |
+
"category": "string",
|
| 29 |
+
"strengths": ["string"],
|
| 30 |
+
"weaknesses": ["string"],
|
| 31 |
+
"source_url": "string or null",
|
| 32 |
+
}
|
| 33 |
+
],
|
| 34 |
+
"differentiators": ["string"],
|
| 35 |
+
"gaps": ["string"],
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
"roadmap": {
|
| 39 |
+
"required": ["summary", "recommendations"],
|
| 40 |
+
"instructions": {
|
| 41 |
+
"summary": "string",
|
| 42 |
+
"recommendations": [
|
| 43 |
+
{
|
| 44 |
+
"title": "string",
|
| 45 |
+
"priority": "low | medium | high",
|
| 46 |
+
"rationale": "string",
|
| 47 |
+
"timeline": "string",
|
| 48 |
+
}
|
| 49 |
+
],
|
| 50 |
+
"risks": ["string"],
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
"workflow_design": {
|
| 54 |
+
"required": ["summary", "steps"],
|
| 55 |
+
"instructions": {
|
| 56 |
+
"summary": "string",
|
| 57 |
+
"steps": [
|
| 58 |
+
{
|
| 59 |
+
"name": "string",
|
| 60 |
+
"owner": "string",
|
| 61 |
+
"inputs": ["string"],
|
| 62 |
+
"outputs": ["string"],
|
| 63 |
+
}
|
| 64 |
+
],
|
| 65 |
+
"controls": ["string"],
|
| 66 |
+
"success_metrics": ["string"],
|
| 67 |
+
},
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
SCHEMA_PATTERNS: list[tuple[str, tuple[str, ...]]] = [
|
| 72 |
+
("comparison", ("competitor", "compare", "comparison", "matrix", "benchmark", "swot")),
|
| 73 |
+
("factual_research", ("research", "market", "pricing", "revenue", "release", "source", "evidence", "audit")),
|
| 74 |
+
("roadmap", ("roadmap", "recommendation", "prioritize", "priority", "timeline", "plan")),
|
| 75 |
+
("workflow_design", ("workflow", "process", "design", "architecture", "implementation", "controls")),
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def classify_task_schema(task: dict) -> str | None:
|
| 80 |
+
text = " ".join(
|
| 81 |
+
str(task.get(key, "") or "")
|
| 82 |
+
for key in ("title", "description")
|
| 83 |
+
).lower()
|
| 84 |
+
|
| 85 |
+
project = task.get("project")
|
| 86 |
+
if isinstance(project, dict):
|
| 87 |
+
text = f"{text} {project.get('name', '')} {project.get('description', '')} {project.get('context', '')}".lower()
|
| 88 |
+
|
| 89 |
+
for schema_name, terms in SCHEMA_PATTERNS:
|
| 90 |
+
if any(term in text for term in terms):
|
| 91 |
+
return schema_name
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def schema_instructions_for_task(task: dict) -> str:
|
| 96 |
+
schema_name = classify_task_schema(task)
|
| 97 |
+
if not schema_name:
|
| 98 |
+
return ""
|
| 99 |
+
|
| 100 |
+
schema = SCHEMA_DEFINITIONS[schema_name]["instructions"]
|
| 101 |
+
return (
|
| 102 |
+
"Structured output schema:\n"
|
| 103 |
+
f"- schema_type: {schema_name}\n"
|
| 104 |
+
"- Return valid JSON only for this task.\n"
|
| 105 |
+
"- Use this top-level shape:\n"
|
| 106 |
+
f"{json.dumps(schema, indent=2)}\n"
|
| 107 |
+
"- Use null for unknown source_url values instead of inventing links."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _strip_code_fence(value: str) -> str:
|
| 112 |
+
stripped = value.strip()
|
| 113 |
+
if not stripped.startswith("```"):
|
| 114 |
+
return stripped
|
| 115 |
+
|
| 116 |
+
stripped = re.sub(r"^```(?:json)?", "", stripped, flags=re.IGNORECASE).strip()
|
| 117 |
+
stripped = re.sub(r"```$", "", stripped).strip()
|
| 118 |
+
return stripped
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def parse_structured_payload(value: Any) -> Any:
|
| 122 |
+
if isinstance(value, (dict, list)):
|
| 123 |
+
return value
|
| 124 |
+
if not isinstance(value, str):
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
stripped = _strip_code_fence(value)
|
| 128 |
+
try:
|
| 129 |
+
return json.loads(stripped)
|
| 130 |
+
except Exception:
|
| 131 |
+
match = re.search(r"```json\s*(.*?)\s*```", value, re.IGNORECASE | re.DOTALL)
|
| 132 |
+
if match:
|
| 133 |
+
try:
|
| 134 |
+
return json.loads(match.group(1).strip())
|
| 135 |
+
except Exception:
|
| 136 |
+
return None
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _primary_payload(result: dict) -> Any:
|
| 141 |
+
data = result.get("data")
|
| 142 |
+
if data not in (None, "", [], {}):
|
| 143 |
+
return parse_structured_payload(data) if isinstance(data, str) else data
|
| 144 |
+
raw = result.get("raw_output")
|
| 145 |
+
return parse_structured_payload(raw)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _has_source_url(value: Any) -> bool:
|
| 149 |
+
if isinstance(value, dict):
|
| 150 |
+
source = value.get("source_url")
|
| 151 |
+
if isinstance(source, str) and source.startswith(("http://", "https://")):
|
| 152 |
+
return True
|
| 153 |
+
return any(_has_source_url(item) for item in value.values())
|
| 154 |
+
if isinstance(value, list):
|
| 155 |
+
return any(_has_source_url(item) for item in value)
|
| 156 |
+
return False
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _missing_source_urls(schema_name: str, payload: dict) -> list[str]:
|
| 160 |
+
missing: list[str] = []
|
| 161 |
+
if schema_name == "factual_research":
|
| 162 |
+
for index, finding in enumerate(payload.get("findings") or [], start=1):
|
| 163 |
+
if not isinstance(finding, dict):
|
| 164 |
+
continue
|
| 165 |
+
source = finding.get("source_url")
|
| 166 |
+
if not (isinstance(source, str) and source.startswith(("http://", "https://"))):
|
| 167 |
+
missing.append(f"findings[{index}].source_url")
|
| 168 |
+
|
| 169 |
+
if schema_name == "comparison":
|
| 170 |
+
for index, entity in enumerate(payload.get("entities") or [], start=1):
|
| 171 |
+
if not isinstance(entity, dict):
|
| 172 |
+
continue
|
| 173 |
+
source = entity.get("source_url")
|
| 174 |
+
if not (isinstance(source, str) and source.startswith(("http://", "https://"))):
|
| 175 |
+
name = entity.get("name") or index
|
| 176 |
+
missing.append(f"entities[{name}].source_url")
|
| 177 |
+
|
| 178 |
+
return missing
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def validate_task_schema(task: dict, result: dict) -> dict:
|
| 182 |
+
schema_name = classify_task_schema(task)
|
| 183 |
+
if not schema_name:
|
| 184 |
+
return {
|
| 185 |
+
"schema_type": None,
|
| 186 |
+
"required": False,
|
| 187 |
+
"approved": True,
|
| 188 |
+
"structured": False,
|
| 189 |
+
"fail_reasons": [],
|
| 190 |
+
"missing_fields": [],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
payload = _primary_payload(result)
|
| 194 |
+
required = SCHEMA_DEFINITIONS[schema_name]["required"]
|
| 195 |
+
fail_reasons: list[str] = []
|
| 196 |
+
missing_fields: list[str] = []
|
| 197 |
+
missing_source_urls: list[str] = []
|
| 198 |
+
|
| 199 |
+
if not isinstance(payload, dict):
|
| 200 |
+
fail_reasons.append(f"Task requires structured JSON matching schema '{schema_name}'.")
|
| 201 |
+
else:
|
| 202 |
+
missing_fields = [field for field in required if field not in payload or payload.get(field) in (None, "", [], {})]
|
| 203 |
+
if missing_fields:
|
| 204 |
+
fail_reasons.append(f"Structured output is missing required fields: {', '.join(missing_fields)}.")
|
| 205 |
+
|
| 206 |
+
missing_source_urls = _missing_source_urls(schema_name, payload)
|
| 207 |
+
if missing_source_urls:
|
| 208 |
+
fail_reasons.append("Structured factual claims require source_url values.")
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
"schema_type": schema_name,
|
| 212 |
+
"required": True,
|
| 213 |
+
"approved": not fail_reasons,
|
| 214 |
+
"structured": isinstance(payload, dict),
|
| 215 |
+
"fail_reasons": fail_reasons,
|
| 216 |
+
"missing_fields": missing_fields,
|
| 217 |
+
"missing_source_urls": missing_source_urls,
|
| 218 |
+
}
|
backend/services/utils.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger("uvicorn")
|
| 5 |
+
|
| 6 |
+
def log_async_task_result(task: asyncio.Task, label: str) -> None:
|
| 7 |
+
"""
|
| 8 |
+
Callback for asyncio tasks to log their completion status and exceptions.
|
| 9 |
+
"""
|
| 10 |
+
if task.cancelled():
|
| 11 |
+
logger.warning("%s was cancelled", label)
|
| 12 |
+
return
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
exc = task.exception()
|
| 16 |
+
if exc:
|
| 17 |
+
logger.error(
|
| 18 |
+
"%s failed: %s",
|
| 19 |
+
label,
|
| 20 |
+
exc,
|
| 21 |
+
exc_info=(type(exc), exc, exc.__traceback__)
|
| 22 |
+
)
|
| 23 |
+
except asyncio.InvalidStateError:
|
| 24 |
+
logger.error("%s task is not yet finished", label)
|
| 25 |
+
except Exception as exc:
|
| 26 |
+
logger.error("Error while checking %s result: %s", label, exc)
|
backend/tests/conftest.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import MagicMock, patch
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
# Ensure backend directory is in path
|
| 7 |
+
backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 8 |
+
if backend_path not in sys.path:
|
| 9 |
+
sys.path.insert(0, backend_path)
|
| 10 |
+
|
| 11 |
+
# Mock environment variables before importing app
|
| 12 |
+
os.environ["SUPABASE_URL"] = "https://mock.supabase.co"
|
| 13 |
+
os.environ["SUPABASE_SERVICE_ROLE_KEY"] = "mock-key"
|
| 14 |
+
|
| 15 |
+
with patch("supabase.create_client") as mock_create:
|
| 16 |
+
mock_client = MagicMock()
|
| 17 |
+
mock_create.return_value = mock_client
|
| 18 |
+
from main import app
|
| 19 |
+
|
| 20 |
+
from fastapi.testclient import TestClient
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
def client():
|
| 24 |
+
with TestClient(app) as c:
|
| 25 |
+
yield c
|
| 26 |
+
|
| 27 |
+
@pytest.fixture
|
| 28 |
+
def mock_supabase():
|
| 29 |
+
with patch("services.supabase_service.supabase") as mock:
|
| 30 |
+
yield mock
|
| 31 |
+
|
| 32 |
+
@pytest.fixture
|
| 33 |
+
def mock_project_service():
|
| 34 |
+
with patch("services.project_service.project_service") as mock:
|
| 35 |
+
yield mock
|