Capstone · Starter Kit
Every file you need to build the capstone, in one place — now with working auth, RAG, and a login + chat UI. Copy each block into the matching path, follow the run steps, and you have a real app you can fill out and ship.
embed · retrieve · generate + PDF upload), and a login + chat UI are all implemented here as reference code. Read each file before pasting — then, as you reach Module 3, Module 6, and Module 5, rebuild these from memory. Typing it yourself is where the skill forms.
Two folders, side by side:
backend/requirements.txtfastapi[standard] sqlalchemy>=2.0 psycopg[binary] pgvector alembic pydantic-settings passlib[bcrypt] pyjwt pypdf anthropic # generation (Claude Opus 4.8 / Sonnet 4.6 / Haiku 4.5) openai # embeddings (text-embedding-3-small → 1536 dims)
text-embedding-3-small (which is 1536-dim — matching our Vector(1536) column) and generates answers with Claude. Swap either for any provider you prefer; just keep the embedding dimension and the column in sync.backend/.env.example → copy to .env and fill inDATABASE_URL=postgresql+psycopg://docchat:docchat@localhost:5432/docchat SECRET_KEY=change-me-to-a-long-random-string ACCESS_TOKEN_EXPIRE_MINUTES=60 ANTHROPIC_API_KEY=sk-ant-... # generation OPENAI_API_KEY=sk-... # embeddings FRONTEND_ORIGIN=http://localhost:3000
backend/app/config.pyfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str
secret_key: str = "dev-secret-change-me"
access_token_expire_minutes: int = 60
anthropic_api_key: str = ""
openai_api_key: str = ""
frontend_origin: str = "http://localhost:3000"
settings = Settings()
backend/app/database.pyfrom sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from .config import settings
engine = create_engine(settings.database_url, echo=False)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
# FastAPI dependency — yields a session, always closes it
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
backend/app/models.pyfrom datetime import datetime, timezone
from pgvector.sqlalchemy import Vector
from sqlalchemy import ForeignKey, String, Text, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
documents: Mapped[list["Document"]] = relationship(back_populates="owner")
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
owner: Mapped["User"] = relationship(back_populates="documents")
chunks: Mapped[list["Chunk"]] = relationship(back_populates="document")
class Chunk(Base):
__tablename__ = "chunks"
id: Mapped[int] = mapped_column(primary_key=True)
document_id: Mapped[int] = mapped_column(ForeignKey("documents.id"))
body: Mapped[str] = mapped_column(Text)
embedding: Mapped[list[float]] = mapped_column(Vector(1536)) # match your model
document: Mapped["Document"] = relationship(back_populates="chunks")
CREATE EXTENSION IF NOT EXISTS vector; before creating the chunks table. Then either run Alembic migrations (Module 3) or, to start fast, Base.metadata.create_all(engine).backend/app/schemas.pyfrom pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class DocumentIn(BaseModel):
title: str
class DocumentOut(BaseModel):
id: int
title: str
class Config:
from_attributes = True # read straight from ORM objects
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
sources: list[str]
backend/app/auth.py — hashing, tokens, the current-user dependencyfrom datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import User
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2 = OAuth2PasswordBearer(tokenUrl="auth/login")
def hash_password(p: str) -> str:
return pwd.hash(p)
def verify_password(p: str, hashed: str) -> bool:
return pwd.verify(p, hashed)
def create_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def get_current_user(token: str = Depends(oauth2), db: Session = Depends(get_db)) -> User:
creds_error = HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
user_id = int(payload["sub"])
except (jwt.PyJWTError, KeyError, ValueError):
raise creds_error
user = db.scalar(select(User).where(User.id == user_id))
if user is None:
raise creds_error
return user
backend/app/rag.py implemented"""chunk → embed → store → retrieve → generate.
Embeddings: OpenAI (Anthropic has no embeddings API). Generation: Claude."""
from anthropic import Anthropic
from openai import OpenAI
from sqlalchemy import text
from sqlalchemy.orm import Session
from .config import settings
_openai = OpenAI(api_key=settings.openai_api_key)
_claude = Anthropic(api_key=settings.anthropic_api_key)
EMBED_MODEL = "text-embedding-3-small" # 1536 dims → matches Vector(1536)
CHAT_MODEL = "claude-sonnet-4-6"
def chunk_text(body: str, size: int = 800, overlap: int = 100) -> list[str]:
words, chunks, i = body.split(), [], 0
while i < len(words):
chunks.append(" ".join(words[i:i + size]))
i += size - overlap
return chunks
def embed(texts: list[str]) -> list[list[float]]:
resp = _openai.embeddings.create(model=EMBED_MODEL, input=texts)
return [d.embedding for d in resp.data]
def retrieve(db: Session, query_vec: list[float], k: int = 5) -> list[str]:
# <=> is pgvector's cosine-distance operator (smaller = more similar)
rows = db.execute(
text("SELECT body FROM chunks ORDER BY embedding <=> :q LIMIT :k"),
{"q": str(query_vec), "k": k},
)
return [r[0] for r in rows]
def generate(question: str, context: list[str]) -> str:
joined = "\n\n".join(context) or "(no relevant context found)"
prompt = (
"Answer using ONLY the context below. If the answer is not present, "
f"say you don't know.\n\nContext:\n{joined}\n\nQuestion: {question}"
)
msg = _claude.messages.create(
model=CHAT_MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return msg.content[0].text
backend/app/routers/auth.py implementedfrom fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import create_token, hash_password, verify_password
from ..database import get_db
from ..models import User
from ..schemas import Token, UserCreate
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=Token, status_code=201)
def register(payload: UserCreate, db: Session = Depends(get_db)):
if db.scalar(select(User).where(User.email == payload.email)):
raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
user = User(email=payload.email, password_hash=hash_password(payload.password))
db.add(user)
db.commit()
db.refresh(user)
return Token(access_token=create_token(user.id))
# OAuth2PasswordRequestForm sends form fields `username` + `password`.
# We treat `username` as the email.
@router.post("/login", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.scalar(select(User).where(User.email == form.username))
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect email or password")
return Token(access_token=create_token(user.id))
backend/app/routers/documents.py upload+ingest donefrom fastapi import APIRouter, Depends, File, Form, UploadFile
from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Chunk, Document, User
from ..rag import chunk_text, embed
from ..schemas import DocumentIn, DocumentOut
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("", response_model=list[DocumentOut])
def list_docs(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
stmt = select(Document).where(Document.user_id == user.id).order_by(Document.id.desc())
return db.scalars(stmt).all()
@router.post("", response_model=DocumentOut)
def create_doc(
payload: DocumentIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
doc = Document(title=payload.title, user_id=user.id)
db.add(doc)
db.commit()
db.refresh(doc)
return doc
@router.post("/upload", response_model=DocumentOut)
def upload(
file: UploadFile = File(...),
title: str = Form(""),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
# 1. extract text from the PDF
body = "\n".join(p.extract_text() or "" for p in PdfReader(file.file).pages)
# 2. create the document row (flush to get its id without committing yet)
doc = Document(title=title or file.filename, user_id=user.id)
db.add(doc)
db.flush()
# 3. chunk → embed → store each chunk as a vector
chunks = chunk_text(body)
for passage, vector in zip(chunks, embed(chunks)):
db.add(Chunk(document_id=doc.id, body=passage, embedding=vector))
db.commit()
db.refresh(doc)
return doc
backend/app/routers/ask.pyfrom fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import User
from ..rag import embed, retrieve, generate
from ..schemas import AskRequest, AskResponse
router = APIRouter(prefix="/ask", tags=["ask"])
@router.post("", response_model=AskResponse)
def ask(
req: AskRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
query_vec = embed([req.question])[0]
context = retrieve(db, query_vec, k=5)
answer = generate(req.question, context)
return AskResponse(answer=answer, sources=context)
backend/app/main.pyfrom fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .routers import auth, documents, ask
app = FastAPI(title="DocChat API")
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {"status": "ok"}
app.include_router(auth.router)
app.include_router(documents.router)
app.include_router(ask.router)
backend/DockerfileFROM python:3.13-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["fastapi", "run", "app/main.py", "--port", "8000", "--host", "0.0.0.0"]
backend/compose.yaml — app + Postgres(pgvector) togetherservices:
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: docchat
POSTGRES_PASSWORD: docchat
POSTGRES_DB: docchat
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
api:
build: .
env_file: .env
ports:
- "8000:8000"
depends_on:
- db
volumes:
dbdata:
localStorage and are Client Components. That's the simplest thing that works — but it's exposed to any script on the page. In Module 5 you upgrade this to an httpOnly cookie + Server Components, which is the production-safe pattern. Know the difference; interviewers ask.frontend/package.json — or just run npx create-next-app@latest{
"name": "docchat-frontend",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "latest",
"react": "latest",
"react-dom": "latest"
}
}
frontend/.env.local.example → copy to .env.local# Server Components read this (never shipped to the browser): API_URL=http://localhost:8000 # Client Components (login/chat) read this — NEXT_PUBLIC_ is exposed to the browser: NEXT_PUBLIC_API_URL=http://localhost:8000
frontend/lib/api.ts implementedconst BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
export function saveToken(t: string) {
localStorage.setItem("token", t);
}
export function getToken(): string | null {
return typeof window === "undefined" ? null : localStorage.getItem("token");
}
export async function login(email: string, password: string): Promise<string> {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: email, password }),
});
if (!res.ok) throw new Error("Login failed");
const data = await res.json();
return data.access_token as string;
}
export async function getDocuments(token: string) {
const res = await fetch(`${BASE}/documents`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to load documents");
return res.json();
}
export async function ask(token: string, question: string) {
const res = await fetch(`${BASE}/ask`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ question }),
});
if (!res.ok) throw new Error("Ask failed");
return res.json();
}
frontend/app/layout.tsxexport const metadata = { title: "DocChat" };
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
frontend/app/page.tsxexport default function Home() {
return (
<main style={{ maxWidth: 640, margin: "4rem auto", fontFamily: "system-ui" }}>
<h1>DocChat</h1>
<p>Upload documents, ask questions, get grounded answers.</p>
<a href="/login">→ Log in</a>
</main>
);
}
frontend/app/login/page.tsx implemented"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { login, saveToken } from "../../lib/api";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const router = useRouter();
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
saveToken(await login(email, password));
router.push("/documents");
} catch {
setError("Login failed — check your email and password.");
}
}
return (
<main style={{ maxWidth: 360, margin: "4rem auto", fontFamily: "system-ui" }}>
<h1>Log in</h1>
<form onSubmit={onSubmit} style={{ display: "grid", gap: 8 }}>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" />
<input value={password} type="password" onChange={(e) => setPassword(e.target.value)} placeholder="password" />
<button type="submit">Log in</button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</form>
</main>
);
}
frontend/app/documents/page.tsx list + chat"use client";
import { useEffect, useState } from "react";
import { ask, getDocuments, getToken } from "../../lib/api";
export default function DocumentsPage() {
const [docs, setDocs] = useState<any[]>([]);
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState<any>(null);
const [loading, setLoading] = useState(false);
const token = getToken();
useEffect(() => {
if (token) getDocuments(token).then(setDocs).catch(() => {});
}, [token]);
async function onAsk(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setLoading(true);
setAnswer(null);
try {
setAnswer(await ask(token, question));
} finally {
setLoading(false);
}
}
if (!token)
return (
<main style={{ margin: "4rem auto", textAlign: "center" }}>
Please <a href="/login">log in</a>.
</main>
);
return (
<main style={{ maxWidth: 640, margin: "4rem auto", fontFamily: "system-ui" }}>
<h1>My documents</h1>
<ul>{docs.map((d) => (<li key={d.id}>{d.title}</li>))}</ul>
<h2>Ask a question</h2>
<form onSubmit={onAsk} style={{ display: "flex", gap: 8 }}>
<input value={question} onChange={(e) => setQuestion(e.target.value)}
placeholder="Ask about your documents…" style={{ flex: 1 }} />
<button disabled={loading}>{loading ? "…" : "Ask"}</button>
</form>
{answer && (
<section style={{ marginTop: 16 }}>
<p>{answer.answer}</p>
<details>
<summary>Sources ({answer.sources.length})</summary>
<ul>{answer.sources.map((s: string, i: number) => (
<li key={i}>{s.slice(0, 160)}…</li>
))}</ul>
</details>
</section>
)}
</main>
);
}
backend/): cp .env.example .env # then add your API keys + secret docker compose up --buildAPI at
http://localhost:8000 — open /docs for Swagger, /health to confirm boot. First time, create tables (psql CREATE EXTENSION vector; then Alembic or Base.metadata.create_all(engine)).python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt fastapi dev app/main.py
frontend/): cp .env.local.example .env.local npm install npm run devOpen
http://localhost:3000 → Log in → upload a PDF (POST /documents/upload in Swagger) → ask a question./auth/register in Swagger), log in on the frontend, upload a PDF, and ask a question about it. When you get a grounded answer with sources, every layer — auth, DB, vectors, retrieval, LLM, UI — is working together. Commit it.The kit works, but the learning is in understanding it. As you reach each module, read the matching file, then rebuild it from memory — and extend it:
| Module | File to study | Extend it with… |
|---|---|---|
| 3 · Auth | routers/auth.py, auth.py | refresh tokens, a /me route, email verification |
| 6 · RAG | rag.py, routers/documents.py | better chunking, an HNSW index, source citations with page numbers |
| 5 · Next.js | login/page.tsx, documents/page.tsx | httpOnly-cookie auth, a Server-Action upload form, streaming answers |
| 7 · Deploy | Dockerfile, compose.yaml | Neon Postgres, Vercel + a container host, a CI workflow |