Guided Project · Build-Along · Inside-Out

AI Learning Assistant
An Incremental Build Walkthrough

Build an advanced, RAG-powered “upload a PDF, ask by text or voice, get grounded answers” assistant — the way you would actually build it: a running skeleton first, then each piece written before the piece that depends on it, and something you can run or test at the end of every milestone. Anthropic Claude is the primary LLM behind a one-line-switch provider seam.

Claude Opus 4.8Anthropic SDKPython 3.11 · FastAPILangChainFAISS Cross-Encoder rerankRAGASOpenRouter (fallback)DockerNext.js (provided)
✅ Milestone stop & run / test it now 🧩 What & why the point of the file ⭐ Trick the insight that makes it click ⚠ Pitfall a mistake to dodge 🔁 Provider swap where we depart from the reference build

How this guide is ordered

This is a build order, not a file dump. If you just wanted the finished files you would read the repo. Instead we build inside-out so nothing ever references something that does not exist yet:

The finished shape (where we are headed)

upload PDF ─▶ extract (4 engines) ─▶ chunk (+overlap) ─▶ embed ─▶ FAISS  (once, at index time) ask ─▶ rewrite query ─▶ FAISS top-15 ─▶ cross-encoder rerank top-5 ─▶ Claude answers + cites pages (per question)
🧩 The one rule that shapes the whole buildRoutes are thin doorways; services do the real work. That is also why the build order works: services are the leaves of the dependency tree, so we write them first and a route is just the last thin wire on top. The LLM is one more service — routes never name a provider.
Phase 0 · foundations

Get a real app running first

Tools, the plan, a folder skeleton, one settings file, and a minimal FastAPI app you can actually hit — before any feature exists.

STEP 1

Install the prerequisites

Get these on your machine first; versions matter because Docker pins them later.

ToolVersionWhy
VS Code (or any editor)latestintegrated terminal + chat help
Python3.11+all backend logic
uvlatestyour env + dependency manager (curl -LsSf https://astral.sh/uv/install.sh | sh)
Node.js + npm18+runs the provided Next.js front end
Docker Desktop20+containerises the app at the end
terminal — sanity check
python --version   # 3.11+
uv --version
node --version     # v18+
docker --version   # 20+
STEP 2

The problem, the stack & the provider decision

Goal: upload a PDF, ask a question (text or voice), get an answer grounded in that PDF with the source cited. What makes it “advanced RAG”: a two-stage retriever and a modular, deployable codebase — not one notebook.

🧩 Why modular matters more than the modelIn interviews for a data-science engineer, the differentiator is not that you can call an LLM — it is that you can lay out a codebase someone else can run and maintain. This whole build is a structure lesson wearing a RAG costume.
LayerToolJob
Backend APIPython 3.11 + FastAPIendpoints
RAG glueLangChainchain the pipeline
LLM (primary)Anthropic Claude Opus 4.8generation
LLM (alt / fallback)OpenAI GPT-4o · OpenRouter→Geminiconfig switch
Embeddingssentence-transformers (local, no key)text → vectors
Vector storeFAISS (local disk)similarity search
Re-rankCross-Encoderstage-2 precision
EvalRAGASscore answers
DevOpsDocker + GitHub Actionspackage + CI/CD
🔁 Provider swap — read onceThe recordings send every LLM call to Gemini via OpenRouter. We build for paid, first-party Claude instead, behind a get_llm() seam (Step 7). OpenAI is a one-word switch; OpenRouter stays as a demo fallback. The only audio exception: Claude has no speech model, so text-to-speech (Step 20) uses an OpenAI-family audio model. Nothing else changes.
STEP 3

Create the project & folder skeleton

Make the GitHub repo (NewAI-learning-assistant → add a README → add a Python .gitignore from the template so .env and __pycache__ are excluded). Clone it, then scaffold the backend with uv.

terminal — from repo root
git clone <your-repo-url> && cd AI-learning-assistant
mkdir backend && cd backend

uv init                      # creates pyproject.toml (uv-managed project)
uv add fastapi "uvicorn[standard]" pydantic-settings python-dotenv
# ^ just enough to boot. We add each library the moment a step needs it.

# folders + the empty __init__.py files that make them importable packages
mkdir -p app/routes app/services app/utils tests
touch app/__init__.py app/routes/__init__.py app/services/__init__.py \
      app/utils/__init__.py tests/__init__.py
🧩 The __init__.py ruleAn empty __init__.py turns a folder into an importable package — it is what lets you write from app.services.llm import get_llm. Skip it and every app.* import fails before the app starts. New code folder → drop one in immediately.
💡 Grow dependencies, don’t front-load themuv add <pkg> as each step introduces a library (LangChain in Step 7, faiss + pdf tools in Step 10, ragas in Step 18, …). Step 22 shows the full pinned list for Docker/CI — but while building locally, add-as-you-go keeps the mental model clear.
STEP 4

app/config.py — one settings file (no dependencies)

Start with config because nothing depends on it and everything else will. It is the app’s Settings screen: the provider switch, keys, the two retrieval numbers, chunk sizes, paths. Pydantic Settings declares each value with a type and reads it from .env.

🔁 Provider swapInstead of one chat_model + one OpenRouter URL, we declare an llm_provider switch (default anthropic) plus a key + model per provider. Only the active provider’s key needs a value.
backend/app/config.py
"""
Application configuration loaded from environment variables via pydantic-settings.

The LLM provider is pluggable. Anthropic Claude is the primary (paid) provider;
OpenAI is a drop-in alternative; OpenRouter is kept as a demo / deployment
fallback. Only the active provider's key is required. Secrets are injected at
runtime via .env — never hard-coded here.
"""

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",
    )

    # ── LLM provider selection ────────────────────────────────────────────────
    # "anthropic" (primary) | "openai" | "openrouter" (demo fallback)
    llm_provider: str = "anthropic"

    # ── API keys (only the active provider's key must be set) ─────────────────
    anthropic_api_key: str | None = None
    openai_api_key: str | None = None
    openrouter_api_key: str | None = None

    # ── Chat models, per provider ─────────────────────────────────────────────
    anthropic_model: str = "claude-opus-4-8"
    openai_model: str = "gpt-4o"
    openrouter_model: str = "google/gemini-2.5-flash"   # cheap demo fallback
    openrouter_base_url: str = "https://openrouter.ai/api/v1"

    # ── Text-to-speech (audio) ────────────────────────────────────────────────
    # Anthropic has no audio-output model, so TTS always uses an OpenAI-family
    # model — either OpenAI directly or the same model via the OpenRouter gateway.
    tts_provider: str = "openai"                        # "openai" | "openrouter"
    tts_model: str = "gpt-4o-audio-preview"
    openai_base_url: str = "https://api.openai.com/v1"

    # ── Embeddings — local sentence-transformers, no API key required ──────────
    embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"

    # ── Re-ranker (Cross-Encoder) ─────────────────────────────────────────────
    reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
    reranker_top_k: int = 5       # chunks fed to the LLM after re-ranking
    retrieval_top_k: int = 15     # initial FAISS retrieval count

    # ── Chunking ──────────────────────────────────────────────────────────────
    chunk_size: int = 1000
    chunk_overlap: int = 150

    # ── Storage ───────────────────────────────────────────────────────────────
    faiss_index_path: str = "./faiss_index"
    eval_log_path: str = "./logs/ragas_eval.jsonl"

    # ── Server ────────────────────────────────────────────────────────────────
    allowed_origins: str = "http://localhost:3000"
    log_level: str = "INFO"

    @property
    def cors_origins(self) -> list[str]:
        return [o.strip() for o in self.allowed_origins.split(",")]


@lru_cache
def get_settings() -> Settings:
    """Cached singleton — the settings object is created once per process."""
    return Settings()
⭐ The two numbers that define “advanced” RAGretrieval_top_k = 15 and reranker_top_k = 5: a fast bi-encoder casts a wide net (15), then a slow cross-encoder judges those 15 and keeps the best 5. Cast wide cheaply, judge few.
💡 @lru_cacheget_settings() builds the settings object once per process and hands back the same instance everywhere — .env is read a single time, not per request.
STEP 5

A minimal app/main.py — and run it

Now the smallest thing that actually runs: create the FastAPI app, add CORS (using the origins from config), and one /health route. No routers yet — the “Routers” section is deliberately empty and we add one line to it per feature from Step 11 onward.

backend/app/main.py
"""
FastAPI application entry point.

Start SMALL: just the app object, CORS, and a /health probe. We add one
`include_router(...)` line per feature as we build the routes — so this file
grows with the app instead of importing things that do not exist yet.

Run with:  uv run uvicorn app.main:app --reload --port 8000
"""

import logging
import sys

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from app.config import get_settings

settings = get_settings()
logging.basicConfig(
    level=getattr(logging, settings.log_level.upper(), logging.INFO),
    stream=sys.stdout,
)

app = FastAPI(
    title="My Learning AI Assistant",
    description="Advanced RAG API — Claude (primary), FAISS, cross-encoder, RAGAS.",
    version="1.0.0",
    docs_url="/docs",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Routers ───────────────────────────────────────────────────────────────────
# (empty for now — each feature below adds ONE line here)


@app.get("/health", tags=["System"])
async def health_check() -> JSONResponse:
    return JSONResponse({"status": "ok", "service": "my-learning-ai-assistant"})

Create .env (even before keys — /health needs no LLM) and boot it:

backend/.env
# backend/.env  (minimal for now)
LLM_PROVIDER=anthropic
ALLOWED_ORIGINS=http://localhost:3000
LOG_LEVEL=INFO
terminal — from backend/
uv run uvicorn app.main:app --reload --port 8000
# then, in another terminal:
curl http://localhost:8000/health
# -> {"status":"ok","service":"my-learning-ai-assistant"}
✅ Milestone 1 — you have a running appOpen http://localhost:8000/docs and you will see the auto-generated Swagger UI with a single /health endpoint. This is your walking skeleton. Everything from here just adds routers to it.
🧩 What CORS actually doesYour browser blocks a page on localhost:3000 (frontend) from calling localhost:8000 (backend) — different origins. The CORS middleware is the backend saying “I permit that specific origin.” Remember the purpose, not the parameters.
Phase 1 · building blocks

The services with no route yet

Three leaf pieces the whole pipeline stands on — a retry helper, the LLM seam, and local embeddings. You test the last two in isolation before anything calls them.

STEP 6

utils/retry.py — a resilience helper

A tiny helper the LLM/embedding calls can lean on: retry transient failures (429 / 5xx / dropped connections) with exponential backoff instead of crashing. Built on tenacity.

terminal — from backend/
uv add tenacity httpx
backend/app/utils/retry.py
"""
Centralised retry / backoff decorators built on `tenacity`.

Usage
-----
    from app.utils.retry import llm_retry, embed_retry

    @llm_retry
    def call_llm(...): ...
"""

import logging

from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    before_sleep_log,
    after_log,
)

logger = logging.getLogger(__name__)

# Exceptions that are safe to retry (network hiccups, rate-limits, timeouts)
_RETRYABLE = (
    ConnectionError,
    TimeoutError,
    OSError,
)

try:
    import httpx

    _RETRYABLE = (*_RETRYABLE, httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout)
except ImportError:
    pass


def _is_retryable(exc: BaseException) -> bool:
    """Return True for 429 / 5xx HTTP errors and standard I/O errors."""
    if isinstance(exc, _RETRYABLE):
        return True
    # Inspect an HTTP status code if the exception carries a response
    status = getattr(getattr(exc, "response", None), "status_code", None)
    if status and (status == 429 or status >= 500):
        return True
    return False


# ── LLM calls (Claude / OpenAI / OpenRouter) ─────────────────────────────────
llm_retry = retry(
    retry=lambda retry_state: _is_retryable(retry_state.outcome.exception())
    if retry_state.outcome.failed
    else False,
    wait=wait_exponential(multiplier=1, min=2, max=60),
    stop=stop_after_attempt(6),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    after=after_log(logger, logging.DEBUG),
    reraise=True,
)

# ── Embedding calls ──────────────────────────────────────────────────────────
embed_retry = retry(
    retry=lambda retry_state: _is_retryable(retry_state.outcome.exception())
    if retry_state.outcome.failed
    else False,
    wait=wait_exponential(multiplier=1, min=1, max=30),
    stop=stop_after_attempt(5),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
⚠ This is a corrected version — the repo’s is brokenThe reference repo passes retry= to llm_retry twice (a leftover retry=retry_if_exception_type(Exception) if False else … above the real one), which is a hard SyntaxError: keyword argument repeated: retry — the module will not import. It hides in the repo only because nothing imports it there. Type the single-retry= version above.
💡 Intent over syntaxWhat matters: retry only safe errors (rate-limits, 5xx, connection drops), back off exponentially, give up after N attempts, re-raise. _is_retryable is where “safe” is decided (note it also inspects .response.status_code so SDK 429s retry).
STEP 7

services/llm.py — the provider seam

The heart of a Claude-first build. One function, get_llm(), returns a LangChain chat model for whichever provider .env selects. Every route and service imports this and never names a provider directly — that is why swapping providers later is a one-line change.

terminal — from backend/
uv add langchain langchain-core langchain-anthropic langchain-openai
# (langchain-anthropic pulls in the official `anthropic` SDK)
backend/app/services/llm.py
"""
The LLM provider seam.

One function, get_llm(), returns a LangChain chat model for whichever provider
is configured. Everything downstream — query rewrite, answer generation, topic
extraction, question suggestions, and the TTS text rewrite — calls this and
never names a provider itself. Swap providers by editing .env, not code.

  LLM_PROVIDER=anthropic   -> ChatAnthropic (Claude Opus 4.8)   <- primary
  LLM_PROVIDER=openai      -> ChatOpenAI    (GPT-4o)
  LLM_PROVIDER=openrouter  -> ChatOpenAI against the OpenRouter gateway (demo fallback)
"""

from functools import lru_cache

from langchain_core.language_models import BaseChatModel

from app.config import get_settings


@lru_cache(maxsize=1)
def get_llm() -> BaseChatModel:
    """Build the chat model for the configured provider (once per process)."""
    settings = get_settings()
    provider = settings.llm_provider.lower()

    if provider == "anthropic":
        # Uses the official Anthropic SDK under the hood via langchain-anthropic.
        # IMPORTANT: do not pass temperature/top_p/top_k — Claude Opus 4.8
        # rejects sampling parameters with a 400. Steer with prompting instead.
        from langchain_anthropic import ChatAnthropic

        return ChatAnthropic(
            model=settings.anthropic_model,
            api_key=settings.anthropic_api_key,
            max_tokens=2048,
            timeout=60,
        )

    if provider == "openai":
        from langchain_openai import ChatOpenAI

        return ChatOpenAI(
            model=settings.openai_model,
            api_key=settings.openai_api_key,
            temperature=0.2,
            max_tokens=2048,
        )

    if provider == "openrouter":
        # OpenRouter exposes an OpenAI-compatible endpoint — it is a gateway/
        # aggregator, so ChatOpenAI + base_url is the correct tool here (this is
        # NOT "Claude through a shim"; Claude goes through langchain-anthropic).
        from langchain_openai import ChatOpenAI

        return ChatOpenAI(
            model=settings.openrouter_model,
            api_key=settings.openrouter_api_key,
            base_url=settings.openrouter_base_url,
            temperature=0.2,
            max_tokens=2048,
            default_headers={
                "HTTP-Referer": "https://my-learning-ai-assistant.local",
                "X-Title": "My Learning AI Assistant",
            },
        )

    raise ValueError(f"Unknown LLM_PROVIDER: {settings.llm_provider!r}")
🔁 Provider swap — the core of itClaude goes through ChatAnthropic (the official Anthropic SDK). OpenAI goes through ChatOpenAI. OpenRouter is an OpenAI-compatible gateway, so it uses ChatOpenAI + a base_url — a legitimate gateway, not “Claude through a shim.”
⚠ No temperature on Claude Opus 4.8The anthropic branch passes no temperature: Opus 4.8 rejects sampling params (temperature/top_p/top_k) with a 400. Steer with the prompt. The other branches keep temperature=0.2.

Put your key in .env and prove the seam works before building anything on it:

backend/.env — add
ANTHROPIC_API_KEY=sk-ant-...     # add this line to backend/.env
terminal — from backend/
uv run python -c "from app.services.llm import get_llm; print(get_llm().invoke('Say hi in 3 words').content)"
# -> a short Claude reply, e.g. "Hey there, friend!"
✅ Milestone — Claude is wired upIf that prints a reply, your key, provider seam, and network path all work. Every later “call the LLM” step now rests on proven ground. To use a cheaper model, set ANTHROPIC_MODEL=claude-sonnet-5 in .env — the seam does not change.
STEP 8

services/embeddings.py — local vectors

A thin LangChain-compatible wrapper around a local sentence-transformers model (with a small retry). Runs on-device, so there is nothing to swap by provider — and no API cost for the high-volume embedding of your whole PDF.

terminal — from backend/
uv add langchain-huggingface sentence-transformers
backend/app/services/embeddings.py
"""
Local sentence-transformers embeddings via langchain-huggingface.
Inherits from LangChain's Embeddings base so FAISS accepts it directly.
"""

import logging
from functools import lru_cache

from langchain_core.embeddings import Embeddings
from langchain_huggingface import HuggingFaceEmbeddings
from tenacity import retry, stop_after_attempt, wait_exponential, before_sleep_log

from app.config import get_settings

logger = logging.getLogger(__name__)


class ResilientEmbeddings(Embeddings):
    """
    LangChain-compatible embeddings wrapper around sentence-transformers.
    Runs fully locally — no API key required.
    """

    def __init__(self) -> None:
        settings = get_settings()
        logger.info("Loading embedding model: %s", settings.embedding_model)
        self._model = HuggingFaceEmbeddings(
            model_name=settings.embedding_model,
            model_kwargs={"device": "cpu"},
            encode_kwargs={"normalize_embeddings": True},
        )
        logger.info("Embedding model ready.")

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        return self._embed_docs(texts)

    def embed_query(self, text: str) -> list[float]:
        return self._embed_q(text)

    @retry(
        retry=lambda rs: isinstance(rs.outcome.exception(), (ConnectionError, OSError, TimeoutError))
            if rs.outcome.failed else False,
        wait=wait_exponential(min=1, max=30),
        stop=stop_after_attempt(3),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True,
    )
    def _embed_docs(self, texts: list[str]) -> list[list[float]]:
        return self._model.embed_documents(texts)

    @retry(
        retry=lambda rs: isinstance(rs.outcome.exception(), (ConnectionError, OSError, TimeoutError))
            if rs.outcome.failed else False,
        wait=wait_exponential(min=1, max=30),
        stop=stop_after_attempt(3),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True,
    )
    def _embed_q(self, text: str) -> list[float]:
        return self._model.embed_query(text)


@lru_cache(maxsize=1)
def get_embeddings() -> ResilientEmbeddings:
    return ResilientEmbeddings()

Test it (first run downloads the model, a few seconds):

terminal — from backend/
uv run python -c "from app.services.embeddings import get_embeddings; v=get_embeddings().embed_query('hello world'); print('dims:', len(v), '| first 3:', v[:3])"
# -> dims: 384 | first 3: [...]
✅ Milestone — embeddings work offlineA 384-dim vector means the model loaded and runs locally. Only the final generation call costs money; the bulk vectorising is free and keeps your document text off a third-party embeddings endpoint.
Phase 2 · vertical slice #1

Upload a PDF and index it

Now a full slice, bottom-up: the response schema, then the ingestion service (tested on a real PDF), then the route that calls it — wired into main.py with one line.

STEP 9

app/models.py — start it (upload only)

Create the schemas file, but only add what this slice needs: the upload response. We append query and eval schemas later, when those features arrive. Growing models.py per feature keeps each step self-contained.

backend/app/models.py — v1 (upload)
"""
Pydantic request / response models shared across routes.

We GROW this file feature by feature. Right now it only needs the upload
response; query and evaluation models get appended when we build those routes.
"""

from pydantic import BaseModel, Field  # noqa: F401  (Field is used by later models)


# ── Upload ────────────────────────────────────────────────────────────────────

class UploadResponse(BaseModel):
    message: str
    document_id: str
    chunk_count: int
    description: str
🧩 The contract at the boundaryPydantic classes here are the “standard order ticket” between frontend and backend. FastAPI validates every request against them before your logic runs.
STEP 10

services/document_processor.py — ingest, before any route uses it

The ingestion engine, written and tested before the upload route calls it. Every page is tried through four extractors in order; the first that yields text for a page wins, so a hybrid PDF (some digital, some scanned) works. Then chunk, embed, and persist to FAISS.

StageEngineBest for
1pdfplumbercomplex layouts & tables
2pypdffast, standard digital PDFs
3pymupdfcompressed / legacy PDFs
4OCR (pytesseract + pdf2image)fully scanned / image PDFs
terminal — from backend/
uv add faiss-cpu langchain-community langchain-text-splitters \
       pypdf pdfplumber pymupdf pytesseract pdf2image pillow
# OCR (stage 4) also needs system binaries:  brew install tesseract poppler
backend/app/services/document_processor.py
"""
PDF ingestion, intelligent chunking, and FAISS index management.

Extraction pipeline (each stage feeds the next as fallback)
------------------------------------------------------------
Stage 1 — pdfplumber  : best for complex layouts and tables
Stage 2 — pypdf       : fast, handles most standard digital PDFs
Stage 3 — pymupdf     : robust fallback for compressed / legacy PDFs
Stage 4 — OCR         : pytesseract + pdf2image for fully scanned PDFs

Every page is attempted through all four stages; the first stage that
yields non-empty text for a given page wins. This means a hybrid PDF
(some text pages, some scanned pages) is handled correctly.
"""

import io
import logging
import uuid
from pathlib import Path

from langchain_core.documents import Document
from langchain_community.vectorstores import FAISS
from langchain_text_splitters import RecursiveCharacterTextSplitter

from app.config import get_settings
from app.services.embeddings import get_embeddings

logger = logging.getLogger(__name__)


# ── Stage 1: pdfplumber ───────────────────────────────────────────────────────

def _extract_with_pdfplumber(pdf_bytes: bytes) -> dict[int, str]:
    import pdfplumber
    result: dict[int, str] = {}
    with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
        for page_num, page in enumerate(pdf.pages, start=1):
            text = (page.extract_text() or "").strip()
            if text:
                result[page_num] = text
    return result


# ── Stage 2: pypdf ────────────────────────────────────────────────────────────

def _extract_with_pypdf(pdf_bytes: bytes) -> dict[int, str]:
    from pypdf import PdfReader
    result: dict[int, str] = {}
    reader = PdfReader(io.BytesIO(pdf_bytes))
    for page_num, page in enumerate(reader.pages, start=1):
        try:
            text = page.extract_text(extraction_mode="layout") or ""
        except TypeError:
            text = page.extract_text() or ""
        text = text.strip()
        if text:
            result[page_num] = text
    return result


# ── Stage 3: pymupdf ──────────────────────────────────────────────────────────

def _extract_with_pymupdf(pdf_bytes: bytes) -> dict[int, str]:
    import fitz  # pymupdf
    result: dict[int, str] = {}
    with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
        for page_num, page in enumerate(doc, start=1):
            text = page.get_text("text").strip()
            if text:
                result[page_num] = text
    return result


# ── Stage 4: OCR via pytesseract + pdf2image ──────────────────────────────────

def _extract_with_ocr(pdf_bytes: bytes, missing_pages: set[int]) -> dict[int, str]:
    """
    Convert only the pages that all text-based engines failed on to images,
    then run Tesseract OCR on them. Requires:
      - tesseract  (brew install tesseract)
      - poppler    (brew install poppler)
    """
    import pytesseract
    from pdf2image import convert_from_bytes

    result: dict[int, str] = {}

    # Convert only the missing pages (1-indexed) to avoid processing the whole doc
    for page_num in sorted(missing_pages):
        try:
            images = convert_from_bytes(
                pdf_bytes,
                dpi=300,
                first_page=page_num,
                last_page=page_num,
            )
            if not images:
                continue
            text = pytesseract.image_to_string(images[0], lang="eng").strip()
            if text:
                result[page_num] = text
                logger.info("OCR extracted text from page %d", page_num)
            else:
                logger.warning("OCR returned no text for page %d", page_num)
        except Exception as exc:
            logger.warning("OCR failed on page %d: %s", page_num, exc)

    return result


# ── Multi-engine coordinator ──────────────────────────────────────────────────

def _get_total_pages(pdf_bytes: bytes) -> int:
    """Quick page count without full parsing."""
    try:
        import fitz
        with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
            return len(doc)
    except Exception:
        from pypdf import PdfReader
        return len(PdfReader(io.BytesIO(pdf_bytes)).pages)


def _extract_text_from_pdf(pdf_bytes: bytes) -> list[tuple[str, int]]:
    """
    Run all extraction stages and return a merged (text, page_num) list,
    sorted by page number.
    """
    total_pages = _get_total_pages(pdf_bytes)
    all_page_nums = set(range(1, total_pages + 1))
    page_map: dict[int, str] = {}

    text_stages = [
        ("pdfplumber", _extract_with_pdfplumber),
        ("pypdf",      _extract_with_pypdf),
        ("pymupdf",    _extract_with_pymupdf),
    ]

    for name, extractor in text_stages:
        missing = all_page_nums - page_map.keys()
        if not missing:
            break
        try:
            found = extractor(pdf_bytes)
            for page_num, text in found.items():
                if page_num not in page_map:
                    page_map[page_num] = text
            logger.debug("%s: captured %d page(s)", name, len(found))
        except Exception as exc:
            logger.warning("Extractor '%s' failed: %s", name, exc)

    # OCR pass for any pages still missing text
    missing_after_text = all_page_nums - page_map.keys()
    if missing_after_text:
        logger.info(
            "Falling back to OCR for %d page(s): %s",
            len(missing_after_text),
            sorted(missing_after_text),
        )
        try:
            ocr_results = _extract_with_ocr(pdf_bytes, missing_after_text)
            page_map.update(ocr_results)
        except Exception as exc:
            logger.warning("OCR stage failed: %s", exc)

    return [(text, num) for num, text in sorted(page_map.items())]


# ── Document builder ──────────────────────────────────────────────────────────

def _build_documents(
    pages: list[tuple[str, int]],
    description: str,
    document_id: str,
    filename: str,
) -> list[Document]:
    return [
        Document(
            page_content=text,
            metadata={
                "page": page_num,
                "document_id": document_id,
                "filename": filename,
                "description": description,
            },
        )
        for text, page_num in pages
    ]


# ── Public API ────────────────────────────────────────────────────────────────

def ingest_pdf(
    pdf_bytes: bytes,
    filename: str,
    description: str,
) -> tuple[str, int]:
    """
    Full ingestion pipeline: extract → chunk → embed → persist FAISS.
    Returns (document_id, total_chunks).
    """
    settings = get_settings()
    embeddings = get_embeddings()

    document_id = str(uuid.uuid4())
    logger.info("Starting ingestion for '%s' (id=%s)", filename, document_id)

    pages = _extract_text_from_pdf(pdf_bytes)

    if not pages:
        raise ValueError(
            "Could not extract text from any page of this PDF, even with OCR. "
            "The document may be corrupt, password-protected, or contain only graphics."
        )

    logger.info("Extracted text from %d/%d page(s) of '%s'",
                len(pages), _get_total_pages(pdf_bytes), filename)

    raw_docs = _build_documents(pages, description, document_id, filename)

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=settings.chunk_size,
        chunk_overlap=settings.chunk_overlap,
        separators=["\n\n", "\n", ". ", " ", ""],
        length_function=len,
    )
    chunks = splitter.split_documents(raw_docs)

    for chunk in chunks:
        chunk.metadata["document_id"] = document_id

    logger.info("Split into %d chunks for document_id=%s", len(chunks), document_id)

    index_path = Path(settings.faiss_index_path)
    index_path.mkdir(parents=True, exist_ok=True)

    # Always rebuild the index from scratch so stale chunks from previous
    # uploads never accumulate.
    logger.info("Building fresh FAISS index.")
    vector_store = FAISS.from_documents(chunks, embeddings)

    vector_store.save_local(str(index_path))
    logger.info("FAISS index persisted to %s", index_path)

    return document_id, len(chunks)


def load_vector_store() -> FAISS:
    """Load the persisted FAISS index from disk."""
    settings = get_settings()
    index_path = Path(settings.faiss_index_path)
    if not (index_path / "index.faiss").exists():
        raise FileNotFoundError(
            "No FAISS index found. Please upload a document first."
        )
    return FAISS.load_local(
        str(index_path), get_embeddings(), allow_dangerous_deserialization=True,
    )

Drop any PDF next to your code as sample.pdf and ingest it:

terminal — from backend/
uv run python -c "from app.services.document_processor import ingest_pdf; import pathlib; b=pathlib.Path('sample.pdf').read_bytes(); print(ingest_pdf(b, 'sample.pdf', 'a test document'))"
# -> ('a1b2c3-...uuid...', 42)     # (document_id, chunk_count)
# and a faiss_index/ folder now exists on disk
✅ Milestone — you can turn a PDF into a searchable indexA (document_id, chunk_count) tuple and a faiss_index/ folder mean extraction → chunk → embed → store all work. The route in the next step is now just a thin HTTP wrapper over this proven function.
⚠ OCR needs system packagesStage 4 needs tesseract and poppler on the OS. Without them, scanned-only PDFs return no text and ingest_pdf raises a clear error; digital PDFs are unaffected.
STEP 11

routes/upload.py — the first route + grow main.py

Only now, with ingest_pdf tested, do we add the route that calls it. Watch how little it does: validate, read bytes, delegate. Then add one line to main.py.

terminal — from backend/
uv add python-multipart   # lets FastAPI accept file uploads
backend/app/routes/upload.py
"""
POST /api/upload

Accepts a PDF file + a text description, triggers document ingestion,
and returns the assigned document_id and chunk count.
"""

import logging

from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status

from app.models import UploadResponse
from app.services.document_processor import ingest_pdf

logger = logging.getLogger(__name__)
router = APIRouter()

_MAX_PDF_SIZE_MB = 50
_MAX_PDF_BYTES = _MAX_PDF_SIZE_MB * 1024 * 1024


@router.post(
    "/upload",
    response_model=UploadResponse,
    status_code=status.HTTP_201_CREATED,
    summary="Upload a PDF document for RAG ingestion",
)
async def upload_document(
    file: UploadFile = File(..., description="PDF document to ingest"),
    description: str = Form(..., min_length=5, max_length=1000),
) -> UploadResponse:
    """
    Upload a PDF alongside a descriptive context string.

    The backend will:
    1. Extract and chunk the PDF text.
    2. Generate embeddings via Google embedding-001.
    3. Persist chunks to the local FAISS index.

    Returns a `document_id` you can pass to `/api/query` to restrict
    retrieval to this document.
    """
    # Validate content type
    if file.content_type not in ("application/pdf", "application/octet-stream"):
        raise HTTPException(
            status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
            detail="Only PDF files are accepted.",
        )

    pdf_bytes = await file.read()

    if len(pdf_bytes) > _MAX_PDF_BYTES:
        raise HTTPException(
            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            detail=f"PDF exceeds the {_MAX_PDF_SIZE_MB} MB size limit.",
        )

    if len(pdf_bytes) == 0:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Uploaded file is empty.",
        )

    try:
        document_id, chunk_count = ingest_pdf(
            pdf_bytes=pdf_bytes,
            filename=file.filename or "document.pdf",
            description=description,
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
    except Exception as exc:
        logger.exception("Ingestion failed for file '%s'", file.filename)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Ingestion error: {exc}",
        )

    return UploadResponse(
        message="Document ingested successfully.",
        document_id=document_id,
        chunk_count=chunk_count,
        description=description,
    )

Grow main.py — add the import and one router line to the (currently empty) Routers section:

backend/app/main.py — add these 2 lines
# at the top, with the other imports:
from app.routes import upload

# in the "Routers" section:
app.include_router(upload.router, prefix="/api", tags=["Documents"])

Restart uvicorn, open /docs, and try POST /api/upload with a PDF + a description.

✅ Milestone 2 — your first real endpointUpload returns 201 with a document_id and chunk_count. You now have a working ingestion API on top of the walking skeleton — built service-first, so nothing ever pointed at code that did not exist.
💡 Why the route stays this thinSwap FAISS for another store tomorrow and you edit one service; this route never changes. Status codes: 201 created · 400 empty · 413 too large · 415 wrong type · 422 validation.
Phase 3 · vertical slice #2

The RAG query loop

The core feature, again bottom-up: reranker, then the pipeline (tested end to end), then the schemas it returns, then the route — one more line in main.py.

STEP 12

services/reranker.py — stage-2 precision

After FAISS returns 15 candidates, the Cross-Encoder scores every (query, chunk) pair together and keeps the top 5. A leaf service the pipeline will call — write it first.

backend/app/services/reranker.py
"""
Cross-Encoder re-ranker using sentence-transformers.

After FAISS retrieves `retrieval_top_k` candidates, this module scores
each (query, chunk) pair and returns the top `reranker_top_k` by relevance.
"""

import logging
from functools import lru_cache

from langchain_core.documents import Document
from sentence_transformers import CrossEncoder

from app.config import get_settings

logger = logging.getLogger(__name__)


@lru_cache(maxsize=1)
def _get_cross_encoder() -> CrossEncoder:
    settings = get_settings()
    logger.info("Loading CrossEncoder model: %s", settings.reranker_model)
    return CrossEncoder(settings.reranker_model)


def rerank(query: str, documents: list[Document]) -> list[Document]:
    """
    Score every (query, document) pair and return the top-k documents
    sorted by descending cross-encoder score.

    Parameters
    ----------
    query     : The (possibly rewritten) user question.
    documents : Candidate chunks from initial FAISS retrieval.

    Returns
    -------
    Reranked list of Documents (length ≤ reranker_top_k), each annotated
    with a `rerank_score` metadata field.
    """
    settings = get_settings()

    if not documents:
        return []

    encoder = _get_cross_encoder()

    # Build (query, passage) pairs for batch scoring
    pairs = [(query, doc.page_content) for doc in documents]
    scores: list[float] = encoder.predict(pairs).tolist()

    # Annotate and sort
    for doc, score in zip(documents, scores):
        doc.metadata["rerank_score"] = round(float(score), 4)

    ranked = sorted(documents, key=lambda d: d.metadata["rerank_score"], reverse=True)
    top_k = ranked[: settings.reranker_top_k]

    logger.debug(
        "Re-ranked %d → %d documents. Top score=%.4f",
        len(documents),
        len(top_k),
        top_k[0].metadata["rerank_score"] if top_k else 0.0,
    )
    return top_k
⭐ Why not cross-encode everything?A cross-encoder reads question + chunk together (accurate but slow). FAISS pre-embeds chunks once (fast lookup). Two stages give you the bi-encoder’s speed and the cross-encoder’s judgment, on just 15 items.
STEP 13

services/rag_pipeline.py — the chain, tested end to end

Stitch it together: rewrite the query → FAISS retrieve top-k → cross-encoder rerank → generate with the LLM. All LLM calls go through the seam. Because the doc you indexed in Step 10 is still on disk, you can run the whole pipeline before any route exists.

backend/app/services/rag_pipeline.py
"""
Advanced RAG pipeline.

Stages
------
1. Query Optimisation  — LLM rewrites the raw user question for better retrieval.
2. FAISS Retrieval     — Fetch `retrieval_top_k` candidate chunks.
3. Cross-Encoder Re-rank — Score & filter down to `reranker_top_k` chunks.
4. Generation          — Gemini 2.5 Flash (via OpenRouter) synthesises the answer.

All LLM calls are wrapped in tenacity exponential-backoff retry.
"""

import logging

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.documents import Document
from app.services.llm import get_llm
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    before_sleep_log,
)

from app.config import get_settings
from app.services.document_processor import load_vector_store
from app.services.reranker import rerank

logger = logging.getLogger(__name__)


# ── LLM ────────────────────────────────────────────────────────────
# get_llm() lives in app/services/llm.py, so every route and service
# shares one provider seam (Claude by default; OpenAI/OpenRouter switchable).


# ── Prompts ───────────────────────────────────────────────────────────────────

_REWRITE_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            (
                "You are an expert query optimiser for a document retrieval system. "
                "Your task is to rewrite the user's question to be more precise and "
                "retrieval-friendly, using technical language where appropriate. "
                "Return ONLY the rewritten query — no explanation, no preamble."
            ),
        ),
        ("human", "Original question: {question}"),
    ]
)

_RAG_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            (
                "You are a helpful and knowledgeable AI learning assistant. "
                "Answer the user's question using ONLY the provided context. "
                "If the context does not contain enough information, say so honestly. "
                "Cite relevant page numbers when possible. "
                "Be thorough, structured, and educational in your response."
            ),
        ),
        (
            "human",
            (
                "Context:\n{context}\n\n"
                "Question: {question}\n\n"
                "Answer:"
            ),
        ),
    ]
)


# ── Retry-wrapped LLM invoke ──────────────────────────────────────────────────

def _is_retryable(exc: BaseException) -> bool:
    status = getattr(getattr(exc, "response", None), "status_code", None)
    if status and (status == 429 or status >= 500):
        return True
    return isinstance(exc, (ConnectionError, TimeoutError, OSError))


@retry(
    retry=lambda rs: _is_retryable(rs.outcome.exception()) if rs.outcome.failed else False,
    wait=wait_exponential(multiplier=1, min=2, max=60),
    stop=stop_after_attempt(6),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
def _invoke_llm(llm, prompt: ChatPromptTemplate, **kwargs) -> str:
    chain = prompt | llm
    response = chain.invoke(kwargs)
    return response.content


# ── Pipeline stages ───────────────────────────────────────────────────────────

def _optimise_query(question: str) -> str:
    """Stage 1: Rewrite the question for better vector-space retrieval."""
    llm = get_llm()
    try:
        rewritten = _invoke_llm(llm, _REWRITE_PROMPT, question=question)
        logger.info("Query rewrite: '%s' → '%s'", question, rewritten)
        return rewritten.strip()
    except Exception as exc:
        logger.warning("Query rewrite failed (%s), using original question.", exc)
        return question


def _retrieve(query: str, document_id: str | None) -> list[Document]:
    """Stage 2: FAISS similarity search, optionally filtered by document_id."""
    settings = get_settings()
    vector_store = load_vector_store()

    if document_id:
        # LangChain's FAISS filter= parameter is unreliable (returns far fewer
        # results than the matching chunks). Fetch the full index and filter manually.
        total = len(vector_store.docstore._dict)
        all_results = vector_store.similarity_search(query, k=total)
        docs = [
            d for d in all_results
            if d.metadata.get("document_id") == document_id
        ][:settings.retrieval_top_k]
    else:
        docs = vector_store.similarity_search(query, k=settings.retrieval_top_k)

    logger.debug("Retrieved %d raw candidates.", len(docs))
    return docs


def _build_context(docs: list[Document]) -> str:
    """Format reranked docs into a structured context block for the LLM."""
    parts = []
    for i, doc in enumerate(docs, start=1):
        page = doc.metadata.get("page", "?")
        score = doc.metadata.get("rerank_score", "n/a")
        parts.append(
            f"[Chunk {i} | Page {page} | Relevance {score}]\n{doc.page_content}"
        )
    return "\n\n---\n\n".join(parts)


# ── Public entry point ────────────────────────────────────────────────────────

def run_rag(
    question: str,
    document_id: str | None = None,
) -> dict:
    """
    Execute the full RAG pipeline and return a result dict containing:
    - answer         : LLM-generated answer
    - rewritten_query: query after optimisation
    - sources        : list of SourceChunk-compatible dicts
    """
    # Stage 1 — query optimisation
    rewritten_query = _optimise_query(question)

    # Stage 2 — retrieval
    candidates = _retrieve(rewritten_query, document_id)
    if not candidates:
        return {
            "answer": "I could not find relevant information in the uploaded document.",
            "rewritten_query": rewritten_query,
            "sources": [],
        }

    # Stage 3 — re-ranking
    top_docs = rerank(rewritten_query, candidates)

    # Stage 4 — generation
    context = _build_context(top_docs)
    llm = get_llm()
    answer = _invoke_llm(llm, _RAG_PROMPT, context=context, question=rewritten_query)

    sources = [
        {
            "content": doc.page_content[:400],  # truncate for payload size
            "page": doc.metadata.get("page"),
            "score": doc.metadata.get("rerank_score"),
        }
        for doc in top_docs
    ]

    return {
        "answer": answer.strip(),
        "rewritten_query": rewritten_query,
        "sources": sources,
    }
🔁 Provider swapThe recording defined a local _get_llm() hard-wired to OpenRouter here. We deleted it and import get_llm from the seam; _optimise_query and run_rag call get_llm(). prompt | llm and response.content are pure LangChain and work identically for Claude.

Ask your indexed document a question, no server needed:

terminal — from backend/
uv run python -c "from app.services.rag_pipeline import run_rag; r=run_rag('What is this document about?'); print('ANSWER:', r['answer'][:300]); print('REWRITTEN:', r['rewritten_query']); print('SOURCES:', len(r['sources']))"
✅ Milestone — the full RAG loop worksA grounded answer plus a rewritten query and source chunks means retrieve → rerank → generate is all wired correctly — Claude answering from your document. The query route is now just the HTTP wrapper.
🧩 Two prompts, two jobs_REWRITE_PROMPT turns a vague question into a retrieval-friendly one (invisible to the user). _RAG_PROMPT answers using only the provided context, admits when the context is thin, and cites pages — that last instruction is what stops hallucination.
STEP 14

app/models.py — grow it (query schemas)

Append the query schemas to the models file you started in Step 9. Same file, three more classes — added exactly when the feature needs them.

backend/app/models.py — append (query)
# ── Query ─────────────────────────────────────────────────────────────────────
# Append these to models.py when you build the query route.

class QueryRequest(BaseModel):
    question: str = Field(..., min_length=3, max_length=2000)
    document_id: str | None = Field(
        default=None,
        description="If provided, restrict retrieval to this document's chunks.",
    )
    stream: bool = False


class SourceChunk(BaseModel):
    content: str
    page: int | None = None
    score: float | None = None


class QueryResponse(BaseModel):
    answer: str
    sources: list[SourceChunk]
    rewritten_query: str
    document_id: str | None = None
STEP 15

routes/query.py — the RAG route + grow main.py

With run_rag proven and the schemas in place, the route is trivial: validate, delegate, shape the response. Then one more line in main.py.

backend/app/routes/query.py
"""
POST /api/query

Runs the full RAG pipeline (query rewrite → retrieval → rerank → generation)
and returns the answer with source citations.
"""

import logging

from fastapi import APIRouter, HTTPException, status

from app.models import QueryRequest, QueryResponse, SourceChunk
from app.services.rag_pipeline import run_rag

logger = logging.getLogger(__name__)
router = APIRouter()


@router.post(
    "/query",
    response_model=QueryResponse,
    summary="Ask a question against ingested documents",
)
async def query_documents(payload: QueryRequest) -> QueryResponse:
    """
    Ask a natural-language question.

    The pipeline will:
    1. Rewrite/expand your question for optimal retrieval.
    2. Fetch the top-k semantically relevant chunks from FAISS.
    3. Re-rank them with a Cross-Encoder.
    4. Feed the top results + question to Gemini 2.5 Flash via OpenRouter.

    Optionally supply `document_id` to restrict answers to a single document.
    """
    try:
        result = run_rag(
            question=payload.question,
            document_id=payload.document_id,
        )
    except FileNotFoundError as exc:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
    except Exception as exc:
        logger.exception("RAG pipeline error for question: %s", payload.question)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Pipeline error: {exc}",
        )

    sources = [SourceChunk(**s) for s in result["sources"]]

    return QueryResponse(
        answer=result["answer"],
        sources=sources,
        rewritten_query=result["rewritten_query"],
        document_id=payload.document_id,
    )
backend/app/main.py — add
from app.routes import query                                  # top imports
app.include_router(query.router, prefix="/api", tags=["RAG"])   # Routers section
✅ Milestone 3 — ask your PDF anythingRestart, open /docs, POST /api/query with a question, and read a grounded answer with sources — the whole product loop, working over HTTP.
💡 async = the restaurant waiterEvery route is async def: the server starts a slow LLM call and serves other requests meanwhile, like a waiter taking other tables while one reads the menu. More throughput, no extra threads.
Phase 4 · the extras

Topics, suggestions, evaluation, voice

Four more features, each following the same rhythm you now know: reuse existing services, add a thin route, add one line to main.py (and for eval, a few more schemas).

STEP 16

routes/topics.py — sidebar topics

Both services it needs already exist (load_vector_store, get_llm). It samples chunks evenly across the whole document and asks the LLM for 5–8 topics + one-liners.

backend/app/routes/topics.py
"""
POST /api/topics

Scans the document's chunks and asks the LLM to identify the main topics/sections.
Returns a list of { title, description } objects for the sidebar.
"""

import logging

from fastapi import APIRouter, HTTPException
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel

from app.services.document_processor import load_vector_store
from app.services.llm import get_llm

logger = logging.getLogger(__name__)
router = APIRouter()

_TOPICS_PROMPT = ChatPromptTemplate.from_messages([
    (
        "system",
        (
            "You are a document analyst. Based on the document excerpts provided, "
            "identify 5-8 main topics or sections in the document. "
            "For each topic return exactly this format on its own line:\n"
            "TOPIC: <short title> | <one sentence description>\n"
            "Return nothing else."
        ),
    ),
    ("human", "Document excerpts:\n\n{context}"),
])


class TopicsRequest(BaseModel):
    document_id: str


class Topic(BaseModel):
    title: str
    description: str


class TopicsResponse(BaseModel):
    topics: list[Topic]


@router.post("/topics", response_model=TopicsResponse)
async def get_topics(payload: TopicsRequest) -> TopicsResponse:
    try:
        vector_store = load_vector_store()
        all_docs = list(vector_store.docstore._dict.values())
        chunks = [
            d for d in all_docs
            if getattr(d, "metadata", {}).get("document_id") == payload.document_id
        ]
    except Exception as exc:
        logger.warning("Vector store error: %s", exc)
        raise HTTPException(status_code=404, detail="Document not found.")

    if not chunks:
        raise HTTPException(status_code=404, detail="No chunks for this document.")

    # Sample evenly across the document for broad coverage
    step = max(1, len(chunks) // 8)
    sampled = chunks[::step][:8]
    context = "\n\n---\n\n".join(c.page_content[:350] for c in sampled)

    llm = get_llm()
    try:
        chain = _TOPICS_PROMPT | llm
        result = chain.invoke({"context": context})
        raw = result.content.strip()
    except Exception as exc:
        logger.error("LLM topics error: %s", exc)
        raise HTTPException(status_code=502, detail=f"LLM error: {exc}")

    topics: list[Topic] = []
    for line in raw.splitlines():
        line = line.strip()
        if not line.startswith("TOPIC:"):
            continue
        rest = line[len("TOPIC:"):].strip()
        if "|" in rest:
            title, desc = rest.split("|", 1)
            topics.append(Topic(title=title.strip(), description=desc.strip()))
        else:
            topics.append(Topic(title=rest.strip(), description=""))

    if not topics:
        raise HTTPException(status_code=502, detail="Could not extract topics.")

    return TopicsResponse(topics=topics)
backend/app/main.py — add
from app.routes import topics
app.include_router(topics.router, prefix="/api", tags=["Topics"])
🔁 Provider swapThe only change from the recording: from app.services.llm import get_llm and get_llm(). Prompt, sampling, parsing untouched — the seam paying off.
⭐ Sample the WHOLE docstep = max(1, len(chunks)//8) then chunks[::step][:8] spreads the sample so topics reflect the ending of a 200-page PDF, not just page 1.
STEP 17

routes/suggestions.py — 4 starter questions

Same shape as topics: sample a few chunks, ask for exactly four starter questions, return a clean list.

backend/app/routes/suggestions.py
"""
POST /api/suggestions

Generates 4 document-specific question suggestions using the LLM + a sample
of document chunks. Returns a clean JSON list — no full RAG pipeline needed.
"""

import logging

from fastapi import APIRouter, HTTPException
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel

from app.services.document_processor import load_vector_store
from app.services.llm import get_llm

logger = logging.getLogger(__name__)
router = APIRouter()

_SUGGESTIONS_PROMPT = ChatPromptTemplate.from_messages([
    (
        "system",
        (
            "You are a helpful learning assistant. Based on the document excerpts provided, "
            "generate exactly 4 specific, insightful questions a student would genuinely want to ask. "
            "Each question must be unique, targeting a different concept or section. "
            "Return ONLY the 4 questions, one per line, no numbering, no bullets, no extra text."
        ),
    ),
    ("human", "Document excerpts:\n\n{context}"),
])


class SuggestionsRequest(BaseModel):
    document_id: str


class SuggestionsResponse(BaseModel):
    suggestions: list[str]


@router.post("/suggestions", response_model=SuggestionsResponse)
async def get_suggestions(payload: SuggestionsRequest) -> SuggestionsResponse:
    try:
        vector_store = load_vector_store()
        # Scan docstore directly — avoids FAISS filter k-cutoff issues
        all_docs = list(vector_store.docstore._dict.values())
        docs = [
            d for d in all_docs
            if getattr(d, "metadata", {}).get("document_id") == payload.document_id
        ][:6]
    except Exception as exc:
        logger.warning("Vector store lookup failed: %s", exc)
        raise HTTPException(status_code=404, detail="Document not found in index.")

    if not docs:
        raise HTTPException(status_code=404, detail="No chunks found for this document.")

    context = "\n\n---\n\n".join(doc.page_content[:400] for doc in docs)

    llm = get_llm()
    try:
        chain = _SUGGESTIONS_PROMPT | llm
        result = chain.invoke({"context": context})
        raw = result.content.strip()
    except Exception as exc:
        logger.error("LLM suggestions error: %s", exc)
        raise HTTPException(status_code=502, detail=f"LLM error: {exc}")

    suggestions = [
        line.strip()
        for line in raw.splitlines()
        if line.strip() and len(line.strip()) > 10
    ][:4]

    if len(suggestions) < 2:
        raise HTTPException(status_code=502, detail="Could not generate suggestions.")

    return SuggestionsResponse(suggestions=suggestions)
backend/app/main.py — add
from app.routes import suggestions
app.include_router(suggestions.router, prefix="/api", tags=["Suggestions"])
🔁 Provider swapIdentical two edits (import + call the seam). Note the hard [:4] cap — even a strict prompt occasionally over-produces.
STEP 18

services/evaluator.py — RAGAS scoring

A service (write before its route) that scores an answer on faithfulness, answer-relevancy, context-precision, and — only with a ground truth — context-recall, then appends each run to a JSONL log.

terminal — from backend/
uv add ragas datasets
backend/app/services/evaluator.py
"""
RAGAS-based evaluation of RAG outputs.

Metrics computed
----------------
- faithfulness       : Is the answer grounded in the retrieved context?
- answer_relevancy   : How relevant is the answer to the question?
- context_precision  : Are the retrieved contexts precise and on-topic?
- context_recall     : (Requires ground_truth) Did we retrieve what was needed?

Results are logged to both stdout (structlog) and a JSONL file for persistence.
"""

import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path

import structlog

from app.config import get_settings

logger = structlog.get_logger(__name__)


def _ensure_log_dir(log_path: str) -> Path:
    path = Path(log_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    return path


def evaluate_rag(
    question: str,
    answer: str,
    contexts: list[str],
    ground_truth: str | None = None,
) -> dict:
    """
    Run RAGAS evaluation and return a metrics dict.

    Parameters
    ----------
    question     : The original user question.
    answer       : The LLM-generated answer.
    contexts     : List of retrieved context strings used for generation.
    ground_truth : Optional reference answer (enables context_recall).

    Returns
    -------
    Dict with metric names → float scores (None if metric could not be computed).
    """
    try:
        from datasets import Dataset
        from ragas import evaluate
        from ragas.metrics import (
            answer_relevancy,
            context_precision,
            faithfulness,
        )

        metrics_to_run = [faithfulness, answer_relevancy, context_precision]

        # context_recall requires ground_truth
        if ground_truth:
            from ragas.metrics import context_recall
            metrics_to_run.append(context_recall)

        # Build a single-row HuggingFace Dataset (ragas input format)
        data = {
            "question": [question],
            "answer": [answer],
            "contexts": [contexts],
        }
        if ground_truth:
            data["ground_truth"] = [ground_truth]

        dataset = Dataset.from_dict(data)

        settings = get_settings()

        # RAGAS uses LangChain under the hood; reuse the SAME provider seam
        # as the rest of the app (Claude by default) plus local embeddings.
        from langchain_community.embeddings import HuggingFaceEmbeddings

        from app.services.llm import get_llm

        ragas_llm = get_llm()

        ragas_embeddings = HuggingFaceEmbeddings(
            model_name=settings.embedding_model,
            model_kwargs={"device": "cpu"},
            encode_kwargs={"normalize_embeddings": True},
        )

        result = evaluate(
            dataset,
            metrics=metrics_to_run,
            llm=ragas_llm,
            embeddings=ragas_embeddings,
            raise_exceptions=False,
        )

        scores = result.to_pandas().iloc[0].to_dict()

        metrics = {
            "faithfulness": _safe_float(scores.get("faithfulness")),
            "answer_relevancy": _safe_float(scores.get("answer_relevancy")),
            "context_precision": _safe_float(scores.get("context_precision")),
            "context_recall": _safe_float(scores.get("context_recall")),
        }

    except Exception as exc:
        logger.warning("RAGAS evaluation failed", error=str(exc))
        metrics = {
            "faithfulness": None,
            "answer_relevancy": None,
            "context_precision": None,
            "context_recall": None,
        }

    _log_metrics(question, answer, metrics)
    return metrics


def _safe_float(value) -> float | None:
    try:
        return round(float(value), 4)
    except (TypeError, ValueError):
        return None


def _log_metrics(question: str, answer: str, metrics: dict) -> None:
    """Append evaluation record to the JSONL log file and emit to stdout."""
    settings = get_settings()
    log_path = _ensure_log_dir(settings.eval_log_path)

    record = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "question": question[:200],
        "answer_preview": answer[:200],
        "metrics": metrics,
    }

    # Structured log to stdout
    logger.info("ragas_eval", **record)

    # Persist to JSONL file
    with open(log_path, "a", encoding="utf-8") as fh:
        fh.write(json.dumps(record) + "\n")
🔁 Provider swapRAGAS uses a LangChain LLM under the hood; we reuse the same get_llm() seam so evaluation runs on whatever provider the app runs on (Claude by default). Embeddings stay the local model.
🧩 3 metrics vs 4Always run faithfulness, answer_relevancy, context_precision. context_recall needs a reference answer, so it is appended only when ground_truth is given.
STEP 19

models.py (grow) + routes/evaluate.py

Append the eval schemas, then add the route that delegates to evaluate_rag, then one more line in main.py.

backend/app/models.py — append (evaluation)
# ── Evaluation ────────────────────────────────────────────────────────────────
# Append these to models.py when you build the evaluate route.

class EvalRequest(BaseModel):
    question: str
    answer: str
    contexts: list[str]
    ground_truth: str | None = None


class EvalMetrics(BaseModel):
    faithfulness: float | None = None
    answer_relevancy: float | None = None
    context_precision: float | None = None
    context_recall: float | None = None


class EvalResponse(BaseModel):
    metrics: EvalMetrics
    logged: bool
backend/app/routes/evaluate.py
"""
POST /api/evaluate

Trigger RAGAS evaluation on a previously generated answer.
Metrics are returned in the response AND persisted to a JSONL log file.
"""

import logging

from fastapi import APIRouter, HTTPException, status

from app.models import EvalMetrics, EvalRequest, EvalResponse
from app.services.evaluator import evaluate_rag

logger = logging.getLogger(__name__)
router = APIRouter()


@router.post(
    "/evaluate",
    response_model=EvalResponse,
    summary="Evaluate RAG output quality with RAGAS",
)
async def evaluate_output(payload: EvalRequest) -> EvalResponse:
    """
    Run RAGAS evaluation metrics on a question/answer/context triplet.

    - **faithfulness**      — Is the answer supported by the contexts?
    - **answer_relevancy**  — Is the answer relevant to the question?
    - **context_precision** — Are the contexts precise and on-topic?
    - **context_recall**    — (Only when `ground_truth` is provided.)

    Scores are appended to the `EVAL_LOG_PATH` JSONL file for
    offline analysis and observability.
    """
    if not payload.contexts:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="At least one context string is required for evaluation.",
        )

    try:
        metrics_dict = evaluate_rag(
            question=payload.question,
            answer=payload.answer,
            contexts=payload.contexts,
            ground_truth=payload.ground_truth,
        )
    except Exception as exc:
        logger.exception("Evaluation endpoint error.")
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Evaluation error: {exc}",
        )

    return EvalResponse(
        metrics=EvalMetrics(**metrics_dict),
        logged=True,
    )
backend/app/main.py — add
from app.routes import evaluate
app.include_router(evaluate.router, prefix="/api", tags=["Evaluation"])
STEP 20

routes/tts.py — text to speech

The one place Claude cannot serve: audio. This route rewrites the answer into a warm spoken script (using the primary LLM — Claude), then streams PCM16 audio from an OpenAI-family audio model and wraps it in a WAV container for the browser.

backend/app/routes/tts.py
"""
POST /api/tts

Uses openai/gpt-4o-audio-preview via OpenRouter.
Parses the SSE stream directly with httpx to reliably extract PCM16 audio chunks,
then wraps them in a WAV container for browser playback.
"""

import base64
import json
import logging
import re
import struct

import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel

from app.config import get_settings
from app.services.llm import get_llm
from langchain_core.prompts import ChatPromptTemplate

logger = logging.getLogger(__name__)
router = APIRouter()

ALLOWED_VOICES = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"}

_SPOKEN_REWRITE_PROMPT = ChatPromptTemplate.from_messages([
    (
        "system",
        (
            "You are a skilled audio narrator for a learning app. "
            "Rewrite the following text as a natural, engaging spoken explanation — "
            "like a knowledgeable teacher talking warmly to a curious student. "
            "Remove ALL markdown (headers, bullets, bold, asterisks, code blocks). "
            "Use smooth transitions. Be clear and engaging. "
            "Return ONLY the spoken script, nothing else."
        ),
    ),
    ("human", "{text}"),
])


def _strip_markdown(text: str) -> str:
    text = re.sub(r"#{1,6}\s+", "", text)
    text = re.sub(r"\*{1,3}(.+?)\*{1,3}", r"\1", text)
    text = re.sub(r"`{1,3}[^`]*`{1,3}", "", text)
    text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
    text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
    text = re.sub(r"\n{2,}", ". ", text)
    text = re.sub(r"\n", " ", text)
    return text.strip()


def _pcm16_to_wav(pcm_data: bytes, sample_rate: int = 24000, channels: int = 1) -> bytes:
    data_size   = len(pcm_data)
    byte_rate   = sample_rate * channels * 2
    block_align = channels * 2
    header = struct.pack(
        "<4sI4s4sIHHIIHH4sI",
        b"RIFF", 36 + data_size,
        b"WAVE",
        b"fmt ", 16,
        1, channels, sample_rate,
        byte_rate, block_align, 16,
        b"data", data_size,
    )
    return header + pcm_data


async def _stream_pcm16(url: str, headers: dict, body: dict) -> list[bytes]:
    """Extracted for testability — streams OpenRouter SSE and returns PCM16 chunks."""
    pcm_chunks: list[bytes] = []
    async with httpx.AsyncClient(timeout=60) as client:
        async with client.stream("POST", url, headers=headers, json=body) as resp:
            if resp.status_code != 200:
                raw = await resp.aread()
                raise HTTPException(status_code=502, detail=raw.decode())
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                payload_str = line[6:]
                if payload_str.strip() == "[DONE]":
                    break
                try:
                    data = json.loads(payload_str)
                    choices = data.get("choices", [])
                    if not choices:
                        continue
                    audio = choices[0].get("delta", {}).get("audio") or {}
                    chunk_b64 = audio.get("data")
                    if chunk_b64:
                        pcm_chunks.append(base64.b64decode(chunk_b64))
                except Exception:
                    continue
    return pcm_chunks


class TTSRequest(BaseModel):
    text: str
    voice: str = "nova"


@router.post("/tts", summary="AI speech via gpt-4o-audio-preview on OpenRouter")
async def text_to_speech(payload: TTSRequest) -> Response:
    settings = get_settings()
    voice = payload.voice if payload.voice in ALLOWED_VOICES else "nova"

    # Claude has no audio model, so TTS uses an OpenAI-family model, either
    # OpenAI directly or the same model via the OpenRouter gateway.
    if settings.tts_provider.lower() == "openrouter":
        audio_base = settings.openrouter_base_url
        audio_key = settings.openrouter_api_key
        audio_model = f"openai/{settings.tts_model}"
    else:
        audio_base = settings.openai_base_url
        audio_key = settings.openai_api_key
        audio_model = settings.tts_model

    # Stage 1: LLM rewrite
    llm = get_llm()
    try:
        chain = _SPOKEN_REWRITE_PROMPT | llm
        result = chain.invoke({"text": payload.text})
        spoken_text = result.content.strip()
    except Exception as exc:
        logger.warning("Audio rewrite failed (%s) — using stripped text.", exc)
        spoken_text = _strip_markdown(payload.text)

    # Stage 2: Stream audio via raw httpx SSE
    try:
        pcm_chunks = await _stream_pcm16(
            url=f"{audio_base}/chat/completions",
            headers={
                "Authorization": f"Bearer {audio_key}",
                "Content-Type": "application/json",
                "HTTP-Referer": "https://my-learning-ai-assistant.local",
                "X-Title": "My Learning AI Assistant",
            },
            body={
                "model": audio_model,
                "modalities": ["text", "audio"],
                "audio": {"voice": voice, "format": "pcm16"},
                "messages": [{"role": "user", "content": spoken_text}],
                "stream": True,
            },
        )
    except HTTPException:
        raise
    except Exception as exc:
        logger.error("TTS stream error: %s", exc)
        raise HTTPException(status_code=502, detail=f"TTS error: {exc}")

    if not pcm_chunks:
        raise HTTPException(status_code=502, detail="No audio data received from model.")

    wav_bytes = _pcm16_to_wav(b"".join(pcm_chunks))
    return Response(
        content=wav_bytes,
        media_type="audio/wav",
        headers={"Content-Disposition": "inline; filename=speech.wav"},
    )
backend/app/main.py — add
from app.routes import tts
app.include_router(tts.router, prefix="/api", tags=["Audio"])
🔁 Provider swap — the audio exceptionTwo changes: the script-rewrite step calls get_llm() so Claude writes the narration; the audio itself is provider-aware — TTS_PROVIDER=openai hits api.openai.com with gpt-4o-audio-preview, openrouter reuses the gateway. Anthropic has no speech model, so this stays OpenAI-family by necessity. Set OPENAI_API_KEY (or TTS_PROVIDER=openrouter) in .env if you want voice.
Phase 5 · package & ship

Assemble, containerise, run

Look at the files you grew, pin dependencies, add Docker + CI, and run the whole stack — frontend included.

STEP 21

The assembled main.py & models.py (reference)

You grew both files one feature at a time. Here is the finished shape to check yours against — six routers wired in, all schemas present. This matches the reference repo exactly.

backend/app/main.py — final (all routers)
"""
FastAPI application entry point.

Registers all routers, configures CORS, and sets up structured logging.
Run with: uvicorn app.main:app --reload
"""

import logging
import sys

import structlog
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from app.config import get_settings
from app.routes import evaluate, query, suggestions, topics, tts, upload

# ── Structured logging setup ─────────────────────────────────────────────────

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.dev.ConsoleRenderer() if sys.stderr.isatty()
        else structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    context_class=dict,
    logger_factory=structlog.PrintLoggerFactory(),
)

settings = get_settings()
logging.basicConfig(
    level=getattr(logging, settings.log_level.upper(), logging.INFO),
    stream=sys.stdout,
)

# ── Application ───────────────────────────────────────────────────────────────

app = FastAPI(
    title="My Learning AI Assistant",
    description=(
        "Advanced RAG API powered by Gemini 2.5 Flash (OpenRouter), "
        "Google Embeddings, FAISS, and RAGAS evaluation."
    ),
    version="1.0.0",
    docs_url="/docs",
    redoc_url="/redoc",
)

# ── CORS ──────────────────────────────────────────────────────────────────────

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Routers ───────────────────────────────────────────────────────────────────

app.include_router(upload.router,       prefix="/api", tags=["Documents"])
app.include_router(query.router,        prefix="/api", tags=["RAG"])
app.include_router(evaluate.router,     prefix="/api", tags=["Evaluation"])
app.include_router(tts.router,          prefix="/api", tags=["Audio"])
app.include_router(suggestions.router,  prefix="/api", tags=["Suggestions"])
app.include_router(topics.router,       prefix="/api", tags=["Topics"])


# ── Health check ──────────────────────────────────────────────────────────────

@app.get("/health", tags=["System"])
async def health_check() -> JSONResponse:
    return JSONResponse({"status": "ok", "service": "my-learning-ai-assistant"})


# ── Root ──────────────────────────────────────────────────────────────────────

@app.get("/", tags=["System"])
async def root() -> JSONResponse:
    return JSONResponse({
        "message": "My Learning AI Assistant API",
        "docs": "/docs",
    })
backend/app/models.py — final (all schemas)
"""
Pydantic request / response models shared across routes.
"""

from pydantic import BaseModel, Field


# ── Upload ────────────────────────────────────────────────────────────────────

class UploadResponse(BaseModel):
    message: str
    document_id: str
    chunk_count: int
    description: str


# ── Query ─────────────────────────────────────────────────────────────────────

class QueryRequest(BaseModel):
    question: str = Field(..., min_length=3, max_length=2000)
    document_id: str | None = Field(
        default=None,
        description="If provided, restrict retrieval to this document's chunks.",
    )
    stream: bool = False


class SourceChunk(BaseModel):
    content: str
    page: int | None = None
    score: float | None = None


class QueryResponse(BaseModel):
    answer: str
    sources: list[SourceChunk]
    rewritten_query: str
    document_id: str | None = None


# ── Evaluation ────────────────────────────────────────────────────────────────

class EvalRequest(BaseModel):
    question: str
    answer: str
    contexts: list[str]
    ground_truth: str | None = None


class EvalMetrics(BaseModel):
    faithfulness: float | None = None
    answer_relevancy: float | None = None
    context_precision: float | None = None
    context_recall: float | None = None


class EvalResponse(BaseModel):
    metrics: EvalMetrics
    logged: bool
🧩 Why we did not write these up frontPresented first, this file is six imports of things that do not exist and a route calling an unwritten service — the exact “copy the repo” feeling. Grown last-line-at-a-time, each router appeared only after its route (and that route’s service) was real and tested.
STEP 22

requirements.txt — pin for Docker & CI

uv tracked your deps in pyproject.toml as you went. For the Dockerfile and CI (which install from a requirements file), export a pinned list — or write it out. This is the full set the project ends up needing:

terminal — from backend/
uv pip compile pyproject.toml -o requirements.txt   # or: uv export --no-hashes -o requirements.txt
backend/requirements.txt (reference pins)
# Core API framework
fastapi==0.115.5
uvicorn[standard]==0.32.1
python-multipart==0.0.12

# LangChain ecosystem
langchain==0.3.9
langchain-community==0.3.9
langchain-openai==0.2.10
langchain-text-splitters==0.3.2
langchain-huggingface==0.1.2

# Vector store
faiss-cpu==1.9.0.post1

# PDF processing — multi-engine for maximum compatibility
pypdf==5.1.0
pdfplumber==0.11.4
pymupdf==1.25.3
# OCR for scanned / image-only PDFs (requires tesseract + poppler system packages)
pytesseract==0.3.13
pdf2image==1.17.0
pillow==11.0.0

# Re-ranking (Cross-Encoder)
sentence-transformers==3.3.1

# RAG Evaluation
ragas==0.2.6
datasets==3.1.0

# Resilience / retry
tenacity==9.0.0

# Config & environment
pydantic-settings==2.6.1
python-dotenv==1.0.1

# HTTP client
httpx==0.28.0

# Logging
structlog==24.4.0

# Testing
pytest==8.3.4
pytest-asyncio==0.24.0
httpx==0.28.0

# Linting / formatting
ruff==0.8.3
🔁 Provider swap — the Claude client is hereThe repo list is OpenRouter-era; make sure these two are present for the primary Anthropic path (the OpenAI/OpenRouter branches use the already-listed langchain-openai):
backend/requirements.txt — ensure present
langchain-anthropic==0.3.3
anthropic>=0.42.0
STEP 23

backend/Dockerfile

A two-stage build on python:3.11-slim: install deps in a builder, copy just the installed packages + app into a lean runtime, run as non-root. Provider-independent.

backend/Dockerfile
# ─── Builder stage ────────────────────────────────────────────────────────────
FROM python:3.11-slim AS builder

WORKDIR /build

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    gcc \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --upgrade pip && \
    pip install --prefix=/install --no-cache-dir -r requirements.txt


# ─── Runtime stage ────────────────────────────────────────────────────────────
FROM python:3.11-slim AS runtime

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application source
COPY app/ ./app/

# Non-root user for security
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

# Persistent volume mounts (FAISS index and logs)
VOLUME ["/app/faiss_index", "/app/logs"]

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
⚠ OCR in the imageIf you want Stage-4 OCR to work in Docker, add tesseract-ocr and poppler-utils to an apt-get install line in the runtime stage.
STEP 24

docker-compose.yml

Wires both services: backend on :8000, frontend on :3000, a shared network, named volumes for the FAISS index and logs, and a health-check gate so the frontend waits for the backend.

docker-compose.yml
version: "3.9"

# ── Shared network ────────────────────────────────────────────────────────────
networks:
  app_net:
    driver: bridge

# ── Named volumes for persistence ─────────────────────────────────────────────
volumes:
  faiss_data:
  eval_logs:

services:

  # ── FastAPI Backend ──────────────────────────────────────────────────────────
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
      target: runtime
    container_name: mlai_backend
    restart: unless-stopped
    env_file:
      - ./backend/.env          # copy .env.example → .env and fill values
    environment:
      - ALLOWED_ORIGINS=http://localhost:3000
      - FAISS_INDEX_PATH=/data/faiss_index
      - EVAL_LOG_PATH=/data/logs/ragas_eval.jsonl
    volumes:
      - faiss_data:/data/faiss_index
      - eval_logs:/data/logs
    ports:
      - "8000:8000"
    networks:
      - app_net
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

  # ── Next.js Frontend ─────────────────────────────────────────────────────────
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        NEXT_PUBLIC_API_URL: http://backend:8000
    container_name: mlai_frontend
    restart: unless-stopped
    environment:
      - NEXT_PUBLIC_API_URL=http://backend:8000
    ports:
      - "3000:3000"
    networks:
      - app_net
    depends_on:
      backend:
        condition: service_healthy
🔁 Provider swap — nothing to changeCompose reads ./backend/.env via env_file, so LLM_PROVIDER and ANTHROPIC_API_KEY flow into the container. Just make sure .env exists and has your key.
STEP 25

CI/CD, .env.example & pyproject.toml

Commit a blank-key template (your real .env is git-ignored):

backend/.env.example
# ─── LLM provider ─────────────────────────────────────────────────────────────
# "anthropic" (primary) | "openai" | "openrouter" (demo fallback)
LLM_PROVIDER=anthropic

# Set ONLY the key for the provider you selected above.
# Anthropic: https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=Your_Anthropic_API_KEY
# OpenAI:    https://platform.openai.com/api-keys
# OPENAI_API_KEY=Your_OpenAI_API_KEY
# OpenRouter (demo fallback): https://openrouter.ai/keys
# OPENROUTER_API_KEY=Your_OpenRouter_API_KEY

# ─── Text-to-speech (audio) ───────────────────────────────────────────────────
# Claude has no audio model, so TTS uses an OpenAI-family model.
# "openai" needs OPENAI_API_KEY; "openrouter" reuses OPENROUTER_API_KEY.
TTS_PROVIDER=openai

# NOTE: Embeddings run locally via sentence-transformers — no API key needed.

# ─── Application ──────────────────────────────────────────────────────────────
ALLOWED_ORIGINS=http://localhost:3000
FAISS_INDEX_PATH=./faiss_index
LOG_LEVEL=INFO
EVAL_LOG_PATH=./logs/ragas_eval.jsonl

The ruff + pytest config (uv already made a pyproject.toml; add these tool sections):

backend/pyproject.toml
[project]
name = "my-learning-ai-assistant-backend"
version = "1.0.0"
requires-python = ">=3.11"

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]

[tool.ruff.lint.isort]
known-first-party = ["app"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

And the GitHub Actions workflow — lint, test, build both images, deploy placeholder, on every push:

.github/workflows/main.yml
name: CI/CD — My Learning AI Assistant

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

# Cancel redundant runs on the same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  PYTHON_VERSION: "3.11"
  NODE_VERSION: "20"

jobs:

  # ════════════════════════════════════════════════════════════════════════════
  # 1. Backend — Lint, Type-check, Test
  # ════════════════════════════════════════════════════════════════════════════
  backend-ci:
    name: "Backend — Lint & Test"
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: backend

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ env.PYTHON_VERSION }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - name: Install dependencies
        run: pip install --upgrade pip && pip install -r requirements.txt

      - name: Lint with Ruff
        run: ruff check . --output-format=github

      - name: Format check with Ruff
        run: ruff format --check .

      - name: Run tests
        env:
          # Provide dummy keys so settings validation passes in CI
          OPENROUTER_API_KEY: ci-dummy-key
          GOOGLE_API_KEY: ci-dummy-key
        run: pytest tests/ -v --tb=short

  # ════════════════════════════════════════════════════════════════════════════
  # 2. Frontend — Lint, Type-check, Build
  # ════════════════════════════════════════════════════════════════════════════
  frontend-ci:
    name: "Frontend — Lint, Type-check & Build"
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js ${{ env.NODE_VERSION }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Lint (ESLint)
        run: npm run lint

      - name: TypeScript check
        run: npm run type-check

      - name: Build
        env:
          NEXT_PUBLIC_API_URL: http://localhost:8000
        run: npm run build

  # ════════════════════════════════════════════════════════════════════════════
  # 3. Docker Build — validate images build cleanly
  # ════════════════════════════════════════════════════════════════════════════
  docker-build:
    name: "Docker — Build Images"
    runs-on: ubuntu-latest
    needs: [backend-ci, frontend-ci]
    # Only build Docker images on pushes (not PRs from forks)
    if: github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build backend image
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          push: false
          tags: mlai-backend:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Build frontend image
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          push: false
          tags: mlai-frontend:${{ github.sha }}
          build-args: |
            NEXT_PUBLIC_API_URL=http://backend:8000
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ════════════════════════════════════════════════════════════════════════════
  # 4. Deploy (placeholder — wire up to your infra)
  # ════════════════════════════════════════════════════════════════════════════
  deploy:
    name: "Deploy to Production"
    runs-on: ubuntu-latest
    needs: [docker-build]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Deploy notification
        run: |
          echo "✅ All CI checks passed."
          echo "🚀 Add your deployment steps here (e.g. push to registry, SSH deploy, Fly.io, etc.)"
          echo "   SHA: ${{ github.sha }}"
🧩 Docker vs GitHub ActionsDocker is the shipping container (same box everywhere). Actions is the conveyor belt: on every push it lints, tests, and builds the images. One packages the app; the other moves and checks it.
💡 CI keys are dummiesThe workflow sets fake keys so settings validation passes; because provider keys are optional (str | None) and the tests mock every LLM call, the suite runs with no real key.
STEP 26

tests/test_api.py

A full API suite that patches every heavy service (FAISS, the LLM, embeddings, httpx) so it runs instantly with no GPU, network, or keys. It mocks at the route boundary, so it is provider-agnostic — repo verbatim.

terminal — from backend/
uv add --dev pytest pytest-asyncio ruff
backend/tests/test_api.py
"""
Full API test suite.

Patches all heavy services (FAISS, LLM, embeddings, httpx) so tests run
instantly without GPU, network, or API keys.
"""

import base64
import io
import struct
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from httpx import AsyncClient, ASGITransport

# ── Shared mock return values ─────────────────────────────────────────────────

_DOC_ID     = "test-doc-id-1234"
_CHUNK_COUNT = 11

_MOCK_RAG = {
    "answer": "The document discusses machine learning fundamentals.",
    "rewritten_query": "What are the key ML concepts?",
    "sources": [{"content": "ML is a subfield of AI.", "page": 1, "score": 0.95}],
}

_MOCK_EVAL = {
    "faithfulness": 0.91,
    "answer_relevancy": 0.88,
    "context_precision": 0.85,
    "context_recall": None,
}

# Minimal valid WAV bytes for TTS mock
def _fake_wav() -> bytes:
    pcm = b"\x00\x00" * 100
    header = struct.pack(
        "<4sI4s4sIHHIIHH4sI",
        b"RIFF", 36 + len(pcm), b"WAVE",
        b"fmt ", 16, 1, 1, 24000, 48000, 2, 16,
        b"data", len(pcm),
    )
    return header + pcm


# ── Mock document in FAISS docstore ──────────────────────────────────────────

class _FakeDoc:
    def __init__(self):
        self.page_content = "This is a chunk about machine learning concepts."
        self.metadata = {"document_id": _DOC_ID, "page": 1}

_fake_vs = MagicMock()
_fake_vs.docstore._dict = {f"id-{i}": _FakeDoc() for i in range(6)}


# ── Fixtures ──────────────────────────────────────────────────────────────────

@pytest.fixture(autouse=True)
def patch_services():
    """Patch every external service before each test."""
    mock_llm_result = MagicMock()
    mock_llm_result.content = (
        "TOPIC: Machine Learning | Core concepts of ML.\n"
        "TOPIC: Neural Networks | Deep learning architectures.\n"
        "TOPIC: Evaluation Metrics | How models are measured.\n"
    )

    mock_suggestions_result = MagicMock()
    mock_suggestions_result.content = (
        "What is supervised learning?\n"
        "How do neural networks work?\n"
        "What are evaluation metrics?\n"
        "What is overfitting?\n"
    )

    _pcm_bytes = b"\x00\x01" * 100

    with (
        patch("app.routes.upload.ingest_pdf",        MagicMock(return_value=(_DOC_ID, _CHUNK_COUNT))),
        patch("app.routes.query.run_rag",             MagicMock(return_value=_MOCK_RAG)),
        patch("app.routes.evaluate.evaluate_rag",     MagicMock(return_value=_MOCK_EVAL)),
        patch("app.routes.suggestions.load_vector_store", MagicMock(return_value=_fake_vs)),
        patch("app.routes.topics.load_vector_store",      MagicMock(return_value=_fake_vs)),
        # Patch _get_llm to prevent ChatOpenAI instantiation (avoids httpx.AsyncClient corruption)
        patch("app.routes.suggestions._get_llm", MagicMock(return_value=MagicMock())),
        patch("app.routes.topics._get_llm",      MagicMock(return_value=MagicMock())),
        patch("app.routes.tts._get_llm",         MagicMock(return_value=MagicMock())),
        # Patch each prompt chain's __or__ so invoke() returns the right fixture
        patch("app.routes.suggestions._SUGGESTIONS_PROMPT",
              MagicMock(__or__=MagicMock(return_value=MagicMock(invoke=MagicMock(return_value=mock_suggestions_result))))),
        patch("app.routes.topics._TOPICS_PROMPT",
              MagicMock(__or__=MagicMock(return_value=MagicMock(invoke=MagicMock(return_value=mock_llm_result))))),
        patch("app.routes.tts._SPOKEN_REWRITE_PROMPT",
              MagicMock(__or__=MagicMock(return_value=MagicMock(invoke=MagicMock(return_value=MagicMock(content="Spoken script.")))))),
        # Patch the extracted streaming helper — safe, no httpx module corruption
        patch("app.routes.tts._stream_pcm16", AsyncMock(return_value=[_pcm_bytes])),
        patch("app.config.Settings.openrouter_api_key", "test-or-key", create=True),
    ):
        yield


@pytest.fixture
def app():
    from app.main import app as fastapi_app
    return fastapi_app


@pytest.fixture
async def client(app):
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        yield ac


# ── Health ────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_health(client):
    r = await client.get("/health")
    assert r.status_code == 200
    assert r.json()["status"] == "ok"


# ── Upload ────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_upload_success(client):
    r = await client.post(
        "/api/upload",
        files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4 fake"), "application/pdf")},
        data={"description": "A document about machine learning."},
    )
    assert r.status_code == 201
    body = r.json()
    assert body["document_id"] == _DOC_ID
    assert body["chunk_count"] == _CHUNK_COUNT


@pytest.mark.asyncio
async def test_upload_rejects_non_pdf(client):
    r = await client.post(
        "/api/upload",
        files={"file": ("doc.txt", io.BytesIO(b"hello"), "text/plain")},
        data={"description": "Some description here."},
    )
    assert r.status_code == 415


@pytest.mark.asyncio
async def test_upload_requires_description(client):
    r = await client.post(
        "/api/upload",
        files={"file": ("doc.pdf", io.BytesIO(b"%PDF"), "application/pdf")},
        data={"description": "hi"},   # too short (min 5)
    )
    assert r.status_code == 422


# ── Query ─────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_query_success(client):
    r = await client.post("/api/query", json={"question": "What is machine learning?"})
    assert r.status_code == 200
    body = r.json()
    assert body["answer"] == _MOCK_RAG["answer"]
    assert len(body["sources"]) == 1
    assert "rewritten_query" in body


@pytest.mark.asyncio
async def test_query_with_document_id(client):
    r = await client.post("/api/query", json={"question": "Explain neural networks.", "document_id": _DOC_ID})
    assert r.status_code == 200


@pytest.mark.asyncio
async def test_query_too_short(client):
    r = await client.post("/api/query", json={"question": "Hi"})
    assert r.status_code == 422


# ── Evaluate ──────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_evaluate_success(client):
    r = await client.post("/api/evaluate", json={
        "question": "What is ML?",
        "answer": "ML is a subfield of AI.",
        "contexts": ["Machine learning is a subfield of artificial intelligence."],
    })
    assert r.status_code == 200
    body = r.json()
    assert "metrics" in body
    assert body["metrics"]["faithfulness"] == 0.91


# ── Suggestions ───────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_suggestions_success(client):
    r = await client.post("/api/suggestions", json={"document_id": _DOC_ID})
    assert r.status_code == 200
    body = r.json()
    assert "suggestions" in body
    assert len(body["suggestions"]) >= 2


@pytest.mark.asyncio
async def test_suggestions_invalid_doc(client):
    with patch("app.routes.suggestions.load_vector_store") as mock_vs:
        mock_vs.return_value.docstore._dict = {}
        r = await client.post("/api/suggestions", json={"document_id": "nonexistent"})
    assert r.status_code == 404


# ── Topics ────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_topics_success(client):
    r = await client.post("/api/topics", json={"document_id": _DOC_ID})
    assert r.status_code == 200
    body = r.json()
    assert "topics" in body
    assert len(body["topics"]) >= 1
    assert "title" in body["topics"][0]
    assert "description" in body["topics"][0]


# ── TTS ───────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_tts_returns_wav(client):
    r = await client.post("/api/tts", json={"text": "Hello, this is a test.", "voice": "nova"})
    assert r.status_code == 200
    assert r.headers["content-type"] == "audio/wav"
    # Verify WAV header
    assert r.content[:4] == b"RIFF"
    assert r.content[8:12] == b"WAVE"


@pytest.mark.asyncio
async def test_tts_invalid_voice_falls_back(client):
    r = await client.post("/api/tts", json={"text": "Testing fallback voice.", "voice": "invalid_voice"})
    assert r.status_code == 200  # falls back to nova


@pytest.mark.asyncio
async def test_tts_empty_text_rejected(client):
    r = await client.post("/api/tts", json={"text": ""})
    # pydantic min_length not set, but empty string hits the "no audio" path
    assert r.status_code in (200, 422, 502)
terminal — from backend/
uv run pytest -v
💡 The pattern to stealpatch("app.routes.query.run_rag", ...) replaces the whole pipeline with a fixed dict — you test the HTTP contract (status codes, shape, validation) without ever calling a model. That is how an LLM app keeps fast, deterministic tests.
STEP 27

Run the full stack

Everything is built and tested locally. Bring up both services in Docker:

terminal — repo root
# from the repo root — make sure backend/.env exists with your key
docker compose up --build

# backend  -> http://localhost:8000/docs
# frontend -> http://localhost:3000   (upload a PDF, ask by text or voice)
✅ Final milestone — the whole productA modular RAG backend: multi-engine PDF ingestion, local embeddings, FAISS + cross-encoder two-stage retrieval, grounded generation with citations through a swappable LLM seam (Claude by default), RAGAS evaluation, a mocked test suite, Docker + CI — with the provided Next.js voice UI on top. That is “I shipped a model,” not “I trained one.”
🔁 Switching providers laterTo demo on OpenRouter or move to GPT, change one line of .envLLM_PROVIDER=openrouter (or openai) — and set that key. No code edits. That is the entire payoff of the seam from Step 7.
💡 When something breaks, ask the editor firstPaste the error into VS Code’s built-in chat — it reads your terminal and the failing file and usually pinpoints the fix. Most first-run failures are a missing key, a wrong file name, a missing __init__.py, or the OCR system packages.