Spaces:
Sleeping
Sleeping
deploy to hf space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +9 -0
- .gitignore +0 -0
- Dockerfile +32 -0
- README.md +216 -5
- backend/.env +1 -0
- backend/Dockerfile +21 -0
- backend/__pycache__/main.cpython-312.pyc +0 -0
- backend/__pycache__/test_agent_flow.cpython-312.pyc +0 -0
- backend/__pycache__/test_rag_pipeline.cpython-312.pyc +0 -0
- backend/adapters/__init__.py +3 -0
- backend/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/adapters/__pycache__/adk_adapter.cpython-312.pyc +0 -0
- backend/adapters/__pycache__/agent_adapter.cpython-312.pyc +0 -0
- backend/adapters/__pycache__/planner_adapter.cpython-312.pyc +0 -0
- backend/adapters/adk_adapter.py +36 -0
- backend/adapters/agent_adapter.py +26 -0
- backend/adapters/planner_adapter.py +38 -0
- backend/agents/__init__.py +2 -0
- backend/agents/api_agent.py +33 -0
- backend/agents/architecture_agent.py +33 -0
- backend/agents/base_agent.py +31 -0
- backend/agents/dependency_agent.py +33 -0
- backend/agents/llm_client.py +40 -0
- backend/agents/onboarding_agent.py +33 -0
- backend/agents/orchestrator.py +351 -0
- backend/agents/planner_agent.py +83 -0
- backend/agents/quality_agent.py +33 -0
- backend/agents/response_synthesizer.py +63 -0
- backend/agents/security_agent.py +33 -0
- backend/main.py +561 -0
- backend/memory/__init__.py +7 -0
- backend/memory/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/memory/__pycache__/conversation_manager.cpython-312.pyc +0 -0
- backend/memory/__pycache__/embedding_service.cpython-312.pyc +0 -0
- backend/memory/__pycache__/knowledge_index.cpython-312.pyc +0 -0
- backend/memory/__pycache__/memory_cache.cpython-312.pyc +0 -0
- backend/memory/__pycache__/retriever.cpython-312.pyc +0 -0
- backend/memory/__pycache__/session_manager.cpython-312.pyc +0 -0
- backend/memory/__pycache__/vector_store.cpython-312.pyc +0 -0
- backend/memory/conversation_manager.py +80 -0
- backend/memory/embedding_service.py +46 -0
- backend/memory/knowledge_index.py +185 -0
- backend/memory/memory_cache.py +42 -0
- backend/memory/retriever.py +43 -0
- backend/memory/session_manager.py +65 -0
- backend/memory/vector_store.py +95 -0
- backend/requirements.txt +7 -0
- backend/services/__init__.py +1 -0
- backend/services/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/services/__pycache__/graphBuilder.cpython-312.pyc +0 -0
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gemini API key (required for repository analysis, chat, and semantic search)
|
| 2 |
+
GEMINI_API_KEY=your_gemini_api_key_here
|
| 3 |
+
|
| 4 |
+
# Optional: comma-separated CORS origins (default: *)
|
| 5 |
+
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
| 6 |
+
|
| 7 |
+
# Frontend (local dev) — leave empty to use Vite proxy
|
| 8 |
+
# VITE_API_URL=
|
| 9 |
+
# VITE_API_PROXY=http://localhost:8000
|
.gitignore
ADDED
|
Binary file (262 Bytes). View file
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Stage 1: Build Frontend ---
|
| 2 |
+
FROM node:20-alpine AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package*.json ./
|
| 5 |
+
RUN npm install
|
| 6 |
+
COPY frontend/ ./
|
| 7 |
+
RUN npm run build
|
| 8 |
+
|
| 9 |
+
# --- Stage 2: Serve Backend & Frontend ---
|
| 10 |
+
FROM python:3.11-slim
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
git \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
# Copy backend requirements and install
|
| 18 |
+
COPY backend/requirements.txt ./backend/
|
| 19 |
+
RUN pip install --no-cache-dir -r backend/requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy backend source
|
| 22 |
+
COPY backend/ ./backend/
|
| 23 |
+
|
| 24 |
+
# Copy compiled frontend build
|
| 25 |
+
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
| 26 |
+
|
| 27 |
+
# Expose port (Hugging Face Spaces requires port 7860)
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
# Run FastAPI from the backend directory so imports resolve locally
|
| 31 |
+
WORKDIR /app/backend
|
| 32 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,221 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Repository Intelligence Layer
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Repository Intelligence Layer
|
| 13 |
+
|
| 14 |
+
AI-powered repository analysis platform that clones or uploads codebases, generates structured intelligence artifacts with **Gemini 2.5 Flash**, and provides an interactive dashboard with graph visualization, semantic search, and multi-agent chat.
|
| 15 |
+
|
| 16 |
+

|
| 17 |
+
<!-- Replace with actual screenshot after first run -->
|
| 18 |
+
|
| 19 |
+
## Features
|
| 20 |
+
|
| 21 |
+
- **GitHub cloning** — public and private repos (PAT authentication)
|
| 22 |
+
- **ZIP upload** — drag-and-drop repository archives
|
| 23 |
+
- **Repository scanner** — file tree, manifest parsing, static profiling
|
| 24 |
+
- **Graph builder** — static import/dependency graph + LLM architecture graph
|
| 25 |
+
- **Gemini analysis** — markdown report, profile, summary, and graph JSON
|
| 26 |
+
- **Interactive dashboard** — report, summary, profile, graph viewer, repo tree
|
| 27 |
+
- **AI Assistant** — multi-agent orchestration with RAG context
|
| 28 |
+
- **Knowledge Explorer** — ChromaDB semantic search and conversation history
|
| 29 |
+
- **Download endpoints** — export all intelligence artifacts
|
| 30 |
+
- **Persistent memory** — artifacts saved to disk; ChromaDB vector index
|
| 31 |
+
|
| 32 |
+
## Architecture
|
| 33 |
+
|
| 34 |
+
```mermaid
|
| 35 |
+
flowchart LR
|
| 36 |
+
subgraph Input
|
| 37 |
+
URL[GitHub URL + PAT]
|
| 38 |
+
ZIP[ZIP Upload]
|
| 39 |
+
end
|
| 40 |
+
subgraph Pipeline
|
| 41 |
+
Scan[Repository Scanner]
|
| 42 |
+
Profile[Repository Profiler]
|
| 43 |
+
Graph[Graph Builder]
|
| 44 |
+
LLM[Gemini Analyzer]
|
| 45 |
+
Mem[Memory Layer]
|
| 46 |
+
Chroma[ChromaDB Index]
|
| 47 |
+
end
|
| 48 |
+
subgraph Frontend
|
| 49 |
+
Tree[Repo Tree]
|
| 50 |
+
Dash[Dashboard]
|
| 51 |
+
GraphV[Graph Viewer]
|
| 52 |
+
Chat[AI Assistant]
|
| 53 |
+
end
|
| 54 |
+
URL --> Scan
|
| 55 |
+
ZIP --> Scan
|
| 56 |
+
Scan --> Profile
|
| 57 |
+
Profile --> Graph
|
| 58 |
+
Graph --> LLM
|
| 59 |
+
LLM --> Mem
|
| 60 |
+
LLM --> Chroma
|
| 61 |
+
Mem --> Dash
|
| 62 |
+
Scan --> Tree
|
| 63 |
+
Mem --> GraphV
|
| 64 |
+
Chroma --> Chat
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## Project Structure
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
├── backend/ # FastAPI application
|
| 71 |
+
│ ├── main.py # API routes
|
| 72 |
+
│ ├── services/ # Scanner, profiler, graph builder, LLM, memory
|
| 73 |
+
│ ├── agents/ # Multi-agent orchestration
|
| 74 |
+
│ ├── memory/ # ChromaDB, RAG, conversations
|
| 75 |
+
│ └── tools/ # MCP-ready tool registry
|
| 76 |
+
├── frontend/ # React + Vite dashboard
|
| 77 |
+
├── Dockerfile # Unified build for Hugging Face Spaces (port 7860)
|
| 78 |
+
└── docker-compose.yml # Local split-stack development
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## Installation
|
| 82 |
+
|
| 83 |
+
### Prerequisites
|
| 84 |
+
|
| 85 |
+
- Python 3.11+
|
| 86 |
+
- Node.js 20+
|
| 87 |
+
- Git (for repository cloning)
|
| 88 |
+
- Gemini API key from [Google AI Studio](https://aistudio.google.com/)
|
| 89 |
+
|
| 90 |
+
### Local Setup
|
| 91 |
+
|
| 92 |
+
1. **Clone the repository**
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
git clone <your-repo-url>
|
| 96 |
+
cd "Software Engineer Agent"
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
2. **Configure environment**
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
cp .env.example backend/.env
|
| 103 |
+
# Edit backend/.env and set GEMINI_API_KEY
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
3. **Install backend dependencies**
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
cd backend
|
| 110 |
+
pip install -r requirements.txt
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
4. **Install frontend dependencies**
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
cd ../frontend
|
| 117 |
+
npm install
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
5. **Run locally (two terminals)**
|
| 121 |
+
|
| 122 |
+
Terminal 1 — Backend:
|
| 123 |
+
```bash
|
| 124 |
+
cd backend
|
| 125 |
+
uvicorn main:app --reload --host 127.0.0.1 --port 8000
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Terminal 2 — Frontend:
|
| 129 |
+
```bash
|
| 130 |
+
cd frontend
|
| 131 |
+
npm run dev
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Open **http://localhost:5173** — the Vite dev server proxies `/api` to the backend.
|
| 135 |
+
|
| 136 |
+
## Environment Variables
|
| 137 |
+
|
| 138 |
+
| Variable | Required | Description |
|
| 139 |
+
|----------|----------|-------------|
|
| 140 |
+
| `GEMINI_API_KEY` | Yes | Google Gemini API key for analysis, chat, and embeddings |
|
| 141 |
+
| `CORS_ORIGINS` | No | Comma-separated allowed origins (default: `*`) |
|
| 142 |
+
| `VITE_API_URL` | No | Frontend API base URL (empty = same origin / Vite proxy) |
|
| 143 |
+
| `VITE_API_PROXY` | No | Vite dev proxy target (default: `http://localhost:8000`) |
|
| 144 |
+
|
| 145 |
+
## API Endpoints
|
| 146 |
+
|
| 147 |
+
| Method | Path | Description |
|
| 148 |
+
|--------|------|-------------|
|
| 149 |
+
| `GET` | `/api/health` | Health check |
|
| 150 |
+
| `POST` | `/api/analyze-url` | Clone and analyze a GitHub repository |
|
| 151 |
+
| `POST` | `/api/analyze-zip` | Upload and analyze a ZIP archive |
|
| 152 |
+
| `GET` | `/api/download/{repo_id}/{type}` | Download artifact (`profile`, `graph`, `summary`, `report`) |
|
| 153 |
+
| `POST` | `/api/chat` | Multi-agent chat with RAG |
|
| 154 |
+
| `POST` | `/api/search` | Semantic search over indexed knowledge |
|
| 155 |
+
| `GET` | `/api/memory?repo_id=` | Vector index statistics |
|
| 156 |
+
| `GET` | `/api/conversations?repo_id=` | List chat sessions |
|
| 157 |
+
| `GET` | `/api/conversations/{session_id}` | Session message history |
|
| 158 |
+
| `GET` | `/api/tools` | Tool catalog |
|
| 159 |
+
|
| 160 |
+
## Docker
|
| 161 |
+
|
| 162 |
+
### Unified (Hugging Face / production)
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
docker build -t repo-intelligence .
|
| 166 |
+
docker run -p 7860:7860 -e GEMINI_API_KEY=your_key repo-intelligence
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
Open **http://localhost:7860**
|
| 170 |
+
|
| 171 |
+
### Split stack (development)
|
| 172 |
+
|
| 173 |
+
```bash
|
| 174 |
+
export GEMINI_API_KEY=your_key
|
| 175 |
+
docker compose up --build
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
- Frontend: **http://localhost:5173**
|
| 179 |
+
- Backend: **http://localhost:8000**
|
| 180 |
+
|
| 181 |
+
## Hugging Face Spaces Deployment
|
| 182 |
+
|
| 183 |
+
This project is ready for **free deployment** on [Hugging Face Spaces](https://huggingface.co/spaces) using the Docker SDK.
|
| 184 |
+
|
| 185 |
+
1. Create a new Space → select **Docker** as the SDK
|
| 186 |
+
2. Push this repository (or connect GitHub)
|
| 187 |
+
3. Ensure the root `Dockerfile` is used (builds frontend + serves backend on port **7860**)
|
| 188 |
+
4. Add a Space secret: `GEMINI_API_KEY` = your Gemini API key
|
| 189 |
+
5. Wait for the build to complete
|
| 190 |
+
|
| 191 |
+
The Space will serve both the React dashboard and FastAPI backend from a single container.
|
| 192 |
+
|
| 193 |
+
### HF Space Settings
|
| 194 |
+
|
| 195 |
+
- **SDK:** Docker
|
| 196 |
+
- **App port:** 7860
|
| 197 |
+
- **Secrets:** `GEMINI_API_KEY`
|
| 198 |
+
|
| 199 |
+
## Generated Artifacts
|
| 200 |
+
|
| 201 |
+
After analysis, the platform produces:
|
| 202 |
+
|
| 203 |
+
| File | Description |
|
| 204 |
+
|------|-------------|
|
| 205 |
+
| `repository_report.md` | Full markdown intelligence report |
|
| 206 |
+
| `repository_profile.json` | Languages, frameworks, APIs, modules, auth |
|
| 207 |
+
| `repository_summary.json` | Elevator pitch, features, workflows, risks |
|
| 208 |
+
| `repository_graph.json` | Architecture nodes, edges, flows, concepts |
|
| 209 |
+
|
| 210 |
+
Artifacts are stored in `backend/storage/repos/{repo_id}/` and available via the dashboard download buttons.
|
| 211 |
+
|
| 212 |
+
## Security
|
| 213 |
+
|
| 214 |
+
- ZIP extraction includes path-traversal protection
|
| 215 |
+
- GitHub PAT tokens are redacted from error messages
|
| 216 |
+
- Temporary clone/extract workspaces are cleaned up after analysis
|
| 217 |
+
- Private repos require a valid GitHub Personal Access Token
|
| 218 |
+
|
| 219 |
+
## License
|
| 220 |
+
|
| 221 |
+
MIT
|
backend/.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GEMINI_API_KEY=AQ.Ab8RN6JCtHvyMAUiC4ldysHtPKp2vzTXWHjWFeke1frUha3BAA
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies (Git is required for cloning repos)
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
git \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Copy requirements and install
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy backend code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Expose port
|
| 18 |
+
EXPOSE 8000
|
| 19 |
+
|
| 20 |
+
# Start FastAPI application
|
| 21 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (25.3 kB). View file
|
|
|
backend/__pycache__/test_agent_flow.cpython-312.pyc
ADDED
|
Binary file (5.34 kB). View file
|
|
|
backend/__pycache__/test_rag_pipeline.cpython-312.pyc
ADDED
|
Binary file (9.62 kB). View file
|
|
|
backend/adapters/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from adapters.adk_adapter import GoogleADKPlatformAdapter
|
| 2 |
+
from adapters.agent_adapter import ADKAgentAdapter
|
| 3 |
+
from adapters.planner_adapter import ADKPlannerAdapter
|
backend/adapters/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (327 Bytes). View file
|
|
|
backend/adapters/__pycache__/adk_adapter.cpython-312.pyc
ADDED
|
Binary file (1.96 kB). View file
|
|
|
backend/adapters/__pycache__/agent_adapter.cpython-312.pyc
ADDED
|
Binary file (1.55 kB). View file
|
|
|
backend/adapters/__pycache__/planner_adapter.cpython-312.pyc
ADDED
|
Binary file (2.02 kB). View file
|
|
|
backend/adapters/adk_adapter.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from adapters.agent_adapter import ADKAgentAdapter
|
| 3 |
+
from adapters.planner_adapter import ADKPlannerAdapter
|
| 4 |
+
|
| 5 |
+
class GoogleADKPlatformAdapter:
|
| 6 |
+
"""
|
| 7 |
+
High-level adapter class representing a Google ADK Application context.
|
| 8 |
+
Integrates the multi-agent layers and provides entry points for task execution.
|
| 9 |
+
"""
|
| 10 |
+
def __init__(self, orchestrator: Any):
|
| 11 |
+
self.orchestrator = orchestrator
|
| 12 |
+
self.planner_adapter = ADKPlannerAdapter(orchestrator.planner)
|
| 13 |
+
self.agent_adapters = {
|
| 14 |
+
name: ADKAgentAdapter(agent)
|
| 15 |
+
for name, agent in orchestrator.agents.items()
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async def dispatch_query(
|
| 19 |
+
self,
|
| 20 |
+
profile: Dict[str, Any],
|
| 21 |
+
graph: Dict[str, Any],
|
| 22 |
+
summary: Dict[str, Any],
|
| 23 |
+
report: str,
|
| 24 |
+
query: str
|
| 25 |
+
) -> Dict[str, Any]:
|
| 26 |
+
"""
|
| 27 |
+
Executes queries by forwarding them to the underlying orchestrator,
|
| 28 |
+
representing a standard ADK execution cycle.
|
| 29 |
+
"""
|
| 30 |
+
return await self.orchestrator.execute(
|
| 31 |
+
profile=profile,
|
| 32 |
+
graph=graph,
|
| 33 |
+
summary=summary,
|
| 34 |
+
report=report,
|
| 35 |
+
query=query
|
| 36 |
+
)
|
backend/adapters/agent_adapter.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
from agents.base_agent import BaseAgent
|
| 3 |
+
|
| 4 |
+
class ADKAgentAdapter:
|
| 5 |
+
"""
|
| 6 |
+
Adapter wrapping existing BaseAgent specialized agents to conform to Google ADK
|
| 7 |
+
agent invocation schemas.
|
| 8 |
+
"""
|
| 9 |
+
def __init__(self, agent: BaseAgent):
|
| 10 |
+
self.agent = agent
|
| 11 |
+
self.name = agent.__class__.__name__
|
| 12 |
+
|
| 13 |
+
async def execute_task(self, context: Dict[str, Any], query: str) -> Dict[str, Any]:
|
| 14 |
+
profile = context.get("profile", {})
|
| 15 |
+
graph = context.get("graph", {})
|
| 16 |
+
summary = context.get("summary", {})
|
| 17 |
+
report = context.get("report", "")
|
| 18 |
+
|
| 19 |
+
# Call the underlying agent
|
| 20 |
+
return await self.agent.run(
|
| 21 |
+
profile=profile,
|
| 22 |
+
graph=graph,
|
| 23 |
+
summary=summary,
|
| 24 |
+
report=report,
|
| 25 |
+
query=query
|
| 26 |
+
)
|
backend/adapters/planner_adapter.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.planner_agent import PlannerAgent
|
| 4 |
+
|
| 5 |
+
class ADKPlannerAdapter:
|
| 6 |
+
"""
|
| 7 |
+
Adapter wrapping the PlannerAgent to match ADK planner expectations.
|
| 8 |
+
"""
|
| 9 |
+
def __init__(self, planner: PlannerAgent):
|
| 10 |
+
self.planner = planner
|
| 11 |
+
|
| 12 |
+
async def create_plan(self, context: Dict[str, Any], query: str) -> Dict[str, Any]:
|
| 13 |
+
profile = context.get("profile", {})
|
| 14 |
+
graph = context.get("graph", {})
|
| 15 |
+
summary = context.get("summary", {})
|
| 16 |
+
report = context.get("report", "")
|
| 17 |
+
|
| 18 |
+
# Call planner
|
| 19 |
+
plan = await self.planner.run(
|
| 20 |
+
profile=profile,
|
| 21 |
+
graph=graph,
|
| 22 |
+
summary=summary,
|
| 23 |
+
report=report,
|
| 24 |
+
query=query
|
| 25 |
+
)
|
| 26 |
+
return {
|
| 27 |
+
"plan_id": f"plan_{int(time.time())}",
|
| 28 |
+
"steps": [
|
| 29 |
+
{
|
| 30 |
+
"stage": idx + 1,
|
| 31 |
+
"agents": stage,
|
| 32 |
+
"parameters": {"query": query}
|
| 33 |
+
}
|
| 34 |
+
for idx, stage in enumerate(plan.get("execution_order", []))
|
| 35 |
+
],
|
| 36 |
+
"reasoning": plan.get("reasoning", ""),
|
| 37 |
+
"selected_agents": plan.get("selected_agents", [])
|
| 38 |
+
}
|
backend/agents/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.llm_client import LLMClient, GeminiLLMClient
|
| 2 |
+
from agents.orchestrator import AgentOrchestrator
|
backend/agents/api_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.base_agent import BaseAgent, AgentResponseSchema
|
| 4 |
+
|
| 5 |
+
class ApiAgent(BaseAgent):
|
| 6 |
+
async def run(
|
| 7 |
+
self,
|
| 8 |
+
profile: Dict[str, Any],
|
| 9 |
+
graph: Dict[str, Any],
|
| 10 |
+
summary: Dict[str, Any],
|
| 11 |
+
report: str,
|
| 12 |
+
query: str
|
| 13 |
+
) -> Dict[str, Any]:
|
| 14 |
+
prompt = f"""
|
| 15 |
+
You are the API Agent. Your responsibility is to analyze the API design of the repository, including HTTP methods, routes/endpoints, payload structures, external API dependencies, and data exchange/request-response flow.
|
| 16 |
+
|
| 17 |
+
Here is the repository context:
|
| 18 |
+
1. Profile:
|
| 19 |
+
{json.dumps(profile, indent=2)}
|
| 20 |
+
2. Graph Structure:
|
| 21 |
+
{json.dumps(graph, indent=2)}
|
| 22 |
+
3. Summary:
|
| 23 |
+
{json.dumps(summary, indent=2)}
|
| 24 |
+
4. Intelligence Report:
|
| 25 |
+
{report}
|
| 26 |
+
|
| 27 |
+
User Query:
|
| 28 |
+
{query}
|
| 29 |
+
|
| 30 |
+
Perform a rigorous analysis of the endpoints and API flow to answer this query. Your citations must specify specific routes or lines where endpoints are declared or configured.
|
| 31 |
+
Return your structured answer matching the AgentResponseSchema.
|
| 32 |
+
"""
|
| 33 |
+
return await self._call_llm_json(prompt, AgentResponseSchema)
|
backend/agents/architecture_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.base_agent import BaseAgent, AgentResponseSchema
|
| 4 |
+
|
| 5 |
+
class ArchitectureAgent(BaseAgent):
|
| 6 |
+
async def run(
|
| 7 |
+
self,
|
| 8 |
+
profile: Dict[str, Any],
|
| 9 |
+
graph: Dict[str, Any],
|
| 10 |
+
summary: Dict[str, Any],
|
| 11 |
+
report: str,
|
| 12 |
+
query: str
|
| 13 |
+
) -> Dict[str, Any]:
|
| 14 |
+
prompt = f"""
|
| 15 |
+
You are the Architecture Agent. Your responsibility is to analyze the codebase structure, architectural design patterns, component relationships, data flow, business workflows, and entry points to answer the user query.
|
| 16 |
+
|
| 17 |
+
Here is the repository context:
|
| 18 |
+
1. Profile:
|
| 19 |
+
{json.dumps(profile, indent=2)}
|
| 20 |
+
2. Graph Structure:
|
| 21 |
+
{json.dumps(graph, indent=2)}
|
| 22 |
+
3. Summary:
|
| 23 |
+
{json.dumps(summary, indent=2)}
|
| 24 |
+
4. Intelligence Report:
|
| 25 |
+
{report}
|
| 26 |
+
|
| 27 |
+
User Query:
|
| 28 |
+
{query}
|
| 29 |
+
|
| 30 |
+
Perform a rigorous architectural analysis to answer this query. Your citations must specify files, code blocks, or components (e.g. "backend/main.py:L10-L40", "Graph Node: App.jsx").
|
| 31 |
+
Return your structured answer matching the AgentResponseSchema.
|
| 32 |
+
"""
|
| 33 |
+
return await self._call_llm_json(prompt, AgentResponseSchema)
|
backend/agents/base_agent.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
import asyncio
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from agents.llm_client import LLMClient
|
| 6 |
+
|
| 7 |
+
class AgentResponseSchema(BaseModel):
|
| 8 |
+
agent: str = Field(description="Name of the agent, e.g., SecurityAgent")
|
| 9 |
+
confidence: float = Field(description="Confidence score between 0.0 and 1.0 based on relevance and sufficiency of data")
|
| 10 |
+
answer: str = Field(description="Detailed answer or analysis regarding the user query")
|
| 11 |
+
citations: List[str] = Field(description="Source files, line numbers, or endpoints cited as reference")
|
| 12 |
+
reasoning: List[str] = Field(description="Step-by-step reasoning steps the agent took")
|
| 13 |
+
|
| 14 |
+
class BaseAgent(ABC):
|
| 15 |
+
def __init__(self, llm_client: LLMClient):
|
| 16 |
+
self.llm_client = llm_client
|
| 17 |
+
|
| 18 |
+
@abstractmethod
|
| 19 |
+
async def run(
|
| 20 |
+
self,
|
| 21 |
+
profile: Dict[str, Any],
|
| 22 |
+
graph: Dict[str, Any],
|
| 23 |
+
summary: Dict[str, Any],
|
| 24 |
+
report: str,
|
| 25 |
+
query: str
|
| 26 |
+
) -> Dict[str, Any]:
|
| 27 |
+
"""Runs the agent's analysis."""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
async def _call_llm_json(self, prompt: str, schema: Any, temperature: float = 0.2) -> Dict[str, Any]:
|
| 31 |
+
return await asyncio.to_thread(self.llm_client.generate_json, prompt, schema, temperature)
|
backend/agents/dependency_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.base_agent import BaseAgent, AgentResponseSchema
|
| 4 |
+
|
| 5 |
+
class DependencyAgent(BaseAgent):
|
| 6 |
+
async def run(
|
| 7 |
+
self,
|
| 8 |
+
profile: Dict[str, Any],
|
| 9 |
+
graph: Dict[str, Any],
|
| 10 |
+
summary: Dict[str, Any],
|
| 11 |
+
report: str,
|
| 12 |
+
query: str
|
| 13 |
+
) -> Dict[str, Any]:
|
| 14 |
+
prompt = f"""
|
| 15 |
+
You are the Dependency Agent. Your responsibility is to analyze libraries, package configurations (e.g. package.json, requirements.txt), framework selections, database integrations, cloud stack, and deployment environment setups.
|
| 16 |
+
|
| 17 |
+
Here is the repository context:
|
| 18 |
+
1. Profile:
|
| 19 |
+
{json.dumps(profile, indent=2)}
|
| 20 |
+
2. Graph Structure:
|
| 21 |
+
{json.dumps(graph, indent=2)}
|
| 22 |
+
3. Summary:
|
| 23 |
+
{json.dumps(summary, indent=2)}
|
| 24 |
+
4. Intelligence Report:
|
| 25 |
+
{report}
|
| 26 |
+
|
| 27 |
+
User Query:
|
| 28 |
+
{query}
|
| 29 |
+
|
| 30 |
+
Perform a rigorous analysis of project dependencies and frameworks to answer this query. Your citations must reference the package manifest files or configurations (e.g., "requirements.txt:L3", "docker-compose.yml:L5").
|
| 31 |
+
Return your structured answer matching the AgentResponseSchema.
|
| 32 |
+
"""
|
| 33 |
+
return await self._call_llm_json(prompt, AgentResponseSchema)
|
backend/agents/llm_client.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Dict, Any, Type
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
class LLMClient(ABC):
|
| 6 |
+
@abstractmethod
|
| 7 |
+
def generate_json(
|
| 8 |
+
self,
|
| 9 |
+
prompt: str,
|
| 10 |
+
response_schema: Type[BaseModel],
|
| 11 |
+
temperature: float = 0.2
|
| 12 |
+
) -> Dict[str, Any]:
|
| 13 |
+
"""Generates a structured JSON response matching the given response_schema."""
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
class GeminiLLMClient(LLMClient):
|
| 17 |
+
def __init__(self, api_key: str):
|
| 18 |
+
from google import genai
|
| 19 |
+
self._client = genai.Client(api_key=api_key)
|
| 20 |
+
|
| 21 |
+
def generate_json(
|
| 22 |
+
self,
|
| 23 |
+
prompt: str,
|
| 24 |
+
response_schema: Type[BaseModel],
|
| 25 |
+
temperature: float = 0.2
|
| 26 |
+
) -> Dict[str, Any]:
|
| 27 |
+
import json
|
| 28 |
+
|
| 29 |
+
response = self._client.models.generate_content(
|
| 30 |
+
model='gemini-2.5-flash',
|
| 31 |
+
contents=prompt,
|
| 32 |
+
config={
|
| 33 |
+
'response_mime_type': 'application/json',
|
| 34 |
+
'response_schema': response_schema,
|
| 35 |
+
'temperature': temperature
|
| 36 |
+
}
|
| 37 |
+
)
|
| 38 |
+
if not response.text:
|
| 39 |
+
raise ValueError("Gemini returned empty response text.")
|
| 40 |
+
return json.loads(response.text)
|
backend/agents/onboarding_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.base_agent import BaseAgent, AgentResponseSchema
|
| 4 |
+
|
| 5 |
+
class OnboardingAgent(BaseAgent):
|
| 6 |
+
async def run(
|
| 7 |
+
self,
|
| 8 |
+
profile: Dict[str, Any],
|
| 9 |
+
graph: Dict[str, Any],
|
| 10 |
+
summary: Dict[str, Any],
|
| 11 |
+
report: str,
|
| 12 |
+
query: str
|
| 13 |
+
) -> Dict[str, Any]:
|
| 14 |
+
prompt = f"""
|
| 15 |
+
You are the Onboarding Agent. Your responsibility is to guide new developers in understanding the project execution flow, configuring local developer environments, locating entry point scripts, outlining recommended learning paths, and listing the key folders/files to read first.
|
| 16 |
+
|
| 17 |
+
Here is the repository context:
|
| 18 |
+
1. Profile:
|
| 19 |
+
{json.dumps(profile, indent=2)}
|
| 20 |
+
2. Graph Structure:
|
| 21 |
+
{json.dumps(graph, indent=2)}
|
| 22 |
+
3. Summary:
|
| 23 |
+
{json.dumps(summary, indent=2)}
|
| 24 |
+
4. Intelligence Report:
|
| 25 |
+
{report}
|
| 26 |
+
|
| 27 |
+
User Query:
|
| 28 |
+
{query}
|
| 29 |
+
|
| 30 |
+
Perform a rigorous developer-focused onboarding walkthrough to answer this query. Your citations must point out documentation, entry files, or configuration variables that speed up developer setup.
|
| 31 |
+
Return your structured answer matching the AgentResponseSchema.
|
| 32 |
+
"""
|
| 33 |
+
return await self._call_llm_json(prompt, AgentResponseSchema)
|
backend/agents/orchestrator.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import asyncio
|
| 3 |
+
import logging
|
| 4 |
+
import json
|
| 5 |
+
from typing import Dict, Any, List, Optional
|
| 6 |
+
from agents.llm_client import LLMClient
|
| 7 |
+
from agents.planner_agent import PlannerAgent
|
| 8 |
+
from agents.architecture_agent import ArchitectureAgent
|
| 9 |
+
from agents.security_agent import SecurityAgent
|
| 10 |
+
from agents.api_agent import ApiAgent
|
| 11 |
+
from agents.dependency_agent import DependencyAgent
|
| 12 |
+
from agents.quality_agent import QualityAgent
|
| 13 |
+
from agents.onboarding_agent import OnboardingAgent
|
| 14 |
+
from agents.response_synthesizer import ResponseSynthesizer
|
| 15 |
+
from tools.tool_registry import tool_registry, setup_default_registry
|
| 16 |
+
from memory.memory_cache import memory_cache
|
| 17 |
+
from memory.conversation_manager import conversation_manager
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("orchestrator")
|
| 20 |
+
|
| 21 |
+
class AgentOrchestrator:
|
| 22 |
+
def __init__(self, llm_client: LLMClient):
|
| 23 |
+
self.llm_client = llm_client
|
| 24 |
+
self.planner = PlannerAgent(llm_client)
|
| 25 |
+
self.synthesizer = ResponseSynthesizer(llm_client)
|
| 26 |
+
|
| 27 |
+
# Register specialized agents
|
| 28 |
+
self.agents = {
|
| 29 |
+
"ArchitectureAgent": ArchitectureAgent(llm_client),
|
| 30 |
+
"SecurityAgent": SecurityAgent(llm_client),
|
| 31 |
+
"ApiAgent": ApiAgent(llm_client),
|
| 32 |
+
"DependencyAgent": DependencyAgent(llm_client),
|
| 33 |
+
"QualityAgent": QualityAgent(llm_client),
|
| 34 |
+
"OnboardingAgent": OnboardingAgent(llm_client)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async def execute(
|
| 38 |
+
self,
|
| 39 |
+
profile: Dict[str, Any],
|
| 40 |
+
graph: Dict[str, Any],
|
| 41 |
+
summary: Dict[str, Any],
|
| 42 |
+
report: str,
|
| 43 |
+
query: str,
|
| 44 |
+
repo_id: str,
|
| 45 |
+
session_id: Optional[str] = None,
|
| 46 |
+
vector_store: Optional[Any] = None,
|
| 47 |
+
retriever: Optional[Any] = None
|
| 48 |
+
) -> Dict[str, Any]:
|
| 49 |
+
timeline = []
|
| 50 |
+
agents_used = []
|
| 51 |
+
start_total = time.time()
|
| 52 |
+
|
| 53 |
+
# Initialize tools registry if empty and vector dependencies are available
|
| 54 |
+
if not tool_registry.list_tools() and vector_store and retriever:
|
| 55 |
+
setup_default_registry(vector_store, retriever)
|
| 56 |
+
|
| 57 |
+
# 1. Run Planner Agent to decide execution steps
|
| 58 |
+
logger.info(f"Running PlannerAgent for query: '{query}'")
|
| 59 |
+
planner_start = time.time()
|
| 60 |
+
try:
|
| 61 |
+
planner_res = await self.planner.run(profile, graph, summary, report, query)
|
| 62 |
+
planner_latency = time.time() - planner_start
|
| 63 |
+
logger.info(f"PlannerAgent completed in {planner_latency:.2f}s. Plan: {planner_res}")
|
| 64 |
+
timeline.append({
|
| 65 |
+
"agent": "PlannerAgent",
|
| 66 |
+
"execution_time_ms": int(planner_latency * 1000),
|
| 67 |
+
"status": "success",
|
| 68 |
+
"message": f"Pipeline: memory={planner_res.get('retrieve_memory')}, search={planner_res.get('run_semantic_search')}, tools={planner_res.get('invoke_tools')}, agents={planner_res.get('selected_agents')}"
|
| 69 |
+
})
|
| 70 |
+
except Exception as e:
|
| 71 |
+
planner_latency = time.time() - planner_start
|
| 72 |
+
logger.error(f"PlannerAgent failed: {e}")
|
| 73 |
+
timeline.append({
|
| 74 |
+
"agent": "PlannerAgent",
|
| 75 |
+
"execution_time_ms": int(planner_latency * 1000),
|
| 76 |
+
"status": "failure",
|
| 77 |
+
"message": f"Planner failed: {str(e)}"
|
| 78 |
+
})
|
| 79 |
+
# Fallback plan: enable all pipeline elements and run all agents
|
| 80 |
+
planner_res = {
|
| 81 |
+
"retrieve_memory": True,
|
| 82 |
+
"run_semantic_search": True,
|
| 83 |
+
"invoke_tools": [],
|
| 84 |
+
"run_agents": True,
|
| 85 |
+
"selected_agents": list(self.agents.keys()),
|
| 86 |
+
"execution_order": [list(self.agents.keys())],
|
| 87 |
+
"synthesize_final_answer": True,
|
| 88 |
+
"reasoning": "Fallback plan due to planner failure."
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
retrieve_memory = planner_res.get("retrieve_memory", True)
|
| 92 |
+
run_semantic_search = planner_res.get("run_semantic_search", True)
|
| 93 |
+
invoke_tools = planner_res.get("invoke_tools", [])
|
| 94 |
+
run_agents = planner_res.get("run_agents", True)
|
| 95 |
+
selected_agents = planner_res.get("selected_agents", [])
|
| 96 |
+
execution_order = planner_res.get("execution_order", [])
|
| 97 |
+
synthesize_final_answer = planner_res.get("synthesize_final_answer", True)
|
| 98 |
+
|
| 99 |
+
# 2. Retrieve Memory if requested
|
| 100 |
+
memory_context = ""
|
| 101 |
+
if retrieve_memory and session_id:
|
| 102 |
+
logger.info(f"Retrieving conversation memory for session {session_id}")
|
| 103 |
+
mem_start = time.time()
|
| 104 |
+
session = conversation_manager.get_session(session_id)
|
| 105 |
+
if session and session.history:
|
| 106 |
+
past_turns = []
|
| 107 |
+
for msg in session.history[:-1]: # Exclude current question which was already added
|
| 108 |
+
past_turns.append(f"{msg.role.capitalize()}: {msg.content}")
|
| 109 |
+
memory_context = "\n".join(past_turns)
|
| 110 |
+
timeline.append({
|
| 111 |
+
"agent": "MemoryRetrieval",
|
| 112 |
+
"execution_time_ms": int((time.time() - mem_start) * 1000),
|
| 113 |
+
"status": "success",
|
| 114 |
+
"message": "Retrieved past turns" if memory_context else "No past turns found"
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
# 3. Run Semantic Search if requested
|
| 118 |
+
search_results = []
|
| 119 |
+
if run_semantic_search and retriever:
|
| 120 |
+
logger.info("Running semantic search retrieval")
|
| 121 |
+
search_start = time.time()
|
| 122 |
+
try:
|
| 123 |
+
search_results = retriever.retrieve(repo_id=repo_id, query=query, top_k=5)
|
| 124 |
+
timeline.append({
|
| 125 |
+
"agent": "SemanticSearch",
|
| 126 |
+
"execution_time_ms": int((time.time() - search_start) * 1000),
|
| 127 |
+
"status": "success",
|
| 128 |
+
"message": f"Found {len(search_results)} relevant chunks"
|
| 129 |
+
})
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Semantic search failed: {e}")
|
| 132 |
+
timeline.append({
|
| 133 |
+
"agent": "SemanticSearch",
|
| 134 |
+
"execution_time_ms": int((time.time() - search_start) * 1000),
|
| 135 |
+
"status": "failure",
|
| 136 |
+
"message": str(e)
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
# 4. Invoke Tools (with caching) if requested
|
| 140 |
+
tool_outputs = {}
|
| 141 |
+
if invoke_tools:
|
| 142 |
+
logger.info(f"Invoking tools: {invoke_tools}")
|
| 143 |
+
tool_start = time.time()
|
| 144 |
+
for tool_name in invoke_tools:
|
| 145 |
+
# Check memory cache first
|
| 146 |
+
cache_key = f"tool:{repo_id}:{tool_name}:{query}"
|
| 147 |
+
cached_res = memory_cache.get(cache_key)
|
| 148 |
+
if cached_res is not None:
|
| 149 |
+
logger.info(f"Cache hit for tool '{tool_name}'")
|
| 150 |
+
tool_outputs[tool_name] = cached_res
|
| 151 |
+
else:
|
| 152 |
+
logger.info(f"Cache miss for tool '{tool_name}'. Executing...")
|
| 153 |
+
res = tool_registry.execute_tool(tool_name, repo_id=repo_id, query=query)
|
| 154 |
+
memory_cache.set(cache_key, res, ttl=300) # Cache for 5 mins
|
| 155 |
+
tool_outputs[tool_name] = res
|
| 156 |
+
timeline.append({
|
| 157 |
+
"agent": "ToolInvocation",
|
| 158 |
+
"execution_time_ms": int((time.time() - tool_start) * 1000),
|
| 159 |
+
"status": "success",
|
| 160 |
+
"message": f"Executed {len(tool_outputs)} tools"
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
# 5. Run Specialized Agents if requested
|
| 164 |
+
agent_responses = []
|
| 165 |
+
if run_agents and selected_agents:
|
| 166 |
+
# Construct augmented query containing retrieved context and tool outputs
|
| 167 |
+
augmented_context_parts = []
|
| 168 |
+
if memory_context:
|
| 169 |
+
augmented_context_parts.append(f"--- Conversation History ---\n{memory_context}")
|
| 170 |
+
if search_results:
|
| 171 |
+
search_text = "\n\n".join(
|
| 172 |
+
f"[Relevant Code Chunk | similarity={r['similarity']:.2f}]\n{r['content']}"
|
| 173 |
+
for r in search_results
|
| 174 |
+
)
|
| 175 |
+
augmented_context_parts.append(f"--- Codebase Semantic Search Results ---\n{search_text}")
|
| 176 |
+
if tool_outputs:
|
| 177 |
+
tools_text = json.dumps(tool_outputs, indent=2)
|
| 178 |
+
augmented_context_parts.append(f"--- Direct Tool Outputs ---\n{tools_text}")
|
| 179 |
+
|
| 180 |
+
augmented_query = query
|
| 181 |
+
if augmented_context_parts:
|
| 182 |
+
augmented_query = f"{query}\n\n" + "\n\n".join(augmented_context_parts)
|
| 183 |
+
|
| 184 |
+
for stage_idx, stage in enumerate(execution_order):
|
| 185 |
+
tasks = []
|
| 186 |
+
agent_names = []
|
| 187 |
+
|
| 188 |
+
for agent_name in stage:
|
| 189 |
+
if agent_name in self.agents and agent_name in selected_agents:
|
| 190 |
+
agent_names.append(agent_name)
|
| 191 |
+
tasks.append(self._run_agent_with_retry(agent_name, profile, graph, summary, report, augmented_query))
|
| 192 |
+
|
| 193 |
+
if not tasks:
|
| 194 |
+
continue
|
| 195 |
+
|
| 196 |
+
logger.info(f"Executing Stage {stage_idx + 1} with agents in parallel: {agent_names}")
|
| 197 |
+
stage_results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 198 |
+
|
| 199 |
+
for agent_name, result in zip(agent_names, stage_results):
|
| 200 |
+
agents_used.append(agent_name)
|
| 201 |
+
|
| 202 |
+
if isinstance(result, Exception):
|
| 203 |
+
logger.error(f"Agent {agent_name} failed execution: {result}")
|
| 204 |
+
timeline.append({
|
| 205 |
+
"agent": agent_name,
|
| 206 |
+
"execution_time_ms": 0,
|
| 207 |
+
"status": "failure",
|
| 208 |
+
"message": str(result),
|
| 209 |
+
"confidence": 0.0
|
| 210 |
+
})
|
| 211 |
+
else:
|
| 212 |
+
agent_responses.append(result["response"])
|
| 213 |
+
timeline.append({
|
| 214 |
+
"agent": agent_name,
|
| 215 |
+
"execution_time_ms": result["latency_ms"],
|
| 216 |
+
"status": "success",
|
| 217 |
+
"confidence": result["response"].get("confidence", 0.0),
|
| 218 |
+
"answer": result["response"].get("answer", "")
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
# 6. Run Response Synthesizer if requested
|
| 222 |
+
synth_res = {}
|
| 223 |
+
if synthesize_final_answer:
|
| 224 |
+
logger.info("Running ResponseSynthesizer...")
|
| 225 |
+
synth_start = time.time()
|
| 226 |
+
try:
|
| 227 |
+
synth_res = await self.synthesizer.synthesize(
|
| 228 |
+
query=query,
|
| 229 |
+
agent_responses=agent_responses,
|
| 230 |
+
memory_context=memory_context,
|
| 231 |
+
search_results=search_results,
|
| 232 |
+
tool_outputs=tool_outputs
|
| 233 |
+
)
|
| 234 |
+
synth_latency = time.time() - synth_start
|
| 235 |
+
logger.info(f"ResponseSynthesizer completed in {synth_latency:.2f}s")
|
| 236 |
+
timeline.append({
|
| 237 |
+
"agent": "ResponseSynthesizer",
|
| 238 |
+
"execution_time_ms": int(synth_latency * 1000),
|
| 239 |
+
"status": "success"
|
| 240 |
+
})
|
| 241 |
+
except Exception as e:
|
| 242 |
+
synth_latency = time.time() - synth_start
|
| 243 |
+
logger.error(f"ResponseSynthesizer failed: {e}")
|
| 244 |
+
timeline.append({
|
| 245 |
+
"agent": "ResponseSynthesizer",
|
| 246 |
+
"execution_time_ms": int(synth_latency * 1000),
|
| 247 |
+
"status": "failure",
|
| 248 |
+
"message": str(e)
|
| 249 |
+
})
|
| 250 |
+
# Fallback synthesis
|
| 251 |
+
fallback_answer = "\n\n".join([f"### {r.get('agent')}\n{r.get('answer')}" for r in agent_responses])
|
| 252 |
+
synth_res = {
|
| 253 |
+
"summary": "Fallback summary compiled from individual agents.",
|
| 254 |
+
"detailed_explanation": fallback_answer,
|
| 255 |
+
"agent_contributions": [f"{r.get('agent')} (direct contribution)" for r in agent_responses],
|
| 256 |
+
"confidence_score": 0.5
|
| 257 |
+
}
|
| 258 |
+
else:
|
| 259 |
+
# Synthesis bypassed, compile direct report from tools/search
|
| 260 |
+
logger.info("Bypassing ResponseSynthesizer per Planner decision")
|
| 261 |
+
direct_parts = []
|
| 262 |
+
if tool_outputs:
|
| 263 |
+
direct_parts.append("### Direct Tool Outputs")
|
| 264 |
+
for t_name, val in tool_outputs.items():
|
| 265 |
+
direct_parts.append(f"**{t_name}**:\n```json\n{json.dumps(val, indent=2)}\n```")
|
| 266 |
+
if search_results:
|
| 267 |
+
direct_parts.append("### Codebase Search Results")
|
| 268 |
+
for r in search_results:
|
| 269 |
+
direct_parts.append(f"- **{r['metadata'].get('path', 'unknown')}** (similarity={r['similarity']:.2f}):\n{r['content']}")
|
| 270 |
+
|
| 271 |
+
detailed_explanation = "\n\n".join(direct_parts) if direct_parts else "No tools or search results were requested, and LLM synthesis was bypassed."
|
| 272 |
+
synth_res = {
|
| 273 |
+
"summary": "Direct result compiled from tools/search.",
|
| 274 |
+
"detailed_explanation": detailed_explanation,
|
| 275 |
+
"agent_contributions": ["Direct tool output execution."],
|
| 276 |
+
"confidence_score": 1.0
|
| 277 |
+
}
|
| 278 |
+
timeline.append({
|
| 279 |
+
"agent": "ResponseSynthesizer",
|
| 280 |
+
"execution_time_ms": 0,
|
| 281 |
+
"status": "success",
|
| 282 |
+
"message": "Bypassed synthesizer"
|
| 283 |
+
})
|
| 284 |
+
|
| 285 |
+
total_time_ms = int((time.time() - start_total) * 1000)
|
| 286 |
+
|
| 287 |
+
# Merge citations from all agent responses and search results
|
| 288 |
+
references = []
|
| 289 |
+
for r in agent_responses:
|
| 290 |
+
references.extend(r.get("citations", []))
|
| 291 |
+
for r in search_results:
|
| 292 |
+
path = r["metadata"].get("path")
|
| 293 |
+
if path and path not in references:
|
| 294 |
+
references.append(path)
|
| 295 |
+
|
| 296 |
+
unique_references = []
|
| 297 |
+
for ref in references:
|
| 298 |
+
if ref not in unique_references:
|
| 299 |
+
unique_references.append(ref)
|
| 300 |
+
|
| 301 |
+
answer = synth_res.get("detailed_explanation", "") or ""
|
| 302 |
+
if not answer:
|
| 303 |
+
logger.warning("Synthesized response was empty. Falling back to agent answers or summary.")
|
| 304 |
+
if agent_responses:
|
| 305 |
+
fallback_answer = "\n\n".join(
|
| 306 |
+
f"### {r.get('agent', 'UnnamedAgent')}\n{r.get('answer', '') or 'No answer generated.'}"
|
| 307 |
+
for r in agent_responses
|
| 308 |
+
).strip()
|
| 309 |
+
answer = fallback_answer or synth_res.get("summary", "")
|
| 310 |
+
else:
|
| 311 |
+
answer = synth_res.get("summary", "No answer could be generated from the agents.")
|
| 312 |
+
|
| 313 |
+
return {
|
| 314 |
+
"answer": answer,
|
| 315 |
+
"summary": synth_res.get("summary", ""),
|
| 316 |
+
"agents_used": agents_used,
|
| 317 |
+
"confidence": synth_res.get("confidence_score", 0.0),
|
| 318 |
+
"references": unique_references,
|
| 319 |
+
"agent_contributions": synth_res.get("agent_contributions", []),
|
| 320 |
+
"planner_decision": planner_res,
|
| 321 |
+
"timeline": timeline,
|
| 322 |
+
"total_time_ms": total_time_ms,
|
| 323 |
+
"retrieved_context": search_results
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
async def _run_agent_with_retry(
|
| 327 |
+
self,
|
| 328 |
+
agent_name: str,
|
| 329 |
+
profile: Dict[str, Any],
|
| 330 |
+
graph: Dict[str, Any],
|
| 331 |
+
summary: Dict[str, Any],
|
| 332 |
+
report: str,
|
| 333 |
+
query: str,
|
| 334 |
+
retries: int = 2
|
| 335 |
+
) -> Dict[str, Any]:
|
| 336 |
+
agent = self.agents[agent_name]
|
| 337 |
+
|
| 338 |
+
for attempt in range(retries + 1):
|
| 339 |
+
start = time.time()
|
| 340 |
+
try:
|
| 341 |
+
response = await agent.run(profile, graph, summary, report, query)
|
| 342 |
+
latency_ms = int((time.time() - start) * 1000)
|
| 343 |
+
return {
|
| 344 |
+
"response": response,
|
| 345 |
+
"latency_ms": latency_ms
|
| 346 |
+
}
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.warning(f"Agent {agent_name} attempt {attempt + 1} failed: {e}")
|
| 349 |
+
if attempt == retries:
|
| 350 |
+
raise e
|
| 351 |
+
await asyncio.sleep(0.5)
|
backend/agents/planner_agent.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from agents.base_agent import BaseAgent
|
| 5 |
+
|
| 6 |
+
class PlannerDecision(BaseModel):
|
| 7 |
+
retrieve_memory: bool = Field(
|
| 8 |
+
description="Whether to retrieve past conversation history or context for the current query."
|
| 9 |
+
)
|
| 10 |
+
run_semantic_search: bool = Field(
|
| 11 |
+
description="Whether to query the vector database for matching chunks from the codebase."
|
| 12 |
+
)
|
| 13 |
+
invoke_tools: List[str] = Field(
|
| 14 |
+
description="List of tool names to invoke, from: ['repository_search', 'graph_query', 'dependency_lookup', 'file_reader', 'architecture_lookup', 'api_lookup']."
|
| 15 |
+
)
|
| 16 |
+
run_agents: bool = Field(
|
| 17 |
+
description="Whether specialized agents should be executed."
|
| 18 |
+
)
|
| 19 |
+
selected_agents: List[str] = Field(
|
| 20 |
+
description="List of agent names selected to run if run_agents is true, from: ['ArchitectureAgent', 'SecurityAgent', 'ApiAgent', 'DependencyAgent', 'QualityAgent', 'OnboardingAgent']."
|
| 21 |
+
)
|
| 22 |
+
execution_order: List[List[str]] = Field(
|
| 23 |
+
description="Execution order for agents (e.g., [['SecurityAgent', 'ApiAgent'], ['ArchitectureAgent']]) if run_agents is true."
|
| 24 |
+
)
|
| 25 |
+
synthesize_final_answer: bool = Field(
|
| 26 |
+
description="Whether the response synthesizer should combine everything into the final answer. Set to True unless a direct tool/search result is sufficient."
|
| 27 |
+
)
|
| 28 |
+
reasoning: str = Field(
|
| 29 |
+
description="Explanation of why these pipeline decisions and agent executions were chosen."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
class PlannerAgent(BaseAgent):
|
| 33 |
+
async def run(
|
| 34 |
+
self,
|
| 35 |
+
profile: Dict[str, Any],
|
| 36 |
+
graph: Dict[str, Any],
|
| 37 |
+
summary: Dict[str, Any],
|
| 38 |
+
report: str,
|
| 39 |
+
query: str
|
| 40 |
+
) -> Dict[str, Any]:
|
| 41 |
+
prompt = f"""
|
| 42 |
+
You are the Agent Orchestration Planner. Your task is to analyze the user's query and decide the best execution pipeline to resolve it.
|
| 43 |
+
|
| 44 |
+
The execution pipeline consists of:
|
| 45 |
+
1. retrieve_memory: Fetching past chat history/turns.
|
| 46 |
+
2. run_semantic_search: Finding relevant code snippets via vector embeddings.
|
| 47 |
+
3. invoke_tools: Executing direct lookup/search tools.
|
| 48 |
+
4. run_agents: Launching specialized agents in parallel or sequential stages.
|
| 49 |
+
5. synthesize_final_answer: Combining all info into a clean final markdown explanation.
|
| 50 |
+
|
| 51 |
+
Available Tools:
|
| 52 |
+
- repository_search: Semantic search over indexed repo chunks.
|
| 53 |
+
- graph_query: Query dependency graph, business flows, entry points.
|
| 54 |
+
- dependency_lookup: Look up project languages, frameworks, packages.
|
| 55 |
+
- file_reader: Retrieve specific source code file content.
|
| 56 |
+
- architecture_lookup: Query high-level architecture pattern and major folders.
|
| 57 |
+
- api_lookup: Look up HTTP endpoints, authentication details.
|
| 58 |
+
|
| 59 |
+
Available Agents:
|
| 60 |
+
1. ArchitectureAgent: Focuses on architectural patterns, component interaction, data flow, component responsibilities, and business flows.
|
| 61 |
+
2. SecurityAgent: Focuses on authentication, authorization, API keys, secrets, security risks, vulnerability findings, and unsafe practices.
|
| 62 |
+
3. ApiAgent: Focuses on endpoints, routes, HTTP methods, request/response models, and external APIs.
|
| 63 |
+
4. DependencyAgent: Focuses on libraries, frameworks, cloud stack, dependencies, and infrastructure setup.
|
| 64 |
+
5. QualityAgent: Focuses on complexity, maintainability, dead code, refactoring suggestions, and testing hints.
|
| 65 |
+
6. OnboardingAgent: Focuses on developer onboarding walkthrough, where to start reading, and execution setup.
|
| 66 |
+
|
| 67 |
+
Repository Profile:
|
| 68 |
+
{json.dumps(profile, indent=2)}
|
| 69 |
+
|
| 70 |
+
Repository Summary:
|
| 71 |
+
{json.dumps(summary, indent=2)}
|
| 72 |
+
|
| 73 |
+
User Query:
|
| 74 |
+
{query}
|
| 75 |
+
|
| 76 |
+
Determine:
|
| 77 |
+
1. Which pipeline components should run (retrieve_memory, run_semantic_search, invoke_tools, run_agents, synthesize_final_answer).
|
| 78 |
+
2. Which agents and tools are needed and their execution plan.
|
| 79 |
+
3. The reasoning behind your plan.
|
| 80 |
+
|
| 81 |
+
Return your decision in structured JSON format matching the schema.
|
| 82 |
+
"""
|
| 83 |
+
return await self._call_llm_json(prompt, PlannerDecision, temperature=0.1)
|
backend/agents/quality_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.base_agent import BaseAgent, AgentResponseSchema
|
| 4 |
+
|
| 5 |
+
class QualityAgent(BaseAgent):
|
| 6 |
+
async def run(
|
| 7 |
+
self,
|
| 8 |
+
profile: Dict[str, Any],
|
| 9 |
+
graph: Dict[str, Any],
|
| 10 |
+
summary: Dict[str, Any],
|
| 11 |
+
report: str,
|
| 12 |
+
query: str
|
| 13 |
+
) -> Dict[str, Any]:
|
| 14 |
+
prompt = f"""
|
| 15 |
+
You are the Quality Agent. Your responsibility is to analyze the codebase for code quality, maintainability, architectural complexity, indicators of dead code, large/bloated modules, potential code smells, and testing/coverage strategies.
|
| 16 |
+
|
| 17 |
+
Here is the repository context:
|
| 18 |
+
1. Profile:
|
| 19 |
+
{json.dumps(profile, indent=2)}
|
| 20 |
+
2. Graph Structure:
|
| 21 |
+
{json.dumps(graph, indent=2)}
|
| 22 |
+
3. Summary:
|
| 23 |
+
{json.dumps(summary, indent=2)}
|
| 24 |
+
4. Intelligence Report:
|
| 25 |
+
{report}
|
| 26 |
+
|
| 27 |
+
User Query:
|
| 28 |
+
{query}
|
| 29 |
+
|
| 30 |
+
Perform a rigorous analysis of the code quality and maintainability indicators to answer this query. Your citations must specify modules or files containing smells, complex blocks, or missing test configurations.
|
| 31 |
+
Return your structured answer matching the AgentResponseSchema.
|
| 32 |
+
"""
|
| 33 |
+
return await self._call_llm_json(prompt, AgentResponseSchema)
|
backend/agents/response_synthesizer.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any, List, Optional
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from agents.llm_client import LLMClient
|
| 5 |
+
|
| 6 |
+
class SynthesizedResponse(BaseModel):
|
| 7 |
+
summary: str = Field(description="A concise summary of all findings across agents")
|
| 8 |
+
detailed_explanation: str = Field(description="A comprehensive, detailed markdown explanation combining all insights, resolving duplicates, and directly answering the user query")
|
| 9 |
+
agent_contributions: List[str] = Field(description="List of strings explaining what each agent contributed (e.g. 'SecurityAgent: identified lack of rate limiting on the login route.')")
|
| 10 |
+
confidence_score: float = Field(description="A combined confidence score representing the quality and consensus of the generated answer (0.0 to 1.0)")
|
| 11 |
+
|
| 12 |
+
class ResponseSynthesizer:
|
| 13 |
+
def __init__(self, llm_client: LLMClient):
|
| 14 |
+
self.llm_client = llm_client
|
| 15 |
+
|
| 16 |
+
async def synthesize(
|
| 17 |
+
self,
|
| 18 |
+
query: str,
|
| 19 |
+
agent_responses: List[Dict[str, Any]],
|
| 20 |
+
memory_context: Optional[str] = None,
|
| 21 |
+
search_results: Optional[List[Dict[str, Any]]] = None,
|
| 22 |
+
tool_outputs: Optional[Dict[str, Any]] = None
|
| 23 |
+
) -> Dict[str, Any]:
|
| 24 |
+
import asyncio
|
| 25 |
+
responses_str = json.dumps(agent_responses, indent=2)
|
| 26 |
+
|
| 27 |
+
context_parts = []
|
| 28 |
+
if memory_context:
|
| 29 |
+
context_parts.append(f"--- Past Conversation turns ---\n{memory_context}")
|
| 30 |
+
if search_results:
|
| 31 |
+
search_text = "\n\n".join(
|
| 32 |
+
f"[Search similarity={r['similarity']:.2f}]\n{r['content']}"
|
| 33 |
+
for r in search_results
|
| 34 |
+
)
|
| 35 |
+
context_parts.append(f"--- Semantic Search Context ---\n{search_text}")
|
| 36 |
+
if tool_outputs:
|
| 37 |
+
tools_text = json.dumps(tool_outputs, indent=2)
|
| 38 |
+
context_parts.append(f"--- Direct Tool Outputs ---\n{tools_text}")
|
| 39 |
+
|
| 40 |
+
extra_context = "\n\n".join(context_parts)
|
| 41 |
+
|
| 42 |
+
prompt = f"""
|
| 43 |
+
You are the Principal Systems Integrator and Response Synthesizer.
|
| 44 |
+
Your goal is to digest all specialized agent reports, retrieve context, tool outputs, and compile them into a single, cohesive, premium response answering the user's query.
|
| 45 |
+
|
| 46 |
+
User Query:
|
| 47 |
+
{query}
|
| 48 |
+
|
| 49 |
+
{extra_context}
|
| 50 |
+
|
| 51 |
+
Specialized Agent Responses:
|
| 52 |
+
{responses_str}
|
| 53 |
+
|
| 54 |
+
Tasks:
|
| 55 |
+
1. Synthesize a single coherent, authoritative detailed explanation in markdown.
|
| 56 |
+
2. Merge duplicate findings.
|
| 57 |
+
3. Call out specific contributions or viewpoints of the agents/tools.
|
| 58 |
+
4. Calculate an overall confidence score based on individual agent confidence levels, tool relevance, and consensus.
|
| 59 |
+
5. Create a concise summary.
|
| 60 |
+
|
| 61 |
+
Return the result matching the schema in structured JSON format.
|
| 62 |
+
"""
|
| 63 |
+
return await asyncio.to_thread(self.llm_client.generate_json, prompt, SynthesizedResponse, 0.2)
|
backend/agents/security_agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from agents.base_agent import BaseAgent, AgentResponseSchema
|
| 4 |
+
|
| 5 |
+
class SecurityAgent(BaseAgent):
|
| 6 |
+
async def run(
|
| 7 |
+
self,
|
| 8 |
+
profile: Dict[str, Any],
|
| 9 |
+
graph: Dict[str, Any],
|
| 10 |
+
summary: Dict[str, Any],
|
| 11 |
+
report: str,
|
| 12 |
+
query: str
|
| 13 |
+
) -> Dict[str, Any]:
|
| 14 |
+
prompt = f"""
|
| 15 |
+
You are the Security Agent. Your responsibility is to analyze the codebase for authentication methods, authorization mechanisms, handling of secrets/API keys, safety vulnerabilities, unsafe coding practices, and package dependencies that pose risks.
|
| 16 |
+
|
| 17 |
+
Here is the repository context:
|
| 18 |
+
1. Profile:
|
| 19 |
+
{json.dumps(profile, indent=2)}
|
| 20 |
+
2. Graph Structure:
|
| 21 |
+
{json.dumps(graph, indent=2)}
|
| 22 |
+
3. Summary:
|
| 23 |
+
{json.dumps(summary, indent=2)}
|
| 24 |
+
4. Intelligence Report:
|
| 25 |
+
{report}
|
| 26 |
+
|
| 27 |
+
User Query:
|
| 28 |
+
{query}
|
| 29 |
+
|
| 30 |
+
Perform a rigorous security analysis to answer this query. Your citations must specify files, lines, or configurations where security measures are implemented, missing, or potentially vulnerable.
|
| 31 |
+
Return your structured answer matching the AgentResponseSchema.
|
| 32 |
+
"""
|
| 33 |
+
return await self._call_llm_json(prompt, AgentResponseSchema)
|
backend/main.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
import tempfile
|
| 5 |
+
import uuid
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Header, status
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import FileResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from starlette.background import BackgroundTask
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
|
| 15 |
+
# Load .env variables manually if the file is present
|
| 16 |
+
env_path = os.path.join(os.path.dirname(__file__), ".env")
|
| 17 |
+
if os.path.exists(env_path):
|
| 18 |
+
with open(env_path, "r", encoding="utf-8") as f:
|
| 19 |
+
for line in f:
|
| 20 |
+
line = line.strip()
|
| 21 |
+
if line and not line.startswith("#") and "=" in line:
|
| 22 |
+
key, val = line.split("=", 1)
|
| 23 |
+
os.environ[key.strip()] = val.strip()
|
| 24 |
+
|
| 25 |
+
# Import services
|
| 26 |
+
from services.repositoryScanner import (
|
| 27 |
+
check_repository_privacy,
|
| 28 |
+
clone_repository,
|
| 29 |
+
extract_zip,
|
| 30 |
+
scan_directory,
|
| 31 |
+
handle_remove_readonly
|
| 32 |
+
)
|
| 33 |
+
from services.repositoryProfiler import profile_repository
|
| 34 |
+
from services.graphBuilder import build_initial_graph
|
| 35 |
+
from services.llmAnalyzer import analyze_repository
|
| 36 |
+
from services.repositoryMemory import memory_service
|
| 37 |
+
|
| 38 |
+
# Phase 3: Memory, RAG, Tools
|
| 39 |
+
from memory.embedding_service import EmbeddingService
|
| 40 |
+
from memory.vector_store import VectorStore
|
| 41 |
+
from memory.knowledge_index import KnowledgeIndexBuilder
|
| 42 |
+
from memory.retriever import KnowledgeRetriever
|
| 43 |
+
from memory.conversation_manager import conversation_manager
|
| 44 |
+
from memory.session_manager import session_manager
|
| 45 |
+
from memory.memory_cache import memory_cache
|
| 46 |
+
# Singleton memory infrastructure (initialised lazily per-request)
|
| 47 |
+
_vector_store: VectorStore = None
|
| 48 |
+
|
| 49 |
+
def get_vector_store() -> VectorStore:
|
| 50 |
+
global _vector_store
|
| 51 |
+
if _vector_store is None:
|
| 52 |
+
_vector_store = VectorStore()
|
| 53 |
+
return _vector_store
|
| 54 |
+
|
| 55 |
+
# Setup logging
|
| 56 |
+
logging.basicConfig(level=logging.INFO)
|
| 57 |
+
logger = logging.getLogger("main")
|
| 58 |
+
|
| 59 |
+
app = FastAPI(
|
| 60 |
+
title="Repository Intelligence API",
|
| 61 |
+
description="Foundational Memory and Intelligence Layer for Multi-Agent AI Software Engineering",
|
| 62 |
+
version="1.0.0"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Enable CORS for frontend integration (credentials disabled when using wildcard origins)
|
| 66 |
+
_cors_origins = [o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()]
|
| 67 |
+
app.add_middleware(
|
| 68 |
+
CORSMiddleware,
|
| 69 |
+
allow_origins=_cors_origins,
|
| 70 |
+
allow_credentials="*" not in _cors_origins,
|
| 71 |
+
allow_methods=["*"],
|
| 72 |
+
allow_headers=["*"],
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Request schema for analyzing git repository
|
| 76 |
+
class AnalyzeUrlRequest(BaseModel):
|
| 77 |
+
url: str
|
| 78 |
+
token: Optional[str] = None
|
| 79 |
+
|
| 80 |
+
@app.get("/api/health")
|
| 81 |
+
def health_check():
|
| 82 |
+
return {"status": "healthy"}
|
| 83 |
+
|
| 84 |
+
@app.post("/api/analyze-url")
|
| 85 |
+
async def analyze_git_url(
|
| 86 |
+
request: AnalyzeUrlRequest,
|
| 87 |
+
x_gemini_key: Optional[str] = Header(None)
|
| 88 |
+
):
|
| 89 |
+
"""
|
| 90 |
+
Clones a GitHub repository, validates access permissions, processes the pipeline,
|
| 91 |
+
calls Gemini 2.5 Flash, and returns structured intelligence outputs.
|
| 92 |
+
"""
|
| 93 |
+
repo_url = request.url
|
| 94 |
+
token = request.token
|
| 95 |
+
gemini_key = x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
| 96 |
+
|
| 97 |
+
if not gemini_key:
|
| 98 |
+
raise HTTPException(
|
| 99 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 100 |
+
detail="Gemini API Key is missing. Please provide it in the headers (x-gemini-key) or configure it on the server."
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# 1. Validate repository privacy and access
|
| 104 |
+
logger.info(f"Validating access to repository: {repo_url}")
|
| 105 |
+
privacy_info = await check_repository_privacy(repo_url, token)
|
| 106 |
+
|
| 107 |
+
if privacy_info["status"] in ["private_requires_auth", "private_denied"]:
|
| 108 |
+
raise HTTPException(
|
| 109 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 110 |
+
detail=privacy_info["message"]
|
| 111 |
+
)
|
| 112 |
+
elif privacy_info["status"] == "invalid":
|
| 113 |
+
raise HTTPException(
|
| 114 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 115 |
+
detail=privacy_info["message"]
|
| 116 |
+
)
|
| 117 |
+
elif privacy_info["status"] == "error":
|
| 118 |
+
raise HTTPException(
|
| 119 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 120 |
+
detail=privacy_info["message"]
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
owner_repo = privacy_info["owner_repo"] or "cloned_repo"
|
| 124 |
+
repo_id = str(uuid.uuid4())
|
| 125 |
+
|
| 126 |
+
# Create a temporary directory in a secure, OS-agnostic manner
|
| 127 |
+
temp_dir = tempfile.mkdtemp(prefix="repo_intel_")
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
# 2. Clone the repository
|
| 131 |
+
logger.info(f"Cloning repository into temporary directory: {temp_dir}")
|
| 132 |
+
clone_repository(repo_url, temp_dir, token)
|
| 133 |
+
|
| 134 |
+
# 3. Scan the repository file tree and text files
|
| 135 |
+
logger.info("Scanning directory structure...")
|
| 136 |
+
scan_results = scan_directory(temp_dir)
|
| 137 |
+
|
| 138 |
+
# 4. Generate static profile and basic relationship graph
|
| 139 |
+
logger.info("Generating static profiles...")
|
| 140 |
+
static_profile = profile_repository(scan_results["files"])
|
| 141 |
+
static_graph = build_initial_graph(scan_results["files"])
|
| 142 |
+
static_profile["static_graph"] = static_graph
|
| 143 |
+
|
| 144 |
+
# 5. Call LLM for deep reasoning and structured outputs
|
| 145 |
+
logger.info("Triggering Gemini 2.5 Flash intelligence analysis...")
|
| 146 |
+
analysis_result = await analyze_repository(
|
| 147 |
+
repo_name=owner_repo,
|
| 148 |
+
tree_structure=scan_results["tree"],
|
| 149 |
+
static_profile=static_profile,
|
| 150 |
+
flat_files=scan_results["files"],
|
| 151 |
+
api_key=gemini_key
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# 6. Save outputs inside the repositoryMemory service
|
| 155 |
+
logger.info("Storing generated artifacts in memory layer...")
|
| 156 |
+
stored = memory_service.store(
|
| 157 |
+
repo_id=repo_id,
|
| 158 |
+
profile=analysis_result["profile"],
|
| 159 |
+
graph=analysis_result["graph"],
|
| 160 |
+
summary=analysis_result["summary"],
|
| 161 |
+
report_markdown=analysis_result["report"]
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# 7. Phase 3: Build semantic vector index asynchronously
|
| 165 |
+
gemini_key_for_embed = gemini_key
|
| 166 |
+
try:
|
| 167 |
+
logger.info("Building semantic knowledge index (ChromaDB)...")
|
| 168 |
+
import asyncio
|
| 169 |
+
embedder = EmbeddingService(api_key=gemini_key_for_embed)
|
| 170 |
+
vs = get_vector_store()
|
| 171 |
+
indexer = KnowledgeIndexBuilder(embedder, vs)
|
| 172 |
+
await asyncio.to_thread(
|
| 173 |
+
indexer.build_index,
|
| 174 |
+
repo_id,
|
| 175 |
+
analysis_result["profile"],
|
| 176 |
+
analysis_result["summary"],
|
| 177 |
+
analysis_result["graph"],
|
| 178 |
+
analysis_result["report"],
|
| 179 |
+
scan_results["files"]
|
| 180 |
+
)
|
| 181 |
+
logger.info(f"Knowledge index built for repo {repo_id}.")
|
| 182 |
+
except Exception as idx_e:
|
| 183 |
+
logger.warning(f"Knowledge index build failed (non-fatal): {idx_e}")
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"success": True,
|
| 187 |
+
"repo_id": repo_id,
|
| 188 |
+
"project_name": owner_repo,
|
| 189 |
+
"tree": scan_results["tree"],
|
| 190 |
+
"data": stored
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"Error during repository analysis: {str(e)}")
|
| 195 |
+
raise HTTPException(
|
| 196 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 197 |
+
detail=f"Analysis failed: {str(e)}"
|
| 198 |
+
)
|
| 199 |
+
finally:
|
| 200 |
+
# Clean up temporary directory (safe rmtree for Windows/Linux read-only files)
|
| 201 |
+
logger.info(f"Cleaning up temporary workspace directory: {temp_dir}")
|
| 202 |
+
shutil.rmtree(temp_dir, onerror=handle_remove_readonly)
|
| 203 |
+
|
| 204 |
+
@app.post("/api/analyze-zip")
|
| 205 |
+
async def analyze_uploaded_zip(
|
| 206 |
+
file: UploadFile = File(...),
|
| 207 |
+
x_gemini_key: Optional[str] = Header(None)
|
| 208 |
+
):
|
| 209 |
+
"""
|
| 210 |
+
Extracts an uploaded repository ZIP file, runs structural scanning,
|
| 211 |
+
generates static/dynamic profile schemas, and runs the LLM analysis.
|
| 212 |
+
"""
|
| 213 |
+
gemini_key = x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
| 214 |
+
|
| 215 |
+
if not gemini_key:
|
| 216 |
+
raise HTTPException(
|
| 217 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 218 |
+
detail="Gemini API Key is missing. Please provide it in the headers (x-gemini-key) or configure it on the server."
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
if not file.filename.endswith(".zip"):
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 224 |
+
detail="Invalid file format. Only ZIP archives are supported."
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
repo_id = str(uuid.uuid4())
|
| 228 |
+
project_name = file.filename[:-4] # Strip .zip
|
| 229 |
+
|
| 230 |
+
# Set up temporary directory and paths
|
| 231 |
+
temp_dir = tempfile.mkdtemp(prefix="zip_intel_")
|
| 232 |
+
fd, zip_path = tempfile.mkstemp(suffix=".zip")
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
# Save ZIP upload chunk by chunk
|
| 236 |
+
with os.fdopen(fd, 'wb') as tmp_zip:
|
| 237 |
+
shutil.copyfileobj(file.file, tmp_zip)
|
| 238 |
+
|
| 239 |
+
# 1. Extract ZIP securely with path traversal protection
|
| 240 |
+
logger.info(f"Extracting zip archive: {file.filename}")
|
| 241 |
+
extract_zip(zip_path, temp_dir)
|
| 242 |
+
|
| 243 |
+
# 2. Scan directory
|
| 244 |
+
logger.info("Scanning unzipped directory structure...")
|
| 245 |
+
scan_results = scan_directory(temp_dir)
|
| 246 |
+
|
| 247 |
+
# 3. Generate static profiles
|
| 248 |
+
logger.info("Generating static profiles...")
|
| 249 |
+
static_profile = profile_repository(scan_results["files"])
|
| 250 |
+
static_graph = build_initial_graph(scan_results["files"])
|
| 251 |
+
static_profile["static_graph"] = static_graph
|
| 252 |
+
|
| 253 |
+
# 4. Trigger Gemini analysis
|
| 254 |
+
logger.info("Analyzing unzipped codebase with Gemini 2.5 Flash...")
|
| 255 |
+
analysis_result = await analyze_repository(
|
| 256 |
+
repo_name=project_name,
|
| 257 |
+
tree_structure=scan_results["tree"],
|
| 258 |
+
static_profile=static_profile,
|
| 259 |
+
flat_files=scan_results["files"],
|
| 260 |
+
api_key=gemini_key
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# 5. Store generated artifacts
|
| 264 |
+
logger.info("Storing artifacts in memory service...")
|
| 265 |
+
stored = memory_service.store(
|
| 266 |
+
repo_id=repo_id,
|
| 267 |
+
profile=analysis_result["profile"],
|
| 268 |
+
graph=analysis_result["graph"],
|
| 269 |
+
summary=analysis_result["summary"],
|
| 270 |
+
report_markdown=analysis_result["report"]
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# 6. Phase 3: Build semantic vector index
|
| 274 |
+
try:
|
| 275 |
+
logger.info("Building semantic knowledge index for ZIP repo...")
|
| 276 |
+
import asyncio
|
| 277 |
+
embedder = EmbeddingService(api_key=gemini_key)
|
| 278 |
+
vs = get_vector_store()
|
| 279 |
+
indexer = KnowledgeIndexBuilder(embedder, vs)
|
| 280 |
+
await asyncio.to_thread(
|
| 281 |
+
indexer.build_index,
|
| 282 |
+
repo_id,
|
| 283 |
+
analysis_result["profile"],
|
| 284 |
+
analysis_result["summary"],
|
| 285 |
+
analysis_result["graph"],
|
| 286 |
+
analysis_result["report"],
|
| 287 |
+
scan_results["files"]
|
| 288 |
+
)
|
| 289 |
+
logger.info(f"Knowledge index built for ZIP repo {repo_id}.")
|
| 290 |
+
except Exception as idx_e:
|
| 291 |
+
logger.warning(f"Knowledge index build failed (non-fatal): {idx_e}")
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
"success": True,
|
| 295 |
+
"repo_id": repo_id,
|
| 296 |
+
"project_name": project_name,
|
| 297 |
+
"tree": scan_results["tree"],
|
| 298 |
+
"data": stored
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
except Exception as e:
|
| 302 |
+
logger.error(f"Error processing uploaded zip file: {str(e)}")
|
| 303 |
+
raise HTTPException(
|
| 304 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 305 |
+
detail=f"Analysis failed: {str(e)}"
|
| 306 |
+
)
|
| 307 |
+
finally:
|
| 308 |
+
# Cleanup
|
| 309 |
+
logger.info(f"Cleaning up temporary workspace files...")
|
| 310 |
+
if os.path.exists(zip_path):
|
| 311 |
+
os.remove(zip_path)
|
| 312 |
+
shutil.rmtree(temp_dir, onerror=handle_remove_readonly)
|
| 313 |
+
|
| 314 |
+
def _cleanup_temp_dir(path: str) -> None:
|
| 315 |
+
if os.path.exists(path):
|
| 316 |
+
shutil.rmtree(path, ignore_errors=True)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@app.get("/api/download/{repo_id}/{artifact_type}")
|
| 320 |
+
async def download_intelligence_artifact(repo_id: str, artifact_type: str):
|
| 321 |
+
"""
|
| 322 |
+
Downloads specific intelligence output as files (profile.json, graph.json, summary.json, report.md)
|
| 323 |
+
"""
|
| 324 |
+
data = memory_service.retrieve(repo_id)
|
| 325 |
+
if not data:
|
| 326 |
+
raise HTTPException(
|
| 327 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 328 |
+
detail="Repository intelligence data not found or has expired."
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
artifact_map = {
|
| 332 |
+
"profile": ("repository_profile.json", "application/json", lambda d: json.dumps(d["profile"], indent=2)),
|
| 333 |
+
"graph": ("repository_graph.json", "application/json", lambda d: json.dumps(d["graph"], indent=2)),
|
| 334 |
+
"summary": ("repository_summary.json", "application/json", lambda d: json.dumps(d["summary"], indent=2)),
|
| 335 |
+
"report": ("repository_report.md", "text/markdown", lambda d: d["report"]),
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
if artifact_type not in artifact_map:
|
| 339 |
+
raise HTTPException(
|
| 340 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 341 |
+
detail=f"Invalid artifact type: {artifact_type}"
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
filename, media_type, content_fn = artifact_map[artifact_type]
|
| 345 |
+
temp_dir = tempfile.mkdtemp()
|
| 346 |
+
file_path = os.path.join(temp_dir, filename)
|
| 347 |
+
|
| 348 |
+
try:
|
| 349 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 350 |
+
f.write(content_fn(data))
|
| 351 |
+
return FileResponse(
|
| 352 |
+
file_path,
|
| 353 |
+
media_type=media_type,
|
| 354 |
+
filename=filename,
|
| 355 |
+
background=BackgroundTask(_cleanup_temp_dir, temp_dir),
|
| 356 |
+
)
|
| 357 |
+
except Exception as e:
|
| 358 |
+
_cleanup_temp_dir(temp_dir)
|
| 359 |
+
logger.error(f"Error compiling download file: {str(e)}")
|
| 360 |
+
raise HTTPException(
|
| 361 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 362 |
+
detail="Failed to generate download file."
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
class ChatRequest(BaseModel):
|
| 366 |
+
repo_id: str
|
| 367 |
+
question: str
|
| 368 |
+
session_id: Optional[str] = None
|
| 369 |
+
|
| 370 |
+
@app.post("/api/chat")
|
| 371 |
+
async def chat_with_repo(
|
| 372 |
+
request: ChatRequest,
|
| 373 |
+
x_gemini_key: Optional[str] = Header(None)
|
| 374 |
+
):
|
| 375 |
+
"""
|
| 376 |
+
Phase 2+3: Executes the multi-agent orchestration pipeline with RAG context
|
| 377 |
+
retrieval and conversation memory.
|
| 378 |
+
"""
|
| 379 |
+
import time
|
| 380 |
+
import asyncio
|
| 381 |
+
from agents.llm_client import GeminiLLMClient
|
| 382 |
+
from agents.orchestrator import AgentOrchestrator
|
| 383 |
+
|
| 384 |
+
gemini_key = x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
| 385 |
+
if not gemini_key:
|
| 386 |
+
raise HTTPException(
|
| 387 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 388 |
+
detail="Gemini API Key is missing."
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
repo_data = memory_service.retrieve(request.repo_id)
|
| 392 |
+
if not repo_data:
|
| 393 |
+
raise HTTPException(
|
| 394 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 395 |
+
detail="Repository intelligence data not found or has expired."
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
session_id = request.session_id or str(uuid.uuid4())
|
| 399 |
+
|
| 400 |
+
# Add user message to memory first
|
| 401 |
+
conversation_manager.add_message(
|
| 402 |
+
session_id=session_id,
|
| 403 |
+
repo_id=request.repo_id,
|
| 404 |
+
role="user",
|
| 405 |
+
content=request.question
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
try:
|
| 409 |
+
llm_client = GeminiLLMClient(api_key=gemini_key)
|
| 410 |
+
orchestrator = AgentOrchestrator(llm_client)
|
| 411 |
+
|
| 412 |
+
embedder = EmbeddingService(api_key=gemini_key)
|
| 413 |
+
retriever = KnowledgeRetriever(embedder, get_vector_store())
|
| 414 |
+
|
| 415 |
+
logger.info(f"Orchestrating agents for repo {request.repo_id}: '{request.question}'")
|
| 416 |
+
|
| 417 |
+
# Run pipeline
|
| 418 |
+
result = await orchestrator.execute(
|
| 419 |
+
profile=repo_data["profile"],
|
| 420 |
+
graph=repo_data["graph"],
|
| 421 |
+
summary=repo_data["summary"],
|
| 422 |
+
report=repo_data["report"],
|
| 423 |
+
query=request.question,
|
| 424 |
+
repo_id=request.repo_id,
|
| 425 |
+
session_id=session_id,
|
| 426 |
+
vector_store=get_vector_store(),
|
| 427 |
+
retriever=retriever
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
# Attach session and RAG details
|
| 431 |
+
result["session_id"] = session_id
|
| 432 |
+
|
| 433 |
+
# Populate retrieved context on user message
|
| 434 |
+
session = conversation_manager.get_session(session_id)
|
| 435 |
+
if session and session.history:
|
| 436 |
+
session.history[-1].retrieved_context = result.get("retrieved_context", [])
|
| 437 |
+
|
| 438 |
+
# Store assistant response in conversation memory
|
| 439 |
+
conversation_manager.add_message(
|
| 440 |
+
session_id=session_id,
|
| 441 |
+
repo_id=request.repo_id,
|
| 442 |
+
role="assistant",
|
| 443 |
+
content=result.get("answer", ""),
|
| 444 |
+
agent_decisions={
|
| 445 |
+
"agents_used": result.get("agents_used", []),
|
| 446 |
+
"confidence": result.get("confidence", 0.0),
|
| 447 |
+
"planner_decision": result.get("planner_decision", {})
|
| 448 |
+
}
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# Persist conversation sessions to disk
|
| 452 |
+
session_manager.save_all()
|
| 453 |
+
|
| 454 |
+
return result
|
| 455 |
+
|
| 456 |
+
except Exception as e:
|
| 457 |
+
logger.error(f"Chat orchestration error: {str(e)}")
|
| 458 |
+
raise HTTPException(
|
| 459 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 460 |
+
detail=f"Chat execution failed: {str(e)}"
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
class SearchRequest(BaseModel):
|
| 465 |
+
repo_id: str
|
| 466 |
+
query: str
|
| 467 |
+
top_k: int = 5
|
| 468 |
+
category: Optional[str] = None
|
| 469 |
+
|
| 470 |
+
@app.post("/api/search")
|
| 471 |
+
async def semantic_search(
|
| 472 |
+
request: SearchRequest,
|
| 473 |
+
x_gemini_key: Optional[str] = Header(None)
|
| 474 |
+
):
|
| 475 |
+
"""Phase 3: Semantic search against the repository knowledge vector index."""
|
| 476 |
+
import time
|
| 477 |
+
gemini_key = x_gemini_key or os.environ.get("GEMINI_API_KEY")
|
| 478 |
+
if not gemini_key:
|
| 479 |
+
raise HTTPException(status_code=400, detail="Gemini API Key required for semantic search.")
|
| 480 |
+
|
| 481 |
+
repo_data = memory_service.retrieve(request.repo_id)
|
| 482 |
+
if not repo_data:
|
| 483 |
+
raise HTTPException(status_code=404, detail="Repository data not found.")
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
start = time.time()
|
| 487 |
+
embedder = EmbeddingService(api_key=gemini_key)
|
| 488 |
+
retriever = KnowledgeRetriever(embedder, get_vector_store())
|
| 489 |
+
results = retriever.retrieve(
|
| 490 |
+
repo_id=request.repo_id,
|
| 491 |
+
query=request.query,
|
| 492 |
+
top_k=request.top_k,
|
| 493 |
+
category=request.category
|
| 494 |
+
)
|
| 495 |
+
latency_ms = int((time.time() - start) * 1000)
|
| 496 |
+
return {
|
| 497 |
+
"query": request.query,
|
| 498 |
+
"results": results,
|
| 499 |
+
"result_count": len(results),
|
| 500 |
+
"latency_ms": latency_ms
|
| 501 |
+
}
|
| 502 |
+
except Exception as e:
|
| 503 |
+
logger.error(f"Semantic search error: {e}")
|
| 504 |
+
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
@app.get("/api/memory")
|
| 508 |
+
async def get_memory_info(repo_id: str):
|
| 509 |
+
"""Phase 3: Returns vector index stats for a repository."""
|
| 510 |
+
try:
|
| 511 |
+
vs = get_vector_store()
|
| 512 |
+
count = vs.count_documents(repo_id)
|
| 513 |
+
return {
|
| 514 |
+
"repo_id": repo_id,
|
| 515 |
+
"indexed_chunks": count,
|
| 516 |
+
"storage_path": vs.storage_path
|
| 517 |
+
}
|
| 518 |
+
except Exception as e:
|
| 519 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
@app.get("/api/conversations")
|
| 523 |
+
async def list_conversations(repo_id: str):
|
| 524 |
+
"""Phase 3: Returns conversation sessions for a repository."""
|
| 525 |
+
sessions = conversation_manager.list_sessions_for_repo(repo_id)
|
| 526 |
+
return {"repo_id": repo_id, "sessions": sessions}
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
@app.get("/api/conversations/{session_id}")
|
| 530 |
+
async def get_conversation(session_id: str):
|
| 531 |
+
"""Returns full message history for a conversation session."""
|
| 532 |
+
history = session_manager.get_session_history(session_id)
|
| 533 |
+
if history is None:
|
| 534 |
+
raise HTTPException(
|
| 535 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 536 |
+
detail="Conversation session not found."
|
| 537 |
+
)
|
| 538 |
+
return {"session_id": session_id, "history": history}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
@app.get("/api/tools")
|
| 542 |
+
async def list_tools():
|
| 543 |
+
"""Phase 3: Returns the registered tool catalog (MCP-ready)."""
|
| 544 |
+
tools = [
|
| 545 |
+
{"name": "repository_search", "description": "Semantic search over indexed repo chunks."},
|
| 546 |
+
{"name": "graph_query", "description": "Query architecture graph, entry points, and flows."},
|
| 547 |
+
{"name": "dependency_lookup", "description": "Lookup packages, frameworks, and databases."},
|
| 548 |
+
{"name": "file_reader", "description": "Retrieve specific source file content."},
|
| 549 |
+
{"name": "architecture_lookup", "description": "Query architecture pattern and key modules."},
|
| 550 |
+
{"name": "api_lookup", "description": "Lookup HTTP routes and authentication methods."}
|
| 551 |
+
]
|
| 552 |
+
return {"tools": tools, "count": len(tools)}
|
| 553 |
+
|
| 554 |
+
# Serve static frontend build if present
|
| 555 |
+
frontend_dist = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "frontend", "dist"))
|
| 556 |
+
if os.path.exists(frontend_dist):
|
| 557 |
+
logger.info(f"Serving static frontend files from: {frontend_dist}")
|
| 558 |
+
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="static")
|
| 559 |
+
else:
|
| 560 |
+
logger.warning(f"Frontend dist folder not found at {frontend_dist}. Running in API-only mode.")
|
| 561 |
+
|
backend/memory/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from memory.embedding_service import EmbeddingService
|
| 2 |
+
from memory.vector_store import VectorStore
|
| 3 |
+
from memory.knowledge_index import KnowledgeIndexBuilder
|
| 4 |
+
from memory.retriever import KnowledgeRetriever
|
| 5 |
+
from memory.conversation_manager import conversation_manager
|
| 6 |
+
from memory.session_manager import session_manager
|
| 7 |
+
from memory.memory_cache import memory_cache
|
backend/memory/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (569 Bytes). View file
|
|
|
backend/memory/__pycache__/conversation_manager.cpython-312.pyc
ADDED
|
Binary file (4.59 kB). View file
|
|
|
backend/memory/__pycache__/embedding_service.cpython-312.pyc
ADDED
|
Binary file (3.14 kB). View file
|
|
|
backend/memory/__pycache__/knowledge_index.cpython-312.pyc
ADDED
|
Binary file (7.5 kB). View file
|
|
|
backend/memory/__pycache__/memory_cache.cpython-312.pyc
ADDED
|
Binary file (2.59 kB). View file
|
|
|
backend/memory/__pycache__/retriever.cpython-312.pyc
ADDED
|
Binary file (2.12 kB). View file
|
|
|
backend/memory/__pycache__/session_manager.cpython-312.pyc
ADDED
|
Binary file (4.83 kB). View file
|
|
|
backend/memory/__pycache__/vector_store.cpython-312.pyc
ADDED
|
Binary file (5.44 kB). View file
|
|
|
backend/memory/conversation_manager.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
class MessageRecord(BaseModel):
|
| 6 |
+
role: str # 'user' | 'assistant'
|
| 7 |
+
content: str
|
| 8 |
+
timestamp: float = Field(default_factory=time.time)
|
| 9 |
+
retrieved_context: Optional[List[Dict[str, Any]]] = None
|
| 10 |
+
agent_decisions: Optional[Dict[str, Any]] = None
|
| 11 |
+
|
| 12 |
+
class ConversationSession(BaseModel):
|
| 13 |
+
session_id: str
|
| 14 |
+
repo_id: str
|
| 15 |
+
history: List[MessageRecord] = Field(default_factory=list)
|
| 16 |
+
summary: str = ""
|
| 17 |
+
|
| 18 |
+
class ConversationManager:
|
| 19 |
+
"""
|
| 20 |
+
Manages multi-session conversation tracking, caching history in-memory.
|
| 21 |
+
Persists context, questions, answers, and internal timeline decisions.
|
| 22 |
+
"""
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self._sessions: Dict[str, ConversationSession] = {}
|
| 25 |
+
|
| 26 |
+
def get_or_create_session(self, session_id: str, repo_id: str) -> ConversationSession:
|
| 27 |
+
if session_id not in self._sessions:
|
| 28 |
+
self._sessions[session_id] = ConversationSession(session_id=session_id, repo_id=repo_id)
|
| 29 |
+
return self._sessions[session_id]
|
| 30 |
+
|
| 31 |
+
def add_message(
|
| 32 |
+
self,
|
| 33 |
+
session_id: str,
|
| 34 |
+
repo_id: str,
|
| 35 |
+
role: str,
|
| 36 |
+
content: str,
|
| 37 |
+
retrieved_context: Optional[List[Dict[str, Any]]] = None,
|
| 38 |
+
agent_decisions: Optional[Dict[str, Any]] = None
|
| 39 |
+
) -> MessageRecord:
|
| 40 |
+
session = self.get_or_create_session(session_id, repo_id)
|
| 41 |
+
record = MessageRecord(
|
| 42 |
+
role=role,
|
| 43 |
+
content=content,
|
| 44 |
+
timestamp=time.time(),
|
| 45 |
+
retrieved_context=retrieved_context,
|
| 46 |
+
agent_decisions=agent_decisions
|
| 47 |
+
)
|
| 48 |
+
session.history.append(record)
|
| 49 |
+
|
| 50 |
+
# Keep summary updated with the last user prompt summary or simple description
|
| 51 |
+
if role == "user" and not session.summary:
|
| 52 |
+
# First query acts as session title/summary
|
| 53 |
+
session.summary = content[:40] + ("..." if len(content) > 40 else "")
|
| 54 |
+
|
| 55 |
+
return record
|
| 56 |
+
|
| 57 |
+
def update_summary(self, session_id: str, summary: str):
|
| 58 |
+
if session_id in self._sessions:
|
| 59 |
+
self._sessions[session_id].summary = summary
|
| 60 |
+
|
| 61 |
+
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
| 62 |
+
return self._sessions.get(session_id)
|
| 63 |
+
|
| 64 |
+
def list_sessions_for_repo(self, repo_id: str) -> List[Dict[str, Any]]:
|
| 65 |
+
return [
|
| 66 |
+
{
|
| 67 |
+
"session_id": s.session_id,
|
| 68 |
+
"repo_id": s.repo_id,
|
| 69 |
+
"summary": s.summary,
|
| 70 |
+
"message_count": len(s.history),
|
| 71 |
+
"last_updated": s.history[-1].timestamp if s.history else time.time()
|
| 72 |
+
}
|
| 73 |
+
for s in self._sessions.values() if s.repo_id == repo_id
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
def clear_sessions(self):
|
| 77 |
+
self._sessions.clear()
|
| 78 |
+
|
| 79 |
+
# Global conversation manager singleton
|
| 80 |
+
conversation_manager = ConversationManager()
|
backend/memory/embedding_service.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("embedding_service")
|
| 6 |
+
|
| 7 |
+
class EmbeddingService:
|
| 8 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 9 |
+
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
| 10 |
+
if not self.api_key:
|
| 11 |
+
raise ValueError("Gemini API key is required to generate embeddings.")
|
| 12 |
+
from google import genai
|
| 13 |
+
self.client = genai.Client(api_key=self.api_key)
|
| 14 |
+
|
| 15 |
+
def embed_text(self, text: str) -> List[float]:
|
| 16 |
+
"""Generates embedding for a single text string."""
|
| 17 |
+
try:
|
| 18 |
+
response = self.client.models.embed_content(
|
| 19 |
+
model='text-embedding-004',
|
| 20 |
+
contents=text
|
| 21 |
+
)
|
| 22 |
+
if response.embeddings and len(response.embeddings) > 0:
|
| 23 |
+
return response.embeddings[0].values
|
| 24 |
+
raise ValueError("No embeddings returned from Gemini API.")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
logger.error(f"Error generating embedding: {e}")
|
| 27 |
+
raise e
|
| 28 |
+
|
| 29 |
+
def embed_texts(self, texts: List[str]) -> List[List[float]]:
|
| 30 |
+
"""Generates embeddings for a batch list of text strings."""
|
| 31 |
+
if not texts:
|
| 32 |
+
return []
|
| 33 |
+
try:
|
| 34 |
+
# Check length to prevent massive batch issues; text-embedding-004 supports bulk
|
| 35 |
+
response = self.client.models.embed_content(
|
| 36 |
+
model='text-embedding-004',
|
| 37 |
+
contents=texts
|
| 38 |
+
)
|
| 39 |
+
if response.embeddings and len(response.embeddings) == len(texts):
|
| 40 |
+
return [e.values for e in response.embeddings]
|
| 41 |
+
elif response.embeddings:
|
| 42 |
+
return [e.values for e in response.embeddings]
|
| 43 |
+
raise ValueError("No embeddings returned from batch API call.")
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error(f"Error generating batch embeddings: {e}")
|
| 46 |
+
raise e
|
backend/memory/knowledge_index.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Dict, Any, Tuple
|
| 4 |
+
from memory.embedding_service import EmbeddingService
|
| 5 |
+
from memory.vector_store import VectorStore
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("knowledge_index")
|
| 8 |
+
|
| 9 |
+
class KnowledgeIndexBuilder:
|
| 10 |
+
def __init__(self, embedding_service: EmbeddingService, vector_store: VectorStore):
|
| 11 |
+
self.embedder = embedding_service
|
| 12 |
+
self.store = vector_store
|
| 13 |
+
|
| 14 |
+
def chunk_text(self, text: str, chunk_size: int = 1000, overlap: int = 100) -> List[str]:
|
| 15 |
+
"""Simple sliding window text splitter."""
|
| 16 |
+
if not text:
|
| 17 |
+
return []
|
| 18 |
+
chunks = []
|
| 19 |
+
start = 0
|
| 20 |
+
text_len = len(text)
|
| 21 |
+
|
| 22 |
+
while start < text_len:
|
| 23 |
+
end = min(start + chunk_size, text_len)
|
| 24 |
+
chunks.append(text[start:end])
|
| 25 |
+
start += chunk_size - overlap
|
| 26 |
+
|
| 27 |
+
# Prevent infinite loop if overlap >= chunk_size
|
| 28 |
+
if chunk_size - overlap <= 0:
|
| 29 |
+
break
|
| 30 |
+
|
| 31 |
+
return chunks
|
| 32 |
+
|
| 33 |
+
def build_index(
|
| 34 |
+
self,
|
| 35 |
+
repo_id: str,
|
| 36 |
+
profile: Dict[str, Any],
|
| 37 |
+
summary: Dict[str, Any],
|
| 38 |
+
graph: Dict[str, Any],
|
| 39 |
+
report: str,
|
| 40 |
+
flat_files: List[Dict[str, Any]]
|
| 41 |
+
):
|
| 42 |
+
"""Builds and indexes a repository's code, structure, and profile metadata into ChromaDB."""
|
| 43 |
+
logger.info(f"Starting index build for repository: {repo_id}")
|
| 44 |
+
|
| 45 |
+
# Clear existing collection if any
|
| 46 |
+
self.store.delete_collection(repo_id)
|
| 47 |
+
|
| 48 |
+
documents: List[str] = []
|
| 49 |
+
metadatas: List[Dict[str, Any]] = []
|
| 50 |
+
ids: List[str] = []
|
| 51 |
+
|
| 52 |
+
# Helper to generate unique IDs
|
| 53 |
+
def add_chunk(content: str, metadata: Dict[str, Any], prefix: str):
|
| 54 |
+
chunk_id = f"{prefix}_{len(documents)}"
|
| 55 |
+
documents.append(content)
|
| 56 |
+
metadatas.append(metadata)
|
| 57 |
+
ids.append(chunk_id)
|
| 58 |
+
|
| 59 |
+
# 1. Index the Repository Intelligence Report (Markdown)
|
| 60 |
+
report_chunks = self.chunk_text(report, chunk_size=1000, overlap=100)
|
| 61 |
+
for i, chunk in enumerate(report_chunks):
|
| 62 |
+
add_chunk(
|
| 63 |
+
content=chunk,
|
| 64 |
+
metadata={"category": "report", "chunk_index": i},
|
| 65 |
+
prefix="report"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# 2. Index the Summary details
|
| 69 |
+
elevator_pitch = summary.get("elevator_pitch", "")
|
| 70 |
+
if elevator_pitch:
|
| 71 |
+
add_chunk(
|
| 72 |
+
content=f"Project Elevator Pitch:\n{elevator_pitch}",
|
| 73 |
+
metadata={"category": "summary", "subcategory": "elevator_pitch"},
|
| 74 |
+
prefix="summary_pitch"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
for feat in summary.get("core_features", []):
|
| 78 |
+
add_chunk(
|
| 79 |
+
content=f"Core Feature: {feat}",
|
| 80 |
+
metadata={"category": "summary", "subcategory": "core_feature"},
|
| 81 |
+
prefix="summary_feat"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
for start_point in summary.get("developer_start_points", []):
|
| 85 |
+
add_chunk(
|
| 86 |
+
content=f"Developer Starting Point File: {start_point}",
|
| 87 |
+
metadata={"category": "summary", "subcategory": "developer_start_point"},
|
| 88 |
+
prefix="summary_start"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# 3. Index Profile Metadata (Architecture, APIs, Auth, Dependencies)
|
| 92 |
+
arch_pattern = profile.get("architecture_pattern", "")
|
| 93 |
+
if arch_pattern:
|
| 94 |
+
add_chunk(
|
| 95 |
+
content=f"Architecture Pattern: {arch_pattern}",
|
| 96 |
+
metadata={"category": "architecture", "pattern": arch_pattern},
|
| 97 |
+
prefix="profile_arch"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
for auth_method in profile.get("authentication_methods", []):
|
| 101 |
+
add_chunk(
|
| 102 |
+
content=f"Authentication / Security Method: {auth_method}",
|
| 103 |
+
metadata={"category": "authentication", "method": auth_method},
|
| 104 |
+
prefix="profile_auth"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
for endpoint in profile.get("api_endpoints", []):
|
| 108 |
+
add_chunk(
|
| 109 |
+
content=f"API Endpoint / Route: {endpoint}",
|
| 110 |
+
metadata={"category": "api", "endpoint": endpoint},
|
| 111 |
+
prefix="profile_api"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
for dep in profile.get("dependencies", []):
|
| 115 |
+
add_chunk(
|
| 116 |
+
content=f"Package Dependency: {dep}",
|
| 117 |
+
metadata={"category": "dependency", "dependency": dep},
|
| 118 |
+
prefix="profile_dep"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# 4. Index Business Flows & Concepts from Graph
|
| 122 |
+
for flow in graph.get("business_flows", []):
|
| 123 |
+
flow_name = flow.get("flow_name", "")
|
| 124 |
+
flow_desc = flow.get("description", "")
|
| 125 |
+
flow_steps = ", ".join(flow.get("steps", []))
|
| 126 |
+
add_chunk(
|
| 127 |
+
content=f"Business Flow: {flow_name}\nDescription: {flow_desc}\nSteps: {flow_steps}",
|
| 128 |
+
metadata={"category": "business_flow", "flow_name": flow_name},
|
| 129 |
+
prefix="graph_flow"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
for concept in graph.get("concepts", []):
|
| 133 |
+
concept_name = concept.get("name", "")
|
| 134 |
+
concept_desc = concept.get("description", "")
|
| 135 |
+
concept_files = ", ".join(concept.get("files", []))
|
| 136 |
+
add_chunk(
|
| 137 |
+
content=f"Core Concept: {concept_name}\nDescription: {concept_desc}\nFiles: {concept_files}",
|
| 138 |
+
metadata={"category": "concept", "concept_name": concept_name},
|
| 139 |
+
prefix="graph_concept"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# 5. Index Source Code Files Content
|
| 143 |
+
for f in flat_files:
|
| 144 |
+
file_path = f.get("path", "")
|
| 145 |
+
content = f.get("content", "")
|
| 146 |
+
file_size = f.get("size", 0)
|
| 147 |
+
|
| 148 |
+
# Skip massive files to avoid cluttering vector space
|
| 149 |
+
if file_size > 100 * 1024 or not content:
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
file_chunks = self.chunk_text(content, chunk_size=1000, overlap=100)
|
| 153 |
+
for j, chunk in enumerate(file_chunks):
|
| 154 |
+
# Include path info inside the document text to maintain retrieval association
|
| 155 |
+
chunk_content = f"File: {file_path} (Chunk {j+1}/{len(file_chunks)})\n\n{chunk}"
|
| 156 |
+
add_chunk(
|
| 157 |
+
content=chunk_content,
|
| 158 |
+
metadata={"category": "file", "path": file_path, "chunk_index": j},
|
| 159 |
+
prefix="file_chunk"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
if not documents:
|
| 163 |
+
logger.info("No documents found to index.")
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
# 6. Generate Embeddings in Batches of 50 to respect API rate limits and connection pooling
|
| 167 |
+
batch_size = 50
|
| 168 |
+
logger.info(f"Generating embeddings for {len(documents)} document chunks in batches of {batch_size}...")
|
| 169 |
+
|
| 170 |
+
all_embeddings: List[List[float]] = []
|
| 171 |
+
for start_idx in range(0, len(documents), batch_size):
|
| 172 |
+
end_idx = min(start_idx + batch_size, len(documents))
|
| 173 |
+
batch_docs = documents[start_idx:end_idx]
|
| 174 |
+
batch_embeddings = self.embedder.embed_texts(batch_docs)
|
| 175 |
+
all_embeddings.extend(batch_embeddings)
|
| 176 |
+
|
| 177 |
+
# 7. Write to Vector Store
|
| 178 |
+
self.store.add_documents(
|
| 179 |
+
repo_id=repo_id,
|
| 180 |
+
documents=documents,
|
| 181 |
+
metadatas=metadatas,
|
| 182 |
+
ids=ids,
|
| 183 |
+
embeddings=all_embeddings
|
| 184 |
+
)
|
| 185 |
+
logger.info(f"Vector database indexing complete. Indexed {len(documents)} chunks.")
|
backend/memory/memory_cache.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Dict, Any, Optional
|
| 3 |
+
|
| 4 |
+
class CacheEntry:
|
| 5 |
+
def __init__(self, value: Any, ttl: Optional[float] = None):
|
| 6 |
+
self.value = value
|
| 7 |
+
self.expires_at = time.time() + ttl if ttl is not None else None
|
| 8 |
+
|
| 9 |
+
def is_expired(self) -> bool:
|
| 10 |
+
if self.expires_at is None:
|
| 11 |
+
return False
|
| 12 |
+
return time.time() > self.expires_at
|
| 13 |
+
|
| 14 |
+
class MemoryCache:
|
| 15 |
+
"""
|
| 16 |
+
In-memory cache with TTL support for accelerating AI agent executions
|
| 17 |
+
and caching heavy search/graph query results.
|
| 18 |
+
"""
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self._cache: Dict[str, CacheEntry] = {}
|
| 21 |
+
|
| 22 |
+
def get(self, key: str) -> Optional[Any]:
|
| 23 |
+
entry = self._cache.get(key)
|
| 24 |
+
if not entry:
|
| 25 |
+
return None
|
| 26 |
+
if entry.is_expired():
|
| 27 |
+
del self._cache[key]
|
| 28 |
+
return None
|
| 29 |
+
return entry.value
|
| 30 |
+
|
| 31 |
+
def set(self, key: str, value: Any, ttl: Optional[float] = None):
|
| 32 |
+
self._cache[key] = CacheEntry(value, ttl)
|
| 33 |
+
|
| 34 |
+
def delete(self, key: str):
|
| 35 |
+
if key in self._cache:
|
| 36 |
+
del self._cache[key]
|
| 37 |
+
|
| 38 |
+
def clear(self):
|
| 39 |
+
self._cache.clear()
|
| 40 |
+
|
| 41 |
+
# Global memory cache singleton
|
| 42 |
+
memory_cache = MemoryCache()
|
backend/memory/retriever.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
from memory.embedding_service import EmbeddingService
|
| 4 |
+
from memory.vector_store import VectorStore
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("retriever")
|
| 7 |
+
|
| 8 |
+
class KnowledgeRetriever:
|
| 9 |
+
def __init__(self, embedding_service: EmbeddingService, vector_store: VectorStore):
|
| 10 |
+
self.embedder = embedding_service
|
| 11 |
+
self.store = vector_store
|
| 12 |
+
|
| 13 |
+
def retrieve(
|
| 14 |
+
self,
|
| 15 |
+
repo_id: str,
|
| 16 |
+
query: str,
|
| 17 |
+
top_k: int = 5,
|
| 18 |
+
category: Optional[str] = None
|
| 19 |
+
) -> List[Dict[str, Any]]:
|
| 20 |
+
"""
|
| 21 |
+
Runs semantic search on ChromaDB for the repo using the query.
|
| 22 |
+
Supports filtering by metadata 'category'.
|
| 23 |
+
"""
|
| 24 |
+
logger.info(f"Retrieving context for repo {repo_id}, query: '{query}' (category filter: {category}, top_k: {top_k})")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Generate query vector using embedder
|
| 28 |
+
query_embedding = self.embedder.embed_text(query)
|
| 29 |
+
|
| 30 |
+
# Setup filter
|
| 31 |
+
where_filter = None
|
| 32 |
+
if category:
|
| 33 |
+
where_filter = {"category": category}
|
| 34 |
+
|
| 35 |
+
return self.store.query_documents(
|
| 36 |
+
repo_id=repo_id,
|
| 37 |
+
query_embedding=query_embedding,
|
| 38 |
+
top_k=top_k,
|
| 39 |
+
where_filter=where_filter
|
| 40 |
+
)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Failed to retrieve vector search results: {e}")
|
| 43 |
+
return []
|
backend/memory/session_manager.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Dict, Any, List, Optional
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from memory.conversation_manager import conversation_manager, ConversationSession, MessageRecord
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("session_manager")
|
| 9 |
+
|
| 10 |
+
class SessionManager:
|
| 11 |
+
"""
|
| 12 |
+
Manages persistence and high-level metadata for multiple repository sessions.
|
| 13 |
+
Saves and loads conversation history and repository state to/from disk.
|
| 14 |
+
"""
|
| 15 |
+
def __init__(self, storage_dir: str = None):
|
| 16 |
+
if storage_dir is None:
|
| 17 |
+
storage_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "storage")
|
| 18 |
+
self.storage_dir = storage_dir
|
| 19 |
+
os.makedirs(self.storage_dir, exist_ok=True)
|
| 20 |
+
self.conversations_file = os.path.join(self.storage_dir, "conversations.json")
|
| 21 |
+
self.load_all()
|
| 22 |
+
|
| 23 |
+
def load_all(self):
|
| 24 |
+
if os.path.exists(self.conversations_file):
|
| 25 |
+
try:
|
| 26 |
+
with open(self.conversations_file, "r", encoding="utf-8") as f:
|
| 27 |
+
data = json.load(f)
|
| 28 |
+
for session_id, s_data in data.items():
|
| 29 |
+
session = ConversationSession(
|
| 30 |
+
session_id=session_id,
|
| 31 |
+
repo_id=s_data.get("repo_id"),
|
| 32 |
+
summary=s_data.get("summary", ""),
|
| 33 |
+
history=[
|
| 34 |
+
MessageRecord(**msg) for msg in s_data.get("history", [])
|
| 35 |
+
]
|
| 36 |
+
)
|
| 37 |
+
conversation_manager._sessions[session_id] = session
|
| 38 |
+
logger.info(f"Loaded conversations from {self.conversations_file}")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Error loading conversations: {e}")
|
| 41 |
+
|
| 42 |
+
def save_all(self):
|
| 43 |
+
data = {}
|
| 44 |
+
for session_id, session in conversation_manager._sessions.items():
|
| 45 |
+
data[session_id] = {
|
| 46 |
+
"session_id": session.session_id,
|
| 47 |
+
"repo_id": session.repo_id,
|
| 48 |
+
"summary": session.summary,
|
| 49 |
+
"history": [msg.model_dump() for msg in session.history]
|
| 50 |
+
}
|
| 51 |
+
try:
|
| 52 |
+
with open(self.conversations_file, "w", encoding="utf-8") as f:
|
| 53 |
+
json.dump(data, f, indent=2)
|
| 54 |
+
logger.info(f"Saved conversations to {self.conversations_file}")
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"Error saving conversations: {e}")
|
| 57 |
+
|
| 58 |
+
def get_session_history(self, session_id: str) -> Optional[List[Dict[str, Any]]]:
|
| 59 |
+
session = conversation_manager.get_session(session_id)
|
| 60 |
+
if not session:
|
| 61 |
+
return None
|
| 62 |
+
return [msg.model_dump() for msg in session.history]
|
| 63 |
+
|
| 64 |
+
# Global session manager singleton
|
| 65 |
+
session_manager = SessionManager()
|
backend/memory/vector_store.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, Any, List, Optional
|
| 4 |
+
import chromadb
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("vector_store")
|
| 7 |
+
|
| 8 |
+
class VectorStore:
|
| 9 |
+
def __init__(self, storage_path: Optional[str] = None):
|
| 10 |
+
self.storage_path = storage_path or os.environ.get("CHROMA_DB_PATH", "./chroma_db")
|
| 11 |
+
os.makedirs(self.storage_path, exist_ok=True)
|
| 12 |
+
# Initialize persistent ChromaDB client
|
| 13 |
+
self.client = chromadb.PersistentClient(path=self.storage_path)
|
| 14 |
+
|
| 15 |
+
def get_collection(self, repo_id: str):
|
| 16 |
+
# Convert UUID repo_id to a valid Chroma collection name (alphanumeric and underscores)
|
| 17 |
+
coll_name = f"repo_{repo_id.replace('-', '_')}"
|
| 18 |
+
return self.client.get_or_create_collection(
|
| 19 |
+
name=coll_name,
|
| 20 |
+
metadata={"hnsw:space": "cosine"}
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
def add_documents(
|
| 24 |
+
self,
|
| 25 |
+
repo_id: str,
|
| 26 |
+
documents: List[str],
|
| 27 |
+
metadatas: List[Dict[str, Any]],
|
| 28 |
+
ids: List[str],
|
| 29 |
+
embeddings: List[List[float]]
|
| 30 |
+
):
|
| 31 |
+
"""Adds documents with their corresponding embeddings and metadata."""
|
| 32 |
+
if not documents:
|
| 33 |
+
return
|
| 34 |
+
collection = self.get_collection(repo_id)
|
| 35 |
+
collection.add(
|
| 36 |
+
documents=documents,
|
| 37 |
+
metadatas=metadatas,
|
| 38 |
+
ids=ids,
|
| 39 |
+
embeddings=embeddings
|
| 40 |
+
)
|
| 41 |
+
logger.info(f"Added {len(documents)} chunks to vector store collection for repo {repo_id}")
|
| 42 |
+
|
| 43 |
+
def query_documents(
|
| 44 |
+
self,
|
| 45 |
+
repo_id: str,
|
| 46 |
+
query_embedding: List[float],
|
| 47 |
+
top_k: int = 5,
|
| 48 |
+
where_filter: Optional[Dict[str, Any]] = None
|
| 49 |
+
) -> List[Dict[str, Any]]:
|
| 50 |
+
"""Queries ChromaDB using the query vector, returning matching chunks with similarity scores."""
|
| 51 |
+
collection = self.get_collection(repo_id)
|
| 52 |
+
|
| 53 |
+
# Query ChromaDB
|
| 54 |
+
results = collection.query(
|
| 55 |
+
query_embeddings=[query_embedding],
|
| 56 |
+
n_results=top_k,
|
| 57 |
+
where=where_filter
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
formatted = []
|
| 61 |
+
if results and "documents" in results and len(results["documents"]) > 0:
|
| 62 |
+
docs = results["documents"][0]
|
| 63 |
+
metas = results["metadatas"][0] if results.get("metadatas") else [{} for _ in range(len(docs))]
|
| 64 |
+
ids = results["ids"][0] if results.get("ids") else [str(i) for i in range(len(docs))]
|
| 65 |
+
distances = results["distances"][0] if results.get("distances") else [0.0 for _ in range(len(docs))]
|
| 66 |
+
|
| 67 |
+
for doc, meta, doc_id, dist in zip(docs, metas, ids, distances):
|
| 68 |
+
# Cosine distance to similarity: 1 - distance
|
| 69 |
+
similarity = 1.0 - dist
|
| 70 |
+
formatted.append({
|
| 71 |
+
"id": doc_id,
|
| 72 |
+
"content": doc,
|
| 73 |
+
"metadata": meta,
|
| 74 |
+
"similarity": round(similarity, 4),
|
| 75 |
+
"distance": round(dist, 4)
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# Sort by similarity descending
|
| 79 |
+
formatted.sort(key=lambda x: x["similarity"], reverse=True)
|
| 80 |
+
return formatted
|
| 81 |
+
|
| 82 |
+
def delete_collection(self, repo_id: str):
|
| 83 |
+
coll_name = f"repo_{repo_id.replace('-', '_')}"
|
| 84 |
+
try:
|
| 85 |
+
self.client.delete_collection(name=coll_name)
|
| 86 |
+
logger.info(f"Deleted vector store collection: {coll_name}")
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.warning(f"Could not delete collection {coll_name}: {e}")
|
| 89 |
+
|
| 90 |
+
def count_documents(self, repo_id: str) -> int:
|
| 91 |
+
try:
|
| 92 |
+
collection = self.get_collection(repo_id)
|
| 93 |
+
return collection.count()
|
| 94 |
+
except Exception:
|
| 95 |
+
return 0
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn==0.34.0
|
| 3 |
+
pydantic==2.10.4
|
| 4 |
+
google-genai==1.14.0
|
| 5 |
+
python-multipart==0.0.20
|
| 6 |
+
httpx==0.28.1
|
| 7 |
+
chromadb==1.5.9
|
backend/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services package
|
backend/services/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (125 Bytes). View file
|
|
|
backend/services/__pycache__/graphBuilder.cpython-312.pyc
ADDED
|
Binary file (4.44 kB). View file
|
|
|