AI Exam Prep in Plain JavaScript — Step-by-Step Code Walkthrough
Nuno Andrade • GDG Lisbon • data/[area]/[content files] • Google ADK + Local Gemma 4 + React
1 / 16
Slide 01 / 16 • Project Architecture

Simple JavaScript Folder Layout & data/[area]/[content files]

Every step is a self-contained JavaScript folder (node index.js <area>), study materials live in data/[area]/[content files], and Optional Step 06 provides an IADE-inspired React student web app in step-06-web-ui/.

Workspace Tree
examprep/
├── data/                         # Pass any <area> folder name as a parameter!
│   ├── javascript/
│   │   ├── 01-event-loop-async.md
│   │   └── 02-closures-scopes.md
│   ├── ai-agents/
│   │   ├── 01-adk-fundamentals.md
│   │   └── 02-rag-and-gemma.md
│   └── cloud/
│       ├── 01-containers-and-scaling.md
│       └── 02-caching-and-cdns.md
├── src/shared/
│   ├── content-loader.js         # Loads & searches data/[area]/*.md (or customContent)
│   └── ollama-llm.js             # Minimal ADK BaseLlm adapter using native fetch()
├── step-01-setup/                # cd step-01-setup && node index.js <area>
├── step-02-rag/                  # cd step-02-rag && node index.js <area>
├── step-03-exam-gen/             # cd step-03-exam-gen && node index.js <area>
├── step-04-socratic-agent/       # cd step-04-socratic-agent && node index.js <area>
├── step-05-grading-complete/     # Grader CLI + Express API & OpenAPI/Swagger (:3000)
└── step-06-web-ui/               # Step 6 (Optional): IADE React Student Web UI (:5173)
Slide 02 / 16 • Step 0.1: Runtime Installation

Install Node.js 22+ & Ollama Local LLM Server

Node.js 22+ runs ES Modules ("type": "module") and built-in fetch() natively—no TypeScript build step or extra HTTP libraries required.

Terminal — Install & Verify Node.js + Ollama
# 1. Verify Node.js v22+ and npm
node --version   # v22.x or newer
npm --version

# 2. Install Ollama on Linux (or `brew install ollama` on macOS)
curl -fsSL https://ollama.com/install.sh | sh

# 3. Start Ollama daemon if not already running as a systemd service
ollama serve &

# 4. Verify the local Ollama HTTP API is responding on port 11434
curl http://127.0.0.1:11434/api/tags
Slide 03 / 16 • Step 0.2: Model & npm Setup

Pull Local gemma4:e2b & Install Workspace Dependencies

Pull the Gemma 4 model once for offline inference, then run npm install in the root folder to link all step folders and step-06-web-ui.

Terminal — Pull Model & Install Packages
# 1. Download local Gemma 4 model
ollama pull gemma4:e2b

# 2. Test local inference in CLI
ollama run gemma4:e2b "Say hello in 1 sentence."

# 3. Install all workspace dependencies
cd examprep
npm install
package.json (Root & Step Scripts)
// Root package.json "scripts":
{
  "dev": "concurrently -n api,ui,slides -c green,cyan,magenta \"npm run server\" \"npm run ui\" \"npm run slides\"",
  "step01": "node step-01-setup/index.js",
  "step02": "node step-02-rag/index.js",
  "step03": "node step-03-exam-gen/index.js",
  "step04": "node step-04-socratic-agent/index.js",
  "step04:web": "adk web step-04-socratic-agent/index.js --compile false --bundle false",
  "step05": "node step-05-grading-complete/index.js",
  "step06": "node step-06-web-ui/index.js",
  "server": "node step-05-grading-complete/server.js",
  "ui": "npm --prefix step-06-web-ui run dev",
  "ui:build": "npm --prefix step-06-web-ui run build",
  "slides": "node src/shared/serve-slides.js",
  "test:all": "node src/shared/test-all.js"
}
Slide 04 / 16 • Content Directory Convention

Adding Study Notes in data/[area]/[content files]

Each subfolder inside data/ represents a study area (javascript, ai-agents, cloud, or any folder you create). Markdown files are split into chunks by ## headings.

data/javascript/01-event-loop-async.md
# Event Loop, Microtasks & Async JavaScript

## Slide 1: The Single-Threaded Runtime Model
JavaScript executes code on a single main thread:
- **Call Stack:** Executes synchronous functions (LIFO).
- **Task / Macrotask Queue:** `setTimeout`, `setInterval`.
- **Microtask Queue:** `Promise.then`, `queueMicrotask`.

## Slide 2: Microtask Queue vs. Macrotask Queue
After the Call Stack empties, the Event Loop drains
**100% of the Microtask Queue** before taking the
next Macrotask:
`Sync Code -> All Microtasks -> Next Macrotask`
data/javascript/02-closures-scopes.md
# Closures, Lexical Scope & Memory

## Slide 1: Lexical Scoping & Closure Definition
A **closure** is a function bundled together with
references to its surrounding lexical environment.

## Slide 2: Practical Closure Patterns & Loop Trap
- Using `var` in a `for` loop shares ONE binding
  across all `setTimeout` callbacks.
- Using `let` creates a fresh block-scoped binding
  per loop iteration.
Slide 05 / 16 • Shared Helper 1

Loading Area Content: src/shared/content-loader.js

getAreaArg() reads the area parameter from CLI (node index.js javascript), and loadAreaContent(area, customContent) loads either data/[area]/*.md or a passed customContent string.

src/shared/content-loader.js (Part 1 — Area & File Loader)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const DATA_DIR = path.resolve(__dirname, '../../data');

export function getAreaArg(defaultArea = 'javascript') {
  const args = process.argv.slice(2).filter((a) => a !== '--demo');
  for (const arg of args) {
    if (arg.startsWith('--area=')) return arg.split('=')[1];
    if (!arg.startsWith('-')) return arg;
  }
  return process.env.AREA || defaultArea;
}

export function loadAreaContent(area = 'javascript', customContent = '') {
  const areaPath = path.join(DATA_DIR, area);
  const files = [];
  const chunks = [];

  if (customContent && customContent.trim()) {
    files.push({ file: 'custom-parameter.md', content: customContent.trim() });
    chunks.push(...chunkMarkdownContent(area, 'custom-parameter.md', customContent));
  } else {
    for (const entry of fs.readdirSync(areaPath, { withFileTypes: true })) {
      if (!entry.isFile() || !/\.(md|txt)$/i.test(entry.name)) continue;
      const raw = fs.readFileSync(path.join(areaPath, entry.name), 'utf-8');
      files.push({ file: entry.name, content: raw.trim() });
      chunks.push(...chunkMarkdownContent(area, entry.name, raw));
    }
  }
  return { area, files, chunks };
}
Slide 06 / 16 • Shared Helper 2

Chunking & Searching Area Notes: src/shared/content-loader.js

searchAreaContent({ area, query, topK, customContent }) ranks chunks by keyword matches in the section body and heading.

src/shared/content-loader.js (Part 2 — Keyword Search Engine)
export function searchAreaContent({ area = 'javascript', query = '', topK = 3, customContent = '' } = {}) {
  const { area: resolvedArea, chunks } = loadAreaContent(area, customContent);
  if (!query || !query.trim()) {
    return { area: resolvedArea, query, results: chunks.slice(0, topK) };
  }

  const terms = query
    .toLowerCase()
    .replace(/[^a-z0-9\s]/g, ' ')
    .split(/\s+/)
    .filter((t) => t.length >= 2);

  const scored = chunks.map((chunk) => {
    const haystack = `${chunk.docTitle} ${chunk.heading} ${chunk.content}`.toLowerCase();
    let score = 0;
    for (const term of terms) {
      if (haystack.includes(term)) {
        score += 1;
        if (chunk.heading.toLowerCase().includes(term)) score += 1.5;
      }
    }
    return { ...chunk, score: Number(score.toFixed(2)) };
  });

  scored.sort((a, b) => b.score - a.score);
  return { area: resolvedArea, query, results: scored.slice(0, topK) };
}
Slide 07 / 16 • Shared Helper 3

Google ADK Local Ollama Adapter: src/shared/ollama-llm.js

Extends BaseLlm from @google/adk and calls Ollama's /api/chat endpoint via native fetch(), translating ADK tool declarations and function calls automatically.

src/shared/ollama-llm.js
import { BaseLlm, InMemoryRunner } from '@google/adk';

export async function ollamaChat({ model = 'gemma4:e2b', messages, tools, format, options }) {
  const res = await fetch('http://127.0.0.1:11434/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model, messages, tools, format, options, stream: false }),
  });
  return res.json();
}

export class OllamaGemmaLlm extends BaseLlm {
  constructor({ model = 'gemma4:e2b', temperature = 0.2 } = {}) {
    super({ model });
    this.model = model;
    this.temperature = temperature;
  }

  async *generateContentAsync(llmRequest) {
    // 1. Convert llmRequest.contents & llmRequest.config.tools to Ollama format
    const response = await ollamaChat({
      model: this.model,
      messages,
      tools: tools.length > 0 ? tools : undefined,
      options: { temperature: this.temperature },
    });

    const msg = response.message || {};
    if (msg.tool_calls?.length) {
      yield {
        content: {
          role: 'model',
          parts: msg.tool_calls.map((tc) => ({
            functionCall: { name: tc.function.name, args: tc.function.arguments },
          })),
        },
      };
      return;
    }
    yield { content: { role: 'model', parts: [{ text: msg.content || '' }] } };
  }
}
Slide 08 / 16 • Step 01: Setup & Hello Gemma 4

Execute Step 01: cd step-01-setup && node index.js <area>

Verifies the Ollama connection, lists all folders inside data/, loads the chosen <area> files, and runs a first turn through Google ADK's LlmAgent.

Terminal — Run Step 01 via package.json Scripts
cd step-01-setup
npm start javascript          # or: node index.js javascript

# Or from the root folder:
npm run step01 -- javascript
step-01-setup/index.js
import 'dotenv/config';
import { LlmAgent } from '@google/adk';
import { OllamaGemmaLlm, runAgentTurn, ollamaListModels, DEFAULT_MODEL } from '../src/shared/ollama-llm.js';
import { getAreaArg, listAreas, loadAreaContent } from '../src/shared/content-loader.js';

const area = getAreaArg('javascript');
const { files } = loadAreaContent(area);
const tags = await ollamaListModels();

const agent = new LlmAgent({
  name: 'setup_check_agent',
  model: new OllamaGemmaLlm({ model: DEFAULT_MODEL, temperature: 0.2 }),
  instruction: `You are a concise study assistant for the "${area}" topic area.
The student has loaded the following files from data/${area}/: ${files.map((f) => f.file).join(', ')}.
Reply in 2 short sentences confirming you are ready to help with ${area}.`,
});

const reply = await runAgentTurn({
  agent,
  prompt: `Hello! Confirm you are online and mention one concept we can study in the "${area}" area.`,
});
console.log(reply);
Slide 09 / 16 • Step 02: Area Content RAG Tool

Execute Step 02: cd step-02-rag && node index.js <area>

Wraps searchAreaContent inside an ADK FunctionTool so Gemma 4 automatically searches data/[area]/ before answering.

Terminal — Run Step 02 via package.json Scripts
cd ../step-02-rag
npm start javascript          # or: node index.js javascript "microtask queue promise"

# Or from the root folder:
npm run step02 -- javascript
step-02-rag/index.js
import { FunctionTool, LlmAgent } from '@google/adk';
import { z } from 'zod';
import { OllamaGemmaLlm, runAgentTurn, DEFAULT_MODEL } from '../src/shared/ollama-llm.js';
import { getAreaArg, searchAreaContent } from '../src/shared/content-loader.js';

export function createAreaSearchTool(area = 'javascript', customContent = '') {
  return new FunctionTool({
    name: 'search_area_content',
    description: `Searches the study notes in data/${area}/ for relevant sections and examples.`,
    parameters: z.object({
      query: z.string().describe('Keywords or topic to search in the area markdown files'),
    }),
    execute: async ({ query }) => searchAreaContent({ area, query, topK: 3, customContent }),
  });
}

const area = getAreaArg('javascript');
const ragAgent = new LlmAgent({
  name: 'area_rag_assistant',
  model: new OllamaGemmaLlm({ model: DEFAULT_MODEL, temperature: 0.2 }),
  tools: [createAreaSearchTool(area)],
  instruction: `Always call "search_area_content" first to retrieve notes from data/${area}/ before answering. Cite the source file name.`,
});
Slide 10 / 16 • Step 03: Structured Exam Generator

Execute Step 03: cd step-03-exam-gen && node index.js <area>

Retrieves the top Markdown sections from data/[area]/ and uses Ollama's structured JSON output (format: EXAM_JSON_SCHEMA) + Zod validation to generate a 20-point question and rubric.

Terminal — Run Step 03 via package.json Scripts
cd ../step-03-exam-gen
npm start javascript          # or: node index.js javascript "Event Loop and Closures"

# Or from the root folder:
npm run step03 -- cloud "Horizontal Scaling and Cache Stampede"
step-03-exam-gen/index.js
export async function generateExamQuestion({ area = 'javascript', topic = '', customContent = '' } = {}) {
  const rag = searchAreaContent({ area, query: topic || area, topK: 4, customContent });
  const contextText = rag.results
    .map((r) => `[Source: ${r.file} - ${r.heading}]\n${r.excerpt}`)
    .join('\n\n');

  const response = await ollamaChat({
    model: DEFAULT_MODEL,
    messages: [
      {
        role: 'system',
        content: 'You are an exam designer. Return valid JSON matching the requested schema grounded strictly in the provided notes.',
      },
      {
        role: 'user',
        content: `Create a 20-point technical exam question for area "${area}"...\n\n${contextText}`,
      },
    ],
    format: EXAM_JSON_SCHEMA,
    options: { temperature: 0.2 },
  });

  const parsed = JSON.parse(response.message.content);
  parsed.area = area;
  parsed.totalPoints = 20;
  return ExamQuestionSchema.parse(parsed);
}
Slide 11 / 16 • Step 04: Socratic Study Mentor

Execute Step 04: cd step-04-socratic-agent && node index.js <area>

Creates a multi-turn Socratic Study Mentor agent equipped with createAreaSearchTool(area) that guides the student with hints and a single follow-up question instead of giving away the answer.

Terminal — Run Step 04 in CLI or Launch ADK Web UI
cd ../step-04-socratic-agent
npm start javascript          # Run CLI multi-turn demo
npm run web                   # Launch ADK DevTools Web UI on http://localhost:8000

# Or from the root folder:
npm run step04 -- javascript
npm run step04:web
step-04-socratic-agent/index.js
import { InMemoryRunner, LlmAgent } from '@google/adk';
import { OllamaGemmaLlm, runAgentTurn, DEFAULT_MODEL } from '../src/shared/ollama-llm.js';
import { loadAreaContent } from '../src/shared/content-loader.js';
import { createAreaSearchTool } from '../step-02-rag/index.js';

export function createSocraticMentorAgent({ area = 'javascript', customContent = '' } = {}) {
  const { files } = loadAreaContent(area, customContent);
  const fileList = files.map((f) => f.file).join(', ');
  const searchTool = createAreaSearchTool(area, customContent);

  return new LlmAgent({
    name: 'socratic_study_mentor',
    model: new OllamaGemmaLlm({ model: DEFAULT_MODEL, temperature: 0.3 }),
    tools: [searchTool],
    instruction: `You are a Socratic Study Mentor for the "${area}" study area (files: ${fileList}).
Rules:
1. NEVER give the complete final exam answer immediately.
2. Use the "search_area_content" tool to check the study notes in data/${area}/ when needed.
3. Give a brief, encouraging conceptual hint (2-4 sentences) and end with ONE guiding question.`,
  });
}

// Exported for `adk web` (Google ADK DevTools Web UI)
export const rootAgent = createSocraticMentorAgent({
  area: process.env.AREA || 'javascript',
});
Slide 12 / 16 • Step 05: Deterministic Rubric Grader

Execute Step 05: cd step-05-grading-complete && node index.js <area>

Evaluates a student's Markdown answer against each criterion in the exam rubric and the reference chunks in data/[area]/, returning per-criterion points, justifications, and improvement tips.

Terminal — Run Step 05 via package.json Scripts
cd ../step-05-grading-complete
npm start javascript          # or: node index.js javascript

# Or from the root folder:
npm run step05 -- javascript
step-05-grading-complete/index.js
export async function evaluateStudentAnswer({ area = 'javascript', exam, studentAnswer, customContent = '' }) {
  const rag = searchAreaContent({ area, query: exam.questionPrompt, topK: 3, customContent });
  const referenceNotes = rag.results.map((r) => `[${r.file} - ${r.heading}]\n${r.excerpt}`).join('\n\n');

  const response = await ollamaChat({
    model: DEFAULT_MODEL,
    messages: [
      { role: 'system', content: 'You are a fair, deterministic technical exam grader. Return only valid JSON.' },
      {
        role: 'user',
        content: `EXAM QUESTION:\n${exam.questionPrompt}\n\nRUBRIC:\n${JSON.stringify(exam.rubric)}\n\nREFERENCE NOTES (data/${area}/):\n${referenceNotes}\n\nSTUDENT ANSWER:\n${studentAnswer}`,
      },
    ],
    format: EVALUATION_JSON_SCHEMA,
    options: { temperature: 0.1 },
  });

  const parsed = JSON.parse(response.message.content);
  parsed.totalAwarded = (parsed.criterionScores || []).reduce((s, c) => s + Number(c.awardedPoints || 0), 0);
  parsed.totalPossible = (exam.rubric || []).reduce((s, r) => s + r.maxPoints, 0) || 20;
  parsed.percentage = Math.round((parsed.totalAwarded / parsed.totalPossible) * 100);
  return EvaluationSchema.parse(parsed);
}
Slide 13 / 16 • Step 05 Express API & OpenAPI (Swagger)

Backend API & Root OpenAPI Spec: step-05-grading-complete/server.js

Serves an interactive OpenAPI 3.0.3 (Swagger) explorer on the root route (GET / & GET /openapi.json) and exposes all 5 AI Exam Prep endpoints on http://localhost:3000.

step-05-grading-complete/server.js
// Root ("/") & "/openapi.json": Serve OpenAPI 3.0.3 (Swagger) Specification
app.get(['/openapi.json', '/swagger.json'], (req, res) => {
  res.json(buildOpenApiSpec(`${req.protocol}://${req.get('host')}`));
});
app.get('/', (req, res) => {
  const spec = buildOpenApiSpec(`${req.protocol}://${req.get('host')}`);
  if (req.query.format === 'json' || req.accepts(['html', 'json']) === 'json') return res.json(spec);
  res.type('html').send(renderSwaggerHtml(spec));
});

// 1. Status & available data/[area] folders  |  2. RAG Search over data/[area]/
app.get('/api/status', async (req, res) => { /* returns ollamaReachable, areas, files */ });
app.post('/api/rag/search', (req, res) => res.json(searchAreaContent(req.body)));

// 3. Exam Question Generator  |  4. Socratic Mentor Chat  |  5. Rubric Grader
app.post('/api/exam/generate', async (req, res) => res.json({ exam: await generateExamQuestion(req.body) }));
app.post('/api/socratic/chat', async (req, res) => { /* runs createSocraticMentorAgent({ area, customContent }) */ });
app.post('/api/grade/evaluate', async (req, res) => res.json({ evaluation: await evaluateStudentAnswer(req.body) }));
Slide 14 / 16 • Step 06 (Optional): IADE-Inspired React Student UI

Selecting Areas & Solving Questions in step-06-web-ui/src/App.jsx

Inspired by the IADE website (iade.europeia.pt), students select an area card (javascript, ai-agents, cloud), browse or generate questions via the Step 05 API, and submit answers for instant grading.

step-06-web-ui/src/App.jsx
export function App() {
  const [area, setArea] = useState(getInitialAreaFromUrl);
  const [questionDeck, setQuestionDeck] = useState(getAreaDefault(area).questionDeck);

  const handleSelectArea = (nextArea) => {
    setArea(nextArea);
    setQuestionDeck(getAreaDefault(nextArea).questionDeck);
  };

  const handleGenerateExam = async (topic) => {
    const res = await fetch('/api/exam/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ area, topic, customContent: customContent || undefined }),
    });
    const { exam } = await res.json();
    setQuestionDeck((prev) => [...prev, exam]);
  };
}
Slide 15 / 16 • Step 06 (Optional): Markdown Answer & Rubric Grading

Markdown Preview & Split Editor: step-06-web-ui/src/components/MarkdownPreview.jsx

All scenarios, question prompts, rubric criteria, Socratic chat messages, and RAG excerpts use <MarkdownPreview />, while student answers use <MarkdownBox defaultMode="split" /> with Write Answer (MD) / Split View / Preview tabs.

step-06-web-ui/src/components/MarkdownPreview.jsx
export function MarkdownBox({
  label,
  badge,
  value,
  onChange,
  placeholder = 'Write Markdown here...',
  rows = 6,
  defaultMode = 'split',
  readOnly = false,
}) {
  const [mode, setMode] = useState(defaultMode);
  const effectiveMode = readOnly ? 'preview' : mode;

  return (
    <div className="md-box-wrapper">
      <div className="md-box-toolbar">
        <span className="md-box-label">{label}</span>
        {!readOnly && (
          <div className="md-mode-tabs">
            {['edit', 'split', 'preview'].map((tab) => (
              <button key={tab} onClick={() => setMode(tab)} className={`md-mode-btn ${effectiveMode === tab ? 'active' : ''}`}>
                {tab === 'edit' ? 'Write Answer (MD)' : tab === 'split' ? 'Split View' : 'Preview'}
              </button>
            ))}
          </div>
        )}
      </div>
      {effectiveMode === 'preview' && <MarkdownPreview content={value} emptyPlaceholder={placeholder} />}
      {effectiveMode === 'edit' && <textarea rows={rows} value={value} onChange={(e) => onChange(e.target.value)} />}
    </div>
  );
}
Slide 16 / 16 • Complete Execution Cheat Sheet

Running Every Step & Launching Step 06 (IADE React UI)

Enter any folder to run it standalone with node index.js <area>, or run npm run step06 to launch the Step 05 API + Step 06 IADE React Student Portal.

Run Individual Steps (Step or Root package.json)
# Inside each step folder:
cd step-01-setup && npm start javascript
cd ../step-02-rag && npm start javascript
cd ../step-03-exam-gen && npm start javascript
cd ../step-04-socratic-agent && npm start javascript
cd ../step-04-socratic-agent && npm run web    # Launch ADK Web UI (:8000)
cd ../step-05-grading-complete && npm start javascript
cd ../step-06-web-ui && npm start javascript   # Step 06: IADE React UI (:5173)

# Or from root package.json:
npm run step01 -- javascript
npm run step02 -- ai-agents
npm run step03 -- cloud
npm run step04 -- javascript
npm run step04:web                             # Launch ADK Web UI (:8000)
npm run step05 -- javascript
npm run step06 -- javascript                   # Starts Step 05 API + Step 06 UI
Run Web UI + API Server + Slides (concurrently)
# Run API Server + React UI + Slides concurrently:
npm run dev

# Which executes via concurrently:
# - npm run server  (Step 05 API + OpenAPI UI on http://localhost:3000)
# - npm run ui      (Step 06 IADE React UI on http://localhost:5173)
# - npm run slides  (Slide Deck on http://localhost:3030)

# Open Step 06 UI with any data/[area] parameter:
# http://localhost:5173/?area=javascript
# http://localhost:5173/?area=ai-agents
# http://localhost:5173/?area=cloud