Capstone · Starter Kit

DocChat — 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.

What this is A complete, runnable two-repo scaffold for DocChat — a FastAPI + Postgres/pgvector backend and a Next.js frontend. The auth flow, the RAG pipeline (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.
Contents
  1. Architecture & folders
  2. Backend — FastAPI auth + RAG done
  3. Frontend — Next.js login + chat done
  4. Run it locally
  5. Study order & what to extend

1 · Architecture & folders

Browser ──▶ Next.js (frontend) ──▶ FastAPI (backend) ──▶ Postgres + pgvector │ └────────────▶ OpenAI (embeddings) · Claude (answers)

Two folders, side by side:

docchat/ ├── backend/ FastAPI · the API, auth, RAG │ ├── app/ │ │ ├── main.py app + CORS + routers │ │ ├── config.py env settings │ │ ├── database.py engine + session + get_db │ │ ├── models.py User, Document, Chunk (pgvector) │ │ ├── schemas.py Pydantic request/response │ │ ├── auth.py hashing + JWT + get_current_user │ │ ├── rag.py chunk · embed · retrieve · generate │ │ └── routers/ │ │ ├── auth.py register + login │ │ ├── documents.py list · create · upload+ingest │ │ └── ask.py the RAG question endpoint │ ├── requirements.txt │ ├── .env.example │ ├── Dockerfile │ └── compose.yaml └── frontend/ Next.js · the UI ├── app/ │ ├── layout.tsx │ ├── page.tsx │ ├── login/page.tsx login form │ └── documents/page.tsx list + chat box ├── lib/api.ts ├── .env.local.example └── package.json

2 · Backend — FastAPI

backend/requirements.txt

fastapi[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)
Why two AI providers? Anthropic has no first-party embeddings API, so DocChat embeds text with OpenAI's 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 in

DATABASE_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.py

from 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.py

from 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.py

from 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")
One-time DB setup In psql: 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.py

from 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 dependency

from 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 implemented

from 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 done

from 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.py

from 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.py

from 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/Dockerfile

FROM 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) together

services:
  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:

3 · Frontend — Next.js

A note on where the token lives For a runnable starter, the login + chat pages keep the JWT in 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 implemented

const 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.tsx

export const metadata = { title: "DocChat" };

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

frontend/app/page.tsx

export 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>
  );
}

4 · Run it locally

Start Postgres + the API (from backend/):
cp .env.example .env       # then add your API keys + secret
docker compose up --build
API 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)).
Or run the API without Docker:
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
fastapi dev app/main.py
Start the frontend (from frontend/):
cp .env.local.example .env.local
npm install
npm run dev
Open http://localhost:3000Log in → upload a PDF (POST /documents/upload in Swagger) → ask a question.
End-to-end win Register a user (/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.

5 · Study order & what to extend

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:

ModuleFile to studyExtend it with…
3 · Authrouters/auth.py, auth.pyrefresh tokens, a /me route, email verification
6 · RAGrag.py, routers/documents.pybetter chunking, an HNSW index, source citations with page numbers
5 · Next.jslogin/page.tsx, documents/page.tsxhttpOnly-cookie auth, a Server-Action upload form, streaming answers
7 · DeployDockerfile, compose.yamlNeon Postgres, Vercel + a container host, a CI workflow
Build order Health check green → register/login → upload a PDF → ask a question → then the UI polish. Follow the Capstone Build lesson for the full assembly walkthrough.