cesjavi commited on
Commit
372477f
·
0 Parent(s):

Production deployment with AMD priority and stability fixes

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +15 -0
  2. .gemini/antigravity/brain/8453b74f-68a6-47ae-887d-1123cb011afb/scratch/verify_supabase.py +10 -0
  3. .gitattributes +35 -0
  4. .gitignore +13 -0
  5. Dockerfile +40 -0
  6. README.md +199 -0
  7. ROADMAP.md +72 -0
  8. SPEC.md +200 -0
  9. VERSION +1 -0
  10. backend/.env.example +19 -0
  11. backend/Dockerfile +32 -0
  12. backend/agents/agent_factory.py +45 -0
  13. backend/agents/amd_agent.py +42 -0
  14. backend/agents/base.py +179 -0
  15. backend/agents/digitalocean_agent.py +62 -0
  16. backend/agents/gemini_agent.py +37 -0
  17. backend/agents/groq_agent.py +107 -0
  18. backend/agents/local_agent.py +48 -0
  19. backend/agents/openai_agent.py +37 -0
  20. backend/agents_debug.json +1 -0
  21. backend/api/index.py +1 -0
  22. backend/main.py +199 -0
  23. backend/project_debug.json +1 -0
  24. backend/requirements.txt +19 -0
  25. backend/routers/__init__.py +1 -0
  26. backend/routers/agent_runner.py +483 -0
  27. backend/routers/generator.py +109 -0
  28. backend/routers/monitoring.py +121 -0
  29. backend/routers/orchestrator.py +233 -0
  30. backend/scratch/check_db.py +22 -0
  31. backend/scratch/create_comparison_project.py +168 -0
  32. backend/scratch/find_user.py +24 -0
  33. backend/scratch/fix_logs_rls.py +33 -0
  34. backend/services/agent_runner_service.py +399 -0
  35. backend/services/audit_service.py +31 -0
  36. backend/services/budget_service.py +208 -0
  37. backend/services/config.py +107 -0
  38. backend/services/embedding_service.py +87 -0
  39. backend/services/evidence_service.py +315 -0
  40. backend/services/infrastructure_service.py +97 -0
  41. backend/services/memory_service.py +174 -0
  42. backend/services/orchestrator_service.py +1059 -0
  43. backend/services/output_quality.py +325 -0
  44. backend/services/project_service.py +52 -0
  45. backend/services/semantic_backprop.py +104 -0
  46. backend/services/supabase_service.py +13 -0
  47. backend/services/task_queue.py +235 -0
  48. backend/services/task_schemas.py +218 -0
  49. backend/services/utils.py +26 -0
  50. 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