From e9392e2127971af4eea1f0a6606014b17b95ffbf Mon Sep 17 00:00:00 2001 From: Karan Bosamiya Date: Sat, 4 Jul 2026 11:10:10 +0530 Subject: [PATCH] [IMP] Initialise structure --- README.md | 439 ++++++++++++++++++++++++++++ backend/app/__init__.py | 0 backend/app/ai/__init__.py | 0 backend/app/ai/prompts.py | 22 ++ backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 0 backend/app/api/v1/auth.py | 153 ++++++++++ backend/app/api/v1/chat.py | 6 + backend/app/api/v1/health.py | 9 + backend/app/api/v1/knowledge.py | 6 + backend/app/api/v1/meetings.py | 6 + backend/app/api/v1/projects.py | 6 + backend/app/api/v1/requirements.py | 6 + backend/app/api/v1/risks.py | 6 + backend/app/api/v1/sprints.py | 6 + backend/app/api/v1/stories.py | 6 + backend/app/api/v1/webhooks.py | 6 + backend/app/core/__init__.py | 0 backend/app/core/config.py | 100 +++++++ backend/app/core/database.py | 18 ++ backend/app/core/deps.py | 45 +++ backend/app/core/exceptions.py | 25 ++ backend/app/core/redis_client.py | 14 + backend/app/core/security.py | 60 ++++ backend/app/core/vector_store.py | 18 ++ backend/app/main.py | 50 ++++ backend/app/models/__init__.py | 0 backend/app/models/user.py | 35 +++ backend/app/schemas/__init__.py | 0 backend/app/schemas/auth.py | 26 ++ backend/app/schemas/rag.py | 38 +++ backend/app/services/__init__.py | 0 backend/app/services/ai_service.py | 30 ++ backend/app/services/rag_service.py | 38 +++ backend/requirements.txt | 16 + 35 files changed, 1190 insertions(+) create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/ai/__init__.py create mode 100644 backend/app/ai/prompts.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/chat.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/app/api/v1/knowledge.py create mode 100644 backend/app/api/v1/meetings.py create mode 100644 backend/app/api/v1/projects.py create mode 100644 backend/app/api/v1/requirements.py create mode 100644 backend/app/api/v1/risks.py create mode 100644 backend/app/api/v1/sprints.py create mode 100644 backend/app/api/v1/stories.py create mode 100644 backend/app/api/v1/webhooks.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/redis_client.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/core/vector_store.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/rag.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ai_service.py create mode 100644 backend/app/services/rag_service.py create mode 100644 backend/requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f40363 --- /dev/null +++ b/README.md @@ -0,0 +1,439 @@ +# ProjectOS — Developer Setup Guide + +> Hackathon edition. Read this before writing a single line of code. +> If something in here is wrong or out of date, fix it — don't just work around it. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Clone & Python Environment](#2-clone--python-environment) +3. [Environment Variables — Where to Get Every Credential](#3-environment-variables--where-to-get-every-credential) +4. [Local Services (Postgres + Redis)](#4-local-services-postgres--redis) +5. [Run the Backend](#5-run-the-backend) +6. [Docker Compose (Alternative to Steps 4–5)](#6-docker-compose-alternative-to-steps-45) +7. [Phase 1 Health Check](#7-phase-1-health-check) +8. [Troubleshooting](#8-troubleshooting) + +--- + +## 1. Prerequisites + +Install these before anything else. If you already have them, skip. + +| Tool | Version | Install | +|---|---|---| +| Python | 3.12.x | [python.org/downloads](https://www.python.org/downloads/) or `brew install python@3.12` | +| PostgreSQL | 16.x | `brew install postgresql@16` or [postgresql.org](https://www.postgresql.org/download/) | +| Redis | 7.x | `brew install redis` or [redis.io](https://redis.io/docs/getting-started/) | +| Git | any recent | pre-installed on Mac; `brew install git` otherwise | +| Docker + Docker Compose | latest | [docker.com/get-started](https://www.docker.com/get-started/) — only needed if using the compose path | + +Verify everything is on `PATH`: + +```bash +python3.12 --version # Python 3.12.x +psql --version # psql (PostgreSQL) 16.x +redis-cli --version # Redis cli 7.x +``` + +--- + +## 2. Clone & Python Environment + +```bash +# from ProjectOS root +cd backend + +# create the virtual environment +python3.12 -m venv .venv + +# activate it (do this every time you open a new terminal) +source .venv/bin/activate # macOS / Linux +# .venv\Scripts\activate # Windows + +# install dependencies +pip install -r requirements.txt +``` + +Your shell prompt should now show `(.venv)`. Every command in this guide assumes the venv is active. + +--- + +## 3. Environment Variables — Where to Get Every Credential + +Copy the example file first: + +```bash +cp backend/.env.example backend/.env +``` + +Then fill in each blank below. The `.env` file is **git-ignored** — never commit it. + +--- + +### 3.1 Local Infrastructure (no signup needed) + +```env +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/projectos +REDIS_URL=redis://localhost:6379/0 +``` + +These point at the local Postgres and Redis you'll start in Step 4. No changes needed for local dev. +If you change the Postgres password when creating the DB, update `postgres:postgres` accordingly. + +--- + +### 3.2 Anthropic — Claude API Key + +**Used by:** Squad A (`ai_service.py`). Every squad's feature work ultimately flows through this. + +**Required for:** Phase 2 onwards (stub works without it in Phase 1). + +**How to get it:** + +1. Go to [console.anthropic.com](https://console.anthropic.com) +2. Sign in with the team account (ask Karan for credentials if you don't have access) +3. Navigate to **API Keys** → **Create Key** +4. Copy the key — it starts with `sk-ant-` + +```env +ANTHROPIC_API_KEY=sk-ant-... +CLAUDE_SONNET_MODEL=claude-sonnet-4-6 +CLAUDE_HAIKU_MODEL=claude-haiku-4-5-20251001 +``` + +The model names are already correct in the example — do not change them unless Karan explicitly says so. + +--- + +### 3.3 Voyage AI — Code Embeddings + +**Used by:** Squad A / Prit Chavda (`rag_service.py`) for embedding code chunks (`voyage-code-2`). + +**Required for:** Phase 2 (knowledge hub ingestion). + +**How to get it:** + +1. Go to [dash.voyageai.com](https://dash.voyageai.com) +2. Sign in / create account with the team email +3. Navigate to **API Keys** → **New Key** +4. Copy the key — it starts with `pa-` + +```env +VOYAGE_API_KEY=pa-... +``` + +--- + +### 3.4 Google Gemini — Text Embeddings (Free) + +**Used by:** Squad A / Prit Chavda (`rag_service.py`) for embedding prose chunks — docs, tickets, meeting transcripts — using `text-embedding-004`. + +**Required for:** Phase 2 (knowledge hub ingestion — prose content). + +**Free tier:** 1500 embedding requests/day via Google AI Studio — no billing required, no credit card. + +**How to get it:** + +1. Go to [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) +2. Sign in with a Google account +3. Click **Create API key** → **Create API key in new project** +4. Copy the key — it starts with `AIza` + +```env +GEMINI_API_KEY=AIza... +``` + +> **Why two embedding providers?** Voyage AI's `voyage-code-2` is purpose-built for code — it outperforms general embedders on function/class-level retrieval. Gemini's `text-embedding-004` handles prose (docs, tickets, meetings) for free. Using one model for both degrades code search quality (§11.2). + +--- + +### 3.5 GitHub — OAuth App + +**Used by:** Squad A (login flow, `auth.py`) and Squad B (repo connector, `GitHubConnector`). + +**Required for:** Phase 1 — the OAuth redirect is the very first thing the demo shows. + +**How to get it:** + +1. Go to [github.com/settings/developers](https://github.com/settings/developers) +2. Click **New OAuth App** +3. Fill in: + - **Application name:** `ProjectOS (Local Dev)` — one app per dev is fine + - **Homepage URL:** `http://localhost:8000` + - **Authorization callback URL:** `http://localhost:8000/api/v1/auth/github/callback` +4. Click **Register application** +5. On the next screen: copy **Client ID** immediately +6. Click **Generate a new client secret** → copy it immediately (shown only once) + +```env +GITHUB_CLIENT_ID=Ov23li... +GITHUB_CLIENT_SECRET=... +GITHUB_OAUTH_REDIRECT_URI=http://localhost:8000/api/v1/auth/github/callback +``` + +> **One OAuth app per developer.** Each person's callback URL points to their own `localhost:8000`. For the shared demo host, Chaitanya (Squad F) will create a separate app with the production URL. + +--- + +### 3.6 Slack — Incoming Webhook + +**Used by:** Squad D (`risks.py`) to fire alerts when a risk hits `severity = HIGH`. + +**Required for:** Phase 2 (risk digest feature). Leave blank in Phase 1 — the code guards against an empty URL. + +**How to get it:** + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch** +2. Name it `ProjectOS Alerts`, pick the team Slack workspace +3. Under **Add features and functionality** → **Incoming Webhooks** → toggle **On** +4. Click **Add New Webhook to Workspace** → pick the `#projectos-alerts` channel (create it first) +5. Copy the webhook URL — it looks like `https://hooks.slack.com/services/T.../B.../...` + +```env +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +``` + +--- + +### 3.7 JWT & Token Encryption — Generate Locally + +**Used by:** Auth system (`security.py`). These are generated once per environment, never shared publicly. + +**Required for:** Phase 1 — auth won't start without valid values. + +Run these two commands and paste the output: + +```bash +# JWT_SECRET_KEY — 64 random hex chars +python3 -c "import secrets; print(secrets.token_hex(32))" + +# TOKEN_ENCRYPTION_KEY — must be exactly 32 bytes for Fernet/AES-256 +python3 -c "import secrets; print(secrets.token_urlsafe(32)[:32])" +``` + +```env +JWT_SECRET_KEY= +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 +TOKEN_ENCRYPTION_KEY= +``` + +> Every developer generates their own values. Do not share these across machines — they only need to match if you're sharing a running database with someone else. + +--- + +### 3.8 ChromaDB — Local Path + +No credentials needed. ChromaDB runs embedded inside the FastAPI process and persists to disk. + +```env +CHROMA_PERSIST_PATH=./data/chromadb +``` + +The directory is created automatically on first run. Leave this as-is. + +--- + +### 3.9 CORS & Worker Config + +```env +CORS_ORIGINS=["http://localhost:5173"] +INGESTION_WORKER_CONCURRENCY=5 +LOG_FORMAT=text +LOG_PROMPTS=False +``` + +- `CORS_ORIGINS` — matches Vite's default dev port. Squad E: don't change this unless you change Vite's port. +- `LOG_PROMPTS=True` — set this locally if you want to see the full Claude prompts in logs while building. Never set it on the demo host. + +--- + +### 3.10 Complete `.env` Reference + +Here is the full `.env` with all keys in one place: + +```env +# --- Infrastructure --- +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/projectos +REDIS_URL=redis://localhost:6379/0 + +# --- AI APIs --- +ANTHROPIC_API_KEY=sk-ant-... # console.anthropic.com +VOYAGE_API_KEY=pa-... # dash.voyageai.com (code embeddings) +GEMINI_API_KEY=AIza... # aistudio.google.com/app/apikey (prose embeddings — FREE) +CLAUDE_SONNET_MODEL=claude-sonnet-4-6 +CLAUDE_HAIKU_MODEL=claude-haiku-4-5-20251001 + +# --- Vector Store --- +CHROMA_PERSIST_PATH=./data/chromadb + +# --- Auth --- +JWT_SECRET_KEY= # generate: python3 -c "import secrets; print(secrets.token_hex(32))" +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 +TOKEN_ENCRYPTION_KEY= # generate: python3 -c "import secrets; print(secrets.token_urlsafe(32)[:32])" + +# --- GitHub OAuth --- +GITHUB_CLIENT_ID= # github.com/settings/developers +GITHUB_CLIENT_SECRET= +GITHUB_OAUTH_REDIRECT_URI=http://localhost:8000/api/v1/auth/github/callback + +# --- Slack (Phase 2) --- +SLACK_WEBHOOK_URL= # api.slack.com/apps — leave blank for Phase 1 + +# --- App --- +CORS_ORIGINS=["http://localhost:5173"] +INGESTION_WORKER_CONCURRENCY=5 +LOG_FORMAT=text +LOG_PROMPTS=False +``` + +--- + +## 4. Local Services (Postgres + Redis) + +### Postgres + +```bash +# Start the Postgres service (macOS with Homebrew) +brew services start postgresql@16 + +# Create the database +createdb projectos + +# Verify +psql projectos -c "SELECT version();" +``` + +If `createdb` fails with a role error, create the role first: + +```bash +psql postgres -c "CREATE USER postgres WITH SUPERUSER PASSWORD 'postgres';" +createdb -U postgres projectos +``` + +### Redis + +```bash +# Start Redis +brew services start redis + +# Verify +redis-cli ping # should return PONG +``` + +--- + +## 5. Run the Backend + +```bash +cd backend +source .venv/bin/activate + +# Run database migrations (creates all tables) +alembic upgrade head + +# Start the dev server with live reload +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Health check — open in browser or run: + +```bash +curl http://localhost:8000/health +# {"status":"ok"} +``` + +API docs (auto-generated): + +- Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs) +- ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc) + +--- + +## 6. Docker Compose (Alternative to Steps 4–5) + +If you prefer not to install Postgres/Redis locally, use Compose — it manages both services for you. + +```bash +# From ProjectOS root +docker compose up -d postgres redis + +# Then run the backend locally (steps above) pointing at the compose containers +# DATABASE_URL and REDIS_URL in .env already point at localhost — it works as-is +``` + +Or run the full stack including the backend container: + +```bash +docker compose up -d +``` + +> **Chaitanya (Squad F):** you own the `docker-compose.yml`. When it's committed, update this section. + +--- + +## 7. Phase 1 Health Check + +Before calling Phase 1 done, verify each of these yourself: + +| Check | Command / Action | Expected | +|---|---|---| +| Backend starts | `uvicorn app.main:app --reload` | No import errors, server listening on 8000 | +| Health endpoint | `curl http://localhost:8000/health` | `{"status":"ok"}` | +| DB connected | `alembic upgrade head` | Runs with no errors | +| Redis connected | `redis-cli ping` | `PONG` | +| GitHub OAuth redirect | Visit `http://localhost:8000/api/v1/auth/github/login` | Redirects to GitHub login | +| AI stub contract | `python3 -c "from app.services.ai_service import AIService; print('ok')"` | Prints `ok` | +| RAG stub contract | `python3 -c "from app.services.rag_service import RAGService; print('ok')"` | Prints `ok` | + +**If any of these fail, fix it before pushing to `main`** — every other squad is blocked until the stubs are in and the server runs clean. + +--- + +## 8. Troubleshooting + +**`ModuleNotFoundError` on startup** + +The venv is either not activated or `pip install` didn't run. Run: + +```bash +source backend/.venv/bin/activate +pip install -r backend/requirements.txt +``` + +**`connection refused` on port 5432** + +Postgres isn't running. Run `brew services start postgresql@16` (or `docker compose up -d postgres`). + +**`connection refused` on port 6379** + +Redis isn't running. Run `brew services start redis` (or `docker compose up -d redis`). + +**`pydantic_settings.env_settings.EnvSettingsError` on startup** + +A required env var is missing or empty. Check your `.env` file — every field in section 3 without a default must have a value. The error message names the missing variable. + +**GitHub OAuth redirects to an error page** + +- Double-check the callback URL in your GitHub OAuth app matches exactly: `http://localhost:8000/api/v1/auth/github/callback` +- Make sure `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are both set in `.env` + +**`alembic upgrade head` fails with "table already exists"** + +The database has a partial migration. Reset it: + +```bash +dropdb projectos && createdb projectos +alembic upgrade head +``` + +--- + +> Questions? Blockers? Ping **Karan Bosamiya** (Squad A lead) on the team channel. +> For CI / deploy issues, ping **Shubham Nayak** (Squad F lead). diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ai/__init__.py b/backend/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ai/prompts.py b/backend/app/ai/prompts.py new file mode 100644 index 0000000..0f5406b --- /dev/null +++ b/backend/app/ai/prompts.py @@ -0,0 +1,22 @@ +# app/ai/prompts.py — versioned prompt registry (§8.3, NFR-MAINT-5) +from dataclasses import dataclass + +from pydantic import BaseModel + + +@dataclass(frozen=True) +class PromptTemplate: + system: str + response_schema: type[BaseModel] | None = None + + +# Drafted by Tarun Nagpal (Squad A) in Phase 1 so Squad C has something concrete to build against. +# Real tuning happens in Phase 2 (§8.6.1) — this is a skeleton, not a validated prompt. +WORK_PACKAGE_EXTRACTION_PROMPT_V1 = PromptTemplate( + system=( + "You are extracting discrete work packages from a software requirements document. " + "Return each work package as a short title, a one-paragraph description, and a category. " + "Do not invent requirements that are not present in the source text." + ), + response_schema=None, # set once WorkPackageExtractionResult schema lands (Squad C, §5.3.2) +) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..5f6cf86 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,153 @@ +# app/api/v1/auth.py — GitHub OAuth flow, §13.2/§13.3 +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import APIRouter, HTTPException, Response, status +from sqlalchemy import select + +from app.core.config import settings +from app.core.database import AsyncSessionLocal +from app.core.redis_client import get_redis +from app.core.security import ( + create_access_token, + generate_oauth_state, + generate_refresh_token, + hash_refresh_token, + verify_refresh_token, +) +from app.models.user import RefreshToken, User +from app.schemas.auth import RefreshRequest, TokenPair, UserOut + +router = APIRouter() + +GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_USER_URL = "https://api.github.com/user" +GITHUB_EMAILS_URL = "https://api.github.com/user/emails" +OAUTH_STATE_TTL_SECONDS = 600 + + +@router.get("/github/login") +async def github_login(): + state = generate_oauth_state() + redis = await get_redis() + await redis.set(f"oauth_state:{state}", "1", ex=OAUTH_STATE_TTL_SECONDS) + + params = { + "client_id": settings.GITHUB_CLIENT_ID, + "redirect_uri": settings.GITHUB_OAUTH_REDIRECT_URI, + "scope": "read:user user:email", + "state": state, + } + query = "&".join(f"{k}={v}" for k, v in params.items()) + return Response(status_code=status.HTTP_302_FOUND, headers={"Location": f"{GITHUB_AUTHORIZE_URL}?{query}"}) + + +@router.get("/github/callback", response_model=TokenPair) +async def github_callback(code: str, state: str): + redis = await get_redis() + if not await redis.get(f"oauth_state:{state}"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_or_expired_state") + await redis.delete(f"oauth_state:{state}") + + async with httpx.AsyncClient(timeout=10.0) as client: + token_resp = await client.post( + GITHUB_TOKEN_URL, + headers={"Accept": "application/json"}, + data={ + "client_id": settings.GITHUB_CLIENT_ID, + "client_secret": settings.GITHUB_CLIENT_SECRET, + "code": code, + "redirect_uri": settings.GITHUB_OAUTH_REDIRECT_URI, + }, + ) + token_resp.raise_for_status() + github_token = token_resp.json().get("access_token") + if not github_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="github_token_exchange_failed") + + gh_headers = {"Authorization": f"Bearer {github_token}", "Accept": "application/vnd.github+json"} + profile_resp = await client.get(GITHUB_USER_URL, headers=gh_headers) + profile_resp.raise_for_status() + profile = profile_resp.json() + + email = profile.get("email") + if not email: + emails_resp = await client.get(GITHUB_EMAILS_URL, headers=gh_headers) + emails_resp.raise_for_status() + primary = next((e for e in emails_resp.json() if e.get("primary")), None) + email = primary["email"] if primary else f"{profile['id']}+{profile['login']}@users.noreply.github.com" + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(User).where(User.oauth_provider == "github", User.oauth_subject == str(profile["id"])) + ) + user = result.scalar_one_or_none() + if user is None: + user = User( + email=email, + oauth_provider="github", + oauth_subject=str(profile["id"]), + display_name=profile.get("name") or profile["login"], + avatar_url=profile.get("avatar_url"), + ) + db.add(user) + await db.flush() + + access_token = create_access_token(user.id) + refresh_token = generate_refresh_token() + db.add( + RefreshToken( + user_id=user.id, + token_hash=hash_refresh_token(refresh_token), + expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), + ) + ) + await db.commit() + await db.refresh(user) + + return TokenPair(access_token=access_token, refresh_token=refresh_token, user=UserOut.model_validate(user)) + + +@router.post("/refresh", response_model=TokenPair) +async def refresh(body: RefreshRequest): + async with AsyncSessionLocal() as db: + result = await db.execute( + select(RefreshToken).order_by(RefreshToken.created_at.desc()) + ) + candidates = result.scalars().all() + matched = next((rt for rt in candidates if verify_refresh_token(body.refresh_token, rt.token_hash)), None) + + if matched is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token") + + if matched.used: + # Reuse detected — theft response: revoke the entire token family (§13.2/§13.3). + await db.execute( + RefreshToken.__table__.update() + .where(RefreshToken.user_id == matched.user_id) + .values(used=True) + ) + await db.commit() + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_reused") + + if matched.expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="refresh_token_expired") + + matched.used = True + user = await db.get(User, matched.user_id) + + new_access_token = create_access_token(user.id) + new_refresh_token = generate_refresh_token() + db.add( + RefreshToken( + user_id=user.id, + token_hash=hash_refresh_token(new_refresh_token), + expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), + ) + ) + await db.commit() + + return TokenPair( + access_token=new_access_token, refresh_token=new_refresh_token, user=UserOut.model_validate(user) + ) diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py new file mode 100644 index 0000000..29d63d0 --- /dev/null +++ b/backend/app/api/v1/chat.py @@ -0,0 +1,6 @@ +# app/api/v1/chat.py — owner: Tarun Nagpal / Squad A (§9.9) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Tarun Nagpal / Squad A (§9.9)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..8be1abe --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,9 @@ +# app/api/v1/health.py — §4.9 +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/api/v1/knowledge.py b/backend/app/api/v1/knowledge.py new file mode 100644 index 0000000..a8f1904 --- /dev/null +++ b/backend/app/api/v1/knowledge.py @@ -0,0 +1,6 @@ +# app/api/v1/knowledge.py — owner: Ishit Desai — Squad B (§9.8) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Ishit Desai — Squad B (§9.8)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/meetings.py b/backend/app/api/v1/meetings.py new file mode 100644 index 0000000..42dad01 --- /dev/null +++ b/backend/app/api/v1/meetings.py @@ -0,0 +1,6 @@ +# app/api/v1/meetings.py — owner: Parth Shah — Squad D (§9.6) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Parth Shah — Squad D (§9.6)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py new file mode 100644 index 0000000..227f172 --- /dev/null +++ b/backend/app/api/v1/projects.py @@ -0,0 +1,6 @@ +# app/api/v1/projects.py — owner: Karan Bosamiya — Squad A +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Karan Bosamiya — Squad A): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/requirements.py b/backend/app/api/v1/requirements.py new file mode 100644 index 0000000..52d9294 --- /dev/null +++ b/backend/app/api/v1/requirements.py @@ -0,0 +1,6 @@ +# app/api/v1/requirements.py — owner: Meet Shah — Squad C (§9.4) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Meet Shah — Squad C (§9.4)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/risks.py b/backend/app/api/v1/risks.py new file mode 100644 index 0000000..b94c496 --- /dev/null +++ b/backend/app/api/v1/risks.py @@ -0,0 +1,6 @@ +# app/api/v1/risks.py — owner: Parth Shah — Squad D (§9.7) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Parth Shah — Squad D (§9.7)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/sprints.py b/backend/app/api/v1/sprints.py new file mode 100644 index 0000000..5c260ef --- /dev/null +++ b/backend/app/api/v1/sprints.py @@ -0,0 +1,6 @@ +# app/api/v1/sprints.py — owner: Meet Shah / Parth Harpal — Squad C (§9.5) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Meet Shah / Parth Harpal — Squad C (§9.5)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/stories.py b/backend/app/api/v1/stories.py new file mode 100644 index 0000000..ef0864b --- /dev/null +++ b/backend/app/api/v1/stories.py @@ -0,0 +1,6 @@ +# app/api/v1/stories.py — owner: Meet Shah — Squad C (§9.4) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Meet Shah — Squad C (§9.4)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/api/v1/webhooks.py b/backend/app/api/v1/webhooks.py new file mode 100644 index 0000000..d4d6be1 --- /dev/null +++ b/backend/app/api/v1/webhooks.py @@ -0,0 +1,6 @@ +# app/api/v1/webhooks.py — owner: Chaitanya Vekaria — Squad F (§9.10) +from fastapi import APIRouter + +router = APIRouter() + +# TODO(Chaitanya Vekaria — Squad F (§9.10)): Phase 1 scaffold only — real endpoints land per HACKATHON_TEAM_PLAN.md diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..66fe058 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,100 @@ +# app/core/config.py +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # ── Database ────────────────────────────────────────────────────────────── + # asyncpg driver required — SQLAlchemy async sessions block the event loop + # if you use the sync psycopg2 driver (NFR-PERF-1, §4.7.1). + DATABASE_URL: str + + # ── Redis ───────────────────────────────────────────────────────────────── + # Three narrowly-scoped uses (ADR-6): sliding-window rate limiting (NFR-SEC-9), + # LLM response cache keyed by prompt hash (§8.4.2), webhook dedup (§4.7.3). + # Not used as a message broker or task queue. + REDIS_URL: str + + # ── AI APIs ─────────────────────────────────────────────────────────────── + # All Claude calls go through AIService.call_claude() — never instantiate + # anthropic.Anthropic() directly. This keeps retry/backoff, cost logging, + # and prompt caching in one place (§8.1, NFR-OBS-2, NFR-SCALE-4). + ANTHROPIC_API_KEY: str + + # Voyage AI: purpose-built code-embedding model (voyage-code-2). + # Used exclusively for code chunks — gives meaningfully better retrieval + # quality over general-purpose embedders for function/class-level search (§11.2). + VOYAGE_API_KEY: str + + # Google Gemini: text-embedding-004 for prose chunks (docs, tickets, meetings). + # Free tier via Google AI Studio — 1500 req/day, sufficient for the hackathon. + # Kept separate from Voyage because code and prose chunks have different + # optimal embedding spaces; one model for both degrades code retrieval (§11.2). + # Get a free key at: aistudio.google.com/app/apikey + GEMINI_API_KEY: str = "" + + # Model identifiers are env-configurable so a model version bump is a config + # change, not a code deploy (NFR-MAINT-4). Sonnet for complex generation, + # Haiku for cheap high-frequency classification calls (§8.2). + CLAUDE_SONNET_MODEL: str = "claude-sonnet-4-6" + CLAUDE_HAIKU_MODEL: str = "claude-haiku-4-5-20251001" + + # ── Vector Store ────────────────────────────────────────────────────────── + # ChromaDB runs embedded in the FastAPI process — zero ops, no separate service. + # This path is volume-mounted in Docker so vectors survive container restarts. + # Upgrade path to HttpClient mode (separate service) is a one-line config swap (ADR-4). + CHROMA_PERSIST_PATH: str = "/data/chromadb" + + # ── Auth ────────────────────────────────────────────────────────────────── + # JWT_SECRET_KEY signs access + refresh tokens (HS256). Must be secret and + # at least 32 random bytes — generate with: python3 -c "import secrets; print(secrets.token_hex(32))" + JWT_SECRET_KEY: str + JWT_ALGORITHM: str = "HS256" + + # Short-lived access token (15 min) + long-lived refresh token (7 days). + # Access tokens are never stored — they're validated statelessly on every request. + # Refresh tokens ARE stored (hashed) in Postgres for revocation (§13.2, NFR-SEC-2). + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # Fernet/AES-256-GCM key for encrypting PATs stored in integrations.encrypted_credentials. + # Must be exactly 32 URL-safe chars — generate with: + # python3 -c "import secrets; print(secrets.token_urlsafe(32)[:32])" + TOKEN_ENCRYPTION_KEY: str + + # ── GitHub OAuth ────────────────────────────────────────────────────────── + # Drives both the login flow (FR-1.1) and the repo connector (Squad B). + # Each developer registers their own OAuth app at github.com/settings/developers + # so callback URLs point to their own localhost. See README §3.5 for exact steps. + GITHUB_CLIENT_ID: str + GITHUB_CLIENT_SECRET: str + GITHUB_OAUTH_REDIRECT_URI: str = "http://localhost:8000/api/v1/auth/github/callback" + + # ── Slack ───────────────────────────────────────────────────────────────── + # Incoming webhook URL for high-severity risk alerts (FR-6.7, Squad D). + # Empty string disables alerting gracefully — safe to leave blank in Phase 1. + # Create at: api.slack.com/apps → Incoming Webhooks. + SLACK_WEBHOOK_URL: str = "" + + # ── CORS ────────────────────────────────────────────────────────────────── + # Vite dev server default is 5173. Add the production frontend URL here + # when Chaitanya deploys the demo host — never use ["*"] in any environment. + CORS_ORIGINS: list[str] = ["http://localhost:5173"] + + # ── Workers ─────────────────────────────────────────────────────────────── + # Max concurrent ingestion tasks (repo indexing). Each task holds an embedding + # API connection; keep this below the API's concurrency limit for your plan tier. + INGESTION_WORKER_CONCURRENCY: int = 5 + + # ── Observability ───────────────────────────────────────────────────────── + # LOG_FORMAT: "json" in production (structured for log aggregators), + # "text" locally for human-readable dev output. + # LOG_PROMPTS: set True locally to see full Claude prompts in logs. + # NEVER set True on the demo host — prompts may contain user data (NFR-SEC-6). + LOG_FORMAT: str = "json" + LOG_PROMPTS: bool = False + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..b2bd45b --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,18 @@ +# app/core/database.py +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..ffbc619 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,45 @@ +# app/core/deps.py — §4.3 dependency injection chain +from collections.abc import AsyncGenerator +from uuid import UUID + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.security import decode_access_token +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/github/callback", auto_error=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session + + +async def get_current_user( + token: str | None = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + user_id = decode_access_token(token) if token else None + if user_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_or_expired_token") + user = await db.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user_not_found") + return user + + +async def assert_project_member( + project_id: UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> User: + """§13.4 — authorization check every project-scoped route depends on. + + Real membership check lands with Squad A/B's `project_members` model in Phase 2; + Phase 1 stub exists so downstream routers can `Depends()` on the correct shape now. + """ + raise NotImplementedError("project_members check lands in Phase 2 — see §13.4, §13.5") diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..1e11885 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,25 @@ +# app/core/exceptions.py — domain exception hierarchy (§4.6) + + +class ProjectOSError(Exception): + """Base class for all domain exceptions.""" + + +class NotFoundError(ProjectOSError): + pass + + +class ForbiddenError(ProjectOSError): + """Raised by assert_project_member when the user isn't on the project (§13.4).""" + + +class TokenReusedError(ProjectOSError): + """Refresh token reuse detected — full token family revoked (§13.2).""" + + +class KnowledgeHubEmpty(ProjectOSError): + """RAG retrieve() found zero chunks for the query filter (§7.2).""" + + +class InsufficientProjectData(ProjectOSError): + pass diff --git a/backend/app/core/redis_client.py b/backend/app/core/redis_client.py new file mode 100644 index 0000000..cac4a74 --- /dev/null +++ b/backend/app/core/redis_client.py @@ -0,0 +1,14 @@ +# app/core/redis_client.py +import redis.asyncio as redis + +from app.core.config import settings + +_pool: redis.Redis | None = None + + +async def get_redis() -> redis.Redis: + global _pool + if _pool is None: + _pool = redis.from_url(settings.REDIS_URL, decode_responses=True) + await _pool.ping() + return _pool diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..ed9a8bf --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,60 @@ +# app/core/security.py — JWT encode/decode, refresh-token hashing, OAuth token encryption (§13) +import base64 +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from cryptography.fernet import Fernet +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import settings + +_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Fernet needs a 32-byte urlsafe-base64 key; derive one from TOKEN_ENCRYPTION_KEY so any +# passphrase-shaped .env value works without the operator having to pre-generate a Fernet key. +_derived_key = base64.urlsafe_b64encode(hashlib.sha256(settings.TOKEN_ENCRYPTION_KEY.encode()).digest()) +_fernet = Fernet(_derived_key) + + +def encrypt_token(plaintext: str) -> str: + """NFR-SEC-5 — encrypts OAuth tokens (e.g. `integrations.encrypted_credentials`) before storage.""" + return _fernet.encrypt(plaintext.encode()).decode() + + +def decrypt_token(ciphertext: str) -> str: + return _fernet.decrypt(ciphertext.encode()).decode() + + +def create_access_token(user_id: UUID) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + payload = {"sub": str(user_id), "exp": expire, "type": "access"} + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_access_token(token: str) -> UUID | None: + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + if payload.get("type") != "access": + return None + return UUID(payload["sub"]) + except (JWTError, ValueError, KeyError): + return None + + +def generate_refresh_token() -> str: + return secrets.token_urlsafe(48) + + +def hash_refresh_token(token: str) -> str: + return _pwd_context.hash(token) + + +def verify_refresh_token(token: str, token_hash: str) -> bool: + return _pwd_context.verify(token, token_hash) + + +def generate_oauth_state() -> str: + return secrets.token_urlsafe(24) diff --git a/backend/app/core/vector_store.py b/backend/app/core/vector_store.py new file mode 100644 index 0000000..2ee35c3 --- /dev/null +++ b/backend/app/core/vector_store.py @@ -0,0 +1,18 @@ +# app/core/vector_store.py +import chromadb + +from app.core.config import settings + +_client: chromadb.ClientAPI | None = None + + +def get_chroma_client() -> chromadb.ClientAPI: + global _client + if _client is None: + _client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_PATH) + return _client + + +def get_collection(name: str): + """name is e.g. `proj_{project_id}_knowledge` or `proj_{project_id}_planning` (§7.2).""" + return get_chroma_client().get_or_create_collection(name=name) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f362695 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,50 @@ +# app/main.py — FastAPI app factory, lifespan, router registration (§4.1/§4.2) +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import auth, chat, health, knowledge, meetings, projects, requirements, risks, sprints, stories, webhooks +from app.core.config import settings +from app.core.database import engine +from app.core.redis_client import get_redis +from app.core.vector_store import get_chroma_client + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.connect(): + pass # PostgreSQL ping + get_chroma_client() # ChromaDB init + await get_redis() # Redis ping + yield + await engine.dispose() + + +app = FastAPI( + title="ProjectOS API", + version="1.0.0", + lifespan=lifespan, + docs_url="/docs", + redoc_url=None, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(projects.router, prefix="/api/v1/projects", tags=["projects"]) +app.include_router(requirements.router, prefix="/api/v1/projects", tags=["requirements"]) +app.include_router(stories.router, prefix="/api/v1/projects", tags=["stories"]) +app.include_router(sprints.router, prefix="/api/v1/projects", tags=["sprints"]) +app.include_router(meetings.router, prefix="/api/v1/projects", tags=["meetings"]) +app.include_router(risks.router, prefix="/api/v1/projects", tags=["risks"]) +app.include_router(knowledge.router, prefix="/api/v1/projects", tags=["knowledge"]) +app.include_router(chat.router, prefix="/api/v1/projects", tags=["chat"]) +app.include_router(webhooks.router, prefix="/api/v1/webhooks", tags=["webhooks"]) +app.include_router(health.router, prefix="/api/v1", tags=["health"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..5b9728f --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,35 @@ +# app/models/user.py — §5.3.1 users, plus refresh_tokens backing the §13.2/§13.3 rotation flow +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + __table_args__ = (UniqueConstraint("oauth_provider", "oauth_subject"),) + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) + oauth_provider: Mapped[str] = mapped_column(String(20), nullable=False) + oauth_subject: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(200), nullable=False) + avatar_url: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class RefreshToken(Base): + """Backs the rotation + reuse-detection flow in §13.2/§13.3 — never stores the raw token.""" + + __tablename__ = "refresh_tokens" + + id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + token_hash: Mapped[str] = mapped_column(String, nullable=False) + used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..6910c5d --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,26 @@ +# app/schemas/auth.py +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class UserOut(BaseModel): + id: UUID + email: str + display_name: str + avatar_url: str | None + created_at: datetime + + class Config: + from_attributes = True + + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + user: UserOut + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py new file mode 100644 index 0000000..a22d6d3 --- /dev/null +++ b/backend/app/schemas/rag.py @@ -0,0 +1,38 @@ +# app/schemas/rag.py — shared types for rag_service.py's frozen contract (§7.1, §5.3.6) +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel + + +class RetrievedChunk(BaseModel): + """Mirrors the provenance columns on `knowledge_chunks` (§5.3.6) — enough to render + a citation (`file_path` + line range + repo) without a round-trip to Chroma.""" + + chunk_id: UUID + chroma_id: str + chunk_type: Literal["code", "doc", "ticket", "ci", "meeting"] + content: str + file_path: str | None = None + start_line: int | None = None + end_line: int | None = None + repo_name: str | None = None + score: float + + +class ChatTurn(BaseModel): + role: Literal["user", "assistant"] + content: str + + +class Citation(BaseModel): + chunk_id: UUID + file_path: str | None = None + start_line: int | None = None + end_line: int | None = None + repo_name: str | None = None + + +class GeneratedAnswer(BaseModel): + text: str + citations: list[Citation] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..9958bd8 --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,30 @@ +# app/services/ai_service.py — FROZEN Phase 1 contract (§8.1). Every service calls Claude only +# through this class, never `anthropic.Anthropic()` directly (NFR-OBS-2, NFR-SEC-6, NFR-SCALE-4). +# +# Phase 1 scope (Karan Bosamiya, Squad A): signature only, reviewed with Prit Chavda. +# Phase 2 swaps the body for a real implementation without changing this signature — +# every squad building against `call_claude()` today keeps working unchanged. +from collections.abc import AsyncGenerator +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel + +from app.ai.prompts import PromptTemplate + + +class AIService: + async def call_claude( + self, + project_id: UUID, + prompt_template: PromptTemplate, + variables: dict, + model: Literal["sonnet", "haiku"] = "sonnet", + response_schema: type[BaseModel] | None = None, + stream: bool = False, + cache_key: str | None = None, + ) -> dict | AsyncGenerator[str, None]: + """Real implementation (retry/backoff §8.5, per-project semaphore §4.7.2, + structured-output validation) lands in Phase 2. Signature is frozen as of hour 1.5. + """ + raise NotImplementedError("AIService.call_claude — real implementation lands in Phase 2 (§8.1)") diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py new file mode 100644 index 0000000..65de5b9 --- /dev/null +++ b/backend/app/services/rag_service.py @@ -0,0 +1,38 @@ +# app/services/rag_service.py — FROZEN Phase 1 contract (§7.1). One shared RAG service for both +# the onboarding assistant (FR-8.1-8.9) and context-hungry planning features (risk, sprint deps) — +# every caller is a thin wrapper around `retrieve()` + `generate_with_citations()`. +# +# Phase 1 scope (Karan Bosamiya, Squad A): signature only, reviewed with Prit Chavda for +# `RetrievedChunk` shape and `response_schema` fit. Phase 2 swaps in the real ChromaDB pipeline +# (embed → similarity search → re-rank §7.4 → dedup → token-budget trim) without signature changes. +from collections.abc import AsyncGenerator +from typing import Literal +from uuid import UUID + +from app.schemas.rag import ChatTurn, GeneratedAnswer, RetrievedChunk + + +async def retrieve( + project_id: UUID, + query: str, + collection_name: Literal["knowledge", "planning"], + chunk_types: list[str] | None = None, + repository_id: UUID | None = None, + top_k: int = 10, +) -> list[RetrievedChunk]: + """Real implementation (embed query, Chroma similarity search, re-rank, dedup — §7.2–§7.4) + lands in Phase 2. Signature is frozen as of hour 1.5. + """ + raise NotImplementedError("rag_service.retrieve — real implementation lands in Phase 2 (§7.2)") + + +async def generate_with_citations( + query: str, + chunks: list[RetrievedChunk], + prompt_template: str, + model: Literal["sonnet", "haiku"], + stream: bool = False, + conversation_history: list[ChatTurn] | None = None, +) -> AsyncGenerator[str, None] | GeneratedAnswer: + """Real implementation (context assembly §7.5, citation enforcement §7.6) lands in Phase 2.""" + raise NotImplementedError("rag_service.generate_with_citations — real implementation lands in Phase 2 (§7.6)") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7c9feeb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy[asyncio]>=2.0 +asyncpg>=0.29 +alembic>=1.13 +pydantic>=2.0 +pydantic-settings>=2.4 +httpx>=0.27 +redis>=5.0 +anthropic>=0.34 +chromadb>=0.5 +authlib>=1.3 +python-jose[cryptography]>=3.3 +passlib[bcrypt]>=1.7 +cryptography>=42.0 +python-multipart>=0.0.9