Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 52 additions & 60 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,21 @@ FastAPI-based backend for the InterXAI interview automation platform. Handles al
11. [Code Quality](#code-quality)
12. [Docker](#docker)


## Tech Stack

| Technology | Purpose |
|---|---|
| **FastAPI** | Async REST API framework |
| **SQLAlchemy 2.0** | Async ORM |
| **Alembic** | Schema migrations |
| **TaskIQ + Redis** | Async background job queue |
| **LangChain + LiteLLM** | LLM orchestration |
| **Groq** | LLM inference provider |
| **PyPDF2** | Resume PDF text extraction |
| **Supabase** | File storage (resume PDFs) |
| **PyJWT + bcrypt** | Authentication |
| **Pydantic v2** | Request/response validation and settings |
| **uv** | Python package manager |

| Technology | Purpose |
| ----------------------- | ---------------------------------------- |
| **FastAPI** | Async REST API framework |
| **SQLAlchemy 2.0** | Async ORM |
| **Alembic** | Schema migrations |
| **TaskIQ + Redis** | Async background job queue |
| **LangChain + LiteLLM** | LLM orchestration |
| **Groq** | LLM inference provider |
| **PyPDF2** | Resume PDF text extraction |
| **Supabase** | File storage (resume PDFs) |
| **PyJWT + bcrypt** | Authentication |
| **Pydantic v2** | Request/response validation and settings |
| **uv** | Python package manager |

## Project Structure

Expand Down Expand Up @@ -73,7 +71,7 @@ backend/
│ │
│ ├── utils/ # Concrete implementations
│ │ ├── authorization.py # JWT auth dependencies (get_current_user, etc.)
│ │ ├── supabase.py # SupabaseStorageProvider
│ │ ├── supabase.py and vercel_blob.py # SupabaseStorageProvider and VercelBlobStorageProvider
│ │ └── ... # BcryptHasher, JwtEncrypter, PDF extractor
│ │
│ ├── ai/ # LLM agents and prompts
Expand All @@ -100,7 +98,6 @@ backend/
└── mypy.ini # Mypy type checker configuration
```


## Setup & Installation

This project uses [`uv`](https://github.com/astral-sh/uv) for dependency management.
Expand All @@ -113,7 +110,6 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync --dev
```


## Configuration

All settings are loaded from a `.env` file via `pydantic-settings`. Create `backend/.env`:
Expand Down Expand Up @@ -156,7 +152,6 @@ from app.config import settings
print(settings.DATABASE_URL)
```


## Running the Server

```bash
Expand All @@ -168,10 +163,10 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```

Interactive API docs are served automatically:

- **Swagger UI**: `http://localhost:8000/docs`
- **ReDoc**: `http://localhost:8000/redoc`


## Background Jobs (TaskIQ)

InterXAI uses [TaskIQ](https://taskiq-python.github.io/) with a Redis broker for asynchronous resume processing. The worker runs independently from the API server.
Expand Down Expand Up @@ -200,7 +195,6 @@ When a candidate applies for an interview (`POST /applications/{interview_id}`):

The broker uses `taskiq-redis` and supports optional SSL for production Redis connections. The broker is started and stopped via FastAPI's `lifespan` context manager in `main.py`.


## Database Migrations

Migrations are managed with [Alembic](https://alembic.sqlalchemy.org/).
Expand All @@ -221,7 +215,6 @@ uv run alembic history --verbose

> **Note:** Alembic uses a sync connection even for async SQLAlchemy setups. This is configured in `alembic/env.py`.


## Architecture Deep Dive

### Dependency Injection Pattern
Expand All @@ -240,12 +233,12 @@ async def get_interview(

Auth guards are composable dependencies:

| Dependency | Purpose |
|---|---|
| `get_current_user()` | Validates JWT, returns authenticated user |
| `verify_ownership()` | Ensures the user owns the requested resource |
| `verify_org_ownership()` | Ensures the org owns the requested resource |
| `is_organization()` | Restricts route to organization accounts only |
| Dependency | Purpose |
| ------------------------ | --------------------------------------------- |
| `get_current_user()` | Validates JWT, returns authenticated user |
| `verify_ownership()` | Ensures the user owns the requested resource |
| `verify_org_ownership()` | Ensures the org owns the requested resource |
| `is_organization()` | Restricts route to organization accounts only |

### Interface / Implementation Pattern

Expand Down Expand Up @@ -299,44 +292,42 @@ StorageException → 502
AIError → 500
```


## API Endpoints

### Users (`/users`)

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/users/signup` | — | Register a new candidate account |
| `POST` | `/users/login` | — | Authenticate, receive JWT |
| `GET` | `/users/{user_id}` | User | Get user profile |
| `PUT` | `/users/{user_id}` | User | Update profile |
| `DELETE` | `/users/{user_id}` | User | Delete account |
| Method | Path | Auth | Description |
| -------- | ------------------ | ---- | -------------------------------- |
| `POST` | `/users/signup` | — | Register a new candidate account |
| `POST` | `/users/login` | — | Authenticate, receive JWT |
| `GET` | `/users/{user_id}` | User | Get user profile |
| `PUT` | `/users/{user_id}` | User | Update profile |
| `DELETE` | `/users/{user_id}` | User | Delete account |

### Organizations (`/organizations`)

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/organizations/signup` | — | Register a new organization |
| `GET` | `/organizations/{org_id}` | Org | Get organization details |
| `PUT` | `/organizations/{org_id}` | Org | Update organization |
| `DELETE` | `/organizations/{org_id}` | Org | Delete organization |
| Method | Path | Auth | Description |
| -------- | ------------------------- | ---- | --------------------------- |
| `POST` | `/organizations/signup` | — | Register a new organization |
| `GET` | `/organizations/{org_id}` | Org | Get organization details |
| `PUT` | `/organizations/{org_id}` | Org | Update organization |
| `DELETE` | `/organizations/{org_id}` | Org | Delete organization |

### Interviews (`/interviews`)

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/interviews/` | Org | Create a new interview |
| `GET` | `/interviews/` | Any | List (orgs see own, users see open) |
| `GET` | `/interviews/applied` | User | List interviews the user has applied to |
| `GET` | `/interviews/{interview_id}` | Org | Get full interview details |
| Method | Path | Auth | Description |
| ------ | ---------------------------- | ---- | --------------------------------------- |
| `POST` | `/interviews/` | Org | Create a new interview |
| `GET` | `/interviews/` | Any | List (orgs see own, users see open) |
| `GET` | `/interviews/applied` | User | List interviews the user has applied to |
| `GET` | `/interviews/{interview_id}` | Org | Get full interview details |

### Applications (`/applications`)

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/applications/{interview_id}` | User | Apply with a resume PDF |
| `GET` | `/applications/{interview_id}` | Org | List all applications for an interview |

| Method | Path | Auth | Description |
| ------ | ------------------------------ | ---- | -------------------------------------- |
| `POST` | `/applications/{interview_id}` | User | Apply with a resume PDF |
| `GET` | `/applications/{interview_id}` | Org | List all applications for an interview |

## AI Pipeline

Expand All @@ -361,6 +352,7 @@ class ResumeEvaluatorResponse(BaseModel):
```

The agent:

1. Renders a `ChatPromptTemplate` with the request data
2. Calls `LiteLLMProvider.generate()` → Groq API
3. Parses the JSON response with LangChain's `JsonOutputParser`
Expand All @@ -370,7 +362,6 @@ The agent:

Wraps `langchain_litellm.ChatLiteLLM` and maps provider-specific exceptions to the custom `AIError` hierarchy, keeping the rest of the application decoupled from the LLM provider.


## Code Quality

All checks are run from the `backend/` directory.
Expand All @@ -392,15 +383,16 @@ uv run mypy .
### Configuration

**`ruff.toml`**

- Line length: `100`
- Enabled rule sets: `E, W, F, I, N, UP, B, C4, SIM, ARG, PTH`
- Excluded: `alembic/versions/`

**`mypy.ini`**

- Strict mode enabled
- `untyped-decorator` disabled for `app/background/celery/` (Celery decorator limitation)


## Docker

### Building Images
Expand Down Expand Up @@ -431,8 +423,8 @@ docker-compose logs -f taskiq_worker

**Services started by Docker Compose:**

| Service | Port | Description |
|---|---|---|
| `api` | `8000` | FastAPI application server |
| `taskiq_worker` | — | Background job worker |
| `redis` | `6379` | TaskIQ broker and result backend |
| Service | Port | Description |
| --------------- | ------ | -------------------------------- |
| `api` | `8000` | FastAPI application server |
| `taskiq_worker` | — | Background job worker |
| `redis` | `6379` | TaskIQ broker and result backend |
4 changes: 2 additions & 2 deletions backend/app/background/celery/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from app.logger import get_logger
from app.models.application import Application
from app.models.interview import CustomInterview
from app.utils.default_providers import default_storage_provider
from app.utils.pdf import extract_pdf_content
from app.utils.supabase_provider import SupabaseStorageProvider

logger = get_logger(__name__)

Expand Down Expand Up @@ -45,7 +45,7 @@ def process_resume_task(file_bytes_b64: str, file_name: str, application_id: int
"""
logger.info("Received resume processing job for application %d", application_id)
file_bytes = base64.b64decode(file_bytes_b64)
provider = SupabaseStorageProvider()
provider = default_storage_provider()

async def process_and_evaluate() -> None:
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
Expand Down
3 changes: 3 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class Settings(BaseSettings):
SUPABASE_KEY: str = ""
SUPABASE_BUCKET_NAME: str = "resumes"

# Vercel Blob
BLOB_READ_WRITE_TOKEN: str = ""

# Providers
STORAGE_PROVIDER: str = "supabase"
BACKGROUND_WORKER: str = "taskiq"
Expand Down
67 changes: 67 additions & 0 deletions backend/app/utils/vercel_blob.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import asyncio
from typing import cast

import httpx
from vercel_blob import delete, put

from app.exceptions.storage import (
StorageDeleteError,
StorageDownloadError,
StorageUploadError,
)
from app.interfaces.storage_proivder import StorageProviderInterface
from app.logger import get_logger

logger = get_logger(__name__)


class VercelBlobStorageProvider(StorageProviderInterface):
async def upload(self, file: bytes, file_name: str) -> str:
try:
response = await asyncio.to_thread(
put,
file_name,
file,
{
"access": "public",
},
)

return cast(str, response["url"])

except Exception as e:
logger.error(
"Vercel upload failed: %s",
str(e),
exc_info=True,
)
raise StorageUploadError(f"Failed to upload file to storage: {str(e)}") from e

async def delete(self, file_name: str) -> None:
try:
await asyncio.to_thread(delete, file_name)

except Exception as e:
logger.error(
"Vercel delete failed: %s",
str(e),
exc_info=True,
)
raise StorageDeleteError(f"Failed to delete file from storage: {str(e)}") from e

async def download(self, file_name: str) -> bytes:
try:
async with httpx.AsyncClient() as client:
response = await client.get(file_name)

response.raise_for_status()

return response.content

except Exception as e:
logger.error(
"Vercel download failed: %s",
str(e),
exc_info=True,
)
raise StorageDownloadError(f"Failed to download file from storage: {str(e)}") from e
2 changes: 2 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ dependencies = [
"taskiq-redis>=0.5.0",
"python-multipart>=0.0.27",
"supabase>=2.30.0",
"vercel-blob>=0.4.2",
"httpx>=0.28.1",
]

[dependency-groups]
Expand Down
Loading
Loading