Build a Hybrid RAG Study Workspace

Build a local PDF study workspace with module-isolated, source-grounded AI chat.

Introduction

30 Second Summary

Lecture notes often sit inside long PDFs. Finding one useful passage can take longer than understanding it.

In this project, you will build an AI Module Workspace that organizes lecture PDFs by subject. Its hybrid retrieval-augmented generation pipeline selects local excerpts for every source-grounded answer.

What You'll Build

Your finished workspace turns a question about one module into a grounded answer with its supporting PDF pages ready to inspect.

By the end of this project, you'll have:

  • A persistent React dashboard where module folders survive a browser refresh. SQLite restores every folder when you return.
  • A local PDF index that displays page counts. It also displays the searchable chunk count created from each upload. Ollama creates the embeddings on your computer.
  • A source-grounded chat that shows the routed model beside each answer. Expandable cards reveal the supporting PDF pages selected before OpenRouter generation.
  • Secret Mission: Create two contrasting modules. Ask adversarial questions to prove retrieval never crosses module boundaries.

What should I have ready?

You need an OpenRouter API key plus a lecture PDF whose words you can select. The Ollama embedding model uses approximately 639 MB of local disk space.

Before We Start

Before any hands-on work begins, this step locks in a real module and a lecture PDF with selectable text. It also connects the request path from React to FastAPI, SQLite, local Ollama embeddings, module-filtered cosine retrieval, and OpenRouter generation to your software and AI engineering goal.

Set Up the Full-Stack Workspace

A full-stack workspace can only reveal the request path when both sides run reliably. Pinned dependencies give every later feature a stable foundation.

You will prepare a React frontend with Vite. You will also start a FastAPI backend with a visible health endpoint.

In this step, get ready to:
  • Check that Python and Node.js meet the pinned runtime requirements.
  • Create the backend and frontend projects with pinned dependencies.
  • Run both development servers and verify the starter application.
Check runtime compatibility

The backend requires Python 3.10 or newer. The frontend requires Node.js 20.19 or newer within the 20 release line.

Node.js 22.12 or newer also works. These checks prevent package installation from failing halfway through the setup.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open Windows PowerShell.
  • Check the Python runtime selected by the Windows launcher by running:
py --version

What does this check show?

The command prints the Python version selected by the Windows launcher. Its major and minor numbers determine whether the pinned FastAPI release can run.

✔️ I see Python 3.10 or higher

Your Python runtime meets the backend requirement. Keep this PowerShell window open for the Node.js check.

ⓧ I see an older Python version

The selected runtime is below the backend requirement. Install Python 3.10 or newer before continuing.

  • Follow the official Python guidance for Windows to install a supported runtime.
  • Close PowerShell after the installation completes.
  • Open a new PowerShell window through Windows search.
  • Repeat the Python version check above.

Continue when the result shows Python 3.10 or higher.

ⓧ The py command is unavailable

Windows cannot find Python through its launcher. Install a supported Python runtime before creating the backend.

  • Follow the official Python guidance for Windows to install Python 3.10 or newer.
  • Close PowerShell after the installation completes.
  • Open a new PowerShell window through Windows search.
  • Repeat the Python version check above.

Continue when the command prints a supported Python version.

  • Check the Node.js runtime available to Vite by running:
node --version

What does this check show?

The command prints the installed Node.js version. Vite uses this runtime to start the frontend development server.

✔️ I see version 20.19 or higher

Your Node.js runtime meets the frontend requirement. A version from the supported 22 release line also works from 22.12 onward.

ⓧ I see an older version

The installed runtime is below Vite's requirement. Upgrade Node.js before installing the frontend dependencies.

  • Download the current LTS release from the official Node.js download page.
  • Run the downloaded installer with its default options.
  • Close PowerShell after the installation completes.
  • Open a new PowerShell window through Windows search.
  • Repeat the Node.js version check above.

Continue when the result shows a supported Node.js version.

ⓧ The node command is unavailable

Windows cannot find Node.js. Install its current LTS release before creating the frontend.

  • Download the current LTS release from the official Node.js download page.
  • Run the downloaded installer with its default options.
  • Close PowerShell after the installation completes.
  • Open a new PowerShell window through Windows search.
  • Repeat the Node.js version check above.

Continue when the command prints a supported Node.js version.

The project keeps both runtimes inside one top-level folder. Separate subfolders let each tool resolve its own files without mixing Python packages with frontend packages.

  • Create the project on your Desktop by running these commands:
Set-Location -Path ~/Desktop
New-Item -Path ai-module-workspace -ItemType Directory
Set-Location -Path ai-module-workspace
New-Item -Path backend -ItemType Directory
New-Item -Path frontend/src -ItemType Directory -Force
code .

What do these commands create?

  • PowerShell creates ai-module-workspace on your Desktop.
  • The backend folder holds the Python service.
  • The frontend/src folder holds the React source files.
  • The final command opens the project folder in Visual Studio Code.
  • Review the folder contents if VS Code displays a Workspace Trust dialog.
  • Choose the trust option for this project folder.
  • Confirm that the Explorer sidebar lists backend.
  • Expand frontend in the Explorer sidebar.
  • Confirm that src appears inside frontend.

Your workspace now has a visible boundary between the Python service and the browser application.

Workspace folders missing?

Check that the VS Code title bar ends with ai-module-workspace. If the editor command failed, start VS Code through Windows search.

Use the editor's folder-opening option to select ai-module-workspace from your Desktop.

Help me fix my workspace structure.

Add pinned project files

Each side needs a small runnable foundation before application features arrive. The package manifests lock the versions that support this request path.

  • Create .gitignore inside ai-module-workspace with the Explorer sidebar's file creation control.
  • Add the generated local paths by pasting:
backend/study_room.db
backend/__pycache__/
frontend/node_modules/
frontend/dist/

What stays local?

The database and Python cache stay outside source control. Installed frontend packages plus production build output also remain local.

  • Save .gitignore with Ctrl+S.
  • Confirm that the file contains four ignored paths.
  • Create backend/requirements.txt with the Explorer sidebar's file creation control.
  • Add the pinned backend dependencies by pasting:
fastapi[standard]==0.162.0
pydantic==2.14.1

What do these packages provide?

  • The FastAPI standard installation provides the web framework plus its development command.
  • Pydantic validates the shape of data entering the API.
  • Save backend/requirements.txt.
  • Confirm that the file contains exactly two dependency lines.
  • Open VS Code's integrated terminal from the Terminal menu.
  • Confirm the backend folder now contains its dependency file by running:
Get-ChildItem -Path .\backend

What should this list show?

The output should include requirements.txt under the backend folder. This confirms that the file exists in the correct project boundary.

  • Create backend/main.py with the Explorer sidebar's file creation control.
  • Add the initial API by pasting:
from fastapi import FastAPI, HTTPException, UploadFile
from pydantic import BaseModel

app = FastAPI(title="AI Module Workspace API")


class ModuleCreate(BaseModel):
    name: str


class ChatRequest(BaseModel):
    question: str


@app.get("/api/health")
def health():
    return {"status": "ok"}

What does this API define?

  • The app object gives the backend its title plus its server entry point.
  • ModuleCreate describes a future module request.
  • ChatRequest describes a future study question.
  • The /api/health endpoint returns JSON that proves the service is available.
  • Save backend/main.py.
  • Confirm that the file ends with the health response.
  • Install the backend dependencies from backend by running:
Set-Location -Path backend
py -m pip install -r requirements.txt

What does this installation do?

PowerShell moves into backend. The Python launcher then installs the exact versions recorded in requirements.txt.

  • Wait for PowerShell to return to its prompt.
  • Confirm that the final output reports the backend packages as installed.

Backend installation failing?

Confirm that the terminal path ends with backend. Check that the Python version from the earlier compatibility test is 3.10 or newer.

Help me fix the backend installation.

  • Create frontend/package.json with the Explorer sidebar's file creation control.
  • Add the pinned frontend manifest by pasting:
{
  "name": "ai-module-workspace-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}

What does this manifest control?

  • The dependency sections pin React 19.3.1 plus Vite 8.3.5.
  • The dev script starts the local Vite server.
  • The build script creates a production bundle.
  • Save frontend/package.json.
  • Confirm that VS Code shows no JSON error marker.
  • Create frontend/vite.config.js with the Explorer sidebar's file creation control.
  • Enable React support by pasting:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
});

What does this configuration do?

The React plugin lets Vite process JSX files. The exported configuration applies that plugin whenever the frontend server starts.

  • Save frontend/vite.config.js.
  • Confirm that the file contains one plugins array.
  • Install the frontend dependencies from frontend by running:
Set-Location -Path ..\frontend
npm install

What does npm create?

The command installs every package listed in package.json. It also creates package-lock.json plus the local node_modules folder.

  • Wait for PowerShell to return to its prompt.
  • Confirm that the Explorer sidebar lists package-lock.json inside frontend.

Frontend installation failing?

Confirm that the terminal path ends with frontend. If the output mentions an unsupported runtime, install the current Node.js LTS release.

Help me fix the frontend installation.

  • Create frontend/index.html with the Explorer sidebar's file creation control.
  • Add the browser entry document by pasting:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Organize lecture modules and ask source-grounded questions."
    />
    <title>AI Module Workspace</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this document connect?

The root element gives React a place to render. The module script loads main.jsx as the frontend entry point.

  • Save frontend/index.html.
  • Confirm that the document title is AI Module Workspace.
  • Create frontend/src/main.jsx with the Explorer sidebar's file creation control.
  • Connect React to the HTML root by pasting:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

What does this entry point do?

The entry point renders App inside the HTML root. Strict Mode helps surface unsafe React behavior during development.

The stylesheet import loads the global CSS with the component tree.

  • Save frontend/src/main.jsx.
  • Confirm the first two imports come from React packages.
  • Confirm the new source entry file exists by running:
Get-ChildItem -Path .\src

What should this list show?

The output should include main.jsx inside frontend/src. This confirms that the browser entry point is in the folder Vite expects.

  • Create frontend/src/App.jsx with the Explorer sidebar's file creation control.
  • Add the starter component by pasting:
async function requestJson(path, options = {}) {
  const response = await fetch(path, options);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(data.detail || "The request failed");
  }
  return data;
}

export default function App() {
  return (
    <main className="app-shell">
      <header className="hero">
        <div>
          <p className="eyebrow">Local knowledge. Grounded answers.</p>
          <h1>AI Module Workspace</h1>
          <p className="hero-copy">
            Organize lecture modules, index PDFs locally, and study with
            source-grounded AI.
          </p>
        </div>
        <span className="status-pill">Hybrid RAG workspace</span>
      </header>
    </main>
  );
}

What does this starter provide?

  • requestJson converts successful API responses into JavaScript data.
  • The same helper converts failed responses into readable errors.
  • App renders the starter heading that proves React reached the browser.
  • Save frontend/src/App.jsx.
  • Confirm that the component exports App as its default function.
  • Create frontend/src/styles.css with the Explorer sidebar's file creation control.
  • Add the initial global styles by pasting:
:root {
  color: #1f2937;
  background: #f4f7fb;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, rgba(109, 94, 252, 0.12), transparent 28rem),
    #f4f7fb;
}

What do these styles establish?

The root selector sets the page colors plus its font stack. The body selector removes the default margin plus paints the starter background.

Border-box sizing keeps future component dimensions predictable.

  • Save frontend/src/styles.css.
  • Confirm that the file contains :root plus body selectors.

The pinned backend and frontend foundations are now complete. Use the comparison tabs to catch any missing line before starting the servers.

✔️ Awesome, I've got everything!

Your project files are ready. Save every open editor tab before starting the development servers.

ⓧ I'd like to double check the full code

Compare your complete .gitignore with this file.

backend/study_room.db
backend/__pycache__/
frontend/node_modules/
frontend/dist/

What should match?

All four generated paths should appear once. The file should contain no additional project files.

Compare your complete backend/requirements.txt with this file.

fastapi[standard]==0.162.0
pydantic==2.14.1

What should match?

Both backend packages should use the exact pinned versions shown here.

Compare your complete backend/main.py with this file.

from fastapi import FastAPI, HTTPException, UploadFile
from pydantic import BaseModel

app = FastAPI(title="AI Module Workspace API")


class ModuleCreate(BaseModel):
    name: str


class ChatRequest(BaseModel):
    question: str


@app.get("/api/health")
def health():
    return {"status": "ok"}

What should match?

The file should define the two request models plus the health endpoint. The FastAPI object must be named app.

Compare your complete frontend/package.json with this file.

{
  "name": "ai-module-workspace-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.2",
    "vite": "8.3.5"
  }
}

What should match?

The two scripts plus all four pinned frontend packages should match exactly.

Compare your complete frontend/vite.config.js with this file.

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
});

What should match?

The configuration should import the React plugin plus register it once.

Compare your complete frontend/index.html with this file.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="Organize lecture modules and ask source-grounded questions."
    />
    <title>AI Module Workspace</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What should match?

The document should contain the root element plus the /src/main.jsx module script.

Compare your complete frontend/src/main.jsx with this file.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

What should match?

The entry point should import App plus the stylesheet. It should render inside root.

Compare your complete frontend/src/App.jsx with this file.

async function requestJson(path, options = {}) {
  const response = await fetch(path, options);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(data.detail || "The request failed");
  }
  return data;
}

export default function App() {
  return (
    <main className="app-shell">
      <header className="hero">
        <div>
          <p className="eyebrow">Local knowledge. Grounded answers.</p>
          <h1>AI Module Workspace</h1>
          <p className="hero-copy">
            Organize lecture modules, index PDFs locally, and study with
            source-grounded AI.
          </p>
        </div>
        <span className="status-pill">Hybrid RAG workspace</span>
      </header>
    </main>
  );
}

What should match?

The helper should appear before the exported component. The component should render the starter hero content.

Compare your complete frontend/src/styles.css with this file.

:root {
  color: #1f2937;
  background: #f4f7fb;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, rgba(109, 94, 252, 0.12), transparent 28rem),
    #f4f7fb;
}

What should match?

The stylesheet should contain the root theme plus the universal sizing rule. It should end with the body background.

Start both development servers

The browser application and API run as separate development processes. Keeping one process in each terminal makes reload messages easy to trace.

  • Create a new integrated terminal from the Terminal menu.
  • Start the backend from backend by running:
Set-Location -Path backend
fastapi dev

What happens when FastAPI starts?

The development command imports app from main.py. It starts the API at http://127.0.0.1:8000.

This terminal remains occupied while the server runs. Leave it open so backend reloads stay visible.

  • Confirm that the backend terminal reports a running development server.

Backend server not starting?

Confirm that the terminal path ends with backend. If the command is unavailable, repeat the backend dependency installation from that folder.

Help me start the FastAPI server.

  • Create another integrated terminal from the Terminal menu.
  • Start the frontend from frontend by running:
Set-Location -Path frontend
npm run dev

What happens when Vite starts?

The dev script starts Vite with React support. It serves the frontend at http://localhost:5173.

This terminal also remains occupied while the server runs. Keep it open beside the backend terminal.

  • Confirm that the frontend terminal prints the local development URL.

Frontend server not starting?

Confirm that the terminal path ends with frontend. Check that node_modules plus package-lock.json exist inside that folder.

Help me start the Vite server.

Before you open the frontend, which project title do you expect React to render?

  • Open http://localhost:5173 in your browser.

You should see AI Module Workspace with the local knowledge message beneath it. The pale background confirms that styles.css loaded through the React entry point.

  • Open http://127.0.0.1:8000/docs in a second browser tab.
  • Confirm that the generated API documentation lists /api/health.

You now have visible proof from both halves of the application. The starter page confirms the React path while the generated documentation confirms the FastAPI contract.

Starter page missing?

Check the frontend terminal for a file name plus a line number. A compile failure usually points directly to the file that needs attention.

If the browser cannot connect, confirm that both server terminals remain open. Each terminal should still show its running development process.

Help me debug the starter workspace.

That is the full-stack spine working: your pinned FastAPI backend and React frontend are both running. Next, you will turn the starter page into a module dashboard whose memory limitation becomes impossible to miss.

Build the Module Dashboard

Your frontend and backend are already running. The starter page now needs to become a workspace where each subject has its own module folder.

In this step, React state controls the visible modules and the currently open study room. You will finish by testing what happens when the browser refreshes.

In this step, get ready to:
  • Build a dashboard that creates module folder cards.
  • Open each module inside a three-column study room.
  • Refresh the dashboard to test whether its module cards survive.
Build the dashboard interactions

The interface needs two views. The dashboard lists modules while the study room gives the selected module its own document, chat, and study-tool panels.

How does React state control the views?

The useState hook stores values while the app is running. Updating activeModule tells App whether to render the dashboard or the study room.

The remaining state values prepare the interface for document upload and chat features. Their controls stay disabled until later steps connect them to the backend.

  • In the VS Code file tree, select frontend/src/App.jsx.
  • Replace the existing contents with the next two code chunks in order:
import { useState } from "react";

function StudyRoom({ module, onBack }) {
  // Show the selected module inside a three-panel workspace
  return (
    <main className="room-shell">
      <header className="room-header">
        <button className="back-button" onClick={onBack} type="button">Back to modules</button>
        <div><p className="eyebrow">Focused study room</p><h1>{module.name}</h1></div>
        <span className="status-pill">Ready</span>
      </header>
      <section className="study-grid">
        <aside className="panel documents-panel">
          <div className="panel-heading"><span>01</span><div><h2>Lecture material</h2><p>Extract and embed locally</p></div></div>
          <div className="upload-box"><strong>Choose a text-based PDF</strong><input disabled type="file" /><button disabled type="button">Upload and index</button></div>
          <p className="muted">No indexed documents yet.</p>
        </aside>
        <section className="panel chat-panel">
          <div className="panel-heading"><span>02</span><div><h2>Grounded chat</h2><p>Ask the indexed lecture material</p></div></div>
          <div className="chat-empty"><strong>Ready when your lecture is indexed.</strong><p>Ask for an explanation, comparison, or worked summary.</p></div>
          <div className="chat-form"><textarea disabled placeholder="Ask a question grounded in this module..." rows="3" /><button disabled type="button">Ask with sources</button></div>
        </section>
        <aside className="panel tools-panel">
          <div className="panel-heading"><span>03</span><div><h2>Study tools</h2><p>Planned follow-on features</p></div></div>
          <article className="tool-card"><strong>Flashcards</strong><p>Generate recall prompts from selected lecture chunks.</p><button disabled type="button">Coming next</button></article>
          <article className="tool-card"><strong>Quiz builder</strong><p>Turn module sources into self-marking questions.</p><button disabled type="button">Coming next</button></article>
        </aside>
      </section>
    </main>
  );
}

What does the study room do?

  • The module prop supplies the room title for the selected folder.
  • The onBack prop returns the learner to the module dashboard.
  • The three panels reserve visible space for lecture uploads, grounded chat, and future study tools.
  • Paste the main App component immediately below StudyRoom:
export default function App() {
  // Keep the dashboard and its temporary modules in browser memory
  const [modules, setModules] = useState([]), [activeModule, setActiveModule] = useState(null), [moduleName, setModuleName] = useState("");
  const [selectedFile, setSelectedFile] = useState(null), [question, setQuestion] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState("");

  function createModule(event) {
    event.preventDefault();
    if (!moduleName.trim()) return;
    const nextModule = { id: Date.now(), name: moduleName.trim(), document_count: 0, message_count: 0, documents: [], messages: [] };
    setModules((currentModules) => [nextModule, ...currentModules]);
    setModuleName("");
  }

  function openModule(module) { setActiveModule(module); }
  function returnToDashboard() { setActiveModule(null); setError(""); }

  if (activeModule) return <StudyRoom module={activeModule} onBack={returnToDashboard} />;

  return (
    <main className="app-shell">
      <header className="hero"><div><p className="eyebrow">Local knowledge. Grounded answers.</p><h1>AI Module Workspace</h1><p>Organize lecture modules, index PDFs locally, and study with source-grounded AI.</p></div><span className="status-pill">Hybrid RAG workspace</span></header>
      <section className="dashboard-toolbar"><div><h2>Your modules</h2><p>Create a dedicated knowledge boundary for every subject.</p></div><form className="create-form" onSubmit={createModule}><input aria-label="Module name" value={moduleName} onChange={(event) => setModuleName(event.target.value)} placeholder="e.g. Software Engineering" /><button disabled={busy} type="submit">+ Create New Module</button></form></section>
      {error && <p className="error-banner">{error}</p>}
      <section className="module-grid" aria-live="polite">
        {modules.length === 0 && <div className="empty-card"><span className="empty-icon">+</span><h3>No modules yet</h3><p>Create your first module to open a focused study room.</p></div>}
        {modules.map((module, index) => <button className={`module-card tone-${index % 4}`} key={module.id} onClick={() => openModule(module)} type="button"><span className="folder-tab" /><span className="folder-symbol">F</span><strong>{module.name}</strong><span>{module.document_count} docs · {module.message_count} messages</span><small>Open study room</small></button>)}
      </section>
    </main>
  );
}

What does this code do?

  • The seven useState calls hold the dashboard modules, selected module, form values, activity state, and visible errors.
  • The createModule function builds a module object with zero document and message counts.
  • The openModule function selects a card while returnToDashboard clears that selection.
  • The conditional render switches between the module dashboard and the three-column room.
  • Save frontend/src/App.jsx.
  • Switch back to http://localhost:5173.
  • Enter Software Engineering in the module name field.
  • Select + Create New Module.
  • Select the Software Engineering module card.

You will see a study room titled Software Engineering with lecture, chat, and study-tool columns. That interaction now gives every module a focused workspace.

Dashboard not rendering?

Check that StudyRoom appears above App in frontend/src/App.jsx. Confirm that the file has one export default declaration.

If the page stays blank, inspect the frontend terminal for the first file and line named in the build error.

Help me debug my React dashboard.

Style the dashboard and study room

The interactions work, but visual hierarchy makes the two views easier to read. Gradient folder cards identify modules while bordered panels separate the study-room responsibilities.

  • Select frontend/src/styles.css in the VS Code file tree.
  • Replace its contents with the dashboard styles below:
/* Establish the page foundation and shared controls */
:root { color: #1f2937; background: #f4f7fb; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, rgba(109, 94, 252, 0.12), transparent 28rem), #f4f7fb; }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell, .room-shell { width: min(1440px, 100%); margin: 0 auto; padding: 32px; }

/* Shape the dashboard header and creation form */
.hero, .room-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 28px; color: white; border-radius: 28px; background: linear-gradient(135deg, #352f73, #6d5efc 60%, #9b8cff); box-shadow: 0 20px 55px rgba(53, 47, 115, 0.22); }
.hero h1, .room-header h1 { margin: 4px 0 8px; font-size: clamp(2rem, 5vw, 4rem); line-height: 1; }
.hero p, .room-header p { max-width: 660px; margin-bottom: 0; color: rgba(255, 255, 255, 0.82); }
.eyebrow { margin: 0; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; }
.status-pill { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border: 1px solid rgba(255, 255, 255, 0.24); border-radius: 999px; background: rgba(255, 255, 255, 0.12); color: white; font-size: 0.82rem; font-weight: 700; white-space: nowrap; }
.dashboard-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin: 38px 0 22px; }
.dashboard-toolbar h2 { margin: 0; }
.dashboard-toolbar p { margin: 6px 0 0; color: #6b7280; }
.create-form { display: flex; gap: 10px; width: min(100%, 580px); }
.create-form input { width: 100%; min-height: 46px; padding: 0 14px; border: 1px solid #d7dcec; border-radius: 14px; background: white; color: #111827; outline: none; }
.create-form button { min-height: 46px; padding: 0 18px; border: 0; border-radius: 14px; background: #2f2a68; color: white; font-weight: 800; white-space: nowrap; }

/* Turn module buttons into folder cards */
.module-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; }
.module-card, .empty-card { position: relative; min-height: 230px; padding: 28px; overflow: hidden; border: 0; border-radius: 22px; text-align: left; }
.module-card { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end; color: white; box-shadow: 0 14px 36px rgba(56, 49, 121, 0.18); transition: transform 180ms ease, box-shadow 180ms ease; }
.module-card:hover { transform: translateY(-5px); box-shadow: 0 20px 44px rgba(56, 49, 121, 0.26); }
.tone-0 { background: linear-gradient(145deg, #5b4be8, #8e7cff); } .tone-1 { background: linear-gradient(145deg, #007f8f, #31b7b4); } .tone-2 { background: linear-gradient(145deg, #9b4b7a, #df7da7); } .tone-3 { background: linear-gradient(145deg, #be6b2c, #f2aa62); }
.folder-tab { position: absolute; top: 0; left: 26px; width: 86px; height: 18px; border-radius: 0 0 12px 12px; background: rgba(255, 255, 255, 0.23); }
.folder-symbol, .empty-icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: auto; border-radius: 16px; background: rgba(255, 255, 255, 0.16); font-weight: 900; }
.module-card strong { margin-top: 26px; font-size: 1.3rem; }
.module-card span, .module-card small { margin-top: 8px; color: rgba(255, 255, 255, 0.8); }
.empty-card { border: 2px dashed #cbd2e4; background: rgba(255, 255, 255, 0.66); }
.empty-card .empty-icon { color: #6d5efc; background: #ebe8ff; }

How do the dashboard styles work?

  • The shared page rules set a light canvas with a purple radial highlight.
  • The hero gradient creates a clear starting point for the workspace.
  • The tone-0 through tone-3 classes rotate module cards through four gradients.
  • The folder tab and symbol make each module read as a clickable subject folder.
  • Save frontend/src/styles.css.
  • Switch back to the dashboard by selecting Back to modules.

You will see a purple hero and a gradient Software Engineering folder card. The card displays 0 docs · 0 messages beneath its module name.

  • Append the study-room and responsive styles to the bottom of frontend/src/styles.css:
/* Arrange the selected module as a three-column workspace */
.room-header { align-items: center; padding: 22px 26px; }
.room-header h1 { font-size: clamp(1.8rem, 4vw, 3rem); }
.back-button { border: 1px solid rgba(255, 255, 255, 0.26); border-radius: 12px; padding: 10px 14px; background: rgba(255, 255, 255, 0.12); color: white; font-weight: 800; }
.study-grid { display: grid; grid-template-columns: minmax(240px, 0.8fr) minmax(380px, 1.6fr) minmax(240px, 0.8fr); gap: 18px; margin-top: 22px; }
.panel { min-height: 650px; padding: 22px; border: 1px solid #e2e6f0; border-radius: 22px; background: rgba(255, 255, 255, 0.9); box-shadow: 0 14px 36px rgba(31, 41, 55, 0.08); }
.panel-heading { display: flex; gap: 12px; align-items: flex-start; padding-bottom: 18px; border-bottom: 1px solid #e7eaf2; }
.panel-heading > span { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; background: #ece9ff; color: #5b4be8; font-size: 0.72rem; font-weight: 900; }
.panel-heading h2 { margin: 0; }
.panel-heading p { margin: 6px 0 0; color: #6b7280; font-size: 0.85rem; }
.upload-box { display: grid; gap: 12px; margin-top: 20px; padding: 18px; border: 2px dashed #c8cce0; border-radius: 18px; background: #fafaff; }
.upload-box button, .chat-form button { min-height: 42px; padding: 0 15px; border: 0; border-radius: 14px; background: #2f2a68; color: white; font-weight: 800; }
.chat-panel { display: flex; flex-direction: column; }
.chat-empty { margin: auto; padding: 28px; border-radius: 18px; background: #f7f6ff; text-align: center; color: #5b4be8; }
.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding-top: 16px; border-top: 1px solid #e7eaf2; }
.chat-form textarea { width: 100%; resize: vertical; padding: 12px; border: 1px solid #d7dcec; border-radius: 14px; }
.tools-panel { display: flex; flex-direction: column; gap: 14px; }
.tool-card { padding: 16px; border: 1px solid #e2e6ef; border-radius: 16px; background: #fbfcff; }
.tool-card p { color: #6b7280; font-size: 0.82rem; line-height: 1.45; }
.tool-card button { min-height: 36px; border: 0; border-radius: 10px; background: #e8eaf2; color: #73788b; font-weight: 800; }
.muted { color: #7b8193; }

/* Stack the layout when the available width becomes narrow */
@media (max-width: 1100px) { .module-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .study-grid { grid-template-columns: 1fr; } .panel { min-height: auto; } }
@media (max-width: 700px) { .app-shell, .room-shell { padding: 16px; } .hero, .room-header, .dashboard-toolbar, .create-form { flex-direction: column; align-items: stretch; } .module-grid, .chat-form { grid-template-columns: 1fr; } }

How does the room layout work?

  • The study-grid selector gives extra width to the center chat panel.
  • Each panel uses the same border, corner radius, and shadow to keep the columns visually related.
  • The responsive rules stack the panels when the screen no longer has enough horizontal space.
  • Save frontend/src/styles.css.
  • Select the Software Engineering folder card.

You will see three bordered columns beneath the room header. The middle grounded-chat column is wider than the lecture and study-tool columns.

Styles not appearing?

Confirm that frontend/src/main.jsx still imports ./styles.css. Check that both CSS chunks are saved in frontend/src/styles.css.

If the room stacks into one column, widen the browser window. The responsive layout deliberately changes at narrower widths.

Help me debug the dashboard styling.

✔️ Awesome, I've got everything!

Your dashboard can create gradient module cards and open each card inside a three-column study room. Make sure App.jsx and styles.css are saved.

ⓧ I'd like to double check the full code

Compare your complete frontend/src/App.jsx with this version:

import { useState } from "react";

function StudyRoom({ module, onBack }) {
  // Show the selected module inside a three-panel workspace
  return (
    <main className="room-shell">
      <header className="room-header">
        <button className="back-button" onClick={onBack} type="button">Back to modules</button>
        <div><p className="eyebrow">Focused study room</p><h1>{module.name}</h1></div>
        <span className="status-pill">Ready</span>
      </header>
      <section className="study-grid">
        <aside className="panel documents-panel">
          <div className="panel-heading"><span>01</span><div><h2>Lecture material</h2><p>Extract and embed locally</p></div></div>
          <div className="upload-box"><strong>Choose a text-based PDF</strong><input disabled type="file" /><button disabled type="button">Upload and index</button></div>
          <p className="muted">No indexed documents yet.</p>
        </aside>
        <section className="panel chat-panel">
          <div className="panel-heading"><span>02</span><div><h2>Grounded chat</h2><p>Ask the indexed lecture material</p></div></div>
          <div className="chat-empty"><strong>Ready when your lecture is indexed.</strong><p>Ask for an explanation, comparison, or worked summary.</p></div>
          <div className="chat-form"><textarea disabled placeholder="Ask a question grounded in this module..." rows="3" /><button disabled type="button">Ask with sources</button></div>
        </section>
        <aside className="panel tools-panel">
          <div className="panel-heading"><span>03</span><div><h2>Study tools</h2><p>Planned follow-on features</p></div></div>
          <article className="tool-card"><strong>Flashcards</strong><p>Generate recall prompts from selected lecture chunks.</p><button disabled type="button">Coming next</button></article>
          <article className="tool-card"><strong>Quiz builder</strong><p>Turn module sources into self-marking questions.</p><button disabled type="button">Coming next</button></article>
        </aside>
      </section>
    </main>
  );
}

export default function App() {
  // Keep the dashboard and its temporary modules in browser memory
  const [modules, setModules] = useState([]), [activeModule, setActiveModule] = useState(null), [moduleName, setModuleName] = useState("");
  const [selectedFile, setSelectedFile] = useState(null), [question, setQuestion] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState("");

  function createModule(event) {
    event.preventDefault();
    if (!moduleName.trim()) return;
    const nextModule = { id: Date.now(), name: moduleName.trim(), document_count: 0, message_count: 0, documents: [], messages: [] };
    setModules((currentModules) => [nextModule, ...currentModules]);
    setModuleName("");
  }

  function openModule(module) { setActiveModule(module); }
  function returnToDashboard() { setActiveModule(null); setError(""); }

  if (activeModule) return <StudyRoom module={activeModule} onBack={returnToDashboard} />;

  return (
    <main className="app-shell">
      <header className="hero"><div><p className="eyebrow">Local knowledge. Grounded answers.</p><h1>AI Module Workspace</h1><p>Organize lecture modules, index PDFs locally, and study with source-grounded AI.</p></div><span className="status-pill">Hybrid RAG workspace</span></header>
      <section className="dashboard-toolbar"><div><h2>Your modules</h2><p>Create a dedicated knowledge boundary for every subject.</p></div><form className="create-form" onSubmit={createModule}><input aria-label="Module name" value={moduleName} onChange={(event) => setModuleName(event.target.value)} placeholder="e.g. Software Engineering" /><button disabled={busy} type="submit">+ Create New Module</button></form></section>
      {error && <p className="error-banner">{error}</p>}
      <section className="module-grid" aria-live="polite">
        {modules.length === 0 && <div className="empty-card"><span className="empty-icon">+</span><h3>No modules yet</h3><p>Create your first module to open a focused study room.</p></div>}
        {modules.map((module, index) => <button className={`module-card tone-${index % 4}`} key={module.id} onClick={() => openModule(module)} type="button"><span className="folder-tab" /><span className="folder-symbol">F</span><strong>{module.name}</strong><span>{module.document_count} docs · {module.message_count} messages</span><small>Open study room</small></button>)}
      </section>
    </main>
  );
}

Compare your complete frontend/src/styles.css with this version:

/* Establish the page foundation and shared controls */
:root { color: #1f2937; background: #f4f7fb; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top left, rgba(109, 94, 252, 0.12), transparent 28rem), #f4f7fb; }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell, .room-shell { width: min(1440px, 100%); margin: 0 auto; padding: 32px; }

/* Shape the dashboard header and creation form */
.hero, .room-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 28px; color: white; border-radius: 28px; background: linear-gradient(135deg, #352f73, #6d5efc 60%, #9b8cff); box-shadow: 0 20px 55px rgba(53, 47, 115, 0.22); }
.hero h1, .room-header h1 { margin: 4px 0 8px; font-size: clamp(2rem, 5vw, 4rem); line-height: 1; }
.hero p, .room-header p { max-width: 660px; margin-bottom: 0; color: rgba(255, 255, 255, 0.82); }
.eyebrow { margin: 0; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; }
.status-pill { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border: 1px solid rgba(255, 255, 255, 0.24); border-radius: 999px; background: rgba(255, 255, 255, 0.12); color: white; font-size: 0.82rem; font-weight: 700; white-space: nowrap; }
.dashboard-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin: 38px 0 22px; }
.dashboard-toolbar h2 { margin: 0; }
.dashboard-toolbar p { margin: 6px 0 0; color: #6b7280; }
.create-form { display: flex; gap: 10px; width: min(100%, 580px); }
.create-form input { width: 100%; min-height: 46px; padding: 0 14px; border: 1px solid #d7dcec; border-radius: 14px; background: white; color: #111827; outline: none; }
.create-form button { min-height: 46px; padding: 0 18px; border: 0; border-radius: 14px; background: #2f2a68; color: white; font-weight: 800; white-space: nowrap; }

/* Turn module buttons into folder cards */
.module-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; }
.module-card, .empty-card { position: relative; min-height: 230px; padding: 28px; overflow: hidden; border: 0; border-radius: 22px; text-align: left; }
.module-card { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end; color: white; box-shadow: 0 14px 36px rgba(56, 49, 121, 0.18); transition: transform 180ms ease, box-shadow 180ms ease; }
.module-card:hover { transform: translateY(-5px); box-shadow: 0 20px 44px rgba(56, 49, 121, 0.26); }
.tone-0 { background: linear-gradient(145deg, #5b4be8, #8e7cff); } .tone-1 { background: linear-gradient(145deg, #007f8f, #31b7b4); } .tone-2 { background: linear-gradient(145deg, #9b4b7a, #df7da7); } .tone-3 { background: linear-gradient(145deg, #be6b2c, #f2aa62); }
.folder-tab { position: absolute; top: 0; left: 26px; width: 86px; height: 18px; border-radius: 0 0 12px 12px; background: rgba(255, 255, 255, 0.23); }
.folder-symbol, .empty-icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: auto; border-radius: 16px; background: rgba(255, 255, 255, 0.16); font-weight: 900; }
.module-card strong { margin-top: 26px; font-size: 1.3rem; }
.module-card span, .module-card small { margin-top: 8px; color: rgba(255, 255, 255, 0.8); }
.empty-card { border: 2px dashed #cbd2e4; background: rgba(255, 255, 255, 0.66); }
.empty-card .empty-icon { color: #6d5efc; background: #ebe8ff; }

/* Arrange the selected module as a three-column workspace */
.room-header { align-items: center; padding: 22px 26px; }
.room-header h1 { font-size: clamp(1.8rem, 4vw, 3rem); }
.back-button { border: 1px solid rgba(255, 255, 255, 0.26); border-radius: 12px; padding: 10px 14px; background: rgba(255, 255, 255, 0.12); color: white; font-weight: 800; }
.study-grid { display: grid; grid-template-columns: minmax(240px, 0.8fr) minmax(380px, 1.6fr) minmax(240px, 0.8fr); gap: 18px; margin-top: 22px; }
.panel { min-height: 650px; padding: 22px; border: 1px solid #e2e6f0; border-radius: 22px; background: rgba(255, 255, 255, 0.9); box-shadow: 0 14px 36px rgba(31, 41, 55, 0.08); }
.panel-heading { display: flex; gap: 12px; align-items: flex-start; padding-bottom: 18px; border-bottom: 1px solid #e7eaf2; }
.panel-heading > span { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; background: #ece9ff; color: #5b4be8; font-size: 0.72rem; font-weight: 900; }
.panel-heading h2 { margin: 0; }
.panel-heading p { margin: 6px 0 0; color: #6b7280; font-size: 0.85rem; }
.upload-box { display: grid; gap: 12px; margin-top: 20px; padding: 18px; border: 2px dashed #c8cce0; border-radius: 18px; background: #fafaff; }
.upload-box button, .chat-form button { min-height: 42px; padding: 0 15px; border: 0; border-radius: 14px; background: #2f2a68; color: white; font-weight: 800; }
.chat-panel { display: flex; flex-direction: column; }
.chat-empty { margin: auto; padding: 28px; border-radius: 18px; background: #f7f6ff; text-align: center; color: #5b4be8; }
.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding-top: 16px; border-top: 1px solid #e7eaf2; }
.chat-form textarea { width: 100%; resize: vertical; padding: 12px; border: 1px solid #d7dcec; border-radius: 14px; }
.tools-panel { display: flex; flex-direction: column; gap: 14px; }
.tool-card { padding: 16px; border: 1px solid #e2e6ef; border-radius: 16px; background: #fbfcff; }
.tool-card p { color: #6b7280; font-size: 0.82rem; line-height: 1.45; }
.tool-card button { min-height: 36px; border: 0; border-radius: 10px; background: #e8eaf2; color: #73788b; font-weight: 800; }
.muted { color: #7b8193; }

/* Stack the layout when the available width becomes narrow */
@media (max-width: 1100px) { .module-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .study-grid { grid-template-columns: 1fr; } .panel { min-height: auto; } }
@media (max-width: 700px) { .app-shell, .room-shell { padding: 16px; } .hero, .room-header, .dashboard-toolbar, .create-form { flex-direction: column; align-items: stretch; } .module-grid, .chat-form { grid-template-columns: 1fr; } }
Expose the refresh failure

The card creation flow feels complete because you can move between the dashboard and study room. A browser refresh now tests whether the module exists beyond the current page session.

  • Return to the dashboard by selecting Back to modules.
  • Confirm that the Software Engineering folder card still shows zero documents and zero messages.

Before you refresh, do you think the module card will remain on the dashboard?

  • Refresh http://localhost:5173.

You will see the empty dashboard again. The Software Engineering card has disappeared.

This failure is intentional

The modules array lived inside React component memory. Refreshing rebuilt the component with the original empty array.

The dashboard now proves its interaction design while exposing the missing persistence boundary. That gap gives the next step a concrete problem to solve.

You have a working module dashboard and a visible reason to move module ownership beyond browser memory. Next, you will connect the interface to FastAPI and SQLite so the cards survive a refresh.

Persist Module Folders

Your dashboard can create a convincing module workspace. A browser refresh still erases every card because React currently owns the module data in component memory.

This step moves ownership to a FastAPI backend with a local SQLite database. A REST API then gives the dashboard a persistent source of truth.

In this step, get ready to:
  • Create the database schema for modules and future study data.
  • Expose module list, creation, and detail endpoints.
  • Connect the dashboard to the persistent API.
Move module ownership into SQLite

SQLite stores the workspace in a single local file at backend/study_room.db. The database will eventually hold modules, documents, searchable chunks, and chat messages under one relational structure.

  • Return to backend/main.py in the VS Code editor.
  • Add the database-related imports beneath the existing json import by copying this group:
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path

What do these imports provide?

  • The sqlite3 module reads and writes the local database.
  • The contextmanager helper gives every database operation the same commit, rollback, and cleanup behavior.
  • The date utilities create consistent UTC timestamps for persisted records.
  • The Path class anchors the database beside main.py.
  • Find the existing FastAPI import in backend/main.py.
  • Replace it with this updated import:
from fastapi import FastAPI, HTTPException

Why add HTTPException?

The module endpoints use HTTPException to return clear status codes when a module is missing, blank, or duplicated. The frontend can turn those API details into visible error messages.

  • Add the database path and connection helper above the existing app = FastAPI(...) line by copying this code:
BASE_DIR = Path(__file__).resolve().parent
DATABASE_PATH = BASE_DIR / "study_room.db"


@contextmanager
def database():
    connection = sqlite3.connect(DATABASE_PATH)
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys = ON")
    try:
        yield connection
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        connection.close()


def now_iso():
    return datetime.now(timezone.utc).isoformat()

How does the connection helper protect data?

  • The DATABASE_PATH value keeps the database inside the existing backend folder.
  • The row factory lets endpoint helpers convert query results into JSON-ready dictionaries.
  • Foreign-key enforcement preserves relationships between modules and their future study data.
  • A successful operation commits its changes. A failed operation rolls them back.
  • Add the first version of init_database() below now_iso() by copying this code:
def init_database():
    with database() as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS modules (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL COLLATE NOCASE UNIQUE,
                created_at TEXT NOT NULL
            );

            CREATE TABLE IF NOT EXISTS documents (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                filename TEXT NOT NULL,
                page_count INTEGER NOT NULL,
                chunk_count INTEGER NOT NULL,
                created_at TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
            );
            """
        )


init_database()

What does this schema establish?

  • The modules table assigns every module a numeric ID, a unique case-insensitive name, and a creation time.
  • The documents table reserves module-owned records for the lecture PDFs added later.
  • The init_database() call creates missing tables whenever the backend starts.
  • Save backend/main.py.
  • Switch back to the backend PowerShell terminal from the previous step.

The backend reloads after the save. You should now see study_room.db beneath the backend folder in the VS Code Explorer.

  • Place the remaining table definitions after the documents table inside the same SQL string by copying this code:
            CREATE TABLE IF NOT EXISTS chunks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                document_id INTEGER NOT NULL,
                page_number INTEGER NOT NULL,
                chunk_index INTEGER NOT NULL,
                text TEXT NOT NULL,
                embedding_json TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE,
                FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
            );

            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                content TEXT NOT NULL,
                sources_json TEXT NOT NULL DEFAULT '[]',
                model TEXT,
                created_at TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
            );

Why create future tables now?

The chunks table will hold page-aware text and local embeddings. The messages table will keep each module's chat history and source metadata.

Every future record carries a module_id. That relationship creates the knowledge boundary the workspace relies on.

  • Save backend/main.py.

The backend reloads again. The existing study_room.db file now includes all four tables without deleting any stored data.

  • Insert the module lookup and card-list helpers above the init_database() call by copying this code:
def get_module_row(connection, module_id):
    module = connection.execute(
        "SELECT id, name, created_at FROM modules WHERE id = ?",
        (module_id,),
    ).fetchone()
    if module is None:
        raise HTTPException(status_code=404, detail="Module not found")
    return module


def list_module_cards(connection):
    rows = connection.execute(
        """
        SELECT
            m.id,
            m.name,
            m.created_at,
            (SELECT COUNT(*) FROM documents d WHERE d.module_id = m.id) AS document_count,
            (SELECT COUNT(*) FROM messages msg WHERE msg.module_id = m.id) AS message_count
        FROM modules m
        ORDER BY m.id DESC
        """
    ).fetchall()
    return [dict(row) for row in rows]

What do these helpers return?

  • The get_module_row() helper returns one module or raises a clear missing-module response.
  • The list_module_cards() helper returns the fields already displayed by each dashboard card.
  • The two nested counts calculate current document and message totals from stored records.
  • Add the module-list endpoint below the existing health() endpoint by copying this code:
@app.get("/api/modules")
def get_modules():
    with database() as connection:
        return list_module_cards(connection)

What does this endpoint expose?

A request to GET /api/modules now reads module cards from SQLite. A new database returns an empty JSON list because no module records exist yet.

  • Save backend/main.py.
  • Open http://127.0.0.1:8000/api/modules in your browser to check the endpoint.

You should see an empty JSON list. That response proves the browser can read the newly initialized database.

  • Insert the room-detail helper above the init_database() call by copying this code:
def module_details(connection, module_id):
    module = dict(get_module_row(connection, module_id))
    documents = connection.execute(
        """
        SELECT id, filename, page_count, chunk_count, created_at
        FROM documents
        WHERE module_id = ?
        ORDER BY id DESC
        """,
        (module_id,),
    ).fetchall()
    message_rows = connection.execute(
        """
        SELECT id, role, content, sources_json, model, created_at
        FROM messages
        WHERE module_id = ?
        ORDER BY id
        """,
        (module_id,),
    ).fetchall()
    messages = []
    for row in message_rows:
        message = dict(row)
        message["sources"] = json.loads(message.pop("sources_json") or "[]")
        messages.append(message)
    module["documents"] = [dict(row) for row in documents]
    module["messages"] = messages
    return module

How are rooms kept separate?

The document and message queries both filter by the requested module_id. Opening one room therefore returns only records owned by that module.

The helper converts stored source JSON into a list before returning the response. The React room can consume that shape directly.

  • Add the creation and detail endpoints below get_modules() by copying this code:
@app.post("/api/modules", status_code=201)
def create_module(payload: ModuleCreate):
    name = payload.name.strip()
    if not name:
        raise HTTPException(status_code=400, detail="Module name is required")

    try:
        with database() as connection:
            cursor = connection.execute(
                "INSERT INTO modules (name, created_at) VALUES (?, ?)",
                (name, now_iso()),
            )
            module_id = cursor.lastrowid
            module = get_module_row(connection, module_id)
            result = dict(module)
            result["document_count"] = 0
            result["message_count"] = 0
            return result
    except sqlite3.IntegrityError as exc:
        raise HTTPException(
            status_code=409,
            detail="A module with that name already exists",
        ) from exc


@app.get("/api/modules/{module_id}")
def get_module(module_id: int):
    with database() as connection:
        return module_details(connection, module_id)

What completes the module contract?

  • The creation endpoint trims the submitted name before storing it.
  • The unique database rule prevents duplicate names regardless of letter case.
  • The detail endpoint returns the selected module with its documents and messages.
  • Save backend/main.py.
  • Switch back to the backend PowerShell terminal.

You should see the server finish reloading without a traceback. The database layer and three module endpoints are now active.

Backend failing after the reload?

Check that every SQL table definition remains inside the triple-quoted string passed to connection.executescript(). A misplaced quote can turn SQL into invalid Python.

Check that init_database() sits below all four helper functions. This keeps the final file aligned with the project code.

Help me debug my SQLite initialization error.

✔️ Awesome, I've got everything!

Your backend now initializes study_room.db and exposes the persistent module contract.

ⓧ I'd like to double check the full code

Compare your complete backend/main.py with this cumulative version.

import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

BASE_DIR = Path(__file__).resolve().parent
DATABASE_PATH = BASE_DIR / "study_room.db"

app = FastAPI(title="AI Module Workspace API")


class ModuleCreate(BaseModel):
    name: str


class ChatRequest(BaseModel):
    question: str


@contextmanager
def database():
    connection = sqlite3.connect(DATABASE_PATH)
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys = ON")
    try:
        yield connection
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        connection.close()


def now_iso():
    return datetime.now(timezone.utc).isoformat()


def init_database():
    with database() as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS modules (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL COLLATE NOCASE UNIQUE,
                created_at TEXT NOT NULL
            );

            CREATE TABLE IF NOT EXISTS documents (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                filename TEXT NOT NULL,
                page_count INTEGER NOT NULL,
                chunk_count INTEGER NOT NULL,
                created_at TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
            );

            CREATE TABLE IF NOT EXISTS chunks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                document_id INTEGER NOT NULL,
                page_number INTEGER NOT NULL,
                chunk_index INTEGER NOT NULL,
                text TEXT NOT NULL,
                embedding_json TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE,
                FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
            );

            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                content TEXT NOT NULL,
                sources_json TEXT NOT NULL DEFAULT '[]',
                model TEXT,
                created_at TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
            );
            """
        )


def get_module_row(connection, module_id):
    module = connection.execute(
        "SELECT id, name, created_at FROM modules WHERE id = ?",
        (module_id,),
    ).fetchone()
    if module is None:
        raise HTTPException(status_code=404, detail="Module not found")
    return module


def list_module_cards(connection):
    rows = connection.execute(
        """
        SELECT
            m.id,
            m.name,
            m.created_at,
            (SELECT COUNT(*) FROM documents d WHERE d.module_id = m.id) AS document_count,
            (SELECT COUNT(*) FROM messages msg WHERE msg.module_id = m.id) AS message_count
        FROM modules m
        ORDER BY m.id DESC
        """
    ).fetchall()
    return [dict(row) for row in rows]


def module_details(connection, module_id):
    module = dict(get_module_row(connection, module_id))
    documents = connection.execute(
        """
        SELECT id, filename, page_count, chunk_count, created_at
        FROM documents
        WHERE module_id = ?
        ORDER BY id DESC
        """,
        (module_id,),
    ).fetchall()
    message_rows = connection.execute(
        """
        SELECT id, role, content, sources_json, model, created_at
        FROM messages
        WHERE module_id = ?
        ORDER BY id
        """,
        (module_id,),
    ).fetchall()

    messages = []
    for row in message_rows:
        message = dict(row)
        message["sources"] = json.loads(message.pop("sources_json") or "[]")
        messages.append(message)

    module["documents"] = [dict(row) for row in documents]
    module["messages"] = messages
    return module


init_database()


@app.get("/api/health")
def health():
    return {"status": "ok"}


@app.get("/api/modules")
def get_modules():
    with database() as connection:
        return list_module_cards(connection)


@app.post("/api/modules", status_code=201)
def create_module(payload: ModuleCreate):
    name = payload.name.strip()
    if not name:
        raise HTTPException(status_code=400, detail="Module name is required")

    try:
        with database() as connection:
            cursor = connection.execute(
                "INSERT INTO modules (name, created_at) VALUES (?, ?)",
                (name, now_iso()),
            )
            module_id = cursor.lastrowid
            module = get_module_row(connection, module_id)
            result = dict(module)
            result["document_count"] = 0
            result["message_count"] = 0
            return result
    except sqlite3.IntegrityError as exc:
        raise HTTPException(
            status_code=409,
            detail="A module with that name already exists",
        ) from exc


@app.get("/api/modules/{module_id}")
def get_module(module_id: int):
    with database() as connection:
        return module_details(connection, module_id)
Connect the dashboard through the Vite proxy

The browser needs a stable path to the new API while both development servers run on separate ports. A Vite development proxy forwards every relative /api request to FastAPI.

  • Return to frontend/vite.config.js in VS Code.
  • Replace its contents with this configuration:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://127.0.0.1:8000",
    },
  },
});

Why use a development proxy?

The frontend can request relative paths such as /api/modules. Vite forwards those requests to the backend while the React code stays independent of a development port.

This keeps the local request boundary narrow. The project does not need broad browser access rules for development.

  • Save frontend/vite.config.js.
  • Switch back to the frontend PowerShell terminal from the previous step.
  • Stop the existing development server by pressing Ctrl+C.
  • Restart the frontend with the new proxy configuration by running:
npm run dev

What should you see?

Vite prints its local development address after it starts. Keep this terminal running so the proxy and React Fast Refresh remain active.

Frontend server not restarting?

Confirm the terminal is still inside the existing frontend folder. The command needs the pinned package.json in that folder.

Help me troubleshoot the Vite development server.

  • Return to frontend/src/App.jsx in VS Code.
  • Find the existing React import at the top of the file:
import { useState } from "react";

What is changing?

The current import supports temporary component state. The dashboard now also needs an effect that loads stored modules when it mounts.

  • Replace the existing import with this updated line:
import { useEffect, useState } from "react";

Why add useEffect?

The useEffect hook runs the initial module request after React mounts the dashboard. Stored cards can therefore return after a page refresh.

  • Add the shared request helper between the React import and export default function App() by copying this code:
async function requestJson(path, options = {}) {
  const response = await fetch(path, options);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(data.detail || "The request failed");
  }
  return data;
}

What does requestJson handle?

The helper sends requests through the proxy and parses JSON responses. Failed responses become JavaScript errors containing the backend's detail message.

  • Save frontend/src/App.jsx.

The dashboard should compile and remain visible. This confirms the new import and request helper are valid before they replace the temporary state actions.

  • Add loadModules() and the initial effect below the existing state declarations by copying this code:
  async function loadModules() {
    try {
      setError("");
      setModules(await requestJson("/api/modules"));
    } catch (caughtError) {
      setError(caughtError.message);
    }
  }

  useEffect(() => {
    loadModules();
  }, []);

How does the dashboard load persisted cards?

  • The loadModules() function replaces the card array with the database response.
  • The empty dependency list runs the effect once for each dashboard mount.
  • A backend failure appears in the existing error banner.
  • Save frontend/src/App.jsx.

The dashboard reloads with its empty-state card because the database does not contain a module yet. That visible state came through the proxy from SQLite.

  • Find the existing temporary createModule() function in frontend/src/App.jsx.
  • Replace the entire function with this API-backed version:
  async function createModule(event) {
    event.preventDefault();
    if (!moduleName.trim()) return;
    try {
      setBusy(true);
      setError("");
      await requestJson("/api/modules", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name: moduleName }),
      });
      setModuleName("");
      await loadModules();
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

How does module creation persist?

The form now sends a JSON object containing the module name to POST /api/modules. A successful insert triggers a fresh module list from SQLite.

The existing busy and error states keep the form responsive during the request. Duplicate or blank names surface the backend's message.

  • Save frontend/src/App.jsx.
  • Enter Software Engineering in the module-name field.
  • Click + Create New Module.

The purple folder card should appear with 0 docs · 0 messages. That card now represents a row inside study_room.db.

  • Find the existing temporary openModule() function.
  • Replace the entire function with this detail request:
  async function openModule(moduleId) {
    try {
      setBusy(true);
      setError("");
      setSelectedFile(null);
      setActiveModule(await requestJson(`/api/modules/${moduleId}`));
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

What happens when a card opens?

The card passes its stored numeric ID to openModule(). The detail endpoint returns the module with its own document and message lists.

  • Find the onClick property on the mapped module-card button.
  • Set the property to this ID-based handler:
              onClick={() => openModule(module.id)}

Why pass the ID?

The backend owns the complete module record. The card only needs its persistent ID to request the latest room state.

  • Add refreshRoom() below openModule() by copying this code:
  async function refreshRoom() {
    if (!activeModule) return;
    const refreshed = await requestJson(`/api/modules/${activeModule.id}`);
    setActiveModule(refreshed);
    await loadModules();
  }

Why refresh both views?

The room will use refreshRoom() after future document and chat requests. It refreshes the active room before updating the card counts shown on the dashboard.

  • Find the existing returnToDashboard() function.
  • Replace the entire function with this persistent version:
  function returnToDashboard() {
    setActiveModule(null);
    setError("");
    loadModules();
  }

What changes on the way back?

Returning to the dashboard now reloads cards from SQLite. The browser no longer relies on the copy that happened to be in memory.

  • Save frontend/src/App.jsx.

The React page should refresh without an error banner. The folder card remains available through the API-backed dashboard.

Dashboard showing a request error?

Confirm the backend terminal still shows FastAPI running. The proxy cannot load modules when the backend process is stopped.

Confirm vite.config.js maps /api to the backend address. Restart Vite after correcting the configuration.

Help me trace the failing module request.

✔️ Awesome, I've got everything!

Your proxy is active. The React persistence helpers now create, load, open, and refresh database-backed modules.

ⓧ I'd like to double check the full code

Compare your complete frontend/vite.config.js with this version.

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://127.0.0.1:8000",
    },
  },
});

Double-check that frontend/src/App.jsx contains the exact persistence sections supplied above: requestJson, loadModules, the initial useEffect, createModule, openModule, refreshRoom, and returnToDashboard.

Prove modules survive a refresh

Persistence is only useful when a fresh browser session can reconstruct the same workspace. You will now repeat the action that erased the temporary dashboard.

  • Open http://localhost:5173 in your browser.
  • Click the Software Engineering module card.

You should see the three-column study room with Software Engineering in the header. The room came from GET /api/modules/{module_id}.

  • Click Back to modules.

Before you refresh, what do you expect the dashboard to reconstruct from SQLite? Make your prediction now.

  • Refresh the page by pressing Cmd+R (macOS) or Ctrl+R (Windows).

You should still see the Software Engineering folder card with 0 docs · 0 messages. The designed failure from the previous step is fixed.

  • Open the persisted Software Engineering card again.
  • Return to the dashboard with Back to modules.
  • Open http://127.0.0.1:8000/docs in a new browser tab.
  • Confirm the generated contract lists the module list, creation, and detail routes.

That is the persistence boundary working end to end. React sends relative requests, Vite forwards them, FastAPI applies the contract, and SQLite keeps the records after memory resets.

Your module workspace now has durable folders and recoverable room state. Next, you will turn one of those rooms into searchable lecture knowledge by extracting a PDF and creating local embeddings.

Index a Lecture PDF Locally

Your module folders now survive a browser refresh. However, each study room still lacks searchable lecture knowledge.

This step uses FastAPI to extract a text-based PDF with pypdf. Local Ollama embeddings then turn each page-aware chunk into a searchable representation stored in SQLite.

In this step, get ready to:
  • Install the PDF upload dependencies.
  • Extract page-aware chunks from a lecture PDF.
  • Embed the chunks locally and display the indexed document.
Install PDF tools and pull the embedding model

Text extraction requires a PDF reader. Multipart support lets the browser send the selected file to FastAPI. A scanned image-only PDF contains no selectable text for pypdf to extract.

  • Switch back to the PowerShell terminal running the backend.
  • Stop the FastAPI development server by pressing Ctrl+C.
  • In Visual Studio Code, select backend/requirements.txt from the Explorer sidebar.
  • Replace its contents with the following pinned dependencies:
fastapi[standard]==0.162.0
pydantic==2.14.1
pypdf==6.21.2
python-multipart==0.0.32

What do these dependencies add?

  • pypdf reads each PDF page so the backend can extract selectable text.
  • python-multipart lets FastAPI receive the uploaded file as multipart form data.
  • Save backend/requirements.txt.
  • Reinstall the backend dependencies by running this command from the backend folder:
py -m pip install -r requirements.txt

What does this command do?

The command reads every pinned package from requirements.txt. It installs the PDF reader and multipart upload support into the same Python environment used by the backend.

The installation is complete when PowerShell returns to the prompt without a dependency error.

The embedding model is a 639 MB download. Expect the pull to take a few minutes on some connections.

  • Download the local embedding model by running:
ollama pull qwen3-embedding:0.6b

What does this download provide?

The qwen3-embedding:0.6b model converts text into numeric vectors. The same model later embeds each question so cosine similarity can compare it with the stored lecture chunks.

The model is ready when the pull completes and PowerShell returns to the prompt.

Having trouble with the dependencies or model?

Confirm the terminal is inside the backend folder if the requirements file cannot be found.

Confirm Ollama is running locally if the model pull cannot connect.

Help me fix the installation or model download.

Build the local indexing endpoint

The upload endpoint needs three layers. It validates the file first. It then extracts overlapping chunks before sending only those chunks to the local Ollama API.

  • In backend/main.py, replace the import section with the following imports:
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from urllib import error, request

from fastapi import FastAPI, HTTPException, UploadFile
from pydantic import BaseModel
from pypdf import PdfReader

What do these imports enable?

  • UploadFile exposes the uploaded PDF as a file-like object.
  • PdfReader reads that object without saving a separate uploaded copy.
  • The urllib imports send JSON requests to the local embedding service.
  • Find the existing DATABASE_PATH constant.
  • Replace the constants at the top of backend/main.py with the following block:
BASE_DIR = Path(__file__).resolve().parent
DATABASE_PATH = BASE_DIR / "study_room.db"
OLLAMA_EMBED_URL = "http://localhost:11434/api/embed"
OLLAMA_MODEL = "qwen3-embedding:0.6b"

Why keep these values together?

The constants keep the local database path and embedding service configuration in one place. The backend always uses the same embedding model for lecture chunks and future questions.

  • Save backend/main.py.
  • Restart the backend development server by running:
fastapi dev

What does this check prove?

A successful restart confirms Python can import the new packages. Keep this terminal running so FastAPI reloads after each backend edit.

You should see the backend finish its startup without an import failure.

  • In backend/main.py, locate the end of module_details.
  • Add the JSON request helper directly below module_details by copying this code:
def post_json(url, payload, headers=None, timeout=180):
    request_headers = {"Content-Type": "application/json"}
    if headers:
        request_headers.update(headers)

    remote_request = request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers=request_headers,
        method="POST",
    )

    try:
        with request.urlopen(remote_request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(
            f"Remote service returned HTTP {exc.code}: {body[:400]}"
        ) from exc
    except error.URLError as exc:
        raise RuntimeError(f"Could not reach remote service: {exc.reason}") from exc

What does this helper do?

  • post_json serializes a Python payload before sending it to a JSON endpoint.
  • The timeout gives local embedding batches time to finish.
  • Network failures become runtime errors that the endpoint can convert into visible API responses.
  • Add the embedding helper directly below post_json by copying this code:
def embed_texts(texts):
    response = post_json(
        OLLAMA_EMBED_URL,
        {"model": OLLAMA_MODEL, "input": texts},
    )
    embeddings = response.get("embeddings", [])
    if len(embeddings) != len(texts):
        raise RuntimeError("Ollama returned an unexpected number of embeddings")
    return embeddings

How are the embeddings checked?

embed_texts sends an array of text chunks to Ollama. It verifies that every submitted chunk receives one embedding before returning the vectors.

  • Save backend/main.py.
  • Check the backend terminal after FastAPI reloads the file.

The backend should complete its reload without a Python syntax error.

  • Add the page chunking helper directly below embed_texts by copying this code:
def split_page(text, chunk_size=900, overlap=150):
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        if end == len(text):
            break
        start = end - overlap
    return chunks

Why do the chunks overlap?

Each chunk holds up to 900 characters. The 150-character overlap preserves context when an explanation crosses a chunk boundary.

The endpoint stores the page number separately. This gives every retrieved excerpt a traceable source page.

  • Locate the existing get_module endpoint near the bottom of backend/main.py.
  • Add the first part of the upload endpoint directly below get_module by copying this code:
@app.post("/api/modules/{module_id}/documents", status_code=201)
def upload_document(module_id: int, file: UploadFile):
    with database() as connection:
        get_module_row(connection, module_id)

    filename = file.filename or "lecture.pdf"
    if not filename.lower().endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Choose a PDF file")

    try:
        reader = PdfReader(file.file)
        page_chunks = []
        for page_number, page in enumerate(reader.pages, start=1):
            text = " ".join((page.extract_text() or "").split())
            for chunk in split_page(text):
                page_chunks.append(
                    {"page_number": page_number, "text": chunk}
                )
    except Exception as exc:
        raise HTTPException(
            status_code=400,
            detail="The PDF could not be read. Use an unencrypted text-based PDF.",
        ) from exc

    if not page_chunks:
        raise HTTPException(
            status_code=400,
            detail="No extractable text was found. Scanned PDFs need OCR, which is outside this project.",
        )

How does upload validation work?

  • get_module_row confirms that the requested module exists before indexing begins.
  • The filename check rejects uploads without the PDF extension.
  • PdfReader extracts each page independently so every chunk keeps its originating page number.
  • An empty chunk list identifies a PDF with no extractable text.
  • Save backend/main.py.
  • Return to http://127.0.0.1:8000/docs in your browser.
  • Confirm the document upload endpoint appears under its module path.

You should see POST /api/modules/{module_id}/documents in the generated API contract. This proves FastAPI registered the new request path.

  • Return to backend/main.py.
  • Add the embedding and document creation block directly below the empty-text check:
    try:
        embeddings = embed_texts([chunk["text"] for chunk in page_chunks])
    except RuntimeError as exc:
        raise HTTPException(
            status_code=502,
            detail=f"Local embedding failed: {exc}",
        ) from exc

    with database() as connection:
        cursor = connection.execute(
            """
            INSERT INTO documents
                (module_id, filename, page_count, chunk_count, created_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                module_id,
                filename,
                len(reader.pages),
                len(page_chunks),
                now_iso(),
            ),
        )
        document_id = cursor.lastrowid

What gets created first?

Ollama embeds every extracted chunk as one batch. The endpoint creates the document record only after that local embedding request succeeds.

The document row stores the filename and counts needed by the React room.

  • Save backend/main.py.
  • Check the backend terminal after the automatic reload.

The backend should finish reloading with the upload endpoint still listed.

  • Add the chunk storage loop directly below document_id = cursor.lastrowid by copying this code:
        for chunk_index, (chunk, embedding) in enumerate(
            zip(page_chunks, embeddings)
        ):
            connection.execute(
                """
                INSERT INTO chunks
                    (module_id, document_id, page_number, chunk_index, text, embedding_json)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (
                    module_id,
                    document_id,
                    chunk["page_number"],
                    chunk_index,
                    chunk["text"],
                    json.dumps(embedding),
                ),
            )

        return {
            "id": document_id,
            "filename": filename,
            "page_count": len(reader.pages),
            "chunk_count": len(page_chunks),
        }

What does each chunk record contain?

Every row includes the active module ID and its parent document ID. It also stores the page number and extracted text beside the locally generated embedding JSON.

The module ID is the boundary that keeps future retrieval inside the open study room.

  • Save backend/main.py.
  • Confirm the backend completes one more automatic reload.

Your indexing path is now ready to receive a real lecture PDF from the frontend.

Is the backend failing to reload?

Check that the chunk storage loop remains indented inside upload_document. The final return block must remain inside the database context.

Check that UploadFile and PdfReader appear in the import section.

Help me debug the PDF indexing endpoint.

Connect the room upload form

The backend can now index a lecture. The React room needs to hold the chosen file and submit it as multipart form data.

  • In frontend/src/App.jsx, find the existing moduleName state.
  • Add this file selection state directly below it:
  const [selectedFile, setSelectedFile] = useState(null);

What does this state hold?

selectedFile holds the browser file object chosen in the lecture material panel. Resetting it after a successful upload prepares the room for another document.

  • Find the existing refreshRoom function.
  • Add the upload handler directly below refreshRoom by copying this code:
  async function uploadPdf(event) {
    event.preventDefault();
    if (!selectedFile || !activeModule) return;

    const formData = new FormData();
    formData.append("file", selectedFile);

    try {
      setBusy(true);
      setError("");
      await requestJson(`/api/modules/${activeModule.id}/documents`, {
        method: "POST",
        body: formData,
      });
      setSelectedFile(null);
      event.currentTarget.reset();
      await refreshRoom();
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

How does the upload handler work?

  • FormData packages the selected file under the field name expected by FastAPI.
  • requestJson posts the form through the existing Vite API proxy.
  • refreshRoom reloads the module after indexing so the new document appears immediately.
  • The error state shows PDF validation or local embedding failures inside the room.
  • Save frontend/src/App.jsx.
  • Check the frontend terminal after React Fast Refresh processes the edit.

The frontend should finish its update without a JavaScript compilation error.

  • In the lecture material panel, replace the existing upload form with this connected form:
          <form className="upload-box" onSubmit={uploadPdf}>
            <label htmlFor="pdf-upload">Choose a text-based PDF</label>
            <input
              accept=".pdf,application/pdf"
              id="pdf-upload"
              onChange={(event) => setSelectedFile(event.target.files[0] || null)}
              type="file"
            />
            <button disabled={!selectedFile || busy} type="submit">
              Upload and index
            </button>
          </form>

What does the form enforce?

The file picker asks for PDF files. The upload button stays disabled until a file is selected.

The backend still performs the authoritative validation because browser file filters can be bypassed.

  • Replace the existing document placeholder below the form with this document list and privacy note:
          <div className="document-list">
            {activeModule.documents.length === 0 ? (
              <p className="muted">No indexed documents yet.</p>
            ) : (
              activeModule.documents.map((document) => (
                <article className="document-item" key={document.id}>
                  <span>PDF</span>
                  <div>
                    <strong>{document.filename}</strong>
                    <small>
                      {document.page_count} pages · {document.chunk_count} chunks
                    </small>
                  </div>
                </article>
              ))
            )}
          </div>
          <p className="privacy-note">
            Full PDFs and embeddings stay local. Retrieved excerpts are sent to
            OpenRouter when you ask a question.
          </p>

What stays on your computer?

The full PDF and extracted text stay inside the local backend process and SQLite database. Ollama creates every embedding through its local API.

No OpenRouter request occurs during indexing. Hosted generation begins only when a later chat request sends selected excerpts.

  • Save frontend/src/App.jsx.

Before you test the upload, what filename and counts do you expect the study room to display?

  • Return to http://localhost:5173 in your browser.
  • Open the persistent module you chose for this project.
  • Select your text-based lecture PDF using the file picker in the lecture material panel.
  • Click Upload and index.

Indexing can take a little while because every extracted chunk receives a local embedding. Keep the backend terminal visible so you can distinguish active work from a stopped server.

You should see the PDF filename followed by its page count and chunk count in the left panel. The module card also shows a document count after you return to the dashboard.

That is the local knowledge layer working. Your lecture now has page-aware chunks and searchable embeddings under the correct module.

Is the PDF missing after upload?

Use a PDF whose words can be selected. A scanned image-only lecture requires OCR and produces no extractable chunks in this project.

Confirm Ollama is still running if the room reports a local embedding failure. Confirm the qwen3-embedding:0.6b pull completed before retrying.

Help me diagnose my PDF upload failure.

✔️ Awesome, I've got everything!

Great. Confirm backend/requirements.txt, backend/main.py, and frontend/src/App.jsx are saved.

Keep both development servers running for the next step.

ⓧ I'd like to double check the indexing code

Your complete backend/requirements.txt should contain these four pinned dependencies:

fastapi[standard]==0.162.0
pydantic==2.14.1
pypdf==6.21.2
python-multipart==0.0.32

This file adds the exact PDF reader and multipart upload packages used by the endpoint.

The new local indexing section in backend/main.py should match this reference:

OLLAMA_EMBED_URL = "http://localhost:11434/api/embed"
OLLAMA_MODEL = "qwen3-embedding:0.6b"


def post_json(url, payload, headers=None, timeout=180):
    request_headers = {"Content-Type": "application/json"}
    if headers:
        request_headers.update(headers)

    remote_request = request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers=request_headers,
        method="POST",
    )

    try:
        with request.urlopen(remote_request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(
            f"Remote service returned HTTP {exc.code}: {body[:400]}"
        ) from exc
    except error.URLError as exc:
        raise RuntimeError(f"Could not reach remote service: {exc.reason}") from exc


def embed_texts(texts):
    response = post_json(
        OLLAMA_EMBED_URL,
        {"model": OLLAMA_MODEL, "input": texts},
    )
    embeddings = response.get("embeddings", [])
    if len(embeddings) != len(texts):
        raise RuntimeError("Ollama returned an unexpected number of embeddings")
    return embeddings


def split_page(text, chunk_size=900, overlap=150):
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        if end == len(text):
            break
        start = end - overlap
    return chunks


@app.post("/api/modules/{module_id}/documents", status_code=201)
def upload_document(module_id: int, file: UploadFile):
    with database() as connection:
        get_module_row(connection, module_id)

    filename = file.filename or "lecture.pdf"
    if not filename.lower().endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Choose a PDF file")

    try:
        reader = PdfReader(file.file)
        page_chunks = []
        for page_number, page in enumerate(reader.pages, start=1):
            text = " ".join((page.extract_text() or "").split())
            for chunk in split_page(text):
                page_chunks.append(
                    {"page_number": page_number, "text": chunk}
                )
    except Exception as exc:
        raise HTTPException(
            status_code=400,
            detail="The PDF could not be read. Use an unencrypted text-based PDF.",
        ) from exc

    if not page_chunks:
        raise HTTPException(
            status_code=400,
            detail="No extractable text was found. Scanned PDFs need OCR, which is outside this project.",
        )

    try:
        embeddings = embed_texts([chunk["text"] for chunk in page_chunks])
    except RuntimeError as exc:
        raise HTTPException(
            status_code=502,
            detail=f"Local embedding failed: {exc}",
        ) from exc

    with database() as connection:
        cursor = connection.execute(
            """
            INSERT INTO documents
                (module_id, filename, page_count, chunk_count, created_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                module_id,
                filename,
                len(reader.pages),
                len(page_chunks),
                now_iso(),
            ),
        )
        document_id = cursor.lastrowid

        for chunk_index, (chunk, embedding) in enumerate(
            zip(page_chunks, embeddings)
        ):
            connection.execute(
                """
                INSERT INTO chunks
                    (module_id, document_id, page_number, chunk_index, text, embedding_json)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (
                    module_id,
                    document_id,
                    chunk["page_number"],
                    chunk_index,
                    chunk["text"],
                    json.dumps(embedding),
                ),
            )

        return {
            "id": document_id,
            "filename": filename,
            "page_count": len(reader.pages),
            "chunk_count": len(page_chunks),
        }

This reference covers every backend addition from this step. Keep the existing database helpers and module endpoints around these additions.

The upload state and handler in frontend/src/App.jsx should match this reference:

  const [selectedFile, setSelectedFile] = useState(null);

  async function uploadPdf(event) {
    event.preventDefault();
    if (!selectedFile || !activeModule) return;

    const formData = new FormData();
    formData.append("file", selectedFile);

    try {
      setBusy(true);
      setError("");
      await requestJson(`/api/modules/${activeModule.id}/documents`, {
        method: "POST",
        body: formData,
      });
      setSelectedFile(null);
      event.currentTarget.reset();
      await refreshRoom();
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

          <form className="upload-box" onSubmit={uploadPdf}>
            <label htmlFor="pdf-upload">Choose a text-based PDF</label>
            <input
              accept=".pdf,application/pdf"
              id="pdf-upload"
              onChange={(event) => setSelectedFile(event.target.files[0] || null)}
              type="file"
            />
            <button disabled={!selectedFile || busy} type="submit">
              Upload and index
            </button>
          </form>

          <div className="document-list">
            {activeModule.documents.length === 0 ? (
              <p className="muted">No indexed documents yet.</p>
            ) : (
              activeModule.documents.map((document) => (
                <article className="document-item" key={document.id}>
                  <span>PDF</span>
                  <div>
                    <strong>{document.filename}</strong>
                    <small>
                      {document.page_count} pages · {document.chunk_count} chunks
                    </small>
                  </div>
                </article>
              ))
            )}
          </div>
          <p className="privacy-note">
            Full PDFs and embeddings stay local. Retrieved excerpts are sent to
            OpenRouter when you ask a question.
          </p>

Place the state with the other React state declarations. Place the handler below refreshRoom and keep the JSX inside the lecture material panel.

Your module now contains searchable lecture knowledge without sending the full PDF to a hosted model. Next, you will retrieve the most relevant chunks and turn them into source-grounded answers.

Add Grounded Chat with Sources

Your React room can store searchable lecture chunks. Those vectors still need a retrieval path that turns a question into a traceable answer.

This step adds module-scoped retrieval and hosted generation through OpenRouter. The question and four selected excerpts leave your computer. The PDF, embeddings, and database remain local.

In this step, get ready to:
  • Store the OpenRouter API key in the backend PowerShell session.
  • Rank the active module's chunks with cosine similarity.
  • Display grounded answers with the routed model and source pages.
Protect the hosted API key

An API key authorizes requests to a hosted service. Keeping it in the backend process prevents browser code from exposing it to anyone who opens developer tools.

Credentials deserve extra care. Your key stays in the existing backend PowerShell session. It never enters a project file.

  • Return to the backend PowerShell terminal from earlier.
  • Stop the running backend by pressing Ctrl+C.
  • Replace sk-or-v1-YOUR-API-KEY with your supplied key in the command below.
  • Set the key for this PowerShell session by running:
$env:OPENROUTER_API_KEY="sk-or-v1-YOUR-API-KEY"

What does this command do?

The command places the key in the current PowerShell process environment. The backend can read it without storing it in React code or a project file.

  • Restart the backend from the existing backend folder by running:
fastapi dev

Why restart the backend?

The restarted process inherits the environment variable you just set. The key disappears from that process when you close the terminal.

You should see the backend complete its startup and remain ready for requests.

Backend not starting?

Confirm the terminal is still inside the backend folder. Another running backend process can also keep the local port occupied.

If the server starts but hosted generation later rejects the key, set the environment variable again in this same terminal before restarting the backend.

Help me diagnose my backend startup or OpenRouter key issue.

Build module-scoped retrieval and generation

A grounded answer starts with retrieval. The backend embeds the question with Ollama. It compares that vector only with chunks belonging to the open module.

Why cosine similarity?

Cosine similarity measures how closely two vectors point in the same direction. Ranking by this score surfaces chunks whose meaning is closest to the question.

Filtering by module_id before ranking creates the knowledge boundary. A question in one room cannot retrieve chunks owned by another room.

  • Switch back to backend/main.py in Visual Studio Code.
  • Replace the opening imports, constants, app declaration, and request schemas with this updated section:
import json
import math
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from urllib import error, request

from fastapi import FastAPI, HTTPException, UploadFile
from pydantic import BaseModel
from pypdf import PdfReader

BASE_DIR = Path(__file__).resolve().parent
DATABASE_PATH = BASE_DIR / "study_room.db"
OLLAMA_EMBED_URL = "http://localhost:11434/api/embed"
OLLAMA_MODEL = "qwen3-embedding:0.6b"
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = "openrouter/free"

app = FastAPI(title="AI Module Workspace API")


class ModuleCreate(BaseModel):
    name: str


class ChatRequest(BaseModel):
    question: str

What does this section add?

  • The math import supports vector magnitude calculations.
  • The os import reads the backend-only environment variable.
  • OPENROUTER_URL and OPENROUTER_MODEL define the hosted generation target.
  • ChatRequest validates the JSON question sent by React.
  • Find the end of split_page() in backend/main.py.
  • Add the vector comparison helper directly below it:
def cosine_similarity(left, right):
    if not left or len(left) != len(right):
        return -1.0
    dot_product = sum(a * b for a, b in zip(left, right))
    left_norm = math.sqrt(sum(value * value for value in left))
    right_norm = math.sqrt(sum(value * value for value in right))
    if left_norm == 0 or right_norm == 0:
        return -1.0
    return dot_product / (left_norm * right_norm)

How does the comparison work?

The helper calculates a dot product and divides it by both vector magnitudes. Invalid vector pairs receive a low score so they cannot outrank valid matches.

The chat endpoint has one long request path. You will add it in focused sections so each boundary stays visible.

  • Move to the end of backend/main.py.
  • Start the chat endpoint with question validation and the module-filtered chunk query:
@app.post("/api/modules/{module_id}/chat")
def chat_with_module(module_id: int, payload: ChatRequest):
    question = payload.question.strip()
    if not question:
        raise HTTPException(status_code=400, detail="Enter a question")

    with database() as connection:
        get_module_row(connection, module_id)
        chunk_rows = connection.execute(
            """
            SELECT c.id, c.text, c.page_number, c.embedding_json, d.filename
            FROM chunks c
            JOIN documents d ON d.id = c.document_id
            WHERE c.module_id = ?
            """,
            (module_id,),
        ).fetchall()

    if not chunk_rows:
        raise HTTPException(
            status_code=400,
            detail="Upload and index a PDF before asking questions",
        )

Where is the module boundary?

The query joins chunks to their document metadata. The WHERE c.module_id = ? condition limits retrieval to the room identified by the URL.

  • Continue inside chat_with_module() with question embedding and local ranking:
    try:
        question_embedding = embed_texts([question])[0]
    except RuntimeError as exc:
        raise HTTPException(
            status_code=502,
            detail=f"Question embedding failed: {exc}",
        ) from exc

    ranked = []
    for row in chunk_rows:
        score = cosine_similarity(
            question_embedding,
            json.loads(row["embedding_json"]),
        )
        ranked.append((score, row))
    ranked.sort(key=lambda item: item[0], reverse=True)

What happens during ranking?

The question uses the same local embedding model as the document chunks. Each stored JSON vector receives a similarity score before the list is sorted from strongest match to weakest.

  • Continue the endpoint by packaging the four strongest matches as numbered sources:
    sources = []
    context_blocks = []
    for source_number, (score, row) in enumerate(ranked[:4], start=1):
        source = {
            "number": source_number,
            "filename": row["filename"],
            "page_number": row["page_number"],
            "text": row["text"],
            "score": round(score, 4),
        }
        sources.append(source)
        context_blocks.append(
            f"[Source {source_number}] {row['filename']}, page {row['page_number']}\n{row['text']}"
        )

Why keep source metadata?

Each selected excerpt retains its filename and page number. The same metadata later appears beneath the answer so the learner can inspect the evidence.

  • Continue the endpoint with the key check and grounded prompt:
    api_key = os.getenv("OPENROUTER_API_KEY")
    if not api_key:
        raise HTTPException(
            status_code=500,
            detail="OPENROUTER_API_KEY is not set in the backend terminal",
        )

    prompt = (
        "Use only the lecture excerpts below to answer the question. "
        "Cite supporting excerpts with [Source N]. If the excerpts do not contain "
        "the answer, say that the uploaded lecture material does not provide it.\n\n"
        + "\n\n".join(context_blocks)
        + f"\n\nQuestion: {question}"
    )

What makes the prompt grounded?

The prompt limits the answer to the retrieved lecture excerpts. It also tells the model how to respond when those excerpts do not contain enough evidence.

  • Continue the endpoint with the hosted generation request:
    try:
        response = post_json(
            OPENROUTER_URL,
            {
                "model": OPENROUTER_MODEL,
                "messages": [
                    {
                        "role": "system",
                        "content": "You are a careful study assistant that grounds every answer in supplied lecture excerpts.",
                    },
                    {"role": "user", "content": prompt},
                ],
            },
            headers={"Authorization": f"Bearer {api_key}"},
        )
        answer = response["choices"][0]["message"]["content"]
        actual_model = response.get("model", OPENROUTER_MODEL)
    except (RuntimeError, KeyError, IndexError, TypeError) as exc:
        raise HTTPException(
            status_code=502,
            detail=f"OpenRouter generation failed: {exc}",
        ) from exc

What leaves your computer?

The request contains the question and four selected excerpts. It does not contain the full PDF or the complete set of local embeddings.

The response also identifies the actual routed model. Saving that value makes provider routing visible instead of hiding it behind the free router.

  • Continue the endpoint by storing the learner's question:
    with database() as connection:
        created_at = now_iso()
        connection.execute(
            """
            INSERT INTO messages
                (module_id, role, content, sources_json, model, created_at)
            VALUES (?, 'user', ?, '[]', NULL, ?)
            """,
            (module_id, question, created_at),
        )

Why persist the question?

The user message belongs to the active module in SQLite. Reloading the room can reconstruct the conversation from local storage.

  • Finish the endpoint by storing the grounded answer and returning it to the client:
        connection.execute(
            """
            INSERT INTO messages
                (module_id, role, content, sources_json, model, created_at)
            VALUES (?, 'assistant', ?, ?, ?, ?)
            """,
            (
                module_id,
                answer,
                json.dumps(sources),
                actual_model,
                created_at,
            ),
        )

    return {"answer": answer, "model": actual_model, "sources": sources}

What does the saved answer contain?

The assistant row stores the answer, serialized source cards, routed model, timestamp, and module ID. The API returns the same grounded result immediately.

  • Save backend/main.py.
  • Wait for the running backend terminal to report that the application reloaded.
  • Open http://127.0.0.1:8000/docs in your browser.
  • Confirm the generated API contract now includes POST /api/modules/{module_id}/chat.

The new route proves that FastAPI loaded the retrieval and generation workflow.

Chat route missing?

Check the backend terminal for a Python syntax error. Confirm every endpoint section remains indented inside chat_with_module().

Help me find why my FastAPI chat route is missing.

Connect the room chat and inspect its sources

The backend now returns a persisted answer with evidence. The center panel needs to submit questions and render each message with its model and source cards.

  • Switch back to frontend/src/App.jsx.
  • Find the state declarations near the top of App().
  • Replace the declarations from moduleName through error with this updated group:
  const [moduleName, setModuleName] = useState("");
  const [selectedFile, setSelectedFile] = useState(null);
  const [question, setQuestion] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

What does the new state hold?

The question state tracks the text in the chat form. Existing busy and error state can cover both PDF indexing and chat requests.

  • Add askQuestion() directly below uploadPdf().
  async function askQuestion(event) {
    event.preventDefault();
    const nextQuestion = question.trim();
    if (!nextQuestion || !activeModule) return;

    try {
      setBusy(true);
      setError("");
      setQuestion("");
      await requestJson(`/api/modules/${activeModule.id}/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ question: nextQuestion }),
      });
      await refreshRoom();
    } catch (caughtError) {
      setQuestion(nextQuestion);
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

What does the submit handler do?

The handler sends the question to the active module's chat route. A successful request reloads persisted room details.

A failed request restores the question so the learner can retry. The shared busy state prevents duplicate submissions while generation is running.

  • Find the messages container in the center chat panel.
  • Add this empty state inside that container:
            {activeModule.messages.length === 0 && (
              <div className="chat-empty">
                <strong>Ready when your lecture is indexed.</strong>
                <p>Ask for an explanation, comparison, or worked summary.</p>
              </div>
            )}

Why include an empty state?

The empty state explains what the panel is waiting for. Once messages exist, the conversation takes its place.

  • Add the persisted message list below the empty state:
            {activeModule.messages.map((message) => (
              <article className={`message ${message.role}`} key={message.id}>
                <span>{message.role === "user" ? "You" : "Study AI"}</span>
                <p>{message.content}</p>
                {message.model && <small>Model: {message.model}</small>}
                {message.sources?.length > 0 && (
                  <div className="source-list">
                    {message.sources.map((source) => (
                      <details key={`${message.id}-${source.number}`}>
                        <summary>
                          Source {source.number}: {source.filename}, page {source.page_number}
                        </summary>
                        <p>{source.text}</p>
                      </details>
                    ))}
                  </div>
                )}
              </article>
            ))}

How does the evidence appear?

User and assistant rows use their saved roles for styling. Assistant messages show the routed model and expandable source details when evidence is present.

  • Replace the existing center-panel chat form with this connected form:
          <form className="chat-form" onSubmit={askQuestion}>
            <textarea
              aria-label="Question about this module"
              disabled={activeModule.documents.length === 0 || busy}
              onChange={(event) => setQuestion(event.target.value)}
              placeholder="Ask a question grounded in this module..."
              rows="3"
              value={question}
            />
            <button
              disabled={
                activeModule.documents.length === 0 || !question.trim() || busy
              }
              type="submit"
            >
              Ask with sources
            </button>
          </form>

How does the form protect the workflow?

The form stays disabled until the room has an indexed document. It also blocks empty questions and additional submissions while a request is running.

  • Save frontend/src/App.jsx.
  • Return to the browser tab running http://localhost:5173.
  • Open the module that contains your indexed lecture PDF.

Before you ask, which filename and page do you expect the strongest source to come from?

  • Enter a question whose answer appears in the indexed lecture PDF.
  • Click Ask with sources.

The center panel should show your question followed by a grounded answer. Beneath the answer, you should see the actual routed model and numbered source cards.

  • Expand one numbered source card.
  • Confirm it shows the indexed PDF filename, a page number, and the retrieved excerpt.
  • Refresh the browser.
  • Reopen the same module.

You should see the same question, answer, model, and sources after the refresh. That persistence proves the complete request-to-retrieval-to-generation path now works.

Answer or sources missing?

If the page shows a local embedding failure, confirm Ollama is still running with the embedding model available. If generation fails, confirm the key was set in the same terminal that runs the backend.

Free routed models can have limited capacity or changing availability. Retry the request after confirming the local services and key are ready.

Help me debug my grounded chat request.

✔️ Awesome, I've got everything!

Your room now retrieves module-specific lecture excerpts and shows grounded answers with inspectable sources. Confirm both changed files are saved before moving on.

ⓧ I'd like to double check the full code

Compare your complete backend/main.py with this reference:

import json
import math
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from urllib import error, request

from fastapi import FastAPI, HTTPException, UploadFile
from pydantic import BaseModel
from pypdf import PdfReader

BASE_DIR = Path(__file__).resolve().parent
DATABASE_PATH = BASE_DIR / "study_room.db"
OLLAMA_EMBED_URL = "http://localhost:11434/api/embed"
OLLAMA_MODEL = "qwen3-embedding:0.6b"
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = "openrouter/free"

app = FastAPI(title="AI Module Workspace API")


class ModuleCreate(BaseModel):
    name: str


class ChatRequest(BaseModel):
    question: str


@contextmanager
def database():
    connection = sqlite3.connect(DATABASE_PATH)
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys = ON")
    try:
        yield connection
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        connection.close()


def now_iso():
    return datetime.now(timezone.utc).isoformat()


def init_database():
    with database() as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS modules (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL COLLATE NOCASE UNIQUE,
                created_at TEXT NOT NULL
            );

            CREATE TABLE IF NOT EXISTS documents (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                filename TEXT NOT NULL,
                page_count INTEGER NOT NULL,
                chunk_count INTEGER NOT NULL,
                created_at TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
            );

            CREATE TABLE IF NOT EXISTS chunks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                document_id INTEGER NOT NULL,
                page_number INTEGER NOT NULL,
                chunk_index INTEGER NOT NULL,
                text TEXT NOT NULL,
                embedding_json TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE,
                FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
            );

            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                module_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                content TEXT NOT NULL,
                sources_json TEXT NOT NULL DEFAULT '[]',
                model TEXT,
                created_at TEXT NOT NULL,
                FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
            );
            """
        )


def get_module_row(connection, module_id):
    module = connection.execute(
        "SELECT id, name, created_at FROM modules WHERE id = ?",
        (module_id,),
    ).fetchone()
    if module is None:
        raise HTTPException(status_code=404, detail="Module not found")
    return module


def list_module_cards(connection):
    rows = connection.execute(
        """
        SELECT
            m.id,
            m.name,
            m.created_at,
            (SELECT COUNT(*) FROM documents d WHERE d.module_id = m.id) AS document_count,
            (SELECT COUNT(*) FROM messages msg WHERE msg.module_id = m.id) AS message_count
        FROM modules m
        ORDER BY m.id DESC
        """
    ).fetchall()
    return [dict(row) for row in rows]


def module_details(connection, module_id):
    module = dict(get_module_row(connection, module_id))
    documents = connection.execute(
        """
        SELECT id, filename, page_count, chunk_count, created_at
        FROM documents
        WHERE module_id = ?
        ORDER BY id DESC
        """,
        (module_id,),
    ).fetchall()
    message_rows = connection.execute(
        """
        SELECT id, role, content, sources_json, model, created_at
        FROM messages
        WHERE module_id = ?
        ORDER BY id
        """,
        (module_id,),
    ).fetchall()

    messages = []
    for row in message_rows:
        message = dict(row)
        message["sources"] = json.loads(message.pop("sources_json") or "[]")
        messages.append(message)

    module["documents"] = [dict(row) for row in documents]
    module["messages"] = messages
    return module


def post_json(url, payload, headers=None, timeout=180):
    request_headers = {"Content-Type": "application/json"}
    if headers:
        request_headers.update(headers)

    remote_request = request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers=request_headers,
        method="POST",
    )

    try:
        with request.urlopen(remote_request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(
            f"Remote service returned HTTP {exc.code}: {body[:400]}"
        ) from exc
    except error.URLError as exc:
        raise RuntimeError(f"Could not reach remote service: {exc.reason}") from exc


def embed_texts(texts):
    response = post_json(
        OLLAMA_EMBED_URL,
        {"model": OLLAMA_MODEL, "input": texts},
    )
    embeddings = response.get("embeddings", [])
    if len(embeddings) != len(texts):
        raise RuntimeError("Ollama returned an unexpected number of embeddings")
    return embeddings


def split_page(text, chunk_size=900, overlap=150):
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        if end == len(text):
            break
        start = end - overlap
    return chunks


def cosine_similarity(left, right):
    if not left or len(left) != len(right):
        return -1.0
    dot_product = sum(a * b for a, b in zip(left, right))
    left_norm = math.sqrt(sum(value * value for value in left))
    right_norm = math.sqrt(sum(value * value for value in right))
    if left_norm == 0 or right_norm == 0:
        return -1.0
    return dot_product / (left_norm * right_norm)


init_database()


@app.get("/api/health")
def health():
    return {"status": "ok"}


@app.get("/api/modules")
def get_modules():
    with database() as connection:
        return list_module_cards(connection)


@app.post("/api/modules", status_code=201)
def create_module(payload: ModuleCreate):
    name = payload.name.strip()
    if not name:
        raise HTTPException(status_code=400, detail="Module name is required")

    try:
        with database() as connection:
            cursor = connection.execute(
                "INSERT INTO modules (name, created_at) VALUES (?, ?)",
                (name, now_iso()),
            )
            module_id = cursor.lastrowid
            module = get_module_row(connection, module_id)
            result = dict(module)
            result["document_count"] = 0
            result["message_count"] = 0
            return result
    except sqlite3.IntegrityError as exc:
        raise HTTPException(
            status_code=409,
            detail="A module with that name already exists",
        ) from exc


@app.get("/api/modules/{module_id}")
def get_module(module_id: int):
    with database() as connection:
        return module_details(connection, module_id)


@app.post("/api/modules/{module_id}/documents", status_code=201)
def upload_document(module_id: int, file: UploadFile):
    with database() as connection:
        get_module_row(connection, module_id)

    filename = file.filename or "lecture.pdf"
    if not filename.lower().endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Choose a PDF file")

    try:
        reader = PdfReader(file.file)
        page_chunks = []
        for page_number, page in enumerate(reader.pages, start=1):
            text = " ".join((page.extract_text() or "").split())
            for chunk in split_page(text):
                page_chunks.append(
                    {"page_number": page_number, "text": chunk}
                )
    except Exception as exc:
        raise HTTPException(
            status_code=400,
            detail="The PDF could not be read. Use an unencrypted text-based PDF.",
        ) from exc

    if not page_chunks:
        raise HTTPException(
            status_code=400,
            detail="No extractable text was found. Scanned PDFs need OCR, which is outside this project.",
        )

    try:
        embeddings = embed_texts([chunk["text"] for chunk in page_chunks])
    except RuntimeError as exc:
        raise HTTPException(
            status_code=502,
            detail=f"Local embedding failed: {exc}",
        ) from exc

    with database() as connection:
        cursor = connection.execute(
            """
            INSERT INTO documents
                (module_id, filename, page_count, chunk_count, created_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                module_id,
                filename,
                len(reader.pages),
                len(page_chunks),
                now_iso(),
            ),
        )
        document_id = cursor.lastrowid

        for chunk_index, (chunk, embedding) in enumerate(
            zip(page_chunks, embeddings)
        ):
            connection.execute(
                """
                INSERT INTO chunks
                    (module_id, document_id, page_number, chunk_index, text, embedding_json)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (
                    module_id,
                    document_id,
                    chunk["page_number"],
                    chunk_index,
                    chunk["text"],
                    json.dumps(embedding),
                ),
            )

        return {
            "id": document_id,
            "filename": filename,
            "page_count": len(reader.pages),
            "chunk_count": len(page_chunks),
        }


@app.post("/api/modules/{module_id}/chat")
def chat_with_module(module_id: int, payload: ChatRequest):
    question = payload.question.strip()
    if not question:
        raise HTTPException(status_code=400, detail="Enter a question")

    with database() as connection:
        get_module_row(connection, module_id)
        chunk_rows = connection.execute(
            """
            SELECT c.id, c.text, c.page_number, c.embedding_json, d.filename
            FROM chunks c
            JOIN documents d ON d.id = c.document_id
            WHERE c.module_id = ?
            """,
            (module_id,),
        ).fetchall()

    if not chunk_rows:
        raise HTTPException(
            status_code=400,
            detail="Upload and index a PDF before asking questions",
        )

    try:
        question_embedding = embed_texts([question])[0]
    except RuntimeError as exc:
        raise HTTPException(
            status_code=502,
            detail=f"Question embedding failed: {exc}",
        ) from exc

    ranked = []
    for row in chunk_rows:
        score = cosine_similarity(
            question_embedding,
            json.loads(row["embedding_json"]),
        )
        ranked.append((score, row))
    ranked.sort(key=lambda item: item[0], reverse=True)

    sources = []
    context_blocks = []
    for source_number, (score, row) in enumerate(ranked[:4], start=1):
        source = {
            "number": source_number,
            "filename": row["filename"],
            "page_number": row["page_number"],
            "text": row["text"],
            "score": round(score, 4),
        }
        sources.append(source)
        context_blocks.append(
            f"[Source {source_number}] {row['filename']}, page {row['page_number']}\n{row['text']}"
        )

    api_key = os.getenv("OPENROUTER_API_KEY")
    if not api_key:
        raise HTTPException(
            status_code=500,
            detail="OPENROUTER_API_KEY is not set in the backend terminal",
        )

    prompt = (
        "Use only the lecture excerpts below to answer the question. "
        "Cite supporting excerpts with [Source N]. If the excerpts do not contain "
        "the answer, say that the uploaded lecture material does not provide it.\n\n"
        + "\n\n".join(context_blocks)
        + f"\n\nQuestion: {question}"
    )

    try:
        response = post_json(
            OPENROUTER_URL,
            {
                "model": OPENROUTER_MODEL,
                "messages": [
                    {
                        "role": "system",
                        "content": "You are a careful study assistant that grounds every answer in supplied lecture excerpts.",
                    },
                    {"role": "user", "content": prompt},
                ],
            },
            headers={"Authorization": f"Bearer {api_key}"},
        )
        answer = response["choices"][0]["message"]["content"]
        actual_model = response.get("model", OPENROUTER_MODEL)
    except (RuntimeError, KeyError, IndexError, TypeError) as exc:
        raise HTTPException(
            status_code=502,
            detail=f"OpenRouter generation failed: {exc}",
        ) from exc

    with database() as connection:
        created_at = now_iso()
        connection.execute(
            """
            INSERT INTO messages
                (module_id, role, content, sources_json, model, created_at)
            VALUES (?, 'user', ?, '[]', NULL, ?)
            """,
            (module_id, question, created_at),
        )
        connection.execute(
            """
            INSERT INTO messages
                (module_id, role, content, sources_json, model, created_at)
            VALUES (?, 'assistant', ?, ?, ?, ?)
            """,
            (
                module_id,
                answer,
                json.dumps(sources),
                actual_model,
                created_at,
            ),
        )

    return {"answer": answer, "model": actual_model, "sources": sources}

How to use this reference

Compare structure, indentation, identifiers, and literal values. Keep the API key outside this file.

Compare your complete frontend/src/App.jsx with this reference:

import { useEffect, useState } from "react";

async function requestJson(path, options = {}) {
  const response = await fetch(path, options);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(data.detail || "The request failed");
  }
  return data;
}

export default function App() {
  const [modules, setModules] = useState([]);
  const [activeModule, setActiveModule] = useState(null);
  const [moduleName, setModuleName] = useState("");
  const [selectedFile, setSelectedFile] = useState(null);
  const [question, setQuestion] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

  async function loadModules() {
    try {
      setError("");
      setModules(await requestJson("/api/modules"));
    } catch (caughtError) {
      setError(caughtError.message);
    }
  }

  useEffect(() => {
    loadModules();
  }, []);

  async function createModule(event) {
    event.preventDefault();
    if (!moduleName.trim()) return;
    try {
      setBusy(true);
      setError("");
      await requestJson("/api/modules", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name: moduleName }),
      });
      setModuleName("");
      await loadModules();
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

  async function openModule(moduleId) {
    try {
      setBusy(true);
      setError("");
      setSelectedFile(null);
      setActiveModule(await requestJson(`/api/modules/${moduleId}`));
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

  async function refreshRoom() {
    if (!activeModule) return;
    const refreshed = await requestJson(`/api/modules/${activeModule.id}`);
    setActiveModule(refreshed);
    await loadModules();
  }

  async function uploadPdf(event) {
    event.preventDefault();
    if (!selectedFile || !activeModule) return;

    const formData = new FormData();
    formData.append("file", selectedFile);

    try {
      setBusy(true);
      setError("");
      await requestJson(`/api/modules/${activeModule.id}/documents`, {
        method: "POST",
        body: formData,
      });
      setSelectedFile(null);
      event.currentTarget.reset();
      await refreshRoom();
    } catch (caughtError) {
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

  async function askQuestion(event) {
    event.preventDefault();
    const nextQuestion = question.trim();
    if (!nextQuestion || !activeModule) return;

    try {
      setBusy(true);
      setError("");
      setQuestion("");
      await requestJson(`/api/modules/${activeModule.id}/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ question: nextQuestion }),
      });
      await refreshRoom();
    } catch (caughtError) {
      setQuestion(nextQuestion);
      setError(caughtError.message);
    } finally {
      setBusy(false);
    }
  }

  function returnToDashboard() {
    setActiveModule(null);
    setError("");
    loadModules();
  }

  if (!activeModule) {
    return (
      <main className="app-shell">
        <header className="hero">
          <div>
            <p className="eyebrow">Local knowledge. Grounded answers.</p>
            <h1>AI Module Workspace</h1>
            <p className="hero-copy">
              Organize lecture modules, index PDFs locally, and study with
              source-grounded AI.
            </p>
          </div>
          <span className="status-pill">Hybrid RAG workspace</span>
        </header>

        <section className="dashboard-toolbar">
          <div>
            <h2>Your modules</h2>
            <p>Create a dedicated knowledge boundary for every subject.</p>
          </div>
          <form className="create-form" onSubmit={createModule}>
            <input
              aria-label="Module name"
              value={moduleName}
              onChange={(event) => setModuleName(event.target.value)}
              placeholder="e.g. Software Engineering"
            />
            <button disabled={busy} type="submit">
              + Create New Module
            </button>
          </form>
        </section>

        {error && <p className="error-banner">{error}</p>}

        <section className="module-grid" aria-live="polite">
          {modules.length === 0 && (
            <div className="empty-card">
              <span className="empty-icon">+</span>
              <h3>No modules yet</h3>
              <p>Create your first module to open a focused study room.</p>
            </div>
          )}

          {modules.map((module, index) => (
            <button
              className={`module-card tone-${index % 4}`}
              key={module.id}
              onClick={() => openModule(module.id)}
              type="button"
            >
              <span className="folder-tab" />
              <span className="folder-symbol">F</span>
              <strong>{module.name}</strong>
              <span>
                {module.document_count} docs · {module.message_count} messages
              </span>
              <small>Open study room</small>
            </button>
          ))}
        </section>
      </main>
    );
  }

  return (
    <main className="room-shell">
      <header className="room-header">
        <button className="back-button" onClick={returnToDashboard} type="button">
          Back to modules
        </button>
        <div>
          <p className="eyebrow">Focused study room</p>
          <h1>{activeModule.name}</h1>
        </div>
        <span className="status-pill">{busy ? "Working..." : "Ready"}</span>
      </header>

      {error && <p className="error-banner room-error">{error}</p>}

      <section className="study-grid">
        <aside className="panel documents-panel">
          <div className="panel-heading">
            <span>01</span>
            <div>
              <h2>Lecture material</h2>
              <p>Extract and embed locally</p>
            </div>
          </div>

          <form className="upload-box" onSubmit={uploadPdf}>
            <label htmlFor="pdf-upload">Choose a text-based PDF</label>
            <input
              accept=".pdf,application/pdf"
              id="pdf-upload"
              onChange={(event) => setSelectedFile(event.target.files[0] || null)}
              type="file"
            />
            <button disabled={!selectedFile || busy} type="submit">
              Upload and index
            </button>
          </form>

          <div className="document-list">
            {activeModule.documents.length === 0 ? (
              <p className="muted">No indexed documents yet.</p>
            ) : (
              activeModule.documents.map((document) => (
                <article className="document-item" key={document.id}>
                  <span>PDF</span>
                  <div>
                    <strong>{document.filename}</strong>
                    <small>
                      {document.page_count} pages · {document.chunk_count} chunks
                    </small>
                  </div>
                </article>
              ))
            )}
          </div>
          <p className="privacy-note">
            Full PDFs and embeddings stay local. Retrieved excerpts are sent to
            OpenRouter when you ask a question.
          </p>
        </aside>

        <section className="panel chat-panel">
          <div className="panel-heading">
            <span>02</span>
            <div>
              <h2>Grounded chat</h2>
              <p>Ask the indexed lecture material</p>
            </div>
          </div>

          <div className="messages" aria-live="polite">
            {activeModule.messages.length === 0 && (
              <div className="chat-empty">
                <strong>Ready when your lecture is indexed.</strong>
                <p>Ask for an explanation, comparison, or worked summary.</p>
              </div>
            )}

            {activeModule.messages.map((message) => (
              <article className={`message ${message.role}`} key={message.id}>
                <span>{message.role === "user" ? "You" : "Study AI"}</span>
                <p>{message.content}</p>
                {message.model && <small>Model: {message.model}</small>}
                {message.sources?.length > 0 && (
                  <div className="source-list">
                    {message.sources.map((source) => (
                      <details key={`${message.id}-${source.number}`}>
                        <summary>
                          Source {source.number}: {source.filename}, page {source.page_number}
                        </summary>
                        <p>{source.text}</p>
                      </details>
                    ))}
                  </div>
                )}
              </article>
            ))}
          </div>

          <form className="chat-form" onSubmit={askQuestion}>
            <textarea
              aria-label="Question about this module"
              disabled={activeModule.documents.length === 0 || busy}
              onChange={(event) => setQuestion(event.target.value)}
              placeholder="Ask a question grounded in this module..."
              rows="3"
              value={question}
            />
            <button
              disabled={
                activeModule.documents.length === 0 || !question.trim() || busy
              }
              type="submit"
            >
              Ask with sources
            </button>
          </form>
        </section>

        <aside className="panel tools-panel">
          <div className="panel-heading">
            <span>03</span>
            <div>
              <h2>Study tools</h2>
              <p>Planned follow-on features</p>
            </div>
          </div>

          <article className="tool-card">
            <span>FC</span>
            <div>
              <strong>Flashcards</strong>
              <p>Generate recall prompts from selected lecture chunks.</p>
            </div>
            <button disabled type="button">Coming next</button>
          </article>

          <article className="tool-card">
            <span>QZ</span>
            <div>
              <strong>Quiz builder</strong>
              <p>Turn module sources into self-marking questions.</p>
            </div>
            <button disabled type="button">Coming next</button>
          </article>

          <article className="architecture-card">
            <strong>Request path</strong>
            <ol>
              <li>React sends the module question.</li>
              <li>FastAPI embeds it with Ollama.</li>
              <li>SQLite returns the closest chunks.</li>
              <li>OpenRouter generates from those sources.</li>
            </ol>
          </article>
        </aside>
      </section>
    </main>
  );
}

What should match?

Confirm the question state, submit handler, message rendering, source details, and connected form match this reference. The client must not contain the OpenRouter API key.

You built the complete hybrid retrieval path. Your workspace now turns local lecture knowledge into answers that can be checked against their source pages.

Secret mission

Prove That Modules Do Not Leak Knowledge

Create two modules with contrasting lecture PDFs. Ask adversarial questions in both directions to prove that every retrieved source stays inside the currently open module.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the workspace running, pause it to stop local electricity use, or delete its project data. The OpenRouter Free Models Router has no prompt or completion token charge.

Resources you used:

  • Backend FastAPI development server plus frontend Vite development server running in separate PowerShell terminals.
  • Project folder ai-module-workspace containing the backend, frontend, pinned dependencies, and frontend/node_modules/.
  • Local SQLite database at backend/study_room.db containing modules, extracted chunks, embeddings, messages, and source metadata.
  • OpenRouter project key loaded through OPENROUTER_API_KEY in the backend terminal session.

Keep everything running

No action needed. Choose this if you are still testing module isolation or improving the grounded chat experience.

  • Keep both PowerShell terminals open so the backend and frontend remain available.
  • Retain the ai-module-workspace folder so your modules, indexed PDFs, embeddings, and chat history stay available.
  • Keep the OpenRouter project key active while this workspace still needs hosted generation.
  • Retain the qwen3-embedding:0.6b model in Ollama for local embedding requests.

Pause - I'll come back to this later

Shut down the running processes to free local memory while keeping the project files for later. Your database and embedding model remain available.

  • Stop the backend server by pressing Ctrl+C in the backend PowerShell terminal.
  • Stop the frontend server by pressing Ctrl+C in the frontend PowerShell terminal.
  • Leave the ai-module-workspace folder in its current location.
  • Close the backend PowerShell terminal to remove OPENROUTER_API_KEY from that terminal session.

Your modules, local embeddings, indexed PDF content, and adversarial test history remain stored in backend/study_room.db.

Delete - I don't want to use this again

Remove the local workspace and revoke its hosted credential so you can start fresh. Deleting the project folder removes both modules plus their documents, embeddings, messages, dependencies, and source metadata.

  • Stop the backend server by pressing Ctrl+C in the backend PowerShell terminal.
  • Stop the frontend server by pressing Ctrl+C in the frontend PowerShell terminal.
  • Press the Windows key to open the search bar.
  • Type File Explorer and press Enter to open it.
  • Return to the location where you created ai-module-workspace.
  • Select the ai-module-workspace folder.
  • Press Delete to remove the folder.
  • Confirm that ai-module-workspace no longer appears in its previous location.

Revoking the project key disables only that credential. It cannot remove any unrelated OpenRouter keys.

  • Revoke the project key you used for this workspace from OpenRouter settings.
  • Confirm that the project key is no longer active.
  • Close the backend PowerShell terminal to clear OPENROUTER_API_KEY from the terminal session.

Cleanup complete. Both the local workspace and its hosted credential are retired.

The reusable qwen3-embedding:0.6b model remains installed for future projects.

Nice Work!

Nice Work!

Brilliant work! Your desktop-first AI Module Workspace now uses hybrid RAG to turn lecture PDFs into module-specific answers with inspectable source pages.

You've learned how to:

  • Build a persistent module dashboard using React on Vite. Folder cards survive browser refreshes through backend persistence.
  • Design REST contracts in FastAPI. Persist the complete study-room state in SQLite.
  • Implement a grounded RAG pipeline that extracts page-aware PDF text with pypdf. Create local embeddings through Ollama. Rank module-filtered chunks using cosine similarity. Generate answers through OpenRouter from only four selected excerpts. Display source pages beneath each answer. Show the actual routed model for inspection.
  • Secret Mission: prove module isolation with adversarial cross-module questions. Every displayed source remains inside the open module.

Ready to quiz yourself?