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/.
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)
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.
# 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
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.
# 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
// 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"
}
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.
# 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`
# 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.
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.
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 };
}
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.
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) };
}
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.
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 || '' }] } };
}
}
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.
cd step-01-setup
npm start javascript # or: node index.js javascript
# Or from the root folder:
npm run step01 -- javascript
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);
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.
cd ../step-02-rag
npm start javascript # or: node index.js javascript "microtask queue promise"
# Or from the root folder:
npm run step02 -- javascript
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.`,
});
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.
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"
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);
}
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.
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
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',
});
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.
cd ../step-05-grading-complete
npm start javascript # or: node index.js javascript
# Or from the root folder:
npm run step05 -- javascript
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);
}
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.
// 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) }));
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.
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]);
};
}
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.
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>
);
}
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.
# 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 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