fix: update lessons
This commit is contained in:
BIN
Les12-Tool-Calling/Les12-Slides.key
Executable file
BIN
Les12-Tool-Calling/Les12-Slides.key
Executable file
Binary file not shown.
1
Les13-Agents/.~lock.Les13-Slides.pdf#
Normal file
1
Les13-Agents/.~lock.Les13-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 07:13,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
785
Les13-Agents/Les13-Docenttekst.md
Normal file
785
Les13-Agents/Les13-Docenttekst.md
Normal file
@@ -0,0 +1,785 @@
|
||||
# Les 13 — Agents
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 13 van 18
|
||||
**Onderwerp:** Agents — LLM in een loop met tools
|
||||
**Duur:** 180 minuten
|
||||
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Nieuwe research-agent (from scratch, los van Polderfest)
|
||||
|
||||
---
|
||||
|
||||
## Hoe deze tekst werkt
|
||||
|
||||
Dit document is een **lopend script**. Lees mee tijdens de les op je laptop.
|
||||
|
||||
- `[SLIDE X]` — Klik naar slide X
|
||||
- `[SCHERM: slides | terminal | editor | browser | supabase]` — Welk scherm op de beamer
|
||||
- **Vertel:** "..." — Letterlijk wat je zegt (mag in eigen woorden)
|
||||
- `*[stage direction]*` — Korte instructie voor jezelf, niet uitspreken
|
||||
- Code blocks = wat je typt
|
||||
- 💬 = verwachte studentenvraag
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES — Setup (45 min)
|
||||
|
||||
### 1. Tavily account
|
||||
|
||||
- Maak account op https://tavily.com/
|
||||
- Kopieer API key (begint met `tvly-...`)
|
||||
- Gratis tier = 1000 calls/maand, ruim genoeg voor deze les
|
||||
|
||||
### 2. Nieuwe lege Next.js demo-folder
|
||||
|
||||
`*[Doe dit thuis vooraf, niet in de klas. Klas kijkt naar de setup-stap als je 'm doet.]*`
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les13-Agents
|
||||
pnpm create next-app@latest research-agent \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd research-agent
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||||
```
|
||||
|
||||
### 3. Environment vars
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=...
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
```
|
||||
|
||||
### 4. Supabase tabel
|
||||
|
||||
```sql
|
||||
create table research_reports (
|
||||
id bigserial primary key,
|
||||
query text not null,
|
||||
summary text not null,
|
||||
sources jsonb default '[]'::jsonb,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
alter table research_reports enable row level security;
|
||||
create policy "demo open" on research_reports
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
### 5. Backup eindstaat
|
||||
|
||||
- Werkende `lib/tools.ts`, `lib/agent.ts`, `app/api/research/route.ts` ergens als backup
|
||||
- Verwacht: 1-2 typos tijdens live coding — geen ramp
|
||||
|
||||
### 6. Browser tabs
|
||||
|
||||
- `localhost:3000` (dev server, maar gaan we voornamelijk via terminal testen)
|
||||
- Supabase dashboard
|
||||
- https://ai-sdk.dev/docs/agents/overview (referentie)
|
||||
- https://tavily.com/ (dashboard voor usage)
|
||||
|
||||
---
|
||||
|
||||
# HET SCRIPT — Lees mee tijdens de les
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik (10 min)
|
||||
|
||||
`[SLIDE 1 — Title]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Welkom bij les 13. Vandaag stappen we van tool-calling naar agents. Eén stap verder qua autonomie van AI."
|
||||
|
||||
`[SLIDE 2 — Terugblik]`
|
||||
|
||||
**Vertel:** "Vorige les bouwden we Tool Calling in onze Polderfest-app. Zes tools. `stopWhen: stepCountIs(5)`. AI roept zelf functies aan om aan data te komen. Goed werk.
|
||||
|
||||
Maar er waren dingen die een tool-call bot **nog niet kon**. Lange ketens — 30, 40, 50 stappen — daar is `stepCountIs(5)` te krap voor. Een eigen plan maken en bijstellen. Tools die andere tools triggeren. Per stap een ander model kiezen.
|
||||
|
||||
Daarvoor heb je een **agent** nodig. Een LLM die tools gebruikt in een loop, en zelf bepaalt hoe lang die loop duurt en welke kant het op gaat."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Vandaag drie uur. Eerst een half uur theorie — wat is een agent precies, hoe ziet de loop eruit, wat is een `ToolLoopAgent`. Daarna vier live demos waarin we from scratch een research-agent bouwen. Geen polderfest vandaag — losse repo, omdat agents een nieuwe context verdienen.
|
||||
|
||||
Aan het eind: wanneer wel/niet een agent, en wat doe je voor huiswerk."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Theorie (30 min)
|
||||
|
||||
`[SLIDE 4 — Wat is een agent]`
|
||||
|
||||
**Vertel:** "De definitie uit de AI SDK docs is kort en bruikbaar:
|
||||
|
||||
> *Agents are LLMs that use tools in a loop to accomplish tasks.*
|
||||
|
||||
Drie ingrediënten. Eén — een LLM die besluit wat de volgende actie is. Twee — tools die het LLM uitbreiden. Drie — een loop die deze twee orchestreert.
|
||||
|
||||
In Les 12 hadden we deze drie ook al — `stopWhen: stepCountIs(5)` is in feite een mini-agent. Het verschil zit in **schaal**. Vandaag praten we over 20-50 stappen, en het model bepaalt zelf welke tools het wanneer gebruikt."
|
||||
|
||||
💬 *Mogelijke vraag: "Wat is dan het verschil met Les 12 echt?"*
|
||||
|
||||
**Antwoord:** "Schaal en autonomie. Les 12 was: vraag → 2-5 tool-calls → antwoord. Vandaag: vraag → agent maakt plan → voert plan uit (10-50 stappen) → schrijft rapport. Het model beslist veel meer zelf."
|
||||
|
||||
`[SLIDE 5 — Anatomie van de loop]`
|
||||
|
||||
**Vertel:** "Wat gebeurt er per loop-iteratie, één stap? Vijf dingen.
|
||||
|
||||
Eén: we sturen de huidige messages naar het LLM. Twee: LLM antwoordt — óf met text, óf met een tool-call. Als het text is, stopt de loop. Dat is een natural finish — model heeft niks meer te doen. Als het een tool-call is, gaan we door. Drie: we voeren de tool execute uit. Vier: we voegen het resultaat toe aan de berichten. Vijf: we checken stop-conditie. Niet voldaan? Volgende iteratie.
|
||||
|
||||
De loop stopt in vier gevallen: text-only antwoord, stopWhen geactiveerd, tool zonder execute aangeroepen — dat is het 'done-pattern', komen we straks op terug, en tool die approval nodig heeft. Die laatste skippen we vandaag.
|
||||
|
||||
Default in v6: `stepCountIs(20)`. Dat is een veiligheidsgrens — voorkomt dat een rare bug in je systeem prompt resulteert in 200 API-calls en een rekening van 30 euro."
|
||||
|
||||
`[SLIDE 6 — ToolLoopAgent]`
|
||||
|
||||
**Vertel:** "In oudere AI SDK versies moest je de loop zelf schrijven — een while-loop rond `streamText` en `generateText`. In v6 is er een dedicated class: `ToolLoopAgent`.
|
||||
|
||||
`*[Wijs naar code op slide]*`
|
||||
|
||||
Je geeft model, system prompt, tools, en een stop-conditie. Eén keer definiëren. Dan `agent.generate({ prompt })` aanroepen, of `agent.stream` voor streaming.
|
||||
|
||||
Wat je terugkrijgt: `result.text` is het eindantwoord, `result.steps` is een array met alle stappen die de agent zette — welke tool aangeroepen, met welke input, welk resultaat. Dat is je debug-goud."
|
||||
|
||||
💬 *Vraag: "Kan dat nog steeds met streamText?"*
|
||||
|
||||
**Antwoord:** "Ja, voor backwards compatibility. Maar voor nieuwe code is `ToolLoopAgent` de aanrader. Minder boilerplate, herbruikbaar."
|
||||
|
||||
`[SLIDE 7 — Stop-condities]`
|
||||
|
||||
**Vertel:** "Vier soorten stop-condities.
|
||||
|
||||
Eén: `stepCountIs(N)` — stop na N stappen. Standaard veiligheidsmaatregel.
|
||||
|
||||
Twee: `hasToolCall('saveReport')` — stop zodra een specifieke tool is aangeroepen. Logisch voor 'doe het werk en sla op, dan klaar'.
|
||||
|
||||
Drie: `isLoopFinished()` — onbeperkt. Agent stopt alleen natuurlijk wanneer model klaar is. Dit is gevaarlijk zonder andere safeguards — agent kan in theorie oneindig doorgaan. Alleen gebruiken met cost limit erbij.
|
||||
|
||||
Vier: combineer ze in een array. Loop stopt zodra één conditie waar is.
|
||||
|
||||
En vijf — niet built-in maar zelf te schrijven: `StopCondition` callback. Daarmee kun je stoppen op kosten, op aantal tools, op wat dan ook dat je uit de step-historie kunt halen."
|
||||
|
||||
`[SLIDE 8 — prepareStep]`
|
||||
|
||||
**Vertel:** "`prepareStep` is misschien de krachtigste feature van `ToolLoopAgent`. Het is een async callback die VOOR elke stap draait. Je kunt het model dynamisch wisselen, tools beperken, messages trimmen.
|
||||
|
||||
Voorbeeld: begin met `gpt-4o-mini` — snel en goedkoop. Maar als de stap-historie complex wordt, switch naar Claude Sonnet voor beter reasoning. Dat scheelt geld zonder kwaliteit te verliezen.
|
||||
|
||||
Of: dwing een research-fase af. Stap 0-3 mag alleen `webSearch`. Stap 4-6 alleen `readPage`. Stap 7+ alleen `saveReport`. Agent kan niet 'cheaten' door te vroeg op te slaan.
|
||||
|
||||
Of: trim oude tool-results uit de berichten. Een lange agent kan 30k tokens aan tool-output verzamelen. Niet meer relevant voor de huidige stap — eruit, scheelt budget."
|
||||
|
||||
💬 *Vraag: "Hoe weet ik wanneer ik prepareStep nodig heb?"*
|
||||
|
||||
**Antwoord:** "Niet bij elke agent. Begin zonder. Als je merkt dat je agent veel geld kost, of te vaak in een verkeerde fase blijft hangen, dan kom je terug om `prepareStep` toe te voegen. Premature optimization is ook in agents reëel."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Live Demo 1: Setup research-agent (25 min)
|
||||
|
||||
`[SLIDE 9 — Wat we bouwen]`
|
||||
|
||||
**Vertel:** "Genoeg theorie. We gaan een **research-agent** bouwen. Doel: gebruiker stelt een vraag — bijvoorbeeld 'wat zijn AI-trends in 2026?' — en de agent zoekt zelfstandig op het web, leest de relevante pagina's, schrijft een rapport, slaat op in Supabase.
|
||||
|
||||
Vier tools: `webSearch` met Tavily, `readPage` om URLs te lezen, `saveReport` om in DB te zetten, en eventueel een `done`-tool.
|
||||
|
||||
Stack: Next.js 16, AI SDK v6, Tavily, Supabase, gpt-4o-mini. Losse repo — niet de Polderfest-demo. Agents verdienen een schone context."
|
||||
|
||||
`[SLIDE 10 — LIVE DEMO 1]` `[SCHERM: terminal + editor]`
|
||||
|
||||
**Vertel:** "Klas, leun achterover. Vragen tussendoor mogen. Ik laat zien hoe je een agent from-scratch bouwt."
|
||||
|
||||
`*[Open terminal in /Les13-Agents]*`
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest research-agent \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd research-agent
|
||||
```
|
||||
|
||||
`*[Wacht op next-app, ondertussen praat door over Tavily]*`
|
||||
|
||||
**Vertel:** "Voor web search heb je een API nodig. OpenAI heeft sinds kort een eigen web-search tool, maar die werkt alleen met gpt-4o en is duur. Voor onafhankelijke search gebruiken we Tavily — gemaakt voor AI agents, gratis tier van 1000 calls per maand. Gewoon registreren met email, API key gepakt, klaar.
|
||||
|
||||
Andere opties: Brave Search API, Serper, Google Custom Search. Tavily is het simpelst voor onze case omdat het naast search ook content-extractie biedt."
|
||||
|
||||
```bash
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||||
```
|
||||
|
||||
`*[Open editor]*`
|
||||
|
||||
**Vertel:** "Eerst environment vars."
|
||||
|
||||
`*[Maak .env.local]*`
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=...
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
```
|
||||
|
||||
**Vertel:** "Belangrijk: `OPENAI_API_KEY` is GEEN `NEXT_PUBLIC_*`. Anders zit-ie in je client-bundle en kan iedereen 'm zien in DevTools. Server-only. Hetzelfde voor `TAVILY_API_KEY`."
|
||||
|
||||
`*[Check .gitignore — moet .env.local in staan]*`
|
||||
|
||||
**Vertel:** "Check je `.gitignore`. `.env.local` moet erin. Standaard create-next-app doet dit goed maar — altijd checken."
|
||||
|
||||
`*[Supabase Dashboard openen]*` `[SCHERM: supabase]`
|
||||
|
||||
```sql
|
||||
create table research_reports (
|
||||
id bigserial primary key,
|
||||
query text not null,
|
||||
summary text not null,
|
||||
sources jsonb default '[]'::jsonb,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
```
|
||||
|
||||
**Vertel:** "Tabel voor onze rapporten. `query` is de vraag, `summary` is het rapport zelf, `sources` is een JSON array van URLs die werden gebruikt."
|
||||
|
||||
```sql
|
||||
alter table research_reports enable row level security;
|
||||
create policy "demo open" on research_reports
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
**Vertel:** "RLS aan, en een open policy voor demo. Productie: koppelen aan auth.uid()."
|
||||
|
||||
`*[Terug naar editor]*` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nu de tools. Ik maak een `lib/tools.ts` met onze drie tools."
|
||||
|
||||
`*[Maak lib/tools.ts]*`
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
export const webSearch = tool({
|
||||
description: "Zoek op het web. Returnt top 5 resultaten met url + snippet.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Zoekterm"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const res = await fetch("https://api.tavily.com/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
query,
|
||||
max_results: 5,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.results?.map((r: any) => ({
|
||||
url: r.url,
|
||||
title: r.title,
|
||||
snippet: r.content,
|
||||
})) ?? [];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "Beschrijving van `webSearch` is kort en specifiek. AI leest description om te beslissen welke tool nodig is — vaag = verkeerde keuze. `max_results: 5` houdt het beheersbaar — Tavily kan ook 10 of 20, maar 5 is goed startpunt."
|
||||
|
||||
`*[readPage tool toevoegen]*`
|
||||
|
||||
```typescript
|
||||
export const readPage = tool({
|
||||
description: "Haal de volledige tekst van een URL op.",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const res = await fetch("https://api.tavily.com/extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
urls: [url],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const content = data.results?.[0]?.raw_content ?? "";
|
||||
return { url, content: content.slice(0, 5000) };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "`readPage` haalt de fulltext van een URL — Tavily heeft een extract-endpoint dat ruis verwijdert. Ik knip op 5000 chars om token-budget niet te exploderen. Voor productie: chunken + samenvatten."
|
||||
|
||||
`*[saveReport tool]*`
|
||||
|
||||
```typescript
|
||||
export const saveReport = tool({
|
||||
description:
|
||||
"Sla een onderzoeksrapport op in de database. " +
|
||||
"Alleen aanroepen als het rapport compleet is.",
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
summary: z.string().min(100),
|
||||
sources: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string(),
|
||||
})),
|
||||
}),
|
||||
execute: async ({ query, summary, sources }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("research_reports")
|
||||
.insert({ query, summary, sources })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { error: error.message };
|
||||
return { reportId: data.id, success: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "`saveReport` is onze enige write-tool. Description benadrukt: alleen aanroepen als compleet. `summary.min(100)` — zod schema dwingt minimum lengte af, AI mag geen 'TODO' insturen."
|
||||
|
||||
`*[lib/agent.ts maken]*`
|
||||
|
||||
**Vertel:** "Nu de agent zelf."
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { webSearch, readPage, saveReport } from "./tools";
|
||||
|
||||
export const researchAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je bent een research-assistent. Werk als volgt:
|
||||
1. Begin met een kort plan (3-5 bullets).
|
||||
2. Gebruik webSearch voor oriëntatie.
|
||||
3. Gebruik readPage voor de 2-3 meest relevante URLs.
|
||||
4. Schrijf een samenvatting van min 200 woorden.
|
||||
5. Sla op met saveReport. Inclusief alle gebruikte sources.
|
||||
|
||||
Verzin niets — gebruik alleen wat je via tools hebt opgehaald.`,
|
||||
tools: { webSearch, readPage, saveReport },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "System prompt is **het** kritieke onderdeel. Ik geef expliciete fasen — plan, search, read, samenvat, save. Hoe duidelijker, hoe minder de agent drifft. 'Verzin niets' is belangrijk — anders maakt AI bronnen op."
|
||||
|
||||
`*[app/api/research/route.ts]*`
|
||||
|
||||
```typescript
|
||||
import { researchAgent } from "@/lib/agent";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { query } = await req.json();
|
||||
const result = await researchAgent.generate({ prompt: query });
|
||||
|
||||
return Response.json({
|
||||
text: result.text,
|
||||
steps: result.steps.map((s, i) => ({
|
||||
step: i,
|
||||
toolCalls: s.toolCalls?.map((tc) => ({
|
||||
tool: tc.toolName,
|
||||
input: tc.input,
|
||||
})),
|
||||
hasText: !!s.text,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Endpoint stuurt query naar de agent, returnt text + step-samenvatting. Voor productie zou je dit streamen, voor demo houden we het simpel — <20><>én POST, JSON response."
|
||||
|
||||
`*[Run]*` `[SCHERM: terminal]`
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`*[In tweede terminal:]*`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/research \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Wat zijn de belangrijkste AI-trends in 2026?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
**Vertel:** "We zien... `*[wachten 20-40s]*` ...de agent draait. JSON komt terug. Kijk — `steps` array. webSearch, dan readPage, readPage, saveReport. Vier stappen. `text` veld is een samenvatting."
|
||||
|
||||
`*[Open Supabase, check tabel]*` `[SCHERM: supabase]`
|
||||
|
||||
**Vertel:** "Rapport staat in Supabase. Werkend agent. From scratch in 20 minuten."
|
||||
|
||||
💬 *Vraag: "Hoeveel tokens kost zo'n call?"*
|
||||
|
||||
**Antwoord:** "Goeie vraag. `result.usage` heeft `inputTokens` en `outputTokens`. Voor zo'n research van 4 stappen: ongeveer 8.000 input + 1.500 output tokens. Op gpt-4o-mini: ~0.001 dollar. Bijna gratis. Op gpt-4o: ~0.05 dollar. Houdbaar."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Live Demo 2: stopWhen variants (25 min)
|
||||
|
||||
`[SLIDE 11 — DEMO 2]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "We hebben nu een agent met `stepCountIs(10)`. Laten we de andere stop-condities uitproberen en zien wat het verschil maakt."
|
||||
|
||||
`*[Wijzig stopWhen]*`
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs, hasToolCall } from "ai";
|
||||
|
||||
// ...
|
||||
stopWhen: hasToolCall("saveReport"),
|
||||
```
|
||||
|
||||
**Vertel:** "Nu stopt agent **zodra** saveReport is aangeroepen. Geen veiligheidscap. Laten we draaien."
|
||||
|
||||
`*[Curl draaien]*`
|
||||
|
||||
**Vertel:** "Werkt. Agent doet zijn werk, roept saveReport, stopt. Voordeel: geen onnodige extra stappen. Nadeel: als saveReport faalt en agent retried, kan-ie alsnog lang doorgaan."
|
||||
|
||||
`*[Beter: combineren]*`
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(20),
|
||||
hasToolCall("saveReport"),
|
||||
],
|
||||
```
|
||||
|
||||
**Vertel:** "Best practice: array. Veiligheidscap én task-completion. Loop stopt op de eerste van de twee."
|
||||
|
||||
`*[Probeer isLoopFinished]*`
|
||||
|
||||
```typescript
|
||||
import { isLoopFinished } from "ai";
|
||||
// ...
|
||||
stopWhen: isLoopFinished(),
|
||||
```
|
||||
|
||||
**Vertel:** "`isLoopFinished` is letterlijk: geen limit. Alleen natuurlijke finish stopt het. Dit is **gevaarlijk** zonder andere safeguards. Een misverstand in je system prompt en agent draait 50 stappen door."
|
||||
|
||||
`*[Curl draaien — agent doet nu 8-10 stappen]*`
|
||||
|
||||
**Vertel:** "Zie je? Agent gaat door tot het zelf besluit te stoppen. Eindigt met text — geen tool-call. Werkt, maar — wat als prompt slecht is, of als API een rare fout gooit en de agent retried?"
|
||||
|
||||
`*[Custom StopCondition]*`
|
||||
|
||||
**Vertel:** "Eigen stop. Bijvoorbeeld stoppen als we genoeg pagina's hebben gelezen."
|
||||
|
||||
```typescript
|
||||
import type { StopCondition } from "ai";
|
||||
import type { researchAgent } from "./agent";
|
||||
|
||||
type Tools = typeof researchAgent extends ToolLoopAgent<infer T> ? T : never;
|
||||
|
||||
const enoughReading: StopCondition<any> = ({ steps }) => {
|
||||
const readCount = steps.flatMap((s) =>
|
||||
s.toolCalls?.filter((tc) => tc.toolName === "readPage") ?? []
|
||||
).length;
|
||||
return readCount >= 3;
|
||||
};
|
||||
|
||||
stopWhen: [
|
||||
stepCountIs(20),
|
||||
enoughReading,
|
||||
],
|
||||
```
|
||||
|
||||
**Vertel:** "Drie pagina's lezen is genoeg, stoppen. Custom logic die uit step-historie wordt gehaald."
|
||||
|
||||
`*[Demo run]*`
|
||||
|
||||
**Vertel:** "Zie: agent stopt op stap waar readPage voor de derde keer wordt aangeroepen. Conditie werkt. Voor productie gebruik je dit vaak voor kostenlimieten — daar komen we morgen op terug, eh, in het huiswerk."
|
||||
|
||||
💬 *Vraag: "Waarom `: StopCondition<any>` en niet typed?"*
|
||||
|
||||
**Antwoord:** "Excuus, dat is een shortcut. In productie zou je een `ToolSet` type extracten zodat je `StopCondition<typeof tools>` krijgt — dan kun je tool-names typesafe checken. Voor demo houd ik 't simpel."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 12 — Pauze]`
|
||||
|
||||
**Vertel:** "Pauze. Vijftien minuten. Loop een ronde."
|
||||
|
||||
`*[Reset agent.ts naar stepCountIs(15) + hasToolCall("saveReport") combo voor volgende demo]*`
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Live Demo 3: prepareStep (25 min)
|
||||
|
||||
`[SLIDE 13 — DEMO 3]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nu het krachtigste stuk: `prepareStep`. Voor elke stap kunnen we model, tools, messages aanpassen. Drie use cases vandaag."
|
||||
|
||||
### Use case 1 — Dynamic model
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
console.log(`Stap ${stepNumber}, ${messages.length} messages`);
|
||||
if (stepNumber > 3) {
|
||||
console.log("→ switch naar gpt-4o");
|
||||
return { model: openai("gpt-4o") };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
```
|
||||
|
||||
**Vertel:** "Stap 0-3 doen we met gpt-4o-mini — goedkoop en snel. Vanaf stap 4 switchen we naar de grote gpt-4o. Reden: vroege stappen zijn 'zoek even', late stappen zijn 'vat samen en redeneer'. Dat laatste verdient een sterker model."
|
||||
|
||||
`*[Curl draaien — kijk in console naar logs]*`
|
||||
|
||||
**Vertel:** "Zien jullie: 'Stap 0 — mini'. 'Stap 1 — mini'. 'Stap 4 — switch naar gpt-4o'. Dynamic. Effect: lagere kosten, betere eindkwaliteit."
|
||||
|
||||
### Use case 2 — Fase-gebaseerde tools
|
||||
|
||||
`*[Pas aan]*`
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 2) {
|
||||
return { activeTools: ["webSearch"] };
|
||||
}
|
||||
if (stepNumber <= 5) {
|
||||
return { activeTools: ["readPage"] };
|
||||
}
|
||||
return {
|
||||
activeTools: ["saveReport"],
|
||||
toolChoice: "required",
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
**Vertel:** "Nu dwingen we een fase af. Stap 0-2 mag alleen searchen. Stap 3-5 alleen lezen. Stap 6+ alleen opslaan, en moet die ook gebruiken want `toolChoice: required`."
|
||||
|
||||
`*[Curl draaien]*`
|
||||
|
||||
**Vertel:** "Agent kan niet 'cheaten'. Geen `saveReport` op stap 2 met een vage samenvatting. Eerst werken, dan opslaan."
|
||||
|
||||
💬 *Vraag: "Is dat niet een beetje vechten tegen het model?"*
|
||||
|
||||
**Antwoord:** "Het is een trade-off. Meer controle = minder flexibel. Voor research is een gefaseerde aanpak vaak prima. Voor open-ended problem solving — bijv. een coding agent — zou ik 't niet doen. Daar moet het model vrij kunnen kiezen."
|
||||
|
||||
### Use case 3 — Context-trimming
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ messages, stepNumber }) => {
|
||||
if (messages.length > 12) {
|
||||
return {
|
||||
messages: [
|
||||
messages[0], // system
|
||||
...messages.slice(-8) // laatste 8
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
```
|
||||
|
||||
**Vertel:** "Bij lange agents groeit `messages` snel — 30+ items, elk met tool-results van duizenden tokens. Hier zeggen we: na 12 messages, gooi de oudste weg, hou alleen system + laatste 8.
|
||||
|
||||
Voor research specifiek niet zo nodig — meestal eindigt agent na 8-10 stappen. Maar voor coding agents of debugging-agents die 30+ stappen doen — essentieel."
|
||||
|
||||
`*[Test]*`
|
||||
|
||||
**Vertel:** "Voor onze research-agent niet zichtbaar effect. Maar nu je het weet: bij langere agents — gebruik je dit."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Live Demo 4: Done-tool + custom stop (20 min)
|
||||
|
||||
`[SLIDE 14 — DEMO 4]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nog twee patronen. Eerst de **done-tool**. Een tool **zonder execute**."
|
||||
|
||||
```typescript
|
||||
export const done = tool({
|
||||
description:
|
||||
"Roep aan wanneer onderzoek klaar is en rapport is opgeslagen. " +
|
||||
"Geef het reportId en een korte samenvatting.",
|
||||
inputSchema: z.object({
|
||||
reportId: z.number(),
|
||||
summary: z.string(),
|
||||
}),
|
||||
// GEEN execute — dat stopt de loop
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "Geen execute betekent: als agent deze tool aanroept, stopt de loop automatisch. Geen tool-result toegevoegd, geen volgende stap.
|
||||
|
||||
Combineren met `toolChoice: 'required'` — dwingen dat agent altijd via een tool stopt, nooit met losse text."
|
||||
|
||||
```typescript
|
||||
export const researchAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `... [zelfde] ...
|
||||
6. Wanneer alles opgeslagen is, roep dan 'done' aan met het reportId.`,
|
||||
tools: { webSearch, readPage, saveReport, done },
|
||||
toolChoice: "required",
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
```
|
||||
|
||||
`*[Run]*`
|
||||
|
||||
**Vertel:** "Agent doet zijn werk, eindigt met `done({ reportId: 42, summary: '...' })`. We pakken het eindresultaat uit `result.staticToolCalls`:"
|
||||
|
||||
```typescript
|
||||
const result = await researchAgent.generate({ prompt: query });
|
||||
const doneCall = result.staticToolCalls[0];
|
||||
if (doneCall?.toolName === "done") {
|
||||
console.log("Final:", doneCall.input.summary);
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Voordeel: gestructureerd eindantwoord. Geen vage text. Voor pipelines waar je het resultaat ergens anders moet injecten — heel handig."
|
||||
|
||||
### Custom stop op kosten
|
||||
|
||||
`*[Voeg toe]*`
|
||||
|
||||
```typescript
|
||||
const costLimit: StopCondition<any> = ({ steps }) => {
|
||||
const tokens = steps.reduce(
|
||||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||||
0
|
||||
);
|
||||
const usd = (tokens * 0.00015) / 1000; // gpt-4o-mini rate
|
||||
console.log(`Total tokens: ${tokens}, ~$${usd.toFixed(4)}`);
|
||||
return usd > 0.05;
|
||||
};
|
||||
|
||||
stopWhen: [stepCountIs(30), costLimit],
|
||||
```
|
||||
|
||||
**Vertel:** "Stop als kosten boven 5 dollarcent uitkomen. Voor productie: kritiek. Eén bug in je prompt en je hebt 100 calls — zonder cost cap is dat 20 euro. Mét cap: maximaal 5 cent per run, klaar."
|
||||
|
||||
### Plan-Act-Reflect
|
||||
|
||||
`[SLIDE — terug naar slide 14 visual]`
|
||||
|
||||
**Vertel:** "Laatste pattern — alleen kort toelichten. ReAct: Reason + Act. Idee: in de system prompt vraag je expliciet om eerst een plan, dan acties, dan reflectie.
|
||||
|
||||
System prompt:
|
||||
```
|
||||
1. Begin met een plan in 3-5 bullets.
|
||||
2. Voer plan uit met tools.
|
||||
3. Eindig met korte reflectie — wat zou je anders doen?
|
||||
```
|
||||
|
||||
Geen extra code nodig — het model doet dit vanzelf binnen de loop. Effect: tracebaarder gedrag, en zelf-correctie in latere stappen.
|
||||
|
||||
Voor sub-agents — een agent die een andere agent als tool gebruikt — schuiven we naar Les 14, want we hebben tijdsdruk."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — Wanneer wel, wanneer niet (10 min)
|
||||
|
||||
`[SLIDE 15 — Agent vs tool-call vs workflow]`
|
||||
|
||||
**Vertel:** "Drie niveaus van AI-autonomie. Plain tool-call: 1 tool, 1 antwoord. Multi-step uit Les 12: 2-5 tools, 1 doel. Agent uit Les 13: 10-50 stappen, open-ended. En het hoogste niveau van **controle** — een explicit workflow. Code die exact bepaalt welke tool wanneer.
|
||||
|
||||
`*[Wijs tabel op slide]*`
|
||||
|
||||
Wanneer GEEN agent. Determinisme nodig — finance, juridisch, medische data. Geen agent. Doe het in code. Latency belangrijk — agent kost al snel 30 seconden. Voor user-facing chat: te traag. Voorspelbare kosten — agent met 50 stappen kan duur zijn.
|
||||
|
||||
Wanneer WEL agent. Open-ended — research, planning, debugging. Volgorde onbekend — model moet zelf bepalen. Async gebruik — backend job, niet realtime UI.
|
||||
|
||||
Quote uit de docs: *Agents are flexible and powerful, but non-deterministic.*"
|
||||
|
||||
💬 *Vraag: "Wat is een workflow dan precies in code?"*
|
||||
|
||||
**Antwoord:** "Gewone JavaScript. If-statements, switches, expliciete functies. Bijvoorbeeld:
|
||||
|
||||
```typescript
|
||||
async function processOrder(order) {
|
||||
const validated = await validate(order);
|
||||
if (!validated.ok) return reject(validated.errors);
|
||||
const enriched = await enrichWithAI(validated.data);
|
||||
await save(enriched);
|
||||
await notify(enriched);
|
||||
}
|
||||
```
|
||||
|
||||
Dat is een workflow. Reproduceerbaar, debuggable, testbaar. Een agent zou dit ook kunnen, maar je weet niet exact wat-ie doet — non-deterministisch."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 9 — Lesopdracht + Huiswerk (15 min)
|
||||
|
||||
`[SLIDE 16 — Lesopdracht + Huiswerk]`
|
||||
|
||||
**Vertel:** "Lesopdracht. Vandaag in de les: setup van de research-agent zoals ik 'm vandaag liet zien. Tavily account, Supabase tabel, 3 tools, agent, één test. Half uur — kun je halen voor we klaar zijn.
|
||||
|
||||
Voor huiswerk: uitbreiden. Vier dingen.
|
||||
|
||||
Eén: 4e tool `listReports` — om eerder opgeslagen rapporten op te halen. System prompt aanpassen zodat agent eerst checkt of er al onderzoek is.
|
||||
|
||||
Twee: `prepareStep` toevoegen. Kies één use case — dynamic model, fase-tools, of context-trimming. Documenteer wat je koos en waarom.
|
||||
|
||||
Drie: custom `StopCondition`. Token-budget, of aantal pagina's gelezen, of iets eigens.
|
||||
|
||||
Vier: schrijf een `AGENT.md` in repo-root. Tool-lijst, stop-strategie, prepareStep-keuze, één voorbeeld-run met logs, één observatie.
|
||||
|
||||
Bonus voor wie wil: UI met live step-rendering, of een sub-agent voor samenvatting.
|
||||
|
||||
Beoordeling: 10 punten totaal, voldoende is 6+. Inleveren via Brightspace met repo URL voor Les 14."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 10 — Afsluiting (5 min)
|
||||
|
||||
`[SLIDE 17 — Afsluiting]`
|
||||
|
||||
**Vertel:** "Wat hebben we vandaag gedaan. Een agent is: LLM in een loop met tools. We gebruikten `ToolLoopAgent` om de loop te managen. Vier stop-condities — stepCountIs, hasToolCall, isLoopFinished, custom. `prepareStep` voor dynamic model, tools, context. Done-tool pattern voor gestructureerde eindantwoorden. En we wisten wanneer een agent wel/niet de juiste tool is.
|
||||
|
||||
Volgende les: RAG. Embeddings. Vector search in pgvector. Combo met agents wordt heel sterk — een 'searchKnowledgeBase' tool die semantic search doet, in een research-agent.
|
||||
|
||||
Daarna: testing, deployment, performance, eindopdracht.
|
||||
|
||||
Vragen?"
|
||||
|
||||
`*[Vragenronde — minstens 5 minuten over laten]*`
|
||||
|
||||
---
|
||||
|
||||
## NA DE LES — Wrap-up
|
||||
|
||||
- Push working `research-agent` repo naar GitHub als referentie
|
||||
- Brightspace: link naar deze repo + huiswerk-instructie
|
||||
- Verzamel vragen die niet beantwoord — neem mee in Les 14 opening
|
||||
- Note voor Klas B (later): Klas B doet dit waarschijnlijk in week na RAG, niet ervoor — orde aanpassen indien nodig
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten tijdens live coding
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| `TAVILY_API_KEY undefined` | `pnpm dev` opnieuw starten — env wordt bij start ingelezen |
|
||||
| Tavily 401 | Verkeerde key — check dashboard tavily.com |
|
||||
| `result.steps` is undefined | Je gebruikt oude v5 API — check je `ai` versie is `^6` |
|
||||
| `ToolLoopAgent` import faalt | `pnpm update ai` — moet 6.0+ zijn |
|
||||
| Agent stopt na 1 stap | `stopWhen` te restrictief, of system prompt zegt niet duidelijk wat te doen |
|
||||
| Tool returnt `[Object]` | `JSON.stringify` voor logs — Tavily resultaat is genest |
|
||||
| Supabase RLS error | Open policy nog niet aangemaakt, of `enable rls` ontbreekt |
|
||||
| Cost-cap stop werkt niet | Check of `step.usage` echt populated is — sommige providers geven het niet altijd terug |
|
||||
|
||||
---
|
||||
|
||||
## Mentale model voor de klas
|
||||
|
||||
Als studenten verward zijn over "wanneer is iets een agent vs tool-call":
|
||||
|
||||
> **Tool-calling** (Les 12) = "AI mag tot 5 keer een tool aanroepen om je vraag te beantwoorden."
|
||||
>
|
||||
> **Agent** (Les 13) = "AI krijgt een DOEL, mag autonoom plannen en 20-50 stappen doen, en levert pas op als-ie zelf vindt dat het klaar is."
|
||||
|
||||
Het verschil is dus **doelgericht autonoom werken**, niet zomaar 'meer stappen'.
|
||||
322
Les13-Agents/Les13-Huiswerk.md
Normal file
322
Les13-Agents/Les13-Huiswerk.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Les 13 — Huiswerk
|
||||
## Agent uitbreiden + prepareStep + custom stop + AGENT.md
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Deadline:** Voor Les 14 — RAG + embeddings
|
||||
**Inleveren:** GitHub repo + `AGENT.md` in root
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouwt voort op de **lesopdracht** (research-agent met 3 tools + `stepCountIs(10)`). In dit huiswerk maak je de agent slimmer: extra tools, dynamic step-control, een eigen stop-conditie, en documenteer je het geheel.
|
||||
|
||||
> Niet klaar met de lesopdracht? Eerst die afmaken — het huiswerk heeft die als startpunt nodig.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Extra tool (verplicht)
|
||||
|
||||
Voeg een 4e tool toe: **`listReports`** om opgeslagen rapporten op te halen.
|
||||
|
||||
```typescript
|
||||
export const listReports = tool({
|
||||
description:
|
||||
"Toon eerder opgeslagen onderzoeksrapporten. " +
|
||||
"Gebruik dit als de gebruiker vraagt naar bestaand onderzoek " +
|
||||
"voordat je nieuw onderzoek doet.",
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(20).default(5),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("research_reports")
|
||||
.select("id, query, created_at")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(limit);
|
||||
if (error) return { error: error.message };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Voeg toe aan `agent.tools` en test:
|
||||
|
||||
> "Heb ik eerder iets over AI in de bouw onderzocht? Zo nee, doe het nu."
|
||||
|
||||
Verwacht: agent roept eerst `listReports`, dan eventueel `webSearch` → `readPage` → `saveReport`.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] `listReports` werkt
|
||||
- [ ] System prompt aangepast — agent weet wanneer te checken
|
||||
- [ ] Minstens 1 test-query gedraaid die `listReports` triggert
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — prepareStep toevoegen (verplicht)
|
||||
|
||||
Voeg een `prepareStep` callback toe aan je `ToolLoopAgent`. Kies **minimaal één** use case:
|
||||
|
||||
### Optie B1 — Dynamic model
|
||||
|
||||
Begin met `gpt-4o-mini`, switch naar betere model bij complexe stappen.
|
||||
|
||||
```typescript
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
tools: { webSearch, readPage, saveReport, listReports },
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
if (stepNumber > 4 || messages.length > 12) {
|
||||
return { model: openai("gpt-4o") };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Optie B2 — Fase-gebaseerde tools
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 2) {
|
||||
return { activeTools: ["listReports", "webSearch"] };
|
||||
}
|
||||
if (stepNumber <= 5) {
|
||||
return { activeTools: ["readPage"] };
|
||||
}
|
||||
return { activeTools: ["saveReport"], toolChoice: "required" };
|
||||
}
|
||||
```
|
||||
|
||||
### Optie B3 — Context-trimming
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ messages }) => {
|
||||
if (messages.length > 15) {
|
||||
return {
|
||||
messages: [messages[0], ...messages.slice(-10)],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Minimaal 1 `prepareStep` use-case geïmplementeerd
|
||||
- [ ] Effect zichtbaar in console-logs (welk model / welke tools per stap)
|
||||
- [ ] In `AGENT.md` uitleggen welke optie gekozen + waarom
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — Custom stop-conditie (verplicht)
|
||||
|
||||
Schrijf een eigen `StopCondition`. Twee voorbeelden — kies één of bedenk eigen:
|
||||
|
||||
### Optie C1 — Token-budget
|
||||
|
||||
```typescript
|
||||
import type { StopCondition } from "ai";
|
||||
|
||||
const tokenBudget: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const total = steps.reduce(
|
||||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||||
0
|
||||
);
|
||||
return total > 30_000;
|
||||
};
|
||||
```
|
||||
|
||||
### Optie C2 — Genoeg sources verzameld
|
||||
|
||||
```typescript
|
||||
const enoughSources: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const readPages = steps.flatMap((s) =>
|
||||
s.toolCalls?.filter((tc) => tc.toolName === "readPage") ?? []
|
||||
);
|
||||
return readPages.length >= 5; // 5 pagina's gelezen = genoeg
|
||||
};
|
||||
```
|
||||
|
||||
Combineren met andere condities:
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(30),
|
||||
tokenBudget,
|
||||
hasToolCall("saveReport"),
|
||||
]
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Custom `StopCondition` werkt
|
||||
- [ ] Gecombineerd met `stepCountIs(N)` voor veiligheid
|
||||
- [ ] In `AGENT.md` uitleggen wat de conditie doet
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel D — `AGENT.md` documentatie (verplicht)
|
||||
|
||||
Schrijf in repo-root.
|
||||
|
||||
### Sectie 1 — Tools
|
||||
|
||||
| Tool | Wat doet 't | Read / Write |
|
||||
|------|-------------|--------------|
|
||||
| webSearch | Tavily query → 5 resultaten | Read |
|
||||
| readPage | Tavily extract → fulltext | Read |
|
||||
| saveReport | Insert in research_reports | Write |
|
||||
| listReports | Bestaande rapporten ophalen | Read |
|
||||
| (eigen tool?) | ... | ... |
|
||||
|
||||
### Sectie 2 — Stop-strategie
|
||||
|
||||
- `stepCountIs(N)` — safety cap, N = ...
|
||||
- Custom: ... (uitleg)
|
||||
- Combinatie waarom: ...
|
||||
|
||||
### Sectie 3 — prepareStep keuze
|
||||
|
||||
- Welke optie gekozen (B1/B2/B3)
|
||||
- Waarom
|
||||
- Wat zie je in de console-logs verschillen tussen "met" en "zonder" prepareStep
|
||||
|
||||
### Sectie 4 — Voorbeeld-run
|
||||
|
||||
Pak een echte query, draai 'm, en log alle steps. Plak in `AGENT.md`:
|
||||
|
||||
```
|
||||
Query: "Wat zijn de belangrijkste AI-trends in 2026?"
|
||||
|
||||
Step 0: webSearch({ query: "AI trends 2026" })
|
||||
→ 5 resultaten
|
||||
Step 1: readPage({ url: "..." })
|
||||
→ 4500 char tekst
|
||||
Step 2: readPage({ url: "..." })
|
||||
Step 3: webSearch({ query: "AI regulation EU 2026" })
|
||||
Step 4: readPage({ url: "..." })
|
||||
Step 5: saveReport({ ... })
|
||||
→ reportId: 42
|
||||
|
||||
Finale rapport: (200-300 woorden)
|
||||
```
|
||||
|
||||
### Sectie 5 — Eén observatie
|
||||
|
||||
Iets wat opviel:
|
||||
- Onverwacht goed gedrag?
|
||||
- Loop die te lang duurde?
|
||||
- Tool die niet werd gekozen?
|
||||
- Effect van `prepareStep` zichtbaar?
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 700 woorden totaal
|
||||
- Concrete logs, geen abstracte tekst
|
||||
- Mag wat informeel
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — UI met live steps
|
||||
|
||||
`app/page.tsx` — een form + lijst die elke step toont terwijl agent draait.
|
||||
|
||||
Gebruik `agent.stream()` ipv `agent.generate()`:
|
||||
|
||||
```typescript
|
||||
const result = researchAgent.stream({ prompt: query });
|
||||
return result.toUIMessageStreamResponse();
|
||||
```
|
||||
|
||||
Client: tonen welke tool aangeroepen wordt + resultaat collapsed.
|
||||
|
||||
### Bonus 2 — Sub-agent voor samenvatten
|
||||
|
||||
Maak een 2e `ToolLoopAgent` (kleiner model) die per URL samenvat. Hoofdagent gebruikt deze als tool:
|
||||
|
||||
```typescript
|
||||
const summarizer = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: "Vat één pagina samen in 5 bullets.",
|
||||
tools: { readPage },
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
const summarizePage = tool({
|
||||
description: "Vat een URL samen",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const r = await summarizer.generate({ prompt: `Samenvat: ${url}` });
|
||||
return { summary: r.text };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Bonus 3 — Done-tool pattern
|
||||
|
||||
Vervang `hasToolCall("saveReport")` door een expliciete `done`-tool zonder execute, met `toolChoice: "required"`. Documenteer in `AGENT.md` wat het verschil was.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **`AGENT.md`** in repo-root
|
||||
3. **Updated `lib/tools.ts`** met `listReports` (+ optionele extra tool)
|
||||
4. **Updated `lib/agent.ts`** met `prepareStep` + custom stop
|
||||
5. **Schema-update** in `schema.sql` (mocht je nieuwe tabel hebben)
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — listReports werkt + agent gebruikt 'm correct | 2 |
|
||||
| B — prepareStep geïmplementeerd + zichtbaar effect | 2 |
|
||||
| C — Custom StopCondition werkt | 2 |
|
||||
| D — AGENT.md compleet met 5 secties | 3 |
|
||||
| Agent draait end-to-end (geen broken runs) | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee bij twijfelgevallen.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — listReports + test | 20 min |
|
||||
| B — prepareStep | 30 min |
|
||||
| C — Custom StopCondition + tests | 30 min |
|
||||
| D — AGENT.md schrijven met logs | 30 min |
|
||||
| **Totaal** | **~2 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
- **`prepareStep` returnt niks** → je moet `{}` returnen voor "geen wijziging", niet `undefined`
|
||||
- **Custom stop returnt nooit true** → log `steps.length` en je conditie-waarde per stap
|
||||
- **Agent stopt te vroeg** → check of je `stopWhen` array niet te restrictief is
|
||||
- **Sub-agent recursie** → een sub-agent gebruikt zelf weer een sub-agent? Stack-overflow risico — limit op depth
|
||||
- **Token-cost out of control** → log per stap usage en zet kostencap
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Log per stap** — `console.log({ step, toolCalls, usage })` is de beste debug-tool
|
||||
- **Test isolated** — test je `StopCondition` met fake steps eerst
|
||||
- **Houd `AGENT.md` bij tijdens werken** — niet aan eind
|
||||
- **Eindrapport kwaliteit** — als agent rapport een 4 is van 10, ligt het meestal aan system prompt — niet aan tools
|
||||
|
||||
Volgende les: RAG + embeddings. Combo met agents: een RAG-tool in een agent. Tot dan!
|
||||
194
Les13-Agents/Les13-Huiswerk.pdf
Normal file
194
Les13-Agents/Les13-Huiswerk.pdf
Normal file
@@ -0,0 +1,194 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521071303+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521071303+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 13 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2436
|
||||
>>
|
||||
stream
|
||||
Gatm<CN%rs(B)NcEFGLJ3H_4ji+(uF$b4s@0i2u$M)P6"5XA=YM_RZC,S_[9dbhcOL/6K[ja,N66uk8.m_*[KB-`;n,M[LS-Mh^Whu,)s8=EnJn<m)D?g-%q]ChO"A`O*7PiaH]`+!<XW4M[0$:5NJ1)?[2`,t((Y6EB9eg.LQMF9jtTrT!$+;TYndQTB;Jl%45eghM.$.1pE_D&^=^=,k^ojS\[g-O!W+(1HZ3hlmYjYJN@.%pC)B#"?:h$(JEY>(RjatflE2jHs9%5"B7kj7<j=^>Z_A!ZbNq-`c6ZSk6n9_8!8Ipln_n9l$*?h%eGrteUt;"T('eT[n%BlX0<>\?Jl@KQ0@2RQ$^AZ'7@"H28)_&8=%\//W1g@M)4h.BDB*b$IAB\uLl5L`;ccQT.An0!Bn4:$UG4-#"g73\T09o-<M2$b-j;K)+epGua##TqIh-nP$NeB%19c0m9.'LCrb4SY<NXegX:'!\09e7e3gs3Gkf!3%/*[9Z7\CED]eFr>M1#%>E*[=*9:C`c.ALrMqns0M)./7=#?>)\439El\1ZLg.h5A_ltpG6?52Hp_pZ:gNU#U@;WUnj^j.WQTLdh$\bgW/i%$Fh9'>`hjDil3O8_J0)'ZYQ?LD6rB11>1(ioIXo0ZUlY<[P8^`V;&R1f7YgSFE[ZKf/[%g&*=:R7CMD&B;Q>soFH=09Xsc*HeiTN<9;C63E3S=4U4<c";\HG]Cc?4B'lmNgjutU3I@+JSF.dgeLF%%faW3V[b/P_>bH8ZEDlX97f6e[AY'Y!YU-u5LS-6KBZ!4Alr$tej<Die,ONb/3P97\QY*ePMX1*NRj<KL9c$6Y/ZVQbfVk]?\EBITXEhLm6m?Nk8H_sm:fWgBGtC3CTR$6[q#jonA]gX.[bL'Jpi#n[$u$QO$VHDiFY=3E$)h,-KSM\JbrH!s>_UY6-9mIDieHGbDtiG&muN".hD^c*4dMPt1,j/;g/+5N)bVf"]0HN1LN_V`Hl+9FgFf9/a%)e_%qtA+j=,]OrWo7oLlV?HCUKGU,&40$[tIt]e5d?tk1t^aRl`c3>1%h+:uQh4MWFqE=Nk:"Z"L#7!U;h+>@u6)#!K6R,p(c`nDnm#^C.S*Q%Nd'bHC8_SXlEsPU:U*A**O\1Z*r9W6">GEdQEb#?6>1;"p<2E`8<oAX,5U2QKMgOt'bN9O.HsRt_V7?u@hW%!?O\O)eG](\M5apu6O<bikdoA_,5.B41$N\Y8bp&*4ZsZrhIBXk0F2-1eC@Or!\CQCj%FSYZhnAQfjD<TYZ>d'$,tAZe2LEQH30?9h\^DJ^+HVLfdkR3C$lf4JSfcCUUs^'\!G$M`..;R,W`Ys(\j'd?:$5P.=I-0/BZ4,e`sJ>$ph:WB3uB%ckub3;IDcUkfY$o]:QNqs2P86Z8b#Ail>2ePs[+%W)5J"M&hDoML7%mYN(@`_P&C<oBr]>]C?'?`I4Fe9<R(R8C+qB:'>)&TH>QKL$"1`2`-0,#-Wd;JRSlNH*b/eHu)g^%_lV/&0fYD*Ua^TEho^&"=@[.uZE,ZRbf&(%gEH%qhk*$8WX$Iu'cOhY/qhj*?X7D!JAFdg_i=BW2NOX[br6WLFs>2]qtgE>F<g_:9/r.//U:'FjC7>;,/9gBd;Y%''Tr/oH3U*&4O\X'RC]5S`.=[(k0O/=/5T<gobIAUHgmEg>n?H@Q,Z3?Re\!%"*L(Q\1%lS_YXXC=J#f]A;2LtI6"!;[QerSC@[sJ2o]\7XimuQ@lEoW',d2kp)8*_iqeZ,+0\GF&;GVT*.s+iaEER&qQ]82jeMKGCifqum6*@eI0+jMhoJ7H3F6q?jS6mX_@'#el(;lHUkqP,l+oakDfNp,4"n;)/h\j+'(S',Q9h7*rVTLY[-?aiCapRt3Bl\:9BWT_d[9p20m%[*G,0(`EtO5b!5=sf4\qaG,*G8%>pBc;/:EW@iXc6b,P<c#4hgN7#XaXR2)b00NmrWg+]Hj#$:UKZks=^qJi/a.;aR%Z*D7H:Q:X?`hn2p'+Z9VQ1*V7B4,3F2rKj$W[;)TS/d[%b8S?6IT$,2VWt5+6TJQr?WB:IU/Hp:42S,1-pNVaHd@J;GrsrS9s8B7^X,o\umfU#HHS3k7l`>&Q@AAQ##<6"B#VjTnJsY,ZNUQlYk\j7fRuQ_U,2]*A__6j5c#_$$es\b"fX[QL8<Xf4LT:g07iB=Qi_jrbq:YjZ?ZVi[`L+!=<d:9Y*tSUrf[n8q9(#_$9-ol9ID@THbh"a:GZbhC81pQ#E,K=da6qXrMVSEg"PLK:;*<Ws1Y"/kO?X\E(GkNU.jBVeh,Ca8AQ4#lQl"%sqT])CTl&(:VH3rP?'bHAta)]9GZ,J-*(#PO_I>5$oipZtm\5B\.i:-7Z*&1O?gpa_(?O+1NuMGH3G/jpD%<Ca:%p*Zj&SM[8Ca:e3%kN%p\PP8FsV`c+~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 535
|
||||
>>
|
||||
stream
|
||||
GauI1bu3:M'SZ:1MR-!E5tJ:4a\+ZH"#KQSl?:.dj'3Rj=>mH5_qS7tSMY&Rb?>g$jEp<=cBSBj98mN6!q-Me>mL^miZ:E@L#O!mMrZU$;G3c?Ygi5#QXG*Mq;CSW+k'-J1)I1M0P_Ae(!PCe6H2&rKRkN+T-OK^BPsmQZQdk!+4[8_9,UqaQ>)oi>sIr6%:7?P9G]F]*Q@"[@Q(Vm9bl\41Du&cMpGaZ>iDuhBrG'<\&S1#WEKJ\8SN-mFa?4[bn+QF&+ke2?/LeQmnipMEO+i:Vm"O-WK`l>rG*;o;HuCC___+D8b["'2m.hh'=s$o*g)nY\]qXA_hcol]UhfVA)ZHMjDHC#qNs(29)^QB4`V%?@6M5a>Kq07M`Y@k!TH:R.Te$N't[emJs4D#<$peqCFm35`)p79^2*UrZ?.tsjY-pHehLc!'?-_gNlWFVWYJMEjPE:^^AjtqD6'5*=1-#@p8>o!gFg3sBP7[LO&+hD:t*jZA[B=\+fs^N];<75@6j<V?'>dh3&fkanf'F+h?*~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1696
|
||||
>>
|
||||
stream
|
||||
Gb!;dgN)%,&:N_Cm-%#9/Hf,fFIUM8i\[(#8^jVdM=Np\YfrCLRAB5IM>t6s?9h]&/A'.d;orO$9Ao.<fQ2i`",^I:r/WXaVfR+XJcXmc56<0gK_]E9T6hq,iB@dJ![9H9+U8VeqgiePje."\LHK+?Bg?b_6#&lr%Q/2F(Bc:>d_O64Nr]Q7)JBFF1Z`EPb'64"cI'$Fb4igHnQgOrL)1K;M[mX@rt#TcY\m>K<CfHE$5C]?R(dkCq^Zno/56j$(I\\GeYS+`CKPNs^lqj+-WQF2[5ip'k00X?.Pl@`"7n`brkS@KiCfl#'K!=Ys4UGf"M?ikWcM,<g#DuD/X&9u0`lZVMqTEO]SeV>RKs9=B/d!'-ZEY(OM:?1o=,u]e(IW[#D?!S7lIN^?BVP.k@9G8qo#)4@=r:6/<hrEfIp4^YQ\h>\,^hBMuiY>e789<%1fE?U<ZEud34A]Dd8C[?oO(mb[^+oCQchhgV&+3_Br'B]fshLIZ3tO,`IGICLDrK[*m3j4WWnt@I5hR?Usm?K-(_T'jl!U=GVf\$%GsD<E,F][95e$R'XX\9b**GR423OK%[Wcfp3M)86ohtN'6/u=Wa`=C-u<B_o(Ed^N'Y0T+Xi?`^:Dhdp\[kH'1Tn$4EP0o5Hatnud0I2hf`'5TEd9mo#t5a,6u<A(>FtrDmcAEgXRHeVSjTXQ@k$:O/0N0kA7s8[er!:]ckJ27%C6'K64"aB`"R20;1(,.uaAb`b?>MO1ZGT%)7"*)',dHpT+g`YRD)M*5JZ),WLmIKS-T":9_kOH:K-W4G_V!bXftiPmW&d!G>O%UaDJAP&_R3RAr#DYu2XTm85h"GBLpe*B"/ElNN$;'MjfHp-Tc+K@We1$8@G5\4673R&%-\I4h)WX*_a/f$P<kV7J!?=nP/'bD[F?k#4`EeJrF*'D54I/2be8S[g-I>hdX&Uk+r<5]T(m[sU<2YCdP_rir*p7PNU4A$C<a`:D-A2-BgMskS='QujFd^5AX2tuI%'D>/Pp$VcsF4+F_W/GL*U/5ZQ2LHfSns5Rp:`!_:F+>'**ZEP<,-A<&'D%JcN9K$FPMn$O9?G3GAGDIR8dS!`L+fd.PDd4Z/3VgKUSkHF!K7N&m[9Z*.`-QZDb&,XOqM>]X&-tKV#90`gk'saikeUj.fH*O:<q)=VUHT6R&"s^qphd*ZE>Od0KD&smnHd$a-2Qgl$,di[aK/Rf&3pbIh7mQ=d:<p_ulaE`@S`\W3#m"U,19$lK$uC_qetG71u(Q^sGhOPL:FG[RPEf,n_\L`;`G%,[)L?<og[aX.IjQP2QFS\QsluAS-"Pr8o3APW^g/3[6=j1XZ'`70q519rC:1H:oJLrQQHO]nHr(FRXG!,'6C02Mf^#cNS+Ld;\cI;nssj];?/BA*tTXp=&n/HM,Mn'ZicLEM6BWni)KkN7#Via5+@!aoCi!_'sF^45!MBXf9.8VJ@r&\t;qKhctrH\t@*L:YIr!ZaDH&(jA2ao]%eM9Y)o:Qf&h"Cb_D_Cq="?YVA[2)CU?:YOcnWP4%MIH=JY30!S$<fd[Gs`g(X_&CYH\)Z+$!4ecd%9B/LthqSHDDJ%LPZch;(7n4o/iGVp\+%/s48Ifa&=%-k9BsHH@o#05;j5g3igg9+3jN%3XF-7Q\Bi:j@Bn%Tl2sHq=Wt>0.q[Unkc:)n&Jp5HU]RKqRG(%I~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1699
|
||||
>>
|
||||
stream
|
||||
Gau`SgN)%,&:N_Cm.a.I'*MAL9PSne2]'f9:=H0/6^6gh'I5Ep73O*mg._LZ$XQ*@[]I'(E@d/A/V155fKIC]",\2OI)d,?dj[D,c@Bo%/q?M;@JU>&9ta=DiB@dJ![9H9+U8VeqZl62b<bsB%sc5]dt$XIK%,`m*GXLl0*kY[V1HTKNr]Q7)JBFF1Z`EPb'ZL&cI'$Fb4ifso/hETdL1ohYsXuI5D%i"eh,%<$:uXEJV%S?POmD@T>A][BrZDX'Iir\QR6%h0]P9G-YEs]<)liHAe%%n$^2@lbiJF?9L$DdT(OtXQU295BX!Krb#H.Z]\Fd_B2ef[ir0QBP<8n,!d]Kgg*?F+@iA0<KS>Ak)gC'N\2.;Z?i#k0>`na2fi!k9KMnd>2QjYhP-Dab@uKWm`1F<o%F^8=-t`m,>c>GH$eC>X"F<Fo"T/)GA,)8t&#fZ*c:dNOJOPG\*6E*(>K1@<Z+':mUp?UNp(*eMjCtpO-b@K^;9VAD=NJ#WL:BQ#h8JZ%'GnX?D8(B<iVO(58GH24abaIM-LNekVQ,O_n8%tF0(O9c-O+1H$WF4W"I!bI\1]T7OLiB7@YFH)45k22XLgs<ZrO>A3-cIHb2hRaN6/m]9Dj6s9MSe978@7efbh4IMXlBSl.k?\[cgsl#k%sa=%TW.o7q;OhH$#V?JGE<AZ]"1`EssAn6n+[RX.1/`G(MGNFEaD]uM&OY,u^_`F)Oe]T#YJaWP9_PI%*WeC7559Wj3IKM!/A/\3#;Hd9W-(0jJ_5WN%>Uf%8?E/#QM"uun5Jl'>q/"/`hd:54fdD;1"N?6Y9#Hln\@5Ysi[CQq]2CV"sQVGO=7kmn1R'2qa:QfO"PVps?Y75m4:+>rFM\7iF+A*+b?XGsCaA="]:cFKJ8)]10,r&c.A!BfDIVkZ=D:7Lm[eCjqV.,!#MR5F!,F$mu'VAN0n9&m[8&t,\AkkO-[+(O'h>hkOFd+(P"%5a;ho'+b\J[pA`3Ntq&0R0OPMM"oV1JC1K=-h9'eRZ:Bm_`=W8Q_>kj7g<bmpNn^'M*`V`,,&JcrrV^NTPAE)1F4k-9=8GN2=pDp#`IM.7+h:J0lcpi]_snFc=c3[[;_jSY"[$?+,1$^2B=$!"5Z@sg^TmJSETYhJIt0gapt:+>o892*E_idp[@,pouBeUd0]9W5NgNe+Oh''"GN'&tNb!2G1M4"`6lg1ut/OkVEgDqi?]CTUs,B^;o[d2[Kkb`cB%M_qG`Nn1NCWjT[VSLuF%$(aV#>+LaDP1HM;:j(Q6XQ/I=3G'NV*?Ds^:Z)5F@j-*kr[r<[<_ZJU0C'>m++a9cneN(u?:q\E@nqsK6^!oOqj5OV&ERuGLVBC\r^5/O;%[ZWq7W%5K_Cp'eQBre.KigJa]`e=*!9iY8p3FhP+@::@!(m\a%G2dQL0,#=CGtOmr"m?/&'JAPLjHtCa/Sf%ke5te0F=JGY&LJW1%m]SP9Toked74Cq#%C]RR-jC+P59q\6kXkYh!#T#8S`J[i$]'8=Q7Uq3OLGNWD3a$+5/\<!`?*-XpQI_Nf\@5$E,8(/J8a#]fs+P96ugKhd[grS+7MF;g@g?qGt6%&E063F-0\$!R!Xk*0^`,5<+jh*$l>KSqGf7Z<%@9\qka`:sUh0f_bpC::%^D;0^_ikn_cDMqW_^;?]Za>M=cVY/CU#<i$\E8>:VYPc:0>G_$mJ~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1837
|
||||
>>
|
||||
stream
|
||||
Gb!;d>>O9K'Roe[380^R$AOu=gl0acJ7Z>`/+u<P^?!8c6SLKCeom]Zec4J^G%*NE_F8-B#U3k9o:>>TkF=6!pgWpRBEGCsK^JLB(l.&f0L5gM5Jk7`qsG'!\/#^5Dl=as+G'!1TGW"n84?Ln!sXKp@^H,D(bm?k_"!rG9LE7IlrT5Kd>DZ`J/BVu;0U=_6ue6]"d)C\\'NCr)+Q>&C<d<'2STNP44mf;(=a4S18uQ.-SROVFE.X/o!'dAo/oh-c`44sGm,G<2=)B%m?V<]<]5\Vp<'9.FCMRk",7UGr7/pSi<s!*iD[FKs3[.JI/#Bgb%k3d2RoBsEKZ"uHU(rbW!alik=d`j`-7<jA?Eg6+"^uMTnJ#Sa!GF@gU5PkRSYJ-DiL7^/jYk*]Y>X,HQoY!_ZscjF&;7%JGTBS/[iuo4Y;6(6A,IXc`+tS;q?@f>9MWlM*0Ls8F`=<jSWtt7SiWs488$5Ej*LWg[AG;eBt=QRE%hDB)uEli7n<">i/(Nqs7V708)2p^AZp+=#GFlpg&9`M.?VkmLoNe*sY;'15R1"UR;LgYY$I*#$87#V?o`LB]Gf-:]YDX_dt&L^WR4MAomP.'W+S34,S[.%.e,#D\[P(D8K:nE<`8!O@-Rb,^6Y,I-N!7Q,[PpGD<liJ1rscV;g/h.u'F@jWb$:rd<7OiMfO[@Oh^_\?JdiR:uH#2N5,['hMt,)rqSNYSb*Y&U7f=j!p[=PuSDsG+@33RC'q,b!$JBJ%UT^<d3EOmDX>Q^0)<BhR^#8Sp]4_,Gt.I,Vd1__gH?5XY>uQ%(2b^F!hC#mS<NJ=]QQ/UVY>kUTmW5T%WB67,`5$No2i=jF2mCNC/2eRL<7o<-*Oi<r&OgDO+8$*O!rT-`Zd>%,9)UVg,b_OM24B,+mX&o]<fgP$G\B<r%_XVc`E)_B*2P_53^Y4<h%`;QhIQT'2qf2SHJZ:Rc-4O)=@Um61L#Xn1H5d8RGV*H&&/G>;t*D.o)!P\4R!g9!1RQgV<9^<*_6(6;6;iL?0/-./O:8Y-`reu%t<1!:WP.0VNR^TB_UQXFY7Xs:FFI:eBZRh-BER(tA=9gJ%&24duj(WBm1(QSLRN2sO^Ae[<_S3Yg&-5u/u)FkR[S'Gm1$*B]]V-Q_W@Al9?/[Zg`,#hCp?SRI%Ok6`j/Vm8]ePPWO2UYe+YQ/=i1n.YQm0Z1&s1"@u)q4\OG$q\@jgo3Uks.>5oCJM2h;cY\g4u>E'4'En#IT\^j9Vf%f+,MSkO$'SDd`f.a[HE="`2/pCg85%Qfp[nCuO`G`?ccIQ2n)E^0*U4[J0gB)\tF227mh)#de>Ke/&,@JSTUYCr7GX*tA3eiX2[P*l=FtPPtdc=Xo6.TUE,@XU,C9[XJI<NHI6SZ(\Y[")"SY9Z\>-[Y'@Q(hZ5%=Nu^;3FR"##U4h\h?o5*L^6SWLn9A*<#2UJr\t+T0*Ps7bCr0QM[G7O`paJ^?a;Vb49QfbEHG:f+o6R-JKQ48^,@!^gqg_pb'q=7Vl6+X#kG2P^/)-)`l$B171F2Qn).lgr[c*kG$e:OO3Y_V'C['t-7iat3-@!"n7LgIDY`M;CTP(OboRo63VD,.pHiFu2J)nrj@be)ITkIbb&qn=G>O7A*gnBt:f@8)Mn5,FUmpUIL;Wk1ke8Jak;J("`G92cG"qSu$JN@XGs6p!I?)k1<G%&"1Q8qg>.oNrd!`#mCR$2*p0ZRkH0[6#Z.W="rpRP[_:]\Q-sVJZdWBQj^-=Zt:ACu4pGRqglLe.EZrQ/SoQlZ:PG\$<-k9VtrWTm6>YsN^(o@H0o.2AmM_FF52f[RhDu2>D^lJ!Z(_>K<]QFcn~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2009
|
||||
>>
|
||||
stream
|
||||
Gb"/'gMYb*&:N/3%"cI<BcBO;.#Dul"d66,!sIFsj)eZ+:g%CIV7K*iS6h;,hk@2j@Ri_R^24il1.<80+_T(/7DZnQXhKE)i+N/s-PRt]aX<aOE1!03X,1h-&EY[5gH[;u=rme*=o&["KopoQ&BA9%"_"u1\/`#O6jQQg_f;hP?-"=>H=B\nY%,%p!tj%+KQCe=73'<dC//TcnSXD+lkA)8O#W,ABP.4(@+^)iUcMJ5'9h.Q%?hVuDu]fU$?+@H/:qH%7W>&A)(ZLr)0=d+FT8Qn_Eu@)]TaoaV-,>_[0MmgQorc;Xl4?E-JUB4+7[Zf!Ru][AQW3\r,[kpO1tr6+.P3]GIXS'P/A22>uM;-6O*(o.rJJ!1;`#\G9Af2:$UK9/M`\d9TtCB2C=l!S5@gGlRd;7-SW+i]V0pT%Fm=]f[$mt_mX77V_au_#ggB\4u+NMV>Wcf"#@Jk!lOB,M"!oIA2C`jZ,Sm[Q:Bt8qGZk]_23?,b'P-Q)ZNpo1QU08B1r:o>YE_Dg9Xjrqa+u0bdXckpeHP3&U"t_1La8n*VTg:UGOD-R#]TgKkV`B1u0,A"@niM-X<e"J4lXlBgo2&]A;gsH<Rr?`@8`'5npIITV#l9&!h%?`>Pn6f='TqR$B8)q:ChFQF"445=7<Q_26_7)>OXK/5fmJq=/RV/4bgVJhVa,npONRb"i"abM6/sG9q2JKfBuMRc:f851`b!ao"34N1e%BH1(R'+qNUEYt&,9<j21A3SU`S=f#gBKo]]Kns,B[YYc5Z`:cm&OofBm;k\GZf3/e:.6t9eXEM2-i3$8lDq>aB**ijUp._)=42g3W]A=ac5(V*s3+IUL_()CdNME&9<r*`)0rOHh=jXQ!<<#=dJLe[mA$ZCrqmj'VLn97PgY.VTg%;`m"i18DMH=l**dEW!PS_doh"7W6*X-7U-n*4tp@IdCbi&^NnerjB0$_m%44H>Q*IZh8'j=!T"DWqX<JUWp3g\52dS)!:3$%B;1:>HWrA[KfSa#";!A)hg?P'ld.__Xda(*cti%+S$qE_+AoCe&=;^\'WgN4._0IrnhWe0RGc/\<9qjMda]V8jDh_Fs'F%g!TDMA.-L:cJhL8m*-G:A_q'qWH[RAeSD3jOJHF5/?5CI<!:Hd;r.@iFlBVjd=N?53!sCO2KD\753,@Q_<0VG4"![j:-=8`X<Vm*1he>ka0NBCu`CXgr)<p\&qQ-<[=nK;FP*0mk51lr"&Afq?P6h11(p-AtJ"Ysi$YAoKabC+YpM6Ig.u02fF))#WB/&jV)PET"fC0YlQ8#d5GZ`WE)?;?r&.NI&2P2l%8W!DqMsg-45A!b?QpB5.hscPtCS_]Rd`baXXqA@YDSb7u&IYkqN<E@kH1ZpSl5auhu1YIT$$6V`mDU>TV`M4m&sK!dl_$CCIt_94e#"NTnM_W)mkX%lVIYq"GYcB$t9kp_*QI_o+>@LEp'<P\p.@ocslW="r?Tk3rMO3k@[$\'J[b:LogmY1dOY&gV'2>JPV_B>(8@TuLd>FNc5\f5"+VMh%3eCo;]lgZ_>",quj>[E\(0'BjUV]rW96)dW]&g:+M%q!(t4!F,6Xte^u=AGu/Nou6Q_M7%N'aGD3'`DD%s/nCiGg;j@s5Z,jq56f;LW1<>ngPPMFAL-VW7F=ENV_OX_ps9G[j$9<*NE-'9c.gA_X:Z&D/3upZ[1ETDge7`mM.uK*ih]00m1+oDDAQ=XH[-][g*8;s8#.>cd[fl7VMWCo]o>e.;,U>=^#BG%,%C<>V&m;q17<^/QTfN_MgX.RIbLD3orY"RbiLbVWo3hr)!`&%j8f?NmL3Qp\!j<.%D;W"Xh,"?[Y6m<%\+(O/R;^GSGW)!W:TRo;g3M%DN=\cd1^`5.Ee1TYt;"Xt&R\fKKXFqZ15WNb_q0m["Z"m@cu!5`2TJX3<QRn(B<>,bZcm,q+6i,KuacR7qM3&CEDLgQjrNVP.>)I^'7F<>mGW10O2qE0Woa<+6.7cI;%7D(u4S5;>1f^A~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1570
|
||||
>>
|
||||
stream
|
||||
Gb!ksbBDVu&DcY&As:e;/X-7mmE0EH-TsJ('!',D)i0.q=<t]_TRG7Yr-JYkorr74D)YbMH;GjkpC7B`(LmdQ^8V&a)M%!,q!f.-Peh/B1a3OUhS@=fjR\&/KV_NS,g:ioB0c%7rIM*u/]7reEgBYFJs9e["AqW[QK8u<S<p*0"YF-XCdN]K8W[NrOGq1NQ9:NVP(YQcN_#Zcq;M8!^Wa6TP@X:qh&P8WL)+l9e.sp#83l2lL04UkDh%ZZ)IBC8Qsumk%DQ+!MNrlU@jtZqSmE6YN>3bP&@\B7>pEPM'P99XHfhj3n=qZa;&H,'n_XTS1k\rD1j;9i#MaO9OtDH2&*Q.\oms#\`^gjM&T5UfR,bh)gBDoGU2>kFCL\#HO4fWM"[@[XlXgn?:*SlC^Aq$^8_iOW7@h5*O03M]ldH'E.e%/s`jFFRaolD%r:XE2Bn_k$F(EULEJ$1Cn+tUmG0`/;Q5`^/q-S*HggaU)G>63(l=[%Mofe1,BktLqO+e[+=."P8M1t@D55;jC6>=Zs@p-A8E("?"gECu!AF/n>IA]NS[CEZ>6fL<Uf#<5^%:ZmG)gimc;8.u!&`1rAp;X*:Y-$tA<Ei'G<BDc/"P&<MPUTB+pkp`Q`RW/@1X_L)\u)N^YgD;U#(t*^_OR8<iPK)H=H\+PrC3'@F(mqIjj^:Z?=84_$.6UVX#44*hjXAi'9".[4iTJJlqCR-lP!*I.1Q!pSFHH6T:6.\=iNd-(l"J!H?IM0F5XsdKZ%i=Q&oTZ?LG7VCXfQ^pG=F0)\#FhM(-]>L-7#,;%daU5#7b$"DT/`^8M4F["b<@SN@O)(@n?E^`+D3PbRB^@0XQ]'.2LuU)8bN?\?_Mg>0cY^h<s?K5='9D@2p&Y4W(dIL"VQJHmaq)P]_Tg'lkipU>YfI!Q"ajGDXd__kkMo9@3kNi/KWqbD'^IF3+[K7FtWaao!0@S:";&+SGu!Rt>G8(8X@?3.#-+ETS5%^3HqZ]@H&[n>nm_Lo$+E6IQi0C)KpS.h$?@,i3h\?+D0G;c@5H;@,>YeA'!B][qVa5N#.7UZ=HrAG.Cp)JkX8WTB7fo"5,3TL3ZPR@?B_c$mMc0AUDW3rXu',ljTk9u3R;oJ;<267JsLdSR25fCH\C*fk\Vc\dbm70@BY[G#mr6+#hFh;@hQ;0<'#CtG)@QJ0[mWbppgpeXLF;+U*7;#][=2Bgf<8@npXOWkS(V5_fjV[)"k0-E:Zqpr-HMf>MU9)ACjl))DKBt#@c?`[`mU!*U$LqfK7PQ,E%GB-d%C$r.)#UQeQK1?'Tnnl%@CqD>Rh)(BIoNmIqa!=PA7tmEM8%Y=Sg!W(CK)kkVUbV2.h7C'*%X"iQb9KGq3YUJk"e8]b[ssb/I;9Zkc:[=4%rs#G'/'0I!5haP-9Rp2^!cCX8//OAr^a&GVnhb?E(ZoS_jg#"4gPrclcbFHImRjEo)D0m@e^9`(-8?Y,d0I*Nu`a_h+t$Ab#e[-[qBWK&/>_>1anK"%9URErc#5eWlKA>*tr?_o3AASS/e5/7i$'!gfoi-pQVl"'@1Y"n;(JNYEg6rWJYYAB+~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000001943 00000 n
|
||||
0000002236 00000 n
|
||||
0000002334 00000 n
|
||||
0000004862 00000 n
|
||||
0000005488 00000 n
|
||||
0000007276 00000 n
|
||||
0000009067 00000 n
|
||||
0000010996 00000 n
|
||||
0000013097 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<464a00a4f31894d5e1fb188fac933ea2><464a00a4f31894d5e1fb188fac933ea2>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
14759
|
||||
%%EOF
|
||||
282
Les13-Agents/Les13-Lesopdracht.md
Normal file
282
Les13-Agents/Les13-Lesopdracht.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Les 13 — Lesopdracht
|
||||
## Setup research-agent + eerste run
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Duur:** 30 min in-class
|
||||
**Status:** Tijdens de les afronden — daarna huiswerk uitbreiden
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Aan het einde van deze opdracht heb je een **werkende research-agent** in een eigen Next.js repo, draait er één test-query, en kun je in de console alle stappen zien die de agent zette.
|
||||
|
||||
---
|
||||
|
||||
## Vereisten
|
||||
|
||||
- Node 20+, pnpm, git
|
||||
- Supabase account (mag dezelfde zijn als Les 11/12)
|
||||
- OpenAI API key (zit waarschijnlijk al in je `.env`)
|
||||
- **Tavily API key** — gratis tier op https://tavily.com/ (1000 calls/maand)
|
||||
- VS Code / Cursor
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Nieuwe Next.js app
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest research-agent \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd research-agent
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||||
pnpm add -D @types/node
|
||||
```
|
||||
|
||||
Open in editor.
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Environment variabelen
|
||||
|
||||
`.env.local` (in `.gitignore`!):
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
|
||||
```
|
||||
|
||||
> **Let op:** `OPENAI_API_KEY` is GEEN `NEXT_PUBLIC_*` — moet server-side blijven.
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — Supabase tabel
|
||||
|
||||
In Supabase SQL editor:
|
||||
|
||||
```sql
|
||||
create table research_reports (
|
||||
id bigserial primary key,
|
||||
query text not null,
|
||||
summary text not null,
|
||||
sources jsonb default '[]'::jsonb,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table research_reports enable row level security;
|
||||
|
||||
create policy "demo open" on research_reports
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — Tools schrijven
|
||||
|
||||
`lib/tools.ts`:
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
export const webSearch = tool({
|
||||
description: "Zoek op het web. Returnt top 5 resultaten met url + snippet.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Zoekterm"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const res = await fetch("https://api.tavily.com/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
query,
|
||||
max_results: 5,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.results?.map((r: any) => ({
|
||||
url: r.url,
|
||||
title: r.title,
|
||||
snippet: r.content,
|
||||
})) ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export const readPage = tool({
|
||||
description: "Haal de volledige tekst van een URL op.",
|
||||
inputSchema: z.object({
|
||||
url: z.string().url(),
|
||||
}),
|
||||
execute: async ({ url }) => {
|
||||
const res = await fetch("https://api.tavily.com/extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
urls: [url],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const content = data.results?.[0]?.raw_content ?? "";
|
||||
return { url, content: content.slice(0, 5000) };
|
||||
},
|
||||
});
|
||||
|
||||
export const saveReport = tool({
|
||||
description:
|
||||
"Sla een onderzoeksrapport op in de database. " +
|
||||
"Alleen aanroepen als het rapport compleet is.",
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
summary: z.string().min(100),
|
||||
sources: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string(),
|
||||
})),
|
||||
}),
|
||||
execute: async ({ query, summary, sources }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("research_reports")
|
||||
.insert({ query, summary, sources })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { error: error.message };
|
||||
return { reportId: data.id, success: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Agent definiëren
|
||||
|
||||
`lib/agent.ts`:
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { webSearch, readPage, saveReport } from "./tools";
|
||||
|
||||
export const researchAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je bent een research-assistent. Werk als volgt:
|
||||
1. Begin met een kort plan (3-5 bullets).
|
||||
2. Gebruik webSearch voor oriëntatie.
|
||||
3. Gebruik readPage voor de 2-3 meest relevante URLs.
|
||||
4. Schrijf een samenvatting van min 200 woorden.
|
||||
5. Sla op met saveReport. Inclusief alle gebruikte sources.
|
||||
|
||||
Verzin niets — gebruik alleen wat je via tools hebt opgehaald.`,
|
||||
tools: { webSearch, readPage, saveReport },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — API route
|
||||
|
||||
`app/api/research/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { researchAgent } from "@/lib/agent";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { query } = await req.json();
|
||||
|
||||
const result = await researchAgent.generate({ prompt: query });
|
||||
|
||||
return Response.json({
|
||||
text: result.text,
|
||||
steps: result.steps.map((s, i) => ({
|
||||
step: i,
|
||||
toolCalls: s.toolCalls?.map((tc) => ({
|
||||
tool: tc.toolName,
|
||||
input: tc.input,
|
||||
})),
|
||||
hasText: !!s.text,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 7 — Test in terminal
|
||||
|
||||
In één terminal:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
In een tweede terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/research \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Wat zijn de belangrijkste AI-trends in 2026?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
Verwacht: JSON met `text` (eindrapport) en `steps` (welke tools werden aangeroepen in welke volgorde).
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Repo werkt — `pnpm dev` zonder errors
|
||||
- [ ] `.env.local` met alle 4 keys + in `.gitignore`
|
||||
- [ ] Supabase tabel aangemaakt
|
||||
- [ ] Alle 3 tools werken
|
||||
- [ ] Agent draait 1 succesvolle query
|
||||
- [ ] Je kunt in steps zien welke tools werden aangeroepen
|
||||
- [ ] Eindrapport staat in Supabase (`research_reports` tabel)
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1 — Next.js setup | 5 min |
|
||||
| 2-3 — Env + Supabase | 5 min |
|
||||
| 4 — Tools | 10 min |
|
||||
| 5-6 — Agent + route | 5 min |
|
||||
| 7 — Test + debug | 5 min |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| `TAVILY_API_KEY undefined` | Restart `pnpm dev` na env-edit |
|
||||
| Tavily 401 | Key fout — kopieer opnieuw |
|
||||
| Supabase insert faalt | RLS policy check (`alter table ... enable rls; create policy "demo open" ...`) |
|
||||
| Agent stopt te vroeg | `stepCountIs(10)` → `stepCountIs(20)` |
|
||||
| Steps array leeg | Check je `result.steps`-mapping — niet `result.text.steps` |
|
||||
| `gpt-4o-mini` does not exist | Check OpenAI key heeft GPT-4 access — anders `gpt-4o` |
|
||||
|
||||
---
|
||||
|
||||
## Klaar? Verder met huiswerk
|
||||
|
||||
Het huiswerk bouwt voort op deze setup:
|
||||
|
||||
- Extra tools (listReports, eigen tool)
|
||||
- `prepareStep` toevoegen
|
||||
- Custom stop-conditie
|
||||
- `AGENT.md` documentatie
|
||||
|
||||
Zie `Les13-Huiswerk.md`.
|
||||
194
Les13-Agents/Les13-Lesopdracht.pdf
Normal file
194
Les13-Agents/Les13-Lesopdracht.pdf
Normal file
@@ -0,0 +1,194 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521071303+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521071303+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 13 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2307
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/u')p0`0it#uP=DV/jkI(+JLIMPo@7fM>4Gl!!sVJ:Ct]T&OHa&JfAEn[[A;mXgr5mp)'\hBR@2m!B1=D-j!1#]A5;c79&ne3#t`Ul,Wt5:FI0G6c528X5_bk+bZ3GX,dGEJIoN"#3V`[`+rL\b4Cfr6aA[[>'e,;J(PX-0;Uag\NZ])A4B*@V-2^"3\(8/BO:n9Njp+2Z\\?Ng)>,0hFP9%*p)d\0+ZcALWp;Alr6VA+S@8,T*:D0<Mc3n-B17s1Vn1C"=\VCaL,@R$9r]\FC-A>!oqfrk`E_U!/"3'YoX0f=_"XYFAE8I1pq+*]i=[3N@TLJaH9XSfo9dGrlQc+G"W#0Sft\=RN-lH)n_g9+!S5L90D+"ik*2PO1!'MR1,shM\[$,YPW6)pKZgmo$a[;1$^_ljLb+u+;*2r.ZTX.$(s\XHR-iiq)XiA+%Ykr?^:X3s_t:eVSMLL@c%K!CL$]N@i\lglYT*U,gSG9K2cN0!A]VC@^?TR%;)cZ*ZTZ<OgT\-@*o<[^?J,9o])hMk08'tO@;nVA7#710R$g_h%HH=g,;9-#a-'`H%(mES/j`XN'LG76_eA8QNTUdVGs)]WWXoV>\.:nr/<[ju!TX>g0,N!AdW(\FNT*mT(]?a*Q\[[PQhdBW!nbk]$j's!_1oQDE'*$:_jfW8?9fsr-nHsVXk%Q6<P&CB[/]H>!lm&3QjrFJ)LC1"N%]Wd;gPBB"@"Z=58@D(@m<^Zlf-hBr2YeOFh'%F\)OCF)cjJ)ehgab3i=eV-!=JQ%pAC%"ru-4]r[f56Ap:3Z,PsM+3:@^BmMtlGssM`)+#SI>S]=\g&RP[E=$AE>Gfr9/h^n+egI:h0n)cbNuM8*YP*C?3e`sYnEPISkLp6Oe**F=U[tKo#=W_)JuBt"&/-)>*@>)0>/2f:O!bkAM<aj]6PSg]VBeeN-8umD`T4g4D*s>7n#CN1Q#=r-JNBH0@2@O^TgGpf!QeMe]I-g>8(JhAK\e<>"t@C+a"Zs/[,8OD1SHW$pM7pB+*BcZAIFE@UcA&VAE8@n=k%'6N`C^_qBIh#.Xa(#^jX.,^'4?S31&t;oDZso:b6_>30'-aD7ZH]Vd>Lf8Y"7tY">P\8%bD>\H\JaA3<_Y^(%*]+e)o2U.'G*n!]eKR'S3t&3)jK!EIR1laMSj<rWBecrE7*ZIJ"/S!@K%!T[^X)kd7d[%tdD!o"c/po<V(Z+u<f<9NW4Z/>eCKrBRjN<N)^89g8CZpo)p*m4-W00m'u[+W1I4sY!@B^VicT2aVtPIprWm.".u'$'_)"dum"I;B^/c\;'.)i;%l@hi*g,Z&IQSiHkIU*&G3Lg)=*HLQ:KH*:bffU=P!]IPHiMZV;9(<SFU8*8s,#(auMJEn\L0ICoQCKDk:D;KXMn=--SQ/E[JJL=T)YH6\)C9T$_h0gS7?dQF`q*-+A.p<Yk%lhfI;U(=0_gCt`Nit%[H<JS:Q$W\/8FiD0clui@hp?(`J$oU."tsUc0C)aF*@r#s8R-fZP,)33JW@2$nL;!-7VA?%lV2\sK.\WgC/-K!o'"U+(AEO4\$u,_Mma/S]ZCXXp35^e?c1_>Yj)s\"T7>6YuuC%!J:hiJi9%B0-<$\YIVB\_(/[_=%Di,l6sA?Zg3mm2Ks9mW\'B/bIC:tW`,^[@Y*TVQE[0P@t+XE&<)iNNTY;OcQ:r((Yk%S3r[#!H>eC[9^e<Z<Kp9THnn$^j)OlXYJ"UUPM\@'iMXd!K2!3LV0[E`D;g3Fkcs$W;M;-^!]*Rp38d[5gLjm13aR:F@.$M$NN3)$c,%ZA^8nHZ)>AA8lqsfDK,^!SMMT(BL,UH1q/%33=+2`G.(VuN"5<pV^1$p`<,+,2HSF="19R<s)gJQ%\%]>KJ%a;g>`+u6j*,dglAEIM=`[AKcHRVB8kL,URW9D##cI>RMbP)%=]HOah0hf'Joq[XY*:Ljd:cKd06EJDl(U(q36eU&^uH.Rn=:AjW_>mgZpZ.008^CZZS\iKLUZ<q5<LJiib6Q<Bd5-rD5d=#g>Pb#@t$P0;4Q(h3#u#*MfF<tL?;2bUUO6ifZ1%Zd<YDP1Q0W#)1<mBQR+Wc,m[8S"Ljq/VIbmZ>:R4Cb;;E1ftA,Kmh^G_nr%/D>>mhmX?c`7;?B9%6ckjik!c9LEV/Pq$R%%4]a+(Zmf`O9"T)VWHZ-32N4SQSO6s]2W@gZ!Z"FX4FE4mtqj1Sq?Y7oN,HT;QhY)Gra8;LcmI%VO55;KLGrIb955t3;mOr3q]r6B$Nrc_a2\`;g-dOl*-[Qs/)GTnt^Xi)Uk`GCCnUVR8~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 627
|
||||
>>
|
||||
stream
|
||||
Gar>C?#SFN'Rf.GS<lhg;I/&I=!$UlDJ4i(.IB$-&q]%.W^'Z!nb[O$KTDEYZ$g->j5Y3A!2h0t52-,L.IJ8]KLGY@TpS/-6';`W%@B!#.Ld.R*lSaCNl$E`4kN%>AmuVc3,Y.j$H`\;>jNR^cWXaSH+XRk!mDR#SH<k!)eZ>E,A*aIUD;7t`1QXJIP0!*RH`B:c0cl3g*-;cSPUQM\Bn8$h;WVSoK(!QEp`1/;jISnR`]94#(NJV(lduRFe.-D?f@J=pnHP^rN>sJoH!0MS99CdUgc)[p*9RTf+8O-./0cWEIsc6f(j1aH%GW^\mCLVZ"$OM7J3>8S#*#>1lGr(8V=*1<oj-#).dZYXQaih-n$30F;0="r?H@n26q9gb_qB-VUDD!LmD[qRV.jX6d[.,PTNhK5g!ce[*8CV?-c`H))h7I`,9PSGH7dVO=+Kf[>N'>:.Dhh'Q4j/g"fNP=b*u5Fd6jXb$2'lP>dO"$5NQK.UEgK4r`c4>&3"[P1N)%@&!8akHS3GOu=`_D;i?gWnf?*QSsM;SuLL/c'%=)4<Mhfo:O,Hj>FY!aV<4bG<2A04A<b%kJPRYgQIY6?QFN:=6D)pAWa_Lr/Q1Z8[-<XgG&C5!^GA0~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2004
|
||||
>>
|
||||
stream
|
||||
Gb"/&=``=U&:W67f]jc+lG*B*n5(K,SMZbGof-ikAD0Kj%8FYS*F9n_3(bOt]aKpR.-G"0P.m/E8?ID\Qb2,r?pO7;g&FVRn8J_i:Dbbqdn=)##ah4!j6>e#LiAsk63?[Dl9u[`jI$D@X&/oG-[ruY"sFFH(^LZr,@MFC^i<]mYQ/DAQZkq.8Dqr>'L4u3rQtRX^IN[s;7.OppS6L?48V?S?:=_:3]+2A75.u_\sIH9Sfj9>5f,@5^!in*O5ZGr8i=_r/2MOJCR$G.bW$-kkkO"3e"ht($i!l<;-@&`WmLVs]nYcWUT9/'\0*mH_(P_DC4LS[\$B2eZ%G5uYL4U!YD;d4qK2o&o?Q!=L;`[sJFWE/dmkH?-hN'FI)nf_P4m;FZ4RTQV'>9JGhB,2KEXR.9B9\Z13,-t?m0"ab)%<I(J/Su*c'@e"#b`6%GX'Y,DP*]m?c<';5Y]XdB+-$IW.c"S)%lPhE2Z-8![6=V[`[b'>knb@%W)m7=&BPPjjcS%iF;PTaYej6n^0G[;N^,n)=-Z59g#8I)1YM&TW(#s'3SZ)K)Nn^8g1N\5ApZ$^D/56AgRqmeN.[N$:)jOh8*@Y_9g!,J7$IO$jPd)<bVa8;,K/gko@*qLf1ooHiI.9ep`E-5iLT1(32S*jX;i?j"]MC_o)>S-5Oo*t0NK0^O3^[pX<YbKqHg^'!3Fg?me3c1^[k[.#3D;59am778?F3-#Jm(^7XX5tbr-CE'-E@0LCF>UR^?XfA\X%F"N0>o+Ip_^#P%A>'"O"h<kB]kUN;`Yh`uR`G(>?E*V6!$%8Wq,2<RY?)(?[?jp42&%A9Xg2aC-:/Nl.,;TpX5*"?=r<45-Nthc#?.ej"=p+TY+)`,J;(Rn=.+e)04*M'9WW%1dZ91q@E:OOf(k5LXO4=1=,i73b/"kTc"q=,s(+S9&7ZrkLpR%%^:[W?id65Nf?Z,@4;)n/[n.TQ>1K7?LFQJQGlsIK-.&T$<Mt.+?j=Zq1/4su^oGW,A.@RQb"F-5<K*V&5#OBPq!R!bpTgkDc8*`m`!'VPR^BRk9'^f"B)?P2l6R]KQ+V.C*sN)fongV-oRH'#VO^A/Mp<X&c8ERtW<&NZlLaq4.Y\dkg$BRG\uuEGA?_Jm0C?\LNEKpoqcdFD?C"n;C86iW>&1M&W/G^iGfZ/)C&B=&erq*n5%t2<q$a1$i4j3'8-W:[q@1pf=A^?@ap-Y=3b0$e$_5-l;"qd`:a_r@0Y5g-N>ngOgp.&AgduQcR,C,p5f*c9XNMU'EB5/>JN3ZB.3l8/o$/Dk.i1Pa4Ml+k4h1sJf$d,lMGq2Uql/faZGXr)^$B8A5=AS2JSr:+N3_BD&P6+^)E')'rf/^#_Z][LXJ%p]]rS`_DVG_&]HO26s.%B\Sio1rIPa9=G'<NIkksuKfcMU!%CB=l6:*g^s"3<Zl-n,WDiao:`:\M`kqdrTQ^]cFVfKS4N0B6tlE(M,lca37\8NfSgNu/XO&@08[$c@YGRCP$8uH5TJaqWoHA1/iQHCBb/9_h"dq3UZ^do3Km]>[^^5&RVX%I(]*h(e@-*S<iMe]*7Z*EH]"Q#:3i`[WFqtKJ;B1.`^YnNV[#!U9)LX6->\_COf]u:RNWsQ_h\k'Wq)C2im,7HPeq8/HA%LkHHj85Tuj-r$YE2drbSF)<&>$&u0n1_"CU2;XU[/o>p]7:m9sBHNN._JT:(_>[S8kSt8^_]dg5jKe2NV?`J@El=PW>5(6HH(bL/S\usO"+lfT>q'e414uAJe(d8?WkT9o<RCuj5J:9Y2HJ6=d]j)*=mFE/J^!LJ7SG+Bm3B'*IIXL_<r93M",h^e?"kq.Sq>g*]mC]8H3^\APBu!ukXLg@egFWMQIVZCL:hp[/fPq:Y-71d[iNf^EfGh>(kuD"k4OUq@"-U\>@dV^5&&l-)JAHd/%KW+b0]+6S'>,@D3CIN&I'O7fRjqek8^"XFfBjP"3nL':%j&NEQX=edJ3aD6a8HOXVfs!V"12sOJH~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1601
|
||||
>>
|
||||
stream
|
||||
Gb!#ZD/\/e&H8h>EUY?RE#Fh5hE5mG[^BeWTZnCa5Z&<u.l4G6]p5>@Udb:[Nfk^ADE>bS0[)FKlca_DGgDK_),P^eqE)t&I[S%21BrfUlk5[.!bSA"FQRso#krTe4<SN4h1u/i>l+SNjlG@q`/f/]+J]#E!s'TV,RgFZ3;NdZ$U?5PI,lt(JPUnp!"#(VL]2Ms0(CsBd"6MV+78p.o*]D]&b4K+@@GFG_/KT_0<.B^"ej-J4IB\6Ie!!R#s9lD<#%'E,)+W<X*A>dc?TE1-]a=h.]JA1^i/LAV+$fOg`4,r,Kq4^=<%$e$c4,#Cu_.f$CD2\']h[)%f/k*-+O/`Jb:DA[0?0t3n_=A8a'$&I+2__k-M`X$Gm"J\Y9O7[MEATj8oE40Y"LS<iC@tH'Au^.(@"uUFbtuTl+\6[L>/e.H)?%,KLi:":_u6/TjHPX^c;7>/2LbcsY<8[W>oUD\UKR#X_ocFZ+er7^ej+E6eDo;O9[^6)ac+FVkG%E8tljK@)k`m*Iqa'HankfDb_`qOb%!FQuo8mtR.GB.cS0%?uP3VVGQ0+q\JC#>Q][1odMoX.-EA#3$i1%!9-FWL96:^YRdf/J8FYiZUB@'#4R<=XU%;ML5c+k>J0jK'OUTeKV9STKZeGCRKfQ3P9f`$:RNUPCXR%:V8(ilPN3"Y0;1(<108n=&OG@W+(C9_Fg`W:`5hb=7bt[J=slAE&TuuMW'K)'A"tgNJ^e,9gMghGmnJ1MZZb(L7t&\*(^OlI%`'-KiOCFA7FIpR<U_6O-[eMeb&?-Q3f;:3_=6]>\&WT`o)S/UWHmlJNS]=l9YM^at/GS<r_OJq/!:B4=!e^M[f\4G(A#k'Y_EIf-3.ul9(ZoQ>2)7Z7OHMQBpCFO&_='i,0qC$O@4*AqD+^`U+@g5C`K<9iZ4Z45=Ju,L#m?'*Z1O?$oNU<pCl0<CKl99kMFa:/9\Q*1rd,r:0FH5^JQNS#L>.k(QLho&&EqI_ZuM#M0V;5/$i4X8*>>oqlYud,h1?GOY`D_m#\l>"BmMQGW>>Q4r$B")u+%^)M_eBQ6Bt!a8i\GcT=n8)t\=Img[AEm[WA3eGam+GB'D^u`4iTp8dF*9qr/Bk'll+/Hcf;Y)W6TFt(9\*Q?[N^pEUjE6!H5&<_NH`,`plB)?@Val)l?R;o+.oi4A&!T<(n@IShhZ#k?4LA#!95MbCO)i(LW0]_B]UmWr?YcY&YC5WrYI2^uN:sOUC\bl;:"p(\lF0(7"80ut3Tno(8,n>?QP5^2h>S7^Vm7)W,0/a<COngOn[Q!+TL?XO.Ob9FdgB$u8pi=F]"+[.5M")m"<QLm9'P)IS_[)V!NK.ca^#I@-$XSf]D;3MC;_W')UEnk-af8=-4aJ?Y#`H$Y7X-9IBUU:0r`m1<kEsBLXiWX[`CYj[^3*5pY9q,Q7"7_PIAC?]E*F.X3LA7<\)>rPh(PC]2#r;<@L/R9'n4=jAHd3?DC>tPgo6o6J%^9)U/fde6p3enk*UPYkUA()IY.]er>K6<+-<5l5p:REf$UA<Q"BO8m312bs4%ss4D\qODb&"R%)g#i75*`s(#Og&DMQ^'T??-6iWX=%*i4>!Sq2~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1809
|
||||
>>
|
||||
stream
|
||||
Gb"/%bAu>q']&@2DS52S)%-L2&-S7igH:qU*D_j<9!Zhg0Ek+V.?Z#$4)$&AhTIk>GX?&,DIRrD&d$?ApPHe<i\.A3LKf;,$^eV8^c3mjJI*N.E<:V@S1BA6aaN+KE><Tu%O?#:+3<&/!hLYmhuNpKIA2)i68>36TRc&_he,.6#0n;O%#?R9Y65EU4K7CCk?T8l8(3'7;?jVV2uHWH0-IQ/LG\<]"N)7]EN2BK"ej034]lN/o((>CA,%Q[Rj-X`BaeosDShBe-0IHS3M&D`MQ!ak"jbH$M:69V&pjnB$WoMBD%5t<(PE%1ho@r9"Y^b^,L'9"m\!qQFd-S/#$Cn5>[&o()mVY-`-&&?q=nRK*>a@'>TnfSXi!;"5B^hI_gOkN:kLX.RU(M!?h305fBrs9D@I\T@]Y7JQ3,hXGgKm2XPVD<BGWu\fKR=c_f&]X-1-4rB-hY5cQ^AqNb!4@F(=-Rk\QchY0,W_rNd;OrDWqOM/#XnO>]Q7=,ac,&D'pRWmW4!Gp07!oBjj)^9$f+_S=),S9QU#g>0Y2[5nDnWNnD4'WfN(PXN9Rh)9_HRq/TQA3dc<.,,@!^\s>9TU"*.*i=&jQNS[UF>hUE<FUm^Su"Mc$-si&5\3rJN1r8`\D:e'McC3qV\<B`]6/eqf1k/)`K!d%4P2<0P<fg2W'=Nam@M\hk6L0<.)d^8DG;8"TU[bp39RU(e1p/f&?:4/bKs=*n29ULBQ'rQ7+M0"H`kWhhOFN7c:W1%W3S\W.`?i?S8kWG8r93,Q+9Jnc-kCaB3=)23_@f%.[D<pOd&"%GDSI8i!Z(<knXbNFR\M^dF:K=V20okg4rEZPN\rtq-?M>@Wa]3QU`3IM7'$pZ+Vl)Vafk.jp3?B?K&+EaB;l>2l><Vg*/>2IMJVIUo6nj2"HW3*li['2&5_f1)-TW@?EoSZ)KhP0dPmn5G63Ak7In"F/E%[3IktX1@`#6gi@G>cKnN+dk,b4_=I)/":I_)i(MI*'q[1JgGsZVgV?t6c^:M<4Os-'K<5LL9n!C00&SdMf60>eK],JqBhas,G\Z`^JZYp10?`!YYte[eEMjkJmGcdN@X1L6:oQsPoP3Z]^n9a?haNl[^6)Wg6^gNd=PLoUhutb,93XPsO4P.A%C\+.V_4,k<A%d&XV5L.m@0;K"<V90lNs*3-,(L@)K,-0ik1.7U%'bMf%*=4)A!K7HY)fb%GG'TA-?'?6r`3?K_>s<elWG_K_n(`E1DUOY)(:^AhH!GXrTq(7CpZ01Zj5NPEJ0-G3'*m8Ct%`Q?O\U(EH'kbU6(gK9^S$qF4]!^p$9HWm66V3O?eRD)PS</+R,EG$UI00'B76h$RY@T8"H(jZ(IWC"N'39saR5PK\f7DE2-I[(4o:''II/nBolO.[XH7a\R?X,P@?%,P77;o[W8;aO?\;A$pgfl>#:i23lImREhO041T!hanlAq-@7KA;dfPg!c)-fFCJq7C$"dq=813;NC_I9V^-re>nYj>:#P]Fe&??jS#\Q^0ipHLoTk7dVG@C'$0PqQHThP'c9J!YO$V[eW4Y6ra@24<bK9`s*)fe_$`REJ#B5<_&c?9^D%2@Oa=qq.S[SaAXUXpQ]p2N6ln2'f84A:"9]Of.UkZ_lnAguY8@8)LPV6P;D5`"XJF\RI3eZdXCitIeSeO[+@IbK>$DY9-U_#d!4GRU'c^r.U&!ki<1VE7?<C+3_hca&=]I0U?N<@O[cbY>Nn4OHmk[TeCCpd;,N=[.GRKaK7AFsIUMOaOm7Mgt4Gn1r1pW;2BnT$k#\!5!Z.IS^I^"EqAInc#i5l~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2065
|
||||
>>
|
||||
stream
|
||||
Gb".=gJZcqoNbgDL51MZj(3GBP#c\%*Lc+W_?9omD.]0W..Ui#F[M<dS@8ISZaq)l<&,-]-.j@)B[?Xg$Nl8.<rVb:J7$@$pQ>R;0YRZ:Lk,"-]GMSH#2qIaKE];Ye9!\^f@3$H9oTu@S(K?;)+a$(km.i`&Lgul*:Y#(3KP]"HtG&TN_LNCJ4O;*d_:KT&t<rQ/VmummTo`o5,lMBM-Wc0@THh]L+?B.<(kaUKJL`U?l[#N]ArKMGb%N.mU%83#eq1DnuiD1i7rm9_pLcA#C3!in=*U[0M*K((JeIe%HD)i=-()RJoFdNX7VHP%a5'g,j?TG>1+eT1l+<!HaMY)>oR62YD%+t,c4-RoZ)SR2bZ&g1kdMtL^\0`lF)D##'U?L6M=eleuJp^Nm7G4X'+I%/IZQbA5&2%5q]<]/SJjfXBO'[ciAG-Ydk8RL2U5hP.$$WbJcDR-Uih%XcPD\X%#m+hmbGa\C."lrHg2?r0.4u'3U)]cY\UR/f?U!+^J#MZ?2T&q1Z&qb]g6RqK>Hf-2]rT_26-9N856FB8)\(;h`(jjTPcA'Io"p-GlI<Y[,t;K[)ua^8oYAp<noBoVGRaC>!dN2%sBp.t#oh&iRSuXFcL1<!+qb]iIF$"s9^%iK]jo+WRE&p(eD^6;ifqJ_H9;*!CE\&D=/TN>?-sP">1tA/J@DL8p(Rgb:U.'F9-7@HL*`JnU'02(]hRZBL;l;Ud+cSDESdi%i,#X\79bQ:kK.\D:K!kL2DM@%nEh?H,BHD#a07k5W9@QGA_pPlLbi[N4?]gCepAgY-mIfgI=L%]^DR'!r5;q"CD(Ig_qj1"u0OE\%KSfVVSI!Ip\p0_uGXNmG^a<>=]shHlTE??har`P+nmh=K'&02oFP1B.\60%RM'L:1e/a5jlW%PW2<43s,#^m6hr02o7h?CHV"mhJ,$mp5*J]JE*AUDn=$D+IF[a^7+gF-S>$p0m+pYCJ:BFc6>1T2b$ndk&##@DC<o'0Bnja`V1TPhWt>r:=H\><bsbreBa.Tes#Jg'7#jRae8KU.^SS,/N]Xb&2>phM4%&a6t-K"dJql/$%dDL&,'(Y_L(K>jSI5`bqBF8QMKV._^[n2G!EsLpnk(.4B/baPg02'1DN?Car(WMYCecC;hA'V[:)OGY3]H=@eWX0"&G)$!SD_gi#;#]EiNmDb,CrR^J[:pGW:.?T1[5Y/&f.]tp&VMDi.ZA&PHa212g^>VO4O(np.TMkR%0Ark0_;jA[j:M0:PHoK;N\nOCX^*N5:AY?=[amtG^8T=M!N6q*@<&5mO?BDHEPSM=K6\R%'C72u#\mUV6:^#6,aj^E=@aEetSfP")l$50_]h_spe^;9@;O,5Bo4GrDC0C9(eEV9A9!&3_0=*2)ZTC1\WfS[=VbkG"E=,E[TE&eX?;TRcI.$k]a*r_WQ@m!Ug@6b\S;u3r[!SQU2NQ#j=kU]4^/7U'hUJtUdWgZWJ!I`$\k*)@p:O;p-5Gu(ID9J!0q!aM($^5i6,(_P@\Yed4km0!#`O5m)<>&%E02F!/;\>7=-.%9Vbfh"EsWLT)\<,7KnsBbDJ(rgU-0U"m8L/"@bhs^l@VH'p<L(^_D3f5fsSSHYep_1MR?%cd,f&N]GE#tF'V"*J6BNG@p2>N<ig!5Q4&>P3aZQd+/`P1k<V5tXhQqm\drcADFnGfE.3'(iT%briQ&D\5/8.jjt_bCA4VRrrD[2;_G\klFqB#1]ipBE,hR)(MQCN7eiNL?6+@%`fatJ.iN6;#AMPpsLM5]/=$OMP8HXZVA!4i_Rbr!lLrHeZE&4^$ENW.#9@!u)#hcMd;,<7u+AHf.^'+I.q6NecXD6d$[G8DcdK4B/]th'4*FMiVl5oX]_1m-#?XU:Wc0FM*=93FFDISYE>+J^Z7d+EO[tIQeZECI(CW548@0l&fYTRL=*C\Ur<eG4B@hf0rZl"X1'ErIVP=4LD-O$Hp5sAgc\&\:]47g:^*gGX6a`o)Hdt:8B1SDqB)uRChdO?J$Ys*.t$FBZFKZ&`OVlpM<_/3D,U+SJ!@gH_:>9YjTI2lb^$M`HXj'RG%Y\!~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 721
|
||||
>>
|
||||
stream
|
||||
GarnSgMWN8'R\fAR$U@5.-oMn%.pAgeYJ$?V+jT]WQGU"P?h@0"`G%Hl['',-&i8Qk$ZLhShg_:D`*JV"h[KIEiu&t,S*^TBI$Y=94kqO_D>Ms!sh,SIdBA=^:V`DB/K`89^K_*OUOYmC?S3F.#K6oN!hufU*Dc?bnNQZA/K$:8-L=qYOK7[T#\Us+ttAC(I./BS@hsk8KGto+"$"gC-'k1e^m-f+:O?2QsY?S]u3@GUcr!?9o3esUMOL]kBS4G0,LO7?hBY)]Ve7LS$o'F*[:E:jN.:8O'd6;Gd.`cnm>6)jb'J.c+A'jrAe.!Fc(.!l\9h9-.YIf7"NkC%phH0-\fI7pEd'LR71-lXg<s`'m++YT-[)^fTO$o`P2Adfp)TD?"&s"D(D5_UN\!'m+D+s@k:F;`5o-&"-JTmj!int3GfU'3U"\k_Y5;)ZNI6!=(`,MN$CNiLcpbdbk%4++W5=IZ-g)>@LRi.C`E77,ZYD3osT/%po:^u3dA3e3XK0(DQ+f;-g.!ZjTtjXEm-b6C"g8>/Tj'rW5rGU0)?s\ILRlO.eLe:4Uc@jhT;6om"'-5XIh$bS#3N*>3,=2d$%bEDa;XW949iP4mqGYkOAqf]W](34@\(&Rs+GS;Ti;.Z(WJoa2liFD5p=Ak$59OXJtg.(3BtbEVeY\3Y>AP]ZJ_WeL@[-r$]X?<V_Q28XX8`#Os"8/uU_HNS.Z7Gm[^~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000001943 00000 n
|
||||
0000002239 00000 n
|
||||
0000002337 00000 n
|
||||
0000004736 00000 n
|
||||
0000005454 00000 n
|
||||
0000007550 00000 n
|
||||
0000009243 00000 n
|
||||
0000011144 00000 n
|
||||
0000013301 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<2116caca3db23abc7cc418256d9c507e><2116caca3db23abc7cc418256d9c507e>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
14113
|
||||
%%EOF
|
||||
474
Les13-Agents/Les13-Lesstof.md
Normal file
474
Les13-Agents/Les13-Lesstof.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Les 13 — Lesstof
|
||||
## Agents — LLMs die tools gebruiken in een loop
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Vorige les:** Les 12 — Tool Calling
|
||||
**Volgende les:** Les 14 — RAG + embeddings
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Wat is een agent](#1-wat-is-een-agent)
|
||||
2. [De agent-loop in detail](#2-de-agent-loop-in-detail)
|
||||
3. [ToolLoopAgent — de v6 abstractie](#3-toolloopagent--de-v6-abstractie)
|
||||
4. [Stop-condities](#4-stop-condities)
|
||||
5. [prepareStep — control per stap](#5-preparestep--control-per-stap)
|
||||
6. [Done-tool pattern](#6-done-tool-pattern)
|
||||
7. [Planning + reflectie patronen](#7-planning--reflectie-patronen)
|
||||
8. [Agent vs tool-call vs workflow](#8-agent-vs-tool-call-vs-workflow)
|
||||
9. [Productie-overwegingen](#9-productie-overwegingen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Wat is een agent
|
||||
|
||||
De definitie van Vercel AI SDK is kort en goed bruikbaar:
|
||||
|
||||
> Agents are LLMs that use tools in a loop to accomplish tasks.
|
||||
|
||||
Drie ingrediënten:
|
||||
|
||||
1. **LLM** — beslist iedere stap wat de volgende actie is. Tool aanroepen of antwoorden in text.
|
||||
2. **Tools** — uitbreidingen van wat het model kan: zoeken, lezen, schrijven, rekenen, anything.
|
||||
3. **Loop** — de runtime die LLM en tools afwisselt. Beheert berichten (context) en stop-condities.
|
||||
|
||||
Het verschil met Les 12 zit niet in *of* je een loop hebt, maar in **hoe lang die loop draait** en **hoeveel autonomie** het model krijgt. In Les 12 deden we maximaal 5 stappen — kort en gecontroleerd. In Les 13 gaan we naar 20-50 stappen, en het model bepaalt zelf welke tools het in welke volgorde gebruikt.
|
||||
|
||||
### Voorbeelden van agent-taken
|
||||
|
||||
- **Research agent** — gegeven een vraag, zoek op het web, lees relevante pagina's, schrijf een rapport
|
||||
- **Coding agent** — gegeven een bugreport, lees code, draai tests, voer fix door
|
||||
- **Planner** — gegeven beschikbaarheid + voorkeuren, plan een vakantie
|
||||
- **Customer support triage** — gegeven een ticket, classify, gather context, draft response
|
||||
|
||||
In al deze gevallen is de *volgorde* van stappen niet vooraf bekend. Daarom heeft een agent meer ruimte nodig dan een simpele tool-call.
|
||||
|
||||
---
|
||||
|
||||
## 2. De agent-loop in detail
|
||||
|
||||
Wat gebeurt er per iteratie?
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ STAP N: │
|
||||
│ │
|
||||
│ 1. Stuur huidige messages naar LLM │
|
||||
│ 2. LLM antwoordt met: │
|
||||
│ a) text-only? → loop stopt │
|
||||
│ b) tool-call(s)? → ga door │
|
||||
│ 3. Execute(s) de tools (parallel) │
|
||||
│ 4. Voeg tool-result(s) toe aan msgs │
|
||||
│ 5. Check stopWhen-conditie │
|
||||
│ 6. Indien niet gestopt → STAP N+1 │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
De loop stopt in vier gevallen:
|
||||
|
||||
- **Natural finish** — model genereert text in plaats van tool-call
|
||||
- **stopWhen voldaan** — bijv. `stepCountIs(20)`
|
||||
- **Tool zonder execute aangeroepen** — done-pattern
|
||||
- **Tool needs approval** — agent vraagt user-confirmation (advanced)
|
||||
|
||||
> **Default:** `stepCountIs(20)`. Dit is een veiligheidsgrens om runaway loops te voorkomen.
|
||||
|
||||
---
|
||||
|
||||
## 3. ToolLoopAgent — de v6 abstractie
|
||||
|
||||
In AI SDK v3-v5 schreef je de loop vaak zelf met `streamText` + `stopWhen`. In v6 is er een dedicated class: `ToolLoopAgent`.
|
||||
|
||||
### Het basis-pattern
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const researchAgent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
system: "Je bent een research-assistent. Plan je werk, zoek, lees, vat samen.",
|
||||
tools: {
|
||||
webSearch,
|
||||
readPage,
|
||||
saveReport,
|
||||
},
|
||||
stopWhen: stepCountIs(30),
|
||||
});
|
||||
|
||||
const result = await researchAgent.generate({
|
||||
prompt: "Schrijf een rapport over AI in de Nederlandse bouwsector",
|
||||
});
|
||||
|
||||
console.log(result.text); // finale antwoord
|
||||
console.log(result.steps); // array van alle stappen
|
||||
console.log(result.steps[0].toolCalls); // welke tools werden aangeroepen
|
||||
console.log(result.steps[0].toolResults); // resultaten daarvan
|
||||
```
|
||||
|
||||
### Waarom ToolLoopAgent?
|
||||
|
||||
- **Minder boilerplate** — geen handmatige while-loop, geen message-array management
|
||||
- **Herbruikbaar** — definieer agent 1x, gebruik in API-routes / scripts / queues
|
||||
- **Single source of config** — system prompt, tools, model, stop-conditie op één plek
|
||||
|
||||
### streaming vs generate
|
||||
|
||||
```typescript
|
||||
// Eenmalig antwoord
|
||||
const result = await agent.generate({ prompt });
|
||||
|
||||
// Voor chat-UI: streaming
|
||||
const result = agent.stream({ prompt });
|
||||
return result.toUIMessageStreamResponse(); // streamt steps naar client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Stop-condities
|
||||
|
||||
De `stopWhen` parameter bepaalt wanneer de agent klaar is. Vier opties:
|
||||
|
||||
### 4.1 `stepCountIs(N)` — maximum aantal stappen
|
||||
|
||||
```typescript
|
||||
stopWhen: stepCountIs(50) // stop na 50 stappen
|
||||
```
|
||||
|
||||
Bruikbaar als veiligheidsgrens. Default is `stepCountIs(20)`.
|
||||
|
||||
### 4.2 `hasToolCall(toolName)` — stop bij specifieke tool
|
||||
|
||||
```typescript
|
||||
stopWhen: hasToolCall("saveReport")
|
||||
```
|
||||
|
||||
Stop zodra een bepaalde tool is aangeroepen. Handig voor "doe het werk en sla op, dan klaar".
|
||||
|
||||
### 4.3 `isLoopFinished()` — onbeperkt
|
||||
|
||||
```typescript
|
||||
stopWhen: isLoopFinished() // GEEN limit — agent stopt alleen natuurlijk
|
||||
```
|
||||
|
||||
> **Waarschuwing:** zonder limiet kan agent oneindig doorgaan. Alleen gebruiken met andere safeguards (cost limit, timeout).
|
||||
|
||||
### 4.4 Combineren — array van condities
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(50), // max 50 stappen, of
|
||||
hasToolCall("submit"), // submit aangeroepen, of
|
||||
]
|
||||
```
|
||||
|
||||
Stop zodra één conditie waar is.
|
||||
|
||||
### 4.5 Custom stop-condition
|
||||
|
||||
```typescript
|
||||
import { StopCondition, ToolSet } from "ai";
|
||||
|
||||
const tools = { webSearch, readPage, saveReport } satisfies ToolSet;
|
||||
|
||||
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const tokens = steps.reduce(
|
||||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||||
0
|
||||
);
|
||||
return tokens > 50_000; // stop bij 50k tokens
|
||||
};
|
||||
|
||||
new ToolLoopAgent({
|
||||
tools,
|
||||
stopWhen: [stepCountIs(30), budgetExceeded],
|
||||
});
|
||||
```
|
||||
|
||||
De callback krijgt alle `steps` tot nu toe. Je kunt op alles stoppen wat je kunt afleiden uit de step-historie.
|
||||
|
||||
---
|
||||
|
||||
## 5. prepareStep — control per stap
|
||||
|
||||
`prepareStep` is een async callback die VOOR elke stap draait. Je kunt dynamisch het model, de tools, de messages of de toolChoice aanpassen.
|
||||
|
||||
### 5.1 Signature
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({
|
||||
model, // huidig model
|
||||
stepNumber, // 0-indexed
|
||||
steps, // alle vorige steps
|
||||
messages, // berichten die naar model gaan
|
||||
}) => {
|
||||
// return iets — of {} voor "geen wijziging"
|
||||
return {
|
||||
model?: ...,
|
||||
messages?: ...,
|
||||
activeTools?: [...],
|
||||
toolChoice?: ...,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Dynamic model selection
|
||||
|
||||
Begin goedkoop, switch naar duur model voor complex reasoning:
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
if (stepNumber > 2 && messages.length > 10) {
|
||||
return { model: "anthropic/claude-sonnet-4.5" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Tool-filtering per fase
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 3) {
|
||||
return { activeTools: ["webSearch"], toolChoice: "required" };
|
||||
}
|
||||
if (stepNumber <= 6) {
|
||||
return { activeTools: ["readPage"] };
|
||||
}
|
||||
return { activeTools: ["saveReport", "done"], toolChoice: "required" };
|
||||
}
|
||||
```
|
||||
|
||||
Forceer een research → read → save flow zonder dat agent kan 'cheaten'.
|
||||
|
||||
### 5.4 Context-trimming
|
||||
|
||||
Lange agents genereren veel berichten. Token-budget bewaken:
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ messages }) => {
|
||||
if (messages.length > 20) {
|
||||
return {
|
||||
messages: [
|
||||
messages[0], // system
|
||||
...messages.slice(-10),// laatste 10
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Done-tool pattern
|
||||
|
||||
Soms wil je dat agent ALTIJD via een tool stopt — bijvoorbeeld omdat je het uiteindelijke antwoord gestructureerd wilt.
|
||||
|
||||
```typescript
|
||||
const tools = {
|
||||
webSearch,
|
||||
readPage,
|
||||
saveReport,
|
||||
done: tool({
|
||||
description: "Roep aan wanneer onderzoek klaar is en rapport is opgeslagen.",
|
||||
inputSchema: z.object({
|
||||
finalReportId: z.number(),
|
||||
summary: z.string(),
|
||||
}),
|
||||
// GEEN execute — dit signaleert: stop de loop
|
||||
}),
|
||||
};
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools,
|
||||
toolChoice: "required", // model MOET altijd een tool aanroepen
|
||||
});
|
||||
|
||||
const result = await agent.generate({ prompt });
|
||||
|
||||
// Het finale antwoord zit in de done-tool call:
|
||||
const doneCall = result.staticToolCalls[0];
|
||||
if (doneCall?.toolName === "done") {
|
||||
console.log(doneCall.input.summary);
|
||||
}
|
||||
```
|
||||
|
||||
**Wanneer dit pattern gebruiken:**
|
||||
- Je wilt een gestructureerd eindantwoord (geen vrije text)
|
||||
- Je wilt voorkomen dat agent tussendoor stopt zonder iets op te slaan
|
||||
- Combineren met `toolChoice: "required"` om text-generatie uit te sluiten
|
||||
|
||||
---
|
||||
|
||||
## 7. Planning + reflectie patronen
|
||||
|
||||
### 7.1 Plan-Act-Reflect (ReAct)
|
||||
|
||||
Een populair pattern uit de literatuur:
|
||||
|
||||
1. **Plan** — agent schrijft eerst een plan in text (stap 0)
|
||||
2. **Act** — agent voert plan uit via tools (stap 1-N)
|
||||
3. **Reflect** — agent kijkt terug, vat samen, evalueert (laatste stap)
|
||||
|
||||
In de praktijk komt dit door simpelweg in de system prompt te zetten:
|
||||
|
||||
```
|
||||
1. Begin met een plan in 3-5 bullets.
|
||||
2. Voer het plan uit met tools.
|
||||
3. Aan het eind: schrijf een korte reflectie — wat ging goed, wat zou anders?
|
||||
```
|
||||
|
||||
Het model doet dit dan vanzelf binnen de loop — geen aparte code nodig.
|
||||
|
||||
### 7.2 Sub-agents
|
||||
|
||||
Een agent kan een andere agent aanroepen als tool:
|
||||
|
||||
```typescript
|
||||
const summarizer = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o-mini",
|
||||
system: "Vat één webpagina samen in 5 bullets.",
|
||||
tools: { readPage },
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
const summarizePageTool = tool({
|
||||
description: "Vat een URL samen in bullets",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const result = await summarizer.generate({
|
||||
prompt: `Vat samen: ${url}`,
|
||||
});
|
||||
return { summary: result.text };
|
||||
},
|
||||
});
|
||||
|
||||
const researchAgent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: { webSearch, summarizePage: summarizePageTool, saveReport },
|
||||
});
|
||||
```
|
||||
|
||||
Voordelen:
|
||||
- Token-budget: sub-agent ziet kleinere context
|
||||
- Specialisatie: sub-agent kan kleiner/goedkoper model gebruiken
|
||||
- Modulair: sub-agent te testen los van hoofdagent
|
||||
|
||||
### 7.3 Self-correction
|
||||
|
||||
Geef de agent een tool om eigen tussenresultaat te valideren:
|
||||
|
||||
```typescript
|
||||
const validate = tool({
|
||||
description: "Check of een rapport feitelijk juist is. Returnt issues.",
|
||||
inputSchema: z.object({ reportText: z.string() }),
|
||||
execute: async ({ reportText }) => {
|
||||
// bv. tweede LLM-call als fact-checker
|
||||
return { issues: [...] };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Combineer met system prompt: "Als validate issues teruggeeft, herschrijf en check opnieuw."
|
||||
|
||||
---
|
||||
|
||||
## 8. Agent vs tool-call vs workflow
|
||||
|
||||
Niet elke taak hoort bij een agent. Drie niveaus:
|
||||
|
||||
| Niveau | Wanneer | Voorbeeld |
|
||||
|--------|---------|-----------|
|
||||
| **Plain tool-call** | 1 tool nodig, 1 antwoord | "Wat is het weer in Utrecht?" |
|
||||
| **Multi-step (Les 12)** | 2-5 tools, vraag heeft 1 doel | "Hoeveel jazz acts vs rock?" |
|
||||
| **Agent (Les 13)** | 10-50 stappen, open-ended | "Onderzoek X en schrijf rapport" |
|
||||
| **Workflow (code)** | Reproduceerbaar, audit-trail | Order intake pipeline |
|
||||
|
||||
### Wanneer GEEN agent
|
||||
|
||||
- **Determinisme nodig** — finance, juridisch, medische data
|
||||
- **Latency** — agent gebruikt al snel 30s+
|
||||
- **Voorspelbare kosten** — agent met 50 stappen kan duur uitlopen
|
||||
- **Eenvoudige flow** — een if-statement is genoeg
|
||||
|
||||
### Wanneer WEL agent
|
||||
|
||||
- **Open-ended** — research, planning, debugging
|
||||
- **Volgorde onbekend** — model bepaalt zelf
|
||||
- **Lange ketens** — meerdere bronnen, meerdere stappen
|
||||
- **Latency + kosten acceptabel** — async use cases vooral
|
||||
|
||||
Quote uit AI SDK docs:
|
||||
|
||||
> Agents are flexible and powerful, but non-deterministic. When you need reliable, repeatable outcomes with explicit control flow, use core functions with structured workflow patterns.
|
||||
|
||||
---
|
||||
|
||||
## 9. Productie-overwegingen
|
||||
|
||||
### 9.1 Kosten bewaken
|
||||
|
||||
Eén onbedoelde loop kan tientallen euro's kosten. Altijd:
|
||||
|
||||
- `stepCountIs(N)` als safety cap
|
||||
- Custom `StopCondition` op tokens of dollar-amount
|
||||
- Logging per step (welke tool, hoeveel tokens)
|
||||
|
||||
```typescript
|
||||
const costCap: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const usd = steps.reduce((s, step) =>
|
||||
s + estimateCost(step.usage), 0);
|
||||
return usd > 0.50;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.2 Latency
|
||||
|
||||
Een 20-step agent op gpt-4o duurt 30-60 seconden. Voor user-facing:
|
||||
|
||||
- Stream steps naar client (`agent.stream()` + `toUIMessageStreamResponse()`)
|
||||
- Toon progress (welke stap, welke tool)
|
||||
- Loading state met geschatte tijd
|
||||
|
||||
### 9.3 Observability
|
||||
|
||||
Log per step:
|
||||
- Step number
|
||||
- Tool calls + input
|
||||
- Tool result (truncated)
|
||||
- Token usage
|
||||
- Latency
|
||||
|
||||
Dit is essentieel voor debugging — agents zijn niet-deterministisch, dus een specifieke run is vaak niet exact reproduceerbaar.
|
||||
|
||||
### 9.4 Veiligheid
|
||||
|
||||
- Write-tools (DB inserts, mails, API calls) altijd met explicit user-intent
|
||||
- Confirmation UI voor irreversible acties
|
||||
- Read-only sandbox voor development (Supabase RLS policies open)
|
||||
- Production: tighter RLS + write-tools authenticated
|
||||
|
||||
### 9.5 Testing
|
||||
|
||||
Agents zijn lastig te testen — non-determinisme. Strategieën:
|
||||
|
||||
- **Mock LLM** — test je tools los met fake LLM
|
||||
- **Snapshot tests** — log volledige step-trace, vergelijk met goedgekeurde versie
|
||||
- **Eval suites** — set van vragen + verwachte tool-sequences
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **AI SDK Agents — Overview:** https://ai-sdk.dev/docs/agents/overview
|
||||
- **AI SDK Agents — Loop Control:** https://ai-sdk.dev/docs/agents/loop-control
|
||||
- **AI SDK Agents — Building Agents:** https://ai-sdk.dev/docs/agents/building-agents
|
||||
- **AI SDK Agents — Subagents:** https://ai-sdk.dev/docs/agents/subagents
|
||||
- **AI SDK Workflow patterns:** https://ai-sdk.dev/docs/agents/workflows
|
||||
- **Tavily Web Search API:** https://tavily.com/
|
||||
- **Anthropic — Building effective agents:** https://www.anthropic.com/research/building-effective-agents
|
||||
- **ReAct paper:** https://arxiv.org/abs/2210.03629
|
||||
315
Les13-Agents/Les13-Lesstof.pdf
Normal file
315
Les13-Agents/Les13-Lesstof.pdf
Normal file
@@ -0,0 +1,315 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R /F4 15 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 21 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521071303+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521071303+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 13 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Count 13 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
16 0 R 17 0 R 18 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1870
|
||||
>>
|
||||
stream
|
||||
Gatm;?$Dbt&:N_CbY-8RgR(S7K]lY:\N-/8VQI=UaU8.&AcmhjG..;6G9ekM4!E,>36/I\FD%agk)N,di/=&u-j+]MLK>QG:^6Lp:tKl!IKf3'edor9SGB;m[NgSA0X(XLs$=3&Eq$T`!u=]E`4(HhJh*cO6m.$=52W8%PS+`b/./i3M(`,<"nfC`#Q#O21PVc4gZ(EKhr"8X[!RAK>OTq`jr_EY"`6hRW/bX,lM2LF1H>:'e$i!F9eBokXcuH:YI]tGWeMJ4I(6\V>uXg%eM;l'E[+L_A2:?\_u+G?^X0Cnpo_eV&)H!Z^QK=.#OnLgeY7W.%[Ta.!Zg3H`\ZEPERm1n*GYJ&k?oi1&)?0S0=+p@CJL9_MM&]VK^1"<e*"SVrKor_FTY5VRs6")Hi#g@4$F+H6_cJO<cuEnRE-:/:9E)L2a_mJdMn)qJo7CZhQCc]oIT-t[B_L12ALg(aIQb>3$r>BT#U$)1C#'D?OXJ$h^3r"U?BgRG#`1VS'g$'6-$[.+[$b.NW!e`)U,NN8d4U=\Mj%mJ@2[71&Yi:iRJ1?_0*"j@^3@F"rW@7:47mXi1l3`pq\Y:Rt=aOqH(<)D\RdAKlBLY"$9migIKWb15Dp259S'6in%//!]!nGNAXfq[_d0hEhtO]X'p_.ma<d?+fV%a)mngEftjs30'EGRA,V!$(,:lZ-\VI3_hE5:\(shf]*2n,s18Vjl))Se@r=Sk@%McSmp>HB4nL5>m*_de'q=W#5:oaEjp3$G;'\=?WD):s?Tq1qWeEScO%AdH'h964jnCU5Sn9\lS0!:""l,V.Y[3[#pfSp6mMo](WEEIQ)k#SG+/-WoE@E6XNCoP1MPl;*26fZqkQ#C5F0=Qp2G@&nLG:R)EBtd(O!UTB6C9+RG_h=5OSNqe&RL(5DIop@]/hYAH-^T11`gfs-;^9-Pbqf<(SL/I_op>lO#VE'FBh'8/e8j,`te_`+-nPGT,li#le/DYRfq7"D(8EOUDfj`.J+'A2b!(3]PED%NH*8V]1nDJ]H8>45G6YFdc"$3/[pM/HKWk0*^mMFJ#4g!r_C%Q_uN81IrGR*f/2s7p`%$V^;#3RHA97#i+*'<&-U;hH\dI5bmp1h:tl6>ON1%UReA[CgbB[[Rn>d,6OD9Le>/'q<R^4S^Q.R6P%C_*.W^eFg8s&qku&4;IeE]1h,*!!q-G=l;C<*],=X.5</sS2h`"um,t$L:iD/MpNrl*RkKC;D^,.FK<>HjCDD_?ZrVj$AE""\X,FXqO4Nc*tK[>$&_\.a6p(XHTJ;=F3Tr&m59B@>e9anlU6A[@Y%NBj&)Klb's5c4Qr%<jaoWA$ab;59R(C-0''&ed.E*5o6@K_GYhO'QiSH=W>XYPn<u>_^pWe;mA`tL8m6R4I:Q_4e-_@?8K8!M@_L7/.@6R<OIOuc;L"pW*YfO6?p^G*f##hO3cfD=?YA\)Wj'&!?Q),(F^[T"pTWO*>Q5JP[,9CaGZu\LMD6!1D-Vs_XJS>ct"*29kB^9,!7&4\6%eJ,$"@Xq<7tAi&;a-,!7>?%ccAsd9g`P8^BJ(2F"<GUR@JE9!.,D"hE)1]T3P=:9Hi]&`NYVT+CZ,C-V!Y9A6"M"<a2nk&)-?W[TO'4k.r$6+2'K5kK2Xb1o@V5&c-jR#Y_^$6Ijdq-i1PO?0e03[LNOO"ab'LLsH)AJhX`$1P[oiX'Y2_6glm)_i.0S=D;>[r"_IHil8[s"D\<,a&2:R'g.P:%aQ>k;j_IA2TC".`1@!ADK*&OH'18b]lIfc#_[:6KbNY":3@#,--guF2rN&Kc0AuAL`?imYZL#T[CnQNtIVDb`mf=r&dUTljSq]umm*Co<&-JLo@f%c>%q@@e`3r&~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1565
|
||||
>>
|
||||
stream
|
||||
Gb!;cfkq`f'RcUW\-NXF0l2U*m!Ws@"uYDK8Y[UcI\C@%M:$!++mGj!qHe1XNNFK7$3RPD%7u\;G<+iL@R0J&r+E8=pgjdc?SPq.VK6,WM-tW5^J0>V!T=>\UDQOhd4j`Ko$I>d?3E.(#t]0/1WX6%Z8@.]@jK7g$okC_;jN$$0L#j\O>NYF('2NZS7KaeqN)tmj44g!P9bXj^9]J6k2dlQX@"eu2hKusei>U\L(U+1:1KZo@IiSgmk<YoAg'mRCmk_1Z!%o9$eLoHE.3(UDN$>OQ>e!cA^tVJZF2Doq@TSPDQaY$'QFb\Gn9GKU]D545)a@@/aOF]:pg9e_Kh%om=>o,)4K!4KHX&E(InQKKG^(-N05T?FhhM82M\Oei$k?Vb!ol'lXiS=V$S6)`;Q2M(rier1<(<`0U1V14GEkuAF5o'J:=.V"8d6V0`75R^SCrC*Hik5]!\9Lc<t1lG]70RGnDS0cRJU';ucERj,PL*6F)UmUQdK*GfFt6$us09c@!de-ng7]@=J+oi?o[2-[Z`f0nSN.gB[KqKEYpPar84X+e?V0)#0P-X/Ee3i;$%jHbbMA,uFl#Kq_HaV]&gJL+#c"WDTUuK;s_pf,7489X`aoMJX_5h4gU0UH5/%8BeR,fIh+p5,o))S$ZNh*E%HL(r)s(K_CUU3t#0-LD-V=kHZ^aHm(Mp1Pugj3EChRT.T5SEM20KhSqA<['bG\Yk<9]\b17,6cg70*k>,ib>G;/5BYXf^;fue1'_c#G;-RhF&a`k,[=-j0B(a+CFG[h>efbGF[ebN7/+\Y0$dbf-I9sX!=T8+iTf\="*TO^\1nUI(*`pKBE;,^Cp4GE.%9h+;J(at1Fkha4^a<1cJ.ct;bE2Y.ksgTkP+r^:5.MW7tHN:ETp1>4#ORq(^!bkZn.KOBlY66@G@faXFK(]FGJpsb!u8XFYY`Y]-TrSjK:F*^Fc>WCN@\JoUq=KWa^^:C"V`jGkf#B?tKX5FGXoHJseH_:94Z*PfNi7`<Pal!2YA'VF#$*6PPPgarIbu,u(!a"T;Za(kS`^oDTlf-p,mUQ,k&KLps,P+]q>El_]m]X@.K\pEt(]qi[$*h=%kfWNB>s.$YT5T_GSR`V`IK,:iZaqMq=i,bO+pf"Ph)LSR-Y-h!nObE/`3NB-M&(Oe%t>6F**qSBHj(6H_NgBacc@N=e]XMqkV$W5l.iH2?@;'32GUTFpS65E:4F]1l29*.7C#R_e<5V7XY\t6mJ#<;,,R,Ifm_.I_?qcNVEX#Vf6<djX`;cMQ^3C,PA"hB^NIA9i/HoWVSR/4S'dPQ7sAQ<mtnQ1K%.ou5F4GN,>dI@%5CRdMX^A>OW;.+lH,D@b9UW_/Ie35g3KmB2Z!n,''`a?N218fXjfBVB91_KqJFemC^!9_S0N@UaDio%L'OS,MJC*=9nBe5"t[ZMKjl5oCINW-1YRQ$M1CT7(*^VMT%`VW]oZ/Q6o]NWP_rl"Ds\jc'hnjX^`e.6NX]Y!]=#-/J:@=KDW9&L67q7g<;Z'+epX/9/H#@'ugpX[f'DXhIY2GJ&LJV+5[rr@,3_[Z~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1637
|
||||
>>
|
||||
stream
|
||||
GauHK997gc&AII3bU%cuKc*'C.Ye4B*b^(@RNQKgnO4sJD'cLK9!jfSf6=.uV;8P'RiZ#X(.iM[H17:HaaiB;FoU,)i3sF`H-LkZ=Bu#m7)J4%?EKs:,JmrnV#hhL'aY,.#lfHf'31]7&goeM@&PDA99=l?@hJk`jg8V<)@P/X,6gQV<#Z.^0GU)PNk?JhkGVPs(!"F5q8#8TbtCSi=GLM^56SkACiCks$:uXE>/=qYMmn1bHiq.8)r"uZ\h;qpf@Xp*@*Q6:kAQ[4gU:ddc/GK^g9TT(Cum9Wobgek]ri6ePaj-YmjA%1D1N#/8*fI+*qimN.B4*#%-KdnF9IKs@&TrW#XL>N`Ou;5"9ujO)9Wk?&QbcHL9WMnRSYKYClFilQ6]/EHp4:E3E['9Ye^`X*\%RE3%n,?YQa@U"9ZYp$k432A]4'iRpOMC7"^dVU6?<<G(Pj/E,]=LGnt7lN`5r30E%*dSi[s<@q#Xgba+2b_@>N,(ubmt9XeP[[P!7+@D;]1iPusf6MOi6cNogZDf=RK[#f0e7a&*?pJ=hN>F-,e6YqPl)VEs]&Gk9YP%1H'N\5*]T=*OW`B2g>iG=^h_EPZ,l(fr.BG]6[H&SI.R&],u1U(@1ju223O1Rqth5>N8.jJc,(S$4H/Vj^u<6n(Y3%'X$(diUh.9Qlj`Y'J\&hnnn,Z?WN=:dq5C?g'7Bk(bYklaI'?(QOXnpsQE%e>fB;tD:7"=2eQcr9R08AZ67;H$"dXLa[$HrrO]#B-6+Xtc\>,#E&aH3lj;i.F*2)V?*g]\$?=@C\[a[8<]6".,@(g^f0k'hT[PHT4d5#c*a_>1ZL:&C*FH:lI])`pa#&qkWNcE;#c*elDPER3,T2$6A<7'^j:Hd1K2hi[nV\^afqO7=(AuA[u-Sl\fBsEg&hMG2A9-'o@W&".UM'lC6$.c?:n/JK8\;lbu&)&D`YV_bHgU5(?^BglEuhd=Us%=q^U4gJ)U"A(sB8H6'0.]MGT42YWtejLX77##n%L`R\X.]$PlZg1m&+<F0&k5<tIlfqZIghA4ZCCA07:K@eT>(\U\u;1aR\nAEc]/)IZ$^>^.@X@F4VoT?<%f;#Q]95I>VC%lpIZ(R#1e&a"V&7IEjm.P_IIX]cF2:ME'O]Y3i_egpf\dR0.9X(kmi<^cP#G`>E(KJk#4#N4#MuLZ!X)hesDAosg[W,SVq)Ifc1j(AiO]EV[c5LF_R%G1S;:c=JA)oQglS?M&3?=0]iq&9TSa?>EIAkqjG.li+7\\a_S^X4oqP`]b?!mF!k<G?bQlEK;:;1+PCfB*%4VN[bY3^2$3o&JjqqV4.hZ&)g.r4_ldf$nZYr%to\2uafYt<E(K0sDn?O<ooAqrmG]#"N?k*QXHbtl^FrRq$6jfH0Y]!?QG#FAb^(2k+ZK<-WtraLIW4$7&lr?6e[M&j6R.,d8Z>E4N;Y(CKT8F;*t0>S$XS3Rd\d'9.8/3_2W&Y!kVTcPZ#LVhYbR*bl^cHZj,nEDZ,d3DlWGC75:>b\cl$/W<'(M^IjdE[?k\g;OrMq3B\\@BQF'EOT8`;nIDi9]^^mhs_+m[\n3nht(Wb0JXZV\n>UCl>UOCi,<DS&SJ\Q;&V81l!RZRXX_6Um@Mo#t4dp$'TE`~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1844
|
||||
>>
|
||||
stream
|
||||
Gau`S968iG&AIa;m.eq)1,]Pk+94IOKA.Xb[7Qk+8p\m=VBC^%7EQleVoe#7G6'm=b@]2eUlm9k+RncF^GQ+NDs"do%DXVQ3.9)&EL8>/#pNdN&t[@\/!"YkPmDp8Ree04')M;*`QH@HO@4gcA>#nG=pYb/&ZnD0*7$(Q'GLb;O$*\88ZK,tU0/VH]Q;pWP&h<h80DJgqg>02[d&Q72VOjno`r<P#A]:`:jkVX$WI,4%8?croDKA0j==0KlT@u%>ud8:?,<%P"m=?uo<M'4at2JM\Me09h3b9i"LAUmHYd7&n:fdG5kXj@?^_Up&,9((q6Ga'*NbJU!\qK9K0F.',]=As<"n/e$=iV!_`](^UO=;>KT4"S;I=T_WQ,aI<9V.:f?Gp6R$)j,'"!!pY?SmPVAZ^:h,9(+_>lW5T@J9L"knYI'l:'PO5X$g5GT;2mA.ZAmBoLTVGjc\gmKn$4SI?tb:3=k6\"sfk)N)@8T^.;K9.f[\7XM$L:Fr-ZjW9dL^RY!RrA<:n>4#5$,;C:1P1=J[sRm+bd3X]^-,h30#:>Q79$F^^r!mpK]o_CMC>V?1R9"YaY#TVL-kFVa^M>U?2[[%+t?]EMA8g^)<AD`_(n'D_St!]iS"uWK"6bM:`-Nl&&r@KU71T)UeB@-$;`SnD._p/70!?bh4t0PJ2%pZ?6jN$$RJQCZ92pQG:mq,p[*.MHfZSbcea9iMng\aPg#BpUWd'<^NE\hEkmDsGjY9c-^:WX:UcX96;r68?g8Po,ZISt>g_r1f^%9/-;L5=P1/A.-S"cUaVp[Z_fVA/%D!:-l>LK-.'3`8Bb*[[=gf`#DZfBLD9hn,oTof@_M?M"/3QrG+UM",<gqSb5bGWP9fCb=W8J'M`UF]o]KO9I0\]t+>ujZmp^51ghnW*R*'H=;-T_?r^d3diIo!lC%cMKC(kn'gjbAJ6?rqm(Znu:s`HbKb80=[bMKiEni6RLQRsicJP:n/T"aWtNg-#G*HstGZ36J]WMiuQ>[Q5#rdI@JHp:F-md9r2gUE_]'nsb2'X-]rh<n!82-DIRcUTb.gZD+I92FcdG](X-K?9##r7#_u'63^p9?(8=C3WJC^/e&)M6)SUcDuX"1ltdO=q8B3&7jC9mO6P=erp0L08<gBrr'4Z=I5&f!,XQ1hpS5AoD0VhF.(L(7JmYCjL,Bn65S5N;2.gb]3O]_mklNqXnmPZk.G?g!k1E.?7nmeeKZeae;jt5=Mm8A#J4O&3Dfgg('BO;<+*Iq1`al!Dc67IJ8=HaEPM.[c_FBQk!+2Y%Yf)YL2%X?L9qE/ZOU*k[pHdPm-u3XPN`$!4/+etG`b8*)o[#MlWtG3q):,/-JF-WLS%Q4bR.Q]kU"Ff@PXD_*4`9"cb>F4Bj)99NK<H4gn"7qodBpo8,X%POV=%=r-`)@P>h$m,$eTJ_5K*5uOpma"ZS>$GYtZt9Dj,*&QEG$NYG5p=M5k)ti+@rR:@&7*YA-U3oZ,YBb5+m1?^TiYfHhUmUSp.gq8-P5-O--[4rpC?gico?kHCd27D>gA0KR2#W(=@^]t2pcSLGZOaq&U:^[fK8P-&Otk.;fqJH)!E'ebd$@JA9i<3q!u>]\`+li2L-\K<MLDUMm'YYEB_43G=KDm]+aoq,7_Kptuf5.E5=mIK;d,tF"GUppAR\dU=#>Qo.-?&#fLCF-UW1(9UGSHdo-SM%ODDS#18BFdRZ=[./Bggb,qLhA'h6trt'7fM*$jF^tbh'VrP\7(n6#A!aL*,H(g>QaLWW_VDFc^Olu0F&\cFGHTGAiKm-T^Pd.aN]F<G!uTeiTK[,Y=r(CFBp/19t<]lkam4P2]d5D!'+(NJH~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1640
|
||||
>>
|
||||
stream
|
||||
Gb"/&gQ(#H&:N/3$j$QOJqV=:8?+osdV[D8#e!In2F-Rlcna3R<u4Ai2f6=D6X;*cf?'lqd_<XX/;YR3%QS0^L(#JZ=neO(!8f?dlo>0f@<i/Q&dc`_n8&uUN<iTjb8?$h<$WGS,5_]2D,`j'Le;p/^edaaQ66V]`ZtaJNmjG2W#],,MAQo,W&>9E@BhTQep'IpkP/^^@\o6Wis1%Qdn>K8fHJN=LLAbe%?h-t65%FM*Q,15`PqSd]S%*Oehu]pWcImFX^/MR@E#d3V_=Z)D47P?h$1T9f.O9#Ke(o5Yl"ThO8Q=rpel,F,J@Ub=0;QN!"Ve.?1tKefPOYmLhA]NED8qG`<W-+)Bo=7(lJnWGU.6:N0$X]-mB^rr\V"D&_D\6BkD5;%)!op>4Hl24SD@Ad"Wpl0]o&q;hFt2(]q-RI@ic4Ce0]HGb.D6n$;1)%6ua4-&q-,,0,U,EYodn0q3G,m@m?SrnE5-m((_C-XK6c+HK[H(a[rk/q>+J0X9k/->.<AXC(+Z`u2VVn.WZWj*O5!"Ef'Es&3.hT;5JtakFrrGDSR&)FX(<%Z[ULiE/%BBr:?3$k!d?rk(:VmE9+L\6(o\j]L/"Zf4f3[:O,pBNi%*]CLd0BkC]8g@XrH'ISe6*0.^+4*dP$&Df'.&P9*O0*lE?,]Si(E,#`7Ta6o_cWeWG-]LH^i#Tm`KeNouCIAmco'rEjlU:T%(SWgbCSG5GoiPOLAn"7X(;l5jkB)#JIKG[-*V>Tm&s@4R<B,;ATLnM-ls0?$*5sD)(b@X."+ME*GL2On3Y`s*rYQsMgX,t\]lDCsBD/\kHd?p+9mt*cf%/D[o*2@'Q=nsCb;RVRG%F%f'JJhsDrL"l\fBst]*/lf1T0r_<4g++A9PGfMk+^NkhkP]cW?#?cRs%m<Y%EHKLFL4A\P,<F/\j^/GC'0m='0SWc%:]#gK?aE"N0CYD):*(B1Hp[0>_c=M)8Sq8Rte#%W_f\ui"`LC$B,KgdZO_KAl/n6f0TfXVdS\bZYPBTlZ!p5`Wl9=W,!'cqaUZ=OZp\:9r]B?dKWg-<p+XP(//C,+Fc<T:*AAGZs\A3s`W^M*POB`(8K]N'IUm+DR'=cp,\;>O7R)A0jX2S-YH=<9eEf=Ad[2f0__Zb8#MEE%JsV"t7hOQ-M1]s7&Co,?Aej*2G/3XZSlK>Nnhnlnj38knMoT&Nj7j9).IX$7#d*\#&qN48&uU&V#2cP1Qljq3j*I91<(blq+6BHG>"l[H`?4R=##a'`HP">BU,T6:T#7Ohq0&%ds#cUm#?46Hb4P"pb-@s96T)bbA/&KKY=jgGPhIr:mYP7`?Vl9o=U/(SgFmUD1\EeT6V\%#_Z9QaM$0H5,$/Qr[9Vg7]YC$X@L%?"j&Nj*uBK>q6/mD7AC#$-YhC>)u_O%1'>g8o8DTfr(/IJl>;&90h]fVcDuc`:NGPt6%j?.\eR]1n)sgrrVArdPSZRV8R-3O^3oF'Ej8eK.u%Ti5>1>.cMBMr7@(Khc#tHIdR^<AGmnBQM(kIQP5c*<unpDS^T_$>u3'O(%sZ^YuW[F;]B+R!1ERecYE$Wo1*NH/IrH&K&"!IFJ*GrF,W71&l](@DVor$psmQ<&igq(?!Aqo6s22h:t"VoFG?\m6:~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 944
|
||||
>>
|
||||
stream
|
||||
GasIe?#S1G'Re<2\CskCMJ##256fk!`[3$,^fZ*lMGTYND4jHmibkEgs.,X?-7tn!WFG&5pY=eMO3N47_l@pF!5&Ztk_]E$&5I`^+s=OLbL$.N&MCU,!>&U:hf'^X07T'Z]bCN>Uu@&ZVM]%55\NWo2B"R;.E*"^(++eX"/7&@=ocr$OBVXImh<f'Q51a-deoZnnC&[H6Y+(EC"@b:_\K9+</^#L6<Tr_#SZF8IJ;tEhIKPh7'eG6_ag'JB]`^D\UsG=kN>8G(XA'"[=,:NCm!B"oca5CiF,l)"()=/!i3;bIq&jo52]8ICf80b+&1d;"EFV-(,]A"8_t!fKMN#slmVB0UWc7CX;u*89>N_iR.(+9"qj][<K#\Qk+9r,9^G)_7jOd[Wm-?+YDKnM`f)5m4KkF/]qc>i]'O;Dc7,q5[1(ap6?OAkNtI)6?%dB/[&XpeZm.BIO-XRFhsI;17c)@iS5hb\!KO=10U79q7cTVj^6ql//qGHC!eV30[`-?4b8i^]S]_cb=ZVRKS>?;nd3Ekd_$VN7QGC<b5'ZUI41)4i<:)5FGH;-2mRbsJUF[u:3%S(F$$(9tpNLeF8$`6mIPaOkfDu\]#Q)></6DW*iaNOqf,B8<8;U5^[Z;-S`$i/fl@(ab>'KcmZ?C')a4p0tFMAV-9jTP=7I%eI'B%hP'X/MLiT(]Ep<[9+:dlm<4b0pM5AXL1;'D(WNgGkKp@]WVJ1qjs"ppA`YtS!%RKq-s=W&I-!nBCX/p!e5=B@OF"$5cZJF5$uI.Z4J03Fm^7rpdO5+ZH&=eAp-bgZ7Q:*H:f.&D8EU`i'<bq"b'T#H7)ANdK6aW_kU=1U^lm:0_#Rk.d6:[t="#4AYQqaC7MlXj'qk6JUt38Pb=/`*;][CGuoZfs2l.3!Ys/Q6k!%"!HRK5JAOh#k>3.s89>EpJ;N#P)c+NW~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1615
|
||||
>>
|
||||
stream
|
||||
Gau`SD0+/q&H8h>Z/Z?g@%>Erl.uPs%cq7oLES7ZHti@p8/GK!QAhW80HL.>=)TJ>A>1dBJF1;+lLqdEgO5YT0qe;RmJhD=nB2/6Shjao<p.7$&[6QfcW7m&J8iK8O?c-"_h\DO_WD$`3ljT:U*:X50N8^\"UN#%0p)KsITeN:V1\Ad!<]&2eOBNn9IcgOndgT:*2aQ?.MX!#p[OXu]SNLM3Xi@l+;g,HI8V+D'SJ6@-*4Ooo]l)9$i%,3].2YO70\7mV2I8iS8k^jnVcGJ<L$Um9Ale]FBPm']"G`bX1r(cYF00X0*EX8_qW4]kpg'ai)`npk8ipXG+Ib2oE==f>gi^EnNKcD%1,D%YnkqDGS&lOQr#3)?lO&V\t@iF"^D^5KpgT'ksl&s[,P#B*6,pJ\)@j0(i,eW.Dg(e"HOS/DN#*60Z4gdn2h!phIY_Al@Gn+,nB,U7>;UBaPPUJ1!)Nf2F6(rc+>M)an)BRg?)DRo^+@^)5Dj(`^=-i)]EGEP0$:$#j[fPn9]bj>5X3GYOS*B?kN,"*r`<a4'T9T[G/NgD.9Qj",MlJ0!Y@uY(Wg`]=HKh>5?`_fEg;G^Z4jfS[%91m5Wl(H1\WCSAu2q/:2<q&!3RshO^=Q#+'aWmIZL)ett<Uh].$RaD4=1kB<==7poOH/mCZ%A>tm!`P/cO*l-WM9M[Q8)S70h,AZnAXX][$]nEO`8XO3af.QugU$*-]F`+7-oHml,[_=0Gr3Se"knmJRaV`!;Tdom,Nbg;h:rWe0b..t7et&O\>8@K3)op@eAPWmfW1I2:0I@:BNG1\;;QPTC2Ir0*+Um:&Rr4=K^fq@b]P)PeNLAS!,8_HaC8baa*9\5TmltD?rk+D/+[g/NkZ(<78CT7IP8n#=3$c@hY\d1-*W:>m\p=@-pkD3h!"@]9^J7O3F-.g6]!K^*U"Vn<M&lslIeW,:qqdU]%Z)7)H/_#uA-RcGMb9`N9Jm7b8`ns:.+>TiDU-$p8XPZ.<!0n$"!#=Iir8$0QF492AX=pKO_`Q97j6G:1*-"RZb,_r&^7=7[UCQfd]SM*FNCG/c:LGm#\/,Je/.B4q`kEi?LK@$Br5C8CS1Wd]S.U\eAaT)\5"^5W_k8<rsJkabohZ_d37Zt`2IB/MLZpgRhJE_s5h'j7JcJ]+EN]!E5rrOlF-Qa[$C6mDk5$^%[X3:kLD?+c+P^U%p-r1iFtqX2-%hu.A,#1X(<RR5s>?gC"1R<Gd[/Z3eGb!Y4&HXHVD"S/baA1n&@:rpAs#H4J!URE:=,mgpuH28`oSd]g4HL#X<riaMgG+\sBr=$_,b;ZIl#D8Y"Il`#VoJT"Fh9:\ouPiZ.I[b;Z\H#j$PI-]bV^`bjD^.+[p6RanA0q78^&T%iN7B,N$B4r<E):JQWT"^Hl^UC<acKp7dr9I"5qT^aEd=PBOoE%rDF"q)HfkW%2g50?QCl<//aOHOs;o[f?*kok`OgcO/r-fgc`Lq"T\rc.Da3*XCl`Ok5BX$_&rmc-`>\n%Ci#J9pC'k\cso2E-FQ@ng%B+o[DG)LS%7o0]YYO&*4m<1p$0:&h5MVR#sS\IaU)G]okE*G&CJkr93;gho;l#qu*FJ(r3r!.]!/,]~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 742
|
||||
>>
|
||||
stream
|
||||
GasJPgMYJ*&;KZJ.HWCfd`J'k78tagMFnTg@CU#t@@:sFA1&NY86(q!BC,Cm5`$Lmj2q;XcMfV8!M"&THtNVuRfm=^!ec6f&VNU_aW8#efM@7M8uE,knlu>)(fYq"DSan3/D;5!!7)[3Va5Z\h?ORSFNt0t]*OJ\9E7,sS_&Bu)!gQRHA,Bl>$@]+2Z0[qoCI*N!H";HV0VhIN+Rlh;a",pjMEkY8HG"c+2?XKqop=NflFF@iG5iZfs64?PRaj70<WK=<EAWO]T'[9^=a9A?(F:P"-W8%XV"oUM2'H*ahN`[#R*&Le`QCPrKJp8lP08Qo9h`.5ZY=MRNTsjE]#?a$8-oV_trn2Hu0]FLJ\tI#:Ps_mU12[bF/(:((R%P+r7`ODB/3&S;d+q@0CU\7"ac7MQ1)"FDt[4@8`^`+!K/'O>DI5qpF;KJFR'MN:A'ZS0%N3>Q(dqiFg/rXQch_4uC(A7R:]KR%nSHA5p$g5mh>*b<6&BF]5a9/oK-Dr)1N$-)BYO3eR7h)%%j'9"b<'q&-iZK'30C6B;fj\`9AL!j&@"P7E.h>VE*'>_1r=4%8-5Kf-Ff!?H^Uf5?"/(<BJhr#gW@$^Yg^>YB3?:%o+N6%.'$VD,Sn1FSmZS.s#32PAXXb83i?lqJr#<>Ob]$UL@XCMKH<'lh?Gj(.2=UEW1gLZrf82JABI]jh$,pjApb.4I?prqL;$a/"/e[BmJkSQ_g.CXrF'$Nes&[DG.!~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1781
|
||||
>>
|
||||
stream
|
||||
GauHK9lo&I&A@[:m._i=C`!.<!d)K3E>,6m`_Dmi9b157'Eu[eq&tr`SYhVNh?E]=`%od_,0;#4@NXH9h_*Ig.)),H:3(lWk&BsU!TPlK"=AqTq$!;5]j$QeO@p/cnJ1^A%OCW4$`^U95S0)4iXl8],uIQj:lHe;.H"ke-'M/>\:!En+>T`j#p>*/TaJQ?pIWFd/hKU*L=)@]/O*h273l^&+jK/%?oWeH:A(R"+I!$bJ9_X2f5EjC__:+Tf?%#qB(3F3FE1r#_7>6Jo>B%g:2?V+n3Z$sXPDmJ0`*Q=LA<X;ed,Ug:'.6c&H>)K^jNpjD9C$+p:#:;(,83%S@?#+*Djg]U67"cqc%#UT%n;2a,Ns1#Xo$0`rjcFp1'N2-)";:d25sP/4#X3q041Kjs47tL,Vd\Atf'p#j4VWo[l?Ck4AefJHQ:&pR0(k.2s>V*f0Nj,T[t.AGDoIfc'U&N''nK/'hqP^73Ik]sQL5iXL`ZjY7)[UP(/;I:.lsX$F4WKql-henjrEf+EJqn0>fb`j^h5&aK&jren)/LW53g'-!^I@S-m!%!s#MNq)Yc5r+do/lel^K3Xj@[>*B319]Y#c/;dC.Q.jA;8R9u0gC0\!q+JRZnB/A6%s2[jY,*K]<\N(>%]?6F)-Qn=5(GJ2**<i3_QYp1rP*Ya*F`2Kc)5#WO@Z:<a&QT]<3:fr-Qp\?W,l]`[[NVgbUJjrO#M8;``JB_OE^54X"B8<r0)F'8fu2@Ap@m+eaM1nVHJm,i2@k,+h7C1H$25Kc#"T5R5RZfhCZKoHOW!r30Xs8rXn@!K7M(=hUlP#<Hg@m0@QagILr)6fd<!+-V%^=6#Z2&fhXu.8WT=eAfJBL(j@Fn_2Pu#'f<(B*`I[Qg]'%-!(=RO5gt@215III&Gld9j*m:#qVbUoi]XnXY;8c*KS0UMM7*qLtBWX9sJqEl,KN1E=@3_9"fd80&SoG$5=YJW7L64WGg]AMrP/m)Gos(]WiaLYB#8qc"p^J]d_ALpRfp-?TTht>Ot%hdWp3KT1HN*eqoggJ8m?SDI8`T,%Ss4SZaSBZ-AWH>0IcZ;j8[VJjs2P<c@RlY5Yj`R#PgbrWX4:'F?J@HC3%--<QUd8.@'k*Pp'7B<OUNeB-9T3.s=uUL+iAF6q$W`X9#Wge7^'_A.e@p`#S'*"tb!9KrJ10j.n-.-AmKR"^;m,XX:YP,<8k5b[XTN^CEE7o&I(A@ret6W2@XTtJ>?M,Goa\qnh1ou7u/E_:Y:#)%[r\(`NF-VUbYn54_;an)Pg9eaug.Xep'/%sXuE,fr`i7Bo#F,2W9?E]eB!V<8,I+$!_$Xon("+fX0g],7l6j1A_#b_r^3WajX(6a_%;!E:qfau*rES:VMi0UTUUG3+[WQH4,Y\=Fg+JCI=#hp,fkAce2HF"%B:2J@s7bWmSJ'u(Y5^f7qTCW7E`GTVFJc84,G.ruX%4<MNY\2#N(Aj<g((7P;GhW4%U1L2n<iu[@i^sbrF+2R-mckuReTprB_0"l2m6i`e1aPn8UYa077C,54EkK9)[>b]uos3;K2nIYp,4o/&=(<`04ml(YY4#f3rs,C,=5^-FrU(/A#Q"_SoiAC&PB&$Blt$9RSa0sk^&`QpS37")V-TkW3jC=V.,/N[9c!#.D5Z/h.5k[*Il*)^SXcqKT\n)c@BKRjp]kp,VT_PYWS,k1/ljsg[\KJMnI;HUp7OmY!KZMj[p[SOn*'KFo(mX"02nrT(s6*Q*G$,#DAV<M[_/Hj[bd#D[KlhY#N!$NGlIhVpr7g~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1937
|
||||
>>
|
||||
stream
|
||||
GauHK>Ar7S'RnZ;3%0Ntko,;P,`6Wi!SQ%u!OAgD2gb'^AXR_+aJ,\-dO*%bhkBI0m&68DEF?m&q47CCT,:E&Yj+=*!;nR(K3&HuLY3$%6QDI6_onJ@aWau/cZ+NgP1i))inNT+ZF,sJ.u<]*cTkBh8C%<,1*WPJJ`Ac:@if.f<_k:O`"UXa%M6`e+iV.d2A)ISBSX)apik%CqFRHR:ILruW`f?H)"(dk;&u\(&B\8(L"QYWs874a"L%K%jl*2i0mkB)'qQ0pkM1^-U]&(mO7GUhi@f>I@\%C9;XD6d$t`:pX9$%F97jTs\+,MZ%a5-ijFppjY]#,9PQ\ec!9c!q[7(_@dF=qSfJ`CV(_:rJZI(JVG(_)J@r[jb\(t5(EK<r]aMAd]bq=c*:[m>#g[5BMD@LNOAB[;]90Ve8?0JM0<q#8eBGX!7Ye?)LL/1tPOENmrP7h'+f-83VSW_7VcA[>9Tlk*M8S().ljmEjjJfFd&D?-b+D-QF/fD-L+g*D&=iJT(`.bGLc$->nHr8l"K\sgo3,t@IM0fX19cPEMPcWh&bi;^)_BMm>#">C&Q&OgXOL32f:F#/0Y+?XYGi4S((R63ALLIIfY#YYePBR@`chD<kZ!@l[9+O_;l@4Rsj6F44<.\JW"B(eVE=c^$nj6moPYhHS"`]41Jtag&ktWhA>'><Kd"5;ZgX($tH.TLYo+J+3<IAF^m;V"1%RtWAkg617[$'jpeK@$=^km2M.e);V8_lR^2>/0.I[*?7WreO.Eq>83`?Pp?:oM^Z:;*k<5Dn_'.no-`B0'Y_6^D(mPng^3-=ng(!"*Puq#_s.MT,9j=T!RO2s'dqdHAp1H%D+&b4RR##Vc5YNhU0Z/qUUO'Y#7S\%=-A(!`IaDQ1e>:fk)"f:u%Y.l.>2lrm>ODHO7@(S$U,82IqN0c*'PE>1mB!564H=#d75*:rbG1U[j>NDDHYWFAtE]Npu;a'`3Vad%`bXW`lsP[H_W>(Y=>m>SHpo,nLJ6:$D=OQ^8_hAr!?BNmT&gO*?\p8b.Y::X[`'0k%pS+*2&.asTX6LIc*#!nDdQ($eoYA.Hha6XMWGt2,3MMb=dMUMl7=Egn5U>ckXgBb!\f>_hnY@_b0>&62YM==ofeBpBEW'e8WMJW$?<_io[-]XG(K'Lo,eC-@@Ym0Are=/K.\eTn`Ii)AJ8ND#hHSF0s1<`)Y2UGd^k'V)AJ%LtKf\fi)U>(KX:OLGIne=]Q6iALIP>EXZWUk/:$*rbeAafa6FU9i4gbEF9TaC"3EkS#M[YRULiaS-%7&]Eb$;WLU5nG'<dph4^qjT!jSidqmncU:kF;gg`KG!GOS'.ub#g5OtDm'IPW^U+?euJ8-\p%R[`j%"61+GmU<VA;EWbstRCLfZG'$7A0RkXo0@s#@Yf0ZD8SZ64XG:NPiQUu1]cR8F6D[Zji[?RanjCZ.#Ht5>0;;W%PfEZRS,etJo\&I!%BB"iC2HGl/Qb%hV[l&8aY9d>9(6M@\aI"=DiSAJqKdI"6+n0>Bk!%KrP*X)an5O7V.9mIu35bQEg-M.GAg&f_,cL=VI#m3C\\cUiGHls@f>0@Kb>=gr8Y.@6q3N@[#2S4QMr.s]&],nj<Nn=r5j_ZP1VmW6%EjQ8h&+/17_S,6`9>6&:hW>L'L&+q1Na&>>bdlL+%NDT>U!u[G$*ehAD>&/3U`#>W[O#)?8olOR6j@OH%VRu,`_9?]EpMfA*)0G[q,I2)<'<*N;1e/H(oG9/II\!+!6smY@mi2K5]]@[X8L`2eS_k;b;@VFW%ojG-\iEmEP:69(j(_EijL]^<TYrAQ[H-XJ3XlIB7uSDGPbRq5p`:2#i!(_C158g^qO]q[N".bWf3F[62JiQSmiDXVP%WS_n^(^>k9Hk`5-&`nu)F]`jHW!r6`-E7uU/L3#_IiDf-nZ?j$5qLeH*`LX,H~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1856
|
||||
>>
|
||||
stream
|
||||
Gb!Sl=`Y6:&:U6Yd4\H&eeTLTV0U9#JVD>M!AA`?Ml`'.M8tdI,6<8l[cWU2GbMRE@Uj&rmT,re[nOWW&$:n5"7Z0rT"h'd)9`j]IH&02&h[eAB8<^Q7=G6Tj/s1S+L,SZ+C@/JJrkX?M^`U(,!>G4kEKp,F(!'*75TU$_;^kMJOH1NE?>Ia6--r,IZK/@iR0mr_D'j.DtW-k]pOJTG,T$E7:A_Q>GHMDSkiT06ikLiE;'"]Vq:En=fOfkN61#H108Kr_@9ueSA1Ye(I_Ml.Y/E$:1\%L;7_,^ZWie[ZYPQ]q]7+u9Cu'[V(."AJ>]+uMam&[YtVP*8k4K9d&ES3OL:NH/gm7R7Mq(S4mmRY)=I<o+2Z$+!GO1`B:6X]ijq;q4_).$nd1:B2LZ9\1t(W\i<A>->#G6n)!U#[HJ'?VBR0in#8.d<Xttjm><9E=HU2PaMKIW>M[XNM-!TNgN035M=ZLV&fhW:Yk:^]CeCbcRhWDj,B%/,c;s%L>d*Xu6Fj0V//qc-#hE?q\LUI&IY^Q=2,6<cYE?E7d?XaYa>lc6`gf0.^ZT%V*?S=0UIj?K>oF:SW'IO5"q3X6Chj!qbiO(kT1i[brKZ(;AdGUR:b7q4FEddohEgcs+gs5)#mILE$`sctdVR"Z4-*"kj9&5"Oap-j@Ts[eo@;6QeWa]<.e=V]Ak=T=-Lu,7j3u'3_a#-7V<m(;5UjC+5':Q@'Zt>]`)kkH%e0?uIF3Aceec@DTinIlg/H)NEri=2aFGdT;Kpr:`fZhkY$F[$P)Up5P<l`.W,BibV[;9d"[r8\^E[00Y9r6%mR#[Qi6P[#oGph-X\YqS!>UW4Lh'tPgK?'t>M)sb<QMlFcPfVqnEYMpPUOJ.@Vmdt3>"Y!WP<-_N)Va$mQ,JL'KubVaW['a5;QjHFqCYS/0UH7N7LU&$hVeDc_AVO\6b$OVFhPBdEWcsKnB)mTgin"I)2]!o1AHZ%FEP)`"VIIf$b:td_ZU#-#sOusduG<^_qHnumi&jN15/dG(N2Sj+84]p_g(E9hIulD1>P6ZJY/]jP7W(/j[NiF/Y1_E-p"@Rp;!?#Jp+8EJW6$_/@Lu/RkNaeKdp*:8Te3Q'PN]+bBg/(Yd7s$=Q$icV4,2:Q)@32Zd&\3WkM;kLf,5COFtT'Sf>8K^=*OXU/Y<F4`#J9-EpJ@]Lpd%L^XWm1@X)Y[n!N3ma&5N>O+AQ[U#iaWba;l8ZiMs[=6=%)ca`TIBXJ*P,cZu9$>Gqi+((-M8&a.='R/%B+QAU3%]4se=ITn>+I:MX'rHh0cnaEdm>"]gf*MegA-3eT7bhi`T:eJo>9^@Mdl,r2W;?bj_`J;^c"QPV0Y.Q7*VbYDPQo8pIc[]-^--%L/VLP>clBYLHVlXkok\ig'Wt-n4dJP?Q6R/KuR"rA,"Ej&W<nLE0a?TcOH1sSd\Cu"T+JSlFcnl,ufR".1149"`ki(7_l-oj"RT6`:$r>r6pWd$!7:I\Dui>a]pFbSjU^XKu.@,1/#+M-Ptj\Te?bBU!L]Gcc<jS$p8kmrr'hUm1oec_bEZR;h*uhB^$$o_JkTsf+N?r4'=HSn^?f,>UrD*.DXPQ9@+[7>#,GT\:+Y9H"+InCC?!TR,^Ai`/Fo1o-SGjr-!Q-N]4Rpe*Jaf24e8Vl(O2cF=64XM>=CZKX7`%PR>]C_oERCi]]pD8`9'7MiIB#Q[Y8Aou<m0g[uANqN>%Z"aXWnmT%!"E-""B8t6+Y2!N&u16i!F=4dPgRL6:d,\VIh>2N'DC9'b_7oP]$CfJ1D3kJ22'rWe?/<qZ1f]Y(L=l!U9:&Z'37\s[a)I5U&?Bf^]b::P$\fWe;.n\;[(Z\-PK(BL^o"sq~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1724
|
||||
>>
|
||||
stream
|
||||
Gb!TW>Bed\&:i[:/*:\_)dLR1pT9eSCsLIEB_R2s@12*</D$%VZbLrZ1)QoSDVL@,%@ggb2)id-)Mrus04OpY97U)RGlF!/EFCs3inHYU7rUMt^0.l`\re(bmDY*P\6p\Ure!8@8dIaF9\b:dlpQ":q;B<3NoMAh(WSrVM4eH7HYO.sUX4j,+7,?t@3"]op[$7L;71<I@@6sPIViGnbtIDBD&o(u^/5_26.SeR;E`o\8.YqY?GbY!LTb@[VoY9"k<a._U2$">'hhjkJsU,fQVnSsYmfKn(#u#lZ;Ni:nHFlDC0a!u^),Pf96=J=Le/^1i.k9@er'Dso:i#tet/O1(t=P@cs9io3O.ss:5"gNA&Kr0,85EQk_HCLgq[e\]6i=R,MR59\9H8Yf2)3#M*e\$-eLPF>2;?S+>G$C<-Kk/kE5g+fcFk"kmZ5"8C>5t27Ekj9"$rFSKOiZP$6/^$BAIS,M.Q02gV'nf%`#PV-iWF5C]_ta(0+%a6WZebo(M;2^Y[4"QarL&=Y[QHLkU02U"C7p>guuIU]q$npT+"8:l\F:&`1:A50'22A@ZB?S]i[S<^Q06Pqmj146^Q0%(K?Y;Q$j@"L*-941M]0P-SmnUp!&]#28M>m80._6Cf(C+pFj]?Xh=nH^j!n1%OIPp"3ub66iC""ltF$X*UdUf<S"1lXB#pd;p8(i$r]Y)8/(`Bd//iXR!<B;oBihTXNi3oY<&gPh30Mq<MC>\=9CgWD>b(edI7*AeBb4s66MSZ]k]o_&G$6W(N49rZG!AFP'=`$j?N2#tcF8318G$;t%JZi`>T4J&l`?(EKmKp1i>?T/87-(HK:5*oEG$@tcIJJ+?T&s/rXi1/@c]$qn6ak92Vog,a@7'7+%'rsXH0h_e<N%Z%E.ej=o"YMrsY<?5gHf'cXLWVN2r1A_&&]I(1YARuA&6r.[^JLG0l+>6f7"HNrQ(IQZ;F=eVehjc9cP_HI8=!^_BJOrMU4u.CH8qZK7e$tIn<<?c@)QC(Sn%deQR7\;Pa'a\E_TQXWAQ:dBGm34g<pCs%!HWr-e"*5+Cfu@W+qIHfR.g!,o*OW?s?tsHhm!&K4Z#Bk`oV]A-u!X9,2p)/*>YjiJ6r!A&X3Pf&MW_$XTbn&B"8&LD'dKfn&`)[I7CrdlhFf[kWr0$X6p2$H9QQB%\`5XM,jUKCJCNg$6.ej`9]q34:WhTI?l'VWIdVJFZ)*5mfJSF'-seR:<^=`t)9g89!t8L#f0MHD%T)Le>u$+2/[TPnKG>68s!:h7@-5g9g7>3?'Z1Z@m^bM\=0aWF<d\X-Z7X86$ecmFuXG3BrSc?PtU]T<i!N-.tSblT#JClOa]mn%)>3W;?bS8\B>&<5KqVaV.e)Jn%`>HS<(hRO+>LYBZ0FWbkCe;R%28-V".jgk!_-H:c@.m)rP$\OBfa`h\p>PV]!/4@:/KcTX.R%7SCBE"cMh;t1l@)=)?t1G1.&h<30G<'jA4gU@VR,33T9TLASWgVeK"Lb[aLK?ISqdmhS>X3>$qG<EIgNaVi*J&Vo%=6@!0L\SX'\W=@@a`o2]"cPq>G0YHYMP2^lE7&Q8,]Fn9K4Yhl2k)P::.=$goCJ,GE\<Sr;XX:Ois&m;3+lBV1U@N-Wg]gO";"<gj2K!3h&$Haj_Vc%3b)R-okS\%pC,=2MK4df<pP=H8G%0r93B`UVnga3B?`9(X#<t4P'q,Mn(@g1[^*aG(DCWQD?~>endstream
|
||||
endobj
|
||||
34 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 934
|
||||
>>
|
||||
stream
|
||||
GauHI9lldX&A@Zcp6]I366@>,UXM%u:GY_od,pK!`j/<kOTWO9s*^50PB]E+I'E,X=KV;1\%>g"TQma<2r4O>K]$/k?l[$00TUGi?Gi,VH#a1W>^8LETrA;WO?cbeaB)+@^*GJl!<Z%2dE2Pe1ST#bJ`AqjR"F,/o%[Y=Pb^ou5W&)/Q3[9jOdmZ'7slb<\,0BY\tTuPfs3u6bnl#+#AZd$;2%Lgo9BjF#0ss?r1:`+].ugf[PihHAT$LM#nNOVR*I&-U.C0i/E"tC9PV$UPEha_qubo'Kf+\T#u+6SH6*%>'Yjk0XXNSB$2YrN-!>o`6(hFUC)9J4QDpUI#XK1@@l35+^s$KN8Pd4CiBCrsHX#cS74@j$^rCN_jUn@8+'STf-['eT4tt5`'rj1rkSO$@`tkcHkm;Hd(^YB3WP`\?m2'IYM"W7%6h`-0I-F52RsQ"Tjfu.8lDCSaVY9FXeNA!K8tt)>(t;5g2?V5^0T7NhYNqW33[bDF9qL8!#6;%6#REWtn2Tr@J=ZQEi$,X5B!QX'h`%Pg]tXD?%]u'Z=O]lW=Pf[lDEk-e7J377VAYK6oN&>6/PXUgTV"A$V-\-]ZGc>0%#_Lu4,hh1fosA`2;`cbB@@aR?%.Fj$;XV%2`m5l'(aPAO=6M2Mat',4*P,n_Mr:Xf="HCP,\j\:FNmY4&5X[[9NEBZSKS;1T!6&a%>&GNbCqJ-^3Cq*N/hp3Ct#Y[d'kW-@XhJ:7CnhrV0001Ar6EEVf-qkL%>mMp?*+ZS,+P\\gI'o=`BjQ`5CaD,cfJcc3B-[,,<H`<iH43,Zfkd4S#j_7c8SSVco^%FZE*7X/&hOk=P@,k;UD1OeC=bK=sF[n5ArlArAV/koRt3s-Q_nHnC0EF\=4RDpO2magH9fB.RH*O5F_NTPr<3MV67ab'Zs\+KpMIgbq4T)~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 35
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000123 00000 n
|
||||
0000000230 00000 n
|
||||
0000000342 00000 n
|
||||
0000000547 00000 n
|
||||
0000000752 00000 n
|
||||
0000000857 00000 n
|
||||
0000001062 00000 n
|
||||
0000001267 00000 n
|
||||
0000001472 00000 n
|
||||
0000001678 00000 n
|
||||
0000001884 00000 n
|
||||
0000002090 00000 n
|
||||
0000002296 00000 n
|
||||
0000002502 00000 n
|
||||
0000002618 00000 n
|
||||
0000002824 00000 n
|
||||
0000003030 00000 n
|
||||
0000003236 00000 n
|
||||
0000003306 00000 n
|
||||
0000003598 00000 n
|
||||
0000003742 00000 n
|
||||
0000005704 00000 n
|
||||
0000007361 00000 n
|
||||
0000009090 00000 n
|
||||
0000011026 00000 n
|
||||
0000012758 00000 n
|
||||
0000013793 00000 n
|
||||
0000015500 00000 n
|
||||
0000016333 00000 n
|
||||
0000018206 00000 n
|
||||
0000020235 00000 n
|
||||
0000022183 00000 n
|
||||
0000023999 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<11be394e8ad147cab02e0a1e4416f65a><11be394e8ad147cab02e0a1e4416f65a>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 20 0 R
|
||||
/Root 19 0 R
|
||||
/Size 35
|
||||
>>
|
||||
startxref
|
||||
25024
|
||||
%%EOF
|
||||
442
Les13-Agents/Les13-Slide-Overzicht.md
Normal file
442
Les13-Agents/Les13-Slide-Overzicht.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# Les 13 — Agents
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Nieuwe research-agent (from scratch, los van Polderfest)
|
||||
**Vervolg op:** Les 12 — Tool Calling
|
||||
**Aansluit op:** Les 14 — RAG + embeddings
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 13 — Agents
|
||||
|
||||
**Visual:**
|
||||
- Background: CREAM
|
||||
- "Les 13" in BLUE
|
||||
- "Agents" in BLACK
|
||||
- Subtitle: "Een LLM in een loop met tools — autonoom tot het klaar is"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Vorige les (Les 12):**
|
||||
- Tool Calling — AI roept zelf functies aan
|
||||
- `tool({ description, inputSchema, execute })`
|
||||
- `stopWhen: stepCountIs(5)` — multi-step
|
||||
- Polderfest chat met 6 tools (search, stats, schedule, favorites)
|
||||
|
||||
**Wat een tool-call bot nog NIET kan:**
|
||||
- Lange ketens (30+ stappen)
|
||||
- Eigen plan opstellen + bijstellen
|
||||
- Tools die andere tools triggeren
|
||||
- Dynamisch het model wisselen per stap
|
||||
|
||||
**Vandaag: agents. Eén stap verder.**
|
||||
|
||||
**Visual:** Pijltje van "5-step tool calling" naar "20-50 step agent loop"
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Welkom + Terugblik | 10 min |
|
||||
| Theorie: wat is een agent? | 30 min |
|
||||
| **Live Demo 1** — Setup nieuwe research-agent | 25 min |
|
||||
| **Live Demo 2** — ToolLoopAgent + stopWhen | 25 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — prepareStep + dynamic control | 25 min |
|
||||
| **Live Demo 4** — Planning patterns + done-tool | 20 min |
|
||||
| Wanneer agent vs tool-call vs workflow? | 10 min |
|
||||
| Lesopdracht + Huiswerk uitleg | 15 min |
|
||||
| Vragen + Afsluiting | 5 min |
|
||||
|
||||
**Format:** Demo-driven. Code-fragmenten in slides + live in editor.
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is een agent?
|
||||
### LLM + Tools + Loop
|
||||
|
||||
**De definitie van Vercel AI SDK:**
|
||||
> Agents are LLMs that use tools in a loop to accomplish tasks.
|
||||
|
||||
**Drie componenten:**
|
||||
|
||||
1. **LLM** — beslist wat de volgende actie is
|
||||
2. **Tools** — uitbreiding (search, read, write, anything)
|
||||
3. **Loop** — runtime die deze twee orchestreert
|
||||
- *Context management:* wat ziet model in elke stap
|
||||
- *Stopping condition:* wanneer is de taak klaar
|
||||
|
||||
**Het verschil met Les 12:**
|
||||
- Les 12: tot 5 stappen, korte taken
|
||||
- Les 13: 20-50+ stappen, langere autonome workflows
|
||||
|
||||
**Visual:** Loop-diagram — LLM → kies tool → execute → resultaat → LLM → ... → klaar
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: De anatomie van de loop
|
||||
### Wat gebeurt er per stap?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Loop iteratie (1 step): │
|
||||
│ │
|
||||
│ 1. Stuur messages naar model │
|
||||
│ 2. Model genereert response: │
|
||||
│ - text? → loop stopt │
|
||||
│ - tool-call? → ga door │
|
||||
│ 3. Voer tool execute() uit │
|
||||
│ 4. Voeg tool-result toe aan msgs │
|
||||
│ 5. Check stop-conditie │
|
||||
│ 6. Volgende iteratie │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**De loop stopt wanneer:**
|
||||
- Model geeft text in plaats van tool-call (natural finish), OF
|
||||
- `stopWhen`-conditie is voldaan, OF
|
||||
- Tool zonder execute wordt aangeroepen (done-pattern), OF
|
||||
- Tool-call needs approval
|
||||
|
||||
**Default in v6:** `stepCountIs(20)` — veiligheidsgrens.
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: ToolLoopAgent
|
||||
### De nieuwe agent-abstractie in v6
|
||||
|
||||
**In plaats van zelf streamText loopen, gebruik je ToolLoopAgent:**
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
|
||||
const researchAgent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
system: "You are a research assistant...",
|
||||
tools: {
|
||||
webSearch,
|
||||
readPage,
|
||||
saveReport,
|
||||
},
|
||||
stopWhen: stepCountIs(30),
|
||||
});
|
||||
|
||||
const result = await researchAgent.generate({
|
||||
prompt: "Onderzoek de impact van AI op de Nederlandse bouwsector",
|
||||
});
|
||||
|
||||
console.log(result.text); // antwoord
|
||||
console.log(result.steps); // alle stappen
|
||||
```
|
||||
|
||||
**Wat krijg je:**
|
||||
- Minder boilerplate (geen eigen loop schrijven)
|
||||
- Herbruikbaar (definieer 1x, gebruik overal)
|
||||
- Built-in loop, context, stopping
|
||||
|
||||
**Visual:** Side-by-side: handmatige while-loop vs ToolLoopAgent.
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Stop-condities
|
||||
### Wanneer is een agent klaar?
|
||||
|
||||
**Built-in condities:**
|
||||
|
||||
| Conditie | Wanneer | Wanneer gebruiken |
|
||||
|----------|---------|-------------------|
|
||||
| `stepCountIs(N)` | Na N stappen | Default veiligheid |
|
||||
| `hasToolCall("done")` | Specifieke tool gebruikt | Done-pattern |
|
||||
| `isLoopFinished()` | Nooit — model bepaalt | Lange autonomie |
|
||||
|
||||
**Combineren met array:**
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(50), // max budget
|
||||
hasToolCall("submit"), // OF submit-tool aangeroepen
|
||||
]
|
||||
```
|
||||
|
||||
**Custom conditie:**
|
||||
```typescript
|
||||
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const cost = computeCost(steps);
|
||||
return cost > 0.50; // stop bij $0.50
|
||||
};
|
||||
```
|
||||
|
||||
**Visual:** Drie traffic lights — rood (step limit), oranje (tool reached), groen (kostgrens).
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: prepareStep
|
||||
### Per stap iets anders doen
|
||||
|
||||
**`prepareStep` runt VOOR elke stap. Je kunt aanpassen:**
|
||||
- Model wisselen (mini → sonnet voor complex)
|
||||
- Tools beperken (alleen search in fase 1, alleen write in fase 3)
|
||||
- Messages trimmen (context-budget bewaken)
|
||||
- `toolChoice` forceren
|
||||
|
||||
**Voorbeeld — dynamic model:**
|
||||
```typescript
|
||||
new ToolLoopAgent({
|
||||
model: "openai/gpt-4o-mini", // default
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
if (stepNumber > 2 && messages.length > 10) {
|
||||
return { model: "anthropic/claude-sonnet-4.5" };
|
||||
}
|
||||
return {}; // niks aanpassen
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Voorbeeld — fase-gebaseerde tools:**
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 3) return { activeTools: ["webSearch"] };
|
||||
if (stepNumber <= 6) return { activeTools: ["readPage"] };
|
||||
return { activeTools: ["saveReport"], toolChoice: "required" };
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Wat we vandaag bouwen
|
||||
### Research-agent from scratch
|
||||
|
||||
**Doel:** Een agent die zelfstandig een onderzoeksvraag beantwoordt door web te zoeken, pagina's te lezen, en een rapport in Supabase op te slaan.
|
||||
|
||||
**Stack:**
|
||||
- Next.js 16 + TypeScript + Tailwind + Shadcn
|
||||
- AI SDK v6 (ToolLoopAgent)
|
||||
- Tavily API (web search)
|
||||
- Supabase (rapport opslag)
|
||||
- OpenAI gpt-4o / Anthropic Sonnet
|
||||
|
||||
**Tools die we bouwen:**
|
||||
|
||||
| Tool | Wat | Gebruik in flow |
|
||||
|------|-----|-----------------|
|
||||
| `webSearch` | Tavily query → 5 resultaten | Fase 1: oriëntatie |
|
||||
| `readPage` | URL → fulltext extractie | Fase 2: diepgang |
|
||||
| `saveReport` | Insert in `research_reports` | Fase 3: opslaan |
|
||||
| `done` | No-execute, signaal klaar | Eind: stoppen |
|
||||
|
||||
**Visual:** Diagram van research-flow: vraag → search → read → save → done.
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: LIVE DEMO 1 — Setup research-agent
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `pnpm create next-app research-agent --typescript --tailwind --app`
|
||||
2. `pnpm add ai @ai-sdk/openai @ai-sdk/anthropic zod @supabase/supabase-js`
|
||||
3. `.env.local` — TAVILY_API_KEY, OPENAI_API_KEY, SUPABASE_*
|
||||
4. Supabase schema: `research_reports` (id, query, summary, sources jsonb, created_at)
|
||||
5. `lib/tools.ts` — eerste tool `webSearch` met Tavily fetch
|
||||
6. `lib/agent.ts` — `ToolLoopAgent` initialiseren
|
||||
7. `app/api/research/route.ts` — POST endpoint die agent.generate() doet
|
||||
8. Eerste test in terminal: `curl /api/research -d '{"query": "..."}'`
|
||||
|
||||
**Belangrijk:** Tavily API key is gratis tier (1000 calls/maand). Studenten krijgen instructie om zelf account te maken.
|
||||
|
||||
**Visual:** Folder-tree van de research-agent repo.
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: LIVE DEMO 2 — ToolLoopAgent + stopWhen
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. Tweede en derde tool toevoegen: `readPage`, `saveReport`
|
||||
2. `ToolLoopAgent` configuren met alle drie + `stopWhen: stepCountIs(30)`
|
||||
3. System prompt schrijven: "plan eerst, search dan, lees relevante pagina's, vat samen, sla op"
|
||||
4. Test met `"Schrijf een rapport over de Polderfest 2027 lineup"`
|
||||
5. `result.steps` loggen — zien dat agent 8-15 stappen doet
|
||||
6. Wisselen naar `isLoopFinished()` — zien dat agent zelf stopt
|
||||
7. `hasToolCall("saveReport")` toevoegen — stoppen zodra opgeslagen
|
||||
|
||||
**Visual:** Console-log met alle stappen (toolName, input, output) — laat zien hoe agent zelf plant.
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: Pauze
|
||||
### 15 minuten
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: LIVE DEMO 3 — prepareStep
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
**Use case 1 — Dynamic model:**
|
||||
- Default: `gpt-4o-mini` (snel + goedkoop)
|
||||
- Vanaf stap 5: `claude-sonnet-4.5` (beter reasoning)
|
||||
- Console-log per stap welk model
|
||||
|
||||
**Use case 2 — Fase-gebaseerde tools:**
|
||||
- Stap 0-3: alleen `webSearch` (oriëntatie)
|
||||
- Stap 4-7: alleen `readPage` (diepgang)
|
||||
- Stap 8+: alleen `saveReport` + `done` (afronden)
|
||||
- Zien: agent kan niet 'cheaten' door te vroeg op te slaan
|
||||
|
||||
**Use case 3 — Context-trimming:**
|
||||
- Na 15 messages: trim oude tool-results
|
||||
- System prompt + laatste 10 berichten
|
||||
- Voorkomt token-explosie bij lange agents
|
||||
|
||||
**Visual:** Timeline-strip met de drie fasen + model-switch.
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: LIVE DEMO 4 — Done-tool + custom stop
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
**Pattern 1 — Done-tool (no-execute):**
|
||||
```typescript
|
||||
done: tool({
|
||||
description: "Signaleer dat onderzoek klaar is.",
|
||||
inputSchema: z.object({
|
||||
finalReportId: z.number(),
|
||||
summary: z.string(),
|
||||
}),
|
||||
// GEEN execute — stopt de loop
|
||||
})
|
||||
```
|
||||
Met `toolChoice: "required"` forceer je dat agent ALTIJD een tool aanroept — en `done` is hoe-ie stopt.
|
||||
|
||||
**Pattern 2 — Custom stop op kosten:**
|
||||
```typescript
|
||||
const costLimit: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const tokens = steps.reduce((sum, s) =>
|
||||
sum + (s.usage?.totalTokens ?? 0), 0);
|
||||
return tokens > 50_000;
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern 3 — Plan-act-reflect loop:**
|
||||
- Stap 0: agent maakt plan (text)
|
||||
- Stap 1-N: voert plan uit (tools)
|
||||
- Eind: agent schrijft reflectie (text)
|
||||
|
||||
**Visual:** Plan-Act-Reflect ReAct-diagram.
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Wanneer agent vs tool-call vs workflow?
|
||||
### Niet alles hoeft autonoom
|
||||
|
||||
| Scenario | Aanpak |
|
||||
|----------|--------|
|
||||
| Vraag → 1 tool → antwoord | Plain tool-call |
|
||||
| Vraag → 2-5 tools → antwoord | `stopWhen: stepCountIs(5)` (Les 12) |
|
||||
| Onderzoek → 10-50 tools → rapport | **Agent** (Les 13) |
|
||||
| Productie pipeline met validatie | **Workflow** (explicit code) |
|
||||
| Wettelijk: must be reproducible | **Workflow** — geen agent |
|
||||
|
||||
**Wanneer GEEN agent gebruiken:**
|
||||
- Deterministisch nodig (audit, finance, juridisch)
|
||||
- Voorspelbare kosten
|
||||
- Latency-sensitive (< 1s antwoord)
|
||||
|
||||
**Wanneer WEL agent:**
|
||||
- Open-ended taken (research, planning, debugging)
|
||||
- Tot stand komen via meerdere stappen
|
||||
- Cost en latency mag wat hoger
|
||||
|
||||
**Quote uit AI SDK docs:** *"Agents are flexible and powerful, but non-deterministic. When you need reliable, repeatable outcomes, use workflow patterns."*
|
||||
|
||||
---
|
||||
|
||||
## Slide 16: Lesopdracht + Huiswerk
|
||||
### Bouw je eigen agent
|
||||
|
||||
**Lesopdracht (in-class, 30 min):**
|
||||
- Setup research-agent in eigen repo
|
||||
- Tavily account aanmaken + key in .env
|
||||
- Agent draaien met `stepCountIs(10)` en eerste eigen query stellen
|
||||
- Logs bekijken: hoeveel stappen, welke tools
|
||||
|
||||
**Huiswerk (voor Les 14):**
|
||||
- 4e tool toevoegen — `listReports` om opgeslagen rapporten op te halen
|
||||
- `prepareStep` toevoegen — minimaal 1 use case (model OF tools OF context)
|
||||
- Custom `StopCondition` schrijven (kosten OF specifieke conditie)
|
||||
- `AGENT.md` schrijven: tool-lijst + stop-strategie + 1 voorbeeld-run met logs
|
||||
|
||||
**Bonus:**
|
||||
- UI met live-step-rendering (elke stap als card)
|
||||
- Sub-agent: 2e agent die per pagina samenvat, hoofdagent gebruikt sub-agent als tool
|
||||
|
||||
---
|
||||
|
||||
## Slide 17: Volgende les + Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- Agent = LLM + tools + loop
|
||||
- `ToolLoopAgent` met `stopWhen` en `prepareStep`
|
||||
- 4 stop-condities: stepCountIs, hasToolCall, isLoopFinished, custom
|
||||
- prepareStep voor dynamic model / tools / context
|
||||
- Done-tool pattern voor forced tool calling
|
||||
- Wanneer wel/niet een agent
|
||||
|
||||
**Volgende les (Les 14): RAG + embeddings**
|
||||
- Semantic search op grote datasets
|
||||
- pgvector in Supabase
|
||||
- Embedding models — wat zijn vectors
|
||||
- RAG-pipeline: chunk → embed → retrieve → generate
|
||||
- Combo: RAG-tool in een agent — best of both worlds
|
||||
|
||||
**Daarna in deze leerlijn:**
|
||||
- Les 15-16: Testing + Deployment + Performance
|
||||
- Les 17-18: Eindopdracht-werkdagen + Pitch
|
||||
|
||||
**Vragen? Feedback?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Wat is een agent | Theorie |
|
||||
| 5 | Anatomie van de loop | Theorie |
|
||||
| 6 | ToolLoopAgent | Theorie |
|
||||
| 7 | Stop-condities | Theorie |
|
||||
| 8 | prepareStep | Theorie |
|
||||
| 9 | Wat we vandaag bouwen | Intro demo |
|
||||
| 10 | **LIVE DEMO 1** — Setup | Demo |
|
||||
| 11 | **LIVE DEMO 2** — stopWhen | Demo |
|
||||
| 12 | Pauze | Break |
|
||||
| 13 | **LIVE DEMO 3** — prepareStep | Demo |
|
||||
| 14 | **LIVE DEMO 4** — done + custom stop | Demo |
|
||||
| 15 | Wanneer wel/niet | Reflectie |
|
||||
| 16 | Lesopdracht + Huiswerk | Praktijk |
|
||||
| 17 | Afsluiting + Les 14 preview | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- AI SDK Agents — Overview: https://ai-sdk.dev/docs/agents/overview
|
||||
- AI SDK Agents — Loop Control: https://ai-sdk.dev/docs/agents/loop-control
|
||||
- AI SDK Agents — Building Agents: https://ai-sdk.dev/docs/agents/building-agents
|
||||
- AI SDK Agents — Workflows: https://ai-sdk.dev/docs/agents/workflows
|
||||
- Tavily API: https://tavily.com/
|
||||
- Anthropic — Building effective agents: https://www.anthropic.com/research/building-effective-agents
|
||||
BIN
Les13-Agents/Les13-Slides.pdf
Normal file
BIN
Les13-Agents/Les13-Slides.pdf
Normal file
Binary file not shown.
BIN
Les13-Agents/Les13-Slides.pptx
Normal file
BIN
Les13-Agents/Les13-Slides.pptx
Normal file
Binary file not shown.
BIN
Les13-Agents/lu41dctb9.tmp
Normal file
BIN
Les13-Agents/lu41dctb9.tmp
Normal file
Binary file not shown.
1
Les14-RAG-Embeddings/.~lock.Les14-Slides.pdf#
Normal file
1
Les14-RAG-Embeddings/.~lock.Les14-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 10:59,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
1
Les14-RAG-Embeddings/.~lock.Les15-Slides.pdf#
Normal file
1
Les14-RAG-Embeddings/.~lock.Les15-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 10:32,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
575
Les14-RAG-Embeddings/Les14-Docenttekst.md
Normal file
575
Les14-RAG-Embeddings/Les14-Docenttekst.md
Normal file
@@ -0,0 +1,575 @@
|
||||
# Les 14 — RAG + Embeddings
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 15 van 18
|
||||
**Onderwerp:** RAG (Retrieval-Augmented Generation) — AI laten antwoorden op eigen documenten
|
||||
**Duur:** 180 minuten
|
||||
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** PDF Q&A from scratch — nieuwe kleine app
|
||||
|
||||
---
|
||||
|
||||
## Hoe deze tekst werkt
|
||||
|
||||
- `[SLIDE X]` — klik naar slide X
|
||||
- `[SCHERM: slides | terminal | editor | browser | supabase]`
|
||||
- **Vertel:** "..." — wat je zegt
|
||||
- `*[stage direction]*` — instructie voor jezelf
|
||||
- 💬 = verwachte studentenvraag
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES — Setup (45 min)
|
||||
|
||||
### 1. Demo-folder + Supabase
|
||||
|
||||
- Maak vooraf werkende `pdf-qa` lokaal (backup)
|
||||
- Supabase project: pgvector extension AL geactiveerd
|
||||
- Schema (chunks tabel + match_chunks function) al gedraaid
|
||||
- 1 PDF al klaar om te indexeren: `polderfest-lineup-2027.pdf` (genereer met AI of pak echte)
|
||||
|
||||
### 2. Voorbeeld-PDF klaar
|
||||
|
||||
- Print of genereer een PDF over Polderfest 2027 — ~10-20 pagina's
|
||||
- Liefst: line-up, dagschema's, locatie-info
|
||||
- Studenten kunnen deze ook downloaden
|
||||
- Backup-PDF: Wikipedia-artikel over willekeurig onderwerp als geprinte PDF
|
||||
|
||||
### 3. Browser-tabs
|
||||
|
||||
- `localhost:3000` (dev server)
|
||||
- Supabase dashboard (Table Editor + SQL Editor)
|
||||
- https://ai-sdk.dev/docs/ai-sdk-core/embeddings (referentie)
|
||||
- https://platform.openai.com/usage (cost-monitoring)
|
||||
|
||||
### 4. Backup als demo crasht
|
||||
|
||||
- Werkende eindstaat (alle code) op USB
|
||||
- Console-log met "voorbeeld-embedding" als slide-content
|
||||
- Backup-vraag-en-antwoord screenshots
|
||||
|
||||
### 5. Terminals
|
||||
|
||||
- 3 tabs: dev, watch (`watch curl ...`), supabase SQL via CLI als nodig
|
||||
|
||||
---
|
||||
|
||||
# HET SCRIPT — Lees mee tijdens de les
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik (10 min)
|
||||
|
||||
`[SLIDE 1 — Title]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Welkom bij les 15. Vandaag: RAG. Retrieval-Augmented Generation. AI laten antwoorden op basis van jouw eigen documenten."
|
||||
|
||||
`[SLIDE 2 — Terugblik]`
|
||||
|
||||
**Vertel:** "Lessen 11-14: AI SDK basics, tool calling, agents, externe APIs en deploy. Nu hebben we een complete stack.
|
||||
|
||||
Wat we nog niet hadden: AI laten werken met **jouw documenten**. Stel je hebt 200 pagina's productdocumentatie of een dik handboek of een lange whitepaper. Hoe stel je daar vragen aan?
|
||||
|
||||
Twee naïeve oplossingen. Eén: alles meesturen in context. Werkt voor 5 pagina's, niet voor 500. Te duur. Twee: AI zelf laten zoeken. Werkt voor publieke info, niet voor jouw private data.
|
||||
|
||||
RAG is de derde oplossing. In één zin: haal de relevante stukjes uit je documenten op, geef die aan AI, AI antwoordt."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Drie uur. Eerst vijftig minuten theorie — embeddings, similarity, pipeline, pgvector, chunking. Veel om te begrijpen, maar het loont. Daarna vier demos. Pauze rond minuut 100."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Theorie embeddings (20 min)
|
||||
|
||||
`[SLIDE 4 — Wat is een embedding]`
|
||||
|
||||
**Vertel:** "Een embedding is een vector. Letterlijk een array van getallen. Voor OpenAI's small model: 1536 getallen tussen min-één en plus-één.
|
||||
|
||||
Je stopt tekst erin, krijgt vector terug. Maar het magische zit hier — vergelijkbare betekenissen produceren vergelijkbare vectors.
|
||||
|
||||
`*[Wijs naar slide voorbeeld]*`
|
||||
|
||||
'De kat zit op de mat' en 'Een poes ligt op het tapijt' hebben bijna geen woorden gemeen. Maar betekenis is hetzelfde. Hun embeddings staan dichtbij elkaar in de 1536-dimensionale ruimte. 'Voetbal in Nederland' — totaal andere kant op.
|
||||
|
||||
Dit is fundamenteel anders dan keyword search. Keyword zou de eerste twee zinnen totaal niet matchen — geen woord overlap. Semantic search wel.
|
||||
|
||||
Welke modellen. OpenAI 3-small en 3-large — de standaarden. Open-source alternatieven zoals nomic-embed-text als je lokaal wilt embedden. Voor vandaag: 3-small. Goedkoop, snel, prima kwaliteit voor de meeste apps."
|
||||
|
||||
💬 *Vraag: 'Wat doet zo'n embedding-model eigenlijk?'*
|
||||
|
||||
**Antwoord:** "Het is een neural network, getraind om de betekenis van tekst te 'condenseren' naar een dichte vector. Vergelijkbaar met de eerste lagen van een GPT-model, maar zonder de generative head. Heel veel context distilleren tot 1536 nummers. Wiskundig: een functie die tekst projecteert in een 'semantic space'."
|
||||
|
||||
`[SLIDE 5 — Vector similarity]`
|
||||
|
||||
**Vertel:** "Hoe meet je 'dichtbij'? Drie metrics, één winnaar voor text.
|
||||
|
||||
Cosine similarity — hoek tussen twee vectors. Van min-één tot plus-één. Plus-één betekent identiek georiënteerd, nul ongerelateerd, min-één tegenovergesteld. Voor genormaliseerde vectors (OpenAI's zijn dat) is cosine effectief dot product.
|
||||
|
||||
Voor RAG: cosine. Standaard, robuust, simpel.
|
||||
|
||||
In pgvector schrijf je `<=>` voor cosine distance. Een kleinere afstand = meer similar. Order by `<=>` ascending = top resultaten eerst. Onthoud die operator."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Theorie RAG pipeline + pgvector (30 min)
|
||||
|
||||
`[SLIDE 6 — RAG pipeline]`
|
||||
|
||||
**Vertel:** "Twee pipelines. Index time en query time.
|
||||
|
||||
Index time gebeurt eenmalig — wanneer je een document uploadt. PDF binnen, parsen, opknippen in chunks van ongeveer vijfhonderd tokens, elke chunk embedden, alles in Postgres.
|
||||
|
||||
Query time gebeurt elke vraag. Vraag binnen, embedden — zelfde model als index, dat is belangrijk — similarity search in Postgres, top vijf chunks terug. Die geef je als context aan LLM, LLM antwoordt.
|
||||
|
||||
Belangrijkste inzicht: LLM ziet nooit de hele DB. Alleen de vijf meest relevante chunks. Schaalbaar tot miljoenen documenten."
|
||||
|
||||
💬 *Vraag: 'Waarom moet het embedding-model hetzelfde zijn?'*
|
||||
|
||||
**Antwoord:** "Embeddings zijn alleen vergelijkbaar binnen hetzelfde model. Model A's vector voor 'kat' staat op een totaal andere plek dan Model B's vector voor 'kat'. Hun coördinatensystemen zijn niet uitwisselbaar. Dus: zelfde model voor index én query — always."
|
||||
|
||||
`[SLIDE 7 — pgvector]`
|
||||
|
||||
**Vertel:** "Hoe sla je vectors op? Postgres heeft een extensie: pgvector. Voegt `vector` datatype toe, plus operators voor cosine, euclidean, dot product. In Supabase: één klik in dashboard om te activeren.
|
||||
|
||||
`*[Toon SQL op slide]*`
|
||||
|
||||
Schema: chunks tabel met content kolom en embedding kolom. `vector(1536)` — dimensie matcht model. Voor performance: een HNSW index. Hierarchical Navigable Small Worlds. Wiskundige magie waardoor je in miljoenen vectors in milliseconden zoekt. Bij minder dan tienduizend rows kun je het skippen — exacte search is dan al snel."
|
||||
|
||||
`[SLIDE 8 — Chunking]`
|
||||
|
||||
**Vertel:** "Hoe knip je een document op. Klinkt simpel — is het niet.
|
||||
|
||||
Drie strategieën. Fixed size — vijfhonderd tokens per chunk, vijftig tokens overlap. Simpel, default. Recursive — knip eerst op paragrafen, dan zinnen, dan woorden. Behoudt structuur beter. Semantic — embed zinnen, group similar. Beste kwaliteit, duurder.
|
||||
|
||||
Sweet spot voor chunks: tweehonderd tot vijfhonderd tokens. Te klein, je verliest context. Te groot, irrelevant info verdunt het signaal. Overlap van tien tot vijftien procent zodat info aan chunk-grenzen niet verdwijnt.
|
||||
|
||||
Vandaag: fixed size, vijfhonderd char chunks, vijftig overlap. Werkt prima voor de meeste docs."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Demo 1: pgvector setup (25 min)
|
||||
|
||||
`[SLIDE 9 — Wat we bouwen]`
|
||||
|
||||
**Vertel:** "Vandaag bouwen we een PDF Q&A app from scratch. Upload PDF, hij wordt geïndexeerd, je stelt vragen. Klassieke RAG-use-case."
|
||||
|
||||
`[SLIDE 10 — DEMO 1]` `[SCHERM: terminal + editor]`
|
||||
|
||||
**Vertel:** "Klas, kijk mee."
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les14-RAG-Embeddings
|
||||
pnpm create next-app@latest pdf-qa \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd pdf-qa
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js unpdf
|
||||
cursor .
|
||||
```
|
||||
|
||||
`*[Supabase dashboard]*` `[SCHERM: supabase]`
|
||||
|
||||
**Vertel:** "Eerst pgvector activeren. Database → Extensions → zoek vector → enable. Eén click.
|
||||
|
||||
Of via SQL:"
|
||||
|
||||
```sql
|
||||
create extension if not exists vector;
|
||||
```
|
||||
|
||||
`*[SQL Editor — voer uit:]*`
|
||||
|
||||
```sql
|
||||
create table chunks (
|
||||
id bigserial primary key,
|
||||
source text not null,
|
||||
page int,
|
||||
content text not null,
|
||||
embedding vector(1536),
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
create index on chunks
|
||||
using hnsw (embedding vector_cosine_ops);
|
||||
|
||||
alter table chunks enable row level security;
|
||||
create policy "demo open" on chunks
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
**Vertel:** "Tabel, HNSW index, RLS, open policy. Dezelfde regels als alle eerdere demos."
|
||||
|
||||
`*[Editor → lib/embeddings.ts]*`
|
||||
|
||||
```typescript
|
||||
import { embed, embedMany } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const embedModel = openai.textEmbeddingModel("text-embedding-3-small");
|
||||
|
||||
export async function embedOne(text: string) {
|
||||
const { embedding } = await embed({ model: embedModel, value: text });
|
||||
return embedding;
|
||||
}
|
||||
|
||||
export async function embedBatch(texts: string[]) {
|
||||
const { embeddings } = await embedMany({ model: embedModel, values: texts });
|
||||
return embeddings;
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Twee functies. embedOne voor één string, embedBatch voor veel tegelijk. Batch is veel sneller — één API-call in plaats van een loop."
|
||||
|
||||
`*[Voeg chunkText functie toe]*`
|
||||
|
||||
```typescript
|
||||
export function chunkText(text: string, size = 500, overlap = 50): string[] {
|
||||
const chunks: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
chunks.push(text.slice(i, i + size).trim());
|
||||
i += size - overlap;
|
||||
}
|
||||
return chunks.filter((c) => c.length > 0);
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Simpelste chunker mogelijk. Slice op chars. Voor productie: gebruik LangChain's RecursiveCharacterTextSplitter — handelt edge cases beter. Voor demo: dit volstaat."
|
||||
|
||||
`*[Test embed]*`
|
||||
|
||||
```typescript
|
||||
// Eenmalig in een test-script of API route
|
||||
const e = await embedOne("De kat zit op de mat");
|
||||
console.log(e.length, e.slice(0, 5));
|
||||
// 1536 [0.012, -0.0345, 0.0123, ...]
|
||||
```
|
||||
|
||||
**Vertel:** "Vector van 1536 nummers. Eerste vijf: kleine getallen tussen min-één en plus-één. Dat is alles."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Demo 2: Index pipeline (20 min)
|
||||
|
||||
`[SLIDE 11 — DEMO 2]` `[SCHERM: editor + terminal]`
|
||||
|
||||
**Vertel:** "Index endpoint. POST een PDF erin, krijg chunk-count terug."
|
||||
|
||||
`*[app/api/index/route.ts]*`
|
||||
|
||||
```typescript
|
||||
import { extractText } from "unpdf";
|
||||
import { embedBatch, chunkText } from "@/lib/embeddings";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) return Response.json({ error: "No file" }, { status: 400 });
|
||||
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const { text } = await extractText(buffer, { mergePages: true });
|
||||
|
||||
const chunks = chunkText(text, 500, 50);
|
||||
const embeddings = await embedBatch(chunks);
|
||||
|
||||
const { error } = await supabase.from("chunks").insert(
|
||||
chunks.map((content, i) => ({
|
||||
source: file.name,
|
||||
content,
|
||||
embedding: embeddings[i],
|
||||
}))
|
||||
);
|
||||
|
||||
if (error) return Response.json({ error: error.message }, { status: 500 });
|
||||
return Response.json({ chunks: chunks.length, source: file.name });
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Vier stappen. Eén: ontvang file via formData. Twee: parse met unpdf. Drie: chunk + embed. Vier: insert alles in Supabase."
|
||||
|
||||
`*[Terminal:]*`
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`*[Tweede terminal:]*`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/index \
|
||||
-F "file=@/path/to/polderfest-lineup.pdf"
|
||||
```
|
||||
|
||||
`*[Wacht 5-10s]*`
|
||||
|
||||
**Vertel:** "Response: chunks: 30. Dertig chunks van onze PDF in de DB."
|
||||
|
||||
`*[Supabase Table Editor]*` `[SCHERM: supabase]`
|
||||
|
||||
**Vertel:** "Daar staan ze. Content kolom met tekst. Embedding kolom — clickbaar voor de vector. Source filename. Klaar voor querying."
|
||||
|
||||
💬 *Vraag: 'Hoe lang duurt embedding van 1000 chunks?'*
|
||||
|
||||
**Antwoord:** "Met embedMany batch: enkele seconden voor duizend chunks. OpenAI's API is snel — duizenden tokens per seconde. Voor één PDF van 200 pagina's: misschien 5-10 seconden totaal. Indexing is goedkoop én snel."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 12 — Pauze]`
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Demo 3: Query pipeline (20 min)
|
||||
|
||||
`[SLIDE 13 — DEMO 3]` `[SCHERM: editor + terminal + supabase]`
|
||||
|
||||
**Vertel:** "Nu querying. We schrijven een SQL function voor de similarity search."
|
||||
|
||||
`*[Supabase SQL Editor:]*`
|
||||
|
||||
```sql
|
||||
create or replace function match_chunks(
|
||||
query_embedding vector(1536),
|
||||
match_count int default 5
|
||||
)
|
||||
returns table (id bigint, content text, source text, similarity float)
|
||||
language sql stable as $$
|
||||
select chunks.id, chunks.content, chunks.source,
|
||||
1 - (chunks.embedding <=> query_embedding) as similarity
|
||||
from chunks
|
||||
order by chunks.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
$$;
|
||||
```
|
||||
|
||||
**Vertel:** "Postgres function neemt vector + count, returnt top N met similarity score. `1 - (a <=> b)` converteert distance terug naar similarity score."
|
||||
|
||||
`*[app/api/ask/route.ts]*`
|
||||
|
||||
```typescript
|
||||
import { embedOne } from "@/lib/embeddings";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { question } = await req.json();
|
||||
const embedding = await embedOne(question);
|
||||
|
||||
const { data: chunks } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
|
||||
if (!chunks?.length) {
|
||||
return Response.json({ answer: "Geen relevante info gevonden." });
|
||||
}
|
||||
|
||||
const context = chunks
|
||||
.map((c: any, i: number) => `[Source ${i + 1}: ${c.source}]\n${c.content}`)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
prompt: `Beantwoord de vraag op basis van deze context. Als geen antwoord in de context staat, zeg dat eerlijk.
|
||||
|
||||
CONTEXT:
|
||||
${context}
|
||||
|
||||
VRAAG: ${question}
|
||||
|
||||
ANTWOORD:`,
|
||||
});
|
||||
|
||||
return Response.json({ answer: text, sources: chunks });
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Vier stappen. Eén: embed de vraag. Twee: RPC call naar match_chunks — top 5 terug. Drie: bouw context-string met source-labels. Vier: generateText met prompt-template."
|
||||
|
||||
`*[Terminal:]*`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/ask \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"question": "Wie zijn de headliners op Polderfest 2027?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
`*[Wacht 2-3s]*`
|
||||
|
||||
**Vertel:** "Antwoord: 'De headliners op Polderfest 2027 zijn... blah blah, gebaseerd op de line-up uit het document.'
|
||||
|
||||
Plus: sources. Welke chunks waren de top vijf. Similarity scores. Volledig traceerbaar."
|
||||
|
||||
`*[Tweede vraag voor demo:]*`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/ask \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"question": "Op welk podium spelen de jazz acts?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
**Vertel:** "Andere vraag, andere chunks opgehaald, ander antwoord. Werkt."
|
||||
|
||||
💬 *Vraag: 'Wat als vraag niet in document staat?'*
|
||||
|
||||
**Antwoord:** "Twee dingen. Eén: similarity scores zijn laag — top vijf is alsnog niet relevant. Twee: LLM merkt dat context het niet bevat, en zegt 'staat niet in document'. Dat tweede dwingen we af met de prompt: 'Als geen antwoord in context staat, zeg dat eerlijk.' Anders zou-ie kunnen hallucineren."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — Demo 4: RAG-tool in agent (15 min)
|
||||
|
||||
`[SLIDE 14 — DEMO 4]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Laatste demo. Combineer Les 13 met Les 14. Een RAG-tool in een ToolLoopAgent."
|
||||
|
||||
`*[lib/agent.ts]*`
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { embedOne } from "./embeddings";
|
||||
import { supabase } from "./supabase";
|
||||
import { z } from "zod";
|
||||
|
||||
const ragSearch = tool({
|
||||
description:
|
||||
"Zoek in geüploade documenten op basis van semantic similarity. " +
|
||||
"Gebruik voor inhoudelijke vragen over de documenten.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Wat je wilt vinden"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const embedding = await embedOne(query);
|
||||
const { data } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
export const docAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je beantwoordt vragen over geüploade documenten. Werkwijze:
|
||||
1. Zoek met ragSearch voor relevante info
|
||||
2. Lees de chunks
|
||||
3. Eventueel: tweede ragSearch met andere query voor meer context
|
||||
4. Antwoord met bronvermeldingen.
|
||||
|
||||
Verzin niets - alleen wat in de chunks staat.`,
|
||||
tools: { ragSearch },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "Agent met één tool: ragSearch. System prompt vertelt hem multi-step te werken — zoek, lees, evalueer of nog een query nodig is.
|
||||
|
||||
Het voordeel boven simple RAG: voor complexe vragen kan agent meerdere searches doen. 'Vergelijk de jazz- en rock-headliners' — agent doet twee searches, één voor jazz, één voor rock, dan vergelijkt."
|
||||
|
||||
`*[Quick test in script of route:]*`
|
||||
|
||||
```typescript
|
||||
const result = await docAgent.generate({
|
||||
prompt: "Vergelijk de jazz- en rock-headliners op Polderfest 2027.",
|
||||
});
|
||||
console.log(result.text);
|
||||
console.log("Steps:", result.steps.length);
|
||||
```
|
||||
|
||||
`*[Run]*`
|
||||
|
||||
**Vertel:** "Antwoord: vergelijking van beide. Steps: drie of vier — twee searches, dan reasoning, dan finale text. Agent doet zijn werk."
|
||||
|
||||
💬 *Vraag: 'Wanneer agent vs simple RAG?'*
|
||||
|
||||
**Antwoord:** "Simple RAG voor single-shot vragen. 'Wie is X?' → één search → antwoord. Agent voor vragen die multiple lookups vereisen. Vergelijkingen, multi-hop, of als gebruiker breed vraagt en je wilt dat AI verfijnt. Trade-off: agent kost 3-5x meer en duurt langer. Begin met simple, upgrade als nodig."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 9 — Wanneer RAG (10 min)
|
||||
|
||||
`[SLIDE 15 — Wanneer wel/niet]`
|
||||
|
||||
**Vertel:** "Niet alles is RAG. Wanneer wel: veel documenten, ze veranderen, semantic search nodig, privacy belangrijk, bronvermelding gewenst.
|
||||
|
||||
Wanneer niet: klein document — stop in prompt. Exacte data — gebruik tool-calls of SQL. Structured data — SQL is beter dan vector search. Code-base — gebruik grep en tree-sitter.
|
||||
|
||||
In productie zie je vaak hybrid. Semantic search voor concept-vragen, keyword search voor exacte termen, metadata filter voor structured criteria. Drie technieken in één query.
|
||||
|
||||
En: re-ranking. Top-twintig ophalen via vector, dan re-rank met Cohere of LLM-as-judge naar top-vijf. Twintig procent betere kwaliteit. Voor productie: doen. Voor demo: skip."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 10 — Lesopdracht + Huiswerk (10 min)
|
||||
|
||||
`[SLIDE 16 — Lesopdracht + Huiswerk]`
|
||||
|
||||
**Vertel:** "Lesopdracht: zelfde app als ik liet zien. Half uur. PDF Q&A, pgvector, index, ask. Voorbeeld-PDF deel ik in de groep.
|
||||
|
||||
Huiswerk: drie dingen.
|
||||
|
||||
Eén: echte PDF van minimaal twintig pagina's, eigen interesse. UI met file upload + chat-interface.
|
||||
|
||||
Twee: RAG-tool in een ToolLoopAgent. UI laat tool-invocations zien zoals Les 12.
|
||||
|
||||
Drie: RAG.md schrijven. Document-info, vijf werkende vragen met sources, één fail-case waar RAG het verkeerd had, één chunking-experiment, één observatie.
|
||||
|
||||
Bonus: hybrid search, streaming met citations, re-ranking, multi-document.
|
||||
|
||||
Tien punten, voldoende zes."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 11 — Afsluiting (5 min)
|
||||
|
||||
`[SLIDE 17 — Afsluiting]`
|
||||
|
||||
**Vertel:** "Wat hebben we vandaag gedaan. Embeddings als vectors. Cosine similarity. RAG pipeline — index + query. pgvector. Chunking. RAG-tool in een agent. Wanneer wel/niet RAG.
|
||||
|
||||
Volgende les: Multimodal. Voice, vision, image generation. Whisper voor transcriptie. GPT-4o voor foto-analyse. Flux of DALL-E voor afbeeldingen genereren in je app. Heel visueel.
|
||||
|
||||
Daarna: les 17 over MCP — Model Context Protocol — eigen MCP server bouwen. Les 18: browser automation en Computer Use. Spannende lessen om mee af te sluiten.
|
||||
|
||||
Vragen?"
|
||||
|
||||
`*[Vragenronde — minstens 5 min over laten]*`
|
||||
|
||||
---
|
||||
|
||||
## NA DE LES — Wrap-up
|
||||
|
||||
- Push working `pdf-qa` repo naar GitHub als referentie
|
||||
- Deel voorbeeld-PDF voor lesopdracht in Brightspace
|
||||
- Brightspace: huiswerk-instructies + repo URL
|
||||
- Voor Klas B: check of zelfde stack werkt (Supabase free tier limiet pgvector?)
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten tijdens live coding
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| `extension "vector" does not exist` | Supabase: enable in dashboard |
|
||||
| Embedding dimension mismatch | Check `vector(1536)` matcht `text-embedding-3-small` |
|
||||
| `match_chunks function not found` | SQL function niet aangemaakt — kopieer uit slide |
|
||||
| RLS error op insert | Open policy nog niet uitgevoerd |
|
||||
| `unpdf` geen text uit PDF | Image-based PDF (scan) — gebruik OCR of andere PDF |
|
||||
| HNSW index trager dan exact | Voor <10k rows: skip index of `set hnsw.ef_search = 100` |
|
||||
| Antwoord is generic, mist details | Top-k te laag of chunks te klein — experimenteer |
|
||||
| Hallucination | Strengere prompt: "Als info ontbreekt, zeg dat" |
|
||||
|
||||
---
|
||||
|
||||
## Mentale model voor de klas
|
||||
|
||||
Als studenten verward zijn over wat embeddings doen:
|
||||
|
||||
> Een embedding is een **adres** in de betekenis-ruimte. Twee adressen dichtbij elkaar = twee zinnen met vergelijkbare betekenis. RAG werkt door: vertaal vraag naar adres, vind alle adressen in de buurt, lees wat daar woont, geef dat aan AI.
|
||||
|
||||
En RAG vs Agent:
|
||||
|
||||
> RAG = bibliotheek met index. Eén vraag, één bezoek, top-vijf boeken, antwoord.
|
||||
> RAG-in-Agent = bibliothecaris die voor jou zoekt. Stelt eerst sub-vragen, doet meerdere zoekrondes, vergelijkt, antwoordt.
|
||||
330
Les14-RAG-Embeddings/Les14-Huiswerk.md
Normal file
330
Les14-RAG-Embeddings/Les14-Huiswerk.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# Les 14 — Huiswerk
|
||||
## Eigen PDF + UI + RAG-tool in agent + analyse
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Deadline:** Voor Les 15 — Cursor + Vercel deploy
|
||||
**Inleveren:** GitHub repo + `RAG.md` in root
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouwt voort op de **lesopdracht** (werkende PDF Q&A app). In dit huiswerk:
|
||||
|
||||
- Index een echte PDF van minimaal 20 pagina's
|
||||
- Maak een nette chat-UI
|
||||
- Bouw een RAG-tool in een ToolLoopAgent
|
||||
- Documenteer 5 vragen + 1 fail-case in `RAG.md`
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Echte PDF + UI (verplicht)
|
||||
|
||||
### Stap 1 — Kies een PDF
|
||||
|
||||
Kies een PDF van minimaal 20 pagina's, iets dat jou interesseert:
|
||||
- Studieboek-hoofdstuk
|
||||
- Open-source whitepaper (LLaMA, RAG-papers)
|
||||
- Bedrijfsjaarverslag
|
||||
- Wikipedia-artikel als PDF geprint
|
||||
- Game manual
|
||||
- Wat dan ook — moet text-based zijn (geen scan)
|
||||
|
||||
### Stap 2 — Upload-UI
|
||||
|
||||
`app/page.tsx` — file upload + lijst van geïndexeerde docs:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Page() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [indexing, setIndexing] = useState(false);
|
||||
const [result, setResult] = useState<string>("");
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) return;
|
||||
setIndexing(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/index", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
setResult(`${data.chunks} chunks geïndexeerd`);
|
||||
setIndexing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto p-8">
|
||||
<h1 className="text-3xl font-bold mb-6">PDF Q&A</h1>
|
||||
<input type="file" accept=".pdf"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<button onClick={handleUpload} disabled={indexing}
|
||||
className="ml-3 bg-blue-600 text-white px-4 py-2 rounded">
|
||||
{indexing ? "..." : "Index"}
|
||||
</button>
|
||||
{result && <p className="mt-4 text-green-600">{result}</p>}
|
||||
|
||||
<ChatBox />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Stap 3 — Chat-interface
|
||||
|
||||
Gebruik `useChat` van AI SDK (zelfde als Les 12):
|
||||
|
||||
```tsx
|
||||
// In een aparte component
|
||||
"use client";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export function ChatBox() {
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
api: "/api/chat", // nieuwe route — zie hieronder
|
||||
});
|
||||
const [input, setInput] = useState("");
|
||||
// ... form + render messages ...
|
||||
}
|
||||
```
|
||||
|
||||
Pas `app/api/ask/route.ts` aan naar `app/api/chat/route.ts` die `streamText` returnt voor mooie streaming.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Upload werkt — file input + index endpoint call
|
||||
- [ ] Geïndexeerde docs zichtbaar (count of lijst)
|
||||
- [ ] Chat-interface met streaming antwoorden
|
||||
- [ ] Antwoorden zijn duidelijk gebaseerd op PDF-inhoud
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — RAG-tool in een agent (verplicht)
|
||||
|
||||
Combineer Les 13 (Agents) + Les 14 (RAG):
|
||||
|
||||
```typescript
|
||||
// lib/agent.ts
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { embedOne } from "./embeddings";
|
||||
import { supabase } from "./supabase";
|
||||
import { z } from "zod";
|
||||
|
||||
const ragSearch = tool({
|
||||
description:
|
||||
"Zoek in geüploade documenten op basis van semantic similarity. " +
|
||||
"Gebruik voor inhoudelijke vragen over de documenten.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Wat je wilt vinden"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const embedding = await embedOne(query);
|
||||
const { data } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
export const docAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je beantwoordt vragen over geüploade documenten. Werkwijze:
|
||||
1. Zoek met ragSearch voor relevante info
|
||||
2. Lees de top chunks
|
||||
3. Eventueel: tweede ragSearch met andere query voor meer context
|
||||
4. Antwoord met bronvermeldingen (source filenames).
|
||||
Verzin niets — alleen wat in de chunks staat.`,
|
||||
tools: { ragSearch },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
Test in chat-route:
|
||||
|
||||
```typescript
|
||||
// app/api/chat/route.ts
|
||||
import { docAgent } from "@/lib/agent";
|
||||
import { convertToModelMessages } from "ai";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
const result = docAgent.stream({
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] `ragSearch` tool gedefinieerd + werkend
|
||||
- [ ] ToolLoopAgent gebruikt deze tool
|
||||
- [ ] In UI: tool-invocations zichtbaar (zoals Les 12)
|
||||
- [ ] Voor complexe vragen: agent doet 2-3 searches
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — `RAG.md` documentatie (verplicht)
|
||||
|
||||
Schrijf in repo-root.
|
||||
|
||||
### Sectie 1 — Document info
|
||||
|
||||
- Bestandsnaam + onderwerp
|
||||
- Aantal pagina's
|
||||
- Aantal chunks na indexing
|
||||
- Chunk-size + overlap
|
||||
|
||||
### Sectie 2 — 5 succesvolle vragen
|
||||
|
||||
Voor elk:
|
||||
|
||||
```markdown
|
||||
**Vraag 1:** "Wat zijn de drie hoofdkenmerken van X?"
|
||||
|
||||
**Antwoord (samenvatting):** ...
|
||||
|
||||
**Top chunks (top 3 met similarity score):**
|
||||
- Source page 12 (sim: 0.87) — "X heeft drie kenmerken..."
|
||||
- Source page 14 (sim: 0.81) — "Het derde kenmerk..."
|
||||
- Source page 11 (sim: 0.74) — "Overzicht van X..."
|
||||
```
|
||||
|
||||
### Sectie 3 — 1 fail-case
|
||||
|
||||
Een vraag waar RAG het **niet goed deed**:
|
||||
|
||||
```markdown
|
||||
**Vraag:** "Wat staat er over Y?"
|
||||
|
||||
**Antwoord (incorrect):** "Y wordt niet besproken in het document."
|
||||
|
||||
**Wat ging mis:** Y wordt wel besproken op pagina 8, maar onder een ander woord (Z). Embedding van "Y" was te ver van embedding van paragraaf over "Z" voor cosine similarity threshold.
|
||||
|
||||
**Wat ik probeerde:** ...
|
||||
**Wat zou helpen:** Hybrid search (keyword + semantic), of synoniem-expansion in de query.
|
||||
```
|
||||
|
||||
### Sectie 4 — Chunking experiment
|
||||
|
||||
Probeer 1 andere chunking-strategie naast je default:
|
||||
- Andere chunk-size (200 of 1000)
|
||||
- Andere overlap (0 of 100)
|
||||
- Of: split per pagina i.p.v. fixed chars
|
||||
|
||||
Documenteer:
|
||||
|
||||
```markdown
|
||||
**Default:** chunk-size 500, overlap 50 → 30 chunks, avg quality 8/10
|
||||
**Experiment:** chunk-size 200, overlap 30 → 75 chunks, avg quality 6/10
|
||||
**Conclusie:** ...
|
||||
```
|
||||
|
||||
### Sectie 5 — Eén observatie
|
||||
|
||||
Iets wat opviel:
|
||||
- Welke vragen werken het best?
|
||||
- Hoe lang duurt index van een PDF van X pagina's?
|
||||
- Effect van top-k (3 vs 5 vs 10)?
|
||||
- Verschil tussen simple RAG en RAG-in-agent?
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 800 woorden totaal
|
||||
- Concrete cijfers + voorbeelden
|
||||
- Mag wat informeel
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — Streaming antwoorden + sources
|
||||
|
||||
Gebruik `streamText` met source-citations in UI. Toon naam + page-number naast elke antwoord-claim.
|
||||
|
||||
### Bonus 2 — Hybrid search
|
||||
|
||||
Voeg keyword filter toe naast semantic. Postgres full-text search of simpel `ilike`:
|
||||
|
||||
```sql
|
||||
-- in match_chunks
|
||||
where to_tsvector(content) @@ plainto_tsquery($keyword)
|
||||
or embedding <=> $query_embedding < 0.5
|
||||
```
|
||||
|
||||
### Bonus 3 — Per-document filter
|
||||
|
||||
UI dropdown: selecteer welk doc te bevragen. Backend: filter op `source` in match function.
|
||||
|
||||
### Bonus 4 — Re-ranking
|
||||
|
||||
Top-20 ophalen via vector search, dan re-rank met Cohere of LLM-as-judge naar top-5.
|
||||
|
||||
### Bonus 5 — Multi-document
|
||||
|
||||
Index 3-5 verschillende PDFs. Vraag over meerdere docs tegelijk. Documenteer hoe agent omgaat met conflicting sources.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **`RAG.md`** in repo-root (5 secties)
|
||||
3. **Updated `lib/agent.ts`** met RAG-tool
|
||||
4. **Updated `app/api/chat/route.ts`** met agent integration
|
||||
5. **Working app** lokaal demonstreerbaar
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — UI + indexing + chat werkend | 3 |
|
||||
| B — RAG-tool in ToolLoopAgent werkend | 2 |
|
||||
| C — RAG.md compleet met 5 secties | 3 |
|
||||
| Fail-case analyse is concreet en doordacht | 1 |
|
||||
| Chunking-experiment uitgevoerd + reflectie | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee bij twijfelgevallen.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — UI + chat + streaming | 45 min |
|
||||
| B — RAG-tool in agent | 25 min |
|
||||
| C — RAG.md schrijven (incl. testen) | 50 min |
|
||||
| **Totaal** | **~2 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Scan-PDF — geen text | unpdf werkt niet op image-PDFs. Gebruik OCR (tesseract) of kies andere PDF |
|
||||
| Vector dimension mismatch | Index én query moeten zelfde model gebruiken |
|
||||
| Top-1 is altijd irrelevant | Chunks te klein, of vraag te abstract — experimenteer |
|
||||
| Agent doet 1 search en stopt | System prompt expliciet: "doe meerdere searches indien nodig" |
|
||||
| Cost-pieken bij grote PDF | Indexing per chunk-batch van 100 ipv all-at-once |
|
||||
| RAG hallucineert | Prompt strikter: "Als info niet in context staat, zeg dat" |
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Test met simpele vragen eerst** — zorg dat basis werkt voor complexer wordt
|
||||
- **Log similarity scores** — onder 0.4 is meestal niet relevant
|
||||
- **Source-citations zijn goud** — gebruiker kan zelf verifiëren
|
||||
- **Chunk-experiment is verplicht** — het verschil is groter dan je denkt
|
||||
- **Eén goede fail-case is meer waard dan 10 succesvolle**
|
||||
|
||||
Volgende les: Cursor + Vercel deploy. Voice transcriptie met Whisper, foto-analyse met GPT-4o vision, image generation. We gaan letterlijk de zintuigen toevoegen aan onze apps. Tot dan!
|
||||
175
Les14-RAG-Embeddings/Les14-Huiswerk.pdf
Normal file
175
Les14-RAG-Embeddings/Les14-Huiswerk.pdf
Normal file
@@ -0,0 +1,175 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105950+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105950+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Count 6 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2045
|
||||
>>
|
||||
stream
|
||||
Gb!;dbBDVu&Dcq.As6.NXUmVi!5!:ja76.X]]<V`%7NSrD2M:`b"f;,[s%gM+=16tX<qXuq,$&^Qj5<dkCO*jI[I[cF+EEuGkR:$j"Qo%F,::"1H6F9T,kds*k37&+fP`-OAP.*.&TfMSko9GLC>F;L^[O-,pQ<B&ie/E!Z"&#@.MfU5Q[P^dKLh_k=bj+HpN/*dBlP/h[_[/\,Jtd?:b"F4*7h)7qFZ3[6[rt_BkFlJHqr1%/tYGnJ#:E;cUfCU<)9*/lb$7iI^Y$N_Y@"<L$Um9&PE7<`u\pX1\EL7[GfTWbqG$]>>eqTY\5=1KC.Gn7n@l2laN*oY-<^Bc[<@.=-c-]$f[2el,p0&qljlc.KSdNfWeoNZ35f1/l9`-7#,XqFPW$I49DbmmHHo)o`#ST#^KY:?E3%YRmlo`kl>P2.e/>dsITFXYD?"SMo1>M]I[mA9_t3[pN<r8a+A<gh4L"j8riSQn-$aNPsBBG-Q+>.RbKWZ1EKB8NkbGcGOgi2.e-qG'd_/[h@D/UReZ>*P5A7&W\Zg]"hRf4VhM3XD1FYRSnD(&oIET=tm4+>;!T'`Y)p8huWt%OjYB=;V5b?GEp57-"&ui)3>CkonFaQ9RD;)O41)NiU)Pin-bbYioU-dafs>RR-"t=ZPmCP*bBh$lJ=in$\:bc/<p,6>P"H0m=<Lm231gSK)SX-41F^fRSQdJ1Bk[2rmsF>'2/Y+(Ud=Q0QQY;])$9gl+U4Bk*',*I<Dl=h=m\m\iC*a*YcO#'d["OX8=,E)7ZfU;:WY4Erf@A-qZ2XjYuGsHC<=J]L\b*Z$/]'S/rBkBqF:0Pq'O5U4"V0m*/+*^KQZ&oG0lZd:Aj4HnL=df*:bt_AT2:aW?Qa'Zf/OQHSibnol$BC^#.;osdD[g8Mf)!$]s._!Sl9rWIp6#14^?_=b\MbRdJ,_qcX+HD+@6fm,>RfMeK(`gO^*O)'QXFJ6g0D@_QU^24Abd!\^2p%H1Thqb$X?E7I[P.f?6m<2++0j*i<43"i+Q*hN)'5FFAXXVhoJraV-TXdt\B+-TkoFu?/M9;#dZ&k'%P2_oRL3e[E,]&i5gBmeLqa8#1i.#[D_gQ1qN[i&7djnqbir!sXK!8+NbTr4WkAD'K:8cC"_=M,];o:Ho5n1d7&d=]'MFCE4aMJ3&j&MKoE?4-p`uW1:>T!>0;h]pjW%Y`RIt`+_eO+-tA(7FF=3Y9@_#p2g_Y^[6=<*akP300@QHed)bs51F99#cO9(l&0f:sTVU:)rT>q3*0L[naq-SQX<E.->.:4j;1r3S4i(<@`!.sj$(gJPQ(qReKO<b#UZb4#Ya"[gY<#`DEMpJs5Y"#IqXYS+n<i9GOK0RV$SLX[fJer$=a?KXK=S89S-0RAkjb\*-M0U#/N+m!Qq&JF/mS81#TKTO9]1rVH>D'<?412hX9oG_CqFS-+JKCki\<GVHClnfF=@Xu^7OejJ>q"[+r,!p$Cik^WGNte)G[]2$P<#KkERT-Gi*A-(+.ItZR6[RjJK[=n0Xbj_]1V7$\Ygl<=Z%h@pLNTp6oeK+h\566o4QT;#Wa9"K2'ubN'7WD<SZN,coOaff@9"l%=M`65XjnV!W"N%uln!I2eDQr2<?%pI*be,1^ON$adbX(9.FZI3FW!UoelS,*diAI3<;67pZnJQ<#j>35PqH%!7`21+djp\LPaQ:C&QVCl^OK@@;Vu/j?lnFQ.,,KX%ufNkBY>6V'd@9*8,3_jUkR9CTk^OVQ[1WO.Qhp'9eZ*"$>V.Zi*IMmGP@iDU2O5-BmbR)dll'J"oL&2IftRH^;u0[),?'0%WgEl8W#VjbnnRj8$#2]%)6QfCKgEW6<Mj5/M!ZoM!JCUN=tBXb]cDM,CkX^Ff?R\/Q3qOoaPsA7-4G!b]mMXqI"jnG?r-aSJHL)n:2KtN"'Q4j'^HP5C3ZZkdTI,$9O9sC=h4$m`g)gntGNq*^uJSn,[hSFofU4;+D/`<@?%LdD:P+)C#?*+BDT?qC&tILN0T;;f=CA/A8,C%k*L?i!^rII&p@>ZDF),q$15hmns~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 437
|
||||
>>
|
||||
stream
|
||||
Gas2E?VeNm'ZJu(.F+]O=F0joNu0.((klMsj9r7SVQf#;p$5g?5`fYte!Kcuqn"uo*fAZmYm.?EoLB#s87"Q=.3uC48YYs8#pZP"@+'$4$a<`N(TK#9TFE]^L^B)Gk,a/T`12`+7*o(0(6up50-W)c\9//&OZ(LTkp8,PK[u#pA)cDB*M;*G`Q[]4!bSoo=a36ETan5'0i$G;,\pZ?h*4Xg/i-ifQ=[VD6W<g"ZGD_9(5'`ZTW[mtnL94le$$p=g)&![P#ipBD6G16,rJVagJe,V?O(t;5P"Zo=)XYuFIqU5*9,%Q(-_DMe4RQIjsl#rY'GBM%FrJ8gK;B;PeB9YF/Sd\V'Bph[70JD`87fEq;R744Wuq"Pf,D@URtTNp)i.5l9-PaYHk6-?Qf<g;npI^hb\2E*KH6WkBZHCQ(Bk*[>!f`+]t3o~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1620
|
||||
>>
|
||||
stream
|
||||
GauHJgMYe)&:N/3%/dJ-D477KOkLu_S2M/c5uT_3'IX_fA1b-g=?#PT`sM\4*E#s#[Y=>sX2R[migcFqS/De=MANV6;$uf/"\13_0Fk-Z^]Y7RH4A2%c@nWqc_^,]\;Iiu#84Q/"6J+U_"3C)n:6(aOshbE-q9F.'QDQcP"@&ZI!Xd++O[/c#p>+N^@K%fGDXX+RMNj5rSXi-kFXNt"iTlB6T<aQK&I'f"Z;saJl(T$%ot,1rd=>:"CC1o>t)@f:Z,Mal=a8:RWAeU-\o`B'j%g"^`WDN-[Cj>XPS`_B37;O[O1ddon[Rd]T)qp=6Mi\C?"MNQffsg?f<og6?";;Vmr]hP0Z3c,uql@OAYl<N]Sl]N,jV*Hq5:GOnNdV,n?8p;P>EmY.@Vb0^82.`9Z#Cc61"0VAM*cK.+NXJHl:cbM"h+"2,gD#Q*]&@JH.J0BiI)#:kp(7kYMQ1H=&:%q/-UO.;bN8S,%_pI?kFGq=;jU,m`sEY]5+!mVN7`-%?CCsJVHnj6Hjc$/UiI,EogKR]%93:U+$`4^m)p[QGEg5mo3+P\07@(j)"AWapP]_27D.th<=WXL<n7<gAYiSKa8Oojs0nCE!P#V"L@*+XmN'K(cCBW+k%:dO2BUA?a01KLi;;C\Jrl=E.*JpBhl"\7:TMZs[r#ssW$$nnDSPLjmZUSBD.%5l-j7pe@V0q.Y&?>?t]fZ_b?R1XDXO_Cmnbh_@IkQm@Hm)Z&_eCFM7?r5otS^Y"T7!Vh`:km`63\7$H49^rKp#=^7#33X5/HiinHW1k!Fe!*f)*NlS7K_$e!polTlZ5G&X_s"A%lYCbH.83nVDH:$3"*"9_.B!"V:.-!is;YENK^T*lYEeMn@1WY'_.90S\7!5Regm.6_l#U-2H5,*RtuX>Vs%)2PR3(j(u>6XS:[c]g%9)IGtF1O%+WH;,cd,K[k2\_16<_i9>/6Z_u"`[Z6*UBlg&u1VMJ7;ja'D.Z?I2RnU"%(4NsslO:#<Oap?b!WE:mYR]W(^oa%L`9l-O/>DfH0+I5\pEA6Jfm0uL0^HD;[OO1]%rLeX#61$"-jS+Mk=nu1_401lm(S&C`rL#>@qOBj,otejFkr:M%=(^6HA)*#Ds_/V\\Q0fmR],$(q([@'A04&q8992J-_h_\"NRjOdC3W26;gJgR4[#VQXB]ebV>Q$S.\u>>GlARrtF$q*l`)X>6?#0kPf;e;#549]I\\RjGi=M!msd5ulMrMkj#@B^7\/PFcdpM:U2Xi0]\XNReDTNQ'\e_MZ(Mo5\>@Wk9c*?'11(VHoNZf,TgQD:>bi-1Z=t@rk6%GUSr1XnTQTMgon2>N1i!IQ+>jWR_GHB6U&ahin!8O%AF,8*%Y5&`2j5N+(Wl'8&F_(=2#5hs.\K[nMf\-'!J-8S[/V#7V_8e0=HlIdY`$!d%a44Pr<[AW(88o#7%hq4Nd.BmC-MWei<@^CPk$gYcSYmF8Gl)uU)bX#1OT(GK@$c8H2:=5^*N(ZK,oLI/XRi#(EUfu3(+#4tu6K75(,:4HSlUTGVqmNdF/-VGE"@@UWc;.t]?$GMs[o.1<na27_1dJ^9,hIC'5Y+6=7:'PZ?Rdk2#r6XiBT<>o7Htk<RrW@dBI=q~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1352
|
||||
>>
|
||||
stream
|
||||
Gb!SkhfIL2&:X@\Z#<.dTN\X;bC-pp9mZ%tVq:XPgcb/$Qj$amK3AK-rq3b+_)S,bg<4$0(DuM[ddFT:^>5&7\FebQDk$j@(rEU\*4UOX"LqtTL2Z!jk^7E<#Xg(h@%MDP8-"Q1qZl64ah\Ki&;c3qE^Q]j#uYc?<2;e+\hN1gPR*I#%L3aRDQ"g7`Vi%*Hg<g1#XPU>o7J0s^OCf2=a1Md2WtEYo-Im'K5Z?VXa6/@g^efMB$bo%qt4a7.]-n==])!aB2rD'X'd2@#_Ub-JME"\E"!Pm_R*EUQrdoSr!:1felTo+oR%uK2nWr+%2sg/=3JO`Z(*arhs"j<CQKQ(mtoPrV*JjYL9>!W2&U$s)0\2P7X6bN!G%5[=``>XV2bhLlUo\F'5B<r1*-Xo45t2A`(iB`2dC??9sm>#c&6s_D0rp`dfS/3_ut%+T/rL6o%c[B2<;2P@`R;u8El`^jCWBN7ORcZR5q[Rm&/P6B<SC]C@qd)D=l6/Z\WTB$))m5>u19rhQrQ>I:9d^q>Rd35H*s>9^LN#K)%MQiSLj>@IP5Z`\?\[Y[!t1a_c.n/L^hFD5Ha$l[lXO7X6$10&FLUU%\f3<J%Td(?7gq0<M5taX>%J(gS8$p&qP^!-.>F>;D"Z[jFJLKjqZ#ObIDh&L$Uu<<q%+XG]km`5D'e8<T4`8O+;'d!Q?@kBDp#5Z?opCiZX6GR?l'`fdWNER:B\:H`#`4($k[eNi;_:17!9SR9U'@"eE%_<cRJ820RMREpqKnq`G+:`qE."FZJ]6)uiB"KC80g&$+[gN)5-ntF6XBZbl:Af8X&<^iH$X@bXpr#c5+4FS*mG>`Ed'I@-JIr=*,krI*3YA%h.2ju5F8lo#Udr8;<W89I;%iJDUOt91IR8ffp`'oN;SUaZB&'^Wr0ZRUrPI)EIK."HRK7?1SV$Y^VQU&4eAjce/BT?2T@A_'R#.[Jn)m#qO[Zmn:"^d-59hcFkT0SSKeO.e2)k(O#ds_AT0!J%;*N0IaOXKCOX0;):JJ5^jM'P-?Ogq?i,FGL\'i#(eW4*eAA3Ac3,2Gr.:j]AA"LCnS'1Z]:m?Y\5/\so9rO>s-#CX/mUc2Xr>)BW=l526=Kl[1Nb4HXq01VV[F!to7QBY+c@=@PRae&@d[e[\:endtufMae9C_#8tqu??Pme'Wb6E2KF&sDmhc*EV[iU=5N*t.Nbkl-p4#mn5(M4-`&?Di+@c"b!gD0L)F1qs"dOl.-NH4*GFgXd4jHu3_u\EM@:o"J,i=6+YrLu=,.]J7L9SCAZB?u8OhZfea"*QfF6qNg![?)X5Kk3!9lH!Fg4&18PIKUt6LMOrX#_&MjP)0#QUZBW*7~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2116
|
||||
>>
|
||||
stream
|
||||
Gb"/'>>s99'Roe[3%s*]V(n8eg[a^Tc9PS@4*6h;d(\un*0IbPQE_H6p=Jbqlo6?Q#"s5F'&qQ+^2q#`F-?lSp`fDfaT)g*Y]iWg:`*L>@4m2k$%*RN2i^Nu)R09IT6l#\_2Wm7#NaU[+UHmXVD/,>74sW<0MYlu@h'4971kBLP]6<8+lGh`(NBX3-UGhk#P3@&U_H6R2[I<GEIOr12sSA"fP61V2K&`9&Yp`'U&*"+8.YqYb[BV;^V:i-OWM[qR:r_\]ImbNWSrHW#kj;aqc/n^[2d#0Gq5.417?at@JCjtDiCb=54\lCq-J.bs$Tnd4rd;GlA$po)r+@H:3-G%&*PefFbUUY<n#.MY&>GHCotYs+"bBY(:"K,LB_Yf[<nbj)S)H[GjcO3m3q+[.&?eh_/)g)FO0FaiIi&HmSSr'iW)#UT/:%="_J8X<,hP:3<XgXo,kXUaQoWIN]5QViD$4\41V-6A"GXna#]K22/BsNBUm+hDs6cC5C.)\]7e+eEZg2(o75RQHf&$DQS`;\]_:h^M]]S'+[2Z=5[nijHh[h,T2op9UPh1`\WlC=oa4LYHo<6k:UkC,qt!JbV!D.uBG[8Hq&D*bm$'Bqe'f*BD)AW,)GD+@3u90"m]"i?&f3;G\87_;LO]iX*#<DA]ZW%rI)1mX=dkC[(Qu-@`[I+;2WqNt"AZ(A%[EWOM&bSCA:Pb%%>b^3<io>_UE*^e$8YhG[)15HAXSROP+kVsbLorP'7$,qBW"d7e`.9`bLKRk5qRT;CY8O^jk.mi4d,cC/-dDj9MKheX&RWT>],2c@i1KB)!N.<gNF_'U@j@#2PApMfjH/mOZ#1_K2Ud^(^R14^DJ*ci"((1-aJt`-RYP1STu9XU],khT1&V+<7P]lr6*4ZK)e[(+'*==[M30Z<?^o?1+CBYRc8m1T,0?N8h/D'f2e:=/*\dfW.Du1<7DGn\=t&-$JVmbk*R%tUt4`DC)0<TW[aK"_kW)=pR`\+Lq>F8R+Jd.F%KZM/;5GFTg,FIdI\'`j?gc[fa4H7*0f]4;Ie@\9p=PB0V_i+$_PH*D#^9"N=6stDGY)-^%tKS[5"XZ=Y4_npW9[Q+m3c0N]iBWGqfqDYG'Cf4Yar]kgh*$eUX_+f__Ui=oMRFYq=&Es$f`t`L\L+]jr>P]i%XA3FcbPj3lM!>hUfL>u1&3CkF=l5)nnf?(+)taF_jl67<\m%/X5pF_,hI7(I`f@P!s\W?nC+GGWjPjjj/AG96A2Lf&dC([Xul<i_@Kq+*i^]-`9DbXj%/Y1)+B!1Xhqq$FieZ>T9k<Y[]NZ"7G#fUN9kLP>&1(:HT"OQ5g:^_,CYbEe=FJ4l67Hr+2==.\93A$lfFXa'<W'O0I:R]SQ.n?@^Y-18dF:,/jH!OZ%ti\`K*CbbYB[qm=iamZ)7ho.i$ae.t/%:^:"q$+FW-A>VWJ,)'gju"M6#G&5)p+oj$Lt<<BMg_'F[M7C=dT%KrF1!pn9/KWqd93.gAgW_4\f.n@=pqd%?P(oJ%n3K&M5V\U>c9`s-9=pV401JLRc1aIaXVZH4A09A[<ma]r)o\ffT"E63o$"+/2dcJ5un>,eo7R'b\S5kR\@[c%5S=P:]16Qbol3!a4*C"c?ss[a>'*ufgj'li('+QZ?b5m>6SA7k=a#g/rQYkN*)O]8AMe;2pSHs3C_&h`MJ`2T>V]sUkLg``*"KI$6-Ms8nCldL4R[e]6C.:pMrq-8P`F62m_YU!>8pa(uk(r!-=@^Hui:>XSM:,o/mDJi&/:^?9>%[oW5b59]4YEaIQBL((WBuIH-+f/-a^pI@a!CIPs!+9kN+j-RL1EQW0p)?eG=>9dT)*Pj0$be@!S<4h;BnHCCilG8V^ZiWdhU<H#cg[]8gC&buDcN(nSek/HQ,Ar@Su'SBO)OCm-`Qm#0h)j"[s=1";H@:WiE_Cbts[)te-9H#IOp+V_KVR!jeJ#'YB/MiAdFTH-2qf2`>pU8ta@4b-/HPDUO"ZJ0p=[=Xu>"Ng#IFW4OnVOco2F/V]!a_)]YY<Y^g?+n2E5to9rkAW=m]'H&:m@?VC;@VdBp-V-*)(6(&P>c]1rj4(S_Hn+am&KboOY)sKJ;;b?eg;R3<'&-Y`D3~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 775
|
||||
>>
|
||||
stream
|
||||
GarnT_/A!]%)(gSGT"jCLaq)`,TjY#710gaML!sQ(KY2h[>pbK::fiY8(hU>VH@Y:U!<?,6p:q8FG]O%QN/*W34fkI18l6Z81(%AJW"::CW8,pBitGZ%F<3+=IH'/KQcLO:e[-W.DToIN0YTG@)eE81;[`W1^&Jee!U35("YnV1SR]8]trl*AJpc!XAGoZ`c(O<,d1`nX,^^c2j$slfPYHERS1XhTaRfbE:WhJO))8j>E!aQpDnW'!ntTnG?Lg%r-Ef5UPf44n-M&+orBD8iB:/$:n=Ie#Q->1R*<*ic$tA.jMTO%`FfTT'CXPaFhS'&5]fXnIA9c`N1#8j)?tV=GE2lZTt]uW)INfSSQhq>&S_VMX/X2\3XVf-7@o+Kk3$J$(1?lfi-@ZV_h'3[pG@JkI=W)7_Pafrd\d%lLZ9VQV6us)^<N(;I+2q?!C'Arceb@6hX$)a]JR!6<bCENP1I`eUHSCJk!eSu7?\hgfYD4fUN-*Cgh^WXQO_J4kj@$QK*cSFGoE+gI=??2`Y43es(7%Mb,sY_``,`/g"Vu[Vr@&tSD4FQTu&e)&dO51\O,$D-IaVn5R_Y<<^KNk2gq1D>ZV.hr./PY5^jV7eGBFPE3f9PM6ZKa4!G-P\>fE]dC4QF/C(@:lG<-W_I*'$U$^IV;<#X8g-<jPpBlOG?3_9TK43<@h5R'/%ZBYG%K/O1RbGY(6A,:%\Eq1??AMPc)]DQ?]"+pf-;G0oi46!X1ZGB"EG[;I:#C*gl<L-KEB@J\qZdT!-V4~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 20
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000741 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001737 00000 n
|
||||
0000002030 00000 n
|
||||
0000002121 00000 n
|
||||
0000004258 00000 n
|
||||
0000004786 00000 n
|
||||
0000006498 00000 n
|
||||
0000007942 00000 n
|
||||
0000010150 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<fdfd9a649f26f3dd357aeedfd55ab30b><fdfd9a649f26f3dd357aeedfd55ab30b>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 12 0 R
|
||||
/Root 11 0 R
|
||||
/Size 20
|
||||
>>
|
||||
startxref
|
||||
11016
|
||||
%%EOF
|
||||
277
Les14-RAG-Embeddings/Les14-Lesopdracht.md
Normal file
277
Les14-RAG-Embeddings/Les14-Lesopdracht.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Les 14 — Lesopdracht
|
||||
## PDF Q&A app opzetten + eerste embeddings
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Duur:** 30 min in-class
|
||||
**Status:** Tijdens de les afronden — daarna huiswerk uitbreiden
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Aan het einde van deze opdracht heb je een werkende **PDF Q&A app**: je uploadt een PDF, hij wordt gechunked + embedded + opgeslagen in Supabase pgvector, en je kunt er vragen aan stellen die accuraat beantwoord worden op basis van de PDF.
|
||||
|
||||
---
|
||||
|
||||
## Vereisten
|
||||
|
||||
- Node 20+, pnpm, git
|
||||
- Supabase account
|
||||
- OpenAI API key
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Nieuwe Next.js app
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest pdf-qa \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd pdf-qa
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js unpdf
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Supabase: pgvector + schema
|
||||
|
||||
In Supabase SQL Editor:
|
||||
|
||||
```sql
|
||||
-- 1. Extension activeren
|
||||
create extension if not exists vector;
|
||||
|
||||
-- 2. Chunks tabel
|
||||
create table chunks (
|
||||
id bigserial primary key,
|
||||
source text not null,
|
||||
page int,
|
||||
content text not null,
|
||||
embedding vector(1536),
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
-- 3. HNSW index voor snelle search
|
||||
create index on chunks
|
||||
using hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- 4. RLS open voor demo
|
||||
alter table chunks enable row level security;
|
||||
create policy "demo open" on chunks
|
||||
for all to anon using (true) with check (true);
|
||||
|
||||
-- 5. Match function
|
||||
create or replace function match_chunks(
|
||||
query_embedding vector(1536),
|
||||
match_count int default 5
|
||||
)
|
||||
returns table (id bigint, content text, source text, similarity float)
|
||||
language sql stable as $$
|
||||
select chunks.id, chunks.content, chunks.source,
|
||||
1 - (chunks.embedding <=> query_embedding) as similarity
|
||||
from chunks
|
||||
order by chunks.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
$$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — lib/embeddings.ts
|
||||
|
||||
```typescript
|
||||
import { embed, embedMany } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const embedModel = openai.textEmbeddingModel("text-embedding-3-small");
|
||||
|
||||
export async function embedOne(text: string) {
|
||||
const { embedding } = await embed({ model: embedModel, value: text });
|
||||
return embedding;
|
||||
}
|
||||
|
||||
export async function embedBatch(texts: string[]) {
|
||||
const { embeddings } = await embedMany({ model: embedModel, values: texts });
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
export function chunkText(text: string, size = 500, overlap = 50): string[] {
|
||||
const chunks: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
chunks.push(text.slice(i, i + size).trim());
|
||||
i += size - overlap;
|
||||
}
|
||||
return chunks.filter((c) => c.length > 0);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — lib/supabase.ts
|
||||
|
||||
```typescript
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Index endpoint
|
||||
|
||||
`app/api/index/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { extractText } from "unpdf";
|
||||
import { embedBatch, chunkText } from "@/lib/embeddings";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) return Response.json({ error: "No file" }, { status: 400 });
|
||||
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const { text } = await extractText(buffer, { mergePages: true });
|
||||
|
||||
const chunks = chunkText(text, 500, 50);
|
||||
const embeddings = await embedBatch(chunks);
|
||||
|
||||
const { error } = await supabase.from("chunks").insert(
|
||||
chunks.map((content, i) => ({
|
||||
source: file.name,
|
||||
content,
|
||||
embedding: embeddings[i],
|
||||
}))
|
||||
);
|
||||
|
||||
if (error) return Response.json({ error: error.message }, { status: 500 });
|
||||
return Response.json({ chunks: chunks.length, source: file.name });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — Ask endpoint
|
||||
|
||||
`app/api/ask/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { embedOne } from "@/lib/embeddings";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { question } = await req.json();
|
||||
const embedding = await embedOne(question);
|
||||
|
||||
const { data: chunks } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
|
||||
if (!chunks?.length) {
|
||||
return Response.json({ answer: "Geen relevante info gevonden." });
|
||||
}
|
||||
|
||||
const context = chunks
|
||||
.map((c: any, i: number) => `[Source ${i + 1}: ${c.source}]\n${c.content}`)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
prompt: `Beantwoord op basis van deze context. Als geen antwoord staat, zeg dat eerlijk.
|
||||
|
||||
CONTEXT:
|
||||
${context}
|
||||
|
||||
VRAAG: ${question}
|
||||
|
||||
ANTWOORD:`,
|
||||
});
|
||||
|
||||
return Response.json({ answer: text, sources: chunks });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 7 — Test in terminal
|
||||
|
||||
`pnpm dev` in één terminal. In een ander:
|
||||
|
||||
```bash
|
||||
# Index een PDF (Tim deelt voorbeeld-PDF in de les)
|
||||
curl -X POST http://localhost:3000/api/index \
|
||||
-F "file=@./polderfest-lineup.pdf"
|
||||
# {"chunks": 30, "source": "polderfest-lineup.pdf"}
|
||||
|
||||
# Stel een vraag
|
||||
curl -X POST http://localhost:3000/api/ask \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"question": "Wie zijn de headliners?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
Verwacht: JSON met `answer` (accuraat antwoord) en `sources` (welke chunks gebruikt).
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] pgvector extension actief in Supabase
|
||||
- [ ] `chunks` tabel + HNSW index + match_chunks function
|
||||
- [ ] 1 PDF succesvol geïndexeerd
|
||||
- [ ] 3 vragen werken met correcte antwoorden
|
||||
- [ ] In Supabase Table Editor: chunks zichtbaar met embedding kolom
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1 — Setup | 3 min |
|
||||
| 2 — Supabase schema | 5 min |
|
||||
| 3-4 — lib files | 5 min |
|
||||
| 5 — Index endpoint | 7 min |
|
||||
| 6 — Ask endpoint | 5 min |
|
||||
| 7 — Test | 5 min |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| `extension "vector" does not exist` | Supabase: Database → Extensions → enable vector |
|
||||
| `dimension mismatch` | Embed model en `vector(N)` moeten matchen — gebruik altijd `text-embedding-3-small` (1536) |
|
||||
| `match_chunks does not exist` | SQL function niet aangemaakt — stap 2 stuk 5 |
|
||||
| `embedMany` slow | Normaal voor grote PDFs — geduld of split batches |
|
||||
| RLS error op insert | `create policy` niet uitgevoerd — stap 2 stuk 4 |
|
||||
| PDF parsing leeg | Scan-PDF (image-based)? unpdf werkt op text-PDFs |
|
||||
|
||||
---
|
||||
|
||||
## Klaar? Verder met huiswerk
|
||||
|
||||
Het huiswerk bouwt voort:
|
||||
|
||||
- Eigen PDF + UI + RAG-tool in een agent
|
||||
- `RAG.md` met analyse + fail case
|
||||
- Bonus: hybrid search of streaming antwoord
|
||||
|
||||
Zie `Les14-Huiswerk.md` of `Les14-Huiswerk.pdf`.
|
||||
175
Les14-RAG-Embeddings/Les14-Lesopdracht.pdf
Normal file
175
Les14-RAG-Embeddings/Les14-Lesopdracht.pdf
Normal file
@@ -0,0 +1,175 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105950+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105950+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Count 6 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2150
|
||||
>>
|
||||
stream
|
||||
Gb"/'D,8qH&H88.0fl9K7p0=`06SgF@XFqqgnBFfV7i(S:e4h^aX/1Q_R7/X`_"b,_X^UmMXEi7[##h#nP&l)1ZN%3n\a)LGR!Y`$CbrO0FlQ-^c^VmF;MB`c@nW1^kLqRE@#`p%O?/h+&o.5%XpZpOG=5i'grB>4NWDDT`gDIKO=j2"eX?d#"/Sn8.UMeINX7^o3DuJN_=`;%ta<Ir8'W:bmR+i>DHg6rs4?(=RVJ?FZi'Z079/4?u<4D@0_63:/r=,'h&=^9r6jU#*(YZ0]!\,lT2-eQ,">]WNr7gWP&$EVOq;.Y1FTMHe]i.oY`NaY,]ls^e7NO<FhcsUXM&\J.8a>iHnj\"hZcPE#B7]n)AfOq9kf6kKOuBpt_F:OC9@ZS-Ji3?j?btEn_'HG"dNCQVcRPHEQWCLauR5#*#ml)eBd@4g?efS2pOWVMA>3C);dKMIpDoh2h"X\efYUS2n+0KLs9koUR44gm0o4ho:0/4*M:b'lrM"(PGEG9C"YMqb^r[e"_=M%HG(bTb`XA`W!:6)U'QmA!1!8OGH(.0jYW(VE\?CCqdkG!Z`sc/D`n@JAiAe9Xrj]JL48E6H`iQA+"libCS#4"`'sO]b<FP+_TL77,EFb(>_1r6WB@JP[@G>W#T,o;ADYAW7d#"Ou,D*mu7uK(KtT.:5/jEeGB8(""kSI5$kl7[#MM;F!GX^q%R)e9gP,Ws(,>!IVD=EHgO+&Q!F1tS\JDbH>$<7FOG*cU"F0!qgMgS?4Jt^+6CG1q7O;rMhcmko>8J>:%PpPn2)6+=]F!sH133e%[s>af+2ho6beK1iE4h>lbhF5rLBh!L."=^&JBMlMB"^h3(]jC6A7XnVtH=Tc>T*>m[>SZb.`pN:1CkCUTtou\RrY%'`.f1m(@(u6,F&C+RDJs70sH)a+bsp!PEBCSLD]VcpT7L63W2bLpOJ<H!/<A%M"HA[_`mVY$@8qmKU+VVut.QHBMuV)>#Hc$EQP6q\\MU-0l.I1%2#:N>1Ela2bP/d!aBoc5=jbj#Z8H1R0=HHm'XB-,k(_6KFn:P-E-.]qP]5o1.HsAG!^,JXg/L#_g\i?ki(Ko(K/FnQ2Zj-&u<X]?WW8-YpfW7q<JuX.\"mG1t[AmaCCd9:2&K&LI;L#/k0Gr*1bSQ<:fN9n@3A^)tSBkTm/CPc'lC_MW(1T;`5OX6:8.SYU!djg?Lsk>`3RE2')>\LNIdJLgZ8@7:J.P1FRijj;[K*ZbAh+Gmn>kLL46G^qe,PkUJ40![gT?Jkuh+^h26DG;58C!=`LPCl%Oqb]C]"ant_0?1qjl,+H6^2:Dj\hN2M[,,2#Y&22r!u;n3)S!<'*/77WI7OeY265qm_7.uoCLfZ_\2!KcY9U4%!N]iH<b-5>ZDOQ+%$NP9>ZYCbU9jNs>U?Iuc.B-$r3jndibq7?i,R21-qj=W<2F\?bATuH[Y>URm/K"TF#h=O\!#kSC#t-WEGJ=cmHa&B7qVKeJapVAM[_*+Ejc"Hl0XmE%[-;TGNb0GhC&`r75;QP:6@HX5CkVOnn#"(q03.aq`Ac"^-#ZJ>^[1Q./tGm:F]KE3suhTQR+h3$bf34k#Vm:lL0ITY'%atn'Jq"XCh+N)4D7u,!;04k%[O%a0S8sIEiSZNd:KB9(bU,ASWh`4Xu1LE\V0Fq@*Q>/F^@3kr6uP1su;Mdt%nX%l+of*e^g.bmP]Vi%_WIe?5XI+fu:)g=jl^hUpSJeA*kO]5r'/1D\#*cR8+J>OS%^<er7@i?:"t%3+8DD.VA")^pnGUPfD<N`Q2*Z[d+:mp+."4FkhXq,44RY0sW'N@<&S;Cjt$)B-B^@T6G!-qPq*UpIA*E7$4rCA;LYF8`#KfAa:dqNnC/XZ5Q>]R.rnPhini,mcsgCdmTe:<kd+<&/9=/J?^9H\L<]gl.?1o,O+-QbonD./_>!8lU72Z6G=7gJ;Kp-)PrTjo6H*I+0sl:s0*[i_)Turg@0a_g[]P_"?hu0b"dIVMA>/C=ddk?s-t5hZ!4Z]Q9$'n+_t2^L#IR,=9I10Qsc#kOa$!nFc4p0?4<Qqr?;gKC.^:pUdWSR\m/nDJgZRJpAUo*mX):^uNPnUpX-Lq0grAe\&a=a(4ri0gBA]G+Ts5o.DcT377$ODLA<nd1SCRq$6]SFqX~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1226
|
||||
>>
|
||||
stream
|
||||
GasaogQ(#H&:N/3%"`os"rg%EL_lj=OJ31&+D;_0l'$[P<haGR-.+5?g%b"tZu5TG[:1'j8WdZ2ba]Q-6B#8tqTgXAT@`T+!sQhT]`fmAJFt;bFWEV6KLr%/!efm,)[=7Mn:>0feW<<G%5S]?XGBmYKVh.QUOhAaV5T!c_Y/+?JLDBd/V%N'81jjaSeK3_RMI02d_%\,nC)r[+`^tG2#AAHi=7n.25,%V@[a4ueeP98s5U;Dn=/1c2VU;p_AD+^D!tfMR"G&Shn3m^U@gF"?gLiu\jL%EIgbSW@Jr7>3l;b-&8$U,`;a@PU^q]iLV-2+kWdO%;5_4TYWcQ%7ki$hZ2mru8MM;!TeSDaS?IA16dR$911:Kl)lOsS<$1UDXc.pn_ngV4b).ILLt2<Y*qs3m[)T'hMDK^5Raqm%JgW7(F%6%$'8(!&U#noaiBZ_8g[P[fcaL,-T#m3VRCEaiB5cp@(@@l\]#(om_:oJ?bXoc.d=Y3>EefAeAJ1A%LeN&c\u`09[pZsr>IT<eU"D^!4>*9N>52hgn!\J#/VXR.F/"<tGZQdP%&[JL<djg@>0-DCrReu![9t;s&Qbn%!(@A.`NHUCOd9s"MCNJ'n7[l,$Qqd"R+#S2BjK>$a916_H!PW*GmB+HB;):&&f-fZ>^NEneJLXQ!c%f[<"k0[2R[q9H!]"4+mb[59t9!6/tl*2As`A@k4^<&.4X*KZoUHi[qSnJ?I,+kk&pbYNerRO3!!o7(QqS%-4*`:p$>U'8Rf%dc[JaSp)B[c"p&g_E.)*&c_uPaZ+/+"hR<1Hcn87[im_(*`mW;ik#,p%50Y#?$tX*sd+YR:$@c<t9c2HV\o?Zeas&paX5$_cgqhRFArd_S"BMM!B#r`X23=+MGR%rB&:HAn/)PGlEsp<Jh]o5I*iHCl[S.@0Uf^.r2PF6lk1h=pdb$pT@V'Af3qK$s<2n4;pUsp,PtL(i:mm-UZ_u),a8>lOpB^.IX<+IS8Z^$V.u%Oa*$p:NC96lm\fF\^=nNdZPjq1ZhOPHG:5Z&"du\t0Q7hi'`ISO'Sd9]M#Zf-UhK>MpZqi*EhJh2cc-:A>Bo!=(Z`shi&gK/FL(sI>3#lU-o:G_D2b4Vhr9++jGj>f.@t9rQnAC;O^!$lh&_YJB\Sasr;hQYab-uB@GKHHJnBb\*3S3=oSe"?d*eI%5OQc2r1a&.KLHcU,6G8?0E;J_Zh8MG`7/m7[-'r*~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1376
|
||||
>>
|
||||
stream
|
||||
Gb"/&9lo&I&A@[:m._j(&rc8i\'?gu-<&6d9f#drM/luPfO@41MF]g(4oXnUO;()CNgWg@Pr&]U`4k&UqZ+tO."5=r2d6AHT>ScD#)pIt!gk5Gk-*^^4Sb?UY.9Z%Lp0i<%`F4nJaI#HK$m%KmMK&@Lc#g@A].bbJYShlbeVfI`KPrU0q'"2JQSeVeR/(ZU,g9@OsmHgIK)Jq)q&dqpXruDh%.>@#4%2)W'V->PYj,A_rURDJ"OW^eYTIU<E0e'=0qSQWdt?:#+hC3T%.si2gu(JphO59S$ao=A!m8uo-Y^4!+0b<\<H%ds-9cBIDDQ1/@T>HrZf,#b7qkplOEEr"4OnYekf"mTEb;`.-ZRREib@+C02igOmY!YMM)p8WO)aH\,@jLR*g:p;7ehk&oDm0/7S]1RN0Uo$6lodh40kJcB)Id]B^YLplt.fdKi6NPUrO*/.0%H8bUH7N'ZQ.Z:\;+Kl>TBPF"R"Xai:6]gN'AA!CrU\,_8n_CF<K9b."d=@_u]EIb5sHq3suplZ<0*,>3Js3E%J@,e:&MZ!F0?bc?YAR8]oUoDBk+WMt+nQ$9]+ula#3-N_`-%QF7kI&.XM);)6K@*^FdaI'`2oUKN$'j!K/Ts'#9;5-Wlk%dWKo.c]W5()M[\0dm',-0;7,$N$;&F'RO(lP,qaa!gG$b:+BA0udCY<&Y-jp'Ym2CCD'ok9!GgO)G7Wpr<f:-O0^b)q<RWcSeC&&Y7i*oomi@)s3IXHB]Lj0,<P$B"/<Z>DCH%buRc"H_?_b"J^KiE:C9L*u9Opk$i0!;s'j(;:o2glFkdMHlR-UJ`$e%F<^Z@00K*!P@Rl?"@bof=[^UW)b*IR95P5+Z-64?5RG0<II*)-gF3=d)ouQg!b`0.%j+;lH0;k-6n#gQD8iP?m:s(Z"g>'S<EY62E%THR]ep>?HZD:C#BCiArQnr$d<GE&V)ZV*n3/qk.0N>]%Z4bRnc*/Q'$:>OT1d\OI7oXF-OEVIdA`N7%MbD6d/3E4ocVmCR@uRGtaQN;OU9omN.DZ?_Dn\tck9#-OB4'l9<8$J4s<4!$B,B_bA51Hah/FX(GW?H':76a@r]g$5U80ZVTOANYZ-s48JcMUEqQf7$r^!>04ia`dg\\R#?"r&2inLPYWsh$=b*KaT3sAF"aQha!3NFMpm8,,M][GPebi$P/.e%R7?a,_[m)S(2a(b)d1*of1=M5W2\dY=VV.H%m8'5/kHhBIR.*'<H/dQn47fPp-sj_Us[*(hXlXZQ8-P#Qdd(_>+RM6nY!`1Wu.QAbK,0j@/+<R.hjnC<iD;ee83_JW?&%)t/^YiV*Mp6JCH!GKB7L%]R1_4/D6"+j\?.^B%[o2cY=r&'C@EP5bNmb!8r~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1166
|
||||
>>
|
||||
stream
|
||||
Gasao;3RJ-&:WeDm+ddXj/s[O,iMKZ]pnYkRE7:"iq0)>J]"cfC;:&u\]i=spF]jh^3320#JjU@mC0-7-$*I9nb/Pb!4OI\gi;6R<ZBs0-*5t>0.ZM-6_T1!!MF@u*t#g1Da%!8]c7*-.MO$2__`8`5oPd'KE_STC!AN"(,i?Y%]C,k7KOJL.&6e'n"UMI6;%`O$=EWOq3ctDM!E'ABQd_aUD`use:?gWA3A3>X>61ApAU,^n8#;:<O+-44a_FL9m.!j+Z.uerSaWRd`U6#]Wki2^,TX]GLs(q!ioBsX:ot1,`5F`F8U6j,5OPlEEJRt!<"(tVKiU@"RC93CCd;\OfVLj"%*PijrnA"<)g55m#'[7c(cb`#fn&Rk"2\eJe,<'-Oji28(4[l$CPH$e7/87M)VT.&T(i+e6jLXd<]uo_PhA#^gOAXeG-?D3,2quIV'fA)e108)p.I2A`.n,+0uh,'6!'Al>!Is"H,>`_H+Vabu#i9CDUG.\54:LHLc3jEf1+>LVk<"Ul.':gW)`ceBXehrd4GG#\fCr]Nb=k3u"ND-a8Yl(>l6-KP/r%70Zh9ITUNkI[kr['fP)4>'=i9\$8SLF%CIsrq@;uc\m3t^M)f3dsP@oL1l5jfF,]!I>I@Pb.`Dnc'+f*i3<V*-8C7<KsH\S+5=kVofZ)Y;s0!1M]=pns0RYqV0uI_8,H-;b!jqOF?)6<"p=.L()A#Ao#eeG.'kk;Mr?P3hr</5(@m!h0q(P_'+&Z\0F3Y##>OrY!.,R<=r5Gd)XA-cN%;E5a!kCBgKm)H^99I7>7u$q^VT(iGNPbRj6tBsj5*2>\?Xht'-$r")$GP;7\=>_"Kp3K?^;A%7^J,pS7Z?@gfiF=CpaCIeg-1<G`mBtnF3^f\1eb7Fj@!%F[_f)F[rcmk8XQCRbMq'Y*!FA?Ic;.`cQu:MIp/DBkS8)=+S#S=6S+V2!UoqeoqW_/O`:r,*Gt\bq!!rQBstf^?Z#ecIi[ANeP>$5*,nZPbk/HBauSkn+BHfbpGJ?Kd`cP@_`)EMRlLJZBJB3B6FbJlt*7`@mc6J?k)d8Ss96#oWJQH)5-Q.)1\:tEF3nZIFPtB^!GaX$9Gtk^CnT_V0-pKdgf:s8157g<(0R$4-k*DQ[*N@XkWdbl1\\?rd8n-KD,mU86)5~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2012
|
||||
>>
|
||||
stream
|
||||
Gb"/'>Ar7S'RnZ;3,f8TBk*U-/u1V:gNj00T]ftWg&^fl1,9LnOX3GZdnAj'^8&<1UfKNahFh?'P)dWPgck9KRK:A</,jlgJB(S*+#%(BVT6J'@_q[_4I[8P#2p%.Q4:cse9!\^f?F7;3AEk)*TFTaAK[rp;HR<u7\k,PE1A9Y'9Hle^2G\J_P';"5U<ZVl!0]r&r1O-D%YNBgoL#sqdaT99:X-c1pd*((li!h;[oa@&^"ou_fQ4&=0LMpJEP=::;'(1"n6l)BQ'$LN,o$7^)6g;VLFuu3BYh.3f)dl%^S<cT#1,KY,d1nEJnef&tAYLFc,;`?D;\9W!<*X3q5Q@FpI=b`jH"lG\*tLP*dR2R#e@82bXX?1bHag?tjSLPdcW'0Xr6i>-]C5Z5R9d-61m-f_$*-BOlA*,\&i6,B@doCI<_kZ>rWK5V;KP_"p5K(qsSK9A0Bn7Z1p+eqsGL)C)<)N>ina4_%n7]ZNHMqSrh$IgQ6=6rk#dUlA6g-0$=+`&4ACMmPL8]F\+uq<GA?mhGOLiRdTO:2K&:G;o"%1DPOJYq/$?*R<]dhk8P[FKfSF#_@QLl+e\Kiu2[8,qTHbi1_I/<4k%=+j0K0a9'8B1nWEXRRQKQC/NBDaEieXZqK(mORB&"kfP/39Ci<oJodEIK,P+9#su$13?s3AS_(4PUTL\h'XIC_[^K6.>Ar6M)bFIi>NCb"`!Md?S8k?,^8*b@=7USE.ZVP!p;\XG)^j#_dj.S)h@(;*`ehf0!<"p<Opk=d-1p,?'mc8iL%+6tiH-tOo_AW=e)r6PB!XY1lp7J4=Oa_c=9'u]dgRk&R%7`ii?>-qb0P%!4=@%@'[DH>]q;`c15YNj!c<JQjcQ.\rU?UK/0qDd4WDu;qN6J!Cf_D7DAhP<"j*r-2-637J@AX7B5,DkF;:i$Q"lK#hJ>/RWm"[p-q=a?qr5Y4NcYkUr#h&*o&W7!m%j<$+"HJf'kS,`pNquR,a;iI'C5YMi-shrV_9q:2cr@)@tXe;rAhcWP^\gmMD<FE%ghmt;3+mq'B[qa"?ug1pX1:#Zq<HorbFD"o>a0bR&rSj#BNUEC/o]pCpli/EA,52+:N]hUJZ:mk`#da,c5G;hRYQ_N_:or8q)IC75.o"SENcs.aq<1c\=TAYQ`/AZ6gX9BWcso7'R5eO05"kfuT%?90`'rN?/j;2K;D6)mD;YQ8,].j(#4.lr+TEF$p5nYFi[c$L:Ydb.*cKlS</B&uC\OG;&:D[f4pSh8Bq7E\;:T6&s]DhH*\j&tQ.f&9jm+`lRF[s0r1HVOqrc.H7uN,urB:C%eR+YITsADkY'hR?Qo+hm:eG8*lu=EQZ".ML5Um6p;p,XAmP:$''?_[06O0HQRu#\INbn]asP.Qma;NhM4*d3N03UOlX2Oo?d+nNJ+`J[+2SEhIiUHh8*We8HO)4H8^`"GR8G>d5.Utghp9"3qi(3W(l+u$"3Bqd<!,JbMpWg*(6A*(/^Mp9M0$HhYH,8ItlYCp9b6Oc-ZP#Nr;4-r.U*i.%'>])#6<%r(sS+'$lUDgYk&Jg)V"nK$-I1cV2-MleC$pgXW-)`WBB#hFLm#32>XjGZ&\pUZn+I:\)4[n3*%<YAmlC#/-+"9%%A_pFZ\n"iPp"hYS5*qPPLb$Plpjo/J,^c\(o(?L2PM!r*^0chhl,k$iSGE./LrSJEIG*uC\bI]C;(jM(GtLs\-Tk6<MEf1PYDKi6e*0NQMdJB*RWaqhj9/B#ot2AM7:R)P,'_\j+s_o/^?Qo^^FU,olYr-ea<D1]eU(Y[',"L+ZM@@A8Y'qS%jEF+qAWtX&C[7^qVmTaRNI@#jlGFGKU*P_c%rp&u9Pl(=[kT1Y3"8N^V9t<?0CL,Nm9:4;X:-`4.i6B^Q4</_#RU8G`*eF`!_jP5*8r'tG=srnnIWE"]5?hFNPu\PN&qq\q^_=g-?C!W:%jq\3=[RVWdbBr,GNe)%Eq)`CbHJXhaGuKW\n_qUl6t$KD!p?K]k5(7iWI1bAb'j?~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1371
|
||||
>>
|
||||
stream
|
||||
Gat=*D/\/e&H8h>EF6:()9e6X.?PIO,"%MO?-kF5RfPm,6'3!gNSZ/,C%Le;Uh!VOS/@`&,a,`cmZ)\i:^eb$RCgI!^`P@WGK,/QR,%iX7)8-j:1rF-O77[T63?ZloecuKIJ\SAmM-iH&nnEc-lN^5$4r(,V6g;aIUU;aW"%N(Gmm4\cqk#kMTJ*echr2(E/pUqO[5YLrVQMW9i$tGcOSFjIL(ZDX=n<Ro`_$_L(\)Y*.5K53JW[WgbePq/2R(Teg=_1E6EM4a6/ZHVFd9.,]I369ib<*.G@.VI=p48NA\Qh>S+&,\;>]D)GD7B<`Vd4%FAIVH!3TIN3KI(?<:M8mun3[,/-i?>`Vr6h=GW,PV3(M5jiV`]&k]4Gs!@:$8ia^Wj`UoK`N9VaH]Kd%:e$uMD5Jr$u*)t&dE0EFZ)SgX+;*$>dZAKiD[168-^7u7#=(<c6C0OOC[fBPVK1#p(j7[Q2PmLs6e,%`t:9RWi*pOfbc#?1%#-2>[L(aQ;*FkKs5H`\*kR>^u9(GTAp;S,h'ij%K%/TZH'8P+%deX@\BaE]?F[["[3@UQ%9eIY\+nQ]5Q8IDo0JtmJ?'e2Os3@+kl?S/9^@9Hg+okE6/%aY#'Ibq<Ko?Q@ugqfs)Yj+Pn]eoI6utZHRO]B4MrID$gO>b:5#>%>su&SiYn3=9eVeW3fJ+o<U;CPElBbI5GqJeo)]\;'h+*"nNDHAm[S^c?*2XG>Vl.,\js2jEERhJN(#MM?m@m)_V31*);[ZI6Mr1jA?jLp*A#^S`Y,EMg:_X$j/dsbO*./gHur;Qj,OS%8(QWQ_D<:<r^*rAb".F\!cF\VH4RQ^F\Bqeki0>Kd\G5-BFmV(4OAhW*0$&9cI*j^Q](C)2iTb0a1M-AIW2SC!i;o?OFL,AVK5HAX:>3R/O\@@[XZGo2qGBGq#h<c<j>lTSdL-/BEd<mg7Z-:#i1>=O5`S1lpd2@Mt9_*kmD@;'ku+_phdOQI-o6XC3o:[CYo8s5-Z-Pi=k6#fflrGNZ4t8V-1<*g,r%:l'M4kA3)0.E^,imNY@s::00-9%7_fUNA3bm_-.eGSEJ[WJ[0*DB[<Rb.\(1Mi2_i\2L>mnX,ZW!kRnsiK)JZ6G2aEBq10/)Tre0&BWPffT+>qDcr-W9tGA=(+I7m`5f5DkcWFs)8cOT3eG4qK6$sm(8@d/']2KdBSf4[d@FP:MC-A9M4IDh[\[P.dFRoHV]1Z-\K(,Cs)*0;pX81LO]d4$Nsn#C1Q/"(4kq/k9as+'Gd0p]9-m(LRt^&[qB%b$9!-Q(3)/SG'eD(hKs0Za\N^!HC$hB)XtfLSBY-!^-P."ip='o\J#C<#+Yc"QfZ,5X@Bskg=seBV"7KTj3._b~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 20
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001737 00000 n
|
||||
0000002033 00000 n
|
||||
0000002124 00000 n
|
||||
0000004366 00000 n
|
||||
0000005684 00000 n
|
||||
0000007152 00000 n
|
||||
0000008410 00000 n
|
||||
0000010514 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<9f48df53726ecc7f762a28209cc7a8fa><9f48df53726ecc7f762a28209cc7a8fa>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 12 0 R
|
||||
/Root 11 0 R
|
||||
/Size 20
|
||||
>>
|
||||
startxref
|
||||
11977
|
||||
%%EOF
|
||||
569
Les14-RAG-Embeddings/Les14-Lesstof.md
Normal file
569
Les14-RAG-Embeddings/Les14-Lesstof.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Les 14 — Lesstof
|
||||
## RAG + Embeddings — AI laten antwoorden op basis van eigen documenten
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Vorige les:** Les 13 — Agents + Cursor + Vercel
|
||||
**Volgende les:** Les 15 — Cursor + Vercel deploy
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Het probleem dat RAG oplost](#1-het-probleem-dat-rag-oplost)
|
||||
2. [Wat is een embedding?](#2-wat-is-een-embedding)
|
||||
3. [Vector similarity](#3-vector-similarity)
|
||||
4. [RAG pipeline](#4-rag-pipeline)
|
||||
5. [pgvector in Supabase](#5-pgvector-in-supabase)
|
||||
6. [Chunking strategieën](#6-chunking-strategieën)
|
||||
7. [Index pipeline in code](#7-index-pipeline-in-code)
|
||||
8. [Query pipeline in code](#8-query-pipeline-in-code)
|
||||
9. [RAG-tool in een agent](#9-rag-tool-in-een-agent)
|
||||
10. [Wanneer wel/niet RAG](#10-wanneer-welniet-rag)
|
||||
11. [Productie-overwegingen](#11-productie-overwegingen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Het probleem dat RAG oplost
|
||||
|
||||
AI-modellen weten heel veel — maar niet wat in **jouw** documenten staat. Je interne kennisbank, klantcontracten, productdocumentatie, dat handboek dat vandaag binnenkwam.
|
||||
|
||||
Twee naïeve aanpakken die niet werken:
|
||||
|
||||
- **Hele document meesturen in context** — werkt voor 5 pagina's, niet voor 500. Te duur, te traag, past niet in context-window.
|
||||
- **AI zelf laten zoeken op het web** — werkt voor publieke info, niet voor jouw private data.
|
||||
|
||||
**RAG (Retrieval-Augmented Generation)** is de oplossing. Drie woorden, simpele kern:
|
||||
|
||||
> Haal de **relevante stukjes** uit je documenten op, geef die aan AI als context, AI antwoordt.
|
||||
|
||||
Het magische zit in 'relevant'. Niet keyword matching — semantic search. AI vindt stukjes die *over hetzelfde gaan*, ook al gebruik je andere woorden.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wat is een embedding?
|
||||
|
||||
Een embedding is een **vector** — een array van ~1536 nummers tussen -1 en 1. Je stopt tekst erin, krijgt vector terug.
|
||||
|
||||
```
|
||||
"De kat zit op de mat" → [0.12, -0.45, 0.88, ...]
|
||||
"Een poes ligt op het tapijt" → [0.14, -0.41, 0.85, ...]
|
||||
"Voetbal in Nederland" → [-0.73, 0.21, -0.32, ...]
|
||||
```
|
||||
|
||||
Magisch: de eerste twee vectors zijn **dichtbij elkaar in de ruimte**. De derde is ver weg. Niet omdat woorden overlappen — omdat **betekenis** vergelijkbaar is.
|
||||
|
||||
### Embedding-modellen
|
||||
|
||||
| Model | Dimensies | Snelheid | Cost | Wanneer |
|
||||
|-------|-----------|----------|------|---------|
|
||||
| `text-embedding-3-small` | 1536 | Snel | $0.02/1M tokens | Default |
|
||||
| `text-embedding-3-large` | 3072 | Trager | $0.13/1M tokens | Betere accuracy |
|
||||
| `nomic-embed-text` | 768 | Snel | Gratis (local) | Privacy, on-device |
|
||||
| `mxbai-embed-large` | 1024 | Middel | Gratis (local) | Open-source alt |
|
||||
|
||||
Voor dit vak: `text-embedding-3-small`. Goed genoeg voor de meeste apps.
|
||||
|
||||
### In AI SDK
|
||||
|
||||
```typescript
|
||||
import { embed, embedMany } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const model = openai.textEmbeddingModel("text-embedding-3-small");
|
||||
|
||||
// Eén embedding
|
||||
const { embedding } = await embed({
|
||||
model,
|
||||
value: "De kat zit op de mat",
|
||||
});
|
||||
|
||||
// Veel tegelijk (sneller)
|
||||
const { embeddings } = await embedMany({
|
||||
model,
|
||||
values: ["chunk 1", "chunk 2", "chunk 3"],
|
||||
});
|
||||
```
|
||||
|
||||
`embedMany` is veel sneller dan een loop met `embed` — minder roundtrips.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vector similarity
|
||||
|
||||
'Dichtbij elkaar' kun je op drie manieren meten:
|
||||
|
||||
| Metric | Wat | Wanneer |
|
||||
|--------|-----|---------|
|
||||
| **Cosine similarity** | Hoek tussen vectors (-1 tot 1) | Default — werkt op text |
|
||||
| **Dot product** | Sum van producten (na normalize) | Sneller — als al genormaliseerd |
|
||||
| **Euclidean** | Afstand in ruimte | Zelden voor text |
|
||||
|
||||
Cosine is de standaard voor text. OpenAI-embeddings zijn al genormaliseerd, dus cosine = dot product (mathematisch equivalent).
|
||||
|
||||
### Cosine similarity waarden
|
||||
|
||||
- `1.0` — exact gelijke betekenis
|
||||
- `0.8-0.95` — sterk gerelateerd
|
||||
- `0.5-0.8` — zwak gerelateerd
|
||||
- `< 0.5` — meestal niet relevant
|
||||
- `0.0` — totaal ongerelateerd
|
||||
- `-1.0` — tegenovergesteld (zelden in praktijk)
|
||||
|
||||
### In pgvector
|
||||
|
||||
```sql
|
||||
-- < => > = cosine distance (1 - cosine similarity)
|
||||
SELECT content, 1 - (embedding <=> '[0.12, ...]'::vector) as similarity
|
||||
FROM chunks
|
||||
ORDER BY embedding <=> '[0.12, ...]'::vector
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
`<=>` is cosine distance — kleinste eerst betekent meest similar eerst.
|
||||
|
||||
Andere operators in pgvector:
|
||||
- `<->` — Euclidean
|
||||
- `<#>` — negative dot product (sneller als al genormaliseerd)
|
||||
|
||||
---
|
||||
|
||||
## 4. RAG pipeline
|
||||
|
||||
RAG splits in twee pipelines: **index time** (eenmalig per document) en **query time** (per vraag).
|
||||
|
||||
### Index time
|
||||
|
||||
```
|
||||
PDF / docs
|
||||
↓
|
||||
Parse + chunk (~500 tokens per chunk)
|
||||
↓
|
||||
Embed elke chunk
|
||||
↓
|
||||
Store: chunk_text + embedding in pgvector
|
||||
```
|
||||
|
||||
Doe je één keer per document, of bij elke document-update.
|
||||
|
||||
### Query time
|
||||
|
||||
```
|
||||
User vraag
|
||||
↓
|
||||
Embed vraag (zelfde model als index!)
|
||||
↓
|
||||
Cosine similarity search → top-k chunks (k=3-10)
|
||||
↓
|
||||
Geef chunks als context aan LLM
|
||||
↓
|
||||
LLM antwoordt op basis van context
|
||||
```
|
||||
|
||||
**Belangrijkste inzicht:** LLM ziet nooit de hele DB. Alleen ~5 relevante chunks per vraag.
|
||||
|
||||
### Context prompt template
|
||||
|
||||
```typescript
|
||||
const prompt = `Beantwoord de vraag op basis van deze context.
|
||||
Als de context geen antwoord bevat, zeg dat eerlijk.
|
||||
|
||||
CONTEXT:
|
||||
${chunks.map((c) => c.content).join("\n\n---\n\n")}
|
||||
|
||||
VRAAG: ${question}
|
||||
|
||||
ANTWOORD:`;
|
||||
```
|
||||
|
||||
Drie ingrediënten: instructie, context, vraag. Variëren werkt, maar deze structuur is robuust.
|
||||
|
||||
---
|
||||
|
||||
## 5. pgvector in Supabase
|
||||
|
||||
Postgres extension die `vector` datatype toevoegt. In Supabase: één click activeren, schaalbaar tot miljoenen rows.
|
||||
|
||||
### Activeren
|
||||
|
||||
```sql
|
||||
create extension if not exists vector;
|
||||
```
|
||||
|
||||
Of via Supabase Dashboard → Database → Extensions → enable `vector`.
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
create table chunks (
|
||||
id bigserial primary key,
|
||||
source text not null, -- filename
|
||||
page int, -- voor PDF page reference
|
||||
content text not null, -- de chunk-tekst
|
||||
embedding vector(1536), -- de vector
|
||||
created_at timestamp default now()
|
||||
);
|
||||
```
|
||||
|
||||
`vector(1536)` — dimensie moet matchen met embedding model. `text-embedding-3-small` = 1536.
|
||||
|
||||
### Index voor snelle search
|
||||
|
||||
```sql
|
||||
create index on chunks
|
||||
using hnsw (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
**HNSW** = Hierarchical Navigable Small Worlds. Approximate nearest neighbor. Tot 100x sneller dan exact search bij grote tabellen.
|
||||
|
||||
Trade-off: HNSW is *approximate* — niet 100% accurate maar 99%+. Voor RAG: prima.
|
||||
|
||||
Voor kleinere tabellen (<10k rows) kun je het index weglaten — exact search is dan al snel.
|
||||
|
||||
### Match function (Postgres RPC)
|
||||
|
||||
Best practice: definieer een SQL function die je vanuit JS aanroept:
|
||||
|
||||
```sql
|
||||
create or replace function match_chunks(
|
||||
query_embedding vector(1536),
|
||||
match_count int default 5,
|
||||
filter_source text default null
|
||||
)
|
||||
returns table (
|
||||
id bigint,
|
||||
content text,
|
||||
source text,
|
||||
page int,
|
||||
similarity float
|
||||
)
|
||||
language sql stable as $$
|
||||
select
|
||||
chunks.id,
|
||||
chunks.content,
|
||||
chunks.source,
|
||||
chunks.page,
|
||||
1 - (chunks.embedding <=> query_embedding) as similarity
|
||||
from chunks
|
||||
where filter_source is null or chunks.source = filter_source
|
||||
order by chunks.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
$$;
|
||||
```
|
||||
|
||||
Voordeel: één call vanuit JS, optionele filters (bijv. per document).
|
||||
|
||||
---
|
||||
|
||||
## 6. Chunking strategieën
|
||||
|
||||
Hoe je documenten opknipt heeft enorme impact op RAG-kwaliteit.
|
||||
|
||||
### Drie aanpakken
|
||||
|
||||
| Strategie | Hoe | Wanneer |
|
||||
|-----------|-----|---------|
|
||||
| **Fixed size** | 500 tokens per chunk, 50 overlap | Default, simpelste |
|
||||
| **Recursive** | Splits op `\n\n` → `\n` → `.` → ` ` | Behoudt structuur |
|
||||
| **Semantic** | Embed zinnen, group similar | Beste kwaliteit |
|
||||
|
||||
### Wat is een goede chunk
|
||||
|
||||
- **~200-500 tokens** (~1000-2500 chars)
|
||||
- **Overlap 10-15%** — info aan grenzen niet verliezen
|
||||
- **Houdt semantisch geheel** — niet midden in zin knippen
|
||||
|
||||
Te kleine chunks → fragmentatie, AI mist context
|
||||
Te grote chunks → ruis verdunt relevant info
|
||||
|
||||
### Fixed size in JS
|
||||
|
||||
```typescript
|
||||
function chunkText(text: string, size = 500, overlap = 50): string[] {
|
||||
const chunks: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
chunks.push(text.slice(i, i + size));
|
||||
i += size - overlap;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
```
|
||||
|
||||
Voor productie: gebruik `langchain` of `llamaindex` recursive splitter — handelt edge cases beter.
|
||||
|
||||
### Contextual retrieval (Anthropic, 2024)
|
||||
|
||||
Nieuwe techniek: voor elke chunk genereert een LLM een kort 'context-stukje' dat de chunk in context van het document plaatst. Embed dat samen met de chunk. Resulteert in 30-50% betere retrieval. Voor productie de moeite, voor demo overkill.
|
||||
|
||||
---
|
||||
|
||||
## 7. Index pipeline in code
|
||||
|
||||
Volledig pattern voor PDF-upload → chunks → embeddings → DB:
|
||||
|
||||
```typescript
|
||||
// app/api/index/route.ts
|
||||
import { extractText } from "unpdf";
|
||||
import { embedMany } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
// 1. Parse PDF
|
||||
const { text } = await extractText(buffer, { mergePages: true });
|
||||
|
||||
// 2. Chunk
|
||||
const chunks = chunkText(text, 500, 50);
|
||||
|
||||
// 3. Embed all chunks
|
||||
const { embeddings } = await embedMany({
|
||||
model: openai.textEmbeddingModel("text-embedding-3-small"),
|
||||
values: chunks,
|
||||
});
|
||||
|
||||
// 4. Insert in Supabase
|
||||
const { error } = await supabase.from("chunks").insert(
|
||||
chunks.map((content, i) => ({
|
||||
source: file.name,
|
||||
content,
|
||||
embedding: embeddings[i],
|
||||
}))
|
||||
);
|
||||
|
||||
if (error) return Response.json({ error: error.message }, { status: 500 });
|
||||
return Response.json({ chunks: chunks.length });
|
||||
}
|
||||
```
|
||||
|
||||
Belangrijk: `embedMany` doet alle chunks in één API-call. Veel sneller dan een loop.
|
||||
|
||||
### Kosten-indicatie
|
||||
|
||||
`text-embedding-3-small` = $0.02 per 1M tokens.
|
||||
|
||||
- 200-pagina PDF ≈ 80k tokens ≈ 160 chunks van 500 tokens
|
||||
- Embeddings: ~$0.0016 (minder dan een cent)
|
||||
|
||||
Indexing is goedkoop. Doe je één keer per document.
|
||||
|
||||
---
|
||||
|
||||
## 8. Query pipeline in code
|
||||
|
||||
```typescript
|
||||
// app/api/ask/route.ts
|
||||
import { embed, generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { question } = await req.json();
|
||||
|
||||
// 1. Embed de vraag
|
||||
const { embedding } = await embed({
|
||||
model: openai.textEmbeddingModel("text-embedding-3-small"),
|
||||
value: question,
|
||||
});
|
||||
|
||||
// 2. Similarity search
|
||||
const { data: chunks } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
|
||||
if (!chunks?.length) {
|
||||
return Response.json({ answer: "Geen relevante info gevonden." });
|
||||
}
|
||||
|
||||
// 3. Build context
|
||||
const context = chunks
|
||||
.map((c, i) => `[Source ${i + 1}: ${c.source}, page ${c.page}]\n${c.content}`)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// 4. Generate answer
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
prompt: `Beantwoord de vraag op basis van deze context. Als geen antwoord in context staat, zeg dat.
|
||||
|
||||
CONTEXT:
|
||||
${context}
|
||||
|
||||
VRAAG: ${question}
|
||||
|
||||
ANTWOORD:`,
|
||||
});
|
||||
|
||||
return Response.json({ answer: text, sources: chunks });
|
||||
}
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Geef bronnen mee in output — gebruiker kan verifiëren
|
||||
- `top-k = 5` is goede default, experimenteer per case
|
||||
- Stream de output (`streamText`) voor betere UX
|
||||
|
||||
---
|
||||
|
||||
## 9. RAG-tool in een agent
|
||||
|
||||
Combo van Les 13 (Agents) + Les 14 (RAG):
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
import { embed } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const ragSearch = tool({
|
||||
description: "Zoek in geüploade documenten op basis van semantic similarity.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Wat je wilt vinden"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const { embedding } = await embed({
|
||||
model: openai.textEmbeddingModel("text-embedding-3-small"),
|
||||
value: query,
|
||||
});
|
||||
const { data } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const docAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o"),
|
||||
system: `Je beantwoordt vragen over documenten. Werkwijze:
|
||||
1. Zoek met ragSearch voor relevante info
|
||||
2. Lees de resultaten
|
||||
3. Eventueel: tweede ragSearch met andere query voor meer context
|
||||
4. Antwoord met bronvermeldingen.`,
|
||||
tools: { ragSearch },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
|
||||
const result = await docAgent.generate({
|
||||
prompt: "Vergelijk de jazz- en rock-headliners op Polderfest 2027.",
|
||||
});
|
||||
```
|
||||
|
||||
Wat krijg je extra t.o.v. simple RAG:
|
||||
|
||||
- **Multi-hop reasoning** — agent kan 2-3 searches doen voor complexe vragen
|
||||
- **Query rewriting** — agent kan zijn eigen zoek-query verbeteren
|
||||
- **Iterative refinement** — eerste resultaat niet goed? Probeer andere query
|
||||
|
||||
Trade-off: duurder + langzamer dan single-call RAG. Voor open-ended vragen wel waard.
|
||||
|
||||
---
|
||||
|
||||
## 10. Wanneer wel/niet RAG
|
||||
|
||||
### Wanneer WEL
|
||||
|
||||
- Veel documenten (>50 pagina's totaal)
|
||||
- Documenten veranderen vaak
|
||||
- Semantic search nodig (niet alleen exact match)
|
||||
- Privacy: data moet in jouw DB blijven
|
||||
- Bronvermelding is belangrijk
|
||||
|
||||
### Wanneer NIET
|
||||
|
||||
- Klein document (<10 pagina's) — gewoon in system prompt
|
||||
- Exacte data (prijzen, IDs) — gebruik tool-calls / SQL
|
||||
- Structured data (tabellen) — SQL is beter
|
||||
- 1 keer per dag bevraagd — overhead niet de moeite
|
||||
- Code-base — gebruik grep / tree-sitter, geen embeddings
|
||||
|
||||
### Hybrid is vaak best
|
||||
|
||||
In productie combineren teams:
|
||||
- **Semantic search** (RAG) voor concept-vragen
|
||||
- **Keyword/full-text** voor exacte termen
|
||||
- **SQL filter** voor metadata (datum, categorie)
|
||||
|
||||
```sql
|
||||
-- Voorbeeld hybrid in Supabase
|
||||
select content
|
||||
from chunks
|
||||
where source = 'product-handleiding.pdf' -- metadata filter
|
||||
and (
|
||||
content ilike '%firmware%' -- keyword
|
||||
or embedding <=> $1 < 0.5 -- semantic
|
||||
)
|
||||
order by embedding <=> $1
|
||||
limit 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Productie-overwegingen
|
||||
|
||||
### Re-ranking
|
||||
|
||||
Top-5 van vector search is niet altijd de beste top-5 voor de LLM. **Re-ranking** = stap erna:
|
||||
|
||||
1. Vector search → 20 candidates
|
||||
2. Re-rank model (Cohere, BGE) → top 5 echt
|
||||
3. Aan LLM geven
|
||||
|
||||
Cohere `rerank-3` is de populairste, kost ~$1/1k searches.
|
||||
|
||||
### Evaluation
|
||||
|
||||
Hoe weet je dat je RAG goed werkt? Eval-suite:
|
||||
|
||||
- 20 vragen + verwachte antwoorden
|
||||
- Run RAG, vergelijk output
|
||||
- LLM-as-judge: tweede model beoordeelt kwaliteit
|
||||
- Track over tijd: retrieval-accuracy, generation-quality
|
||||
|
||||
Tools: Ragas, LangSmith, Vercel's eval helpers.
|
||||
|
||||
### Updates
|
||||
|
||||
Wat als een document verandert?
|
||||
- **Delete + re-index** — simpel maar duur als alles change
|
||||
- **Incremental** — alleen veranderde chunks her-embedden
|
||||
- **Versioning** — oude versies bewaren met `version` column
|
||||
|
||||
### Cost
|
||||
|
||||
Embeddings zijn goedkoop. Vergelijk:
|
||||
|
||||
- 1000 documenten van 100 pagina's:
|
||||
- Indexing: ~$1 (eenmalig)
|
||||
- Storage in Supabase: ~$5/maand
|
||||
- Query: $0.0001 per vraag (embed) + LLM cost
|
||||
|
||||
LLM-cost domineert. Optimaliseer daar.
|
||||
|
||||
### Privacy
|
||||
|
||||
- Embeddings zijn NIET reversible — je kunt geen tekst terughalen uit een vector
|
||||
- Maar: vergelijkbare zinnen produceren vergelijkbare vectors — niet 100% anoniem
|
||||
- Voor gevoelige data: lokaal embedding model (Ollama, nomic-embed-text)
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **AI SDK Embeddings:** https://ai-sdk.dev/docs/ai-sdk-core/embeddings
|
||||
- **OpenAI Embeddings guide:** https://platform.openai.com/docs/guides/embeddings
|
||||
- **pgvector GitHub:** https://github.com/pgvector/pgvector
|
||||
- **Supabase pgvector docs:** https://supabase.com/docs/guides/database/extensions/pgvector
|
||||
- **Supabase vector search guide:** https://supabase.com/docs/guides/ai/vector-columns
|
||||
- **Anthropic Contextual Retrieval:** https://www.anthropic.com/news/contextual-retrieval
|
||||
- **unpdf (PDF parsing):** https://github.com/unjs/unpdf
|
||||
- **Cohere rerank:** https://docs.cohere.com/docs/reranking
|
||||
- **LlamaIndex RAG concepts:** https://docs.llamaindex.ai/en/stable/getting_started/concepts/
|
||||
- **Ragas (RAG evaluation):** https://docs.ragas.io/
|
||||
309
Les14-RAG-Embeddings/Les14-Lesstof.pdf
Normal file
309
Les14-RAG-Embeddings/Les14-Lesstof.pdf
Normal file
@@ -0,0 +1,309 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 7 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 20 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105950+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105950+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Count 13 /Kids [ 4 0 R 5 0 R 6 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
15 0 R 16 0 R 17 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1865
|
||||
>>
|
||||
stream
|
||||
Gatm<a`?,q&A@ZcqN?`".3Vg'J@S?6R4PMNZ>/BW0/NjUZmh1#@`S<@Bn37[Lo*&8?'50gh>MllpR5\ZnX#ZqE!>`C%^4_F?l\<q^hdJtr"S#4X]p^&Q[?.C6qAeT%`B1[#P+^VJE%9ok<8u>4X*$.CrqKt^''Jn4HFcL/%Bt@0d*&\4_Y"\b]0Jk[9jK3F*)74a*%@*rCqZ;LZ]>[M%7G)s3MUc?k''[Wiue@Np"3*"\53"r&fOkku&8T<LUqsDjdZqWe>H=4ZGWn*>_.DP\L7!W&k`lfh*RP$^b;=r2'*EY]&9d3#LM`rDQ^(pjd9OR9nHV^BVk0$%NKOI)@aW%Cs+Xe.mpe^*E,"7M"9pESnpE4f:JT$8E$<_PsTN)4\Dk"n0JaKN:$>9G$JK9^hB3>Tu;m6YDB5^L#GNlXiJ!n9&P)cFOnI>GJm<d%V@HN3CM&k"X02%U)pL?8H"fEe2k'VM['!,OmT#LRef0.6KW/c94K?F_$Q(qou.d=Z<+\GCpBP2a_q-[!25YZ"mhZE>Xn@#A+Y;0HY),/M[3.=2M$4kekriJr.I9C)LP9<]K[R(Z8_XP^<<_K2(KW5Q1Y4_7S6O=tHCY=fb\Vi<j2,6B'XmM9hTM"O/$\5TH"<JKQE1dkkFia#&F,!:(-_Jcr7*l5ip3`;)h?<k892nn@9\02GLWQHjG87fj1h(Yi3DI,1A,7S4*shXOm`T0g-n!S2bd/;#CL@HjP)N#9u*qAJ)$?42&rQ&cf[cXWtl[Vkt^RTao*nJLs$nKj)nUbD^e-rW9F5hSHD,>a'K$d,A,-ltl5eMu;A%``O;,bV/X\9o\KF`[Ok"9k_#AX1F,dWgTPo/N&8/iKr$ne[9,@M+pF(l\Di+9[r\'Z=b'SP8\G-Q#Sfj)(%tBoYT9`tt`m(+P6kLFpR(2?N*``Bb[-&kRA$>l"WVpTt=:H^sOHE:Vl4[tGLKY$WtD.L5nH1Kt0`2gB+$]27-F5T*hF$>D[T+cN0i2bmNa;4cQ<$]TJCNf`R1aG&@Zd:tmRfOMk;TnhU]]c#tZhLQ`$\6m"kNEQ4JG5J3m(i%40'sq+B^JY_`#Zl!=&eaLJVSEroi_s2^4?F5c#=XXIFo0Y/T8BG)Ff%[:l:pD^bRW2Le63J>B%j^TGtc'n?/+1hkmUMd,#rV-:>2gUn*(H"FFNoQ*1\n3K/[P58(SX$gO2nM_KXPq/"d#'o?RfP6A:@b.'aspcf%3UlDA-<D7?tI2D(Xk5nU5n367lc!H:o1N@&UCR87CoZCiV73;]g]gp8D-Yr7\U^K$+Wq\gZ[b[419.tn^Z>H.P:$oU"Vb<*?nXK!BoUH=a9+&('(<>M6j.p/b`5(<KN<a4=;q5R&)&FQVJF)2#G;/Q6p>WAJWPRLAn=/^4['<0kn#YqbsR9jF`S7+luBnV]S-ms8\GBHlWeZY-@(YeFlV:HXAJ61/cckeIXl-hK6%0(0\4&_ud+q.9/jp^]Yr$mO70J]('<akXNDhYY\SR[=)M20@U:6#pFG7-m4a4Bh8m0M?,2"@"S:Qhcc>3oeVrcu^G0#\Kn-=e)GSegL]Kn>GX1"s![kSgsWP!FE5$d8)Tksqj`JJG)d1.>V"G'>%LJFXhup]Ot,=&/m@L0-9n%#O*70hC^u+GhP/h#/AoXJr'E\M6);6/^Boi7RqC)%uBT&q1B;c4hOO59ud,%(Lcqd:$_)SV!e_[;E)^q%h.MY`Dt)K&,-;!q7o1i(_"r-TN$_lr>.2/PHsj=*>F$O:r2maUR-$L[kR9LZ&Jf!i#YXTl!I3M[.G1LO9!WXFk1<SKbN>8lFIsFe'.3*!(-u4B%](rSW(Jdm\t-YF;<?q;1FmZ_cBQKt>k<lTaT=;%3~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 401
|
||||
>>
|
||||
stream
|
||||
Gas2Eb>,r/&4Q?hMRtF)R+`4gZ>9[ZPq&G)@)\uMmK9j',SAppo4XCe6#8OhB?m$hPn7BGp<8Q4/B)-P+W9MK;NX;W`-iPP7Dsq_d\,6L3+5Vr2\4&JPX?BF#q$?\=E7G.3D%LB%NOc8Pnp4:AfOC(28VBXE`*ihY3KLPbb;EgD+sN4^@u].?:(Nu$_#l]YjdA5LH8@D77(aiOWH6'I!T)VU"K2kgD.[.06QNc?&^dTMs95E>#kI"p@#N_hu6H"*dqAP+T4PdDL)ce8hoS'GKF4g.d/+iWk-i)f]VpNT<H'5c78b@.o@5nEa#E'7CX-#FZ36gMJ,rIF!df2LUaD[-_u=&J8.b*F$:c%Qq*>(6?&m%6pK\A'>",I>$dq9AWc.>^V[2.L$/c)WfMf~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1247
|
||||
>>
|
||||
stream
|
||||
GauHJ9lo&I&A@[:lm4Hf7mR-:pgA%.1oW?r8Xg3R?@7;P8>8P($40k'r;&%^&(-].A9t1#Mc^Pfn?@/L!!A*#(4Pj<^i(+'H,kGTaO\->P!J^^hIb0%LiCI9,(RV#`[M.U`pFktS&g/B7&E(XTW',=-'2e0Zq`JtoM^VrW%G)h+pLG,WcS;11Ej0=n;iFe*2cPfO>;7Pht,;K]p-U0*Ra;g#_jR+^Sh`%:'bDt$-;foqs`e3"l*)sgZ7+h.cHSpD8,[^caeY^`Tu9U.2m,+n@O+UATcX0kC8'L!OA2<XpKQ?E$CD#;uHBu62H+m``MI9p+Na$S:Pp:o+>In!(l#\<E,/AV@*(W])dt%%#g2[R7Pt#>`c*Kp6?4kK[N7aqRGr`-%m_hV`eK+P=K+)2OJ*XKc7R$*1%!LZ")@E)$p]l3>*=cA]4'i427R9&05E*,C3s!U+qhtS]>;8<h;h9-toXZFBee-?)sM=?:f5lO3*\B9"8Iq#)9Xm(-67LS*UMk?'q6K-hoITVhAF:[u+QBlLkP`Sb6s#X(?#4*`g:uhm\)#FXX<1s%!`"a\`XWVDI1\!,!HD6djDK(UE>?o`f$;$TM:Zr6a5=%XV^NcMk%mS&#Df'Fk1sYccnKH`2t&^2LfW_5bY=D'E'eNAnij=J/MQ$TmZZd4C:3c!aZlkOZjlg>k584tLT+MdIKGO!r78"CQ$#C0B4q0HSUqAcp5n3U:i9ZR8PcnoA,k^)!*sLN1Ei$K+UFATf.'e[7*i_&.AM4'WN%$62uPZqe&!qq%m=`]>?tPS4M0#MfeN(`uE9i/^m$FtV[oq9'MEpT.Tbm%,YKPpBt6@$k`i>h;?_?OGJW/aXqgfN[,*@r:%U;F8'4,DQ)!<Ab%AR*E8OACj-3<K\2Us"P)b.1V_Che]iXd^7-c%5n%s5r]jNARTf30HBB;&L#j20GdtY?;h]4M%bTV>$=$i^`0@+GX1C4o!16^k:(`E+i2kE%Uf\j)L,SG\2C.=F"GY7JGa4/Wc]5^'[Q1-$mPHa*SWUNihPiPYt50:g*5YR&GV^f"1hhXGil5aIr<%jYH1r6XG*H,eJ&PgPiI08`FgQSE,D8!a2%4&)Vm"(V_\j-R_9sK]Pfp_!UFiH9p@>I.lqPOee0lAQ+hr%.F&PtSh$hspka1^N%&1H0?S<pq1jY:'n_@PHa(bKk`1@P#<ONfeR8q,CY,8E@:@(>%@FGP[RAlRINE=g:T2=!^T=u:$6f6^3:^NM~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1795
|
||||
>>
|
||||
stream
|
||||
Gau`SgN)%,&:N_CltH6gA1Rud5nH_fQ_\e&[n5:1Wq-ebJYlhC2D5D/4&%Nq[s&.?+Jm/F44@p*$`2tElriiE@`<lW%l=6L*8np?k"cjK%Z>C9Jd>I@LSKRthBQ:r:]s%27K&#EM>[&Ne]KBbO@2Q#@\@C*<%(![7[/cEE*O36/;-X5q*U2D#pg%G!2<R<Q2G+):F,*?>*)"lBR>0J:UTg=,!0s=e,CO.HE]"NL[#76=V(eAaC4r9Q]kqSZ!?NS\L>W0IW$^,>VpEBGmKIlB92@>XW+s,6[b`9Eh2NjGlWaq=?LD0NUVY[,X?[Fps4)BoDpUf;k@4G?fP-i`#qN/YAd9a+NZ^>+]ZJTBo(*pc40Mqg_f,\.4!sq%u>Z"Cmi=26E*d+)crSs+=rb)C^GE[`,dha_\?T4PVYbl(Bra$I:dmIJ_lJ2_>\lF0tb\aS(%I9(FAV8^`pZ,Nb#WOV(p+TnLcr!WV4@rqYAT@4%?lGW(nD/8&ct9MK'^Tiqb-$k)o2]F!8bFoCd<[0+E@upm:cX,$9N0h8"LR2iuU`FTFNfe&u<+XM3Ge"W:18L%jUE\2c,j1UUqsQ*u@-Nr?(\0Kg>0l=6<>-3LS^HD?\$X?(,-1UW+"X"+)S2fSkJZ+u]&>j&Z#C1sd9)PLaOn38/%crY>l\k7k_Y^"OYl?YlWdbC<8\&E4&c7!QLFpE6!U,QQ%G!`^pf%^pIBKM-YAY)`*>r\3VqiQO=)NHDLpIRJ[$W)/.;/W%QRj+*W$[V:blC8grB0iKT6`]rXWR5QDJC,N@.sX#55R4IJqB;?em7te#&/dpb?&F\6`#6V,nO_qcD?W$\e8U,9r%i2_>f0H,*]Fbr?F+S//`t7f?q,tY%KM(1!>%TSm;@T*%$#[ISu@LnC2dHf2pB^8$$2dZB]=/\M4qKt_T#t17W=t:rRHQ\.<T\IB93nu;D30X`PljB%0R4?(7[/oald$LfW@jtS2UCenX\lYV6fgbl7BqRlV'868f(T^hdD.sn(;q0n1qtBq,!C-E`V&(8ZZb<DV(pVg+M'*'O)-k;*oIc\ZK+,4k8b3HIOm>=^E_'QKdD[q!JD'iHR`Y*%!(%Ei@S-(N+E[7,PS/I$Y4tg6i$6ft)?bK\D@-`LSX[&Rb='m5P(kN\Eql0A<P]/S-7HM.dImY(p`*gQA6<"V5kHpd83A15bQT+Lo[Fn+e:_$E^@k1GtbP$ES"\4#7.Y!B9k9B''ScR6RbD389ct\$eFUp>j_KIRUM!cIn+=H$.u!qE2L;DV9]OamE]3`E7>2R!3?9`8u?5GFlmGq>6S(hG:A#;+ttm>ZYq9n[8iMj'7goRb/$\D)-I:gI7f8H\bn@B3!`/4lge;f(F<6(!#!T?)9KLSD+DN$3)W_PpjV"/7ji?]l3%[KuFM94nD2E+8THkLU+cm)8%ke@V;_SinnNO`s%LqnG;Y;r>h%k3*8eC4oY1qK_Z4ON0,*@,>@#\.B.@J#SXppEX\J7*Cnli2i]j**PgkMlTZF\(rJ!=+gj'uo6"1nnB$]l#PR^4/j@-b33UIE-?2*BqX1nEd=<ngLeY[*Xc#/]0]0q8XBo5XXa)"!jC=dD16Atli=kbad8bHi(,uen@hFcK)ht^7m*8:G/t>Za]=[CioYDSH1*T%mak]Z@#YE3r),g&d*,V4n3u3X&83aK1iMY()LX2>Wr#s'?dF_4aVTB=GrA_F?NrTj0DZCIG]U/G=!kmb*M9JaPDIoU1fEA,$\nEIOlL_pka2Ch=ol`[(b-mM6+2Q]'4?7r6g#Ut4@]qo`rW`-Jl](~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1514
|
||||
>>
|
||||
stream
|
||||
GauHK>>s9G'RnZ;3"LFV1A,6tg0IF:cB*@M*HFP>\BOV]7h-';_/qs/TN(Udk@:'YS[Bmn:9k<"$.AjGn*WrG?nn9tCVNYsM\M]6&e86DD!dDG_E&?`O//eDHin2LtTP^edsS-3(cPfGokL2(2)"DJ]-`\YD-:MZgNZZ,Ni@6h2og.3?@\]Uj&I6eS1\@!Tb;Vn:"A"P3po8%._6Sn$eJtW<_89Ljt'$=JH_BO`f0B\s&i+'@(H#T7M&c>[?2Rk?sRWAoC-hDsQ&+dXjiQsd@./ae5Uj*'$4'9-JpDVs_abbr<4iKXn3LUJ'ac#egLAfY0Ko$?.!9XE3g&`_[(ojPY6;WYSZ.%Zl5fX]bPuJ;Vga2W1[h^u/'2#)=XDu][8]%jo_\KYG$(PlBDjXt7K^+jcPk//DYQ_(=3Wn)i!"Ec5,DY6eXT+H,5d]8lE+j/YKFaW9NH.O!^cZRRSLUMZ=+0^ZNm$(SJl`Q5KZE#l(a[rk/q>+J0WqC89FT4#YhI?kf)`Ucn3+Xtn]Q_r*ldR\r(f4!dL>\#%O41GMn^UuTF4l@GS^U3;NHF-qK/3Jfk"@IPu:$A0bNE1)2JtcY)ST$HZ?UO.FL89<Xp'gQbnEZ4EP1n/.BPIN1m:&=#RA!q\/eHNCU"akQI*fg2%m*/'6?t#2Z^qY-'HBUj8?[nY7&0Yu1Ccg3Y7!H#=:?W(oR4le.doOVYa8?gT#`TH)Z)IiT9nOCIhrp=f^g9F=Y?,P[TV?*8edaj/+"iG'CNK:fMC2VDSAX-VBdZ5GE$fd+gO572IRoQ#hU.NOLp8*_9)Qe%e]VR*Fk5.J]B5?U=?5dh0__X9.]f)!ibbL5W"/h7V&BDgOnOrM:BAe`9d,c[_P0ZW,B-\o5$f]Gl_7lNV#E.utk0j_Cr,DR/KZ\Z!2Qsuo6K:5e?XR2C-8)MXY:JU(^]PpDO#%h\+qaMe'qT)J#XYgcXf9k*]0Y:q#:E#)_hdopW]=kWRCR?.Y95ORP<\S$:<nKR!=l!BN2a8]uAY\b_hSNo0fR'*@q4o1$V<[0p[X10$/L@:2SfpO&hbbrOiuZ!]-+cYXA2a":1)"kN(Zshh[sL9-]=Z>DN)TcRdJp]"3INpMm9\+LrYA]*>=;t:YoZArKXSZc)d+`I`@eSP8G59>njG[>afb6*/AY63s/cEr?r(%XNI-h;\$8be86=>?4/4e8@7TRb[G.a]*N(4#lhN5XUt0[(l..%K)HnWa93qQpKM&8"/#JR<SA%XAjf^&s<`#n<?A7;i[,=D16Ce;9$:=Crjl!;$)15/*0\YtqcL.Rm5^Q]SrLQ`54$(!3Ag!jYUUs#Yc?M>g&Q]<Ug1@accf+9'+!JiOW12p)/;JtqibWWW=^8e*oNg3DVrj0Y>.R*.?-hk=8#Jj!('pDf=B'[/;KKd`nRoI=L<6ILp,ef*^sDaMLFp4O6jq<+%?<*fcQK`'-hmFS/WN"JIfrhVkEgroQgjtbfX,0J7XM7dTUq[E/AV5C8RR2B&,)BT_Z~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1839
|
||||
>>
|
||||
stream
|
||||
Gb!#[D/\/e&H8h>E@Y5.l"66W.#H*n!H-A0"gY!YDS$D`<^gV:-$jPV2/Q9n^O8UEU?";=1DK%b)jYa.4ZnnRF$)1MpgrSPc2\n[iPAFsN5P8jQllI-L[\j>S3)E$4:iPF=&M!<5XJP%TNjH:GWtXb*\rg6+>OKN,U63A&if^k!=PQ"E,siE5Q[P^;?2j'o;04\mh276**Np"pVX'2D1#6:(D=KB%`<+3#_kKE4"6*Q$::-sGVIBMo]#)jF83FNW%)*h71*u!;T6ftAt0)[Gs7g!<Dq@^%L;8cV8Xo_H+aI3;jcFn@h%p]i5EP&f's8NL#E,?Wk(0(?scB],`f__i4Qoam=9T@"r^<0>aGZ)(L%^2EH,E_.t$;Ck&?A<\(6j+4<Tjq3O!MVp(E#^4s*t@Y#"Cd)r<9jR"hoD&Gq/&=DX<]I;AnS+iXMoqp]'LPk>Qd"ep1)fV*^-.5l"(Xg=S<@-4`#F:>)>\(UT_:Gm>)H$pl@H476@;8OX>/hk4dV'jNRM8?n;eh$FClN#g$p`\UcpmMdG7,G**^RdQ?<O,M:P,hk&n3^q>E'Arp2:5gU8CE<Yl8UJTk#X0C2#X57*N@`t1RO$PoGo7R>^_>fa]//56Q*^VUf<pSBsO,nJ]]oVcTeH#b>#,i<Ph0B7HdCq-@#J'nZJMDp'&2dqn`9#h&SXcT@)504c)h4Z\qj#UUX*eNpO/NR8Fd_0>a3,bT%2t9e*KXe.ILiNkNQs4.f>8K:t_WI:B,C'p*tAShO!3/L2JM9^Z`4"4hY$&QfE-oaNLW5g$oQg4Re%/?LY[c[kep[f65AqV,dCk!EaiJ6jTX4Yl&bAj#(b5pLoH#>a:Zb_s!B5*L.RY"Jh_o>V)Er#97nZ;U0J`645;))CTK7k)s,RE@&,X>BS'hKE"]9<=2:*qi0KN#>ruB<"8Mmb_<PqjY/FeUHQT=04[<(noB>o-@[.P/D$STjmAq9C8=k/LA/5$Er"q\lB*SP=.t;5EJ2Cl3!Z)6Rh6"lZ1<D6+Un\)hGD1.G0B?K!irHN:pc[?n$=$nV$3`6_LngQRoV0DQ'0.B8n8GiDQL"Es5]$="-OO"X^_YZqCaq$@?#WGZ[4rA4[])8c8fB'p\;e5HB^mpoL1b%cqS#m$Ua!XfI:3/om_O.*CN<n;i'u3:q_LY<Naj3No,Y_/Y->)I+Ce$[dTVqB@WSQLa4a]jlaB):[&RWj&pjCP^GMV+X\Xb)g7b__VWK2NTkhWuJsff(Jne;JJIEVO_N2=54TTM!t*%GTW\6?IBT$De\B$2,]AL,dsk@9S_c&RQ+eZ_Ya4l482]<hj=cXBdbF]`OM-Rns>$CXu5nqr%U*u!h5Wile($anVl5s!*`#$D;?a@)O>SI$Do6&<%0a@ZhasB9X"N+AVIWtf'$B[e/Oh`Q\h_$8sHt:P'Wla6'3]50$M-V!g>I[.L3\5387CUH"tl,e<VckhSdSc8roaG6ikef\i;9@Y4@T],<X%2lIQ*&DcSs'DG%IB0*_@O^/Y-Be^CHKZ;h@Ab#W4Uh]hN1c:Nh>q![f<_m]n("<6KOL+`bJo3@dZ)87no:,W/hd<XV-Cu2W:g4Re%X<3u,)>/e7]lY111f`3LHO(]7W@jfWMDf0s/[*FLV<X3_fo[mm$oX?p5SHi"F6a,?c$2>"0s\Om"YS=,k"uR[Q"b8b[K@TFa41E^L;188essSg>R0fokf*29T@<c+^hX28h62eP8J\F?UY.'8(QjKf5toKrnf$C;:5^BiNUG#&3W@%YEH?26[eKNucdH#=NUL1L-I;DEmT4f)WTp;#GDn4<s)*%^9jUNN+!&$Yf`1I,RVot;#CkV5"9~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1884
|
||||
>>
|
||||
stream
|
||||
Gb!#[gMYb*&:N/3bX2^s7E^i.78gbR"hL%p"kmk+osQLX!a2C;26u:6Eglq)%Om6tF&Vq@=N@Ye&U?>U*mU0-T_QR_Dn6&GD%84*1LM3bCLD`"R(n8<kJ9SP\E$7M5XJ2_Qii14qgiJq'G*h[@idGQ<"Z58#gJ1VYm-6[P[XtL"7)ic"/7&@=ocr$P$6_2*tl[U/hKU>AUJQJkF]3N`C+n+TptEc_/KPC2W[e'5q%Xj"S3YX55b*8/\EMkVG=U3RDV6`A^YDt$dKL"HMdV??"\:i<q(mT1jQXhRA)]qq)=[*[;1ckL#=4CpQ"e$R2XldI+1m$DIGaVS#f.GNGq'i9R@o(4%C%9,RgZhZZn1^6&:*>To_UX0&R=US)0G9>S0S^*ne'k7L?qrjC:j4O%BFl_ZnSK=X\3GEEjK7`su66`]MNd;KT-]k5iK0Cj8N7&G<*"!?JhoZ/_DA-MqY=G+Jom^i@Nr[7C8V[H3DnY9^]Sa6:S]-!W5I!^ka>8(ntd1ccGig+_E3Nprm_NsWR\D]-B#jR!QYIWK5OVQ:mji:CBL/<ed];*i4O;rM2u"NJH513YV3AYms"oVVRf)jl%)lXj'=&^=<cME36q!cNk%ItC*+1$?AhBGHWk=aCQLg16PI1'K]/nP1_^]W[iFkm5H\DB3Z*J%!:<KZ00@GT3)W+I$WIK=Brnd*tf19Kn/$63UA!MseW]J[[?[LmF2a-uVJM.MY8\cBN2OGn7OkTn\ThXO;FeRLW_I=Nf,n%sst%YMK+b2ad13g4g/#<meN])jAnXR7/*(Pro"K>+)0@_[/]=L8&M2JEa+hQ>o.HS[gkU_"BOB/`6`+n<V'd*\H+lbM5@o4V[':/7oR=0H3(<QC.ba/+B0B[])ud,rt=mCmS,QE,MhN9u,/40H#]WXtC5bC0J+O_r7=d*:eIY-:[8iZ0l&oai<sZAOSC0)(?=j!qS`?3@tV$L?=8>O>.>6CYlmph9rB*=g:8[p&Gmb(56dK3e7qn@_H3`47822B*in6A33^Ka_hV>ZVD"@p$MWc[Q&[P;+*g,Wm+n/dg7(L+]0EGqI(Fb]TuUCa#qM%Q+6s,DbHp`'C27W-4dKne@)<j,6<S@[;e9$971U%b'e^)rA]f4!^VL^Vm20<ajQheG8/*WTWs>nDp&ib[DUcCgY>(<`t"CY;.r&ORO==e2hH.9ba=s^HPe]S<uscP!?KoDcG`<1+_$Z/;lE,WnIa_qg'q_FWUhp"/UVsR7I\50X'9qell7MsM&>P4c,6K\j/DRBp[1%bg9QXJJG$IQQQ4"nFm/Ku=R0hGC73aj(2>7=RWYgb_X,O&cX5M5g%E9XKsQOp3&5iER4>2aD3eWTq2ein)n4_N]-P_]CK]c"R_Z!&^+-<?aWTsXPFt%t<A<*<].>H6F1e`9dOV7(PNSfp<Dm3W75=/N1TQL@"&mc0fMlFO"RT;*f.SGMqo`#1)*NbTr*I;:Tn%%c[\eWP4PT>n;UeM:.BB-3Llr9M,r\GK;%_m-AG/3$d-pY$5\8!:[[@$lnb8gUIub,1g@?fo+,IOp`;LK+(3bb<Uj5(Y4'#$mclhbi`JJs!)8s4tciK(8C]2lE/qj]13'/=`.^RG7PW+)<4+!?-D7Z5OhJp@3E,dS^p"@Shq+->[f*[>a\PXDEoh[9S\3puYj'#naK9#5Oau"mRW4Q"e\b"i"!D<p(1p!d*WO=S\X=kX+::A@:Uro-A=Qa79cbFmPedk\`3N<ndUt?Z'\PZ_p:q%B9C,e6LTjj]R\]a/r#p`_<l^Q]?ID=UuO&ZaAYF]6g4,AGD07Wfj-BAq?f)$V8O"W$aelB8-PkL/2%%LC"/'QDaCNtej%jN0],lfsnUD_O-U@oH@,pt(\V$7)&!hsNPDZ~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1594
|
||||
>>
|
||||
stream
|
||||
GauHKD0+/q&H8h>Z!Z4)W,pabmS0O""pue59I[ns5@?Y\j^O.hP*+gYT``'CH`,I@Fu8T.L_EEBm6:F6hX96T!Q>T@hoQ)\nH/-N0OPk9kJ.34Y]pIM(W".BE5f5H5XIiUL]`K#pB0M,a?_;M+s&Hm<"l#1#fDHQOTCJg=u-VaL%BWl"/6O\7K>/:.\gnGLI?-BV?N)S75smDI`BcnLhbTdc/ohY:kH)Mor+Q,PY!H8Z%:SHYPrnZc\e!iP''W4n4-d<A6m*9Et+2%+$Zd*[&WL24L(52/%-Cdf13(?!/l3Led,Ug:*e9"NrRO.80@>!e\ue9qg5tJo,,[Wk;C8d"&ccXWi7.M82]hSSH06f%#bLH&dgMtQ__1Zf<TpB6E)Z62/ZSH6't]#]HTG*,j<kLL)IuN<50-@>U6#`%Tt%*'Bg2E,hUpc!Q2B_GY*6)g07,M3j?]pTdSMCpMPpP:P*`IF38p"XhTAFElTQ2,,S(qd?ZLZ\7%K&L:Eq>`c%7`m0d>sQu?;$pr_VeK\sdn)8RN@gM?T!XEXJ/R]iBabIG!]e:)=[S*WFi+<UHu<K-\J0N!E9+uPV@0c,8W[F&t<RdUn5OfLllou=C>&nVcCM`XA=^el2`4^h8kpc_TR?%DWM*2A[9`d@5g`;r9-0\J.d3l/Sj4L_a=#u`M1O^eqCjsILV3(Z>C=/aQn;rh,-8L`0.L!?YY8.*>4/[K[`_7.sC@aF(?<mT1KAns3h8,Mt`*^[("`[8='8h[*P[Gn+=/?LEN[]kb7(lj*#cY;f"=%,uX9lOo'*nthB40)Xbmui"+.h>L9)O#=XkB34e&8[#lDVem)O]-/h3NUA4[rnB8ZhT'9Sm9cc_iV^R<K(hLFPOW:Q+auJ%p8)9n.<d"j*;@8PK@3`_4+d6,2oOPR*C`HplrTDQKmf7m9:J`0%W0H8Z.O?jVX$sQZ$)qYr/<l=I[4.Y\+^af0lc!N4gf1nhGoh=am+K#CQ]JRHu[6BK)<-<1#8*ed"e+#p>]GC2Mc2Cg[HLCpM<(\UrL9:?h:F<o2-[eI7bMYDf1]G;@9$0!"*]5r(Ehcu8)eBBroNr2OHLq!+6gmqt5RL5RlQ0-H90:J.++`dE(#$e+6^\c*%$s%eZah#_4G=P:*ifm_iMeCLiqcd5mTK5Y38H+Mi"EW0@rMFd\a<BH]_Nm<R3KAl'sLJmnm`fGM+ip_JhW4t7Fi^Z0p@L6t-MbP>$DH]rQQ3G-%V`67#I7k/%W#i.0:*p;D226/m(P6q2IOua1aq-UQLAU7iH3X<T64FHClI3b3?IJq6%^t0NB*KIA^pV]7]GYV]%Xqi5K[Y?j?UQ)f]!u7).Hrd0G.u.`3\Bp"[4^gDH9&%rqaYk+iOCda2Y&#_hK7VFhu7V@"Fol)LH7e"Lh%aZ(]m6c]W)o4<<rQt*Fc^1pY]p3g^-Z:p;dEY0],gYUD%AL6S7eTCXoT=BkFV7VXP6Y(J/61<P>!t6YbV^ZPEdL+38V,Xda!d.SCG5cuUK+K+HZ+BR)GN:Iq3HD0f;`IFs5M\;Tt^65U=UEo'*0\QbM46G*7C>NsSe]<^8fC;NekFdc[?'f6'n!4!,T>l~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1699
|
||||
>>
|
||||
stream
|
||||
GasapD/\/e&H8h>E<oU\m8A\7.#Q0o!A3#u`O`J5h!N"l%uO%BC@i95mL+pE\*lj[Pao1*j1qHca60$=+-L7@'L0fjB-k?W-(J6p?lYIO@"JYd^4^.Bi$KROnlS:F6:JeF"%biN#OOFJ+Hf,>4COmr9N3Q.-q9Fn'Q>$.a"2sG_ra8#JAdVj8cV^>P"L0\4sZ;4$nY!qATE"m^eYho$'><OV!7jj$pE7)PXc/dTe)'G=I-2dj43$?i-E#d?$96""ha5,SR(omi]Mt90-3=F]"IA%)P66g\u;)r=8dPL^4YML'5N=U"=Ff0gW+fC4/<D>FFCiBYclMm?DWb-E8KNCN^/a<6t<5f(t'8Qio#Ip+\mt'KbP0BJ4IP#]r_Qb9ILWjB&dXq&qn5ecRmQ.P6Kft*MsZ0:u7G0?P7q1%[lqY"@bb5"NPs'0RV'@Qdc+7+!h<Y`1k$P:N@<=e!O^<"]p9*Pkhhc5J&W'Z/V$:%J#l<)5Dj8`^=-i)lcH^-&ZF%6C=8)]Bp#6>5X'CYORSr=G"-?_gbOcog`]d7Mg?H+g3#hfD:jO"AVl><Fj(a.)P!3nZ<IY'a&>4_HkeL&97&[T!oGc:t,oD9R_=\63WA3(,br!_6f&/*7$%B:u92`aI1`H%NNIQS3-ml6)a%akTNuAp6n`aFGaWTTr&gn`,un]AM\JD.>\TK]41D:MSM77N2+1A+uEhJ&"fCYg,*GVPFprKmEXF3f5&fYh`_<.7OX+-%7FM\FkafHcl`-Cc2&`pE!r/D0&^>B2=&N/01XAKVT!7k#Hk_hleg1VP6XbPUIYfkf;3Z+M,GO2,&B7;d=t<+:K7Ug?'Y).?,>i.Ik=`[<o9FbTDE/t]7+lE4s,g0PTg]Se\V@p^Zp-te8>"<m8J64l%_+anT;MhUN*\Dh*7&6Sdcr;Ph65c$]TZ]%,'op[5]nl';V%>E.QhT^f&h@[g.;kgMCtid$=S1DtBu1O4uSkZs#HW-3?bujb\+:r/YJ%EE$qjT>>1(SP$n9R;$<AE&:>;X46N;j.2pVF6MALL>1bY"KGh6jKJh*T#XDRpK;'/]BZsp::B2m(nRW:;/3h*#A89k`&,#gXHi?;YI<7Lj\ap9"X],IA8qtFdpcs1*LQai[PT1%7cJBq8VkF,U`pVigGS5,g1b"S([B&[6j8^cl?%rp*tF:n@i,Nb0H;JLUe-t4.#\>lhIAik$9;kldBA('*laBM&OIr7nGp.Hf4Om7$*QroI0q4c@B:k(L)QOiq)(.(/]YX%In,Sg6t@,b%s2S4Is=L$OuVNM*-6323F4P>p)Ep6h='&)c^h4)q.T?%X",D,2nbtE8>3)$J+WmV)-_`Bqjjd4.Ni<dYMu&0hPJ54%f5f1%id,AEJkNVA'.mn<T<s)h>Kj;^]uDhUX2-ICZ8/cO,,j/OI7!IT5`(p2gQ#a-Zn]f3#7um5f>8qLJWVf@2%U83]OWfC3aE1"og]8._)CWoiJ\3Kos;9&dd.RMMKSUk>TQ3AG6WW>ZjhhKT5`..C8ApbPeUL?[iAl[Bhhs'+.Kubl6D%2&7tJhlk",nQUuAH=[o<7'nfn<b1V?*R&P!<#0_j4`&Ou]RG0X8'Mi=YG&/'F_/k+020ek5O`LE"`TG5i6+n1Md7n0<f>Cj!\LKP/DkEMiJ@QCYVX#(Y7)3$>-G-q\q-KIX)pZ$Zt7b[`dT'**t.0g^]~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1584
|
||||
>>
|
||||
stream
|
||||
GauHKD/\/e&H8h>E<u9Sl!g*b,3>L#>&<R5'\fq)`IN5I'U`sj=>,ftUd^CF^6>KS2bmWE6S63M:?6KB*M4Qj"r&`Ar'h".b6?*r0jkt:(Vh;YY]pPj0"bt\E1^pFJ-\M2^juW_r]ma^;G]RI"!0-M<"l#Q8Ag6<JH:ekZt:3`$Q%,O#"8>f)[XG:jW"69GX6`&dBt7BpOfZi\,8oo0-G_e%V`L-Jmf2@#1]IU8LRM@_'4HPmsf_0"2AD_>=Or^[Xn0Q@AD!QQCM`8nRhWl/E=%A/,7*?fM.YZ2Q`!JnHfuWC0r35.1F$poIg,I>9\L2>?PMR%ee4f*N(&73W_@]Uio9mDjb?TL_*PGA3?9":LIdnOM:?1n[KdGg"SD+f7[%97Q,/<?BR#0o<C`,I3Pl(@=oH;C8fu'@)"rmO"HabdhCpR&JFHa)irr^2S>hK&FI/!*Z\@!n=_5_%dq4O736`@?6cKHEcXHl<K!bPY>7imO3sIP9"8;7!(1""P\K:(N95gJQfbL?,CI8B%g[Z@00)l\5iM.hT5JN9"nFX^RlIeeV\!DiHUnQC$)0;SW>5T2#`Ifnhjl/60jq[,mbB.\eCp0oDqfNbd!FVXlKcnH<)PlfR''6G%94s-_?4nh);>RS9']Z)i*9.2WFsR^&8HHgoq79^]qtlK*IQD#0LH7ji7a25U)BDH,p=CEArQ.V6pLh!3!tTh#cFh]B/9hbHktl7/1du:7CK<LWQP2*5``]?&^18D=^"jF#M30a<cOGB4ZnMh2N0.kaf`a`1/!stjmc+O&1Jog#-ru2]GEAk4B04YA"0"CeLF>G,L;?d3OsbFe[IPiRrG^Ao+B#qKWle[$HsY[^2,(-6YI38i>eT8D0f3B30i5\4:b(XMEG';NfpW(/jHpR7ji8"J<&V;"l7On5TNDcj^TD.Hq;*\[(t?QXlc9O%cBsDMIA,fDo`9g@Y+4`AVK*KWTcAmpflFt'dgge9Th)"=\PP'LmQ0Or9"02W?TPf?2/(4]Z[`PADr<Upd]Kla*U4s`cV7LEgbL>NQ8E;5C4;/&6Mcr4JPTlV^t<N*[(f<VphV\*$*63/f$cF>Wn_'N#rT[NSusFBbE5cA8P7LKU#i9)(#3f>GXpYQ*lH_j?A>M[qQ[h)!ei'b&!NbBoF2Td"568(YZ5mH''C5.d65q%FaT#bDI%o!\-/^Z(BB]9'7hamc!$!lU)8=lZuUCK"F6/i*5+D+?2=;W'cS<F+%;>3nrK(`[kr6/Q2AeQ8thu[d2?^KP>^IMg7)b-f?g\%b827AH1d<<53_/Vak^`h%L]Eo/2Nia@Y3MZX0:PM`<BQ4!)C*L3"AQ1kQ=m]^FAK*g-1efP\>&@6.SQ*jPFDjLZt09^>2?iaG>34PKGA*WeBiJa+XT0R7O&"e6B3RbAGggDh,N(Ws5S8TemrMf!iQ$b/ORh:NpcL`8+5V,5%:$Ca6%&%>pBrK>P'H9;L]?P9@iT3gIhTGe>.VC$iD[a+6^_TH2<gC9%iIa0YSokE5k;QpTBDC\N%ruK#W?WHS*s-\O35O4N=H#=D5A%9@O#L70tnW2)p^E693D(b>W"6$+I2?~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1631
|
||||
>>
|
||||
stream
|
||||
GauHKD/\/e&H8h>EQBN*Mcl4;7*7kUADOg#kgjnL2IJ48-o,;5PseWHPJ/1>I4+;;NDj9_d)_%('D_..]B>22",[n9s-"G\LG_2C`di&2?NCg._"p(h[.`@%KSNfi!/RhW+)rEchu#dJo*Pai&k4umTVs,.$4r(,V'6'_k=Nc@<#Vn\GmoK;WDiqT@R6s0Hh2XuEfNd#&qj]cq>?8FC?S)3m+<K+p4Ha4JosftW4jC="YtUN%ZB4Nr,i%TR%:CD]eLE&Be,b@/SmD5E.5&SYNrR(H4)t&CRpkKf7cAAoc%!tC=\j3$XXM<7Gc"5Y%sgDGcRsWC12UQGA.Wk@hYN%(t9TUj;h/j(%H]%0c=>W\E@@4PV5?86LOAo[#]hko=-'1$8o).eVt2pacK:M5sH^d"rf`[`E34"Qm*Ld"Ya?]X;s).kR@h2m%6(piEJ4SU]V/K<_5D7ZH2OY5dHkA\2!M9nb/Of<HGRYJ,HA[O&Ksf<2YdKc57j41%&O(0[M:G/n`QW08-`Arqnsr%ip*epjIP+8\cMamN)3ic@90S7Vlc>=r.b=mY^;.(;bk;>%m13g<X&aA"]8*>g_!.BH'j-V^B/8i3_^cAoZZY=DpsnS(8@J%7tD3=*IadjWOcXU^EV;)+E_mLkg=-9d`N6HGWnLF&,?[2LrW0Gs]njib07A6qF8LEb\JMR)Bqj$.bLdq9hP<2CrkuR[G`ea.d[."pT\`TuJaF.]$%,f5q.jj'%Ere`[)U\T[R7DoThm_Xo!l$)5I^)3K_2N\Y7;poNj.M(*0I<P3[Tj!ra"'5050=aW9eNWob]M4g![`<'G*NNPlHS!mdi-iY%^[Sc!Y+3MgG>7Ff#*1@qHMj(J#"#HcEJGJV`e"StW=34e3nHqGmF889Q(>6O9K<@BpVV5'bZ.gg+GIO'j3HQ.%+At@>rE^iVX&fka+,U1*g]e(VHj;Gn@%hQ1mEbje@-[YfDmr7EK,NRbBkM]gkfpu\Dbe'<!=h*O?&HsY5J]7d1#*8Koq@<tj'%YH9K%fm4LWB2Fg=4GDsNUn=nVncU=dO.>3B6'r)]XDB@)4!6Xa%-bf(H(DC!-IJ_PIB))ck67llpDLT5;GcV:5:@:@NdFur&Do!*jo&^1p+0&Qs9RkkP(GZ5Ll+dCuMU-d8>QAFcToF7n,Y+?S23nnJ%"d1^#ZHfB'Cj!/f;sGQ%1"Ena/qmHjBp#"2Wa5fRoVLiGl48.1aLa2jK1tV-%.G*3fRq`mE"03j"_SA_p;WJ7$b8a/R7!8'R`lMNmh=r,hQPLPPBqr2*F$`mIQ4dlk!7Mh[ANkKYHb>Tn+(0;^l*NF@!'ugrdH-=a#>.WoUF4>R4\9lei90*>i/ibo\NV'=CT?/JND'!9EX^:&+>233AQt`T^H[SgkY9`%k$Lg\cQ73Hh`4@\d*IU\+C,nnj/:\[L*kCUkZ;F"1Bm.m,UP8MdI!_9p^3*@Vs/n.Bp"c[78IXm8;FT?/TR)!+5Sn*:ZG%27e$o*,`(SlU@IRlT[ksV;#9*I8ctc`qsOW?#$Djgc\Ol^dkKWV/+Fb$Gff&_#FbW"aUm;oNG`9Y0oWI5CDlf60emD*J`1DRQ/:kPVk\!^5'!LQhV3*doKQH*mp>B!q0Kb(1Kc~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1621
|
||||
>>
|
||||
stream
|
||||
Gb!;d=``=U&:WfGfS=(?A0Mij5ltkdc6Tf=9Kd)fX3$9,+;0iNo2&!#Y5@Osi9`m(eB)r?*@:pkhsRtc=R;IW`AajtL+0q()'$A!^c9OR^]Y6'4+ZF[o@5#>k>HOi>XdJ!!sr60Jn!tTi;(;%GecRA8X'o3'I-3R$952PP"@&1/=nj1M"IZG!f1<WYP^KJ?/fpcU':(75IeD@ePB?@!og]CTpj42_/KTo8bK$b+I%0"K38PF%tAtU&+B(c.Do/W-h(2;QuH'A)<VW=]q<"gbIFWI"@qV&,l'JY=nNJ@k:h6pAW?25WsEa.2t9[;M<Y1LaspaTYTNNnc8:u4"S<"[g*-:)ePoF8>aJ')`_MFR/m:`T_A>D3%eiM8gQ*WjQ\8817Uo]2ZNO^bkMm/XRhIj](u?scR"ag\!h:'Ocss\hRF<\5%>:ZfGFpi'T<dE!m=Aq(-$8@D7=maM-/6C%N=kd/=ZLV$fluKR_`'JulKIntC?&gb1EW_:E3=^7Y,aZbqi&pb@gHl:I/>r;i7A#3_X@gCS9QU#aLrbTMp=(f$Fhk5@2>=>rd=D4>I!*:g;h(^DeR,dI4VJ4OVMUM9YML=g^uO^g2@^%M31V<5OO?&Q"c6"qlJ/H=+HdSd;sZaDUGf,X>A,^N(LQ[8W4aP@C1u(61`b2c>J5`2C<u'%l\Z)RgdRh^NpUFH3mSXBq:bWh'uU%KcZ93+DWWZ7g$.l;s5bfKtZ%5@L"Pf4WLXkem?!a9rbNL%\Y_j#Zm,L;7o<ceJh(-M*[HiD]Ml?l7-'+F<d[o7sC'&(Yt-@I&Y$LT-;XJL,;D`E<bV%jpI,T_`We%028m5iWCt6:BY#@H%l?gRO!cs-,_+R9qnt*&YIs?e5*PapZ^)CSFrZ*j4iVS&)W]qKhq_5q=-(F"#Hl:nf1>P:CIG2QQU:#eWM74RL?/pjk8+[U03qc4O7hOQIf4jV6DTCFBq&8"M1K3NboC`c+Z9/X>(dr1"gVmIoLTu/$'Sc+m,:-%cR'/aWjH[Hh'>%MG72i$7p@)cBZDOR$au8M\L/P<i1+P.<SF\$ncAh[Dm-<a8Odu(0WAdL1?7SBOu8gom%/fRr[Q34K8h%-f<hPa@td.2!nm,bSoLV)]^^disOW<*&k)b2:K*mC_AA85OSrGs0005mWr3XOZ`_]Q%FH6[qCe/!LIgN"g'@D(r]r']?:-,\>&DRYRMnk.E5:V8D/*\rE/%bXKZ^'0\!S65,!SNnVHJmKMFbOQtL2^^P;$o,k6PQdt0CB'C&0nfo%6nZu,aB92V?7YUAD'^L%34T)CV,%'1YgjA.Cg#>^7b3X8c]5Xj$!Su@K`_)hded%cZrj@Z&juReN,@9e'2(NBY2#RmZ>d^XsYn]/5g0pK*>JHgN']R"eHbHXu=s%\JrHtL_*2K)WYXe'eYaHV?dXDKEF>h,"u\!("U*DQJ'&#lY(8<J;U8%dWLj[`6$8WG)mKdg>-mS/Hu4F]&Rbq'&aG[Q)\*j4gd8+0+8g_=i<aTV$lnU!6-\Am)!"7j[HNqhK=ZsD<'@/kJaZRiPiuIi?ng_J.`B1j-OW]]NYBV!4MoCBa*:jU@4"up1e68nsESCf67s%i\[gRqrPi##3T>K\gH)~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1654
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>E<A*Al"XCthP^JrOL/^;/8`hbl_f,=,fWk9MloH.gOB)rYEn5&M2]YqJ_>c0PLSDOp[[f%$7<K$q&RZZN^kdN7tEE4h#b!@%ARu()&Zci_Hc6V%M4PfQHt*V<q1c`Ca@rK`:]NoJX[_>SddTUKQEaf_tuQWHB:b/I:5qpSniZ-#/[A"Ed-o"Mn"_>C7@2clT`%s[Z;6eq8BD;6SkbR$H!Uc#1Z>RCgp;_F'phWK+Acjces")Q<>n=.Y^01.YES4CW8Ir3ZB&M=d3V<_HXR4Z's!LU(:[S#GW)hY)[naiWQN2X7FSJL?8U;A6Rei@P>\[8d%tk,Np@5[?9OUg/?8X`3N&IK<`teL4u5XQL3BYbREWqlEVFg%H\8O[!?;CnTpHpW9:JSLsrF3N-5U_.&XE`eIMDbc?GqHD:*)mCHsZeTRk@1Cf$Xub=T#TA@>YC_d$@Wj.FoDqW8P_#C0]NN&%AiG8:#6]-;OClu`W<o:lsT(VT&h`dsjhM`\S1)5Wn>&6eo*iGV5;>:T+K>?>2gi_U^QG=B>ZWkpmFOW:<R9^a:)_cS7HAV81N2B0@m@M`l3fe,+)r(q^ui(=JWO\AX@MUnusF]\3ElBRtNMU;0272MepOdZe#=GR(q1=.M)9StZo^e_/m7UfeWXqiFU3^8*i?tgKh>0L>j['-TEa*0W=RH8N(S^n_1;HIp(a,4^VD(MduY3[@.A>>!i=Ymll;_R<^??+ZV2dj_M+pk#@BUN^:M`gkfaOh/fZ/(Ml;21JGAIR/qDq%1Png,9JSm3?Q822^3fn/]r!*W.u\/W.,9R'6D(uqc]X=<tTWRMoKmp9ao%rlA94?Ke*2>g.'FBYT'bVk)Zl?Ah\@SR[_c@q;.3rZN"[QV''#s7QrjM<_H]T#D?535VrPrcL&K`u78OUC/np_=.E(YE%b&;e+kc3qCM*M"J=qa7jBeZY)+"Kmb[?P_#.161e"TQ33AoeptKpBPK5Wu!=gh1<WOGBl06e`J^%(NH6^Zk_47EUC1RV]rnV@B;^aGN%#-ai4hg\VT3"N><`5qf,Z@\@ih3I#GR=(KcMsRbET5ZK(:@L*:_#)14D*)'Nd*:pT:3QIQj]/<MWnS7W\SdsO7i0b39,Y5>.>]=cu.OhP!s`f7c^l^oRs&0gQ@X5arO%%DnqOrEGL^E@kQr%krKr/lp-F$V>/+=P0c/;$*VGQk@@$5%o,d\H2k0&g!W`)dLgXB@Y6On(4!s5@siLI_%[+!-JA<><T1dpbtAeFT0npYnq_n$/^(Q1\\6UGM];p(-AKBM"X:)J%fkKQ8dU<KIZjs+<S5EnFFB[W<*nHe^VbANdk:b[lA9GR/a>$[o)(),W@H+qghWjb7[qKee"L"6^dbO&fCZ#L(6]Z\3(kN:\C1.H*/umDTj:rZ:fh7TWBUTCTP&&Xe;P^f`h5(?&^-.40f?0.i@r:L;^Q4Q4ujo0f?h=&5n]6n31@iY2t6X.,1Tid@&.bPlF"RdA4FM4UMX*X2%&,oQlVA%-D'FW*h6Dq?huX=9^&aSTcj[#50'ZhQUDGkQ5IT$5+,2$V-c]51SEI:j"0:NS,5E[m;B;Bs1jI$s'GJo-Qg^I9C&'C:u$Mk[u$3][Gt4uKo8m,1Ws,pZA-\mI/j"ej-QA,~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 34
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000741 00000 n
|
||||
0000000946 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000002079 00000 n
|
||||
0000002285 00000 n
|
||||
0000002491 00000 n
|
||||
0000002697 00000 n
|
||||
0000002903 00000 n
|
||||
0000003109 00000 n
|
||||
0000003179 00000 n
|
||||
0000003471 00000 n
|
||||
0000003615 00000 n
|
||||
0000005572 00000 n
|
||||
0000006064 00000 n
|
||||
0000007403 00000 n
|
||||
0000009290 00000 n
|
||||
0000010896 00000 n
|
||||
0000012827 00000 n
|
||||
0000014803 00000 n
|
||||
0000016489 00000 n
|
||||
0000018280 00000 n
|
||||
0000019956 00000 n
|
||||
0000021679 00000 n
|
||||
0000023392 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<ac774e881c346d045e81ff782e65a794><ac774e881c346d045e81ff782e65a794>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 19 0 R
|
||||
/Root 18 0 R
|
||||
/Size 34
|
||||
>>
|
||||
startxref
|
||||
25138
|
||||
%%EOF
|
||||
478
Les14-RAG-Embeddings/Les14-Slide-Overzicht.md
Normal file
478
Les14-RAG-Embeddings/Les14-Slide-Overzicht.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Les 14 — RAG + Embeddings
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Nieuwe PDF Q&A app from scratch
|
||||
**Vervolg op:** Les 13 — Agents
|
||||
**Aansluit op:** Les 15 — Cursor + Vercel deploy
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 14 — RAG + Embeddings
|
||||
|
||||
**Visual:**
|
||||
- Background: CREAM
|
||||
- "Les 14" in BLUE
|
||||
- "RAG + Embeddings" in BLACK
|
||||
- Subtitle: "AI laten antwoorden op basis van jouw eigen documenten"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Vorige lessen:**
|
||||
- Les 12: Tool Calling — AI kiest welke functie
|
||||
- Les 13: Agents — autonoom 20-50 stappen
|
||||
- Les 14: Externe APIs + Vercel deploy
|
||||
|
||||
**Het probleem dat we nu oplossen:**
|
||||
|
||||
AI weet veel, maar weet NIET wat in jouw documenten staat:
|
||||
- Interne kennisbank
|
||||
- Klantcontracten
|
||||
- Productdocumentatie
|
||||
- 200-pagina PDF die vandaag binnenkwam
|
||||
|
||||
**Twee opties:**
|
||||
- ❌ Alle 200 pagina's elke vraag meesturen — niet schaalbaar
|
||||
- ✅ **RAG** — alleen de relevante stukjes ophalen per vraag
|
||||
|
||||
**Visual:** Pijl van "alle docs context" naar "RAG → top 5 chunks".
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Welkom + Terugblik | 10 min |
|
||||
| Theorie: Wat is een embedding? | 20 min |
|
||||
| Theorie: RAG pipeline (index + query) | 15 min |
|
||||
| Theorie: pgvector + chunking | 15 min |
|
||||
| **Live Demo 1** — pgvector setup + embed pipeline | 25 min |
|
||||
| **Live Demo 2** — PDF upload + index | 20 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — Query pipeline + AI antwoord | 20 min |
|
||||
| **Live Demo 4** — RAG-tool in een agent | 15 min |
|
||||
| Wanneer RAG wel/niet | 10 min |
|
||||
| Lesopdracht + Huiswerk | 10 min |
|
||||
| Vragen + Afsluiting | 5 min |
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is een embedding?
|
||||
### Tekst als een punt in de ruimte
|
||||
|
||||
**Het idee:**
|
||||
- Stop een stuk tekst in een embedding-model
|
||||
- Krijg een **vector** terug — array van ~1536 nummers
|
||||
- Vergelijkbare betekenissen = vergelijkbare vectors
|
||||
|
||||
```
|
||||
"De kat zit op de mat" → [0.12, -0.45, 0.88, ...]
|
||||
"Een poes ligt op het tapijt" → [0.14, -0.41, 0.85, ...]
|
||||
"Voetbal in Nederland" → [-0.73, 0.21, -0.32, ...]
|
||||
```
|
||||
|
||||
Eerste twee zijn **dichtbij in vector-space**. Derde is ver weg. Niet omdat woorden overlappen — omdat **betekenis** vergelijkbaar is.
|
||||
|
||||
**Welk model:**
|
||||
- OpenAI `text-embedding-3-small` — 1536 dim, snel + goedkoop
|
||||
- OpenAI `text-embedding-3-large` — 3072 dim, beter maar duurder
|
||||
- Open-source: `nomic-embed-text`, `mxbai-embed-large`
|
||||
|
||||
**Visual:** 2D plot met 3 punten — twee dichtbij, één ver weg.
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Vector similarity
|
||||
### Hoe meet je 'dichtbij'?
|
||||
|
||||
**Drie metrics:**
|
||||
|
||||
| Metric | Wat | Wanneer |
|
||||
|--------|-----|---------|
|
||||
| **Cosine similarity** | Hoek tussen vectors (–1 tot 1) | Default — werkt op semantic search |
|
||||
| **Dot product** | Sum van producten | Sneller — als vectors genormaliseerd zijn |
|
||||
| **Euclidean distance** | Afstand in ruimte | Zelden voor text — meer voor images |
|
||||
|
||||
**Cosine similarity = 1** → exact gelijke betekenis
|
||||
**Cosine similarity = 0** → ongerelateerd
|
||||
**Cosine similarity = -1** → tegengesteld
|
||||
|
||||
```sql
|
||||
-- In pgvector
|
||||
SELECT * FROM chunks
|
||||
ORDER BY embedding <=> '[0.12, -0.45, ...]'::vector
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
`<=>` is cosine distance. Sorteren op `<=>` = dichtstbijzijnde eerst.
|
||||
|
||||
**Visual:** Drie vectors met hoeken — kleine hoek = similar.
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: RAG pipeline
|
||||
### Index time vs query time
|
||||
|
||||
**Index time (eenmalig of bij upload):**
|
||||
|
||||
```
|
||||
PDF / docs
|
||||
↓
|
||||
Parse + chunk (500 tokens per chunk)
|
||||
↓
|
||||
Embed elke chunk
|
||||
↓
|
||||
Store: chunk_text + embedding in pgvector
|
||||
```
|
||||
|
||||
**Query time (elke vraag):**
|
||||
|
||||
```
|
||||
User vraag
|
||||
↓
|
||||
Embed vraag
|
||||
↓
|
||||
Similarity search → top 5 chunks
|
||||
↓
|
||||
Geef chunks als context aan LLM
|
||||
↓
|
||||
LLM antwoordt op basis van context
|
||||
```
|
||||
|
||||
**Belangrijkste inzicht:** AI ziet nooit de hele DB. Alleen 5 relevante chunks per vraag.
|
||||
|
||||
**Visual:** Twee parallelle flow-diagrammen — index vs query.
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: pgvector
|
||||
### Postgres met vector-support
|
||||
|
||||
**Wat is het:**
|
||||
- Postgres extension — voegt `vector` datatype toe
|
||||
- Cosine, euclidean, dot product operators
|
||||
- Schaalbaar tot ~1M+ vectors per tabel
|
||||
- Ingebouwd in Supabase — één click activeren
|
||||
|
||||
**Schema voorbeeld:**
|
||||
|
||||
```sql
|
||||
create extension if not exists vector;
|
||||
|
||||
create table chunks (
|
||||
id bigserial primary key,
|
||||
source text not null,
|
||||
page int,
|
||||
content text not null,
|
||||
embedding vector(1536),
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
create index on chunks
|
||||
using hnsw (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
`hnsw` index = snelle approximate nearest neighbor search. Voor production met >10k rows.
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: Chunking strategieën
|
||||
### Hoe knip je een document op?
|
||||
|
||||
**Drie aanpakken:**
|
||||
|
||||
| Strategie | Hoe | Wanneer |
|
||||
|-----------|-----|---------|
|
||||
| **Fixed size** | 500 tokens per chunk, 50 overlap | Default, simpelste |
|
||||
| **Recursive** | Splits op paragraaf → zin → woord | Behoudt structuur |
|
||||
| **Semantic** | Embed zinnen, group similar | Beste kwaliteit, duurder |
|
||||
|
||||
**Wat is een goede chunk:**
|
||||
- ~200-500 tokens (~1000-2500 chars)
|
||||
- Genoeg context maar niet te groot
|
||||
- **Overlap** (10-15%) zodat info aan grenzen niet verdwijnt
|
||||
|
||||
**Te kleine chunks** → fragmentatie, AI mist context
|
||||
**Te grote chunks** → ruis, irrelevant info verdunt het antwoord
|
||||
|
||||
**Visual:** Document opgeknipt in chunks met overlap.
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Wat we vandaag bouwen
|
||||
### PDF Q&A from scratch
|
||||
|
||||
**Doel:** App waar je een PDF uploadt en daarna vragen kunt stellen over de inhoud.
|
||||
|
||||
**Stack:**
|
||||
- Next.js 16 + TypeScript + Tailwind
|
||||
- Supabase + pgvector
|
||||
- AI SDK `embedMany` + `generateText`
|
||||
- OpenAI `text-embedding-3-small`
|
||||
- `unpdf` voor PDF parsing
|
||||
|
||||
**Twee endpoints:**
|
||||
|
||||
| Route | Wat | Wanneer |
|
||||
|-------|-----|---------|
|
||||
| `POST /api/index` | PDF upload → chunks → embeddings | Eén keer per document |
|
||||
| `POST /api/ask` | Vraag → embed → search → answer | Per vraag |
|
||||
|
||||
**Bonus:** RAG-tool in een ToolLoopAgent — combineer met Les 13.
|
||||
|
||||
**Visual:** Index + query pipelines uitgelegd in 1 diagram.
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: LIVE DEMO 1 — pgvector + embed pipeline
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `pnpm create next-app pdf-qa` + `pnpm add ai @ai-sdk/openai @supabase/supabase-js unpdf`
|
||||
2. **Supabase:** activeer `pgvector` extension (één SQL command)
|
||||
3. Schema: `chunks` tabel met `vector(1536)` kolom + HNSW index
|
||||
4. `lib/embeddings.ts`:
|
||||
```typescript
|
||||
import { embed, embedMany } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
const model = openai.textEmbeddingModel("text-embedding-3-small");
|
||||
|
||||
export async function embedText(text: string) {
|
||||
const { embedding } = await embed({ model, value: text });
|
||||
return embedding;
|
||||
}
|
||||
```
|
||||
5. Test: `console.log` een embedding — array van 1536 nummers tussen -1 en 1
|
||||
|
||||
**Visual:** Console-log met vector + Supabase tabel.
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: LIVE DEMO 2 — PDF upload + index
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `app/api/index/route.ts` — POST endpoint accepteert file upload
|
||||
2. `unpdf`: extract text per page
|
||||
3. **Chunking**: split text in ~500 char chunks met 50 char overlap
|
||||
4. `embedMany`: batch embed alle chunks (sneller dan loop)
|
||||
5. Insert in Supabase met `embedding` als vector
|
||||
|
||||
```typescript
|
||||
const chunks = chunkText(fullText, 500, 50);
|
||||
const { embeddings } = await embedMany({ model, values: chunks });
|
||||
await supabase.from("chunks").insert(
|
||||
chunks.map((c, i) => ({
|
||||
source: filename,
|
||||
content: c,
|
||||
embedding: embeddings[i],
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
6. Demo: upload Polderfest 2027 line-up PDF — 30 chunks in DB
|
||||
7. Check Supabase tabel — chunks zichtbaar, embedding kolom gevuld
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: Pauze
|
||||
### 15 minuten
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: LIVE DEMO 3 — Query pipeline
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `app/api/ask/route.ts` — POST endpoint accepteert vraag
|
||||
2. Embed vraag (zelfde model als index!)
|
||||
3. Supabase RPC of raw SQL: cosine similarity search
|
||||
|
||||
```typescript
|
||||
const { embedding } = await embed({ model, value: question });
|
||||
const { data: chunks } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
|
||||
const context = chunks.map((c) => c.content).join("\n\n");
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
prompt: `Beantwoord op basis van deze context:\n\n${context}\n\nVraag: ${question}`,
|
||||
});
|
||||
```
|
||||
|
||||
4. Supabase function `match_chunks` definiëren (SQL):
|
||||
|
||||
```sql
|
||||
create function match_chunks(query_embedding vector(1536), match_count int)
|
||||
returns table (content text, similarity float)
|
||||
language sql as $$
|
||||
select content, 1 - (embedding <=> query_embedding) as similarity
|
||||
from chunks
|
||||
order by embedding <=> query_embedding
|
||||
limit match_count;
|
||||
$$;
|
||||
```
|
||||
|
||||
5. Test: vraag "Wie zijn de headliners?" → AI geeft accuraat antwoord op basis van PDF
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: LIVE DEMO 4 — RAG-tool in een agent
|
||||
### ~15 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
Combineer Les 13 (Agents) + Les 14 (RAG):
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
|
||||
const ragSearch = tool({
|
||||
description: "Zoek in geüploade documenten",
|
||||
inputSchema: z.object({ query: z.string() }),
|
||||
execute: async ({ query }) => {
|
||||
const { embedding } = await embed({ model, value: query });
|
||||
const { data } = await supabase.rpc("match_chunks", {
|
||||
query_embedding: embedding,
|
||||
match_count: 5,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const docAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: "Je beantwoordt vragen door eerst in documenten te zoeken.",
|
||||
tools: { ragSearch },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
**Demo vraag:** "Vergelijk de jazz en rock-headliners op Polderfest" — agent doet 2-3 RAG searches, vergelijkt, antwoordt.
|
||||
|
||||
**Krachtig:** RAG + Agent = AI met geheugen + redenering.
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Wanneer RAG wel/niet?
|
||||
### Niet alles is RAG
|
||||
|
||||
**Wanneer WEL RAG:**
|
||||
- Veel documenten (>50 pagina's totaal)
|
||||
- Documenten veranderen (klantcontracten, kennisbank)
|
||||
- Semantic search nodig (niet alleen keyword)
|
||||
- Privacy: data blijft in jouw DB
|
||||
|
||||
**Wanneer NIET RAG:**
|
||||
- Klein document (<10 pagina's) — gewoon meesturen
|
||||
- Exacte data (prijzen, IDs) — gebruik tool-calls / DB query
|
||||
- Structured data (tabellen, records) — SQL is beter
|
||||
- 1 keer per dag — overhead niet de moeite
|
||||
|
||||
**Alternatieven:**
|
||||
|
||||
| Probleem | Beter dan RAG |
|
||||
|----------|---------------|
|
||||
| 5-pagina manual | Volledige tekst in system prompt |
|
||||
| 10k product records | SQL tool-calls (Les 12) |
|
||||
| Live data | API tool-call |
|
||||
| Code-base navigatie | Tree-sitter / grep, geen embeddings |
|
||||
|
||||
**Hybrid is vaak best:** semantic search + keyword filter.
|
||||
|
||||
---
|
||||
|
||||
## Slide 16: Lesopdracht + Huiswerk
|
||||
### PDF Q&A app deployen
|
||||
|
||||
**Lesopdracht (in-class, 30 min):**
|
||||
- PDF Q&A app opzetten (volg demo)
|
||||
- pgvector in Supabase actief
|
||||
- 1 PDF indexed
|
||||
- 3 vragen werkend
|
||||
|
||||
**Huiswerk (voor Les 16):**
|
||||
- **A:** Eigen PDF (interesse, studie, hobby) — minimaal 20 pagina's
|
||||
- **B:** UI met chat-interface (`useChat` van Les 12)
|
||||
- **C:** RAG-tool in een ToolLoopAgent
|
||||
- **D:** `RAG.md` met:
|
||||
- Beschrijving van je PDF + chunks count
|
||||
- 5 vragen + antwoorden + welke chunks werden opgehaald
|
||||
- 1 vraag waar RAG het **fout** had — analyse waarom
|
||||
- Eén chunking-strategie geprobeerd + resultaat
|
||||
|
||||
**Bonus:**
|
||||
- Hybrid search (semantic + keyword via Postgres full-text)
|
||||
- Re-ranking met `cohere-rerank` of LLM
|
||||
- Streaming antwoord met source-citations
|
||||
|
||||
---
|
||||
|
||||
## Slide 17: Volgende les + Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- Embeddings — tekst als vectors in semantic space
|
||||
- Cosine similarity voor 'dichtbij'
|
||||
- RAG pipeline — index time vs query time
|
||||
- pgvector in Supabase met HNSW index
|
||||
- Chunking strategieën
|
||||
- RAG-tool in een agent
|
||||
|
||||
**Volgende les (Les 15): Cursor + Vercel deploy**
|
||||
- Externe APIs in Next.js Server Components
|
||||
- Cursor Composer + Background Agents
|
||||
- Deploy naar Vercel productie + preview per branch
|
||||
- GitHub Actions CI (lint + build)
|
||||
|
||||
**Daarna in deze leerlijn:**
|
||||
- Les 16: MCP — eigen Model Context Protocol server bouwen
|
||||
- Les 17: Externe APIs in diepte (OAuth, webhooks, paid APIs)
|
||||
- Les 18: Supabase Auth + RLS — multi-user apps
|
||||
|
||||
**Vragen? Feedback?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Wat is een embedding | Theorie |
|
||||
| 5 | Vector similarity | Theorie |
|
||||
| 6 | RAG pipeline | Theorie |
|
||||
| 7 | pgvector | Theorie |
|
||||
| 8 | Chunking strategieën | Theorie |
|
||||
| 9 | Wat we bouwen | Intro demo |
|
||||
| 10 | **LIVE DEMO 1** — pgvector + embed | Demo |
|
||||
| 11 | **LIVE DEMO 2** — PDF upload + index | Demo |
|
||||
| 12 | Pauze | Break |
|
||||
| 13 | **LIVE DEMO 3** — Query pipeline | Demo |
|
||||
| 14 | **LIVE DEMO 4** — RAG-tool in agent | Demo |
|
||||
| 15 | Wanneer RAG wel/niet | Reflectie |
|
||||
| 16 | Lesopdracht + Huiswerk | Praktijk |
|
||||
| 17 | Afsluiting + Les 16 preview | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **AI SDK Embeddings:** https://ai-sdk.dev/docs/ai-sdk-core/embeddings
|
||||
- **OpenAI Embeddings:** https://platform.openai.com/docs/guides/embeddings
|
||||
- **pgvector:** https://github.com/pgvector/pgvector
|
||||
- **Supabase pgvector:** https://supabase.com/docs/guides/database/extensions/pgvector
|
||||
- **unpdf:** https://github.com/unjs/unpdf
|
||||
- **Anthropic — contextual retrieval:** https://www.anthropic.com/news/contextual-retrieval
|
||||
- **LlamaIndex RAG concepts:** https://docs.llamaindex.ai/en/stable/getting_started/concepts/
|
||||
BIN
Les14-RAG-Embeddings/Les14-Slides.pdf
Normal file
BIN
Les14-RAG-Embeddings/Les14-Slides.pdf
Normal file
Binary file not shown.
BIN
Les14-RAG-Embeddings/Les14-Slides.pptx
Normal file
BIN
Les14-RAG-Embeddings/Les14-Slides.pptx
Normal file
Binary file not shown.
175
Les14-RAG-Embeddings/Les15-Huiswerk.pdf
Normal file
175
Les14-RAG-Embeddings/Les15-Huiswerk.pdf
Normal file
@@ -0,0 +1,175 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105902+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105902+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Count 6 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2045
|
||||
>>
|
||||
stream
|
||||
Gb!;dbBDVu&Dcq.As6.NXUmVi!5!:ja76.X]]<V`%7NSrD2M:`b"f;,[s%gM+=16tX<qXuq,$&^Qj5<dkCO*jI[I[cF+EEuGkR:$j"Qo%F,::"1H6F9T,kds*k37&+fP`-OAP.*.&TfMSko9GLC>F;L^[O-,pQ<B&ie/E!Z"&#@.MfU5Q[P^dKLh_k=bj+HpN/*dBlP/h[_[/\,Jtd?:b"F4*7h)7qFZ3[6[rt_BkFlJHqr1%/tYGnJ#:E;cUfCU<)9*/lb$7iI^Y$N_Y@"<L$Um9&PE7<`u\pX1\EL7[GfTWbqG$]>>eqTY\5=1KC.Gn7n@l2laN*oY-<^Bc[<@.=-c-]$f[2el,p0&qljlc.KSdNfWeoNZ35f1/l9`-7#,XqFPW$I49DbmmHHo)o`#ST#^KY:?E3%YRmlo`kl>P2.e/>dsITFXYD?"SMo1>M]I[mA9_t3[pN<r8a+A<gh4L"j8riSQn-$aNPsBBG-Q+>.RbKWZ1EKB8NkbGcGOgi2.e-qG'd_/[h@D/UReZ>*P5A7&W\Zg]"hRf4VhM3XD1FYRSnD(&oIET=tm4+>;!T'`Y)p8huWt%OjYB=;V5b?GEp57-"&ui)3>CkonFaQ9RD;)O41)NiU)Pin-bbYioU-dafs>RR-"t=ZPmCP*bBh$lJ=in$\:bc/<p,6>P"H0m=<Lm231gSK)SX-41F^fRSQdJ1Bk[2rmsF>'2/Y+(Ud=Q0QQY;])$9gl+U4Bk*',*I<Dl=h=m\m\iC*a*YcO#'d["OX8=,E)7ZfU;:WY4Erf@A-qZ2XjYuGsHC<=J]L\b*Z$/]'S/rBkBqF:0Pq'O5U4"V0m*/+*^KQZ&oG0lZd:Aj4HnL=df*:bt_AT2:aW?Qa'Zf/OQHSibnol$BC^#.;osdD[g8Mf)!$]s._!Sl9rWIp6#14^?_=b\MbRdJ,_qcX+HD+@6fm,>RfMeK(`gO^*O)'QXFJ6g0D@_QU^24Abd!\^2p%H1Thqb$X?E7I[P.f?6m<2++0j*i<43"i+Q*hN)'5FFAXXVhoJraV-TXdt\B+-TkoFu?/M9;#dZ&k'%P2_oRL3e[E,]&i5gBmeLqa8#1i.#[D_gQ1qN[i&7djnqbir!sXK!8+NbTr4WkAD'K:8cC"_=M,];o:Ho5n1d7&d=]'MFCE4aMJ3&j&MKoE?4-p`uW1:>T!>0;h]pjW%Y`RIt`+_eO+-tA(7FF=3Y9@_#p2g_Y^[6=<*akP300@QHed)bs51F99#cO9(l&0f:sTVU:)rT>q3*0L[naq-SQX<E.->.:4j;1r3S4i(<@`!.sj$(gJPQ(qReKO<b#UZb4#Ya"[gY<#`DEMpJs5Y"#IqXYS+n<i9GOK0RV$SLX[fJer$=a?KXK=S89S-0RAkjb\*-M0U#/N+m!Qq&JF/mS81#TKTO9]1rVH>D'<?412hX9oG_CqFS-+JKCki\<GVHClnfF=@Xu^7OejJ>q"[+r,!p$Cik^WGNte)G[]2$P<#KkERT-Gi*A-(+.ItZR6[RjJK[=n0Xbj_]1V7$\Ygl<=Z%h@pLNTp6oeK+h\566o4QT;#Wa9"K2'ubN'7WD<SZN,coOaff@9"l%=M`65XjnV!W"N%uln!I2eDQr2<?%pI*be,1^ON$adbX(9.FZI3FW!UoelS,*diAI3<;67pZnJQ<#j>35PqH%!7`21+djp\LPaQ:C&QVCl^OK@@;Vu/j?lnFQ.,,KX%ufNkBY>6V'd@9*8,3_jUkR9CTk^OVQ[1WO.Qhp'9eZ*"$>V.Zi*IMmGP@iDU2O5-BmbR)dll'J"oL&2IftRH^;u0[),?'0%WgEl8W#VjbnnRj8$#2]%)6QfCKgEW6<Mj5/M!ZoM!JCUN=tBXb]cDM,CkX^Ff?R\/Q3qOoaPsA7-4G!b]mMXqI"jnG?r-aSJHL)n:2KtN"'Q4j'^HP5C3ZZkdTI,$9O9sC=h4$m`g)gntGNq*^uJSn,[hSFofU4;+D/`<@?%LdD:P+)C#?*+BDT?qC&tILN0T;;f=CA/A8,C%k*L?i!^rII&p@>ZDF),q$15hmns~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 437
|
||||
>>
|
||||
stream
|
||||
Gas2E?VeNm'ZJu(.F+]O=F0joNu0.((klMsj9r7SVQf#;p$5g?5`fYte!Kcuqn"uo*fAZmYm.?EoLB#s87"Q=.3uC48YYs8#pZP"@+'$4$a<`N(TK#9TFE]^L^B)Gk,a/T`12`+7*o(0(6up50-W)c\9//&OZ(LTkp8,PK[u#pA)cDB*M;*G`Q[]4!bSoo=a36ETan5'0i$G;,\pZ?h*4Xg/i-ifQ=[VD6W<g"ZGD_9(5'`ZTW[mtnL94le$$p=g)&![P#ipBD6G16,rJVagJe,V?O(t;5P"Zo=)XYuFIqU5*9,%Q(-_DMe4RQIjsl#rY'GBM%FrJ8gK;B;PeB9YF/Sd\V'Bph[70JD`87fEq;R744Wuq"Pf,D@URtTNp)i.5l9-PaYHk6-?Qf<g;npI^hb\2E*KH6WkBZHCQ(Bk*[>!f`+]t3o~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1620
|
||||
>>
|
||||
stream
|
||||
GauHJgMYe)&:N/3%/dJ-D477KOkLu_S2M/c5uT_3'IX_fA1b-g=?#PT`sM\4*E#s#[Y=>sX2R[migcFqS/De=MANV6;$uf/"\13_0Fk-Z^]Y7RH4A2%c@nWqc_^,]\;Iiu#84Q/"6J+U_"3C)n:6(aOshbE-q9F.'QDQcP"@&ZI!Xd++O[/c#p>+N^@K%fGDXX+RMNj5rSXi-kFXNt"iTlB6T<aQK&I'f"Z;saJl(T$%ot,1rd=>:"CC1o>t)@f:Z,Mal=a8:RWAeU-\o`B'j%g"^`WDN-[Cj>XPS`_B37;O[O1ddon[Rd]T)qp=6Mi\C?"MNQffsg?f<og6?";;Vmr]hP0Z3c,uql@OAYl<N]Sl]N,jV*Hq5:GOnNdV,n?8p;P>EmY.@Vb0^82.`9Z#Cc61"0VAM*cK.+NXJHl:cbM"h+"2,gD#Q*]&@JH.J0BiI)#:kp(7kYMQ1H=&:%q/-UO.;bN8S,%_pI?kFGq=;jU,m`sEY]5+!mVN7`-%?CCsJVHnj6Hjc$/UiI,EogKR]%93:U+$`4^m)p[QGEg5mo3+P\07@(j)"AWapP]_27D.th<=WXL<n7<gAYiSKa8Oojs0nCE!P#V"L@*+XmN'K(cCBW+k%:dO2BUA?a01KLi;;C\Jrl=E.*JpBhl"\7:TMZs[r#ssW$$nnDSPLjmZUSBD.%5l-j7pe@V0q.Y&?>?t]fZ_b?R1XDXO_Cmnbh_@IkQm@Hm)Z&_eCFM7?r5otS^Y"T7!Vh`:km`63\7$H49^rKp#=^7#33X5/HiinHW1k!Fe!*f)*NlS7K_$e!polTlZ5G&X_s"A%lYCbH.83nVDH:$3"*"9_.B!"V:.-!is;YENK^T*lYEeMn@1WY'_.90S\7!5Regm.6_l#U-2H5,*RtuX>Vs%)2PR3(j(u>6XS:[c]g%9)IGtF1O%+WH;,cd,K[k2\_16<_i9>/6Z_u"`[Z6*UBlg&u1VMJ7;ja'D.Z?I2RnU"%(4NsslO:#<Oap?b!WE:mYR]W(^oa%L`9l-O/>DfH0+I5\pEA6Jfm0uL0^HD;[OO1]%rLeX#61$"-jS+Mk=nu1_401lm(S&C`rL#>@qOBj,otejFkr:M%=(^6HA)*#Ds_/V\\Q0fmR],$(q([@'A04&q8992J-_h_\"NRjOdC3W26;gJgR4[#VQXB]ebV>Q$S.\u>>GlARrtF$q*l`)X>6?#0kPf;e;#549]I\\RjGi=M!msd5ulMrMkj#@B^7\/PFcdpM:U2Xi0]\XNReDTNQ'\e_MZ(Mo5\>@Wk9c*?'11(VHoNZf,TgQD:>bi-1Z=t@rk6%GUSr1XnTQTMgon2>N1i!IQ+>jWR_GHB6U&ahin!8O%AF,8*%Y5&`2j5N+(Wl'8&F_(=2#5hs.\K[nMf\-'!J-8S[/V#7V_8e0=HlIdY`$!d%a44Pr<[AW(88o#7%hq4Nd.BmC-MWei<@^CPk$gYcSYmF8Gl)uU)bX#1OT(GK@$c8H2:=5^*N(ZK,oLI/XRi#(EUfu3(+#4tu6K75(,:4HSlUTGVqmNdF/-VGE"@@UWc;.t]?$GMs[o.1<na27_1dJ^9,hIC'5Y+6=7:'PZ?Rdk2#r6XiBT<>o7Htk<RrW@dBI=q~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1352
|
||||
>>
|
||||
stream
|
||||
Gb!SkhfIL2&:X@\Z#<.dTN\X;bC-pp9mZ%tVq:XPgcb/$Qj$amK3AK-rq3b+_)S,bg<4$0(DuM[ddFT:^>5&7\FebQDk$j@(rEU\*4UOX"LqtTL2Z!jk^7E<#Xg(h@%MDP8-"Q1qZl64ah\Ki&;c3qE^Q]j#uYc?<2;e+\hN1gPR*I#%L3aRDQ"g7`Vi%*Hg<g1#XPU>o7J0s^OCf2=a1Md2WtEYo-Im'K5Z?VXa6/@g^efMB$bo%qt4a7.]-n==])!aB2rD'X'd2@#_Ub-JME"\E"!Pm_R*EUQrdoSr!:1felTo+oR%uK2nWr+%2sg/=3JO`Z(*arhs"j<CQKQ(mtoPrV*JjYL9>!W2&U$s)0\2P7X6bN!G%5[=``>XV2bhLlUo\F'5B<r1*-Xo45t2A`(iB`2dC??9sm>#c&6s_D0rp`dfS/3_ut%+T/rL6o%c[B2<;2P@`R;u8El`^jCWBN7ORcZR5q[Rm&/P6B<SC]C@qd)D=l6/Z\WTB$))m5>u19rhQrQ>I:9d^q>Rd35H*s>9^LN#K)%MQiSLj>@IP5Z`\?\[Y[!t1a_c.n/L^hFD5Ha$l[lXO7X6$10&FLUU%\f3<J%Td(?7gq0<M5taX>%J(gS8$p&qP^!-.>F>;D"Z[jFJLKjqZ#ObIDh&L$Uu<<q%+XG]km`5D'e8<T4`8O+;'d!Q?@kBDp#5Z?opCiZX6GR?l'`fdWNER:B\:H`#`4($k[eNi;_:17!9SR9U'@"eE%_<cRJ820RMREpqKnq`G+:`qE."FZJ]6)uiB"KC80g&$+[gN)5-ntF6XBZbl:Af8X&<^iH$X@bXpr#c5+4FS*mG>`Ed'I@-JIr=*,krI*3YA%h.2ju5F8lo#Udr8;<W89I;%iJDUOt91IR8ffp`'oN;SUaZB&'^Wr0ZRUrPI)EIK."HRK7?1SV$Y^VQU&4eAjce/BT?2T@A_'R#.[Jn)m#qO[Zmn:"^d-59hcFkT0SSKeO.e2)k(O#ds_AT0!J%;*N0IaOXKCOX0;):JJ5^jM'P-?Ogq?i,FGL\'i#(eW4*eAA3Ac3,2Gr.:j]AA"LCnS'1Z]:m?Y\5/\so9rO>s-#CX/mUc2Xr>)BW=l526=Kl[1Nb4HXq01VV[F!to7QBY+c@=@PRae&@d[e[\:endtufMae9C_#8tqu??Pme'Wb6E2KF&sDmhc*EV[iU=5N*t.Nbkl-p4#mn5(M4-`&?Di+@c"b!gD0L)F1qs"dOl.-NH4*GFgXd4jHu3_u\EM@:o"J,i=6+YrLu=,.]J7L9SCAZB?u8OhZfea"*QfF6qNg![?)X5Kk3!9lH!Fg4&18PIKUt6LMOrX#_&MjP)0#QUZBW*7~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2116
|
||||
>>
|
||||
stream
|
||||
Gb"/'>>s99'Roe[3%s*]V(n8eg[a^Tc9PS@4*6h;d(\un*0IbPQE_H6p=Jbqlo6?Q#"s5F'&qQ+^2q#`F-?lSp`fDfaT)g*Y]iWg:`*L>@4m2k$%*RN2i^Nu)R09IT6l#\_2Wm7#NaU[+UHmXVD/,>74sW<0MYlu@h'4971kBLP]6<8+lGh`(NBX3-UGhk#P3@&U_H6R2[I<GEIOr12sSA"fP61V2K&`9&Yp`'U&*"+8.YqYb[BV;^V:i-OWM[qR:r_\]ImbNWSrHW#kj;aqc/n^[2d#0Gq5.417?at@JCjtDiCb=54\lCq-J.bs$Tnd4rd;GlA$po)r+@H:3-G%&*PefFbUUY<n#.MY&>GHCotYs+"bBY(:"K,LB_Yf[<nbj)S)H[GjcO3m3q+[.&?eh_/)g)FO0FaiIi&HmSSr'iW)#UT/:%="_J8X<,hP:3<XgXo,kXUaQoWIN]5QViD$4\41V-6A"GXna#]K22/BsNBUm+hDs6cC5C.)\]7e+eEZg2(o75RQHf&$DQS`;\]_:h^M]]S'+[2Z=5[nijHh[h,T2op9UPh1`\WlC=oa4LYHo<6k:UkC,qt!JbV!D.uBG[8Hq&D*bm$'Bqe'f*BD)AW,)GD+@3u90"m]"i?&f3;G\87_;LO]iX*#<DA]ZW%rI)1mX=dkC[(Qu-@`[I+;2WqNt"AZ(A%[EWOM&bSCA:Pb%%>b^3<io>_UE*^e$8YhG[)15HAXSROP+kVsbLorP'7$,qBW"d7e`.9`bLKRk5qRT;CY8O^jk.mi4d,cC/-dDj9MKheX&RWT>],2c@i1KB)!N.<gNF_'U@j@#2PApMfjH/mOZ#1_K2Ud^(^R14^DJ*ci"((1-aJt`-RYP1STu9XU],khT1&V+<7P]lr6*4ZK)e[(+'*==[M30Z<?^o?1+CBYRc8m1T,0?N8h/D'f2e:=/*\dfW.Du1<7DGn\=t&-$JVmbk*R%tUt4`DC)0<TW[aK"_kW)=pR`\+Lq>F8R+Jd.F%KZM/;5GFTg,FIdI\'`j?gc[fa4H7*0f]4;Ie@\9p=PB0V_i+$_PH*D#^9"N=6stDGY)-^%tKS[5"XZ=Y4_npW9[Q+m3c0N]iBWGqfqDYG'Cf4Yar]kgh*$eUX_+f__Ui=oMRFYq=&Es$f`t`L\L+]jr>P]i%XA3FcbPj3lM!>hUfL>u1&3CkF=l5)nnf?(+)taF_jl67<\m%/X5pF_,hI7(I`f@P!s\W?nC+GGWjPjjj/AG96A2Lf&dC([Xul<i_@Kq+*i^]-`9DbXj%/Y1)+B!1Xhqq$FieZ>T9k<Y[]NZ"7G#fUN9kLP>&1(:HT"OQ5g:^_,CYbEe=FJ4l67Hr+2==.\93A$lfFXa'<W'O0I:R]SQ.n?@^Y-18dF:,/jH!OZ%ti\`K*CbbYB[qm=iamZ)7ho.i$ae.t/%:^:"q$+FW-A>VWJ,)'gju"M6#G&5)p+oj$Lt<<BMg_'F[M7C=dT%KrF1!pn9/KWqd93.gAgW_4\f.n@=pqd%?P(oJ%n3K&M5V\U>c9`s-9=pV401JLRc1aIaXVZH4A09A[<ma]r)o\ffT"E63o$"+/2dcJ5un>,eo7R'b\S5kR\@[c%5S=P:]16Qbol3!a4*C"c?ss[a>'*ufgj'li('+QZ?b5m>6SA7k=a#g/rQYkN*)O]8AMe;2pSHs3C_&h`MJ`2T>V]sUkLg``*"KI$6-Ms8nCldL4R[e]6C.:pMrq-8P`F62m_YU!>8pa(uk(r!-=@^Hui:>XSM:,o/mDJi&/:^?9>%[oW5b59]4YEaIQBL((WBuIH-+f/-a^pI@a!CIPs!+9kN+j-RL1EQW0p)?eG=>9dT)*Pj0$be@!S<4h;BnHCCilG8V^ZiWdhU<H#cg[]8gC&buDcN(nSek/HQ,Ar@Su'SBO)OCm-`Qm#0h)j"[s=1";H@:WiE_Cbts[)te-9H#IOp+V_KVR!jeJ#'YB/MiAdFTH-2qf2`>pU8ta@4b-/HPDUO"ZJ0p=[=Xu>"Ng#IFW4OnVOco2F/V]!a_)]YY<Y^g?+n2E5to9rkAW=m]'H&:m@?VC;@VdBp-V-*)(6(&P>c]1rj4(S_Hn+am&KboOY)sKJ;;b?eg;R3<'&-Y`D3~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 775
|
||||
>>
|
||||
stream
|
||||
GarnT_/A!]%)(gSGT"jCLaq)`,TjY#710gaML!sQ(KY2h[>pbK::fiY8(hU>VH@Y:U!<?,6p:q8FG]O%QN/*W34fkI18l6Z81(%AJW"::CW8,pBitGZ%F<3+=IH'/KQcLO:e[-W.DToIN0YTG@)eE81;[`W1^&Jee!U35("YnV1SR]8]trl*AJpc!XAGoZ`c(O<,d1`nX,^^c2j$slfPYHERS1XhTaRfbE:WhJO))8j>E!aQpDnW'!ntTnG?Lg%r-Ef5UPf44n-M&+orBD8iB:/$:n=Ie#Q->1R*<*ic$tA.jMTO%`FfTT'CXPaFhS'&5]fXnIA9c`N1#8j)?tV=GE2lZTt]uW)INfSSQhq>&S_VMX/X2\3XVf-7@o+Kk3$J$(1?lfi-@ZV_h'3[pG@JkI=W)7_Pafrd\d%lLZ9VQV6us)^<N(;I+2q?!C'Arceb@6hX$)a]JR!6<bCENP1I`eUHSCJk!eSu7?\hgfYD4fUN-*Cgh^WXQO_J4kj@$QK*cSFGoE+gI=??2`Y43es(7%Mb,sY_``,`/g"Vu[Vr@&tSD4FQTu&e)&dO51\O,$D-IaVn5R_Y<<^KNk2gq1D>ZV.hr./PY5^jV7eGBFPE3f9PM6ZKa4!G-P\>fE]dC4QF/C(@:lG<-W_I*'$U$^IV;<#X8g-<jPpBlOG?3_9TK43<@h5R'/%ZBYG%K/O1RbGY(6A,:%\Eq1??AMPc)]DQ?]"+pf-;G0oi46!X1ZGB"EG[;I:#C*gl<L-KEB@J\qZdT!-V4~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 20
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000741 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001737 00000 n
|
||||
0000002030 00000 n
|
||||
0000002121 00000 n
|
||||
0000004258 00000 n
|
||||
0000004786 00000 n
|
||||
0000006498 00000 n
|
||||
0000007942 00000 n
|
||||
0000010150 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<9861c8c80f13d2ef89e49cc9fc09ac33><9861c8c80f13d2ef89e49cc9fc09ac33>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 12 0 R
|
||||
/Root 11 0 R
|
||||
/Size 20
|
||||
>>
|
||||
startxref
|
||||
11016
|
||||
%%EOF
|
||||
175
Les14-RAG-Embeddings/Les15-Lesopdracht.pdf
Normal file
175
Les14-RAG-Embeddings/Les15-Lesopdracht.pdf
Normal file
@@ -0,0 +1,175 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105902+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105902+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Count 6 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2150
|
||||
>>
|
||||
stream
|
||||
Gb"/'D,8qH&H88.0fl9K7p0=`06SgF@XFqqgnBFfV7i(S:e4h^aX/1Q_R7/X`_"b,_X^UmMXEi7[##h#nP&l)1ZN%3n\a)LGR!Y`$CbrO0FlQ-^c^VmF;MB`c@nW1^kLqRE@#`p%O?/h+&o.5%XpZpOG=5i'grB>4NWDDT`gDIKO=j2"eX?d#"/Sn8.UMeINX7^o3DuJN_=`;%ta<Ir8'W:bmR+i>DHg6rs4?(=RVJ?FZi'Z079/4?u<4D@0_63:/r=,'h&=^9r6jU#*(YZ0]!\,lT2-eQ,">]WNr7gWP&$EVOq;.Y1FTMHe]i.oY`NaY,]ls^e7NO<FhcsUXM&\J.8a>iHnj\"hZcPE#B7]n)AfOq9kf6kKOuBpt_F:OC9@ZS-Ji3?j?btEn_'HG"dNCQVcRPHEQWCLauR5#*#ml)eBd@4g?efS2pOWVMA>3C);dKMIpDoh2h"X\efYUS2n+0KLs9koUR44gm0o4ho:0/4*M:b'lrM"(PGEG9C"YMqb^r[e"_=M%HG(bTb`XA`W!:6)U'QmA!1!8OGH(.0jYW(VE\?CCqdkG!Z`sc/D`n@JAiAe9Xrj]JL48E6H`iQA+"libCS#4"`'sO]b<FP+_TL77,EFb(>_1r6WB@JP[@G>W#T,o;ADYAW7d#"Ou,D*mu7uK(KtT.:5/jEeGB8(""kSI5$kl7[#MM;F!GX^q%R)e9gP,Ws(,>!IVD=EHgO+&Q!F1tS\JDbH>$<7FOG*cU"F0!qgMgS?4Jt^+6CG1q7O;rMhcmko>8J>:%PpPn2)6+=]F!sH133e%[s>af+2ho6beK1iE4h>lbhF5rLBh!L."=^&JBMlMB"^h3(]jC6A7XnVtH=Tc>T*>m[>SZb.`pN:1CkCUTtou\RrY%'`.f1m(@(u6,F&C+RDJs70sH)a+bsp!PEBCSLD]VcpT7L63W2bLpOJ<H!/<A%M"HA[_`mVY$@8qmKU+VVut.QHBMuV)>#Hc$EQP6q\\MU-0l.I1%2#:N>1Ela2bP/d!aBoc5=jbj#Z8H1R0=HHm'XB-,k(_6KFn:P-E-.]qP]5o1.HsAG!^,JXg/L#_g\i?ki(Ko(K/FnQ2Zj-&u<X]?WW8-YpfW7q<JuX.\"mG1t[AmaCCd9:2&K&LI;L#/k0Gr*1bSQ<:fN9n@3A^)tSBkTm/CPc'lC_MW(1T;`5OX6:8.SYU!djg?Lsk>`3RE2')>\LNIdJLgZ8@7:J.P1FRijj;[K*ZbAh+Gmn>kLL46G^qe,PkUJ40![gT?Jkuh+^h26DG;58C!=`LPCl%Oqb]C]"ant_0?1qjl,+H6^2:Dj\hN2M[,,2#Y&22r!u;n3)S!<'*/77WI7OeY265qm_7.uoCLfZ_\2!KcY9U4%!N]iH<b-5>ZDOQ+%$NP9>ZYCbU9jNs>U?Iuc.B-$r3jndibq7?i,R21-qj=W<2F\?bATuH[Y>URm/K"TF#h=O\!#kSC#t-WEGJ=cmHa&B7qVKeJapVAM[_*+Ejc"Hl0XmE%[-;TGNb0GhC&`r75;QP:6@HX5CkVOnn#"(q03.aq`Ac"^-#ZJ>^[1Q./tGm:F]KE3suhTQR+h3$bf34k#Vm:lL0ITY'%atn'Jq"XCh+N)4D7u,!;04k%[O%a0S8sIEiSZNd:KB9(bU,ASWh`4Xu1LE\V0Fq@*Q>/F^@3kr6uP1su;Mdt%nX%l+of*e^g.bmP]Vi%_WIe?5XI+fu:)g=jl^hUpSJeA*kO]5r'/1D\#*cR8+J>OS%^<er7@i?:"t%3+8DD.VA")^pnGUPfD<N`Q2*Z[d+:mp+."4FkhXq,44RY0sW'N@<&S;Cjt$)B-B^@T6G!-qPq*UpIA*E7$4rCA;LYF8`#KfAa:dqNnC/XZ5Q>]R.rnPhini,mcsgCdmTe:<kd+<&/9=/J?^9H\L<]gl.?1o,O+-QbonD./_>!8lU72Z6G=7gJ;Kp-)PrTjo6H*I+0sl:s0*[i_)Turg@0a_g[]P_"?hu0b"dIVMA>/C=ddk?s-t5hZ!4Z]Q9$'n+_t2^L#IR,=9I10Qsc#kOa$!nFc4p0?4<Qqr?;gKC.^:pUdWSR\m/nDJgZRJpAUo*mX):^uNPnUpX-Lq0grAe\&a=a(4ri0gBA]G+Ts5o.DcT377$ODLA<nd1SCRq$6]SFqX~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1226
|
||||
>>
|
||||
stream
|
||||
GasaogQ(#H&:N/3%"`os"rg%EL_lj=OJ31&+D;_0l'$[P<haGR-.+5?g%b"tZu5TG[:1'j8WdZ2ba]Q-6B#8tqTgXAT@`T+!sQhT]`fmAJFt;bFWEV6KLr%/!efm,)[=7Mn:>0feW<<G%5S]?XGBmYKVh.QUOhAaV5T!c_Y/+?JLDBd/V%N'81jjaSeK3_RMI02d_%\,nC)r[+`^tG2#AAHi=7n.25,%V@[a4ueeP98s5U;Dn=/1c2VU;p_AD+^D!tfMR"G&Shn3m^U@gF"?gLiu\jL%EIgbSW@Jr7>3l;b-&8$U,`;a@PU^q]iLV-2+kWdO%;5_4TYWcQ%7ki$hZ2mru8MM;!TeSDaS?IA16dR$911:Kl)lOsS<$1UDXc.pn_ngV4b).ILLt2<Y*qs3m[)T'hMDK^5Raqm%JgW7(F%6%$'8(!&U#noaiBZ_8g[P[fcaL,-T#m3VRCEaiB5cp@(@@l\]#(om_:oJ?bXoc.d=Y3>EefAeAJ1A%LeN&c\u`09[pZsr>IT<eU"D^!4>*9N>52hgn!\J#/VXR.F/"<tGZQdP%&[JL<djg@>0-DCrReu![9t;s&Qbn%!(@A.`NHUCOd9s"MCNJ'n7[l,$Qqd"R+#S2BjK>$a916_H!PW*GmB+HB;):&&f-fZ>^NEneJLXQ!c%f[<"k0[2R[q9H!]"4+mb[59t9!6/tl*2As`A@k4^<&.4X*KZoUHi[qSnJ?I,+kk&pbYNerRO3!!o7(QqS%-4*`:p$>U'8Rf%dc[JaSp)B[c"p&g_E.)*&c_uPaZ+/+"hR<1Hcn87[im_(*`mW;ik#,p%50Y#?$tX*sd+YR:$@c<t9c2HV\o?Zeas&paX5$_cgqhRFArd_S"BMM!B#r`X23=+MGR%rB&:HAn/)PGlEsp<Jh]o5I*iHCl[S.@0Uf^.r2PF6lk1h=pdb$pT@V'Af3qK$s<2n4;pUsp,PtL(i:mm-UZ_u),a8>lOpB^.IX<+IS8Z^$V.u%Oa*$p:NC96lm\fF\^=nNdZPjq1ZhOPHG:5Z&"du\t0Q7hi'`ISO'Sd9]M#Zf-UhK>MpZqi*EhJh2cc-:A>Bo!=(Z`shi&gK/FL(sI>3#lU-o:G_D2b4Vhr9++jGj>f.@t9rQnAC;O^!$lh&_YJB\Sasr;hQYab-uB@GKHHJnBb\*3S3=oSe"?d*eI%5OQc2r1a&.KLHcU,6G8?0E;J_Zh8MG`7/m7[-'r*~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1376
|
||||
>>
|
||||
stream
|
||||
Gb"/&9lo&I&A@[:m._j(&rc8i\'?gu-<&6d9f#drM/luPfO@41MF]g(4oXnUO;()CNgWg@Pr&]U`4k&UqZ+tO."5=r2d6AHT>ScD#)pIt!gk5Gk-*^^4Sb?UY.9Z%Lp0i<%`F4nJaI#HK$m%KmMK&@Lc#g@A].bbJYShlbeVfI`KPrU0q'"2JQSeVeR/(ZU,g9@OsmHgIK)Jq)q&dqpXruDh%.>@#4%2)W'V->PYj,A_rURDJ"OW^eYTIU<E0e'=0qSQWdt?:#+hC3T%.si2gu(JphO59S$ao=A!m8uo-Y^4!+0b<\<H%ds-9cBIDDQ1/@T>HrZf,#b7qkplOEEr"4OnYekf"mTEb;`.-ZRREib@+C02igOmY!YMM)p8WO)aH\,@jLR*g:p;7ehk&oDm0/7S]1RN0Uo$6lodh40kJcB)Id]B^YLplt.fdKi6NPUrO*/.0%H8bUH7N'ZQ.Z:\;+Kl>TBPF"R"Xai:6]gN'AA!CrU\,_8n_CF<K9b."d=@_u]EIb5sHq3suplZ<0*,>3Js3E%J@,e:&MZ!F0?bc?YAR8]oUoDBk+WMt+nQ$9]+ula#3-N_`-%QF7kI&.XM);)6K@*^FdaI'`2oUKN$'j!K/Ts'#9;5-Wlk%dWKo.c]W5()M[\0dm',-0;7,$N$;&F'RO(lP,qaa!gG$b:+BA0udCY<&Y-jp'Ym2CCD'ok9!GgO)G7Wpr<f:-O0^b)q<RWcSeC&&Y7i*oomi@)s3IXHB]Lj0,<P$B"/<Z>DCH%buRc"H_?_b"J^KiE:C9L*u9Opk$i0!;s'j(;:o2glFkdMHlR-UJ`$e%F<^Z@00K*!P@Rl?"@bof=[^UW)b*IR95P5+Z-64?5RG0<II*)-gF3=d)ouQg!b`0.%j+;lH0;k-6n#gQD8iP?m:s(Z"g>'S<EY62E%THR]ep>?HZD:C#BCiArQnr$d<GE&V)ZV*n3/qk.0N>]%Z4bRnc*/Q'$:>OT1d\OI7oXF-OEVIdA`N7%MbD6d/3E4ocVmCR@uRGtaQN;OU9omN.DZ?_Dn\tck9#-OB4'l9<8$J4s<4!$B,B_bA51Hah/FX(GW?H':76a@r]g$5U80ZVTOANYZ-s48JcMUEqQf7$r^!>04ia`dg\\R#?"r&2inLPYWsh$=b*KaT3sAF"aQha!3NFMpm8,,M][GPebi$P/.e%R7?a,_[m)S(2a(b)d1*of1=M5W2\dY=VV.H%m8'5/kHhBIR.*'<H/dQn47fPp-sj_Us[*(hXlXZQ8-P#Qdd(_>+RM6nY!`1Wu.QAbK,0j@/+<R.hjnC<iD;ee83_JW?&%)t/^YiV*Mp6JCH!GKB7L%]R1_4/D6"+j\?.^B%[o2cY=r&'C@EP5bNmb!8r~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1166
|
||||
>>
|
||||
stream
|
||||
Gasao;3RJ-&:WeDm+ddXj/s[O,iMKZ]pnYkRE7:"iq0)>J]"cfC;:&u\]i=spF]jh^3320#JjU@mC0-7-$*I9nb/Pb!4OI\gi;6R<ZBs0-*5t>0.ZM-6_T1!!MF@u*t#g1Da%!8]c7*-.MO$2__`8`5oPd'KE_STC!AN"(,i?Y%]C,k7KOJL.&6e'n"UMI6;%`O$=EWOq3ctDM!E'ABQd_aUD`use:?gWA3A3>X>61ApAU,^n8#;:<O+-44a_FL9m.!j+Z.uerSaWRd`U6#]Wki2^,TX]GLs(q!ioBsX:ot1,`5F`F8U6j,5OPlEEJRt!<"(tVKiU@"RC93CCd;\OfVLj"%*PijrnA"<)g55m#'[7c(cb`#fn&Rk"2\eJe,<'-Oji28(4[l$CPH$e7/87M)VT.&T(i+e6jLXd<]uo_PhA#^gOAXeG-?D3,2quIV'fA)e108)p.I2A`.n,+0uh,'6!'Al>!Is"H,>`_H+Vabu#i9CDUG.\54:LHLc3jEf1+>LVk<"Ul.':gW)`ceBXehrd4GG#\fCr]Nb=k3u"ND-a8Yl(>l6-KP/r%70Zh9ITUNkI[kr['fP)4>'=i9\$8SLF%CIsrq@;uc\m3t^M)f3dsP@oL1l5jfF,]!I>I@Pb.`Dnc'+f*i3<V*-8C7<KsH\S+5=kVofZ)Y;s0!1M]=pns0RYqV0uI_8,H-;b!jqOF?)6<"p=.L()A#Ao#eeG.'kk;Mr?P3hr</5(@m!h0q(P_'+&Z\0F3Y##>OrY!.,R<=r5Gd)XA-cN%;E5a!kCBgKm)H^99I7>7u$q^VT(iGNPbRj6tBsj5*2>\?Xht'-$r")$GP;7\=>_"Kp3K?^;A%7^J,pS7Z?@gfiF=CpaCIeg-1<G`mBtnF3^f\1eb7Fj@!%F[_f)F[rcmk8XQCRbMq'Y*!FA?Ic;.`cQu:MIp/DBkS8)=+S#S=6S+V2!UoqeoqW_/O`:r,*Gt\bq!!rQBstf^?Z#ecIi[ANeP>$5*,nZPbk/HBauSkn+BHfbpGJ?Kd`cP@_`)EMRlLJZBJB3B6FbJlt*7`@mc6J?k)d8Ss96#oWJQH)5-Q.)1\:tEF3nZIFPtB^!GaX$9Gtk^CnT_V0-pKdgf:s8157g<(0R$4-k*DQ[*N@XkWdbl1\\?rd8n-KD,mU86)5~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2012
|
||||
>>
|
||||
stream
|
||||
Gb"/'>Ar7S'RnZ;3,f8TBk*U-/u1V:gNj00T]ftWg&^fl1,9LnOX3GZdnAj'^8&<1UfKNahFh?'P)dWPgck9KRK:A</,jlgJB(S*+#%(BVT6J'@_q[_4I[8P#2p%.Q4:cse9!\^f?F7;3AEk)*TFTaAK[rp;HR<u7\k,PE1A9Y'9Hle^2G\J_P';"5U<ZVl!0]r&r1O-D%YNBgoL#sqdaT99:X-c1pd*((li!h;[oa@&^"ou_fQ4&=0LMpJEP=::;'(1"n6l)BQ'$LN,o$7^)6g;VLFuu3BYh.3f)dl%^S<cT#1,KY,d1nEJnef&tAYLFc,;`?D;\9W!<*X3q5Q@FpI=b`jH"lG\*tLP*dR2R#e@82bXX?1bHag?tjSLPdcW'0Xr6i>-]C5Z5R9d-61m-f_$*-BOlA*,\&i6,B@doCI<_kZ>rWK5V;KP_"p5K(qsSK9A0Bn7Z1p+eqsGL)C)<)N>ina4_%n7]ZNHMqSrh$IgQ6=6rk#dUlA6g-0$=+`&4ACMmPL8]F\+uq<GA?mhGOLiRdTO:2K&:G;o"%1DPOJYq/$?*R<]dhk8P[FKfSF#_@QLl+e\Kiu2[8,qTHbi1_I/<4k%=+j0K0a9'8B1nWEXRRQKQC/NBDaEieXZqK(mORB&"kfP/39Ci<oJodEIK,P+9#su$13?s3AS_(4PUTL\h'XIC_[^K6.>Ar6M)bFIi>NCb"`!Md?S8k?,^8*b@=7USE.ZVP!p;\XG)^j#_dj.S)h@(;*`ehf0!<"p<Opk=d-1p,?'mc8iL%+6tiH-tOo_AW=e)r6PB!XY1lp7J4=Oa_c=9'u]dgRk&R%7`ii?>-qb0P%!4=@%@'[DH>]q;`c15YNj!c<JQjcQ.\rU?UK/0qDd4WDu;qN6J!Cf_D7DAhP<"j*r-2-637J@AX7B5,DkF;:i$Q"lK#hJ>/RWm"[p-q=a?qr5Y4NcYkUr#h&*o&W7!m%j<$+"HJf'kS,`pNquR,a;iI'C5YMi-shrV_9q:2cr@)@tXe;rAhcWP^\gmMD<FE%ghmt;3+mq'B[qa"?ug1pX1:#Zq<HorbFD"o>a0bR&rSj#BNUEC/o]pCpli/EA,52+:N]hUJZ:mk`#da,c5G;hRYQ_N_:or8q)IC75.o"SENcs.aq<1c\=TAYQ`/AZ6gX9BWcso7'R5eO05"kfuT%?90`'rN?/j;2K;D6)mD;YQ8,].j(#4.lr+TEF$p5nYFi[c$L:Ydb.*cKlS</B&uC\OG;&:D[f4pSh8Bq7E\;:T6&s]DhH*\j&tQ.f&9jm+`lRF[s0r1HVOqrc.H7uN,urB:C%eR+YITsADkY'hR?Qo+hm:eG8*lu=EQZ".ML5Um6p;p,XAmP:$''?_[06O0HQRu#\INbn]asP.Qma;NhM4*d3N03UOlX2Oo?d+nNJ+`J[+2SEhIiUHh8*We8HO)4H8^`"GR8G>d5.Utghp9"3qi(3W(l+u$"3Bqd<!,JbMpWg*(6A*(/^Mp9M0$HhYH,8ItlYCp9b6Oc-ZP#Nr;4-r.U*i.%'>])#6<%r(sS+'$lUDgYk&Jg)V"nK$-I1cV2-MleC$pgXW-)`WBB#hFLm#32>XjGZ&\pUZn+I:\)4[n3*%<YAmlC#/-+"9%%A_pFZ\n"iPp"hYS5*qPPLb$Plpjo/J,^c\(o(?L2PM!r*^0chhl,k$iSGE./LrSJEIG*uC\bI]C;(jM(GtLs\-Tk6<MEf1PYDKi6e*0NQMdJB*RWaqhj9/B#ot2AM7:R)P,'_\j+s_o/^?Qo^^FU,olYr-ea<D1]eU(Y[',"L+ZM@@A8Y'qS%jEF+qAWtX&C[7^qVmTaRNI@#jlGFGKU*P_c%rp&u9Pl(=[kT1Y3"8N^V9t<?0CL,Nm9:4;X:-`4.i6B^Q4</_#RU8G`*eF`!_jP5*8r'tG=srnnIWE"]5?hFNPu\PN&qq\q^_=g-?C!W:%jq\3=[RVWdbBr,GNe)%Eq)`CbHJXhaGuKW\n_qUl6t$KD!p?K]k5(7iWI1bAb'j?~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1371
|
||||
>>
|
||||
stream
|
||||
Gat=*D/\/e&H8h>EF6:()9e6X.?PIO,"%MO?-kF5RfPm,6'3!gNSZ/,C%Le;Uh!VOS/@`&,a,`cmZ)\i:^eb$RCgI!^`P@WGK,/QR,%iX7)8-j:1rF-O77[T63?ZloecuKIJ\SAmM-iH&nnEc-lN^5$4r(,V6g;aIUU;aW"%N(Gmm4\cqk#kMTJ*echr2(E/pUqO[5YLrVQMW9i$tGcOSFjIL(ZDX=n<Ro`_$_L(\)Y*.5K53JW[WgbePq/2R(Teg=_1E6EM4a6/ZHVFd9.,]I369ib<*.G@.VI=p48NA\Qh>S+&,\;>]D)GD7B<`Vd4%FAIVH!3TIN3KI(?<:M8mun3[,/-i?>`Vr6h=GW,PV3(M5jiV`]&k]4Gs!@:$8ia^Wj`UoK`N9VaH]Kd%:e$uMD5Jr$u*)t&dE0EFZ)SgX+;*$>dZAKiD[168-^7u7#=(<c6C0OOC[fBPVK1#p(j7[Q2PmLs6e,%`t:9RWi*pOfbc#?1%#-2>[L(aQ;*FkKs5H`\*kR>^u9(GTAp;S,h'ij%K%/TZH'8P+%deX@\BaE]?F[["[3@UQ%9eIY\+nQ]5Q8IDo0JtmJ?'e2Os3@+kl?S/9^@9Hg+okE6/%aY#'Ibq<Ko?Q@ugqfs)Yj+Pn]eoI6utZHRO]B4MrID$gO>b:5#>%>su&SiYn3=9eVeW3fJ+o<U;CPElBbI5GqJeo)]\;'h+*"nNDHAm[S^c?*2XG>Vl.,\js2jEERhJN(#MM?m@m)_V31*);[ZI6Mr1jA?jLp*A#^S`Y,EMg:_X$j/dsbO*./gHur;Qj,OS%8(QWQ_D<:<r^*rAb".F\!cF\VH4RQ^F\Bqeki0>Kd\G5-BFmV(4OAhW*0$&9cI*j^Q](C)2iTb0a1M-AIW2SC!i;o?OFL,AVK5HAX:>3R/O\@@[XZGo2qGBGq#h<c<j>lTSdL-/BEd<mg7Z-:#i1>=O5`S1lpd2@Mt9_*kmD@;'ku+_phdOQI-o6XC3o:[CYo8s5-Z-Pi=k6#fflrGNZ4t8V-1<*g,r%:l'M4kA3)0.E^,imNY@s::00-9%7_fUNA3bm_-.eGSEJ[WJ[0*DB[<Rb.\(1Mi2_i\2L>mnX,ZW!kRnsiK)JZ6G2aEBq10/)Tre0&BWPffT+>qDcr-W9tGA=(+I7m`5f5DkcWFs)8cOT3eG4qK6$sm(8@d/']2KdBSf4[d@FP:MC-A9M4IDh[\[P.dFRoHV]1Z-\K(,Cs)*0;pX81LO]d4$Nsn#C1Q/"(4kq/k9as+'Gd0p]9-m(LRt^&[qB%b$9!-Q(3)/SG'eD(hKs0Za\N^!HC$hB)XtfLSBY-!^-P."ip='o\J#C<#+Yc"QfZ,5X@Bskg=seBV"7KTj3._b~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 20
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001737 00000 n
|
||||
0000002033 00000 n
|
||||
0000002124 00000 n
|
||||
0000004366 00000 n
|
||||
0000005684 00000 n
|
||||
0000007152 00000 n
|
||||
0000008410 00000 n
|
||||
0000010514 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<f6f6cd7ee638d86a396799cfe445d582><f6f6cd7ee638d86a396799cfe445d582>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 12 0 R
|
||||
/Root 11 0 R
|
||||
/Size 20
|
||||
>>
|
||||
startxref
|
||||
11977
|
||||
%%EOF
|
||||
309
Les14-RAG-Embeddings/Les15-Lesstof.pdf
Normal file
309
Les14-RAG-Embeddings/Les15-Lesstof.pdf
Normal file
@@ -0,0 +1,309 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 7 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 20 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105902+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105902+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Count 13 /Kids [ 4 0 R 5 0 R 6 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
15 0 R 16 0 R 17 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1865
|
||||
>>
|
||||
stream
|
||||
Gatm<a`?,q&A@ZcqN?`".3Vg'J@S?6R4PMNZ>/BW0/NjUZmh1#@`S<@Bn37[Lo*&8?'50gh>MllpR5\ZnX#ZqE!>`C%^4_F?l\<q^hdJtr"S#4X]p^&Q[?.C6qAeT%`B1[#P+^VJE%9ok<8u>4X*$.CrqKt^''Jn4HFcL/%Bt@0d*&\4_Y"\b]0Jk[9jK3F*)74a*%@*rCqZ;LZ]>[M%7G)s3MUc?k''[Wiue@Np"3*"\53"r&fOkku&8T<LUqsDjdZqWe>H=4ZGWn*>_.DP\L7!W&k`lfh*RP$^b;=r2'*EY]&9d3#LM`rDQ^(pjd9OR9nHV^BVk0$%NKOI)@aW%Cs+Xe.mpe^*E,"7M"9pESnpE4f:JT$8E$<_PsTN)4\Dk"n0JaKN:$>9G$JK9^hB3>Tu;m6YDB5^L#GNlXiJ!n9&P)cFOnI>GJm<d%V@HN3CM&k"X02%U)pL?8H"fEe2k'VM['!,OmT#LRef0.6KW/c94K?F_$Q(qou.d=Z<+\GCpBP2a_q-[!25YZ"mhZE>Xn@#A+Y;0HY),/M[3.=2M$4kekriJr.I9C)LP9<]K[R(Z8_XP^<<_K2(KW5Q1Y4_7S6O=tHCY=fb\Vi<j2,6B'XmM9hTM"O/$\5TH"<JKQE1dkkFia#&F,!:(-_Jcr7*l5ip3`;)h?<k892nn@9\02GLWQHjG87fj1h(Yi3DI,1A,7S4*shXOm`T0g-n!S2bd/;#CL@HjP)N#9u*qAJ)$?42&rQ&cf[cXWtl[Vkt^RTao*nJLs$nKj)nUbD^e-rW9F5hSHD,>a'K$d,A,-ltl5eMu;A%``O;,bV/X\9o\KF`[Ok"9k_#AX1F,dWgTPo/N&8/iKr$ne[9,@M+pF(l\Di+9[r\'Z=b'SP8\G-Q#Sfj)(%tBoYT9`tt`m(+P6kLFpR(2?N*``Bb[-&kRA$>l"WVpTt=:H^sOHE:Vl4[tGLKY$WtD.L5nH1Kt0`2gB+$]27-F5T*hF$>D[T+cN0i2bmNa;4cQ<$]TJCNf`R1aG&@Zd:tmRfOMk;TnhU]]c#tZhLQ`$\6m"kNEQ4JG5J3m(i%40'sq+B^JY_`#Zl!=&eaLJVSEroi_s2^4?F5c#=XXIFo0Y/T8BG)Ff%[:l:pD^bRW2Le63J>B%j^TGtc'n?/+1hkmUMd,#rV-:>2gUn*(H"FFNoQ*1\n3K/[P58(SX$gO2nM_KXPq/"d#'o?RfP6A:@b.'aspcf%3UlDA-<D7?tI2D(Xk5nU5n367lc!H:o1N@&UCR87CoZCiV73;]g]gp8D-Yr7\U^K$+Wq\gZ[b[419.tn^Z>H.P:$oU"Vb<*?nXK!BoUH=a9+&('(<>M6j.p/b`5(<KN<a4=;q5R&)&FQVJF)2#G;/Q6p>WAJWPRLAn=/^4['<0kn#YqbsR9jF`S7+luBnV]S-ms8\GBHlWeZY-@(YeFlV:HXAJ61/cckeIXl-hK6%0(0\4&_ud+q.9/jp^]Yr$mO70J]('<akXNDhYY\SR[=)M20@U:6#pFG7-m4a4Bh8m0M?,2"@"S:Qhcc>3oeVrcu^G0#\Kn-=e)GSegL]Kn>GX1"s![kSgsWP!FE5$d8)Tksqj`JJG)d1.>V"G'>%LJFXhup]Ot,=&/m@L0-9n%#O*70hC^u+GhP/h#/AoXJr'E\M6);6/^Boi7RqC)%uBT&q1B;c4hOO59ud,%(Lcqd:$_)SV!e_[;E)^q%h.MY`Dt)K&,-;!q7o1i(_"r-TN$_lr>.2/PHsj=*>F$O:r2maUR-$L[kR9LZ&Jf!i#YXTl!I3M[.G1LO9!WXFk1<SKbN>8lFIsFe'.3*!(-u4B%](rSW(Jdm\t-YF;<?q;1FmZ_cBQKt>k<lTaT=;%3~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 401
|
||||
>>
|
||||
stream
|
||||
Gas2Eb>,r/&4Q?hMRtF)R+`4gZ>9[ZPq&G)@)\uMmK9j',SAppo4XCe6#8OhB?m$hPn7BGp<8Q4/B)-P+W9MK;NX;W`-iPP7Dsq_d\,6L3+5Vr2\4&JPX?BF#q$?\=E7G.3D%LB%NOc8Pnp4:AfOC(28VBXE`*ihY3KLPbb;EgD+sN4^@u].?:(Nu$_#l]YjdA5LH8@D77(aiOWH6'I!T)VU"K2kgD.[.06QNc?&^dTMs95E>#kI"p@#N_hu6H"*dqAP+T4PdDL)ce8hoS'GKF4g.d/+iWk-i)f]VpNT<H'5c78b@.o@5nEa#E'7CX-#FZ36gMJ,rIF!df2LUaD[-_u=&J8.b*F$:c%Qq*>(6?&m%6pK\A'>",I>$dq9AWc.>^V[2.L$/c)WfMf~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1247
|
||||
>>
|
||||
stream
|
||||
GauHJ9lo&I&A@[:lm4Hf7mR-:pgA%.1oW?r8Xg3R?@7;P8>8P($40k'r;&%^&(-].A9t1#Mc^Pfn?@/L!!A*#(4Pj<^i(+'H,kGTaO\->P!J^^hIb0%LiCI9,(RV#`[M.U`pFktS&g/B7&E(XTW',=-'2e0Zq`JtoM^VrW%G)h+pLG,WcS;11Ej0=n;iFe*2cPfO>;7Pht,;K]p-U0*Ra;g#_jR+^Sh`%:'bDt$-;foqs`e3"l*)sgZ7+h.cHSpD8,[^caeY^`Tu9U.2m,+n@O+UATcX0kC8'L!OA2<XpKQ?E$CD#;uHBu62H+m``MI9p+Na$S:Pp:o+>In!(l#\<E,/AV@*(W])dt%%#g2[R7Pt#>`c*Kp6?4kK[N7aqRGr`-%m_hV`eK+P=K+)2OJ*XKc7R$*1%!LZ")@E)$p]l3>*=cA]4'i427R9&05E*,C3s!U+qhtS]>;8<h;h9-toXZFBee-?)sM=?:f5lO3*\B9"8Iq#)9Xm(-67LS*UMk?'q6K-hoITVhAF:[u+QBlLkP`Sb6s#X(?#4*`g:uhm\)#FXX<1s%!`"a\`XWVDI1\!,!HD6djDK(UE>?o`f$;$TM:Zr6a5=%XV^NcMk%mS&#Df'Fk1sYccnKH`2t&^2LfW_5bY=D'E'eNAnij=J/MQ$TmZZd4C:3c!aZlkOZjlg>k584tLT+MdIKGO!r78"CQ$#C0B4q0HSUqAcp5n3U:i9ZR8PcnoA,k^)!*sLN1Ei$K+UFATf.'e[7*i_&.AM4'WN%$62uPZqe&!qq%m=`]>?tPS4M0#MfeN(`uE9i/^m$FtV[oq9'MEpT.Tbm%,YKPpBt6@$k`i>h;?_?OGJW/aXqgfN[,*@r:%U;F8'4,DQ)!<Ab%AR*E8OACj-3<K\2Us"P)b.1V_Che]iXd^7-c%5n%s5r]jNARTf30HBB;&L#j20GdtY?;h]4M%bTV>$=$i^`0@+GX1C4o!16^k:(`E+i2kE%Uf\j)L,SG\2C.=F"GY7JGa4/Wc]5^'[Q1-$mPHa*SWUNihPiPYt50:g*5YR&GV^f"1hhXGil5aIr<%jYH1r6XG*H,eJ&PgPiI08`FgQSE,D8!a2%4&)Vm"(V_\j-R_9sK]Pfp_!UFiH9p@>I.lqPOee0lAQ+hr%.F&PtSh$hspka1^N%&1H0?S<pq1jY:'n_@PHa(bKk`1@P#<ONfeR8q,CY,8E@:@(>%@FGP[RAlRINE=g:T2=!^T=u:$6f6^3:^NM~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1795
|
||||
>>
|
||||
stream
|
||||
Gau`SgN)%,&:N_CltH6gA1Rud5nH_fQ_\e&[n5:1Wq-ebJYlhC2D5D/4&%Nq[s&.?+Jm/F44@p*$`2tElriiE@`<lW%l=6L*8np?k"cjK%Z>C9Jd>I@LSKRthBQ:r:]s%27K&#EM>[&Ne]KBbO@2Q#@\@C*<%(![7[/cEE*O36/;-X5q*U2D#pg%G!2<R<Q2G+):F,*?>*)"lBR>0J:UTg=,!0s=e,CO.HE]"NL[#76=V(eAaC4r9Q]kqSZ!?NS\L>W0IW$^,>VpEBGmKIlB92@>XW+s,6[b`9Eh2NjGlWaq=?LD0NUVY[,X?[Fps4)BoDpUf;k@4G?fP-i`#qN/YAd9a+NZ^>+]ZJTBo(*pc40Mqg_f,\.4!sq%u>Z"Cmi=26E*d+)crSs+=rb)C^GE[`,dha_\?T4PVYbl(Bra$I:dmIJ_lJ2_>\lF0tb\aS(%I9(FAV8^`pZ,Nb#WOV(p+TnLcr!WV4@rqYAT@4%?lGW(nD/8&ct9MK'^Tiqb-$k)o2]F!8bFoCd<[0+E@upm:cX,$9N0h8"LR2iuU`FTFNfe&u<+XM3Ge"W:18L%jUE\2c,j1UUqsQ*u@-Nr?(\0Kg>0l=6<>-3LS^HD?\$X?(,-1UW+"X"+)S2fSkJZ+u]&>j&Z#C1sd9)PLaOn38/%crY>l\k7k_Y^"OYl?YlWdbC<8\&E4&c7!QLFpE6!U,QQ%G!`^pf%^pIBKM-YAY)`*>r\3VqiQO=)NHDLpIRJ[$W)/.;/W%QRj+*W$[V:blC8grB0iKT6`]rXWR5QDJC,N@.sX#55R4IJqB;?em7te#&/dpb?&F\6`#6V,nO_qcD?W$\e8U,9r%i2_>f0H,*]Fbr?F+S//`t7f?q,tY%KM(1!>%TSm;@T*%$#[ISu@LnC2dHf2pB^8$$2dZB]=/\M4qKt_T#t17W=t:rRHQ\.<T\IB93nu;D30X`PljB%0R4?(7[/oald$LfW@jtS2UCenX\lYV6fgbl7BqRlV'868f(T^hdD.sn(;q0n1qtBq,!C-E`V&(8ZZb<DV(pVg+M'*'O)-k;*oIc\ZK+,4k8b3HIOm>=^E_'QKdD[q!JD'iHR`Y*%!(%Ei@S-(N+E[7,PS/I$Y4tg6i$6ft)?bK\D@-`LSX[&Rb='m5P(kN\Eql0A<P]/S-7HM.dImY(p`*gQA6<"V5kHpd83A15bQT+Lo[Fn+e:_$E^@k1GtbP$ES"\4#7.Y!B9k9B''ScR6RbD389ct\$eFUp>j_KIRUM!cIn+=H$.u!qE2L;DV9]OamE]3`E7>2R!3?9`8u?5GFlmGq>6S(hG:A#;+ttm>ZYq9n[8iMj'7goRb/$\D)-I:gI7f8H\bn@B3!`/4lge;f(F<6(!#!T?)9KLSD+DN$3)W_PpjV"/7ji?]l3%[KuFM94nD2E+8THkLU+cm)8%ke@V;_SinnNO`s%LqnG;Y;r>h%k3*8eC4oY1qK_Z4ON0,*@,>@#\.B.@J#SXppEX\J7*Cnli2i]j**PgkMlTZF\(rJ!=+gj'uo6"1nnB$]l#PR^4/j@-b33UIE-?2*BqX1nEd=<ngLeY[*Xc#/]0]0q8XBo5XXa)"!jC=dD16Atli=kbad8bHi(,uen@hFcK)ht^7m*8:G/t>Za]=[CioYDSH1*T%mak]Z@#YE3r),g&d*,V4n3u3X&83aK1iMY()LX2>Wr#s'?dF_4aVTB=GrA_F?NrTj0DZCIG]U/G=!kmb*M9JaPDIoU1fEA,$\nEIOlL_pka2Ch=ol`[(b-mM6+2Q]'4?7r6g#Ut4@]qo`rW`-Jl](~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1514
|
||||
>>
|
||||
stream
|
||||
GauHK>>s9G'RnZ;3"LFV1A,6tg0IF:cB*@M*HFP>\BOV]7h-';_/qs/TN(Udk@:'YS[Bmn:9k<"$.AjGn*WrG?nn9tCVNYsM\M]6&e86DD!dDG_E&?`O//eDHin2LtTP^edsS-3(cPfGokL2(2)"DJ]-`\YD-:MZgNZZ,Ni@6h2og.3?@\]Uj&I6eS1\@!Tb;Vn:"A"P3po8%._6Sn$eJtW<_89Ljt'$=JH_BO`f0B\s&i+'@(H#T7M&c>[?2Rk?sRWAoC-hDsQ&+dXjiQsd@./ae5Uj*'$4'9-JpDVs_abbr<4iKXn3LUJ'ac#egLAfY0Ko$?.!9XE3g&`_[(ojPY6;WYSZ.%Zl5fX]bPuJ;Vga2W1[h^u/'2#)=XDu][8]%jo_\KYG$(PlBDjXt7K^+jcPk//DYQ_(=3Wn)i!"Ec5,DY6eXT+H,5d]8lE+j/YKFaW9NH.O!^cZRRSLUMZ=+0^ZNm$(SJl`Q5KZE#l(a[rk/q>+J0WqC89FT4#YhI?kf)`Ucn3+Xtn]Q_r*ldR\r(f4!dL>\#%O41GMn^UuTF4l@GS^U3;NHF-qK/3Jfk"@IPu:$A0bNE1)2JtcY)ST$HZ?UO.FL89<Xp'gQbnEZ4EP1n/.BPIN1m:&=#RA!q\/eHNCU"akQI*fg2%m*/'6?t#2Z^qY-'HBUj8?[nY7&0Yu1Ccg3Y7!H#=:?W(oR4le.doOVYa8?gT#`TH)Z)IiT9nOCIhrp=f^g9F=Y?,P[TV?*8edaj/+"iG'CNK:fMC2VDSAX-VBdZ5GE$fd+gO572IRoQ#hU.NOLp8*_9)Qe%e]VR*Fk5.J]B5?U=?5dh0__X9.]f)!ibbL5W"/h7V&BDgOnOrM:BAe`9d,c[_P0ZW,B-\o5$f]Gl_7lNV#E.utk0j_Cr,DR/KZ\Z!2Qsuo6K:5e?XR2C-8)MXY:JU(^]PpDO#%h\+qaMe'qT)J#XYgcXf9k*]0Y:q#:E#)_hdopW]=kWRCR?.Y95ORP<\S$:<nKR!=l!BN2a8]uAY\b_hSNo0fR'*@q4o1$V<[0p[X10$/L@:2SfpO&hbbrOiuZ!]-+cYXA2a":1)"kN(Zshh[sL9-]=Z>DN)TcRdJp]"3INpMm9\+LrYA]*>=;t:YoZArKXSZc)d+`I`@eSP8G59>njG[>afb6*/AY63s/cEr?r(%XNI-h;\$8be86=>?4/4e8@7TRb[G.a]*N(4#lhN5XUt0[(l..%K)HnWa93qQpKM&8"/#JR<SA%XAjf^&s<`#n<?A7;i[,=D16Ce;9$:=Crjl!;$)15/*0\YtqcL.Rm5^Q]SrLQ`54$(!3Ag!jYUUs#Yc?M>g&Q]<Ug1@accf+9'+!JiOW12p)/;JtqibWWW=^8e*oNg3DVrj0Y>.R*.?-hk=8#Jj!('pDf=B'[/;KKd`nRoI=L<6ILp,ef*^sDaMLFp4O6jq<+%?<*fcQK`'-hmFS/WN"JIfrhVkEgroQgjtbfX,0J7XM7dTUq[E/AV5C8RR2B&,)BT_Z~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1839
|
||||
>>
|
||||
stream
|
||||
Gb!#[D/\/e&H8h>E@Y5.l"66W.#H*n!H-A0"gY!YDS$D`<^gV:-$jPV2/Q9n^O8UEU?";=1DK%b)jYa.4ZnnRF$)1MpgrSPc2\n[iPAFsN5P8jQllI-L[\j>S3)E$4:iPF=&M!<5XJP%TNjH:GWtXb*\rg6+>OKN,U63A&if^k!=PQ"E,siE5Q[P^;?2j'o;04\mh276**Np"pVX'2D1#6:(D=KB%`<+3#_kKE4"6*Q$::-sGVIBMo]#)jF83FNW%)*h71*u!;T6ftAt0)[Gs7g!<Dq@^%L;8cV8Xo_H+aI3;jcFn@h%p]i5EP&f's8NL#E,?Wk(0(?scB],`f__i4Qoam=9T@"r^<0>aGZ)(L%^2EH,E_.t$;Ck&?A<\(6j+4<Tjq3O!MVp(E#^4s*t@Y#"Cd)r<9jR"hoD&Gq/&=DX<]I;AnS+iXMoqp]'LPk>Qd"ep1)fV*^-.5l"(Xg=S<@-4`#F:>)>\(UT_:Gm>)H$pl@H476@;8OX>/hk4dV'jNRM8?n;eh$FClN#g$p`\UcpmMdG7,G**^RdQ?<O,M:P,hk&n3^q>E'Arp2:5gU8CE<Yl8UJTk#X0C2#X57*N@`t1RO$PoGo7R>^_>fa]//56Q*^VUf<pSBsO,nJ]]oVcTeH#b>#,i<Ph0B7HdCq-@#J'nZJMDp'&2dqn`9#h&SXcT@)504c)h4Z\qj#UUX*eNpO/NR8Fd_0>a3,bT%2t9e*KXe.ILiNkNQs4.f>8K:t_WI:B,C'p*tAShO!3/L2JM9^Z`4"4hY$&QfE-oaNLW5g$oQg4Re%/?LY[c[kep[f65AqV,dCk!EaiJ6jTX4Yl&bAj#(b5pLoH#>a:Zb_s!B5*L.RY"Jh_o>V)Er#97nZ;U0J`645;))CTK7k)s,RE@&,X>BS'hKE"]9<=2:*qi0KN#>ruB<"8Mmb_<PqjY/FeUHQT=04[<(noB>o-@[.P/D$STjmAq9C8=k/LA/5$Er"q\lB*SP=.t;5EJ2Cl3!Z)6Rh6"lZ1<D6+Un\)hGD1.G0B?K!irHN:pc[?n$=$nV$3`6_LngQRoV0DQ'0.B8n8GiDQL"Es5]$="-OO"X^_YZqCaq$@?#WGZ[4rA4[])8c8fB'p\;e5HB^mpoL1b%cqS#m$Ua!XfI:3/om_O.*CN<n;i'u3:q_LY<Naj3No,Y_/Y->)I+Ce$[dTVqB@WSQLa4a]jlaB):[&RWj&pjCP^GMV+X\Xb)g7b__VWK2NTkhWuJsff(Jne;JJIEVO_N2=54TTM!t*%GTW\6?IBT$De\B$2,]AL,dsk@9S_c&RQ+eZ_Ya4l482]<hj=cXBdbF]`OM-Rns>$CXu5nqr%U*u!h5Wile($anVl5s!*`#$D;?a@)O>SI$Do6&<%0a@ZhasB9X"N+AVIWtf'$B[e/Oh`Q\h_$8sHt:P'Wla6'3]50$M-V!g>I[.L3\5387CUH"tl,e<VckhSdSc8roaG6ikef\i;9@Y4@T],<X%2lIQ*&DcSs'DG%IB0*_@O^/Y-Be^CHKZ;h@Ab#W4Uh]hN1c:Nh>q![f<_m]n("<6KOL+`bJo3@dZ)87no:,W/hd<XV-Cu2W:g4Re%X<3u,)>/e7]lY111f`3LHO(]7W@jfWMDf0s/[*FLV<X3_fo[mm$oX?p5SHi"F6a,?c$2>"0s\Om"YS=,k"uR[Q"b8b[K@TFa41E^L;188essSg>R0fokf*29T@<c+^hX28h62eP8J\F?UY.'8(QjKf5toKrnf$C;:5^BiNUG#&3W@%YEH?26[eKNucdH#=NUL1L-I;DEmT4f)WTp;#GDn4<s)*%^9jUNN+!&$Yf`1I,RVot;#CkV5"9~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1884
|
||||
>>
|
||||
stream
|
||||
Gb!#[gMYb*&:N/3bX2^s7E^i.78gbR"hL%p"kmk+osQLX!a2C;26u:6Eglq)%Om6tF&Vq@=N@Ye&U?>U*mU0-T_QR_Dn6&GD%84*1LM3bCLD`"R(n8<kJ9SP\E$7M5XJ2_Qii14qgiJq'G*h[@idGQ<"Z58#gJ1VYm-6[P[XtL"7)ic"/7&@=ocr$P$6_2*tl[U/hKU>AUJQJkF]3N`C+n+TptEc_/KPC2W[e'5q%Xj"S3YX55b*8/\EMkVG=U3RDV6`A^YDt$dKL"HMdV??"\:i<q(mT1jQXhRA)]qq)=[*[;1ckL#=4CpQ"e$R2XldI+1m$DIGaVS#f.GNGq'i9R@o(4%C%9,RgZhZZn1^6&:*>To_UX0&R=US)0G9>S0S^*ne'k7L?qrjC:j4O%BFl_ZnSK=X\3GEEjK7`su66`]MNd;KT-]k5iK0Cj8N7&G<*"!?JhoZ/_DA-MqY=G+Jom^i@Nr[7C8V[H3DnY9^]Sa6:S]-!W5I!^ka>8(ntd1ccGig+_E3Nprm_NsWR\D]-B#jR!QYIWK5OVQ:mji:CBL/<ed];*i4O;rM2u"NJH513YV3AYms"oVVRf)jl%)lXj'=&^=<cME36q!cNk%ItC*+1$?AhBGHWk=aCQLg16PI1'K]/nP1_^]W[iFkm5H\DB3Z*J%!:<KZ00@GT3)W+I$WIK=Brnd*tf19Kn/$63UA!MseW]J[[?[LmF2a-uVJM.MY8\cBN2OGn7OkTn\ThXO;FeRLW_I=Nf,n%sst%YMK+b2ad13g4g/#<meN])jAnXR7/*(Pro"K>+)0@_[/]=L8&M2JEa+hQ>o.HS[gkU_"BOB/`6`+n<V'd*\H+lbM5@o4V[':/7oR=0H3(<QC.ba/+B0B[])ud,rt=mCmS,QE,MhN9u,/40H#]WXtC5bC0J+O_r7=d*:eIY-:[8iZ0l&oai<sZAOSC0)(?=j!qS`?3@tV$L?=8>O>.>6CYlmph9rB*=g:8[p&Gmb(56dK3e7qn@_H3`47822B*in6A33^Ka_hV>ZVD"@p$MWc[Q&[P;+*g,Wm+n/dg7(L+]0EGqI(Fb]TuUCa#qM%Q+6s,DbHp`'C27W-4dKne@)<j,6<S@[;e9$971U%b'e^)rA]f4!^VL^Vm20<ajQheG8/*WTWs>nDp&ib[DUcCgY>(<`t"CY;.r&ORO==e2hH.9ba=s^HPe]S<uscP!?KoDcG`<1+_$Z/;lE,WnIa_qg'q_FWUhp"/UVsR7I\50X'9qell7MsM&>P4c,6K\j/DRBp[1%bg9QXJJG$IQQQ4"nFm/Ku=R0hGC73aj(2>7=RWYgb_X,O&cX5M5g%E9XKsQOp3&5iER4>2aD3eWTq2ein)n4_N]-P_]CK]c"R_Z!&^+-<?aWTsXPFt%t<A<*<].>H6F1e`9dOV7(PNSfp<Dm3W75=/N1TQL@"&mc0fMlFO"RT;*f.SGMqo`#1)*NbTr*I;:Tn%%c[\eWP4PT>n;UeM:.BB-3Llr9M,r\GK;%_m-AG/3$d-pY$5\8!:[[@$lnb8gUIub,1g@?fo+,IOp`;LK+(3bb<Uj5(Y4'#$mclhbi`JJs!)8s4tciK(8C]2lE/qj]13'/=`.^RG7PW+)<4+!?-D7Z5OhJp@3E,dS^p"@Shq+->[f*[>a\PXDEoh[9S\3puYj'#naK9#5Oau"mRW4Q"e\b"i"!D<p(1p!d*WO=S\X=kX+::A@:Uro-A=Qa79cbFmPedk\`3N<ndUt?Z'\PZ_p:q%B9C,e6LTjj]R\]a/r#p`_<l^Q]?ID=UuO&ZaAYF]6g4,AGD07Wfj-BAq?f)$V8O"W$aelB8-PkL/2%%LC"/'QDaCNtej%jN0],lfsnUD_O-U@oH@,pt(\V$7)&!hsNPDZ~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1594
|
||||
>>
|
||||
stream
|
||||
GauHKD0+/q&H8h>Z!Z4)W,pabmS0O""pue59I[ns5@?Y\j^O.hP*+gYT``'CH`,I@Fu8T.L_EEBm6:F6hX96T!Q>T@hoQ)\nH/-N0OPk9kJ.34Y]pIM(W".BE5f5H5XIiUL]`K#pB0M,a?_;M+s&Hm<"l#1#fDHQOTCJg=u-VaL%BWl"/6O\7K>/:.\gnGLI?-BV?N)S75smDI`BcnLhbTdc/ohY:kH)Mor+Q,PY!H8Z%:SHYPrnZc\e!iP''W4n4-d<A6m*9Et+2%+$Zd*[&WL24L(52/%-Cdf13(?!/l3Led,Ug:*e9"NrRO.80@>!e\ue9qg5tJo,,[Wk;C8d"&ccXWi7.M82]hSSH06f%#bLH&dgMtQ__1Zf<TpB6E)Z62/ZSH6't]#]HTG*,j<kLL)IuN<50-@>U6#`%Tt%*'Bg2E,hUpc!Q2B_GY*6)g07,M3j?]pTdSMCpMPpP:P*`IF38p"XhTAFElTQ2,,S(qd?ZLZ\7%K&L:Eq>`c%7`m0d>sQu?;$pr_VeK\sdn)8RN@gM?T!XEXJ/R]iBabIG!]e:)=[S*WFi+<UHu<K-\J0N!E9+uPV@0c,8W[F&t<RdUn5OfLllou=C>&nVcCM`XA=^el2`4^h8kpc_TR?%DWM*2A[9`d@5g`;r9-0\J.d3l/Sj4L_a=#u`M1O^eqCjsILV3(Z>C=/aQn;rh,-8L`0.L!?YY8.*>4/[K[`_7.sC@aF(?<mT1KAns3h8,Mt`*^[("`[8='8h[*P[Gn+=/?LEN[]kb7(lj*#cY;f"=%,uX9lOo'*nthB40)Xbmui"+.h>L9)O#=XkB34e&8[#lDVem)O]-/h3NUA4[rnB8ZhT'9Sm9cc_iV^R<K(hLFPOW:Q+auJ%p8)9n.<d"j*;@8PK@3`_4+d6,2oOPR*C`HplrTDQKmf7m9:J`0%W0H8Z.O?jVX$sQZ$)qYr/<l=I[4.Y\+^af0lc!N4gf1nhGoh=am+K#CQ]JRHu[6BK)<-<1#8*ed"e+#p>]GC2Mc2Cg[HLCpM<(\UrL9:?h:F<o2-[eI7bMYDf1]G;@9$0!"*]5r(Ehcu8)eBBroNr2OHLq!+6gmqt5RL5RlQ0-H90:J.++`dE(#$e+6^\c*%$s%eZah#_4G=P:*ifm_iMeCLiqcd5mTK5Y38H+Mi"EW0@rMFd\a<BH]_Nm<R3KAl'sLJmnm`fGM+ip_JhW4t7Fi^Z0p@L6t-MbP>$DH]rQQ3G-%V`67#I7k/%W#i.0:*p;D226/m(P6q2IOua1aq-UQLAU7iH3X<T64FHClI3b3?IJq6%^t0NB*KIA^pV]7]GYV]%Xqi5K[Y?j?UQ)f]!u7).Hrd0G.u.`3\Bp"[4^gDH9&%rqaYk+iOCda2Y&#_hK7VFhu7V@"Fol)LH7e"Lh%aZ(]m6c]W)o4<<rQt*Fc^1pY]p3g^-Z:p;dEY0],gYUD%AL6S7eTCXoT=BkFV7VXP6Y(J/61<P>!t6YbV^ZPEdL+38V,Xda!d.SCG5cuUK+K+HZ+BR)GN:Iq3HD0f;`IFs5M\;Tt^65U=UEo'*0\QbM46G*7C>NsSe]<^8fC;NekFdc[?'f6'n!4!,T>l~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1699
|
||||
>>
|
||||
stream
|
||||
GasapD/\/e&H8h>E<oU\m8A\7.#Q0o!A3#u`O`J5h!N"l%uO%BC@i95mL+pE\*lj[Pao1*j1qHca60$=+-L7@'L0fjB-k?W-(J6p?lYIO@"JYd^4^.Bi$KROnlS:F6:JeF"%biN#OOFJ+Hf,>4COmr9N3Q.-q9Fn'Q>$.a"2sG_ra8#JAdVj8cV^>P"L0\4sZ;4$nY!qATE"m^eYho$'><OV!7jj$pE7)PXc/dTe)'G=I-2dj43$?i-E#d?$96""ha5,SR(omi]Mt90-3=F]"IA%)P66g\u;)r=8dPL^4YML'5N=U"=Ff0gW+fC4/<D>FFCiBYclMm?DWb-E8KNCN^/a<6t<5f(t'8Qio#Ip+\mt'KbP0BJ4IP#]r_Qb9ILWjB&dXq&qn5ecRmQ.P6Kft*MsZ0:u7G0?P7q1%[lqY"@bb5"NPs'0RV'@Qdc+7+!h<Y`1k$P:N@<=e!O^<"]p9*Pkhhc5J&W'Z/V$:%J#l<)5Dj8`^=-i)lcH^-&ZF%6C=8)]Bp#6>5X'CYORSr=G"-?_gbOcog`]d7Mg?H+g3#hfD:jO"AVl><Fj(a.)P!3nZ<IY'a&>4_HkeL&97&[T!oGc:t,oD9R_=\63WA3(,br!_6f&/*7$%B:u92`aI1`H%NNIQS3-ml6)a%akTNuAp6n`aFGaWTTr&gn`,un]AM\JD.>\TK]41D:MSM77N2+1A+uEhJ&"fCYg,*GVPFprKmEXF3f5&fYh`_<.7OX+-%7FM\FkafHcl`-Cc2&`pE!r/D0&^>B2=&N/01XAKVT!7k#Hk_hleg1VP6XbPUIYfkf;3Z+M,GO2,&B7;d=t<+:K7Ug?'Y).?,>i.Ik=`[<o9FbTDE/t]7+lE4s,g0PTg]Se\V@p^Zp-te8>"<m8J64l%_+anT;MhUN*\Dh*7&6Sdcr;Ph65c$]TZ]%,'op[5]nl';V%>E.QhT^f&h@[g.;kgMCtid$=S1DtBu1O4uSkZs#HW-3?bujb\+:r/YJ%EE$qjT>>1(SP$n9R;$<AE&:>;X46N;j.2pVF6MALL>1bY"KGh6jKJh*T#XDRpK;'/]BZsp::B2m(nRW:;/3h*#A89k`&,#gXHi?;YI<7Lj\ap9"X],IA8qtFdpcs1*LQai[PT1%7cJBq8VkF,U`pVigGS5,g1b"S([B&[6j8^cl?%rp*tF:n@i,Nb0H;JLUe-t4.#\>lhIAik$9;kldBA('*laBM&OIr7nGp.Hf4Om7$*QroI0q4c@B:k(L)QOiq)(.(/]YX%In,Sg6t@,b%s2S4Is=L$OuVNM*-6323F4P>p)Ep6h='&)c^h4)q.T?%X",D,2nbtE8>3)$J+WmV)-_`Bqjjd4.Ni<dYMu&0hPJ54%f5f1%id,AEJkNVA'.mn<T<s)h>Kj;^]uDhUX2-ICZ8/cO,,j/OI7!IT5`(p2gQ#a-Zn]f3#7um5f>8qLJWVf@2%U83]OWfC3aE1"og]8._)CWoiJ\3Kos;9&dd.RMMKSUk>TQ3AG6WW>ZjhhKT5`..C8ApbPeUL?[iAl[Bhhs'+.Kubl6D%2&7tJhlk",nQUuAH=[o<7'nfn<b1V?*R&P!<#0_j4`&Ou]RG0X8'Mi=YG&/'F_/k+020ek5O`LE"`TG5i6+n1Md7n0<f>Cj!\LKP/DkEMiJ@QCYVX#(Y7)3$>-G-q\q-KIX)pZ$Zt7b[`dT'**t.0g^]~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1584
|
||||
>>
|
||||
stream
|
||||
GauHKD/\/e&H8h>E<u9Sl!g*b,3>L#>&<R5'\fq)`IN5I'U`sj=>,ftUd^CF^6>KS2bmWE6S63M:?6KB*M4Qj"r&`Ar'h".b6?*r0jkt:(Vh;YY]pPj0"bt\E1^pFJ-\M2^juW_r]ma^;G]RI"!0-M<"l#Q8Ag6<JH:ekZt:3`$Q%,O#"8>f)[XG:jW"69GX6`&dBt7BpOfZi\,8oo0-G_e%V`L-Jmf2@#1]IU8LRM@_'4HPmsf_0"2AD_>=Or^[Xn0Q@AD!QQCM`8nRhWl/E=%A/,7*?fM.YZ2Q`!JnHfuWC0r35.1F$poIg,I>9\L2>?PMR%ee4f*N(&73W_@]Uio9mDjb?TL_*PGA3?9":LIdnOM:?1n[KdGg"SD+f7[%97Q,/<?BR#0o<C`,I3Pl(@=oH;C8fu'@)"rmO"HabdhCpR&JFHa)irr^2S>hK&FI/!*Z\@!n=_5_%dq4O736`@?6cKHEcXHl<K!bPY>7imO3sIP9"8;7!(1""P\K:(N95gJQfbL?,CI8B%g[Z@00)l\5iM.hT5JN9"nFX^RlIeeV\!DiHUnQC$)0;SW>5T2#`Ifnhjl/60jq[,mbB.\eCp0oDqfNbd!FVXlKcnH<)PlfR''6G%94s-_?4nh);>RS9']Z)i*9.2WFsR^&8HHgoq79^]qtlK*IQD#0LH7ji7a25U)BDH,p=CEArQ.V6pLh!3!tTh#cFh]B/9hbHktl7/1du:7CK<LWQP2*5``]?&^18D=^"jF#M30a<cOGB4ZnMh2N0.kaf`a`1/!stjmc+O&1Jog#-ru2]GEAk4B04YA"0"CeLF>G,L;?d3OsbFe[IPiRrG^Ao+B#qKWle[$HsY[^2,(-6YI38i>eT8D0f3B30i5\4:b(XMEG';NfpW(/jHpR7ji8"J<&V;"l7On5TNDcj^TD.Hq;*\[(t?QXlc9O%cBsDMIA,fDo`9g@Y+4`AVK*KWTcAmpflFt'dgge9Th)"=\PP'LmQ0Or9"02W?TPf?2/(4]Z[`PADr<Upd]Kla*U4s`cV7LEgbL>NQ8E;5C4;/&6Mcr4JPTlV^t<N*[(f<VphV\*$*63/f$cF>Wn_'N#rT[NSusFBbE5cA8P7LKU#i9)(#3f>GXpYQ*lH_j?A>M[qQ[h)!ei'b&!NbBoF2Td"568(YZ5mH''C5.d65q%FaT#bDI%o!\-/^Z(BB]9'7hamc!$!lU)8=lZuUCK"F6/i*5+D+?2=;W'cS<F+%;>3nrK(`[kr6/Q2AeQ8thu[d2?^KP>^IMg7)b-f?g\%b827AH1d<<53_/Vak^`h%L]Eo/2Nia@Y3MZX0:PM`<BQ4!)C*L3"AQ1kQ=m]^FAK*g-1efP\>&@6.SQ*jPFDjLZt09^>2?iaG>34PKGA*WeBiJa+XT0R7O&"e6B3RbAGggDh,N(Ws5S8TemrMf!iQ$b/ORh:NpcL`8+5V,5%:$Ca6%&%>pBrK>P'H9;L]?P9@iT3gIhTGe>.VC$iD[a+6^_TH2<gC9%iIa0YSokE5k;QpTBDC\N%ruK#W?WHS*s-\O35O4N=H#=D5A%9@O#L70tnW2)p^E693D(b>W"6$+I2?~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1631
|
||||
>>
|
||||
stream
|
||||
GauHKD/\/e&H8h>EQBN*Mcl4;7*7kUADOg#kgjnL2IJ48-o,;5PseWHPJ/1>I4+;;NDj9_d)_%('D_..]B>22",[n9s-"G\LG_2C`di&2?NCg._"p(h[.`@%KSNfi!/RhW+)rEchu#dJo*Pai&k4umTVs,.$4r(,V'6'_k=Nc@<#Vn\GmoK;WDiqT@R6s0Hh2XuEfNd#&qj]cq>?8FC?S)3m+<K+p4Ha4JosftW4jC="YtUN%ZB4Nr,i%TR%:CD]eLE&Be,b@/SmD5E.5&SYNrR(H4)t&CRpkKf7cAAoc%!tC=\j3$XXM<7Gc"5Y%sgDGcRsWC12UQGA.Wk@hYN%(t9TUj;h/j(%H]%0c=>W\E@@4PV5?86LOAo[#]hko=-'1$8o).eVt2pacK:M5sH^d"rf`[`E34"Qm*Ld"Ya?]X;s).kR@h2m%6(piEJ4SU]V/K<_5D7ZH2OY5dHkA\2!M9nb/Of<HGRYJ,HA[O&Ksf<2YdKc57j41%&O(0[M:G/n`QW08-`Arqnsr%ip*epjIP+8\cMamN)3ic@90S7Vlc>=r.b=mY^;.(;bk;>%m13g<X&aA"]8*>g_!.BH'j-V^B/8i3_^cAoZZY=DpsnS(8@J%7tD3=*IadjWOcXU^EV;)+E_mLkg=-9d`N6HGWnLF&,?[2LrW0Gs]njib07A6qF8LEb\JMR)Bqj$.bLdq9hP<2CrkuR[G`ea.d[."pT\`TuJaF.]$%,f5q.jj'%Ere`[)U\T[R7DoThm_Xo!l$)5I^)3K_2N\Y7;poNj.M(*0I<P3[Tj!ra"'5050=aW9eNWob]M4g![`<'G*NNPlHS!mdi-iY%^[Sc!Y+3MgG>7Ff#*1@qHMj(J#"#HcEJGJV`e"StW=34e3nHqGmF889Q(>6O9K<@BpVV5'bZ.gg+GIO'j3HQ.%+At@>rE^iVX&fka+,U1*g]e(VHj;Gn@%hQ1mEbje@-[YfDmr7EK,NRbBkM]gkfpu\Dbe'<!=h*O?&HsY5J]7d1#*8Koq@<tj'%YH9K%fm4LWB2Fg=4GDsNUn=nVncU=dO.>3B6'r)]XDB@)4!6Xa%-bf(H(DC!-IJ_PIB))ck67llpDLT5;GcV:5:@:@NdFur&Do!*jo&^1p+0&Qs9RkkP(GZ5Ll+dCuMU-d8>QAFcToF7n,Y+?S23nnJ%"d1^#ZHfB'Cj!/f;sGQ%1"Ena/qmHjBp#"2Wa5fRoVLiGl48.1aLa2jK1tV-%.G*3fRq`mE"03j"_SA_p;WJ7$b8a/R7!8'R`lMNmh=r,hQPLPPBqr2*F$`mIQ4dlk!7Mh[ANkKYHb>Tn+(0;^l*NF@!'ugrdH-=a#>.WoUF4>R4\9lei90*>i/ibo\NV'=CT?/JND'!9EX^:&+>233AQt`T^H[SgkY9`%k$Lg\cQ73Hh`4@\d*IU\+C,nnj/:\[L*kCUkZ;F"1Bm.m,UP8MdI!_9p^3*@Vs/n.Bp"c[78IXm8;FT?/TR)!+5Sn*:ZG%27e$o*,`(SlU@IRlT[ksV;#9*I8ctc`qsOW?#$Djgc\Ol^dkKWV/+Fb$Gff&_#FbW"aUm;oNG`9Y0oWI5CDlf60emD*J`1DRQ/:kPVk\!^5'!LQhV3*doKQH*mp>B!q0Kb(1Kc~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1621
|
||||
>>
|
||||
stream
|
||||
Gb!;d=``=U&:WfGfS=(?A0Mij5ltkdc6Tf=9Kd)fX3$9,+;0iNo2&!#Y5@Osi9`m(eB)r?*@:pkhsRtc=R;IW`AajtL+0q()'$A!^c9OR^]Y6'4+ZF[o@5#>k>HOi>XdJ!!sr60Jn!tTi;(;%GecRA8X'o3'I-3R$952PP"@&1/=nj1M"IZG!f1<WYP^KJ?/fpcU':(75IeD@ePB?@!og]CTpj42_/KTo8bK$b+I%0"K38PF%tAtU&+B(c.Do/W-h(2;QuH'A)<VW=]q<"gbIFWI"@qV&,l'JY=nNJ@k:h6pAW?25WsEa.2t9[;M<Y1LaspaTYTNNnc8:u4"S<"[g*-:)ePoF8>aJ')`_MFR/m:`T_A>D3%eiM8gQ*WjQ\8817Uo]2ZNO^bkMm/XRhIj](u?scR"ag\!h:'Ocss\hRF<\5%>:ZfGFpi'T<dE!m=Aq(-$8@D7=maM-/6C%N=kd/=ZLV$fluKR_`'JulKIntC?&gb1EW_:E3=^7Y,aZbqi&pb@gHl:I/>r;i7A#3_X@gCS9QU#aLrbTMp=(f$Fhk5@2>=>rd=D4>I!*:g;h(^DeR,dI4VJ4OVMUM9YML=g^uO^g2@^%M31V<5OO?&Q"c6"qlJ/H=+HdSd;sZaDUGf,X>A,^N(LQ[8W4aP@C1u(61`b2c>J5`2C<u'%l\Z)RgdRh^NpUFH3mSXBq:bWh'uU%KcZ93+DWWZ7g$.l;s5bfKtZ%5@L"Pf4WLXkem?!a9rbNL%\Y_j#Zm,L;7o<ceJh(-M*[HiD]Ml?l7-'+F<d[o7sC'&(Yt-@I&Y$LT-;XJL,;D`E<bV%jpI,T_`We%028m5iWCt6:BY#@H%l?gRO!cs-,_+R9qnt*&YIs?e5*PapZ^)CSFrZ*j4iVS&)W]qKhq_5q=-(F"#Hl:nf1>P:CIG2QQU:#eWM74RL?/pjk8+[U03qc4O7hOQIf4jV6DTCFBq&8"M1K3NboC`c+Z9/X>(dr1"gVmIoLTu/$'Sc+m,:-%cR'/aWjH[Hh'>%MG72i$7p@)cBZDOR$au8M\L/P<i1+P.<SF\$ncAh[Dm-<a8Odu(0WAdL1?7SBOu8gom%/fRr[Q34K8h%-f<hPa@td.2!nm,bSoLV)]^^disOW<*&k)b2:K*mC_AA85OSrGs0005mWr3XOZ`_]Q%FH6[qCe/!LIgN"g'@D(r]r']?:-,\>&DRYRMnk.E5:V8D/*\rE/%bXKZ^'0\!S65,!SNnVHJmKMFbOQtL2^^P;$o,k6PQdt0CB'C&0nfo%6nZu,aB92V?7YUAD'^L%34T)CV,%'1YgjA.Cg#>^7b3X8c]5Xj$!Su@K`_)hded%cZrj@Z&juReN,@9e'2(NBY2#RmZ>d^XsYn]/5g0pK*>JHgN']R"eHbHXu=s%\JrHtL_*2K)WYXe'eYaHV?dXDKEF>h,"u\!("U*DQJ'&#lY(8<J;U8%dWLj[`6$8WG)mKdg>-mS/Hu4F]&Rbq'&aG[Q)\*j4gd8+0+8g_=i<aTV$lnU!6-\Am)!"7j[HNqhK=ZsD<'@/kJaZRiPiuIi?ng_J.`B1j-OW]]NYBV!4MoCBa*:jU@4"up1e68nsESCf67s%i\[gRqrPi##3T>K\gH)~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1654
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>E<A*Al"XCthP^JrOL/^;/8`hbl_f,=,fWk9MloH.gOB)rYEn5&M2]YqJ_>c0PLSDOp[[f%$7<K$q&RZZN^kdN7tEE4h#b!@%ARu()&Zci_Hc6V%M4PfQHt*V<q1c`Ca@rK`:]NoJX[_>SddTUKQEaf_tuQWHB:b/I:5qpSniZ-#/[A"Ed-o"Mn"_>C7@2clT`%s[Z;6eq8BD;6SkbR$H!Uc#1Z>RCgp;_F'phWK+Acjces")Q<>n=.Y^01.YES4CW8Ir3ZB&M=d3V<_HXR4Z's!LU(:[S#GW)hY)[naiWQN2X7FSJL?8U;A6Rei@P>\[8d%tk,Np@5[?9OUg/?8X`3N&IK<`teL4u5XQL3BYbREWqlEVFg%H\8O[!?;CnTpHpW9:JSLsrF3N-5U_.&XE`eIMDbc?GqHD:*)mCHsZeTRk@1Cf$Xub=T#TA@>YC_d$@Wj.FoDqW8P_#C0]NN&%AiG8:#6]-;OClu`W<o:lsT(VT&h`dsjhM`\S1)5Wn>&6eo*iGV5;>:T+K>?>2gi_U^QG=B>ZWkpmFOW:<R9^a:)_cS7HAV81N2B0@m@M`l3fe,+)r(q^ui(=JWO\AX@MUnusF]\3ElBRtNMU;0272MepOdZe#=GR(q1=.M)9StZo^e_/m7UfeWXqiFU3^8*i?tgKh>0L>j['-TEa*0W=RH8N(S^n_1;HIp(a,4^VD(MduY3[@.A>>!i=Ymll;_R<^??+ZV2dj_M+pk#@BUN^:M`gkfaOh/fZ/(Ml;21JGAIR/qDq%1Png,9JSm3?Q822^3fn/]r!*W.u\/W.,9R'6D(uqc]X=<tTWRMoKmp9ao%rlA94?Ke*2>g.'FBYT'bVk)Zl?Ah\@SR[_c@q;.3rZN"[QV''#s7QrjM<_H]T#D?535VrPrcL&K`u78OUC/np_=.E(YE%b&;e+kc3qCM*M"J=qa7jBeZY)+"Kmb[?P_#.161e"TQ33AoeptKpBPK5Wu!=gh1<WOGBl06e`J^%(NH6^Zk_47EUC1RV]rnV@B;^aGN%#-ai4hg\VT3"N><`5qf,Z@\@ih3I#GR=(KcMsRbET5ZK(:@L*:_#)14D*)'Nd*:pT:3QIQj]/<MWnS7W\SdsO7i0b39,Y5>.>]=cu.OhP!s`f7c^l^oRs&0gQ@X5arO%%DnqOrEGL^E@kQr%krKr/lp-F$V>/+=P0c/;$*VGQk@@$5%o,d\H2k0&g!W`)dLgXB@Y6On(4!s5@siLI_%[+!-JA<><T1dpbtAeFT0npYnq_n$/^(Q1\\6UGM];p(-AKBM"X:)J%fkKQ8dU<KIZjs+<S5EnFFB[W<*nHe^VbANdk:b[lA9GR/a>$[o)(),W@H+qghWjb7[qKee"L"6^dbO&fCZ#L(6]Z\3(kN:\C1.H*/umDTj:rZ:fh7TWBUTCTP&&Xe;P^f`h5(?&^-.40f?0.i@r:L;^Q4Q4ujo0f?h=&5n]6n31@iY2t6X.,1Tid@&.bPlF"RdA4FM4UMX*X2%&,oQlVA%-D'FW*h6Dq?huX=9^&aSTcj[#50'ZhQUDGkQ5IT$5+,2$V-c]51SEI:j"0:NS,5E[m;B;Bs1jI$s'GJo-Qg^I9C&'C:u$Mk[u$3][Gt4uKo8m,1Ws,pZA-\mI/j"ej-QA,~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 34
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000741 00000 n
|
||||
0000000946 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000002079 00000 n
|
||||
0000002285 00000 n
|
||||
0000002491 00000 n
|
||||
0000002697 00000 n
|
||||
0000002903 00000 n
|
||||
0000003109 00000 n
|
||||
0000003179 00000 n
|
||||
0000003471 00000 n
|
||||
0000003615 00000 n
|
||||
0000005572 00000 n
|
||||
0000006064 00000 n
|
||||
0000007403 00000 n
|
||||
0000009290 00000 n
|
||||
0000010896 00000 n
|
||||
0000012827 00000 n
|
||||
0000014803 00000 n
|
||||
0000016489 00000 n
|
||||
0000018280 00000 n
|
||||
0000019956 00000 n
|
||||
0000021679 00000 n
|
||||
0000023392 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<744a57cef2ee7e8b9f4d67dce5818616><744a57cef2ee7e8b9f4d67dce5818616>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 19 0 R
|
||||
/Root 18 0 R
|
||||
/Size 34
|
||||
>>
|
||||
startxref
|
||||
25138
|
||||
%%EOF
|
||||
BIN
Les14-RAG-Embeddings/Les15-Slides.pptx
Normal file
BIN
Les14-RAG-Embeddings/Les15-Slides.pptx
Normal file
Binary file not shown.
BIN
Les14-RAG-Embeddings/lu43khle3.tmp
Normal file
BIN
Les14-RAG-Embeddings/lu43khle3.tmp
Normal file
Binary file not shown.
1
Les15-Cursor-Vercel-Deploy/.~lock.Les14-Slides.pdf#
Normal file
1
Les15-Cursor-Vercel-Deploy/.~lock.Les14-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 07:44,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
1
Les15-Cursor-Vercel-Deploy/.~lock.Les15-Slides.pdf#
Normal file
1
Les15-Cursor-Vercel-Deploy/.~lock.Les15-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 10:59,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
194
Les15-Cursor-Vercel-Deploy/Les14-Huiswerk.pdf
Normal file
194
Les15-Cursor-Vercel-Deploy/Les14-Huiswerk.pdf
Normal file
@@ -0,0 +1,194 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105902+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105902+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 15 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2334
|
||||
>>
|
||||
stream
|
||||
Gatm<8TWXr'#*s=oOTMp+p88i0S``km&nK)4H:aeYK@Z=KI#7cWK<@8RD\phY5ps4[FioP*2b%o"M4JGo^W"k./ksmR4\Ns:>ke8#)pH1#0\GY9YS;tM>+n44QMVH!"kBOQii2!rWi,O.j50i)"X,GU_/`O&&)RBb#e_cF]$($W"mN_GRR+WdRa4",RZpVm]@9Nj;_1s,RD;4GJ1C[C@bmI>3jPZmQV&r)9d\Q;M@mp$_dVJ9,RX$p%pasY3)`U(KFJN+)S..7131e7rWW85&6V"BiPB+eZC=$eP_\"F-p4MjFu/BORJ*Xpjtuio"V+-=9+uFH#onKRniB?1=+,HfKdgPkgYTfOBY@F_+57F!]T.*O*`%Q>C(`4FDdsPi'U0t%J?3Aqa2fWru=Aj\Hg+WTg?Kdg3tRu,%<8dBoL$e/>p^OUu($PiE9`0K_Ei-Y:T>pRSi"@SSu:8-jHBSn8P!+PEH<lc7r@QM&M6-GWP1rTL][:+a+n=.VU#Plo___S2*$tZ+C6rh2Tcf)XWggC[>5,5Q\qNl[ekb#;!l*g#SP"lVc^78(8.9Cr*pH2I-M1N%*$r_$*5?,,J/lLToZ)mO!t';ns(RAr;7d:i8s9cEma'p2,uLbj0"LAoKZKXFcf.mMnHgF],(E=OgEPdPe:$*.._EilfsRP%Aen@o-1$9+GRXE.Ua^^uoUP&!*12)s<lfe:?J[c>D-2PgD!JI,4hil@P.q;/$PF+f8A%^*bjX[Yu82'k:WcGFU.PD9G+LT!m0H&Fr[@hQ%nPnkpJuZ;IhZZkLu"(moh[Wi?tRWAJU<H1hQR>qMs(bOT]14Fi:;7q"G3$ZoORb2]-ieZcACTCV^IHAZFHWXoQXF10)5<q+/kQ[;)G#MPr7:%IW:"Ak/roKW%!(irf_lTGEdmjN3:).5hm&kgkP:A,@*Li"L*;hE1g3-](nSV/<a@GKBnXL%E`E0C*I0CXbkb>_o^Kf5>7$?m_3+YE8Mjr(aO?ni0tk:j;-%#!F(**e0q,>.>FYfdi8V[/:VHQeRGdd_O=CSfV18X.qD]7aojW@g"%RbA)(-T-(EY^erF5jA6AY[Hm.+Yes&7A""6%C#Jg\0D<g8]Uq$0g9]MiT0!RHdDJ\]FK[Qc#!iuT;Z-@WDm:-8*fbjbCC)#hN6.>C44^N/XD,,WYK%O$Ir+VXA00i)dQEK)]E"=TVGD[9`<I>^T0Cq7A"U%cO#!&e/X]&@A]32N?2a`"h/aYn()V[\T$D@9<TJ?b#ppK-)q0=dq>O42;#->6%Zjiot.oWoa4M9o#a#.T'b_<BW51jM-6N".ror"q'%'rd_^A&8]7&"`:><n'Gt:Wpn8cRK>Jo#I_%8p1!!Os0*bulg0)"`LO;&oD!%Ob)oEag:(/.b6^YUiX`q(5MjQNMJB^?:#&\l+*lGIgIn?1RlEk4k(%g)+hFP92_&+<(4DU920b^/"=QM`=M$^Ikc8G>B6:bX0Ra@Q6[ZI`=c+Dj8q1aYJ3Bk6FOJ?n3DG0t!)n7qHp;k_5#b0MIa>#QTSf_H6+q.b-r&A0OTl/>i`].T&X\DisLUsFbb+-q3es!-FHC9[cZar)_:[!^Q5KB;3WBIOI<WA/rHpnk<D9<g%[8(O1g:Lqio%P@\[4a1<)76STr#^0,:s?7mbsp=3-ZKS[`AP?/Imb/<W#?O'ggGtd!uRi_*._CrNYa!5BaZUQZ>+eP)8ZUS,Y5l\8EJl^I^>;G2jn9SdX7@])l<GJEcjSqa-5_N!:rtd(-X@\s"k0dN!!0+O#$Y2[OUF$]=hDpV/>RS+o4t90fT3.s"Es;i_ksFgYrZ99-jMVMORX>ICM&_G?(]u>hs*@rrD8\WES6eT_[ULp9I&J<gak=q.te`OohW,#2TI/Sci2<!j]iIqc20fV76&2<6_(,&ksKgKA3EVQ06s`/C\dN:Hlk1Yh_NHa4,n#WDZ/C5#N;hp)(=RZ@M?aOAVeXGYE/84k9<)rq8ihM+'Kb6Mbk`l@o15Iih.Y:SZm)grpX@Pj+A@p@2O8;5V*^i0\jl\.j+LZbVQnW;])(\k'u[CYB/k]e3%FA<2<8[bCC;Rf;J9=skFN)aP"@]'aSgLe-G3_$oT)2+_[Z75&_i^/Gu6D\!G`)jSq;@Mm/k"QF.Fh"PN.jqr[tMRK[F6=j7d=6nG-7AeV;.WmsD54s5ebS=i]T1:.75pE"ZW8WMWdGck`O%!edQ/a('DQmq'N>gtkUWOq8k7h4\:s8Ec@#j!7YM+uoce+UnP2sS&[dJ.ar`/$U!FbDfCe^G"VFBh@Pa3Y=ef9f:]El/P2iEZ&dF"42`Qui8$p+F*1o[*a<t"Ns%rsn*BE~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1035
|
||||
>>
|
||||
stream
|
||||
Gb!#[gN)"%&:N^lp5u.>Z$_i6Q8D0sRC"+ha\6>l&S<aI=E7sRmGD!Z5Y8IrkQ<8Y^k8cFF3^r^(^c:Oe*8a2!%rS:G"mP!cl3#YM%@9]\E^JN[YPm6OWOSnR,c.<OOu'jT[T3[+=(C^`Y=dR&Jk3#1J/LB)7Xiq'[[1C_W\?qJOH1F0cp[r6--p&oc=EFf;A75&;K_uoaO)c_M_"<TX-?<_ZmGkd"^%"Wok%N##PB6%,,(VT5NUWV-#!QV5E`q1aZ]7IFjJ:r3gZQ'V[9@KM.iq<94uiErs#scoWh#Xi:%JJG>]L4cb)J"jd6eAtiD#T%%bl4;F$3],'VHMRn`\\dd*/EsW3oh\O+9?H_G\?UgrpUjR>k.TC.JBO-=Yh`?Jb20(M%fF^Kh.W;rf"^ZU(U#2FIMq%_o<u7>^ffsb:EujL;du^jmUeZ'CQG7/WVeR>ueNV1L$uGB39D13l-LZh3*OBM&G+V7O:66MC!MtaXEbuE:?O9kh-#Rdgb'cX[P;*KUY#>0De?5i4&lXE$\n^e6\kG[l3CqH%S`gnYrp_7iG5'EK$$s@-Cj9jL+;p*MJDA:Tb$.jMB"7p'*pu==4RUBtLR):]Z^l2@g0'_EdVADXOZdkMU<?dH'#!FKV1l`p456`,q"1%QR]"dUn,eC6_+mJU&W'E_C@""%UV=rp+SC%')q"0-i,6Gu]q*br>EGB!M&kmrK\SaI@=la)fqHM"-2AoWY!e-[n<Y`26oaYlDAQnmFh#apG!;Ze"Uf*$@k6%q$r\u%fH+@,>T$fY4)=PpgW"E9;Rp0%L),&co#'dZji@A8=k6g$la>G_%8NN#Dp08#*2qP"#]mi@JgZa]X-jsE\ZMu^`+_kq#!T%<<s@kP2ZG[VqEW=BRS.GFURM#s+f$+)T7jbC%:F^?iI%sVhf_7,aKYQl1:"O?\kS(5G#B=ZB4L[$6;ONnS3jAu/oUiP?gjY88D[FFit'E;3Sd<;]^"A0Ij`q=K;E1(Q[s@rb:V>n=NmXR,MG@9UF@NZi/SreXZnC@&E*~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1666
|
||||
>>
|
||||
stream
|
||||
GauHK=`<%a&:WfGQq&HQ<dSh1BbWl$-9r'=:kKO+;,GbG@a%4%+ZKAmSL#SFFsJdsN(AOkX`hGSB'1EAMJq'n7k)7F3Qtec?l\<q^a,$rIg<1>hR,k`i`c1DKV9UJ5Q]nN:oq;<Hpq#M!sXKp@^H8P(bm?k^emjBVP155q:K1B[%c8F+GT^7W,L`#;4OCr8UR@d]CJ%4`mG0Mg9F#6m0CIH#MU-HTaF@>o<f,#'IFncIXVZCns(f%Fe0uMHWA^3?,6^-)P4X54tFgdXR$R+g9"*?9kq$"&^8#*r3=^.^mDnc;G_c\s.(`mq=(8Ab8XYj[CGDY.aC?l"/&G7W-]f/G>'kO6o`794aH%Y^lpK>.]FQ`\51i5DCtCg-Bq+Y=Q=#&9+n*:+0PD94d"?ui=.T%3N-gE^jUbE*O]ACSrUU8+[UeXqo!">0C/Y6*>%VF6lt_u,l",Z\J+DT(g%i)CTe$opMPCDHgQE_M-`Wp/j9J'$OC5Ck6;d_$KIVqB?pg3+fmih]@R(tC@D>B^VuP0paj4r]nQK%>eR*Qn1:U!4=fC0>I^&:D6ZZAN4b'=@o8p$g-s!R:`CI>_e2]42Mk'I2'gb%MA8fKa!&+:dnO0&!qJudLY4ElX*E/aAf?D*5ZVm4Xr"F6]XYOHnsaY8`T1g'SD@XtOIHLiTh7-Bb:&g>.Ge@b^hj,gCBEJ^dA3A`\h+HK>2i+@SRB4^eIY\&W?/40rLV*r.uiL^(,f"F)PZakZ3BD\[@92]h$QLA5?uEI@g7G3*fTg(>hGM%#24.SanOBY[g0fO5gVn64WLj1B,m,Na#fuaR**:qSma#fY"bI?1kZ4eQDQc+)qceWEEBUe?rJGp5D?eRD-;i)Ed'Fkhaml#MD0t>[QK2WdP/uG*YE%Tlq'7Oja'TBV*r`shGYLPLA2UT:Ai$_Xb\'d<lp\U;3$`I8iB8G,DC66X"b3YA";c>NhXhA.JaA97@dHO1^PZpr=KTt6"=$DJRJB/N0;0&)edc;#+.__\e>M,1,WhUaQr-U<fUQBC^?I6#I$,>-CqPpe'_:ik35p*J5MpmjU[1KggGOK0dmU#?[H]WpVJ[i`pQ*Ee)FIbKJ0UU+$Yg@s"qocG4\1%5bp5ks/6OD-B85@@t`;km4@5g1l1d\'&)-WLdL`Hl]BTT64b_RM]A;k`apY.EE6+KP/)0c=lfV9CmsG4eRb9'YnSS*NH.NcPPEdpebOE54Y4@Hj((1$7ti_q??JEZkO3NF_-#*LkK+^M'#*#Qp%NWfm)MoEHaU6VG@1%;*LlS:d,'[jn(9EZ4pW3<g0us=_1[rG%'a]WF).tY)]T>SZeNji=K!AN_ZFUZ?J&Dj>Y_%2VGH.J2tr;QcDns&k3c/PXR&e2fOuAF=T0>\mPXt$*;/.t9%DNfNjIJL9Al.+dq$"KgRBSXjl2E/f!R2U/MM2'NtZ+iLt1KHXM1<^?cW2@kGiEMS37CX/+dPH/Vdpr?FF^kqhE7kA"t8Pr(uYQ.P>R]Hs5o8OY_9d<#"I<r,fc^50=J0NdQT-6Ck:>nL4sekHPo=;:q?s_#fW-e!p870%-A)D6N8\!Y\5MZ3\@XK!LC\>Ait&52W_IVCbAW$p8+]:GhTJ(;>!Pf&G.&K5I1r$YtqPCr<?V.;1sBn`?hCG9?i1l1.BnJbMB0^]AE~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1812
|
||||
>>
|
||||
stream
|
||||
Gb"/'gN)%,&:N_CD#KGDWJqH38:l:]a0D2h1JZsi8iiNS%ho1=N-UOW88A1=$^(SUMe(L<1MNbQ`h\p9m[hC,pOEror6X_YPQJM^_lL6\."hL)-mp#2B5lY8Xt@LQk&V>p,A\`85QXIU5r#f'E'<1a/>+,%T*V%k(R,1;#[fX<pSe[WK[EGN!qAG.(EA7(Xs^kDo,ZgFL8HtN4YO<.^[Y*5pB'>3hP$G3(R<qbg41q^*DUG9qC_D=kl()Oo-p3j@p)9b4AXn!.V(A5E64IX#H)nRAJph,%cKt(AWfhfRAh"LrrW.cg/^^.G?i!*gV9`f9U7qL317bI65EY1hXb\+B-Hc)U(8*jV'_6\T),l<N`I$oCEm^W(`D:OO*cslbU)qaOX1e7M/@=]OYP7Bie7.LlUosoR)h$<]V0pTXicG$*#H+mF??iN@iSSX,'7dLT3)f"de9AO'\UJuAD+($$2uVR2NnT/%e@]0=B/8`:VHJ*hWj1$ZI^=qacF`9"bb<S0``!]3l2W^RN6S&L!Vm)\$$hN?DTk/m&kf_Le@hDg`-+I'O_bO?s;V`7Ol\>\EqIO0hl-$]ZV*%AVDLJP82[[3(V;Sk1Xo)*`Y%C+&t:kb&%*4c)t'QCL!3MXP24bAIpQRG,:Fk!jS8VTs^H2e(#>K_UD\&<HWVPXRqY0c%\$Omcpj\['&D'e_agE]q8eW1)jUuRHCaJ)Ce+V"20anWAki`qH:LTZFW7;%\]79Ei!7I`)a+K%C!=-%'Y(Ye!mA,>g1&a_DG4a(>1mI@@XG#j_Y0cZN2KCBqPq`1:l1p$[FmnYu?<0SL"Rpn+BA?$3kO8UGS5><oNQeS9uhHq$&hS<cF?S<3m2]/M09M\=dDuW231ld(o8<an%BGfiAZr)^&a"L%m'O/6<B08\T_k?Q(:E-)_,&p)ou;.%2a"#4#<_H5f+t)UaZi_MBOIL=G:EN=WL7RGQjWDem%[O.0],3b+9s2fgfIdi=(qf^4dNR;3hlMPD:',cpEgWPs;#AqWX>=<.2V:2E@R2L<=([bm@+jd=,(8,:*$T$&Wa93FL]k#W8(<pnFKOR^+W:f[B[dhH/Fc==IdB",3*;\:Sc]CiS"hkFh'mq!sY"BFsYqh[q\-pt<&'$p)`)DR7P^";kD\m;W1RHccMG;Rq!F-5MFYbQ/kGMr.0\2ZZ3H?W'2iHc4IUT+X10csj0,k1.<<5.F-MO2!jG(s+ZYi&2fj^`#;b2mcF;,;In!-+Rj:!,W1f70+*,"/$1^MNMeZH`CL^DAXo:55>!$bA!HIoKk,'m+:_1laSIkW&9Y]C.^32r\C>.m#X2_93h'N<4>rI=J)W8F]uCKYCs^.HI9lPXE+)&`hDr3"!",*P0hQUX5Q=4>-_\^XObCj!'hXKYp`UkBTq0PBFg,I7&6T6?gqF!+a8U(`)dV>3*Y!_j(TIO(C'$]XP-GO+a%6s.>%\l"TqV+$L,U*M8t;rsa>$j%dEap[9FJ*hAh!=`rBO/+Qs4_E@fQ6H5rs9=_Xn[[)3cZ$]@E&U!tR#((L>o[-Q@`OXNGThcJ;Wu7t;pf)A&s0jKWH'&VM<CeeZs6fIW0$['8ZP&$e$TX:0iZV"QHV*\?-FjZ0b0!FA]'UNUl5fICN39C+cE\T5N+JkC=%K5.**Xhh:G3gQ8U(F0:F%0j0aL"\,#g87o4R/S,-dBkYKPdqi\7u`I^,6a;Jb[20T$NH2J9Y\/du?31'A>io('cbj(.I9Fg`;aV[hi4,0<J\^qB"<[ZI8[)Y\2bC+@]malmr@qnIkg-8@@R,sJKC$.V9j=o>X9.tI:O7?aH/~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 972
|
||||
>>
|
||||
stream
|
||||
Gb!Sj9lJ`N&A@Zck"*pDn_.c*l(q##7#_ngM*fZ!(Lmuq@!:e;J`P^4CkqJ0R^PhXJd"JQBAT,'o>VaiHLpDX*WqpUTQS)%]?odE(JI*W$I1_mmg\C`DCBm?SohWR$`r`\0/.*-JKM+hmC##G-Y+tH+=(St[O&G*/e!"2#,-P%i5!4F$)IVK\KAn#kIeh:f;6uKq*EBa56$F7QLNTZGXP%Ul\gAB5PQJ&AQ2GZL\mbUSM;rcJ%<1E:HCo`&%'X@CLJn<'[V!Y&5Fk8SQMB7!Yb?b1X,N&N2ejhc4E%WAZiH9_Xj2bS8^:`'jBNZI%"YjNb282]oLN77Gt?Q\tPkd+:[$*PJ/9?)0FW4DV(\a[Q%8kggsD"k9YAY5'!;jCWIM--.^Y$]80TI]%Q1R\,gRiUrSnL\`@P:TYC+&kF9<&77n^A3/uopC@kIOE0=+=5O?Wcno7uregf:XBQ9DRD0h"`!])[HI+n$6ED&/9LR>atZ/=\$Nf#BADA*c/UB6/U$"D=Gg<"cLKctCO_@GXqY]nQ;=XL6VM@p'PFWIp*9a]-LN/J,/>W#\tQS@$'eBdf8D^e`YjiIIF^904K!Yn-.=IKtWBs4qCMf$ENHPkqLHdHY?K;e-%(GLa]OG0@EZMeC8?LshlGdVkZ;DQpEQDq58BlnqQ5Bm84NTK(-Y^h1G?f98VShHX!4aVW@Hfn\\k6c=WU7%-6m"3rZ5i6R2D$'[ojVk\NogGoM?)n.Y[9c5lg>FO"3LSnQf+6tqK<=H=RcZ`<jJ/I^k1O]ACG;IUd_;)=o^Hj6"o\toGp_uY'<-3V';LI`*a7*q9>qO]80qr49"YO!HK?[W%S_mBj>s]qK!VG]HK28B&h2$8Er=NPkn6S!<T>V:[/`?5@6[%lYNH0?%XX4<HHr[bm^*kHYB$aq2/.='Bn&2i,#*)U%*l4(lRWTfg3iTGfVO_Y%hdoI;HU@2)&b^>MK&;g~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2107
|
||||
>>
|
||||
stream
|
||||
Gb"/'>BcPr&:WeDbY,Fu[S`+t+M(??R^Un5[Vj[\m[fV`H8&B9@R((#h<_?&&R`EK1n42QA&mB#D_BIc1BRdo5IC=t@XsY+*5/16)>%itEl+N"!G?i@c61>>0"6C6_R)E0`3](XA0W<"gEDTt4:8rIM/YIo6&?/E!/C)MQb&8M\9VLZ+Kf4cJccV7G(d1[h]`P?.%1h(EP0it[=1HCk7OWC%b7Yp#_jR;D,c.&EFMi#[@n\[_8f>YcJcGTaZLCj'u<n4a^AAh2.J6YT-#-;H'fCg#]Jct7OjC/;Z$n#>NiiKZ9%d9Z3e!6G8^EIk!3d.57R4pbU+'BcQ@jt%o*An1HKZ0jKMfdf+c0OO?pP.j+`8Z&JfT#"0o[Y;Oo`?,(mGE1qDi*A"k$`$$@9>]ji_8"@dGC:T0pc6T)>8*9Z#%?sBuVdjVH1&YO`6o>mU"PdV+I#0:VV"GYTE&`H0RA3IIXULSRWN/_jVkte_\7rS(_.<hs_7(kfGCZZ5`jGXD<Lhd<$P\YHn=VjCT2m48apeH\7&[m_G)6p/r)g;Vg6m^;%Xq+K8KIW*>])Hiq:2'@&@l_e/\22A"nNmC9f.9p09mA;Q$Ugqjm+=KBIF6GG_pYUnZOHY^C[/K(.X%)qKsW9]>9do(Y_$Rtd9"jW)_Ki29DmA0K.9jG_N@NB8W_g:R3`K+HfsV(:T7"t5KH0k=7@gnhG4B[J.T$GaNmM^Yf+k/`Br.N>f!kY,+4.Q8i-W9*iLnh7d,\iB7KThp9%.Cc;21rpNg12*e-/%REUc=_C1Sh:r;#^1H_Hg/*G$F?=N'Co?otVUV(,Sp/6Amd8W@%d<PT+M'#D\R@5Hi36&("keOfmIPK+.;6I2+?Gb^-\5-\MgGqLG2ug]OI'[T`Vg4tF(ol4!':=J;I$%PSUGZUHY:.pH,hoXraj:"MUE>_]K]fGuUUNHI+.Xbk(,m-%lq9=#8g.>*81se>'AKc.S)J7G?CO9UE^T)E<eK"."/AGAh"FpVhiS5!ob>58a]8k]=kA)(Ua@iU6ITN0.Z\"PRjtqZ]E!o<P:R]5M`gY7\V$0;-aAUn^bY(mQ;oaDBr6X6TEsZRRE(r<4R3%1n].'lk,`dsl#.1F[7tuC%Psr=?"r@k/E.2ELcs%XX*!RkVMC,I@QYYNbuH[4M:=XP$:8bTY@iehQ1Yigfafe(EL)"Y'5ZhEX>_;gfa)H]?,Ff]5U-0/ed;p`N#]/o6V8;<[V0])8UK4F&lX%u^f[V0nnHp>c;ZW"AO5Llgejn)7*08S_p>q5G+@]N[S&hq]P5aYgf9BnI+&qll-:;d=?/!G@?J#0flJ28m'==7I%sPcE7m]2f<Y0lU?O3hec[l]oBL$*@a>Z^j"2nm9Ipp_s(o^LV?q.bcJ[7>k3\8P]Fs6AIYii/55oD91G?mU9_)mEB;(i2)W%Aq%;Tf%LN:Z)4i^,=p[fHtRgr_+&*,jcc-,TC:!3;6R;o`4Adukjai*JSFDkr0jsRuIkEeL>\u,a>VjX#;Jnc::9oeV,cegR">+uO,S46CcA00BbCS<8L_^9XdhH!um30r<,.8?,Bg]LeWghKBfL4*(sI^B?[)G89"0MO.6j8U\HGsX1m8VFVU),J#\'1aWmq9C*@bSuk(gBc5o&cAo)3O+Aegr26?RbC;Be.U#V46,!ZN\JWlrW%;W$lojE'W1`'+jNEbY#EG";(?cQin0OsF6O.0eR9C9Jr["2V.[3&2B'Y@(i"q/JA);.^-5"DX?JS4H"I@"i6fOVAj*$f2+r?"(rMt!+>KH2XfpE2rq0N.Et:h+p6JV$0!FN?MPD4t$r\2!)fh3ZXYcDe?aBMPAMh;P2,S@]R:^n)R>+S`@Tj)Pg8!q=eS.a#2n[F98VjW/cnAtJah[YW70B#fb75@3OJlSiFXS036E-j%^5q>2W$n@X.HeilBbUbr.C7Xq]Jn*UM_W:G.`J%:jms5&VJ`O"P%/m(=S7ZkThH\R%mKR==R1m2%qY;=k.Z:u9Fi#Z@BtKAoQZ!s=J/>YP;EJLOSD[P+X=2/@8T3JN)VJSpO6Cb[0Z)72i,so,%XeS2,3J>F,1QGL%k25k21[*gZ7<^%E2'\f@m8l'9%DA[Ju.L~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 831
|
||||
>>
|
||||
stream
|
||||
GarnTgMWKG&:O:SS<li^g2(rC;lQde2JF@<c)#<X8pS6,->N]V!BX2n^9cbMNh[N2H=5mZR@,i<OtY?Mdh4X0iq,Q'3LJ*c4Q0^0_>/3FG9i]o#sBBINMILT&@$##B_#"(57g-I`!VV8721(;+sis3No4k](oe,V@=C=:&7H^!aFf;5+%3r7GZP$._l5fX#=3_S^You3HEd`6m$QpS1DkJ;9i5+of+V'XToWoEUEA+^=1C(I[HXG@"b(S'V2lW<*%/]"l=A\Y]'Uo`2d0-glB8184f/T\O);$_I*_J>kK<9Ore+H'+."M65#JX+E,UnE<sc`,TNc=!)lA2LLrbo(%td*]Rh:FL94e?I"4@ZnTkiC9N[j"A_9c_LJdq.'moj59q/3DmIAAql8_6H!9V3]mm9l0;FpY74EkFPn%BTL][rJ\Y9_n>66Z.:+VsO5VGf9&\<24Y:AT);rZ&Bp1>Jq$;,3Es.=YC:GY^,2R@]_p,Z>\YTp0P87(Tt*0m7W5"BA`Dgh4('*kh;`0?K\4&/3IJEr\&F79m,:cA$l'E_0^7pB04lhOeW-rLA4nMWooh:KXrmLh6g?(e)2cKQ[*(Agl?C".N>1h:@].Y1WlNBH(W/e%S.7BES]CnA+;Igm^/sTBgU7'Z_/8NQu?u--#u?j@AUSf%^GH$8Z=s`neTPt;.2am2qeb>O.Vije(h%90:5k.JtAq[5T.u!L#G1o%RDbmMAPIRAodi?PSn(ZJPIjRW`n%dT^!aO9c]Cm!pM0rjCAV#K^fFb5N-MX*?RQ.N3\C@[/s]03AW'85[Q5^L48#bnS5DsDVb"&7fNH:Q93[~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000001943 00000 n
|
||||
0000002236 00000 n
|
||||
0000002334 00000 n
|
||||
0000004760 00000 n
|
||||
0000005887 00000 n
|
||||
0000007645 00000 n
|
||||
0000009549 00000 n
|
||||
0000010612 00000 n
|
||||
0000012811 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<5aa6d014f38fa67da8a3214366a4339b><5aa6d014f38fa67da8a3214366a4339b>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
13733
|
||||
%%EOF
|
||||
156
Les15-Cursor-Vercel-Deploy/Les14-Lesopdracht.pdf
Normal file
156
Les15-Cursor-Vercel-Deploy/Les14-Lesopdracht.pdf
Normal file
@@ -0,0 +1,156 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 12 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105902+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105902+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 15 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Count 5 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2129
|
||||
>>
|
||||
stream
|
||||
Gb"/'968TB&AJ$CFAYKW&/[j!D+WjmbI`W@Yfssq/riFnS9Sj[HO/uhrqC:*+aIC>YY.`%;RRlE?[#`1?amt(ifCM!A-qb[$k?W"?lWc+^]G.aln,l;H!`\N"d_L\`)kHL30;3?Io)\^3J<!?LdqjfPsi^o!l]snOTLR7Ugt)f%4;B%"!Ro6.KH2F.$WAQmh<MtM@N0*7'nthf2!:W+sM@#:sJDM_/KPCNO#L35U["O#dYgNNo#21"0[V]>pj8ROhmYaV3A4ik+#(hqI]T2Y1'u0$[AhhlDuD#3c?(Tll9UB7b.i0huVe6+P6/@l5Eb1GUpp*'jKnrnS+H4^d_EJn@K0<K(mCpT+Mrp5-Dqk+5ISiDs7E\m(P<1$l]f;<1#V>'bJONXt:ddCkFI7cnK!BY3Qb>QRi2r``U`MZGLERif.8W?8'Or:kqN2Yf*<87Y]NDI"*rck_J#_LJ(@/;VXds3P(.+3f_(\nc.:fI>%6h)V`O`To+TIg!6@APSIeWN:"&^D7;[XTi!3(5ML(k64u&NQno-k0k:m9b7J:Jf1!U999^Sn^n>3-5%6ufN)@lrc[CnR,,I&8?6"'Y4*'HU5\R-K[8c*l#p#.a1SY's^Fq5%I`N<$)\hC@\nQ6r%mfLgK+8[.b_kkNa)M9TFHu(Vg2K>$dSLF.bBSOHr_QEQi/:+[f>Eq@'ks^4[lcUnVr4Un$<l$ZYHnVJRBae[hB&I=bK6[WipiJcA$$sI.e=g$MTs_WYG\:@ETenELQ$Jl>#j0YEDu?9.t!<31gcZ):\/:``H?To"[?P5a!P\)oaOUYd'jTK*2i[)5"oSQq*O7%\gq;...Kl/jOhk6b6"4NM>h=VesEtZJ5t;C=$"=p>M=JWd)pZW(h2K'a8d/cm03^-*O))6s*06gQRu8e=@+.)N[Bjbi8a5Z$)FqY?bH9V)RC/8nStFg;j%?J/1jSn2T1N+KdGTA.RiR%FlMMsE(r_@OYASTCs*B"i'<BE/NQ'C3#?L9O4_m-5D:#O"E,HtVYu&58m=Fl3.oN^OF-_E^=]`F6o7dU^DTpJLs/Gb0$!Z*.n8f"Z-a0LNTgQ$L[FXa,kI.>nX_r=kBRKTU*i5O0A(A8j%`@3ajW5)1YmRVd3%oC(sY!ZCC\>t[HU'TD:'\bKY+.BMP2RR1d4\74tfs'aM>uKaOc`08R.RBi)Ce.rrSM?o$-7KWiCsaSe]GSW-)YUQ)j`ifr8QP=)%8PFo2MAI_9EQA_a(`FQ&mQk&K&*2"#X\gcROtTnpW"Bl@qn>[TaSe?pTeO8bmO5Qh.h$-]F*U[>shNZ">kC=L:(5K`%F_8e^t0IH=++$O),DG)5/Ku)uJp(eV=E]_8j0Q\S=i:*7^%,Wi7P$/uCV3p6dFP9ZkA3\b90s\Hq3AtXWf(p:cDe_dOVoTS]'0AQd?X\Xu1AeSg-*7!fS+dMD>E`Hg\>+!K39eSR210K[D/s!tV^?BUIMg`1^I?:8Se=Drg$:IRa?=Skjkdq.bLb9^:=cP1k>?drbj!#8;!-jP,;.fErmdsmq"Y+'ZM*HZ^+5On*+Beg-ScqR92hDJ8R=>qQ=6JsJGCI)B9Aulc6$;"^T11[^AW\c>M@tsjHfaaOqarI8iu,AUH:0sLFaAZn2i2=7BFteHrZVod\dG^M";YQ\Mk2sa:C&!r"mc<.FIaZ9_;H"Qh[kgVN4Wg]$t\p^GY'l9C9$tDF]L%B@7h=)5gjViNYYVG?<jNJ_N)PZNRhDPr?Yae4asiY%f7>TQ8h_L,#G:"sJh6+q2I=%)V@bAU?hrJP4Q)Z5V@:KQ7O^(K.6[C[/^6P@[ud3?+pG[@cohEK<1e"8D3Mb0aLd0l6*,OL40hB&--MJu$gLmc8FoYUQa+P71Z<1K9\pTC4s;)t85+mX<AB5_/++q-N>IWRA(,NB#08Z*qVA`5ZS!9^=0r0%((G.@-YB9$PeoL3l]+&+3^TMrU7nj2G&"Z5@\rHIDR>UWYthY&d/MiGL7F78nD';uU(L'^o`#7<a3'K"f":XeJC$_li3,kE+Z\*4J+fGZWn]C(U"SqHW=s\b=]snaOe(!hN2:NtVr>iadC&;e8%'Hqs:N[qWSQ2LhsE;qj2ZPg6(/T%pu^d%"_>j9E8H!K;2A70~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1390
|
||||
>>
|
||||
stream
|
||||
Gb!#Z9lK&M&A@C2b[jX!LhW$H-n.DYAD>pXJ42EO]'[HQ7\Xb=P,OW[-cf_GH\^*`5+:8a7ZJqGRQ%!eO#(<3nRCoJ.0/5.@J,``j07AA'1BY41ON;Oh`Sb%%n-K:O/O.A+L,kbT\ar?"Hpin`sa.1Osh"e,"A:TNe!m1(pX]f%GOgB!=:%],69'!JW/YGH_3jSdBt*JPjq]L5/,*m(DGIlLLG?J"N)Ths&()A0*l,T(r3_5`G],_\qEjkVjh489FcQ\[8-EICcDsaic9=l1g/;m'RnG"SXZY+AU=LE)d&_9?Sd)G"lZ]l+6.k%%BohO0ND;W&c,,eZ;L^q@=?,-%UbIo_+Hp=9EkRon-Gd?QC,%!m#l:Nk$aP^%ra[d_@pA1-%&t@4V:P,3%^,k84!Ihk:=sL9]D%I%*9'pcQ(HE((_pJ%g+dY`na!l"QKhU9p=@B,`JJNcZB5`PHVp";_U4,``RTbl#!<P`7oW6?2!POYkmQNlr6YMeE^Yb>te_Q\-.145=!8L](b@\%^[kIarGN'n!?bLlF,u61DQjg>2`/=[^db=CLY)E0bGUe\AS9@<">lskSf_?l<qn)@7iMZcJ-=gjn\4u5tD'D+c?W1Dqh9P?`be7<`MU%Zbg-njDG-pJ',)ACO\`bYN#uN^^hXpX7?Jod.L8fn?XPn;kislX:5C1THPgkEcuSk]!rDRVp4Pc^/%(fo+$2T=K=!fbKBGc^'gmd9:ank`2:++[&2QI&GV>/ZpTS]f%]nXUi;Qp/J&E,<2tJn,/HCK.aK/X\iDd'5!dOpFk5h6^c1I5"QaJtkkY`8LX?]JChiTcWtW_#YPPiEbmmS8qf\5><Z#amD@CdBgpX(HpN^#Q-qYGl\O'!%6>Qh<\4@6<[4!6"V)6qE)Zme3QB7R<$LI\Qmh`YBTd2Aars,C>kOYF"ooh2u3<Q97VeUhX[Z99"<Y2pAUCFV5s)HGR9;3o:!r4gi`I&4bA+%+F^imcg`;*(GYkV+iC@.&%%A0+Xja5I`k<>`Lmk*\[r%BsW^YK^)n"nd<%iMgKCSbe:Y6#R\-!%*MORK>0<^(3IFfr"L-7pKg<nVYU<WuAH4,PHI35KbXpi\F]rKbQ6)F-'A$+!+]%*O;<'8Vkd3o25tMaQ>G9$;^&67l)e6\%DMrdjL,\i:AU_r%@gHLGsD5'n*qI2qfC`-<>[\0(A+)<rC_U0HWQ4+:"mYX'f"p>k6_Buh^.M6"t%m`P:>24elVj-k+'WMcMfI[sK!2sX4$'o$:@8aYORnno5liW;bu=7R7X1YkC#;I,m1dHKE5EIMRD22/=U)\!-I\h.LG9$2"Z_^7mb0$n"F3T,s4etT^Si<IJd5t+uE%AKTJs5I-2[DU/Cid^K'E*qE)hEUd$I&I~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2009
|
||||
>>
|
||||
stream
|
||||
Gb"/'=``=U&:WfGf^Y/r=qE;mIV?c]SYM$_b/nbj50sufdH21,ZBJnKhqeMIOGXRj)3<4$=;mB,!daq)Ib5qKMB>n!%hjKN-SU"fJ8ps(!%*PUa`BG3=<B-3*UZN=M"%A43=oQ]5:C,E$FW;ATDK$Zl4VF.+?`1VchDtM4P+-s'FI#dYS[r'TroboGg(=nkK80<*6us=,dub.hp-0=o<`j.EE_hXc/sr,E!0s/10TAWPhE2KP6A=$]R9GfSn'WWL@ZsSiOC@:33V7nEu4PBNqUXO.P!B1"*L<s/5K5KmD.ck-4dJcJUY]eiP'E#oflM(0H9MEjjPD-Wa>q9-k/EMFM5\:m?BWDFk3B1&V@U(+1El6-#did+W]f4@W@ko[mh-;ga/1WD]n(H4%^.J3d`?\].p!jA9c!S`j@3?no:1<K@)8Q1WDE$#sra<]8nOu:Kd^Z)F`.X#qakX&`XD^A38It0o.s!k#RHlrFr]ir(,kG#-^J-(*\BJA!CqnE<-L@$IAG2D3KU#+fni/Dm'9A:9ED$r2P4fM9UFdmZRRY<KO!92&$O"'h%R?D\-ooS`Nk9V+S]S40-`epsbs(-RCV,A`2UsBq)N*[)lkGk4Z\Dag4k[O;E.Cm4B3fhAe/6cj?\fa=)ds)/QGb-u1MGaqNWbSQO<_S*W)>fTI<+'75k8#_u5Jh4_dQ`e7AWasAZ]Cg!h(A3TOo>LQ<Agh#jqj+3>cWr@dnU[*2Xa0\o;HWqDO?69::I;S0AdIap"6;i>@DZm``OkMSNR$BRLK\PGH?)D3f>lmW`.EHN8npHs?k:4U%nR$!lW5$A45`r)E7'7o;:;7@Y'q@2e-<ZZ&_5t^-"_D".Q.gpK#a/Jl53L!q6!Q`9O7EnaR]*6PNm;.B-r+r/Dn!W(Il)#oG02;5)Jt;A=eWtN:4o:rKt3(Q8<1I#AUUo%p[23`Cm:OOg-?Jhdo%h*hTrMrl"/3r'OV^?MdE'[A5ugd\E0=Cp.s,m.-]HT+C3Z^mc16lZ8ffa,0s;a62Ccp+0Qj=L/.&$WM\8J%E%m:&=c*+WPt^6NG3bu[eHFQgsbi&7d3m#C;laTm9RdLVf<F=_G$pChAkr**$(&:T[H`#eZ:>KnrtAJ4DF\Of\Em*+_ghBKKRj@V!Zq(+QJ$8B[Se'$u$Xem?Q)UeqQa8cOD<dO4SoO7284)F@3ms9sIu59-K[j&ko4-K)^R<K:W]f8V3:+!aAIJ?kgQKW^.;'7b&J"/rDD7TKD_RmVjppoqt6?L3"1P5nZTM*U$g*/s(DL<7+QS7>,:ibc<3HpVp]j3a\BiFFO/.bN&>lj0se^`bcKQfrB06ScRP?^*qCJ7;([#:TmP'CbYM8s!7H#'"tUXU:2)9J#A0#`GhN.djUceRip+&LBC(pZtTEJo;/3m`_@]5bt#be>nqf5Nn,W$g!,1&C"l^4QL8u34V!oqkb;AqSTW0a`EA`a=O]25du=h[cehBSI#Sti96]Z8p5rllW(S!+$Eo?R9SM"KdE$Zk7][2]r4X1u(1H<QVRf)[X=+dc<\qqhnp?3pXq<>e^3JC;`BFMc:8su)^&1ilZa'aL&E)La`sP=a#So-Q'%fq^kq+(99>pNB&V&sJa3j^J1@RQjEVB3cak&K%GMKM0N3t-DfQ(fslW7!a3csF]-rlXKN[,W\<nJ.io4!71#ue2!&$I^a+?m2M2QSX<raj;nRAhp;gCEL3kTmaZ1d=ZnBdC&e7XJ>YXtc7mX?X$[r<>c5*R"V%Vfe1WXYHWKlW6ng:"F@.kf[,;\C>Es^683*#kE@C4iR(Kgu@8;:ooi]HhZPZcO%6c`Hh+q4N![Bf'%+@,DkS+g3sfXRk3XU9b_VC_u:<L<hQ^'>q/6U$h75QA=0P@0^_\rdFgaHId[J@<$<9RCgTS]Bqe@GemLs:nW(`k_(/I#LR]bh%Dt68djbP2[qJr^0kdkHm]!fY<9r6lk7+Bt&#TDgWM4jha5A.4@B8d=\!dqRlR4ee]+0($R6SL`&'UpHEr~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 627
|
||||
>>
|
||||
stream
|
||||
GasId9lJN8'YO<EN-Zt=qJ&*P5IctF7AU?i'RJaO&pL[DCmH@6Ol)2jSHT/$'uqtVj70de!"Vd4n#QTI,e1E#&/*(uKHc-2*q'a8K9=[3W#-4O*]XOI;?*<BYleH0g.<Z"\KP`&PSI`2Z]H?V(h(d94C'WA17B/_5ZYhWl9FhadSbh%'ua@ght&4E[\r:Z:[A/-Ym7M;6;3/<:a8D6`$RrOiV^U6a#gr;GJ;=Z\h<N`COB0)p`cQNp"e_Pg[<hAkBQ<"Hs%OAJ*:2^q1dt)heRkJUa#&f3Q8c-/OYOOeAo3n([+ZZDLY3N2HUJUf,79*M/3`W9Wql*0bR_N*CO,O;>a`W(/uUuLU]^jPVJrk9<;[Hnih%&/QL3:'fXm'S_%CgM8aH_P2LA2RU_Yn$kHt>fM.8>#"p4,Xcnp)E%JWsbuZU5mZfN@Ot":ZiB$)(Vcc5?<j9DGI2bn"FW8.+0'6[lT589*a@RO4U52JgV&`1;gNQ3[*;&uOcT*aodCXG@am,l5_e"P.S*.5\.s1ZkoOn2YRE$3,ptQYb22AVu3VkgoZ(Lla#AF8'E09s@Oa@,SD[N+ba.mQ.?Qr;#ibP)C3T4Vh>EijbS(B(_0:kp@4>=!HEFS=*~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2099
|
||||
>>
|
||||
stream
|
||||
Gb"/'=``=U&:WfGfZ?fJ:1>FNG_,9Ma#WP<9q4aoV]EP!,Xg$,$&4-"35<08?D;7-X?DQ8i^;]+I(P;Bm'd?0-N;Hi1X#i*:#Y1q$C&AK#1OtX9TS0"eG*bijtb8+J6:U6Hj7NuoFI1kOb;[,f4R8NDL-t_2V=N4c@$02kBS-ATG0),DGVIICO4b?_PuR9pWe/E\;[+pRlIS:hk&`ja0g4WEEhg,c/oPQE!1$;l;9.&R+1XGQtESoiT'/7*6MA_iEXM>^8Oq_Es638H#R`0G>M:2V*kH)`0El)Obm=+KBWP5T5.R3I%/fBq6mbiIk3(B%cE[)U,(r+:P(RJ")nHHT5\'t0UK2iH6t#*AU<R$Xc%(!\2`9L?/((W?k-m'qe)nE4CXa;+$=&G*G#oL\SV&Zl,JBGrsUP/Vc6%VO*hr144%JAZl.=CcohY3_<s?A.))P$QY$b=p/45p>\]D\9s8(h\Mn#+H"bE]3j/*nj8PD6FSsVhlk800aM;ZB,`GZ6!C^7O41o_U+8CQ:AsmRe&+JBnh^@bC^<MI$I>ltsr3o6DndW>%"4]9RhY1@dZ[U^:eV_sF=hc>rq&D(Hl7#p=AtU;"Xk'I0Uqs/,q9BlN]G20:8ttVkMN/UrK'$tR3*c^f/.)];]G-M'#-rdRM)nn3mm=9=:Qoam=:U]n*mM-iXF&XoB9FWF&>V9r-3CTjA>Q)X`>*hiO9l)oAQHlEP[!KrgrHaB.I1;`*Y\c_Mt0b3.PJ3CB0/OgA.XTuZY*K9ZhWRK[ZknfdnW=LMA1qG7Z1(h!ks%GDJ\-<(Gd/ZY,/4HfepGY\DqOp<HCuJ'$$_G&1j'i!gOl3$?TU:b+!6K5,+/MZLPU>3Jau*AHbX8XXh^68K)$1`ml*?T5Md>ak(O:IaMEmPC"C6Hi5&1^!l$dWffIWBp-N(ZRccq,eoVLf$L\@r@T!X$=KCdnn$@`a6^OuD&9)kA)$=h&78MUB^2_gDAM[<0kB0i!3jL5EUC/'NSq+M<o+.HeS8^S6ju,3eW-,tT\*(fRi$m?C%k!&NsA$jf1u;\@Ebne./]5s5rJZW5)=-7>Z=N+[2>1dcF.gcXWNHFbbd=,]nig4!$Ou$2S-2&9N[3?c#(l_O#@o!_VPP5,jAX_F<G3XZ;?D%Kh,:R^cI#uPd=)Sj4.Z+?<gF^U"cj1HVlTFLm/Z`Wta?'c'V6uS)s+Cf/`s2j=LEO2nD/$9:QGT-rFjH!]qQSlf(n^3j&=?rth'U3+j!R&;L.e<Hl;";[Ro$D\*/Vq[AT=GE9I4@Bu<)/DJ&XPZg9XlN\#fMlZ(u``EJ#RQm1E:2t>]8QfBB9]Wd4/*2%+ZLJEfR8tC@j.*NKO&e8V4o:17^j81J!FF)9,Im(8BEe-[?m!rZ@<.PH9$1uK^8F0G1.XUbk`;)Y.]bDb#I70qg2l>HQe5!O!\VqEL_[u]Ukob$S.AAT+MrZ]Pf%g,>,YBd5,!_281?<n`(?Vd..^&TVn,dW_FQ*H=V;BJNUVemo_'Gm=Vg'1jANff0(N\f0'up",URf5Ji&1p(c9_JSZEOqF`"l(e&bpE[9[;NcnXlN4IIMQ@!Yb-8Q[<nC=&Q2&C'N0=ik^+ds6ONr:_J,X7IGNd@c-5U)-<_T?K1?#M=F3a?a"g,">/:SWI#1eL\9BKda81K1tFK9;p7Zi\q,V?,aTiEX8#k#361&Vpgi$I4B5W0RYo\@:C'O(#Ei).dBsfbcL6Xf;:0XXO%FOC-4Wq=KR)\0^msA%MaY2;WtGrW9&>C,?IOG4eK6E`PGe>Gd[fJaFS;Uj&/%>^U)9LHO8]C96@fVE@u^:/L"Mih%Wd4L%scfCU5&.I%%I7+jAoKkE$WY0oOqoL%qOkZ#CgLUCK$ZnXD)c-8Ke46h.$FAH2I%OQtQ,=j*'Gg8]@-dlH$JOD9<j9E@5)?lgeIdfig+)aVNhI$%D)CnqO^XbGS=oNaQBOBSZnF#]NikAkRE5gu4N#D&$4/t,[b^HXkC07uBWf<S]diFQ9Z9EPO)aOP00?QOm3MQPh0d;Bc=47,/+9-rlT]-)-=oCh8?j52+tNoEYc9D(qDi1EF$K6h;$*Oa+Q,+=W'k658k$en"cnc~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 18
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001531 00000 n
|
||||
0000001827 00000 n
|
||||
0000001911 00000 n
|
||||
0000004132 00000 n
|
||||
0000005614 00000 n
|
||||
0000007715 00000 n
|
||||
0000008433 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<cb35ba21cf1abe27c899c350354e0e11><cb35ba21cf1abe27c899c350354e0e11>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 11 0 R
|
||||
/Root 10 0 R
|
||||
/Size 18
|
||||
>>
|
||||
startxref
|
||||
10624
|
||||
%%EOF
|
||||
271
Les15-Cursor-Vercel-Deploy/Les14-Lesstof.pdf
Normal file
271
Les15-Cursor-Vercel-Deploy/Les14-Lesstof.pdf
Normal file
@@ -0,0 +1,271 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 5 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105902+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105902+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 15 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Count 11 /Kids [ 4 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
15 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1912
|
||||
>>
|
||||
stream
|
||||
Gatm;;0/3d&:WeDlq;!>p7b.\"PfK19A<-eD;)Sg4_1FBZ"t(`j8sMYqt7E2(pG"oij8$W/Wb/MPBO]Z@6jA-b]gDo\/<)`f*iLOi<5N8*#QDFX.f48,$]*5EIlb+[0$maYk8C0WKi&cL^87@^o&7D4U2t!&`4+Hi4.)(Qj9%4aU="-nHAN4#Dg]2iJYDB2A)ISCPTDd"M=!#pj=)g5XZ>-L08uP(sZM(W7n9=&B\8(L#E*Y5(*9O%<'crk0Zl%"MPY>N+N7KEpgd%qL"CfEF-^@r$BrM;9OYeMrue,KAd4*X(C1.3,p[cD>Wd5%a5+SaU9sTc/+-&3":GHm!QZ5h&$d;$<5Z:G_'Yj=`ePk-#h\WE]ghdAI8_R!lCq`G?VU3I%*')!Kg@m(paNk?)H43f*&?A(-l_rZ(RUe40,PQ<pOp<<R0k[['<(>B9>V;N!-nrq:1P?ME,@p/3IO]I%T/LSR\\P->@k_NRM>!2O_!9^Q"e2K_j3k)sf\cB%>2l.iNatW3OrUBOd#[cMfbGD9O:Y@m4[Y3J>!q^foEUR!tBenEMn-&@tstZ$98#XUN2VB:d@bn1[7&]qCq4-?HA>'f&E.$uW4hGR9_;/6<_gq'NF=-JNZOR"H:=YWKN%,iDA:L!IDd.3pAMPF9Q$8Ad-6AZ!JJW/,"&_Eo"W^Yolb0aF-+*4_*k4cJJX;8;]m$O_B0R[C=`g(S4YE-X7uVa\ktboU?Te=beY)fF5*fIT>(j_('80Hi;II1VYU6`C?<ro[>/mAL*15J9:0D6n*6O-A5)4,Vd3'Sqgn7)n7HSN[O[YD4,H#KXIYMX;Jhj]rM]O/(oSSF%8N!Sj:"YZbTlq45QHrWhTR6Cj1DGV.D!k8fXD4YF]a[T:KaeFITeI9l1>1JHblAbp]A#'o0B0p6%;&pY:dj7"0.R>rR=ZH#i8o^NJ8;$-Ukncj-i6^d=Oq6]1i-5m[HO8DK*4%.Ok^^b'7@D<cXr!"h2)l!(8!KIFXN^o([0+Tk*m5>/u>0fYN>7E!t;'JjKU&q&7?Kr;!X!S1AW)_=1S8`=p>;`rCRNYjO"q8,+Ucn?(I&A^:>hV0aK*^)phkW$.s3l972$P:"5<+9rF38E9if.=Mhd8W`4i;?u_aFPEp_<H4`Pb4@,=1!2<C1Dofu/3>8#8ECk&sE)<b9ZZjtLBc]eX(kQ5\"0gootLP1`sZ7&D;pZs0G5;eIKAr+b4O]4KiZqM&FI.Chk:_ZTl\Ihh#sLG'u?iDM7n.Q-4ajY@1<):kI#VX9]#O:[LCj.si_F'.UO-ZR[^*b/JMA7:e?>c)3MC:!ad?,r@*[?A<dbi3T;nN`m*F!Q%,FD/-rWIbX^rsXD4I(acSIJEur$ACR.;'q46U:J9WV<]IUN(PVt72X:IR9JR+&%(+:g4*[2i#T4Y)1oJ)Bk`3RW^X%p\(R'd\ZAs:>\BL_Z=.HnMk,P\>Q)LWm,Sc]R`M^8@H*6O0TB[peWn*t)X'h]b#,U)kBe0@-8+/X7PI(\.a?rC)X^T`T-W\C&QDSEc]T-=]nFc_:<%Y8E`%/#?jg\m5_1:)k'=KFM_Ip';j-PC`5AL1K9Tn&?;Kd4d+^#P;3SA4)WN,p?DfTe)BWMQk!@<J0't%[>=_T?ITi6PM[:U5@ruDW1tQd@,SA_0g87m]3^g:G:BHFJ!7c,_,Z%jZ3MQtP3!hRV%leRiM*8ps?'q.G?ul*Og:5kG[um<mR^S`VD*KeDNi&[V4N)td]3t2n^i]IhjMr9H`To,4m_X#`ndp6=*(_3,KW#TX%W&&:&?c>G,8k#f(H]F&L,/ZJ@#ecV4b`;(EuM1eH@S#=._a)BSememMioGG:!YjoTmE;,`D5ak0\"S@"f$Lqq:PPD:n0&cZ[7[Lg.Ct86.Dgi3&VFMh6$/?K4+W_;^I)0~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2006
|
||||
>>
|
||||
stream
|
||||
Gb!;d>BA7Q'RnB336UYU?s:O409TI]B?IXsB"8T]4QQUo;%g@HAS``2l1DaZO`,5\*>jO)9i='1o]_n7*1`!:=SSWPru_Mj5(p`[$C&AG!n8A'$t$ooYkPOqR)c_s!jRGjq[A:!Rm4uk]`_T$GWt^J#`BA4*H2--6MLU!Y2c$ma:q=>oI(:==^3pO"EcldXf]'h`u&^]&D-OOeb-=t_MI@gYEf[;XPO5;1MEmiMeY>lU+UXu(dn>j]?d\=V4O\LQt[SWV]o'UXFLmm*0+>G*p%[7(MXB!C7uiB`[WDY_sd_^:X!,cI)f[67Ifg+^K*?k(\!S(H;_SNa_@,5?lbd3aCtb;Z!'tA$"DX)7pG=*]OrHD_KJ&^K=Js,;Veck;3,^+=cV^)0i?8;#j!,nNk!?(T\#4_%2G*GW-68&H+jY<[S<e_XT6U2J-%Y';30_?FSV]^!a4nC6-i_,&`K'WaEhj,7S!'C2sZ_mAR:Yd#;;&)Pg8S,;7f?=ACMes:hBeu>[+_41`>]5L!Y_>eS>EI/+J&)f("_i:`oZ0*ra9(Ts@)&dFkQ?_?A_ql[iC&qn0c*?L&&-]=["!"K(;N/.\gnA3u-QG;dE7<<?pG0n_P8*-sUbSfOhASE_dW?m-0h<@j=gi(%?=>Y1@"Kn?Q\,bY04k"/C&3g!9AW3Cfq3A/eF9L^KTN<]@->%]Ni&1`HI#@s:c0Fj?K3nNu(!bU`QHr1d"@=hF@*(IA@&`6sP-'7>13$n<tfh@DN3ooY9g6+D8d+33"dR?dmc]sC-.bVCQ)baLSFZ-\NT,Zs,$pe#82k0IA&SbIX+7(Sb<g]_gq]j8Z<[%!h\J,QR]@T)Oj"kM8cQ&_-%ulpA4"U'hf3q?[Xr7]r+2GKf-&6D<U%AAUL;h@33L&RA2oj7Rast1B^Q+CfYO<_>o0Ji07;._$Akn<Q8#Y`N;TpneL&n;+(L_El6ZH`:3lRT%^/OPXH.++S24ZrI=dZ2kGb_#^<4`kCKmN`[RO>?KUWlD[:cX9P"*FCPLh1aT!$Cb.Bbl`\KR(<a8@b)uITDOkc7kjPZ+S!trcKhZcANQD-k'Xk_Zju1EWl9EYk7ukl)@6jQg3^'UDAF@&<"=*-E7>`S\4YMc`YC`aG-ZN5(.ZmP5%^BN*r.>WM3B-X9nf[ld"$SA]Ug/Y[g9?LC9N(nm"Uu-SN73;1EeC#6lN;^=pfKWRfNh2$o5W^H,+7FfhqOXHMWj/4'Y?Gb-&r.UQgtCCG/ekGQ:Lk7W-*_c.DKXP7),1!_/)ZEb5geAB':qlf]s)5q@!Y[JcX.U;#*_g0eE[:<([&XHS0*,6ETO'%F]]-F&<'D1!5arMZgO-WW0)"nQ3gX(IL/uhLF"RQI]/\d[5Jd^!%fiiu1bIpSJ+dP/k93LTR95U!()k.0Rr6br'VOkXPIRu;;)8bsmErEfSdm^^\HIqqS::<'Cm:lmB"ng'h1;?D4[e,<R.PQ[X==9km,%L9[Em2>@FS$DfpRkuJj`@Vg#p@SsHXeRs_quT?A6;@7;1B)`2<7qiN<8T\<YT9IF>-6_/=D5<ci^oh(9AKA5mMnr)G0D3lp8'b69Vf?DtNh.h=H%6mGs1;IYdrFrt!V7kY!2=<+&dhW%0jL17aF$O5>Vk5l)67S"&Kd??gQdgNE!F<q)M,k#l0g_J[V[^DF]WM;nlX8q=P0F'VPb?I[3T50Ir&BCi-a<7e+p$LG)GEZPt"i[&5XW^cqk87a..*:ENILM[]B1FDO88(mYpma:^?$C;U9mcB#Y))i(DmM#D4*%q6$2&hc.GL$J*@%YR,lRJ[!,G)g-LhDlJ)uhid=*d+.PSMadcP>S+*<Z#?)N/o)8(W$t^VHS\mqhr1$`o?6&lqA&%^@oCeX=p)126!8C[(LrGd0D7rGEA#k%?+iS/A(WRTq99VP4C<a[2;r;Ff%C%<Y'B-"?NAJ^dKh-4\0lZ"@4A0hq2Ol6(&BZZ0E"'HL\5Op<cO-%X"oH44<TXD$'-Y[n[U^&J*YR6H'~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2085
|
||||
>>
|
||||
stream
|
||||
Gb!#\>Ar7S'RnZ;3%.hO.:SaA,`9am%)&A9ZDL((p7X*87A4QEZK+W`QQjM#h]ZQ.E\;Oa%`+;N&h3B5(S8et0qe<bbl;nln9bLof*iLOi>@qP4;]-UX.f.0+^FNPo7=E7\fh:k](uYhM3EKDL^8:I5gk2(^]R@f,%W0Rj1ZXD!>Gm\:E0Qa&2]Ii&BT;C`JsgK&Y,V0O8)%pfCuK@P?PY]Lm<.^B)RKm\:UiAZDaor..[N_L,8&,4oT[3Dt8`7j(Y*_n97m2GZqq>o)P@&p7IU];HE&s?E0hooD)'keIE#6@/*.5lJR"1-U+Q>Ndmis?p<MFXR^??q_"<_=TiUIk<8[K!>9dVBL!k`>oMX(Cim2IU1:"b#uA@\iBF5Zm9JrGJ5GOQc5`1O<?>S"i8#9c=VEl?%W@_F9LIL4&D)V"=DX>SCTIT^7#VI2pSl(".;L#'$Uh+]fV,D_.1WGBX3K4SNodN%F\GKnhtlOAYBB3!i"\Y__P;0_N"R`\GC^p?.t),lN-ER$YhI@RY8r"!n3b'2ip2D.+i`mfrWcFS<RHS5$#uDA4S?nY']W=rnMJ?7gS.#*C89eC('U+#BMXQSpqq?O;+8Z8'76cfhkqah-o^,+-lRj9CThk_RW-2u-bM?bk'00gkJ!K[*=kl[NU;r^&;!`>#2L0MXGIrZa-a(/.1DH3$]+7c3@f''B4;WZ%I=Ki[0%G3JWu_GZj!5,/00t\[=PctY4f?U[(!LKV$itW"+"0FL*Yi=$OC%V#c0PjhI$?2Hp3q\kA$;qK?m[W;dTb#BVJ$t?JJkJ)Ba-`l['H])CR)MB/,>]=^37u"qt"MX"d+-NB,^DbKX]E,b?A02eW22Ss:$kit@qMoM:]NFVp0cRo+S#?Ira='S*C9Wg]9$"(Pds:Ipudn1n*dC\2P3>/RDE]:5KWT*RB;(ASRuF]%Pb[8>$p9hqHUm=?c9Ke*'icc/gE<J[k]'Ute%QNSr(%JFJuO3QuNL8OB?X7_1H(.G*k7F"NmnZ;f9<U[]V<Yp.Mg[ru))X>)L$N[BA\g[b(o=R%_WQOr&X95n\H?e$KU?6G/g8okeQ7=`g,pG1Wh:]u#5h-GSJif79Y2V8(+WX^TrP7"#T=m=>F7gPKdc>4L5s>OTbb_;%GKQ0.b(hdSQ$"at?::[$^ncddUkE:l_->+kN5Q0KEVaWt[Bf43f8!GJrFLOc'VdTI4,6]%c1%n]R&LEHdj(ejZ'^Jn2Au=@2ZB5'R7^D3N-@7/rVVD3pNO#\c]t%X]d]jKDl%T`K&3CRbEOLM+N]cr0g^43f<6W=03-$&]7;M(Eh)N+Q;g_Ep?.1`ra(cTCr0\h>HWF3`k5u!e98YJbIGk#nNW[$W)G,.3fO_8Ea@7i0gaBJ=rPl6(`Lj3=rPl6ck_SKZ6#CQ0Fo6u(7H<<BMkcEkC'_6oeCh1#QmrQT4Bk+M[EZJmXI)8X16m6[i[*djk.Jk76Z>oKo4mWYXn+o5`IfD<`/LYn8%"J_?dW@kpT[a9:CrJ+=VlBmDN^ucS`Z*F.GdsWa=]0(F2k;=ieW!BtBQQXH>SXrIa/4klj14\0mo$L;/-oo]nR4=t?C5_Wq1m2-$+2oSQhE_NLO.@+Ug:`EKffX)kH6[B!T3M=XLU2q(S%K`0A1H#T_f`&M=XTI4+Xp?)ZNH$"pLQ4U>dQll6mm#L+A85lqah*Gj7a7q(Z8VTqOF!X\VI2NdiF;MqgMR0&u?.5,I(3@`a2q2ij#+=(Cm.#qL%KKLNJj#jD5h,$^fAC[hf*lG^W,!6('F,cn^8hLp%n*R6LH`YIU<<*T42SnSnmFb:B[Q?6`k?Ug=,920_poO&O^buupmA3"eGlMq1PR?+H30N+(hpqu>HgbR!QVZ:5[bXZ@f=]3)@[VPqDbFg;>i15`j9-`2=!;mRVn+UO-sm6B>]DZF-T5JUd-u:oq&,3S>$ee?#TB3Cg4"[qHLhkj5\$#2ZMnI7@]u"!r=JLU4pn0.Z#DK=U[<d.P_1;ZeU19'V0kSR3)'[I"CAs1%_NIXsTMO&>b\3p?"emnbm'Uh8s?S>Bu\?^IB+594'oU@ht;JP+:'Fn!Yler!REUef"~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 704
|
||||
>>
|
||||
stream
|
||||
Gas1\fl!<6'Rf^W*6hCpFZd)e?LjD^N>a6<2qjTn+"6Y1/H[G`-rQ=[mq!B(1hcquI(B!AEu.IT\n1W]G/3DHMfT3(OK9Q^f17Oa2IX?<=%6=5K1A[)?@i!P]hkh6J_4r%Q'ZI,Eb@8:PMA1eTIA)hBi,&8:7)0n7TcS)SW&Oa:Z<U:PTjke@q5GOnUj$Wb4FBsl:>Oe&/]]6+fQ&2dTjlG`^k84\pFDL!q49SW3]3$k4mA(+knhU.V3UC0gD`)D;Bk\]Erd*05l$-BTA5Q6up^r*T1_8GYIZLj'RNZ1'T=Q;m(H!&*\CEUsK?++/u,a+JT.7`bHumo[F8gV3'P1_8`l9Hs89o($6tm]>oqGC\qS%[[_%Zg;(feG/S?nKfufQ]hcWp0?^ISZ0n:Y2UB(B?$CX7.gMtL_.Y!jBp1@LEphkpR)$YYPqb"Ma_<PG*TEu5VU(3<=>sCMh%.<\4G*#K=E$4Ca?e]eAuQ2\bY;VRf<nUlZH_t([`Vt9&$e%HKN$)qAX.sp\!u>bH&Uuk/8u9&?!>U"@!Qi7<p=JX/<?DFPs&[mE>pA"*+olORo<LkE0o_Ala.f9OV%X*'/PbWI/\dZ2PD<JcqI`=%b]%$MYbqrU)+FoauN13od%_3Zma+7dlS@!F7%TZkLZNqq@qh!2s%6I.uW*TK*7$gIa<M>24_Le&DOL8FE#%p!mdba'E~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1519
|
||||
>>
|
||||
stream
|
||||
GasIg?$Dc-&:N_C9_5'U8*g*TQM0seBp;)dl_q8OIN5!?K@!f=7Nd=7fD5*1?!tJY[OfP91,F8ipYM<L5k2bYs'u6$IL\ji)))QlCLE;rR%M0)mbN/A#<\57&>?%Y306[u5:BT>%XpZpLdqjf%>bE<8B$B_OTLPl@E:b`2-dof(m$GEToqYTo)f_Tk:]BA3DM/_*sBu4rs#)Nn0/K%kR_A)`F*AqR\X1f+UM_/kehcMUXLk4;ts=nFHrqc,(m)2;CCcN>]Ln&nT9.`<GAki9DG1S3\.WXSaYFPr!)<PbZqjV;KCbA]Vk9qF9Tkn]MuN&\dgf[=(r_.hi%(RY%\h'i!n%p.O#!)EA(X(E)5E/-m8[1^rGCuWGgTpk4+O0.TLDGX2[aE('_E9ijUh&kJm"G.E5%02\_rkjF`@B=Dd'uZi^U9D!s0@iEOmHUb34$ZQpUZ:h9@Z5W6n?NF(b(\+VANbbs=oUQ:-kQFm5q%6]#A`[Kf'E<-L.$KWeMAYQXeK[n9Dr7[&Nf_?';ho@[7?kMu'8c2/o:Wjq@9S.[tVXZuc3^6#e+JOl)GPt*PF\GnL`G#o]K*_"shf8mq1VM5i:Nd'qBPT>S36RDT&+L-1HEGX*UfG[dDahCi-(Ia0SmTaV^-A%bH3N&$?`3pd-TD?nqOo0coP</9M@4qqGLr:tQF9m4#d5%Xj2j!VCP3YM4K59!m"Sag#mceQm#S*r-Lok._tkO1?oF5go+@N-cj",bUo]\Gi'oD"pL'@-.b/l?]3r"8bE,eJ3RA7Oj54Fsh%#TL5\.DoLU\:cLbg(Pjs>DFfM_&!#gRS'fB'N`ZUYkM?Dgr;)D5-/KHMI:.`H7XD-8,dX,,'qjND@[H9Al%SkZD;TD\;,&3hGS9co##CaF)37&X:jVj!%\)(^[nllU0[JqSaIg@*:7V'3rYPg>hk?h`TjkGDZi^SJrO*SfpMp]^Lsp.=+ON7f8.gg6I<Wa1Q-8osbas5O6O;mjTdoFEY+-mPj8pm11cXS%SnP.<4J-_@pPjl]bMkZ'^fQdLL>':j^BeXJ$;[V<I%;8fV/8h'Ws?>K9LU7fuoQqQ&e)_r@rO:Rb@aE2jSle/[cP3QF[/mgJMMmP'\CnO1kJ>h;CF'KU]_PT,@=c9LAr@?07Xik-_/i^++PfEKm.*@7pfs56cX_eH,2)67AkL\dcQ,W;XfqlY\8nfG,p)iob.ip5-06qpSRlGi4c_54R-@#73J;Q9P)0R`TCbY+%WZM]NC5e4YeNdr^MM=P;kK+W(c]9bAWt#,Fr9#!;HN7-sUjh6pk+L8u-cKpBB5fl:eK?B*34jQb!0ise!0RrCBDgSYHEjIW<"6kmY-<[eKfXglh`To96`Z,>a'a;;i7r'fYW>;e&13fFjfecNcG7NPSH5>5$`<3@d2iJV_o$/B4WbJKN2I;u9lYf'Zmf`#3kNqNFPtM,]n(kt/Z6(9)"#b/^qU%2RW;9^Vmfdo[P]^Gfl/W([I;+LbHr,0ANdc>06ZmW'*WX\"5I;DC&~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1952
|
||||
>>
|
||||
stream
|
||||
Gb!;$=`<%Qpg(Yo6\0,h'/QEoMX%DHBt@d,dbAPEr5)"3X]U?Oa[t)k^Q5sS[OI)M[1/%iaR;=7JDunqs'Z9(F:>(n))*#L;d_4t@=/BrO0+HYZ+986*Y:h<-a8.)Nqn3s=W)ISnA#MN#jN)^]AO#cKEq_5Gg?&O%pHgAZ,M7gSGL)+,+-CSh`C3;DVL!$,p)6ck4s`q@E8f^Dl?#YlQ62G*NW\LKBft"1=,]&SAN[s=77g#O*mg^E1Z5!r*=%Pc:aPW[L[!eUU-\NMc*"59\lWm?`#;'+2R^j`fCS3!IWM,M21/o_nT.S0G!YMPkhZjqc_;)9EH<OH%Rkh!HG^pe>ImX%eW[8#6f!*-lCpQguXW$[R]Uk\!C.):FLA)@*KJ3/se8agVXc0h%NuJ)@i-Bb^euE!o+T:d(sm-]r**J>mQS.G+;YChm35-m/YND95)_EM?G:IOj2jFN</4927G')1>c:.VpC@i<6!+<BZstZ1.I*PR-al@\;p&HV)!7f(W'oADa"EqGm-DTp,7;.&M-2Z?%[X@dRC<)cHqd_paM251&hF&o5pB:9#F@(:M%&'l$Sab?SW37d*>%<=$lji[sXF46!UrUX^0W,g(H/BC0ErLWtZ7nY"P$;Zk7O#WtO<VT2dY+e-TYt^5C+g\0t<s-)Dr('^hh17fsY?8p$!P:4],d\WP<HXIY\f]GP%Qea$7OSK-uKTXEqd@(^6-\_#m8&3?'CK_bUR,16*IL6g]Tg'1Z3kt)no(5#5nO>;AHK51W?MG)IYjJYChqV5d[*1a"TnHGH"H)["fMrnjamqD9*06mVQGCMA]ESXIrn,CGG\^'fJNiCY#2;Cf_gaBN"_QD5oS=&uq`bM8n3\:iGm"kOK\]&\]Q=$_V1*00MUfMeM$t/(>X?q0]O"sh9""o1>R,/pg4g$lcVQnPu-HTd4k%Q<OW.5SI.$mSthoOjiDth?,2f\O4A*ak2b#ea$1Xe*J7V=)FA1BmiL3&bk20&c`dk]%QdOO"GjNYD8lT*t:L"\]s_:`Y<lIchk?/,=dO;luMdTTn"oY%^aN%*t/FjBoqJ0pkr1`V3@^5ARK;n^=<*O*j3+MF9[gTbm%'a\QXkn9cV9M1P(_@VCu.U><(mkE5j2P8dP/RM&Z.)?TYTWeL/dLp*ka'dn&>iHl5R^DY^mJe_mgl]jrA-Sk[:ihbb103'76jbr4<#3KrUm.U<(l*9gSKB>4]N=qZ2f-<)//u'Pg.<Y^W8U]&nI-pS<N_VmCS;gqU"j+$RUQr2YtrVPWarZg-B0RQ2Yp+IK'3@)GI5k7q=Q2U$2A^;.;(Ebq$uK?8/Z"Pe+oKYQVm+C)"c-X!EJ=K@V]Z/4]Z=.^0b5'FDH`p$>L(E9ANkV-e,!lE(.-\6EB#*';'9jX[':0!=<SW]%<m3qlJK#;:)EfW.&3E?XJW*CYAlc4YG4`MOk(,e(U>\\`nl2qd0S[9)`p6qeIlARtL#=meCSBC"m8`!W:H,_urG<3-bZ&[-8uXj[5Q/?H1[`e!^O/5iF]@GAMUHa'=oFr"d=!6<moG.W49%S>-g3fO3:L#GcX@^4aEorL!JU$HZoUnt\SBDB;Tsj4?!]%3E1!^^$>J*umbu4q/"SnTd!o!m\C\_2dNV<*8+EZ.d[JGI<Y%MU(3G=q@gd%#^*C?=?:$<EN_kMO3I?$r<^5B"U#X$40H/_QiSd?8oB,F*KqB+<9#s1P`OZ.YbPu&C,Xd3"GI05*?*o%V^9C)Fu#84FBkm%J;rfkR:l0+!*d]4iHuu`<uuc+K,Z;S0"+P$$s'=YQs6ME)L'<I<A8@@>F5"oGfcjIP<mh])((rgT`@rPE8>G0u0T#U>=(galmnE%<f3@^M['JA?ur)5I%p`L9h8h*`I.UJh@V6qT8u-M-ACV2DAs9/Rh$Je]_Rl*@Olc_3J-bm&Al;@$.@s6g<-;qc]mm&7GGE=&9f,~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1559
|
||||
>>
|
||||
stream
|
||||
Gau`S9lo&I&A@[:m,ZO'R0k9+O9F=%e&H8dC#do^Ep3`O+UJbfW$GrnrUjIc9<p.;DEH<NZ63Tlmk&]!D]oHO^1d,+('&9NE.1B0176@<El)8]!T$Pa5<Qe,(@r).+T*?j5a&q]6+QInKFs%RQ;A-A9LR*U$VH(C">F'sa#&NK_>K9_J]*Zm$P#SO0G;cso`9md6Y]B5-qYPan,*jE]nAeEGN`Z97PM3Bh51D+J.nQ0E<t"pc@5TakrnrX(TL)of&lI+?)i!L_s`Q'E:JBt;,t3-<<b[d4-ePpThPDB9NNorj1#Tg3G0U*pE&=D:_h=gE2[M5Ddfj265p7$!R@kcdiKoHSYL2?L^8Eh\Sr^iD299p%sR7?GJ*+*/o",7KF>uVB5*<&-1"UF<Y'usWJ;=ETfAis0&*lg')NA=(a$'L?G-g2a)"<+dZ0fB2"mu;5Dkt!rr>#jUU^6cVFGOXd9R6l9u1:Nr$F5$/(fLH79D+B.@f-l)-L^$]\!9,Of$-Eo-kIDJZ7PZoo'TJB*_Yg&fR$f<cnRDRZ4BuAY@VT9UQ5cMo1o0*R=Dff[rf]a3BamDbnB4R`i(H-.k;\b7Nesa0Y>:Oi_A6*UIkXIqj(6-YliL'kC@93-]c\3oi"+"Du@HR[I>0PJKT11nO2XaAk73c#3%g7i40sT2@BOPa2s4_n]O!H<[Cf+I-Oo;\N<iRrj=e"*uKHoqJftFSB6a;M-1/;sDN0AL[3j'=q*gma7jFJtB^qU$OIb0CVlj2Y5D#=tlQVRAj)R"^Ks#S[IPf,HJk#-lLd?PH(8EIt_nk":J%i)"s#`kp-=1$?!"/'#JMtSRR'kh.$r(NTM5rglTQ%Uo9ZZHH-J.1)Tpn7D?&W;bnSGRh@6P#$3U,Fn["f-bST`Ti4aTC"'M[7c*>=@GSP#^tCe#N[I$K_5h"@^ppX`=nkhO!JG3_;E`WHNA`:S?Jle-rubctl;Jj@V6P%9lo'($ptHUh">O=nd]a;Jdj2e^(rj!u3JI0'T$\,4'.>saa),5nF1K>FmhY>hL?aVOci*WX7eAU<2p;H,!U63fL/QW=>1<P!<DSM0ELe%hO-3V$B$)4Hp^SfC,o:psr4Np&P.dLbYGo#'SogNrlN'0]:V/kY&k6"\N:%CoqIJ+X.OE8[$+3doe_#;(j6>d6n@*K+Nl)5[*RKmU1kYfmdg6dJMo4a4hap)m5fD'3n0I@Z"qQG+Qi,fN."PgHhh1`OY5c!QU4dKG3nkFgcVgo3TjXNGCj-4uV)29n>us%)98+@Pp`;.XL#kVI2Gn-[bTWk7e>:-EQ%:i_D=%Z?Ap?*cY&h_0k'SBiMU^1C&UYX_.#:;"J#W#XmhR=Xf648'CF6Su<Ah5B8kl\/)c!5FIAaku!/V%Jl^_ADcVjs!08t#nG^1^HHAscSrMCo\eNUlMl?oL(7e#$K2M2@?;RtOoo5inj-g`?"es$!-do$l.]JER<(^f%j.a+n;E4rB>DJ*5f0SXb'+"ir5F,Nt`V.1knYB`!3'jb6X=b#:@Z@)$nf=%cG,B)>!lfbo'R+^j*OWo>M(C"&T7K~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1871
|
||||
>>
|
||||
stream
|
||||
Gb"/'8TWWE'Ya0?]L`WR1(f+l$^g0h`b@>,4fRHkm_ousKI#7s_31&3o[)LP#O[a$=`RgB;TU/HO21Kj]Yt:-ITcUgV>tP3nFA^E`aLn1AeQ,\VsnS=rGE58EmJoZ#YB`rJb[*0TU8q$3%f3!mPBsS,LWp7QAeB!9Am>+o9]c:K%Y4'!V'F[8K3IrN[rpiqZUt"8(KS<>h<??ho[=+GWp+OktoUd&&E2gCR+0^KhWt:49q`4*.@\FiWMI1\LPLNed6`'$5G0C\E)Ftj-.T)=iP$L,'^/+<2/t`A^rT(?GZ9EiAJK]D,)&bD\`:OQ,"BT93LL^"+(g-j"(0j_YK-Km=8Hu$L1J[KV0C_@].$oJVK<(G&8$nM`[`Fm:hEr4[Q,"7l2jQ<An9WbJt<p>8(1'%<%VEbWthH+4p;uo[naQCO-"B6AP\dpPHlY/K8$E*f0Mp6Al=O,l#^ofl$6.(hk$p$`f::o5.b4o"BmN3d=pSg[H&jN)@G<irU(W$L,XcA^8@MKJd@J^Q.g>)W)q?2SP"Rk%!2Li*WE,<!brB:\8iE:ZYl5i;0ek"EX:0q-/hs[$$!D%kMQ-XHBGk(RMAQAJJj!aHN9ckotgPlN08L5:U\R6D]9u^r;ZTjEZT&65c]39(3\)6B`.82iEW=,E[2=nWjBmf4pou]i2ncHgLg9ZL?,Lj1-E.[!pj,XLA`/doj.eJh&l,D`r#C,Tb^1D7>U$g[$V53lmk)bAmVb$8Z(m+U`l'5/c!ljk$UC-.ia&'Al/)qgLT^e0.FNS\jGF*(LT1i&>_(P30?SN.u;3qNc,tMsi_ajU\)bfL+,JdquT!AC,=,$TRuC`d2#tpB%L]p_mm:K)eD[lPSNQiI`Oln"J\<4Sb=:-U'*_1Q3ei5n`''pi:h0Mr!Q5H,FX_khp?f0&L3q%n^CEl>.6^+k':N-`qeY.?C,t^6K$UV(fFUA@JX^)H8FL!diue8'hLaDa0ea5`hYT11[S#G;H^^95MXc)?WI6`*atC?^lkM)Zsoi#]E=3C>#31FC@<aZ#!"V`duXKC+c2<ou7^!XU2,$k4tl?UJ;9KXi%1N*4%p=/k*CNQ_:6!eF6jl"&I(eCH@!nJX:']jIcSqA8V&Og'kG0gB.J*m0C5%47J*Q)973I,E=>YX,D1njXJ,%R7!1F2YVgZ<nee#n=$Z?XG92Ro='(5&B#6PYA@G=quD#9DIUhP9hg;1!98N2'Nc:tMMp<[*)12%kV<!GOr$.t_<YbP<jBqR%_E=blNg<u2io\VU]l_L[k,om"9`(n7#+'+$[o9t`/X:>I";Uls$$3tqdB/*mXrhd@^p</+]ehp/V5NYg_gcse&B8A<OaU^Bb4NNj1Go"I]OVCdh&^LBpDr(<_eTqW#s@2f8IlpAV@[/HADOpM4EP'>pm0n9Z-L6@o(^-1S!Xo7D/ge`?b$RThj`4KaqL4i:Y0J*;'pkPLn2@a.>%F]sKD$bfCV0pp8q3?0KsMg!KDKD+hud2ObhuHnLJ_e-&8od^I&<HhhJ:X)Vk-Y6qD44]=YS/C_j@"U,,%WnQKcE'VOVLX$QtCXU?\Bk3V_92us>MfDnSY>h'`K?i":YHN^HRk\kK0mW%!4DBfH$@]qep@XW]Z5"1g46QXN>Pp(Oi4=`Jju<P$4U<ja*Z,tQ#<V?@$lYJVo<uetQ-Wl@k=3)oRPiB+&#dACl_:JF3LjA7f7V*OaC9l!4\;*I+B"COSo&DLEO_0mVt@dj+JmruiUsk1b:J8*\S:f,,)2eM;;.9f$q^&(-HrYZ;!]*fl/QJ?n2a,#?`W*g/T_UC:]D<55%caL3E=%Gq`-nN+&Tk$kuSCt:GImWNB2hO^<1DBc7>GM7HN$9mkqF-=FUD\9N>[~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1724
|
||||
>>
|
||||
stream
|
||||
Gatm;>Ar7S'RnZ;35@5`D.A<r.$;YN]<5EAFC!Y3gOOD`A=9lkaHA&Nl*as66cl(`pnBCA*J?2eo:>c4$n-R-o0:n.HoH]$QOF^dfb0Yp!bSXp`Q\5?&KYsA![,4OI`-e*^AU=-no>"&&k4umTVs,.$4r(,V'6'_l(hi&8e&ta*WmTR.[:".=:hM%JDMXskK$hg+:P%BoK2l5'e^ci:-')qrBi8$_0/F2<*p3Z!ZAdi"-YZ.rGi@NMNt?-F\u]=_3?u`)u.LbKP/ocB2t3t7PE\HIAaD\]@"07"LB&74gr!-pd/p4TQpA/YB:""!*h!P/6gc9nLDdnL^#j-d&%g[aEN5.Weg+BM7trbE6h%l-sqWOKT4"SdQY7Ubi=j_.b%jeX5,3jQjie0-g8i$T%h-fVAQX9DC`SF;#h*\BC-Qa5dulKJDG;/0mq1@Qdc%5a!L7h01CU[S<,gAVoD!n`*7R_9\F@MqTG3`G+.=uM;![(^h&H;Xr-na+g/!Me[S9_r'FM$k54HE5A]g6_@Q0R7'HsWFq=V.e@_gEn16;.O]V&MH7TQCLq5RMW<_hJIX%WM/:*M`[*;Z&d,]o+OTg-&4>1`=k&jO&E1Ir].V$[%j!.+G3k9r5)98_-\2M;1hdAK1Z%X,+8[6VFISB?T'?Ac@A8,r>$-_]l>ED"qNsh"E"6%S[o0=,T^;bG^:'gW"Pdei;pcQjY%kr0C1>3^W'YT&da/s222*$=iMX]2$)c'#X(+cq]GsYA$fPe=#((r*q*:Bcn9]G`Q0Ch]QRGU9?]aud2"6dOG,hk[]!k3eYr=>Ae;Q7KJg4ReEQd$f+lTff"GP&R,rSu:!nf_a16'!<iA@Z^(98SN4hNkloJ?2YTY32.B];j%"-%Bj=is!q`"Sh'$"s]0fqJ&5`N_p'NW6\YU7=I+7g.+6`C(CD_lR_q>e=UJ]R5%$g-K6l7;$+tb361BbY2K%KSE><[O>G)`(-'%YHAirB7*nIeO<7DY";p'_r6-T-Os!XSPFH#fN<mKidA5>gW2=fuOTZYV:%imtF^@ei:JfC'gsje(2JE%$&:otA=QD`O.rRq3`JtSQYicEKKt=Q*N8*oMfdK\Hk38ER_.)'\,`RglD=d/WZ3?b!g=4lTGQ%jb%1!>oTjRkm0j@*]_S#$V>Mq\h\u\eFd-*SPef?;t=Dq!iUQL%pA'lC+8/Mp^PiD^CM*M\("M:RbjZ.IPE-`Qn"=3W9\qcEdkg.g'kK/.E8=st2B*jO?Q57o]13&VrFLj#]Irmm!MWArKApQ#IM6[f[#uoY?8,Tu'O4-$NGALIPH0@<Gs)#9\`WfIiY3XMd/l-Xp2d18rd[iC7??ZjsbpD0$,!($dTmerUS/cTGLc_,Lf]dYa"`&naNmf.V$dsY;"tc4db&k&>AK0=91)"p0*p4f=Z#I4V[gO`t(\Z6WGKAgS"C%g#8qm6<X!l9.5]Q<J`S]9mISB2@:$K[^0K5r(0($$URr$Q$=iP)rk$LPirsNr2&(C>e'5u9rLIud8ikcFd-82",)m!=IZMOM'2Q)H[i@g4emCk`tME/TkR,c$60QhMON)a$E>jng+)"XT3_/D4AE..W_5b*%rAos(-gqIOi8]T=W4E`P;e!ic`Y88^+Y(05,D"*a#Cdr;.j)UW'N)+\ns"`Z4!q\&#B!d,[5(9EUrRN]&'lQHCB*L[`<V0KLaO7r/Fh,NXaMN(RM4JJN3s*'E!L(:nUA~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1906
|
||||
>>
|
||||
stream
|
||||
Gb"/'968iG&AIa;CbHeVWF,]/8^O$mN:V#K2GFid8M@eS,UCb]$&+'!8Uc((+@X^3g!2Q@e^Bfr*oF[qliL]_n\a/^ZikV6L&E5#N5K`I9Fq6RGOT50IIRRRGpEG6^8-5%J],;$K6lKV&;2<fH-AP%Lr`,;OGl^J9Am<4MHC"hK)'JK!Js,]8HD5b+I(imof`[B8'unk@2N?WhruMjH8,^fm82($MdSMsF[l&&^*&XD;#o&Xp>5iJq&%uRKkf&4ecgFV[7Zbi:-"Bnn9B!+1jNS1#-ZRf.eL;OQ$ES_1RLA8Z:=W+QNV/^0&s]F%Boj%d25#b@6Z#q]Lm_%&^X$il4ti0SYd!a`:mu!_:\!lcUZ;P?\BVVdc1@BRseZ&nVC!kjJ\-_DO)Cr$Mh1ZfZm(%^(2/V11m6VXr-?@!VG#.CZk<sCcbNoE8*!-L8/Q%Ucf:VV5,.QZmbE6Y@P,P2e-BDl7G.h.f3pi^OKe9j4G:`g$rPd1I_\;N;LC%f9_&10pABB&D"Ggl>qV#3rGB7lhH'qJAk(79DC1+Ue6uXY-h-+a`V_B&&O_-4Ks/Cg@&U%UQ[7?gT\'@YZ@I2InWaO8Q'3*8h.n^VJ;qW=DsWS4t7P'1<D$JT=(i(;V;8>;K.a7E2FaN$rjbmjH>ZbX,3s@RQEZV:$Xu>/kgU>Rf[e/?K'`k$Ii"XiV2-?h99b:UYjr0[K\Uf)r?=MpC6Riflk%3l#.SilW1Vdo<H'`m@Frtb7'$!#DM_:XYhrXR(QTZ0_l/]-7RP1bVH-ECUZp&&3bu&r#Q&[-!c:A5mGe-0'ILKQ';X,M(u$5%_tZj5OuDiH^:cqN"h=^Ujin"`V18GC8oW]Dq7rA#5?:fa1@Spegq00E*kN'0ecOINP20gZ"P\3fZbMWDK-3!q%'aB0o=R?OP3hH(l\Z3RVq[3DpK'0;$EgkG]aE2\mEVhIBg[?O5cD^*_pu[T=i`OB`gqX`kh#Bo`PI@bS'.pe*!`,!n[)@G[#1u\9t[Er+8.5#i(u-76nU@'*mK+K^G!mdB-%#nKVFdf]Zm00QQW'<FTBZ75[I3+sZtg\h1)QZr_!Ri&jfn_oXO&b,;RjOS,:3oARV*Me$q_#cUo_Fr)cGNOJ!$\N%`+o]`FrA3J#F,@.3^@KSQHj$Lut]iljbB=k6l6K>f'[[0Rs_C08_Y(mCt^bg#(l4jLTfbe!A-HF;*8'qN9pV(@C3gj#;G5CS)#tERjSis^cIogp4]1W!Rmje>];&WAK?,Fp!Q8Hcl@BQh2A&1@LA2$r,.`#S2L5WrTQPgiB,%rMI;&D`a%#/Cf(e59kT&d"GV'i"9I>-&B'qUN:/f_J!pC&4=8B5_2gs/L)^5r(E@3Q1%e@&/GocZaa*B4^]4/pi)mm3o2LZM,Rj_1"k@!%KAkop?Okb)_#I^!EthFJG)@M_ab'3eoYF2j>p$lL/7fEIHNJE'=3kj2<QMrW'#1]$c$+=mq+?GVgi^su\!"(/2?VN#2idX7XeePDO[`XSTGO+tWZ"n`K"eY[4\,JCO9Zh>%$!`E<i3@:dCh8#igH35_<cjp6lhSKVQD/YAPkUtPk<b6E:j9?r-j9YM^At-HSG,'dH&OK+X:]j,aXKYS:n4d'?eTn;M+B.SmIt\4;"_)sBH&"?144Oca@^TbW3(2[ho!<b>OqLqtF+>=Qe0N6#F&]Qgn_D_&g^Pe9')GJ;Ij;m[F,o,3-rh6HQf8N[i1,P:5iRAt.oYWr%Suj`M1!U\QQuk7QTGE0\H#Fe=)MS./EaO^m-D8AD[h`n1WQ;4A)<.PdiDEQSrVZl^n6=gD:3`'YMi/LP4b964&H-MGGZb3KA6%ji'ZH[5KiPlT&jZq?_OuHG3T=V$/HP(eTUO)+NhX#;!-<J?pRgn2DKN0O/l=lnGWM\J,.U~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2014
|
||||
>>
|
||||
stream
|
||||
Gb"/(968iG&AII3ChGdB-&j#KI"@&b`_N#AD8pQ9-1/N))2a(rfHd/@P[%SlgUdS;MN[Y:26K]6SVVk5]DLtJ53sa5/Gu4(h^ARd*Pq4c"j14p%FcO2N'>+^UYb_d@35"\(Pp<f4((;T]D;Ujl/giL`!][4JQk=s^&nkCK_']r_gT6pIF&%u?hGfYhm7^u!9)H#\G'CG9g$GBZpS*E$eG&2rpe.EJ9[&tk[sdOb#![JTN\J+6S.O!/rVRLT7?]`"s6egMJ/_`$=?CWU.GYmN;7F52E3,m=d1+b?c63FC7:!lHjQPe(qlqcFn@aR-]#`Kec/J\5[D0lU/]aPg5@YIGmKXX'`ESK?"ie2j/Hs:@b[30ak.L$SrJlk[c9@hA3K(<qQX=!Hd!k'jm^P6j,K9jW:."ZMS"t,HL2V7:ek`$Wa,s./o[N7D:;[cF^VHcTnA9dZq'Xb])9oi'j7Z(W+R]q8F]F?A@&h#d6SiH-9Y:,ePDb5iOc+79_E^b2tbS?R>9/u(uptk$oE1=aVhKIM'96doD37CJ7f8nKKV0M0]LL\.sJtJKi[^p.]!u\\^5O%b97Jn^2!/IeQ0T^4atfP,Wh:1:)lqAqJTA2(+TU$-JP<T\k^l<Y!\9IR'f'@"6QtG]ZEYi'6p!785L=U`3AshD"?7c]ad7l06;=%?)6(-ESHR[X@CBd\,TUp\W"I=@L+BuE7o[jrD!_$R!lYNT%T&>P$?$-)<SI@)TJK[e!2u!ba*sdBPP/[S/P[?KoSR4a3dBMK,Tg>iMrO6Y`o;bKeAV4;gEG2ol?,ZNJ=nlbKumr+pDMl2q5.iT>ES3j#\-FPKc^]J:+^#]duCl`c-MW7HMK0T:8!fZ7O(-50Q@#K$I/)j&AK%6bbF67)%:G7a\3JJOHOO1F9nSMfn)&]*:=6fPJZBlXD*N)V1sUY6"G!huCd!^!8Jb:;c92SXUe>'4d-/J=e><5Ag!IW+ecX?J-qo\N&@(BJ'3`V5]8^!k"eM4KJ5;A55bF&ZnUc2uJ#TKZ,sXOZaOfppRa03V!J]^@)XMmM>GV/^G]V9B!59VN$8m1@Nm%n["$`>*ep[3RMhka6NL->[r]RIZ>??dc_H:CRq@"l<dEGhs)KDW@l./ZssL/h_!"8WcBX"\/"`AS:CBW2?K:6PY/$\W`k+tiV_<B7c,i4S:3SIqe`01=5DeBoAa7bg3OB4,4[`c<>;;F:6N[r-FpuTi2T"C8r)"09gj(RnfN$!A_-C5/o3[M=K>S6m)/u%Q`:IVa%TsM848p*Gu!X2e3H_/gX1r&(Br%#*$13b0(_X:Io)Hsq[P=3;QIIqhP#bT'H8Z$)8ouJS74:/*088V+lfsgXR]mPs#BB@)R<!]gG&n]5QB@d4kUMDa0D6]=d3`FdBT.VB5-Ul>fC,+)66TS0R/4[f/-M%ftK4h(23%Hd+EorPa7/V4/%d5@Y'57_5[LL^jA.!D2*Y]>bLX(]=@MoYhG/RC*1AP^SbM=?c,,';'@-;B_.[-bRL;cGUClVEeU('j,]Xgo91%#ruZoT3='iDU%g8FiD\guI-]kJ;BD\6,5Y/F4R+#n%;Cebn[tBM^IrTG^pPSc8Tc\S_ij'F9gP"=L9ZVn@5sefc,i8._7c<%!Nt?g'38p(N5ICj//7HD%dG(>>A9';(m$..3$->q?'0)dfXeH0SeqbRfSoBM27D%2<JPGn10(e-S4XEn>pu+^7$J'h:IJ@3c;:@FL/pI"aK-SuC_o;$Lbi%HlnEetBD,&-LZ;EO&14Gb2Qrn=7q=Q-(qD5%\i.$=ZXAkqM`];]8#.X70W&U39sp?]ES!$ifeU$P@##bR@C'YZ5H;PBO2tAi@:ZcSd[GqpL>u6L#tIL-d#.7+I#gg1p0h47qNYXTKI='qbf0k!aG8`,Y;@?Xe46.4=7fAh=KH!Qk0X;B!=gggelL:(9N<6/*/-d8`6$q@-8Sr`hmEeg$pnL<9]uU$AWGBRn\&tWNu*2UrI1,fH1cZ#ae5mgi`=*cR0%^7![57XjT~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 30
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000002079 00000 n
|
||||
0000002285 00000 n
|
||||
0000002491 00000 n
|
||||
0000002697 00000 n
|
||||
0000002767 00000 n
|
||||
0000003059 00000 n
|
||||
0000003189 00000 n
|
||||
0000005193 00000 n
|
||||
0000007291 00000 n
|
||||
0000009468 00000 n
|
||||
0000010263 00000 n
|
||||
0000011874 00000 n
|
||||
0000013918 00000 n
|
||||
0000015569 00000 n
|
||||
0000017532 00000 n
|
||||
0000019348 00000 n
|
||||
0000021346 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<edfa56fc3bb0ffcbb0a5a8c9dd537e64><edfa56fc3bb0ffcbb0a5a8c9dd537e64>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 17 0 R
|
||||
/Root 16 0 R
|
||||
/Size 30
|
||||
>>
|
||||
startxref
|
||||
23452
|
||||
%%EOF
|
||||
BIN
Les15-Cursor-Vercel-Deploy/Les14-Slides.pptx
Normal file
BIN
Les15-Cursor-Vercel-Deploy/Les14-Slides.pptx
Normal file
Binary file not shown.
539
Les15-Cursor-Vercel-Deploy/Les15-Docenttekst.md
Normal file
539
Les15-Cursor-Vercel-Deploy/Les15-Docenttekst.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# Les 15 — Externe APIs + Cursor + Vercel
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 14 van 18
|
||||
**Onderwerp:** Externe APIs + Cursor agents (Composer + Background) + Vercel preview deploys + GitHub Actions CI
|
||||
**Duur:** 180 minuten
|
||||
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Pokédex (PokéAPI, geen key, visueel) — nieuwe kleine app
|
||||
|
||||
---
|
||||
|
||||
## Hoe deze tekst werkt
|
||||
|
||||
- `[SLIDE X]` — klik naar slide X
|
||||
- `[SCHERM: slides | cursor | terminal | browser | github | vercel]` — welk venster op de beamer
|
||||
- **Vertel:** "..." — wat je zegt (mag eigen woorden)
|
||||
- `*[stage direction]*` — instructie voor jezelf
|
||||
- 💬 = verwachte studentenvraag
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES — Setup (60 min)
|
||||
|
||||
### 1. Cursor versie checken
|
||||
|
||||
- Cursor moet `>=0.45` zijn voor Background Agents
|
||||
- Pro account aktief (Tim's eigen) — Background Agents zitten daar in
|
||||
- Test: `Cmd+Shift+P` → "Open Background Agent" zichtbaar in command palette
|
||||
- Login bij Cursor cloud — eenmalige flow
|
||||
|
||||
### 2. Vercel + GitHub klaar
|
||||
|
||||
- Vercel account (Tim's eigen) — gekoppeld aan GitHub
|
||||
- Test repo: maak vooraf 1 dummy-repo aan (om snel te kunnen demonstreren als demo crasht)
|
||||
- Bookmark: vercel.com/dashboard, github.com/USER
|
||||
|
||||
### 3. Demo-app vooraf gemaakt (backup)
|
||||
|
||||
- Werkende `pokedex-demo` lokaal + op GitHub + op Vercel
|
||||
- Als live coding faalt: switch hier naartoe, vertel "dit is hoe het er eindelijk uitziet"
|
||||
|
||||
### 4. Folder & terminal-tabs
|
||||
|
||||
- Werk-folder: `~/novi/novi-lessons/Les15-Cursor-Vercel-Deploy/live`
|
||||
- 3 terminal tabs: project root, git-watcher (`watch git status`), Vercel CLI
|
||||
- Cursor open met empty folder klaar
|
||||
- Browser tabs vooraf open:
|
||||
- https://pokeapi.co/
|
||||
- https://vercel.com/dashboard
|
||||
- https://github.com (jouw user)
|
||||
- https://docs.cursor.com/background-agent (referentie)
|
||||
|
||||
### 5. Internet check
|
||||
|
||||
- Background Agents werken alleen met goede verbinding
|
||||
- Vercel deploys ook
|
||||
- Backup hotspot indien WiFi gisteren rare dingen deed
|
||||
|
||||
### 6. Backup screenshots
|
||||
|
||||
Heb klaar voor als demo faalt:
|
||||
- Cursor Background Agent UI (run completed)
|
||||
- GitHub PR met Vercel comment
|
||||
- Vercel preview deploy
|
||||
- GitHub Actions groene check
|
||||
- Branch protection settings page
|
||||
|
||||
---
|
||||
|
||||
# HET SCRIPT — Lees mee tijdens de les
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik (10 min)
|
||||
|
||||
`[SLIDE 1 — Title]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Welkom bij les 14. Vandaag gaan we van localhost de wereld in. Externe APIs, Vercel, Cursor's coding agents, GitHub Actions CI. Heel hands-on."
|
||||
|
||||
`[SLIDE 2 — Terugblik]`
|
||||
|
||||
**Vertel:** "Lessen 11 t/m 13 gingen over AI: SDK, tools, agents. Alles draaide op jouw laptop. Dat was prima om te leren, maar in het echt deploy je iets en wil je dat anderen het kunnen gebruiken.
|
||||
|
||||
Vandaag drie dingen tegelijk. Een — externe APIs aanroepen. Geen LLM, gewoon data ophalen. Twee — die app naar Vercel. Drie — Cursor's twee agent-modes leren kennen en daarmee feature branches openen die automatisch preview-URLs krijgen op Vercel."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Drie uur. Eerst 45 minuten theorie verdeeld over de drie onderwerpen. Daarna vier demos. Pauze rond minuut 90. Tot slot lesopdracht en huiswerk."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Theorie Externe APIs (15 min)
|
||||
|
||||
`[SLIDE 4 — Wat is een externe API]`
|
||||
|
||||
**Vertel:** "Een externe API is een HTTP-endpoint van iemand anders. Je doet `fetch()`, je krijgt JSON. Klaar. Dat is het idee.
|
||||
|
||||
Drie smaken qua authenticatie. Geen key — PokéAPI en Open-Meteo bijvoorbeeld. Met key — Tavily, TMDB, de meeste paid APIs. OAuth — Google, GitHub user-data, dat is complexer.
|
||||
|
||||
In Next.js fetch je op twee plekken. Server-side, in een Server Component of API route. Of client-side, met `useEffect`. Server-side is bijna altijd beter voor initial data. Je key blijft veilig, het is gecached, en sneller."
|
||||
|
||||
`[SLIDE 5 — Code]`
|
||||
|
||||
**Vertel:** "Code voorbeeld. Server Component is `async`. Doe `fetch`, parse als JSON, return. Met `next: { revalidate: 3600 }` zegt je tegen Next.js: cache dit een uur. Eerste user triggert de fetch, de volgende honderd users krijgen de gecachte versie. Schaalbaar zonder dat je iets hoeft te doen.
|
||||
|
||||
💬 *Vraag: 'Wanneer client-side?'*
|
||||
|
||||
**Antwoord:** "Bij user-interactie. Een zoekbalk waar je live filtert — moet client-side, want de input verandert in de browser. Real-time updates, polling — client-side. Maar de eerste page-load: server-side."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Theorie Cursor Agents (15 min)
|
||||
|
||||
`[SLIDE 6 — Composer vs Background]`
|
||||
|
||||
**Vertel:** "Cursor heeft TWEE soorten agents. Niet hetzelfde. Niet uitwisselbaar. Vandaag gaan we beide doen.
|
||||
|
||||
Composer. `Cmd+I`. Lokaal in je editor. Jij wacht, agent werkt, je ziet de diffs verschijnen. Je accepteert per file. Synchroon — je bent erbij. Dit is pair programming.
|
||||
|
||||
Background Agent. `Cmd+Shift+P` → 'Open Background Agent'. Runt in Cursor's cloud, niet jouw machine. Je geeft een prompt, gaat iets anders doen, komt terug. De agent maakt een branch, doet commits, opent een PR op GitHub. Asynchroon — je hoeft er niet bij te zijn.
|
||||
|
||||
Het verschil zit in waar je bent in je workflow. Composer voor exploreren, voor pair-programming, voor 'ik wil even meekijken'. Background Agent voor 'ik weet wat ik wil, voer maar uit terwijl ik vergader'."
|
||||
|
||||
💬 *Vraag: 'Kost Background Agent geld?'*
|
||||
|
||||
**Antwoord:** "Ja, zit in Cursor Pro. Twintig dollar per maand. Hobby-tier — gratis Cursor — heeft alleen Composer. Voor de huiswerk-opdracht: trial van Pro werkt prima, of vraag credits via Cursor support voor studenten."
|
||||
|
||||
💬 *Vraag: 'Hoe vergelijkt dit met Claude Code of OpenCode?'*
|
||||
|
||||
**Antwoord:** "Allemaal coding agents, allemaal goed. Cursor's Background Agent is uniek in dat-ie autonoom een PR opent — Claude Code en OpenCode draaien lokaal en doen geen PR-flow zelf. Het hangt af van je workflow. We zien beide in deze les."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Theorie Vercel + CI (15 min)
|
||||
|
||||
`[SLIDE 7 — Vercel preview deploys]`
|
||||
|
||||
**Vertel:** "Vercel is hosting platform van de Next.js-makers. Voor Next.js apps de simpelste deploy ter wereld. Push naar GitHub, een minuut later: live.
|
||||
|
||||
Het magische zit in **preview deploys**. Elke branch krijgt automatisch een eigen URL. Niet alleen `main` — letterlijk elke. Push naar `feature/search` → `je-app-git-feature-search-user.vercel.app` is live. PR's krijgen een comment met de URL.
|
||||
|
||||
Waarom is dit zo krachtig? Background Agent maakt een PR — preview URL is direct klikbaar. Reviewer test de feature live, niet alleen code-diff. Stakeholders kunnen iets zien. Geen 'werkt op mijn laptop'-discussies meer.
|
||||
|
||||
Environments — drie scopes. Production = main. Preview = alle andere branches. Development = lokaal. Per env-variabele kies je waar-ie beschikbaar is."
|
||||
|
||||
`[SLIDE 8 — GitHub Actions CI]`
|
||||
|
||||
**Vertel:** "Last theory bit. GitHub Actions. Een script dat draait wanneer er iets gebeurt — push, PR, merge.
|
||||
|
||||
Minimaal voor dit vak: lint en build per PR. Eerst tien regels YAML, daarna heb je voor elke PR: lint draait, TypeScript build slaagt, anders rode X.
|
||||
|
||||
Waarom belangrijk? Background Agent kan typo's maken. CI vangt dat op voor je merget. Plus: 'werkte op mijn laptop' bestaat niet meer."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Demo 1: Pokédex met Composer (30 min)
|
||||
|
||||
`[SLIDE 9 — Wat we bouwen]`
|
||||
|
||||
**Vertel:** "Tijd voor demo. We bouwen vandaag een Pokédex. PokéAPI heeft data van duizend Pokémon, geen key nodig, gratis. Vier features in totaal — twee met Composer nu, twee met Background Agent na de pauze."
|
||||
|
||||
`[SLIDE 10 — DEMO 1]` `[SCHERM: terminal + cursor]`
|
||||
|
||||
**Vertel:** "Klas, kijk mee. Vragen tussendoor mogen."
|
||||
|
||||
`*[Terminal:]*`
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les15-Cursor-Vercel-Deploy
|
||||
pnpm create next-app@latest pokedex \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd pokedex
|
||||
cursor .
|
||||
```
|
||||
|
||||
`*[Cursor opens]*`
|
||||
|
||||
**Vertel:** "Cursor open. Nu Composer. Op Mac: `Cmd+I`. Op Windows: `Ctrl+I`."
|
||||
|
||||
`*[Cmd+I — Composer panel opens]*`
|
||||
|
||||
**Vertel:** "Composer is een tab in Cursor — niet hetzelfde als de gewone Chat. Composer kan meerdere files tegelijk wijzigen. Chat alleen één antwoord per keer."
|
||||
|
||||
`*[Type in Composer:]*`
|
||||
|
||||
> Bouw een Pokédex-startpagina op `app/page.tsx`. Haal de eerste 151 Pokémon op van `https://pokeapi.co/api/v2/pokemon?limit=151` als async Server Component. Toon in een grid van cards (Tailwind, 4 kolommen op desktop, 2 op tablet, 1 op mobile). Elke card: naam (capitalized), sprite van `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{id}.png`, en het Pokemon nummer (#001). Hover effect: card lichtjes vergroten en schaduw. Gebruik `next/image`. Voeg PokéAPI's sprite domain toe aan `next.config.ts`.
|
||||
|
||||
**Vertel:** "Klik 'Submit'. Composer denkt na. Je ziet diffs verschijnen — `app/page.tsx`, eventueel `next.config.ts`."
|
||||
|
||||
`*[Accept all diffs]*`
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`*[Open browser → localhost:3000]*`
|
||||
|
||||
**Vertel:** "Daar staan onze 151 Pokémon. Werkt out of the box."
|
||||
|
||||
💬 *Vraag: 'Hoe weet Composer welke files te wijzigen?'*
|
||||
|
||||
**Antwoord:** "Cursor heeft toegang tot je hele workspace. Composer ziet de file-structure, package.json, alles. Hij beslist zelf welke files relevant zijn voor de prompt. Soms vraagt hij om bevestiging — vooral bij nieuwe files."
|
||||
|
||||
`*[Tweede Composer prompt:]*`
|
||||
|
||||
> Voeg detail-pagina toe op `app/pokemon/[name]/page.tsx`. Toon: grote sprite van `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{id}.png`, de naam, types als gekleurde chips (gebruik officiële Pokemon type colors zoals fire = red, water = blue, etc), en stats (HP, Attack, Defense, Sp.Atk, Sp.Def, Speed) als progress bars die de waarde tonen. Voeg height + weight onderaan. Link de homepage-cards naar `/pokemon/{name}`.
|
||||
|
||||
`*[Accept diffs, refresh browser, klik op een card]*`
|
||||
|
||||
**Vertel:** "Detail-pagina werkt. Stats als balkjes. Types met kleurtjes. Composer heeft type-colors zelf gevonden — die zitten in zijn training data."
|
||||
|
||||
`*[Optioneel — als tijd:]*`
|
||||
|
||||
> De stats balkjes zijn te dun en de kleur is wat saai. Maak ze dikker en gebruik een paarse gradient.
|
||||
|
||||
**Vertel:** "Composer kan ook polish doen. Twee minuten later: betere UI. Dit is wat ik bedoelde met 'pair programming' — snel iteraten op visuals."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Demo 2: Vercel deploy (15 min)
|
||||
|
||||
`[SLIDE 11 — DEMO 2]` `[SCHERM: terminal + github + vercel]`
|
||||
|
||||
**Vertel:** "App werkt lokaal. Nu de wereld in."
|
||||
|
||||
`*[Terminal:]*`
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial Pokédex with Composer"
|
||||
git branch -M main
|
||||
```
|
||||
|
||||
`*[Browser → github.com/new]*`
|
||||
|
||||
**Vertel:** "Nieuwe repo. Naam: pokedex. Public — geen secrets in onze code dus public mag. Géén README, géén license — Next.js heeft alles al."
|
||||
|
||||
`*[Klik 'Create repository']*`
|
||||
|
||||
```bash
|
||||
git remote add origin https://github.com/JOUW-USER/pokedex.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
`*[Refresh GitHub — code is daar]*`
|
||||
|
||||
`*[Browser → vercel.com/dashboard]*` `[SCHERM: vercel]`
|
||||
|
||||
**Vertel:** "Vercel. Add New → Project. Import Git Repository. Kies pokedex."
|
||||
|
||||
`*[Op import-screen — alle defaults laten staan]*`
|
||||
|
||||
**Vertel:** "Vercel detecteert Next.js automatisch. Build command: `pnpm build`. Output directory: `.next`. Niks aanpassen. Klik Deploy."
|
||||
|
||||
`*[Wachten ~60-90s]*`
|
||||
|
||||
**Vertel:** "Tijdens het builden: dit is wat er gebeurt. Vercel pulled je code van GitHub, install dependencies, runt `pnpm build`. Output gaat naar hun CDN. Klaar.
|
||||
|
||||
`*[Build done — klik op URL]*`
|
||||
|
||||
**Vertel:** "Daar staat-ie. Productie URL. Live."
|
||||
|
||||
`*[Demonstreer: open URL op je telefoon — werkt]*`
|
||||
|
||||
**Vertel:** "Werkt op telefoon. Werkt overal. Internet."
|
||||
|
||||
`*[Terug naar Cursor — kleine wijziging:]*`
|
||||
|
||||
`*[Composer:]*` "Verander de h1 van 'Pokédex' naar 'Mijn Pokédex (Live!)'"
|
||||
|
||||
```bash
|
||||
git add . && git commit -m "Update title" && git push
|
||||
```
|
||||
|
||||
`*[Browser → Vercel dashboard]*`
|
||||
|
||||
**Vertel:** "Nieuwe deploy verschijnt direct. Wacht 30 seconden..."
|
||||
|
||||
`*[Refresh productie URL]*`
|
||||
|
||||
**Vertel:** "Nieuwe titel zichtbaar. Geen extra commando. Push naar main = deploy."
|
||||
|
||||
💬 *Vraag: 'En als de build faalt?'*
|
||||
|
||||
**Antwoord:** "Productie blijft op de laatste werkende versie. Vercel deployt alleen als build slaagt. Je krijgt mail dat de build mislukte, productie blijft online. Geen downtime."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 12 — Pauze]`
|
||||
|
||||
**Vertel:** "Pauze. Vijftien minuten."
|
||||
|
||||
`*[Tijdens pauze: open Cursor Background Agent flow alvast — zorg dat repo geconnect is]*`
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — Demo 3: Background Agent (25 min)
|
||||
|
||||
`[SLIDE 13 — DEMO 3]` `[SCHERM: cursor + github + vercel]`
|
||||
|
||||
**Vertel:** "Nu de Background Agent. Dit is écht anders. Geen pair-programming meer — we delegeren."
|
||||
|
||||
`*[Cursor: Cmd+Shift+P]*`
|
||||
|
||||
**Vertel:** "Cmd+Shift+P opent command palette. Type 'Background Agent'."
|
||||
|
||||
`*[Select 'Open Background Agent']*`
|
||||
|
||||
**Vertel:** "Eerste keer: Cursor vraagt verbinding met je repo. Volg de flow — opent browser, OAuth met GitHub, Cursor mag in jouw cloud sandbox draaien."
|
||||
|
||||
`*[Connect flow]*`
|
||||
|
||||
**Vertel:** "Klaar. Nu de prompt. Belangrijk: SPECIFIEK zijn. De agent ziet ons niet. Hij krijgt alleen de prompt."
|
||||
|
||||
`*[Schrijf:]*`
|
||||
|
||||
> Maak een nieuwe branch `feature/search`. Voeg een controlled input toe bovenaan `app/page.tsx`. Wanneer gebruiker typt, filter de Pokémon-lijst case-insensitive op naam. Houd Tailwind-styling consistent met bestaande cards (rounded corners, padding p-4). Voeg een count toe rechtsboven: "X van 151 zichtbaar". Push de branch en open een PR met titel "Add search bar". PR body: korte beschrijving + screenshot suggestie.
|
||||
|
||||
`*[Submit]*`
|
||||
|
||||
**Vertel:** "Agent draait nu. Cursor toont je een panel waarin je kunt zien wat-ie doet — bestanden inlezen, edits maken, terminal commands."
|
||||
|
||||
`*[Wachten ~3-5 minuten. Tijdens wachten:]*`
|
||||
|
||||
**Vertel:** "Belangrijk om uit te leggen — terwijl deze agent draait, ben ik vrij om iets anders te doen. Lokaal coden, Composer gebruiken voor andere features, koffie halen. Dat is het voordeel.
|
||||
|
||||
Andere relevante punten. Agent draait in Cursor's sandbox — niet jouw laptop. Hij heeft toegang tot je repo, kan `pnpm install` doen, kan tests draaien, kan PR's openen. Maar hij heeft GEEN toegang tot jouw productie-secrets of services. Sandbox is geïsoleerd."
|
||||
|
||||
`*[Agent done — PR verschijnt op GitHub]*` `[SCHERM: github]`
|
||||
|
||||
**Vertel:** "Pull request op GitHub. Klik."
|
||||
|
||||
`*[PR open]*`
|
||||
|
||||
**Vertel:** "Vercel heeft een comment achtergelaten — preview URL. Klik."
|
||||
|
||||
`*[Browser → Vercel preview URL]*`
|
||||
|
||||
**Vertel:** "Live, op een ANDERE URL dan productie. Test zoekbalk."
|
||||
|
||||
`*[Type 'pika' — Pikachu blijft over]*`
|
||||
|
||||
**Vertel:** "Werkt. Search live op preview URL. Productie ongewijzigd."
|
||||
|
||||
`*[Terug naar GitHub PR — Files Changed tab]*`
|
||||
|
||||
**Vertel:** "Code-review. Agent heeft `app/page.tsx` aangepast, eventueel een nieuwe component gemaakt. Lees diff, check of het ok is."
|
||||
|
||||
`*[Eventueel: comment in PR]*`
|
||||
|
||||
**Vertel:** "Stel ik wil een wijziging — bv. 'voeg ook een clear-button toe'. Twee opties. Een: open de branch lokaal, Composer fix. Twee: nieuwe Background Agent prompt op deze branch. Voor demo doen we optie twee."
|
||||
|
||||
`*[Cursor → Background Agent → existing or new]*`
|
||||
|
||||
> Op de bestaande branch feature/search: voeg een 'X' clear-button toe rechts in de zoekinput die de input leegmaakt.
|
||||
|
||||
`*[Wacht 2-3 min, refresh PR, refresh preview URL]*`
|
||||
|
||||
**Vertel:** "Update zichtbaar. Klik X — input leeg. Werkt."
|
||||
|
||||
`*[Eventueel: merge]*`
|
||||
|
||||
**Vertel:** "Tevreden? Merge PR. Vercel productie-deploy start automatisch. Een minuut later: search live op productie."
|
||||
|
||||
💬 *Vraag: 'Wat als agent vastloopt?'*
|
||||
|
||||
**Antwoord:** "Twee opties. Pakje sluiten en opnieuw beginnen met betere prompt. Of: open de branch lokaal, kijk wat agent gedaan heeft, fix met Composer. De agent is geen blackbox — hij commit echt op een branch, je kunt erin kijken."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 9 — Demo 4: GitHub Actions CI (15 min)
|
||||
|
||||
`[SLIDE 14 — DEMO 4]` `[SCHERM: cursor + github]`
|
||||
|
||||
**Vertel:** "Laatste demo. CI. Eén check per PR — lint + build."
|
||||
|
||||
`*[Cursor → Composer]*`
|
||||
|
||||
> Voeg een GitHub Actions workflow toe: `.github/workflows/ci.yml`. Runt op pull_request en push naar main. Steps: checkout, install pnpm 9, setup node 20 met pnpm cache, install dependencies met frozen lockfile, run lint, run build.
|
||||
|
||||
`*[Accept diffs]*`
|
||||
|
||||
`*[Terminal:]*`
|
||||
|
||||
```bash
|
||||
git checkout -b chore/add-ci
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "Add CI workflow"
|
||||
git push -u origin chore/add-ci
|
||||
```
|
||||
|
||||
`*[GitHub → openen PR]*` `[SCHERM: github]`
|
||||
|
||||
**Vertel:** "PR open. Klik tab 'Actions' — workflow draait. ~1-2 min."
|
||||
|
||||
`*[Wacht]*`
|
||||
|
||||
**Vertel:** "Groene check verschijnt. Merge PR. CI is nu live op elke toekomstige PR."
|
||||
|
||||
`*[Demo: failing CI]*`
|
||||
|
||||
**Vertel:** "Laat ik laten zien wat er gebeurt bij een fout."
|
||||
|
||||
`*[Cursor → Composer:]*`
|
||||
|
||||
> Maak een TypeScript fout in app/page.tsx. Verwijder een belangrijke import. Push naar een nieuwe branch chore/test-ci-fail.
|
||||
|
||||
```bash
|
||||
git push -u origin chore/test-ci-fail
|
||||
```
|
||||
|
||||
`*[GitHub → PR open → wait]*`
|
||||
|
||||
**Vertel:** "Rode X. Build faalt. Klik op de details — daar staat letterlijk wat mis is."
|
||||
|
||||
`*[Fix met Composer]*`
|
||||
|
||||
> Repareer de TypeScript-fout in app/page.tsx (voeg de import terug). Commit.
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
`*[Wacht — groene check]*`
|
||||
|
||||
**Vertel:** "Fixed. CI groen. Merge mag nu."
|
||||
|
||||
`*[Branch protection toevoegen — kort]*` `[SCHERM: github settings]`
|
||||
|
||||
**Vertel:** "Tot slot — branch protection. Settings → Branches → Add rule. Branch pattern: main. Vink aan: require PR before merging. Require status checks: select onze CI workflow. Save.
|
||||
|
||||
Wat dit doet: niemand kan nog direct naar main pushen. Background Agents werken via PRs — dat blijft werken. PR moet groene CI hebben voor merge."
|
||||
|
||||
`*[Demo: probeer direct push naar main]*`
|
||||
|
||||
```bash
|
||||
git checkout main && echo "test" >> README.md && git commit -am "direct" && git push
|
||||
```
|
||||
|
||||
```
|
||||
! [remote rejected] main -> main (protected branch hook declined)
|
||||
```
|
||||
|
||||
**Vertel:** "Geblokkeerd. Goed."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 10 — Composer vs Background reflectie (10 min)
|
||||
|
||||
`[SLIDE 15 — Wanneer welke]`
|
||||
|
||||
**Vertel:** "Twee tools, één keuze per taak. Wanneer welke?
|
||||
|
||||
Composer voor pair-programming. Snel feedback, jij stuurt, agent voert uit, jij accepteert per diff. Goed voor: exploreren, leren, refactors waar je wilt meekijken.
|
||||
|
||||
Background Agent voor delegatie. Specifieke feature, kun je niet bij zijn, parallel werken aan andere taken. Goed voor: PR-prep, parallel werken aan 3 features, terwijl-je-vergadert.
|
||||
|
||||
Risico's bij Background. Vage prompts = vage PR's. Specifiek zijn helpt. Agent kost credits — voor solo-projecten geen issue, voor teams kan oplopen.
|
||||
|
||||
Wat ik zelf doe: 80% Composer, 20% Background. Background voor de écht goed-gedefinieerde features die ik dan parallel laat draaien."
|
||||
|
||||
💬 *Vraag: 'Welke is beter voor onze eindopdracht?'*
|
||||
|
||||
**Antwoord:** "Beide. Ontwerpen + leren = Composer. Specifieke features uitwerken = Background. Voor de eindopdracht-pitch: laat zien dat je beide kunt — dat is een vaardigheid."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 11 — Lesopdracht + Huiswerk (10 min)
|
||||
|
||||
`[SLIDE 16 — Lesopdracht + Huiswerk]`
|
||||
|
||||
**Vertel:** "Lesopdracht — in de les. Half uur. Je eigen Pokédex setuppen, één feature met Composer, push naar GitHub, deploy op Vercel. Aan het eind: jouw eigen productie-URL.
|
||||
|
||||
Huiswerk — voor volgende week. Vier dingen.
|
||||
|
||||
Een: feature toevoegen via Background Agent. Branch + PR + preview URL.
|
||||
|
||||
Twee: GitHub Actions CI workflow. Lint + build per PR.
|
||||
|
||||
Drie: branch protection op main. PR's verplicht, CI moet groen.
|
||||
|
||||
Vier: DEPLOY.md schrijven. URLs, prompt die je aan Background Agent gaf, reflectie over Composer vs Background, bewijs van CI (groen + rood voorbeeld), één observatie.
|
||||
|
||||
Bonus: tweede externe API, environment scoping, parallelle Background Agents.
|
||||
|
||||
Tien punten, voldoende is zes. Inleveren via Brightspace: repo URL + productie URL + DEPLOY.md."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 12 — Afsluiting (5 min)
|
||||
|
||||
`[SLIDE 17 — Afsluiting]`
|
||||
|
||||
**Vertel:** "Wat hebben we vandaag gedaan. Externe APIs in Server Components. Cursor Composer voor synchrone werk. Cursor Background Agent voor async PR's. Vercel productie + preview per branch. GitHub Actions CI. Branch protection.
|
||||
|
||||
Volgende les: RAG en embeddings. Vector search met pgvector. We bouwen een tool die semantic search doet — handig om in een agent te plakken. Combo van les 13 en 15.
|
||||
|
||||
Daarna: performance + observability, eindopdracht-werkdagen, pitch.
|
||||
|
||||
Vragen?"
|
||||
|
||||
`*[Vragenronde — minstens 5 min over laten]*`
|
||||
|
||||
---
|
||||
|
||||
## NA DE LES — Wrap-up
|
||||
|
||||
- Push working `pokedex` repo naar GitHub als referentie voor studenten
|
||||
- Brightspace: link naar referentie-repo + huiswerk-instructies
|
||||
- Verzamel vragen die niet beantwoord — terug in Les 15 opening
|
||||
- Voor Klas B (later): check of Cursor Pro licenties beschikbaar zijn voor studenten, anders Background Agent demo moeilijk
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten tijdens live coding
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| `next/image` PokéAPI domain error | Composer: "voeg images.remotePatterns toe in next.config.ts" |
|
||||
| Cursor Composer onresponsive | Cmd+I opnieuw, of restart Cursor |
|
||||
| Background Agent verbinding faalt | Logout/login Cursor cloud, refresh repo connection |
|
||||
| Vercel deploy faalt: missing dep | `pnpm install` lokaal, commit lockfile, push |
|
||||
| GitHub Actions: pnpm not found | Check `pnpm/action-setup@v3` versie in yml |
|
||||
| Branch protection: kan eigen PR niet mergen | Tijdens demo: voeg jezelf toe als bypass user |
|
||||
| Preview URL 404 | Wachten — eerste build duurt soms 2 min |
|
||||
| Background Agent fixt verkeerde files | Te vage prompt — specifiek met file paths volgende keer |
|
||||
|
||||
---
|
||||
|
||||
## Mentale model voor de klas
|
||||
|
||||
Als studenten verward zijn over de Cursor agent-modes:
|
||||
|
||||
> **Composer** = jij pair-programt met AI. Synchroon. Cmd+I. Lokaal.
|
||||
>
|
||||
> **Background Agent** = jij delegeert aan AI. Async. Cmd+Shift+P. Cloud. Opent PR.
|
||||
|
||||
Belangrijkste regel voor Background Agent: prompts moeten zo specifiek zijn alsof je het aan een nieuwe junior junior dev op afstand uitlegt. Geen context-cues, geen "je weet wel".
|
||||
313
Les15-Cursor-Vercel-Deploy/Les15-Huiswerk.md
Normal file
313
Les15-Cursor-Vercel-Deploy/Les15-Huiswerk.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Les 15 — Huiswerk
|
||||
## Background Agent + CI + branch protection + DEPLOY.md
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Deadline:** Voor Les 16 — MCP servers
|
||||
**Inleveren:** GitHub repo URL + Vercel productie URL + DEPLOY.md
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouwt voort op de **lesopdracht** (Pokédex op Vercel productie). In dit huiswerk:
|
||||
|
||||
- Voeg een feature toe via **Cursor Background Agent** → preview URL
|
||||
- Voeg **GitHub Actions CI** toe (lint + build)
|
||||
- Activeer **branch protection** op `main`
|
||||
- Schrijf **`DEPLOY.md`** met reflectie
|
||||
|
||||
> Niet klaar met de lesopdracht? Eerst die afmaken — het huiswerk heeft die als startpunt.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Feature met Background Agent (verplicht)
|
||||
|
||||
Voeg een feature toe via **Cursor Background Agent**, niet via Composer.
|
||||
|
||||
### Stappen
|
||||
|
||||
1. **Open Background Agent** in Cursor:
|
||||
- `Cmd+Shift+P` → "Open Background Agent"
|
||||
- Eerste keer: verbind je GitHub repo aan Cursor cloud (volg flow)
|
||||
|
||||
2. **Schrijf een specifieke prompt.** Vage prompts → matige output. Voorbeeld:
|
||||
|
||||
> Maak een nieuwe branch `feature/search`. Voeg een controlled input toe bovenaan `app/page.tsx`. Filter de Pokémon-lijst case-insensitive op naam terwijl gebruiker typt. Houd Tailwind-styling consistent met bestaande cards. Push de branch en open een PR met titel "Add search bar". Voeg in de PR body een korte beschrijving van wat je gedaan hebt.
|
||||
|
||||
3. **Wacht** (~3-5 min). Volg in Cursor panel wat agent doet.
|
||||
|
||||
4. **Open PR op GitHub.** Vercel zet een comment met preview URL. Klik die — feature werkt live.
|
||||
|
||||
5. **Review:**
|
||||
- Code-diff op GitHub
|
||||
- Live test op preview URL
|
||||
- Eventueel: comment achterlaten of nieuwe prompt aan agent
|
||||
|
||||
6. **Merge PR** (of laat open tot je CI hebt — zie B).
|
||||
|
||||
### Feature-ideeën (kies één — of bedenk eigen)
|
||||
|
||||
- **Search bar** — filter Pokémon op naam
|
||||
- **Type filter** — chips per type (fire, water, ...) om te filteren
|
||||
- **Sort** — op nummer of naam (asc/desc)
|
||||
- **Favorites** — localStorage-based, ster-icoon per card
|
||||
- **Pagination** — 20 per pagina i.p.v. 151 in één keer
|
||||
- **Eigen externe API** — voeg bv. weather widget toe (Open-Meteo, geen key)
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Feature gebouwd via Background Agent (niet Composer)
|
||||
- [ ] Branch `feature/...` op GitHub bestaat
|
||||
- [ ] PR open of gemerged
|
||||
- [ ] Vercel preview URL werkt
|
||||
- [ ] In `DEPLOY.md`: prompt + agent-resultaat reflectie
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — GitHub Actions CI (verplicht)
|
||||
|
||||
Voeg een CI workflow toe die lint + build draait op elke PR.
|
||||
|
||||
### Stappen
|
||||
|
||||
1. **Via Composer (of handmatig)** maak `.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm build
|
||||
```
|
||||
|
||||
2. **Push naar een nieuwe branch** `chore/add-ci`:
|
||||
|
||||
```bash
|
||||
git checkout -b chore/add-ci
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "Add CI workflow"
|
||||
git push -u origin chore/add-ci
|
||||
```
|
||||
|
||||
3. **Open PR** op GitHub → tab "Actions" → workflow draait → groene check
|
||||
|
||||
4. **Merge** naar main
|
||||
|
||||
### Test je CI
|
||||
|
||||
- Maak een typo in code (bv. `<dvi>` in plaats van `<div>`)
|
||||
- Push naar nieuwe branch + open PR
|
||||
- CI faalt → rode X
|
||||
- Fix met Composer of Background Agent → push → groene check
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] `.github/workflows/ci.yml` in repo
|
||||
- [ ] CI draait succesvol op merge naar main
|
||||
- [ ] Minstens 1 voorbeeld van rode CI (screenshot of beschrijving in DEPLOY.md)
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — Branch protection (verplicht)
|
||||
|
||||
Bescherm `main` zodat niemand direct kan pushen.
|
||||
|
||||
### Stappen
|
||||
|
||||
1. GitHub repo → **Settings → Branches → Add rule**
|
||||
2. Branch name pattern: `main`
|
||||
3. Check: **Require a pull request before merging**
|
||||
4. Check: **Require status checks to pass before merging** → select `check` (van CI workflow)
|
||||
5. Check: **Do not allow bypassing the above settings** (optioneel maar aanbevolen)
|
||||
6. **Save changes**
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
echo "test" >> README.md
|
||||
git add . && git commit -m "direct push"
|
||||
git push
|
||||
```
|
||||
|
||||
Verwacht: `! [remote rejected] main -> main (protected branch hook declined)`.
|
||||
|
||||
Goed — protection werkt.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Branch protection actief op `main`
|
||||
- [ ] PR's vereisen groene CI voor merge
|
||||
- [ ] Direct pushes naar main worden geweigerd
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel D — `DEPLOY.md` (verplicht)
|
||||
|
||||
Schrijf in repo-root.
|
||||
|
||||
### Sectie 1 — URLs
|
||||
|
||||
```markdown
|
||||
- Productie: https://pokedex-XXX.vercel.app
|
||||
- Preview (feature/search): https://pokedex-git-feature-search-XXX.vercel.app
|
||||
- GitHub repo: https://github.com/USER/pokedex
|
||||
```
|
||||
|
||||
### Sectie 2 — Background Agent prompt
|
||||
|
||||
Plak je exacte prompt + wat de agent uiteindelijk leverde:
|
||||
|
||||
```markdown
|
||||
**Prompt:**
|
||||
"Maak een nieuwe branch ..."
|
||||
|
||||
**Resultaat:**
|
||||
- Branch: feature/search
|
||||
- PR #4 — Add search bar
|
||||
- Files changed: app/page.tsx, app/components/SearchBar.tsx
|
||||
- Build time: 2m 14s
|
||||
```
|
||||
|
||||
### Sectie 3 — Composer vs Background reflectie
|
||||
|
||||
Maximaal 200 woorden over:
|
||||
|
||||
- Wanneer werkte Composer beter voor jou?
|
||||
- Wanneer Background Agent?
|
||||
- Eén voorbeeld waar de agent verbluffend goed was
|
||||
- Eén voorbeeld waar het matig was (en wat je deed)
|
||||
|
||||
### Sectie 4 — CI workflow
|
||||
|
||||
- Link naar `.github/workflows/ci.yml`
|
||||
- 1 screenshot van een groene CI run
|
||||
- 1 screenshot of beschrijving van een rode CI run + hoe je het fixte
|
||||
|
||||
### Sectie 5 — Eén observatie
|
||||
|
||||
Iets wat je opviel deze week:
|
||||
|
||||
- Preview deployments — wat was makkelijker dan verwacht?
|
||||
- Branch protection — hoe veranderde je workflow?
|
||||
- Vercel logs / Cursor Background panel?
|
||||
- Iets anders?
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 600 woorden totaal
|
||||
- Concrete links, screenshots, prompts
|
||||
- Mag wat informeel
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — Eigen externe API
|
||||
|
||||
Integreer een 2e externe API in je Pokédex (of als losse pagina):
|
||||
|
||||
- **Open-Meteo** (weer, geen key) — bv. "Pikachu's geboorteplaats Pallet Town weer"
|
||||
- **GitHub API** — embed repo-stats van je eigen pokedex-repo
|
||||
- **Quote API** — random quote bovenaan elke detail-pagina
|
||||
|
||||
### Bonus 2 — Environment scoping
|
||||
|
||||
- Voeg een `NEXT_PUBLIC_APP_NAME` env-var toe
|
||||
- Production scope: "Pokédex"
|
||||
- Preview scope: "Pokédex (Preview)"
|
||||
- Development scope (.env.local): "Pokédex (Dev)"
|
||||
- Toon de naam in header → zie verschil per environment
|
||||
|
||||
### Bonus 3 — Twee parallelle Background Agents
|
||||
|
||||
Start 2 features tegelijk:
|
||||
|
||||
- `feature/sort` — sorteer op naam of nummer
|
||||
- `feature/types` — type-filter chips
|
||||
|
||||
Documenteer in `DEPLOY.md` of parallel werken meerwaarde gaf, of dat het lastig was.
|
||||
|
||||
### Bonus 4 — Custom domain
|
||||
|
||||
Voeg een custom domain toe via Vercel (gratis subdomeinen via vercel.app blijven werken). Niet verplicht — leuk om eens gedaan te hebben.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **Vercel productie URL** in Brightspace
|
||||
3. **`DEPLOY.md`** in repo-root
|
||||
4. **Updated `.github/workflows/ci.yml`** in repo
|
||||
5. **Minstens 1 merged PR** van een Background Agent
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — Background Agent feature werkt + preview URL | 3 |
|
||||
| B — GitHub Actions CI werkt + bewijs van rode + groene run | 2 |
|
||||
| C — Branch protection actief op main | 1 |
|
||||
| D — DEPLOY.md compleet met 5 secties | 3 |
|
||||
| Productie URL werkt | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee bij twijfelgevallen.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — Background Agent feature | 40 min |
|
||||
| B — GitHub Actions CI | 25 min |
|
||||
| C — Branch protection | 5 min |
|
||||
| D — DEPLOY.md schrijven | 30 min |
|
||||
| **Totaal** | **~1,5-2 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Background Agent niet beschikbaar | Cursor Pro nodig — gebruik trial of vraag credits |
|
||||
| Agent maakt branch niet | Prompt explicieter: "Push branch en open PR" |
|
||||
| Vercel preview URL leeg / 404 | Preview-deploy nog aan het builden — wacht 1-2 min |
|
||||
| CI faalt op `pnpm install` | `frozen-lockfile` flag aan — commit je `pnpm-lock.yaml` |
|
||||
| Branch protection blokkeert merge | CI moet eerst groen zijn — wacht of fix lint-errors |
|
||||
| Background Agent kost te veel credits | Maak prompts korter, beperk scope per agent |
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Specifieke prompts** voor Background Agent — vague = matig
|
||||
- **Lees PR description** die agent schrijft — staat vaak goede uitleg in
|
||||
- **Vercel logs zijn je vriend** — bouwfout? Logs lezen meestal de oplossing
|
||||
- **Houd DEPLOY.md bij tijdens werken** — screenshots maak je dan in flow, niet aan eind
|
||||
- **CI failure ≠ slechte agent** — CI vangt typo's, agent loopt soms net naast
|
||||
|
||||
Volgende les: MCP — Model Context Protocol. Een vector-DB met PostgreSQL pgvector. We gaan een tool bouwen die semantic search doet — handig om in een agent te gebruiken. Tot dan!
|
||||
194
Les15-Cursor-Vercel-Deploy/Les15-Huiswerk.pdf
Normal file
194
Les15-Cursor-Vercel-Deploy/Les15-Huiswerk.pdf
Normal file
@@ -0,0 +1,194 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105950+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105950+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 15 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2334
|
||||
>>
|
||||
stream
|
||||
Gatm<8TWXr'#*s=oOTMp+p88i0S``km&nK)4H:aeYK@Z=KI#7cWK<@8RD\phY5ps4[FioP*2b%o"M4JGo^W"k./ksmR4\Ns:>ke8#)pH1#0\GY9YS;tM>+n44QMVH!"kBOQii2!rWi,O.j50i)"X,GU_/`O&&)RBb#e_cF]$($W"mN_GRR+WdRa4",RZpVm]@9Nj;_1s,RD;4GJ1C[C@bmI>3jPZmQV&r)9d\Q;M@mp$_dVJ9,RX$p%pasY3)`U(KFJN+)S..7131e7rWW85&6V"BiPB+eZC=$eP_\"F-p4MjFu/BORJ*Xpjtuio"V+-=9+uFH#onKRniB?1=+,HfKdgPkgYTfOBY@F_+57F!]T.*O*`%Q>C(`4FDdsPi'U0t%J?3Aqa2fWru=Aj\Hg+WTg?Kdg3tRu,%<8dBoL$e/>p^OUu($PiE9`0K_Ei-Y:T>pRSi"@SSu:8-jHBSn8P!+PEH<lc7r@QM&M6-GWP1rTL][:+a+n=.VU#Plo___S2*$tZ+C6rh2Tcf)XWggC[>5,5Q\qNl[ekb#;!l*g#SP"lVc^78(8.9Cr*pH2I-M1N%*$r_$*5?,,J/lLToZ)mO!t';ns(RAr;7d:i8s9cEma'p2,uLbj0"LAoKZKXFcf.mMnHgF],(E=OgEPdPe:$*.._EilfsRP%Aen@o-1$9+GRXE.Ua^^uoUP&!*12)s<lfe:?J[c>D-2PgD!JI,4hil@P.q;/$PF+f8A%^*bjX[Yu82'k:WcGFU.PD9G+LT!m0H&Fr[@hQ%nPnkpJuZ;IhZZkLu"(moh[Wi?tRWAJU<H1hQR>qMs(bOT]14Fi:;7q"G3$ZoORb2]-ieZcACTCV^IHAZFHWXoQXF10)5<q+/kQ[;)G#MPr7:%IW:"Ak/roKW%!(irf_lTGEdmjN3:).5hm&kgkP:A,@*Li"L*;hE1g3-](nSV/<a@GKBnXL%E`E0C*I0CXbkb>_o^Kf5>7$?m_3+YE8Mjr(aO?ni0tk:j;-%#!F(**e0q,>.>FYfdi8V[/:VHQeRGdd_O=CSfV18X.qD]7aojW@g"%RbA)(-T-(EY^erF5jA6AY[Hm.+Yes&7A""6%C#Jg\0D<g8]Uq$0g9]MiT0!RHdDJ\]FK[Qc#!iuT;Z-@WDm:-8*fbjbCC)#hN6.>C44^N/XD,,WYK%O$Ir+VXA00i)dQEK)]E"=TVGD[9`<I>^T0Cq7A"U%cO#!&e/X]&@A]32N?2a`"h/aYn()V[\T$D@9<TJ?b#ppK-)q0=dq>O42;#->6%Zjiot.oWoa4M9o#a#.T'b_<BW51jM-6N".ror"q'%'rd_^A&8]7&"`:><n'Gt:Wpn8cRK>Jo#I_%8p1!!Os0*bulg0)"`LO;&oD!%Ob)oEag:(/.b6^YUiX`q(5MjQNMJB^?:#&\l+*lGIgIn?1RlEk4k(%g)+hFP92_&+<(4DU920b^/"=QM`=M$^Ikc8G>B6:bX0Ra@Q6[ZI`=c+Dj8q1aYJ3Bk6FOJ?n3DG0t!)n7qHp;k_5#b0MIa>#QTSf_H6+q.b-r&A0OTl/>i`].T&X\DisLUsFbb+-q3es!-FHC9[cZar)_:[!^Q5KB;3WBIOI<WA/rHpnk<D9<g%[8(O1g:Lqio%P@\[4a1<)76STr#^0,:s?7mbsp=3-ZKS[`AP?/Imb/<W#?O'ggGtd!uRi_*._CrNYa!5BaZUQZ>+eP)8ZUS,Y5l\8EJl^I^>;G2jn9SdX7@])l<GJEcjSqa-5_N!:rtd(-X@\s"k0dN!!0+O#$Y2[OUF$]=hDpV/>RS+o4t90fT3.s"Es;i_ksFgYrZ99-jMVMORX>ICM&_G?(]u>hs*@rrD8\WES6eT_[ULp9I&J<gak=q.te`OohW,#2TI/Sci2<!j]iIqc20fV76&2<6_(,&ksKgKA3EVQ06s`/C\dN:Hlk1Yh_NHa4,n#WDZ/C5#N;hp)(=RZ@M?aOAVeXGYE/84k9<)rq8ihM+'Kb6Mbk`l@o15Iih.Y:SZm)grpX@Pj+A@p@2O8;5V*^i0\jl\.j+LZbVQnW;])(\k'u[CYB/k]e3%FA<2<8[bCC;Rf;J9=skFN)aP"@]'aSgLe-G3_$oT)2+_[Z75&_i^/Gu6D\!G`)jSq;@Mm/k"QF.Fh"PN.jqr[tMRK[F6=j7d=6nG-7AeV;.WmsD54s5ebS=i]T1:.75pE"ZW8WMWdGck`O%!edQ/a('DQmq'N>gtkUWOq8k7h4\:s8Ec@#j!7YM+uoce+UnP2sS&[dJ.ar`/$U!FbDfCe^G"VFBh@Pa3Y=ef9f:]El/P2iEZ&dF"42`Qui8$p+F*1o[*a<t"Ns%rsn*BE~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1035
|
||||
>>
|
||||
stream
|
||||
Gb!#[gN)"%&:N^lp5u.>Z$_i6Q8D0sRC"+ha\6>l&S<aI=E7sRmGD!Z5Y8IrkQ<8Y^k8cFF3^r^(^c:Oe*8a2!%rS:G"mP!cl3#YM%@9]\E^JN[YPm6OWOSnR,c.<OOu'jT[T3[+=(C^`Y=dR&Jk3#1J/LB)7Xiq'[[1C_W\?qJOH1F0cp[r6--p&oc=EFf;A75&;K_uoaO)c_M_"<TX-?<_ZmGkd"^%"Wok%N##PB6%,,(VT5NUWV-#!QV5E`q1aZ]7IFjJ:r3gZQ'V[9@KM.iq<94uiErs#scoWh#Xi:%JJG>]L4cb)J"jd6eAtiD#T%%bl4;F$3],'VHMRn`\\dd*/EsW3oh\O+9?H_G\?UgrpUjR>k.TC.JBO-=Yh`?Jb20(M%fF^Kh.W;rf"^ZU(U#2FIMq%_o<u7>^ffsb:EujL;du^jmUeZ'CQG7/WVeR>ueNV1L$uGB39D13l-LZh3*OBM&G+V7O:66MC!MtaXEbuE:?O9kh-#Rdgb'cX[P;*KUY#>0De?5i4&lXE$\n^e6\kG[l3CqH%S`gnYrp_7iG5'EK$$s@-Cj9jL+;p*MJDA:Tb$.jMB"7p'*pu==4RUBtLR):]Z^l2@g0'_EdVADXOZdkMU<?dH'#!FKV1l`p456`,q"1%QR]"dUn,eC6_+mJU&W'E_C@""%UV=rp+SC%')q"0-i,6Gu]q*br>EGB!M&kmrK\SaI@=la)fqHM"-2AoWY!e-[n<Y`26oaYlDAQnmFh#apG!;Ze"Uf*$@k6%q$r\u%fH+@,>T$fY4)=PpgW"E9;Rp0%L),&co#'dZji@A8=k6g$la>G_%8NN#Dp08#*2qP"#]mi@JgZa]X-jsE\ZMu^`+_kq#!T%<<s@kP2ZG[VqEW=BRS.GFURM#s+f$+)T7jbC%:F^?iI%sVhf_7,aKYQl1:"O?\kS(5G#B=ZB4L[$6;ONnS3jAu/oUiP?gjY88D[FFit'E;3Sd<;]^"A0Ij`q=K;E1(Q[s@rb:V>n=NmXR,MG@9UF@NZi/SreXZnC@&E*~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1666
|
||||
>>
|
||||
stream
|
||||
GauHK=`<%a&:WfGQq&HQ<dSh1BbWl$-9r'=:kKO+;,GbG@a%4%+ZKAmSL#SFFsJdsN(AOkX`hGSB'1EAMJq'n7k)7F3Qtec?l\<q^a,$rIg<1>hR,k`i`c1DKV9UJ5Q]nN:oq;<Hpq#M!sXKp@^H8P(bm?k^emjBVP155q:K1B[%c8F+GT^7W,L`#;4OCr8UR@d]CJ%4`mG0Mg9F#6m0CIH#MU-HTaF@>o<f,#'IFncIXVZCns(f%Fe0uMHWA^3?,6^-)P4X54tFgdXR$R+g9"*?9kq$"&^8#*r3=^.^mDnc;G_c\s.(`mq=(8Ab8XYj[CGDY.aC?l"/&G7W-]f/G>'kO6o`794aH%Y^lpK>.]FQ`\51i5DCtCg-Bq+Y=Q=#&9+n*:+0PD94d"?ui=.T%3N-gE^jUbE*O]ACSrUU8+[UeXqo!">0C/Y6*>%VF6lt_u,l",Z\J+DT(g%i)CTe$opMPCDHgQE_M-`Wp/j9J'$OC5Ck6;d_$KIVqB?pg3+fmih]@R(tC@D>B^VuP0paj4r]nQK%>eR*Qn1:U!4=fC0>I^&:D6ZZAN4b'=@o8p$g-s!R:`CI>_e2]42Mk'I2'gb%MA8fKa!&+:dnO0&!qJudLY4ElX*E/aAf?D*5ZVm4Xr"F6]XYOHnsaY8`T1g'SD@XtOIHLiTh7-Bb:&g>.Ge@b^hj,gCBEJ^dA3A`\h+HK>2i+@SRB4^eIY\&W?/40rLV*r.uiL^(,f"F)PZakZ3BD\[@92]h$QLA5?uEI@g7G3*fTg(>hGM%#24.SanOBY[g0fO5gVn64WLj1B,m,Na#fuaR**:qSma#fY"bI?1kZ4eQDQc+)qceWEEBUe?rJGp5D?eRD-;i)Ed'Fkhaml#MD0t>[QK2WdP/uG*YE%Tlq'7Oja'TBV*r`shGYLPLA2UT:Ai$_Xb\'d<lp\U;3$`I8iB8G,DC66X"b3YA";c>NhXhA.JaA97@dHO1^PZpr=KTt6"=$DJRJB/N0;0&)edc;#+.__\e>M,1,WhUaQr-U<fUQBC^?I6#I$,>-CqPpe'_:ik35p*J5MpmjU[1KggGOK0dmU#?[H]WpVJ[i`pQ*Ee)FIbKJ0UU+$Yg@s"qocG4\1%5bp5ks/6OD-B85@@t`;km4@5g1l1d\'&)-WLdL`Hl]BTT64b_RM]A;k`apY.EE6+KP/)0c=lfV9CmsG4eRb9'YnSS*NH.NcPPEdpebOE54Y4@Hj((1$7ti_q??JEZkO3NF_-#*LkK+^M'#*#Qp%NWfm)MoEHaU6VG@1%;*LlS:d,'[jn(9EZ4pW3<g0us=_1[rG%'a]WF).tY)]T>SZeNji=K!AN_ZFUZ?J&Dj>Y_%2VGH.J2tr;QcDns&k3c/PXR&e2fOuAF=T0>\mPXt$*;/.t9%DNfNjIJL9Al.+dq$"KgRBSXjl2E/f!R2U/MM2'NtZ+iLt1KHXM1<^?cW2@kGiEMS37CX/+dPH/Vdpr?FF^kqhE7kA"t8Pr(uYQ.P>R]Hs5o8OY_9d<#"I<r,fc^50=J0NdQT-6Ck:>nL4sekHPo=;:q?s_#fW-e!p870%-A)D6N8\!Y\5MZ3\@XK!LC\>Ait&52W_IVCbAW$p8+]:GhTJ(;>!Pf&G.&K5I1r$YtqPCr<?V.;1sBn`?hCG9?i1l1.BnJbMB0^]AE~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1812
|
||||
>>
|
||||
stream
|
||||
Gb"/'gN)%,&:N_CD#KGDWJqH38:l:]a0D2h1JZsi8iiNS%ho1=N-UOW88A1=$^(SUMe(L<1MNbQ`h\p9m[hC,pOEror6X_YPQJM^_lL6\."hL)-mp#2B5lY8Xt@LQk&V>p,A\`85QXIU5r#f'E'<1a/>+,%T*V%k(R,1;#[fX<pSe[WK[EGN!qAG.(EA7(Xs^kDo,ZgFL8HtN4YO<.^[Y*5pB'>3hP$G3(R<qbg41q^*DUG9qC_D=kl()Oo-p3j@p)9b4AXn!.V(A5E64IX#H)nRAJph,%cKt(AWfhfRAh"LrrW.cg/^^.G?i!*gV9`f9U7qL317bI65EY1hXb\+B-Hc)U(8*jV'_6\T),l<N`I$oCEm^W(`D:OO*cslbU)qaOX1e7M/@=]OYP7Bie7.LlUosoR)h$<]V0pTXicG$*#H+mF??iN@iSSX,'7dLT3)f"de9AO'\UJuAD+($$2uVR2NnT/%e@]0=B/8`:VHJ*hWj1$ZI^=qacF`9"bb<S0``!]3l2W^RN6S&L!Vm)\$$hN?DTk/m&kf_Le@hDg`-+I'O_bO?s;V`7Ol\>\EqIO0hl-$]ZV*%AVDLJP82[[3(V;Sk1Xo)*`Y%C+&t:kb&%*4c)t'QCL!3MXP24bAIpQRG,:Fk!jS8VTs^H2e(#>K_UD\&<HWVPXRqY0c%\$Omcpj\['&D'e_agE]q8eW1)jUuRHCaJ)Ce+V"20anWAki`qH:LTZFW7;%\]79Ei!7I`)a+K%C!=-%'Y(Ye!mA,>g1&a_DG4a(>1mI@@XG#j_Y0cZN2KCBqPq`1:l1p$[FmnYu?<0SL"Rpn+BA?$3kO8UGS5><oNQeS9uhHq$&hS<cF?S<3m2]/M09M\=dDuW231ld(o8<an%BGfiAZr)^&a"L%m'O/6<B08\T_k?Q(:E-)_,&p)ou;.%2a"#4#<_H5f+t)UaZi_MBOIL=G:EN=WL7RGQjWDem%[O.0],3b+9s2fgfIdi=(qf^4dNR;3hlMPD:',cpEgWPs;#AqWX>=<.2V:2E@R2L<=([bm@+jd=,(8,:*$T$&Wa93FL]k#W8(<pnFKOR^+W:f[B[dhH/Fc==IdB",3*;\:Sc]CiS"hkFh'mq!sY"BFsYqh[q\-pt<&'$p)`)DR7P^";kD\m;W1RHccMG;Rq!F-5MFYbQ/kGMr.0\2ZZ3H?W'2iHc4IUT+X10csj0,k1.<<5.F-MO2!jG(s+ZYi&2fj^`#;b2mcF;,;In!-+Rj:!,W1f70+*,"/$1^MNMeZH`CL^DAXo:55>!$bA!HIoKk,'m+:_1laSIkW&9Y]C.^32r\C>.m#X2_93h'N<4>rI=J)W8F]uCKYCs^.HI9lPXE+)&`hDr3"!",*P0hQUX5Q=4>-_\^XObCj!'hXKYp`UkBTq0PBFg,I7&6T6?gqF!+a8U(`)dV>3*Y!_j(TIO(C'$]XP-GO+a%6s.>%\l"TqV+$L,U*M8t;rsa>$j%dEap[9FJ*hAh!=`rBO/+Qs4_E@fQ6H5rs9=_Xn[[)3cZ$]@E&U!tR#((L>o[-Q@`OXNGThcJ;Wu7t;pf)A&s0jKWH'&VM<CeeZs6fIW0$['8ZP&$e$TX:0iZV"QHV*\?-FjZ0b0!FA]'UNUl5fICN39C+cE\T5N+JkC=%K5.**Xhh:G3gQ8U(F0:F%0j0aL"\,#g87o4R/S,-dBkYKPdqi\7u`I^,6a;Jb[20T$NH2J9Y\/du?31'A>io('cbj(.I9Fg`;aV[hi4,0<J\^qB"<[ZI8[)Y\2bC+@]malmr@qnIkg-8@@R,sJKC$.V9j=o>X9.tI:O7?aH/~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 972
|
||||
>>
|
||||
stream
|
||||
Gb!Sj9lJ`N&A@Zck"*pDn_.c*l(q##7#_ngM*fZ!(Lmuq@!:e;J`P^4CkqJ0R^PhXJd"JQBAT,'o>VaiHLpDX*WqpUTQS)%]?odE(JI*W$I1_mmg\C`DCBm?SohWR$`r`\0/.*-JKM+hmC##G-Y+tH+=(St[O&G*/e!"2#,-P%i5!4F$)IVK\KAn#kIeh:f;6uKq*EBa56$F7QLNTZGXP%Ul\gAB5PQJ&AQ2GZL\mbUSM;rcJ%<1E:HCo`&%'X@CLJn<'[V!Y&5Fk8SQMB7!Yb?b1X,N&N2ejhc4E%WAZiH9_Xj2bS8^:`'jBNZI%"YjNb282]oLN77Gt?Q\tPkd+:[$*PJ/9?)0FW4DV(\a[Q%8kggsD"k9YAY5'!;jCWIM--.^Y$]80TI]%Q1R\,gRiUrSnL\`@P:TYC+&kF9<&77n^A3/uopC@kIOE0=+=5O?Wcno7uregf:XBQ9DRD0h"`!])[HI+n$6ED&/9LR>atZ/=\$Nf#BADA*c/UB6/U$"D=Gg<"cLKctCO_@GXqY]nQ;=XL6VM@p'PFWIp*9a]-LN/J,/>W#\tQS@$'eBdf8D^e`YjiIIF^904K!Yn-.=IKtWBs4qCMf$ENHPkqLHdHY?K;e-%(GLa]OG0@EZMeC8?LshlGdVkZ;DQpEQDq58BlnqQ5Bm84NTK(-Y^h1G?f98VShHX!4aVW@Hfn\\k6c=WU7%-6m"3rZ5i6R2D$'[ojVk\NogGoM?)n.Y[9c5lg>FO"3LSnQf+6tqK<=H=RcZ`<jJ/I^k1O]ACG;IUd_;)=o^Hj6"o\toGp_uY'<-3V';LI`*a7*q9>qO]80qr49"YO!HK?[W%S_mBj>s]qK!VG]HK28B&h2$8Er=NPkn6S!<T>V:[/`?5@6[%lYNH0?%XX4<HHr[bm^*kHYB$aq2/.='Bn&2i,#*)U%*l4(lRWTfg3iTGfVO_Y%hdoI;HU@2)&b^>MK&;g~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2107
|
||||
>>
|
||||
stream
|
||||
Gb"/'>BcPr&:WeDbY,Fu[S`+t+M(??R^Un5[Vj[\m[fV`H8&B9@R((#h<_?&&R`EK1n42QA&mB#D_BIc1BRdo5IC=t@XsY+*5/16)>%itEl+N"!G?i@c61>>0"6C6_R)E0`3](XA0W<"gEDTt4:8rIM/YIo6&?/E!/C)MQb&8M\9VLZ+Kf4cJccV7G(d1[h]`P?.%1h(EP0it[=1HCk7OWC%b7Yp#_jR;D,c.&EFMi#[@n\[_8f>YcJcGTaZLCj'u<n4a^AAh2.J6YT-#-;H'fCg#]Jct7OjC/;Z$n#>NiiKZ9%d9Z3e!6G8^EIk!3d.57R4pbU+'BcQ@jt%o*An1HKZ0jKMfdf+c0OO?pP.j+`8Z&JfT#"0o[Y;Oo`?,(mGE1qDi*A"k$`$$@9>]ji_8"@dGC:T0pc6T)>8*9Z#%?sBuVdjVH1&YO`6o>mU"PdV+I#0:VV"GYTE&`H0RA3IIXULSRWN/_jVkte_\7rS(_.<hs_7(kfGCZZ5`jGXD<Lhd<$P\YHn=VjCT2m48apeH\7&[m_G)6p/r)g;Vg6m^;%Xq+K8KIW*>])Hiq:2'@&@l_e/\22A"nNmC9f.9p09mA;Q$Ugqjm+=KBIF6GG_pYUnZOHY^C[/K(.X%)qKsW9]>9do(Y_$Rtd9"jW)_Ki29DmA0K.9jG_N@NB8W_g:R3`K+HfsV(:T7"t5KH0k=7@gnhG4B[J.T$GaNmM^Yf+k/`Br.N>f!kY,+4.Q8i-W9*iLnh7d,\iB7KThp9%.Cc;21rpNg12*e-/%REUc=_C1Sh:r;#^1H_Hg/*G$F?=N'Co?otVUV(,Sp/6Amd8W@%d<PT+M'#D\R@5Hi36&("keOfmIPK+.;6I2+?Gb^-\5-\MgGqLG2ug]OI'[T`Vg4tF(ol4!':=J;I$%PSUGZUHY:.pH,hoXraj:"MUE>_]K]fGuUUNHI+.Xbk(,m-%lq9=#8g.>*81se>'AKc.S)J7G?CO9UE^T)E<eK"."/AGAh"FpVhiS5!ob>58a]8k]=kA)(Ua@iU6ITN0.Z\"PRjtqZ]E!o<P:R]5M`gY7\V$0;-aAUn^bY(mQ;oaDBr6X6TEsZRRE(r<4R3%1n].'lk,`dsl#.1F[7tuC%Psr=?"r@k/E.2ELcs%XX*!RkVMC,I@QYYNbuH[4M:=XP$:8bTY@iehQ1Yigfafe(EL)"Y'5ZhEX>_;gfa)H]?,Ff]5U-0/ed;p`N#]/o6V8;<[V0])8UK4F&lX%u^f[V0nnHp>c;ZW"AO5Llgejn)7*08S_p>q5G+@]N[S&hq]P5aYgf9BnI+&qll-:;d=?/!G@?J#0flJ28m'==7I%sPcE7m]2f<Y0lU?O3hec[l]oBL$*@a>Z^j"2nm9Ipp_s(o^LV?q.bcJ[7>k3\8P]Fs6AIYii/55oD91G?mU9_)mEB;(i2)W%Aq%;Tf%LN:Z)4i^,=p[fHtRgr_+&*,jcc-,TC:!3;6R;o`4Adukjai*JSFDkr0jsRuIkEeL>\u,a>VjX#;Jnc::9oeV,cegR">+uO,S46CcA00BbCS<8L_^9XdhH!um30r<,.8?,Bg]LeWghKBfL4*(sI^B?[)G89"0MO.6j8U\HGsX1m8VFVU),J#\'1aWmq9C*@bSuk(gBc5o&cAo)3O+Aegr26?RbC;Be.U#V46,!ZN\JWlrW%;W$lojE'W1`'+jNEbY#EG";(?cQin0OsF6O.0eR9C9Jr["2V.[3&2B'Y@(i"q/JA);.^-5"DX?JS4H"I@"i6fOVAj*$f2+r?"(rMt!+>KH2XfpE2rq0N.Et:h+p6JV$0!FN?MPD4t$r\2!)fh3ZXYcDe?aBMPAMh;P2,S@]R:^n)R>+S`@Tj)Pg8!q=eS.a#2n[F98VjW/cnAtJah[YW70B#fb75@3OJlSiFXS036E-j%^5q>2W$n@X.HeilBbUbr.C7Xq]Jn*UM_W:G.`J%:jms5&VJ`O"P%/m(=S7ZkThH\R%mKR==R1m2%qY;=k.Z:u9Fi#Z@BtKAoQZ!s=J/>YP;EJLOSD[P+X=2/@8T3JN)VJSpO6Cb[0Z)72i,so,%XeS2,3J>F,1QGL%k25k21[*gZ7<^%E2'\f@m8l'9%DA[Ju.L~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 831
|
||||
>>
|
||||
stream
|
||||
GarnTgMWKG&:O:SS<li^g2(rC;lQde2JF@<c)#<X8pS6,->N]V!BX2n^9cbMNh[N2H=5mZR@,i<OtY?Mdh4X0iq,Q'3LJ*c4Q0^0_>/3FG9i]o#sBBINMILT&@$##B_#"(57g-I`!VV8721(;+sis3No4k](oe,V@=C=:&7H^!aFf;5+%3r7GZP$._l5fX#=3_S^You3HEd`6m$QpS1DkJ;9i5+of+V'XToWoEUEA+^=1C(I[HXG@"b(S'V2lW<*%/]"l=A\Y]'Uo`2d0-glB8184f/T\O);$_I*_J>kK<9Ore+H'+."M65#JX+E,UnE<sc`,TNc=!)lA2LLrbo(%td*]Rh:FL94e?I"4@ZnTkiC9N[j"A_9c_LJdq.'moj59q/3DmIAAql8_6H!9V3]mm9l0;FpY74EkFPn%BTL][rJ\Y9_n>66Z.:+VsO5VGf9&\<24Y:AT);rZ&Bp1>Jq$;,3Es.=YC:GY^,2R@]_p,Z>\YTp0P87(Tt*0m7W5"BA`Dgh4('*kh;`0?K\4&/3IJEr\&F79m,:cA$l'E_0^7pB04lhOeW-rLA4nMWooh:KXrmLh6g?(e)2cKQ[*(Agl?C".N>1h:@].Y1WlNBH(W/e%S.7BES]CnA+;Igm^/sTBgU7'Z_/8NQu?u--#u?j@AUSf%^GH$8Z=s`neTPt;.2am2qeb>O.Vije(h%90:5k.JtAq[5T.u!L#G1o%RDbmMAPIRAodi?PSn(ZJPIjRW`n%dT^!aO9c]Cm!pM0rjCAV#K^fFb5N-MX*?RQ.N3\C@[/s]03AW'85[Q5^L48#bnS5DsDVb"&7fNH:Q93[~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000001943 00000 n
|
||||
0000002236 00000 n
|
||||
0000002334 00000 n
|
||||
0000004760 00000 n
|
||||
0000005887 00000 n
|
||||
0000007645 00000 n
|
||||
0000009549 00000 n
|
||||
0000010612 00000 n
|
||||
0000012811 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<047cece75ba73e044622d5b8b8c18397><047cece75ba73e044622d5b8b8c18397>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
13733
|
||||
%%EOF
|
||||
186
Les15-Cursor-Vercel-Deploy/Les15-Lesopdracht.md
Normal file
186
Les15-Cursor-Vercel-Deploy/Les15-Lesopdracht.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Les 15 — Lesopdracht
|
||||
## Pokédex opzetten + eerste deploy naar Vercel
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Duur:** 30 min in-class
|
||||
**Status:** Tijdens de les afronden — daarna huiswerk uitbreiden
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Aan het einde van deze opdracht heb je een **werkende Pokédex** op `pokedex-{jouwnaam}.vercel.app`, gebouwd met Cursor Composer, gepushed naar GitHub, en automatisch gedeployed via Vercel.
|
||||
|
||||
---
|
||||
|
||||
## Vereisten
|
||||
|
||||
- Node 20+, pnpm, git
|
||||
- **Cursor** geïnstalleerd (cursor.com) — Pro account is fijn maar niet verplicht voor lesopdracht
|
||||
- **GitHub** account
|
||||
- **Vercel** account (signup met GitHub kan)
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Nieuwe Next.js app
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest pokedex \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd pokedex
|
||||
```
|
||||
|
||||
Open in Cursor:
|
||||
|
||||
```bash
|
||||
cursor .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Eerste feature met Composer
|
||||
|
||||
Open Composer: `Cmd+I` (Mac) of `Ctrl+I` (Win/Linux).
|
||||
|
||||
**Prompt:**
|
||||
|
||||
> Bouw een Pokédex-startpagina op `app/page.tsx`. Haal de eerste 151 Pokémon van PokéAPI (`https://pokeapi.co/api/v2/pokemon?limit=151`) als Server Component. Toon ze in een grid van cards (Tailwind, ~4 per rij op desktop). Elke card heeft: naam (capitalized), pokeapi sprite (`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{id}.png`), en pokemon nummer. Make sure cards have hover effect. Gebruik `next/image` voor de sprites.
|
||||
|
||||
Accepteer de diffs. Run lokaal:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 — Pokémon-grid zichtbaar.
|
||||
|
||||
> **Tip:** als sprites niet laden, voeg PokéAPI's domain toe aan `next.config.ts` (Composer kan dit voor je doen — vraag het hem).
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — Detail-pagina (optioneel als tijd)
|
||||
|
||||
**Composer prompt:**
|
||||
|
||||
> Voeg detail-pagina toe op `app/pokemon/[name]/page.tsx`. Toon: grote sprite, naam, types als chips (met type-kleur), stats (HP, attack, defense, etc) als progress bars, en height + weight. Link de cards op de homepage naar `/pokemon/{name}`.
|
||||
|
||||
Test in browser.
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — GitHub repo
|
||||
|
||||
Maak een nieuwe repo op GitHub:
|
||||
- Naam: `pokedex`
|
||||
- Public
|
||||
- Geen README/license (we hebben al bestanden)
|
||||
|
||||
Lokaal:
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial Pokédex with Cursor Composer"
|
||||
git branch -M main
|
||||
git remote add origin https://github.com/JOUW-USER/pokedex.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Check op GitHub: code is daar.
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Vercel verbinden
|
||||
|
||||
1. Ga naar https://vercel.com — login met GitHub
|
||||
2. **Add New → Project**
|
||||
3. Kies je `pokedex` repo
|
||||
4. Vercel detecteert Next.js — laat alle defaults staan
|
||||
5. Klik **Deploy**
|
||||
|
||||
Wacht ~60-90 seconden. Vercel toont:
|
||||
|
||||
```
|
||||
🎉 Your project has been deployed
|
||||
https://pokedex-{jouwnaam}.vercel.app
|
||||
```
|
||||
|
||||
Klik de URL. Werkt? Klaar.
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — Test een tweede deploy
|
||||
|
||||
Wijzig de titel van de homepage (kun je via Composer):
|
||||
|
||||
> Verander de h1 van "Pokédex" naar "Mijn Pokédex".
|
||||
|
||||
Push:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Custom title"
|
||||
git push
|
||||
```
|
||||
|
||||
Check Vercel dashboard — nieuwe deploy verschijnt direct. ~30s later: live.
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Repo `pokedex` op GitHub, public
|
||||
- [ ] Minimaal 1 feature toegevoegd met Composer
|
||||
- [ ] App werkt lokaal (`pnpm dev`)
|
||||
- [ ] Vercel productie URL werkt
|
||||
- [ ] Tweede push triggert automatische deploy
|
||||
- [ ] Geen errors in Vercel build logs
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1 — Next.js setup | 3 min |
|
||||
| 2 — Composer eerste feature | 10 min |
|
||||
| 3 — Detail-pagina (optioneel) | 5 min |
|
||||
| 4 — GitHub repo + push | 5 min |
|
||||
| 5 — Vercel deploy | 5 min |
|
||||
| 6 — Tweede deploy test | 2 min |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| `next/image` klaagt over PokéAPI domein | Voeg `images.remotePatterns` toe in `next.config.ts` (vraag Composer) |
|
||||
| `pnpm: command not found` | Installeer: `npm install -g pnpm` |
|
||||
| Vercel build error: TS-error | Composer: "fix de TypeScript build error" |
|
||||
| Cursor Composer niet zichtbaar | Cmd+I — als niets gebeurt, herstart Cursor |
|
||||
| Push naar GitHub: permission denied | Setup SSH key of gebruik HTTPS met PAT |
|
||||
| Vercel deploy mislukt | Check build logs in dashboard — meestal env-var of dep-issue |
|
||||
|
||||
---
|
||||
|
||||
## Klaar? Verder met huiswerk
|
||||
|
||||
Het huiswerk bouwt voort op deze setup:
|
||||
|
||||
- Feature branch met **Background Agent**
|
||||
- GitHub Actions CI workflow
|
||||
- Branch protection op `main`
|
||||
- `DEPLOY.md` met reflectie over Composer vs Background
|
||||
|
||||
Zie `Les15-Huiswerk.md` of `Les15-Huiswerk.pdf`.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Lees Vercel build logs** als iets faalt — meestal staat de oplossing er letterlijk in
|
||||
- **Push klein en vaak** — kleinere commits = makkelijker rollback
|
||||
- **Composer is voor pair programming** — accepteer per file, niet blind alles
|
||||
- **Sla `pokedex` url op** — die heb je nog nodig voor huiswerk
|
||||
156
Les15-Cursor-Vercel-Deploy/Les15-Lesopdracht.pdf
Normal file
156
Les15-Cursor-Vercel-Deploy/Les15-Lesopdracht.pdf
Normal file
@@ -0,0 +1,156 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 12 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105950+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105950+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 15 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Count 5 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2129
|
||||
>>
|
||||
stream
|
||||
Gb"/'968TB&AJ$CFAYKW&/[j!D+WjmbI`W@Yfssq/riFnS9Sj[HO/uhrqC:*+aIC>YY.`%;RRlE?[#`1?amt(ifCM!A-qb[$k?W"?lWc+^]G.aln,l;H!`\N"d_L\`)kHL30;3?Io)\^3J<!?LdqjfPsi^o!l]snOTLR7Ugt)f%4;B%"!Ro6.KH2F.$WAQmh<MtM@N0*7'nthf2!:W+sM@#:sJDM_/KPCNO#L35U["O#dYgNNo#21"0[V]>pj8ROhmYaV3A4ik+#(hqI]T2Y1'u0$[AhhlDuD#3c?(Tll9UB7b.i0huVe6+P6/@l5Eb1GUpp*'jKnrnS+H4^d_EJn@K0<K(mCpT+Mrp5-Dqk+5ISiDs7E\m(P<1$l]f;<1#V>'bJONXt:ddCkFI7cnK!BY3Qb>QRi2r``U`MZGLERif.8W?8'Or:kqN2Yf*<87Y]NDI"*rck_J#_LJ(@/;VXds3P(.+3f_(\nc.:fI>%6h)V`O`To+TIg!6@APSIeWN:"&^D7;[XTi!3(5ML(k64u&NQno-k0k:m9b7J:Jf1!U999^Sn^n>3-5%6ufN)@lrc[CnR,,I&8?6"'Y4*'HU5\R-K[8c*l#p#.a1SY's^Fq5%I`N<$)\hC@\nQ6r%mfLgK+8[.b_kkNa)M9TFHu(Vg2K>$dSLF.bBSOHr_QEQi/:+[f>Eq@'ks^4[lcUnVr4Un$<l$ZYHnVJRBae[hB&I=bK6[WipiJcA$$sI.e=g$MTs_WYG\:@ETenELQ$Jl>#j0YEDu?9.t!<31gcZ):\/:``H?To"[?P5a!P\)oaOUYd'jTK*2i[)5"oSQq*O7%\gq;...Kl/jOhk6b6"4NM>h=VesEtZJ5t;C=$"=p>M=JWd)pZW(h2K'a8d/cm03^-*O))6s*06gQRu8e=@+.)N[Bjbi8a5Z$)FqY?bH9V)RC/8nStFg;j%?J/1jSn2T1N+KdGTA.RiR%FlMMsE(r_@OYASTCs*B"i'<BE/NQ'C3#?L9O4_m-5D:#O"E,HtVYu&58m=Fl3.oN^OF-_E^=]`F6o7dU^DTpJLs/Gb0$!Z*.n8f"Z-a0LNTgQ$L[FXa,kI.>nX_r=kBRKTU*i5O0A(A8j%`@3ajW5)1YmRVd3%oC(sY!ZCC\>t[HU'TD:'\bKY+.BMP2RR1d4\74tfs'aM>uKaOc`08R.RBi)Ce.rrSM?o$-7KWiCsaSe]GSW-)YUQ)j`ifr8QP=)%8PFo2MAI_9EQA_a(`FQ&mQk&K&*2"#X\gcROtTnpW"Bl@qn>[TaSe?pTeO8bmO5Qh.h$-]F*U[>shNZ">kC=L:(5K`%F_8e^t0IH=++$O),DG)5/Ku)uJp(eV=E]_8j0Q\S=i:*7^%,Wi7P$/uCV3p6dFP9ZkA3\b90s\Hq3AtXWf(p:cDe_dOVoTS]'0AQd?X\Xu1AeSg-*7!fS+dMD>E`Hg\>+!K39eSR210K[D/s!tV^?BUIMg`1^I?:8Se=Drg$:IRa?=Skjkdq.bLb9^:=cP1k>?drbj!#8;!-jP,;.fErmdsmq"Y+'ZM*HZ^+5On*+Beg-ScqR92hDJ8R=>qQ=6JsJGCI)B9Aulc6$;"^T11[^AW\c>M@tsjHfaaOqarI8iu,AUH:0sLFaAZn2i2=7BFteHrZVod\dG^M";YQ\Mk2sa:C&!r"mc<.FIaZ9_;H"Qh[kgVN4Wg]$t\p^GY'l9C9$tDF]L%B@7h=)5gjViNYYVG?<jNJ_N)PZNRhDPr?Yae4asiY%f7>TQ8h_L,#G:"sJh6+q2I=%)V@bAU?hrJP4Q)Z5V@:KQ7O^(K.6[C[/^6P@[ud3?+pG[@cohEK<1e"8D3Mb0aLd0l6*,OL40hB&--MJu$gLmc8FoYUQa+P71Z<1K9\pTC4s;)t85+mX<AB5_/++q-N>IWRA(,NB#08Z*qVA`5ZS!9^=0r0%((G.@-YB9$PeoL3l]+&+3^TMrU7nj2G&"Z5@\rHIDR>UWYthY&d/MiGL7F78nD';uU(L'^o`#7<a3'K"f":XeJC$_li3,kE+Z\*4J+fGZWn]C(U"SqHW=s\b=]snaOe(!hN2:NtVr>iadC&;e8%'Hqs:N[qWSQ2LhsE;qj2ZPg6(/T%pu^d%"_>j9E8H!K;2A70~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1390
|
||||
>>
|
||||
stream
|
||||
Gb!#Z9lK&M&A@C2b[jX!LhW$H-n.DYAD>pXJ42EO]'[HQ7\Xb=P,OW[-cf_GH\^*`5+:8a7ZJqGRQ%!eO#(<3nRCoJ.0/5.@J,``j07AA'1BY41ON;Oh`Sb%%n-K:O/O.A+L,kbT\ar?"Hpin`sa.1Osh"e,"A:TNe!m1(pX]f%GOgB!=:%],69'!JW/YGH_3jSdBt*JPjq]L5/,*m(DGIlLLG?J"N)Ths&()A0*l,T(r3_5`G],_\qEjkVjh489FcQ\[8-EICcDsaic9=l1g/;m'RnG"SXZY+AU=LE)d&_9?Sd)G"lZ]l+6.k%%BohO0ND;W&c,,eZ;L^q@=?,-%UbIo_+Hp=9EkRon-Gd?QC,%!m#l:Nk$aP^%ra[d_@pA1-%&t@4V:P,3%^,k84!Ihk:=sL9]D%I%*9'pcQ(HE((_pJ%g+dY`na!l"QKhU9p=@B,`JJNcZB5`PHVp";_U4,``RTbl#!<P`7oW6?2!POYkmQNlr6YMeE^Yb>te_Q\-.145=!8L](b@\%^[kIarGN'n!?bLlF,u61DQjg>2`/=[^db=CLY)E0bGUe\AS9@<">lskSf_?l<qn)@7iMZcJ-=gjn\4u5tD'D+c?W1Dqh9P?`be7<`MU%Zbg-njDG-pJ',)ACO\`bYN#uN^^hXpX7?Jod.L8fn?XPn;kislX:5C1THPgkEcuSk]!rDRVp4Pc^/%(fo+$2T=K=!fbKBGc^'gmd9:ank`2:++[&2QI&GV>/ZpTS]f%]nXUi;Qp/J&E,<2tJn,/HCK.aK/X\iDd'5!dOpFk5h6^c1I5"QaJtkkY`8LX?]JChiTcWtW_#YPPiEbmmS8qf\5><Z#amD@CdBgpX(HpN^#Q-qYGl\O'!%6>Qh<\4@6<[4!6"V)6qE)Zme3QB7R<$LI\Qmh`YBTd2Aars,C>kOYF"ooh2u3<Q97VeUhX[Z99"<Y2pAUCFV5s)HGR9;3o:!r4gi`I&4bA+%+F^imcg`;*(GYkV+iC@.&%%A0+Xja5I`k<>`Lmk*\[r%BsW^YK^)n"nd<%iMgKCSbe:Y6#R\-!%*MORK>0<^(3IFfr"L-7pKg<nVYU<WuAH4,PHI35KbXpi\F]rKbQ6)F-'A$+!+]%*O;<'8Vkd3o25tMaQ>G9$;^&67l)e6\%DMrdjL,\i:AU_r%@gHLGsD5'n*qI2qfC`-<>[\0(A+)<rC_U0HWQ4+:"mYX'f"p>k6_Buh^.M6"t%m`P:>24elVj-k+'WMcMfI[sK!2sX4$'o$:@8aYORnno5liW;bu=7R7X1YkC#;I,m1dHKE5EIMRD22/=U)\!-I\h.LG9$2"Z_^7mb0$n"F3T,s4etT^Si<IJd5t+uE%AKTJs5I-2[DU/Cid^K'E*qE)hEUd$I&I~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2009
|
||||
>>
|
||||
stream
|
||||
Gb"/'=``=U&:WfGf^Y/r=qE;mIV?c]SYM$_b/nbj50sufdH21,ZBJnKhqeMIOGXRj)3<4$=;mB,!daq)Ib5qKMB>n!%hjKN-SU"fJ8ps(!%*PUa`BG3=<B-3*UZN=M"%A43=oQ]5:C,E$FW;ATDK$Zl4VF.+?`1VchDtM4P+-s'FI#dYS[r'TroboGg(=nkK80<*6us=,dub.hp-0=o<`j.EE_hXc/sr,E!0s/10TAWPhE2KP6A=$]R9GfSn'WWL@ZsSiOC@:33V7nEu4PBNqUXO.P!B1"*L<s/5K5KmD.ck-4dJcJUY]eiP'E#oflM(0H9MEjjPD-Wa>q9-k/EMFM5\:m?BWDFk3B1&V@U(+1El6-#did+W]f4@W@ko[mh-;ga/1WD]n(H4%^.J3d`?\].p!jA9c!S`j@3?no:1<K@)8Q1WDE$#sra<]8nOu:Kd^Z)F`.X#qakX&`XD^A38It0o.s!k#RHlrFr]ir(,kG#-^J-(*\BJA!CqnE<-L@$IAG2D3KU#+fni/Dm'9A:9ED$r2P4fM9UFdmZRRY<KO!92&$O"'h%R?D\-ooS`Nk9V+S]S40-`epsbs(-RCV,A`2UsBq)N*[)lkGk4Z\Dag4k[O;E.Cm4B3fhAe/6cj?\fa=)ds)/QGb-u1MGaqNWbSQO<_S*W)>fTI<+'75k8#_u5Jh4_dQ`e7AWasAZ]Cg!h(A3TOo>LQ<Agh#jqj+3>cWr@dnU[*2Xa0\o;HWqDO?69::I;S0AdIap"6;i>@DZm``OkMSNR$BRLK\PGH?)D3f>lmW`.EHN8npHs?k:4U%nR$!lW5$A45`r)E7'7o;:;7@Y'q@2e-<ZZ&_5t^-"_D".Q.gpK#a/Jl53L!q6!Q`9O7EnaR]*6PNm;.B-r+r/Dn!W(Il)#oG02;5)Jt;A=eWtN:4o:rKt3(Q8<1I#AUUo%p[23`Cm:OOg-?Jhdo%h*hTrMrl"/3r'OV^?MdE'[A5ugd\E0=Cp.s,m.-]HT+C3Z^mc16lZ8ffa,0s;a62Ccp+0Qj=L/.&$WM\8J%E%m:&=c*+WPt^6NG3bu[eHFQgsbi&7d3m#C;laTm9RdLVf<F=_G$pChAkr**$(&:T[H`#eZ:>KnrtAJ4DF\Of\Em*+_ghBKKRj@V!Zq(+QJ$8B[Se'$u$Xem?Q)UeqQa8cOD<dO4SoO7284)F@3ms9sIu59-K[j&ko4-K)^R<K:W]f8V3:+!aAIJ?kgQKW^.;'7b&J"/rDD7TKD_RmVjppoqt6?L3"1P5nZTM*U$g*/s(DL<7+QS7>,:ibc<3HpVp]j3a\BiFFO/.bN&>lj0se^`bcKQfrB06ScRP?^*qCJ7;([#:TmP'CbYM8s!7H#'"tUXU:2)9J#A0#`GhN.djUceRip+&LBC(pZtTEJo;/3m`_@]5bt#be>nqf5Nn,W$g!,1&C"l^4QL8u34V!oqkb;AqSTW0a`EA`a=O]25du=h[cehBSI#Sti96]Z8p5rllW(S!+$Eo?R9SM"KdE$Zk7][2]r4X1u(1H<QVRf)[X=+dc<\qqhnp?3pXq<>e^3JC;`BFMc:8su)^&1ilZa'aL&E)La`sP=a#So-Q'%fq^kq+(99>pNB&V&sJa3j^J1@RQjEVB3cak&K%GMKM0N3t-DfQ(fslW7!a3csF]-rlXKN[,W\<nJ.io4!71#ue2!&$I^a+?m2M2QSX<raj;nRAhp;gCEL3kTmaZ1d=ZnBdC&e7XJ>YXtc7mX?X$[r<>c5*R"V%Vfe1WXYHWKlW6ng:"F@.kf[,;\C>Es^683*#kE@C4iR(Kgu@8;:ooi]HhZPZcO%6c`Hh+q4N![Bf'%+@,DkS+g3sfXRk3XU9b_VC_u:<L<hQ^'>q/6U$h75QA=0P@0^_\rdFgaHId[J@<$<9RCgTS]Bqe@GemLs:nW(`k_(/I#LR]bh%Dt68djbP2[qJr^0kdkHm]!fY<9r6lk7+Bt&#TDgWM4jha5A.4@B8d=\!dqRlR4ee]+0($R6SL`&'UpHEr~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 627
|
||||
>>
|
||||
stream
|
||||
GasId9lJN8'YO<EN-Zt=qJ&*P5IctF7AU?i'RJaO&pL[DCmH@6Ol)2jSHT/$'uqtVj70de!"Vd4n#QTI,e1E#&/*(uKHc-2*q'a8K9=[3W#-4O*]XOI;?*<BYleH0g.<Z"\KP`&PSI`2Z]H?V(h(d94C'WA17B/_5ZYhWl9FhadSbh%'ua@ght&4E[\r:Z:[A/-Ym7M;6;3/<:a8D6`$RrOiV^U6a#gr;GJ;=Z\h<N`COB0)p`cQNp"e_Pg[<hAkBQ<"Hs%OAJ*:2^q1dt)heRkJUa#&f3Q8c-/OYOOeAo3n([+ZZDLY3N2HUJUf,79*M/3`W9Wql*0bR_N*CO,O;>a`W(/uUuLU]^jPVJrk9<;[Hnih%&/QL3:'fXm'S_%CgM8aH_P2LA2RU_Yn$kHt>fM.8>#"p4,Xcnp)E%JWsbuZU5mZfN@Ot":ZiB$)(Vcc5?<j9DGI2bn"FW8.+0'6[lT589*a@RO4U52JgV&`1;gNQ3[*;&uOcT*aodCXG@am,l5_e"P.S*.5\.s1ZkoOn2YRE$3,ptQYb22AVu3VkgoZ(Lla#AF8'E09s@Oa@,SD[N+ba.mQ.?Qr;#ibP)C3T4Vh>EijbS(B(_0:kp@4>=!HEFS=*~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2099
|
||||
>>
|
||||
stream
|
||||
Gb"/'=``=U&:WfGfZ?fJ:1>FNG_,9Ma#WP<9q4aoV]EP!,Xg$,$&4-"35<08?D;7-X?DQ8i^;]+I(P;Bm'd?0-N;Hi1X#i*:#Y1q$C&AK#1OtX9TS0"eG*bijtb8+J6:U6Hj7NuoFI1kOb;[,f4R8NDL-t_2V=N4c@$02kBS-ATG0),DGVIICO4b?_PuR9pWe/E\;[+pRlIS:hk&`ja0g4WEEhg,c/oPQE!1$;l;9.&R+1XGQtESoiT'/7*6MA_iEXM>^8Oq_Es638H#R`0G>M:2V*kH)`0El)Obm=+KBWP5T5.R3I%/fBq6mbiIk3(B%cE[)U,(r+:P(RJ")nHHT5\'t0UK2iH6t#*AU<R$Xc%(!\2`9L?/((W?k-m'qe)nE4CXa;+$=&G*G#oL\SV&Zl,JBGrsUP/Vc6%VO*hr144%JAZl.=CcohY3_<s?A.))P$QY$b=p/45p>\]D\9s8(h\Mn#+H"bE]3j/*nj8PD6FSsVhlk800aM;ZB,`GZ6!C^7O41o_U+8CQ:AsmRe&+JBnh^@bC^<MI$I>ltsr3o6DndW>%"4]9RhY1@dZ[U^:eV_sF=hc>rq&D(Hl7#p=AtU;"Xk'I0Uqs/,q9BlN]G20:8ttVkMN/UrK'$tR3*c^f/.)];]G-M'#-rdRM)nn3mm=9=:Qoam=:U]n*mM-iXF&XoB9FWF&>V9r-3CTjA>Q)X`>*hiO9l)oAQHlEP[!KrgrHaB.I1;`*Y\c_Mt0b3.PJ3CB0/OgA.XTuZY*K9ZhWRK[ZknfdnW=LMA1qG7Z1(h!ks%GDJ\-<(Gd/ZY,/4HfepGY\DqOp<HCuJ'$$_G&1j'i!gOl3$?TU:b+!6K5,+/MZLPU>3Jau*AHbX8XXh^68K)$1`ml*?T5Md>ak(O:IaMEmPC"C6Hi5&1^!l$dWffIWBp-N(ZRccq,eoVLf$L\@r@T!X$=KCdnn$@`a6^OuD&9)kA)$=h&78MUB^2_gDAM[<0kB0i!3jL5EUC/'NSq+M<o+.HeS8^S6ju,3eW-,tT\*(fRi$m?C%k!&NsA$jf1u;\@Ebne./]5s5rJZW5)=-7>Z=N+[2>1dcF.gcXWNHFbbd=,]nig4!$Ou$2S-2&9N[3?c#(l_O#@o!_VPP5,jAX_F<G3XZ;?D%Kh,:R^cI#uPd=)Sj4.Z+?<gF^U"cj1HVlTFLm/Z`Wta?'c'V6uS)s+Cf/`s2j=LEO2nD/$9:QGT-rFjH!]qQSlf(n^3j&=?rth'U3+j!R&;L.e<Hl;";[Ro$D\*/Vq[AT=GE9I4@Bu<)/DJ&XPZg9XlN\#fMlZ(u``EJ#RQm1E:2t>]8QfBB9]Wd4/*2%+ZLJEfR8tC@j.*NKO&e8V4o:17^j81J!FF)9,Im(8BEe-[?m!rZ@<.PH9$1uK^8F0G1.XUbk`;)Y.]bDb#I70qg2l>HQe5!O!\VqEL_[u]Ukob$S.AAT+MrZ]Pf%g,>,YBd5,!_281?<n`(?Vd..^&TVn,dW_FQ*H=V;BJNUVemo_'Gm=Vg'1jANff0(N\f0'up",URf5Ji&1p(c9_JSZEOqF`"l(e&bpE[9[;NcnXlN4IIMQ@!Yb-8Q[<nC=&Q2&C'N0=ik^+ds6ONr:_J,X7IGNd@c-5U)-<_T?K1?#M=F3a?a"g,">/:SWI#1eL\9BKda81K1tFK9;p7Zi\q,V?,aTiEX8#k#361&Vpgi$I4B5W0RYo\@:C'O(#Ei).dBsfbcL6Xf;:0XXO%FOC-4Wq=KR)\0^msA%MaY2;WtGrW9&>C,?IOG4eK6E`PGe>Gd[fJaFS;Uj&/%>^U)9LHO8]C96@fVE@u^:/L"Mih%Wd4L%scfCU5&.I%%I7+jAoKkE$WY0oOqoL%qOkZ#CgLUCK$ZnXD)c-8Ke46h.$FAH2I%OQtQ,=j*'Gg8]@-dlH$JOD9<j9E@5)?lgeIdfig+)aVNhI$%D)CnqO^XbGS=oNaQBOBSZnF#]NikAkRE5gu4N#D&$4/t,[b^HXkC07uBWf<S]diFQ9Z9EPO)aOP00?QOm3MQPh0d;Bc=47,/+9-rlT]-)-=oCh8?j52+tNoEYc9D(qDi1EF$K6h;$*Oa+Q,+=W'k658k$en"cnc~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 18
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001531 00000 n
|
||||
0000001827 00000 n
|
||||
0000001911 00000 n
|
||||
0000004132 00000 n
|
||||
0000005614 00000 n
|
||||
0000007715 00000 n
|
||||
0000008433 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<a75d82cab81be88097c0b19a2252975f><a75d82cab81be88097c0b19a2252975f>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 11 0 R
|
||||
/Root 10 0 R
|
||||
/Size 18
|
||||
>>
|
||||
startxref
|
||||
10624
|
||||
%%EOF
|
||||
487
Les15-Cursor-Vercel-Deploy/Les15-Lesstof.md
Normal file
487
Les15-Cursor-Vercel-Deploy/Les15-Lesstof.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# Les 15 — Lesstof
|
||||
## Externe APIs + Cursor agents + Vercel deploy
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Vorige les:** Les 14 — RAG + Embeddings
|
||||
**Volgende les:** Les 16 — MCP servers
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Externe APIs in Next.js](#1-externe-apis-in-nextjs)
|
||||
2. [Server- vs client-side fetching](#2-server--vs-client-side-fetching)
|
||||
3. [Environment variabelen](#3-environment-variabelen)
|
||||
4. [Cursor agents — Composer vs Background](#4-cursor-agents--composer-vs-background)
|
||||
5. [Vercel deployments](#5-vercel-deployments)
|
||||
6. [Preview deployments per branch](#6-preview-deployments-per-branch)
|
||||
7. [GitHub Actions CI](#7-github-actions-ci)
|
||||
8. [Branch protection](#8-branch-protection)
|
||||
9. [Werkstroom — feature van begin tot productie](#9-werkstroom--feature-van-begin-tot-productie)
|
||||
10. [Productie-overwegingen](#10-productie-overwegingen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Externe APIs in Next.js
|
||||
|
||||
Een externe API is een HTTP-endpoint van iemand anders waar je data of functionaliteit kunt ophalen. Voor de meeste apps die je gaat bouwen heb je er minstens één nodig.
|
||||
|
||||
### Wat valt eronder
|
||||
|
||||
- **Data-APIs** — PokéAPI (Pokemon), Open-Meteo (weer), GitHub API (repos), TMDB (films)
|
||||
- **AI-providers** — OpenAI, Anthropic, Tavily (web search)
|
||||
- **Payment** — Stripe, Mollie
|
||||
- **Auth** — Clerk, Auth0, Supabase Auth
|
||||
- **Email** — Resend, Postmark, SendGrid
|
||||
|
||||
### Drie smaken qua authenticatie
|
||||
|
||||
- **Geen key** — PokéAPI, Open-Meteo. Direct fetchen.
|
||||
- **API key in URL of header** — Tavily, TMDB. Key beheren als env-variabele.
|
||||
- **OAuth flow** — Google, GitHub. Complexer, vaak via libraries.
|
||||
|
||||
### Basis-pattern in Next.js
|
||||
|
||||
```typescript
|
||||
// Server Component
|
||||
async function getPokemon(name: string) {
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`);
|
||||
if (!res.ok) throw new Error("Niet gevonden");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const data = await getPokemon(params.name);
|
||||
return <div>{data.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Drie dingen om te onthouden:
|
||||
|
||||
- Server Components mogen `async` zijn — `await` direct in component
|
||||
- Errors moet je zelf afvangen (`!res.ok`)
|
||||
- `fetch` in een Server Component **wordt automatisch gecached** door Next.js
|
||||
|
||||
---
|
||||
|
||||
## 2. Server- vs client-side fetching
|
||||
|
||||
### Server-side (Server Component of API route)
|
||||
|
||||
```typescript
|
||||
// app/pokemon/[name]/page.tsx
|
||||
async function getPokemon(name) {
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`, {
|
||||
next: { revalidate: 3600 }, // cache 1 uur
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
**Voordelen:**
|
||||
- API key blijft op de server (nooit in client-bundle)
|
||||
- Caching gratis via Next.js
|
||||
- SEO-vriendelijk (HTML met data terug)
|
||||
- Snellere TTFB (geen extra roundtrip)
|
||||
|
||||
**Wanneer gebruiken:** initiële data, lijsten, detail-pagina's.
|
||||
|
||||
### Client-side (`useEffect` of `useState` met fetch)
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function LivePokemon({ name }) {
|
||||
const [data, setData] = useState(null);
|
||||
useEffect(() => {
|
||||
fetch(`/api/pokemon/${name}`).then((r) => r.json()).then(setData);
|
||||
}, [name]);
|
||||
return data ? <div>{data.name}</div> : <p>Loading...</p>;
|
||||
}
|
||||
```
|
||||
|
||||
**Wanneer gebruiken:**
|
||||
- User-interactie (search, filter, infinite scroll)
|
||||
- Real-time updates (polling, websockets)
|
||||
- Data die per user verschilt en niet op server kan
|
||||
|
||||
> **Belangrijke regel:** API keys NOOIT in client-code. Voor authenticated externe APIs: maak een API-route in `app/api/...` als proxy.
|
||||
|
||||
### Cache-opties bij `fetch`
|
||||
|
||||
```typescript
|
||||
fetch(url, { cache: "force-cache" }) // permanent (default Next.js)
|
||||
fetch(url, { next: { revalidate: 3600 } }) // ISR — herval na N seconden
|
||||
fetch(url, { cache: "no-store" }) // nooit cachen (per request)
|
||||
fetch(url, { next: { tags: ["pokemon"] } }) // tag-based revalidation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment variabelen
|
||||
|
||||
### Drie scopes in Next.js + Vercel
|
||||
|
||||
| Scope | Wanneer | Waar |
|
||||
|-------|---------|------|
|
||||
| `OPENAI_API_KEY` | Server-only | Process env op server |
|
||||
| `NEXT_PUBLIC_APP_URL` | Server + client | In client-bundle (zichtbaar in DevTools!) |
|
||||
| `.env.local` | Lokaal alleen | Niet committen, `.gitignore` |
|
||||
|
||||
### Regel
|
||||
|
||||
**Alles wat geheim is — zonder `NEXT_PUBLIC_` prefix.** Met prefix = in client bundle = iedereen kan het zien.
|
||||
|
||||
```typescript
|
||||
// ✅ Server-only — veilig
|
||||
process.env.OPENAI_API_KEY
|
||||
|
||||
// ❌ NEVER doen
|
||||
"use client";
|
||||
const key = process.env.NEXT_PUBLIC_OPENAI_KEY; // staat in HTML response!
|
||||
```
|
||||
|
||||
### Vercel environment scoping
|
||||
|
||||
Op Vercel heb je drie environments:
|
||||
|
||||
- **Production** — deploys vanaf `main`
|
||||
- **Preview** — alle andere branches + PRs
|
||||
- **Development** — lokaal (`vercel env pull` om te syncen)
|
||||
|
||||
Per env-var kies je in welke scopes hij beschikbaar is. Handig:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY_PROD → Production scope
|
||||
OPENAI_API_KEY_TEST → Preview + Development scope
|
||||
```
|
||||
|
||||
Op die manier verbruiken preview deploys niet je productie-quota.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cursor agents — Composer vs Background
|
||||
|
||||
Cursor heeft twee soorten "agents". Niet hetzelfde, niet uitwisselbaar.
|
||||
|
||||
### Composer
|
||||
|
||||
- Lokaal in je editor (`Cmd+I` / `Ctrl+I`)
|
||||
- **Synchroon** — jij wacht, agent werkt, jij ziet diffs
|
||||
- Multi-file edits in één keer
|
||||
- Terminal-commands met approval
|
||||
- Voor: pair programming, snelle changes, exploreren
|
||||
|
||||
### Background Agent
|
||||
|
||||
- Runt in **Cursor's cloud sandbox**
|
||||
- **Asynchroon** — jij geeft prompt, gaat iets anders doen
|
||||
- Maakt zelf een branch + commit + opent PR
|
||||
- Kan tests draaien, dependencies installeren
|
||||
- Verbonden via Cursor cloud (eenmalige setup)
|
||||
- Voor: welomschreven features, parallel werk, PR-prep
|
||||
|
||||
### Aanroep
|
||||
|
||||
```
|
||||
Cursor Composer: Cmd+I (Mac) of Ctrl+I (Win/Linux)
|
||||
Cursor Chat: Cmd+L (kan switchen naar Agent mode)
|
||||
Background Agent: Cmd+Shift+P → "Open Background Agent"
|
||||
Of via Slack-integratie
|
||||
```
|
||||
|
||||
### Mentale model
|
||||
|
||||
- **Composer = pair programming.** Jij + AI samen aan dezelfde plek tegelijk.
|
||||
- **Background Agent = delegeren.** Je geeft taak, gaat iets anders doen, komt terug als-ie klaar is.
|
||||
|
||||
### Wanneer welke
|
||||
|
||||
| Scenario | Tool |
|
||||
|----------|------|
|
||||
| Snel iets aanpassen tijdens coden | Composer |
|
||||
| Refactor over 5 files — wil zien wat hij doet | Composer |
|
||||
| Onbekend gebied / leren | Composer |
|
||||
| Welomschreven feature, can-be-async | Background Agent |
|
||||
| Parallel werken aan 3 features | 3x Background Agent |
|
||||
| Tijdens vergadering PR voorbereiden | Background Agent |
|
||||
|
||||
### Hoe schrijf je een goede Background Agent prompt
|
||||
|
||||
Een Background Agent ziet je niet — je moet specifiek zijn.
|
||||
|
||||
**Slecht:**
|
||||
> "Voeg search toe."
|
||||
|
||||
**Goed:**
|
||||
> "Maak een nieuwe branch `feature/search`. Voeg een controlled input toe bovenaan `app/page.tsx`. Filter de Pokémon-lijst op naam terwijl gebruiker typt. Case-insensitive. Gebruik Tailwind. Houd het stijl-consistent met bestaande cards. Push de branch en open een PR met titel 'Add search bar' en korte beschrijving in PR body."
|
||||
|
||||
Specifiek: bestandsnamen, gedrag, edge cases, output-formaat (branch + PR + titel).
|
||||
|
||||
---
|
||||
|
||||
## 5. Vercel deployments
|
||||
|
||||
Vercel is hosting platform door Next.js team (zelfde bedrijf). Voor Next.js apps de simpelste deploy.
|
||||
|
||||
### Eerste deployment
|
||||
|
||||
1. Push je repo naar GitHub
|
||||
2. `vercel.com` → Add New → Import Git Repository
|
||||
3. Kies repo, klik **Deploy**
|
||||
4. ~60-90s later: live URL
|
||||
|
||||
**Geen config nodig.** Vercel detecteert Next.js automatisch.
|
||||
|
||||
### Build settings (als je ze nodig hebt)
|
||||
|
||||
- Framework: Next.js (auto)
|
||||
- Build command: `pnpm build` (auto)
|
||||
- Output directory: `.next` (auto)
|
||||
- Install command: `pnpm install`
|
||||
- Root directory: alleen aanpassen als app in subfolder zit (monorepo)
|
||||
|
||||
### CLI alternatief
|
||||
|
||||
```bash
|
||||
pnpm i -g vercel
|
||||
vercel login
|
||||
vercel # preview deploy van current dir
|
||||
vercel --prod # production deploy
|
||||
vercel env pull # download .env.local van Vercel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Preview deployments per branch
|
||||
|
||||
Hier komt de magie. Vercel maakt automatisch een preview deploy voor **elke branch en elke PR**.
|
||||
|
||||
### Hoe werkt het
|
||||
|
||||
```
|
||||
git push origin main → Production deploy
|
||||
(je-app.vercel.app)
|
||||
|
||||
git push origin feature/search → Preview deploy
|
||||
(je-app-git-feature-search-user.vercel.app)
|
||||
|
||||
PR open → Vercel comment in PR:
|
||||
"🚀 Preview: <URL>"
|
||||
```
|
||||
|
||||
### Wat krijg je hiermee
|
||||
|
||||
- **Reviewers** klikken op de URL in de PR, testen de feature live
|
||||
- **Background Agents** maken PR → Vercel zet preview neer → klikbaar
|
||||
- **Stakeholders** zien een feature voor merge, niet alleen screenshots
|
||||
- **Geen "werkt op mijn laptop"** — alles draait op Vercel infra
|
||||
|
||||
### Belangrijk om in te stellen
|
||||
|
||||
- **Environment scoping:** preview-deploys mogen geen productie-keys gebruiken — gebruik aparte env vars per scope
|
||||
- **Database:** in productie wijs je naar prod-DB, in preview kun je naar test-DB wijzen — meer dan een Vercel-feature, eigen architectuur
|
||||
|
||||
### Comment-bot
|
||||
|
||||
Vercel installeert een GitHub App. Die plakt een comment op je PR met:
|
||||
|
||||
```
|
||||
🚀 Preview Deployment: https://...
|
||||
✅ Build successful — 1m 23s
|
||||
View build log
|
||||
```
|
||||
|
||||
Per push naar de branch wordt deze comment ge-update.
|
||||
|
||||
---
|
||||
|
||||
## 7. GitHub Actions CI
|
||||
|
||||
Een **CI** (Continuous Integration) check draait automatisch op je code. Minimaal: lint + build.
|
||||
|
||||
### Waarom
|
||||
|
||||
- Voor merge weet je: code lint clean, types kloppen, build slaagt
|
||||
- Cursor Background Agent maakt PR → CI draait → groene check of feedback
|
||||
- Voorkomt dat broken code in main komt
|
||||
|
||||
### Minimaal workflow
|
||||
|
||||
`.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm build
|
||||
```
|
||||
|
||||
### Wat gebeurt er
|
||||
|
||||
1. **Trigger:** PR open of update, of push naar `main`
|
||||
2. **Runner:** Ubuntu container op GitHub infra
|
||||
3. **Stappen:**
|
||||
- Checkout code
|
||||
- Install pnpm + Node 20
|
||||
- Install dependencies
|
||||
- Run lint (faalt op fouten)
|
||||
- Run build (faalt op TypeScript errors)
|
||||
4. **Status:** groene check of rode X op de PR
|
||||
|
||||
### Tijdsbudget
|
||||
|
||||
Voor een kleine Next.js app: 1-2 minuten. Voor grote apps met tests: 3-10 minuten.
|
||||
|
||||
### Caching
|
||||
|
||||
Met `cache: pnpm` herbruikt GH Actions de node_modules-cache tussen runs — scheelt 60-80% tijd.
|
||||
|
||||
---
|
||||
|
||||
## 8. Branch protection
|
||||
|
||||
Standaard kan iedereen pushen naar main. Voor productie wil je dat niet.
|
||||
|
||||
### Branch protection rules (op GitHub)
|
||||
|
||||
1. Repo settings → Branches → Add rule
|
||||
2. Branch name pattern: `main`
|
||||
3. Check: **Require a pull request before merging**
|
||||
4. Check: **Require status checks to pass before merging** → select CI workflow
|
||||
5. Check: **Do not allow bypassing the above settings**
|
||||
|
||||
### Effect
|
||||
|
||||
- Niemand kan direct naar `main` pushen — moet via PR
|
||||
- PR kan pas merge als CI groen is
|
||||
- Force-push naar main geblokkeerd
|
||||
- Background Agents werken automatisch via PRs (zo bedoeld)
|
||||
|
||||
### Voor solo-projecten
|
||||
|
||||
Beetje overkill, maar oefenen is goed. En het voorkomt 'fluitje van een cent' bugs naar main.
|
||||
|
||||
---
|
||||
|
||||
## 9. Werkstroom — feature van begin tot productie
|
||||
|
||||
De volledige flow zoals je 'm in dit vak gebruikt:
|
||||
|
||||
```
|
||||
1. Idee voor feature
|
||||
↓
|
||||
2. Background Agent prompt (specifiek!)
|
||||
↓
|
||||
3. Cursor opent branch + maakt commits + opent PR
|
||||
↓
|
||||
4. GitHub Actions CI draait — lint + build
|
||||
↓
|
||||
5. Vercel zet preview deploy neer — URL in PR comment
|
||||
↓
|
||||
6. Jij/team reviewt: code-diff + live preview
|
||||
↓
|
||||
7. Eventueel: commentaar in PR, Background Agent retried
|
||||
↓
|
||||
8. Merge PR → main
|
||||
↓
|
||||
9. Vercel productie-deploy is automatisch
|
||||
↓
|
||||
10. Klaar — live op je-app.vercel.app
|
||||
```
|
||||
|
||||
Tijdsindicatie voor één feature: 10-30 minuten end-to-end (afhankelijk van complexiteit).
|
||||
|
||||
### Wat als CI rood is
|
||||
|
||||
- Klik op rode X in PR → naar GitHub Actions logs
|
||||
- Lees error: lint-error, build-error, of test-failure
|
||||
- Twee opties:
|
||||
- **Composer:** open feature branch lokaal, fix met Composer, push
|
||||
- **Background Agent:** nieuwe prompt: "fix de CI error in deze PR — lees de logs en repareer"
|
||||
|
||||
### Wat als preview niet werkt
|
||||
|
||||
- Open Vercel dashboard → project → Deployments → klik op preview build
|
||||
- Build logs lezen
|
||||
- Vaak: env-var mist (Vercel preview scope leeg)
|
||||
- Of: dependency die op Vercel niet beschikbaar is
|
||||
|
||||
---
|
||||
|
||||
## 10. Productie-overwegingen
|
||||
|
||||
### Costs
|
||||
|
||||
| Service | Free tier | Wanneer betalen |
|
||||
|---------|-----------|-----------------|
|
||||
| **Vercel** | Hobby tier — gratis voor persoonlijk gebruik | Commercieel of >100GB bandwidth |
|
||||
| **GitHub Actions** | 2000 min/maand gratis (public repos unlimited) | Bij grote teams of veel runs |
|
||||
| **Cursor** | Hobby (gratis), Pro ($20/mnd), Business | Background Agents zitten in Pro/Business |
|
||||
| **PokéAPI** | Volledig gratis, geen rate-limit issues | n.v.t. |
|
||||
|
||||
### Performance
|
||||
|
||||
Vercel doet veel automatisch:
|
||||
- Edge CDN voor static assets
|
||||
- ISR (Incremental Static Regeneration) voor `revalidate`
|
||||
- Image optimization via `next/image`
|
||||
- Automatische gzip/brotli
|
||||
|
||||
Voor sub-1s page loads: meestal niks doen.
|
||||
|
||||
### Observability
|
||||
|
||||
- **Vercel Analytics** — page views + Core Web Vitals (gratis tier beperkt)
|
||||
- **Vercel Logs** — server-side console.log zichtbaar
|
||||
- **Sentry / LogRocket** — externe tools voor error tracking
|
||||
- **GitHub Actions logs** — bewaard 90 dagen
|
||||
|
||||
### Security
|
||||
|
||||
- API keys NOOIT in client (geen `NEXT_PUBLIC_`)
|
||||
- `.env.local` in `.gitignore` (vooraf checken!)
|
||||
- Rotate keys regelmatig — vooral na PR's van Background Agents
|
||||
- Vercel environment scoping om dev/prod te scheiden
|
||||
|
||||
### Rollback
|
||||
|
||||
Als productie kapot is:
|
||||
1. Vercel dashboard → Deployments
|
||||
2. Vorige werkende deploy → "Promote to Production"
|
||||
3. Klaar, ~10 seconden — geen rebuild nodig
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **Next.js fetching:** https://nextjs.org/docs/app/building-your-application/data-fetching
|
||||
- **Next.js caching:** https://nextjs.org/docs/app/building-your-application/caching
|
||||
- **PokéAPI:** https://pokeapi.co/
|
||||
- **Cursor docs:** https://docs.cursor.com/
|
||||
- **Cursor Background Agents:** https://docs.cursor.com/background-agent
|
||||
- **Vercel docs:** https://vercel.com/docs
|
||||
- **Vercel preview deployments:** https://vercel.com/docs/deployments/preview-deployments
|
||||
- **Vercel environment variables:** https://vercel.com/docs/environment-variables
|
||||
- **GitHub Actions:** https://docs.github.com/en/actions
|
||||
- **GitHub branch protection:** https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches
|
||||
271
Les15-Cursor-Vercel-Deploy/Les15-Lesstof.pdf
Normal file
271
Les15-Cursor-Vercel-Deploy/Les15-Lesstof.pdf
Normal file
@@ -0,0 +1,271 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 5 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521105950+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521105950+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 15 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Count 11 /Kids [ 4 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
15 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1912
|
||||
>>
|
||||
stream
|
||||
Gatm;;0/3d&:WeDlq;!>p7b.\"PfK19A<-eD;)Sg4_1FBZ"t(`j8sMYqt7E2(pG"oij8$W/Wb/MPBO]Z@6jA-b]gDo\/<)`f*iLOi<5N8*#QDFX.f48,$]*5EIlb+[0$maYk8C0WKi&cL^87@^o&7D4U2t!&`4+Hi4.)(Qj9%4aU="-nHAN4#Dg]2iJYDB2A)ISCPTDd"M=!#pj=)g5XZ>-L08uP(sZM(W7n9=&B\8(L#E*Y5(*9O%<'crk0Zl%"MPY>N+N7KEpgd%qL"CfEF-^@r$BrM;9OYeMrue,KAd4*X(C1.3,p[cD>Wd5%a5+SaU9sTc/+-&3":GHm!QZ5h&$d;$<5Z:G_'Yj=`ePk-#h\WE]ghdAI8_R!lCq`G?VU3I%*')!Kg@m(paNk?)H43f*&?A(-l_rZ(RUe40,PQ<pOp<<R0k[['<(>B9>V;N!-nrq:1P?ME,@p/3IO]I%T/LSR\\P->@k_NRM>!2O_!9^Q"e2K_j3k)sf\cB%>2l.iNatW3OrUBOd#[cMfbGD9O:Y@m4[Y3J>!q^foEUR!tBenEMn-&@tstZ$98#XUN2VB:d@bn1[7&]qCq4-?HA>'f&E.$uW4hGR9_;/6<_gq'NF=-JNZOR"H:=YWKN%,iDA:L!IDd.3pAMPF9Q$8Ad-6AZ!JJW/,"&_Eo"W^Yolb0aF-+*4_*k4cJJX;8;]m$O_B0R[C=`g(S4YE-X7uVa\ktboU?Te=beY)fF5*fIT>(j_('80Hi;II1VYU6`C?<ro[>/mAL*15J9:0D6n*6O-A5)4,Vd3'Sqgn7)n7HSN[O[YD4,H#KXIYMX;Jhj]rM]O/(oSSF%8N!Sj:"YZbTlq45QHrWhTR6Cj1DGV.D!k8fXD4YF]a[T:KaeFITeI9l1>1JHblAbp]A#'o0B0p6%;&pY:dj7"0.R>rR=ZH#i8o^NJ8;$-Ukncj-i6^d=Oq6]1i-5m[HO8DK*4%.Ok^^b'7@D<cXr!"h2)l!(8!KIFXN^o([0+Tk*m5>/u>0fYN>7E!t;'JjKU&q&7?Kr;!X!S1AW)_=1S8`=p>;`rCRNYjO"q8,+Ucn?(I&A^:>hV0aK*^)phkW$.s3l972$P:"5<+9rF38E9if.=Mhd8W`4i;?u_aFPEp_<H4`Pb4@,=1!2<C1Dofu/3>8#8ECk&sE)<b9ZZjtLBc]eX(kQ5\"0gootLP1`sZ7&D;pZs0G5;eIKAr+b4O]4KiZqM&FI.Chk:_ZTl\Ihh#sLG'u?iDM7n.Q-4ajY@1<):kI#VX9]#O:[LCj.si_F'.UO-ZR[^*b/JMA7:e?>c)3MC:!ad?,r@*[?A<dbi3T;nN`m*F!Q%,FD/-rWIbX^rsXD4I(acSIJEur$ACR.;'q46U:J9WV<]IUN(PVt72X:IR9JR+&%(+:g4*[2i#T4Y)1oJ)Bk`3RW^X%p\(R'd\ZAs:>\BL_Z=.HnMk,P\>Q)LWm,Sc]R`M^8@H*6O0TB[peWn*t)X'h]b#,U)kBe0@-8+/X7PI(\.a?rC)X^T`T-W\C&QDSEc]T-=]nFc_:<%Y8E`%/#?jg\m5_1:)k'=KFM_Ip';j-PC`5AL1K9Tn&?;Kd4d+^#P;3SA4)WN,p?DfTe)BWMQk!@<J0't%[>=_T?ITi6PM[:U5@ruDW1tQd@,SA_0g87m]3^g:G:BHFJ!7c,_,Z%jZ3MQtP3!hRV%leRiM*8ps?'q.G?ul*Og:5kG[um<mR^S`VD*KeDNi&[V4N)td]3t2n^i]IhjMr9H`To,4m_X#`ndp6=*(_3,KW#TX%W&&:&?c>G,8k#f(H]F&L,/ZJ@#ecV4b`;(EuM1eH@S#=._a)BSememMioGG:!YjoTmE;,`D5ak0\"S@"f$Lqq:PPD:n0&cZ[7[Lg.Ct86.Dgi3&VFMh6$/?K4+W_;^I)0~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2006
|
||||
>>
|
||||
stream
|
||||
Gb!;d>BA7Q'RnB336UYU?s:O409TI]B?IXsB"8T]4QQUo;%g@HAS``2l1DaZO`,5\*>jO)9i='1o]_n7*1`!:=SSWPru_Mj5(p`[$C&AG!n8A'$t$ooYkPOqR)c_s!jRGjq[A:!Rm4uk]`_T$GWt^J#`BA4*H2--6MLU!Y2c$ma:q=>oI(:==^3pO"EcldXf]'h`u&^]&D-OOeb-=t_MI@gYEf[;XPO5;1MEmiMeY>lU+UXu(dn>j]?d\=V4O\LQt[SWV]o'UXFLmm*0+>G*p%[7(MXB!C7uiB`[WDY_sd_^:X!,cI)f[67Ifg+^K*?k(\!S(H;_SNa_@,5?lbd3aCtb;Z!'tA$"DX)7pG=*]OrHD_KJ&^K=Js,;Veck;3,^+=cV^)0i?8;#j!,nNk!?(T\#4_%2G*GW-68&H+jY<[S<e_XT6U2J-%Y';30_?FSV]^!a4nC6-i_,&`K'WaEhj,7S!'C2sZ_mAR:Yd#;;&)Pg8S,;7f?=ACMes:hBeu>[+_41`>]5L!Y_>eS>EI/+J&)f("_i:`oZ0*ra9(Ts@)&dFkQ?_?A_ql[iC&qn0c*?L&&-]=["!"K(;N/.\gnA3u-QG;dE7<<?pG0n_P8*-sUbSfOhASE_dW?m-0h<@j=gi(%?=>Y1@"Kn?Q\,bY04k"/C&3g!9AW3Cfq3A/eF9L^KTN<]@->%]Ni&1`HI#@s:c0Fj?K3nNu(!bU`QHr1d"@=hF@*(IA@&`6sP-'7>13$n<tfh@DN3ooY9g6+D8d+33"dR?dmc]sC-.bVCQ)baLSFZ-\NT,Zs,$pe#82k0IA&SbIX+7(Sb<g]_gq]j8Z<[%!h\J,QR]@T)Oj"kM8cQ&_-%ulpA4"U'hf3q?[Xr7]r+2GKf-&6D<U%AAUL;h@33L&RA2oj7Rast1B^Q+CfYO<_>o0Ji07;._$Akn<Q8#Y`N;TpneL&n;+(L_El6ZH`:3lRT%^/OPXH.++S24ZrI=dZ2kGb_#^<4`kCKmN`[RO>?KUWlD[:cX9P"*FCPLh1aT!$Cb.Bbl`\KR(<a8@b)uITDOkc7kjPZ+S!trcKhZcANQD-k'Xk_Zju1EWl9EYk7ukl)@6jQg3^'UDAF@&<"=*-E7>`S\4YMc`YC`aG-ZN5(.ZmP5%^BN*r.>WM3B-X9nf[ld"$SA]Ug/Y[g9?LC9N(nm"Uu-SN73;1EeC#6lN;^=pfKWRfNh2$o5W^H,+7FfhqOXHMWj/4'Y?Gb-&r.UQgtCCG/ekGQ:Lk7W-*_c.DKXP7),1!_/)ZEb5geAB':qlf]s)5q@!Y[JcX.U;#*_g0eE[:<([&XHS0*,6ETO'%F]]-F&<'D1!5arMZgO-WW0)"nQ3gX(IL/uhLF"RQI]/\d[5Jd^!%fiiu1bIpSJ+dP/k93LTR95U!()k.0Rr6br'VOkXPIRu;;)8bsmErEfSdm^^\HIqqS::<'Cm:lmB"ng'h1;?D4[e,<R.PQ[X==9km,%L9[Em2>@FS$DfpRkuJj`@Vg#p@SsHXeRs_quT?A6;@7;1B)`2<7qiN<8T\<YT9IF>-6_/=D5<ci^oh(9AKA5mMnr)G0D3lp8'b69Vf?DtNh.h=H%6mGs1;IYdrFrt!V7kY!2=<+&dhW%0jL17aF$O5>Vk5l)67S"&Kd??gQdgNE!F<q)M,k#l0g_J[V[^DF]WM;nlX8q=P0F'VPb?I[3T50Ir&BCi-a<7e+p$LG)GEZPt"i[&5XW^cqk87a..*:ENILM[]B1FDO88(mYpma:^?$C;U9mcB#Y))i(DmM#D4*%q6$2&hc.GL$J*@%YR,lRJ[!,G)g-LhDlJ)uhid=*d+.PSMadcP>S+*<Z#?)N/o)8(W$t^VHS\mqhr1$`o?6&lqA&%^@oCeX=p)126!8C[(LrGd0D7rGEA#k%?+iS/A(WRTq99VP4C<a[2;r;Ff%C%<Y'B-"?NAJ^dKh-4\0lZ"@4A0hq2Ol6(&BZZ0E"'HL\5Op<cO-%X"oH44<TXD$'-Y[n[U^&J*YR6H'~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2085
|
||||
>>
|
||||
stream
|
||||
Gb!#\>Ar7S'RnZ;3%.hO.:SaA,`9am%)&A9ZDL((p7X*87A4QEZK+W`QQjM#h]ZQ.E\;Oa%`+;N&h3B5(S8et0qe<bbl;nln9bLof*iLOi>@qP4;]-UX.f.0+^FNPo7=E7\fh:k](uYhM3EKDL^8:I5gk2(^]R@f,%W0Rj1ZXD!>Gm\:E0Qa&2]Ii&BT;C`JsgK&Y,V0O8)%pfCuK@P?PY]Lm<.^B)RKm\:UiAZDaor..[N_L,8&,4oT[3Dt8`7j(Y*_n97m2GZqq>o)P@&p7IU];HE&s?E0hooD)'keIE#6@/*.5lJR"1-U+Q>Ndmis?p<MFXR^??q_"<_=TiUIk<8[K!>9dVBL!k`>oMX(Cim2IU1:"b#uA@\iBF5Zm9JrGJ5GOQc5`1O<?>S"i8#9c=VEl?%W@_F9LIL4&D)V"=DX>SCTIT^7#VI2pSl(".;L#'$Uh+]fV,D_.1WGBX3K4SNodN%F\GKnhtlOAYBB3!i"\Y__P;0_N"R`\GC^p?.t),lN-ER$YhI@RY8r"!n3b'2ip2D.+i`mfrWcFS<RHS5$#uDA4S?nY']W=rnMJ?7gS.#*C89eC('U+#BMXQSpqq?O;+8Z8'76cfhkqah-o^,+-lRj9CThk_RW-2u-bM?bk'00gkJ!K[*=kl[NU;r^&;!`>#2L0MXGIrZa-a(/.1DH3$]+7c3@f''B4;WZ%I=Ki[0%G3JWu_GZj!5,/00t\[=PctY4f?U[(!LKV$itW"+"0FL*Yi=$OC%V#c0PjhI$?2Hp3q\kA$;qK?m[W;dTb#BVJ$t?JJkJ)Ba-`l['H])CR)MB/,>]=^37u"qt"MX"d+-NB,^DbKX]E,b?A02eW22Ss:$kit@qMoM:]NFVp0cRo+S#?Ira='S*C9Wg]9$"(Pds:Ipudn1n*dC\2P3>/RDE]:5KWT*RB;(ASRuF]%Pb[8>$p9hqHUm=?c9Ke*'icc/gE<J[k]'Ute%QNSr(%JFJuO3QuNL8OB?X7_1H(.G*k7F"NmnZ;f9<U[]V<Yp.Mg[ru))X>)L$N[BA\g[b(o=R%_WQOr&X95n\H?e$KU?6G/g8okeQ7=`g,pG1Wh:]u#5h-GSJif79Y2V8(+WX^TrP7"#T=m=>F7gPKdc>4L5s>OTbb_;%GKQ0.b(hdSQ$"at?::[$^ncddUkE:l_->+kN5Q0KEVaWt[Bf43f8!GJrFLOc'VdTI4,6]%c1%n]R&LEHdj(ejZ'^Jn2Au=@2ZB5'R7^D3N-@7/rVVD3pNO#\c]t%X]d]jKDl%T`K&3CRbEOLM+N]cr0g^43f<6W=03-$&]7;M(Eh)N+Q;g_Ep?.1`ra(cTCr0\h>HWF3`k5u!e98YJbIGk#nNW[$W)G,.3fO_8Ea@7i0gaBJ=rPl6(`Lj3=rPl6ck_SKZ6#CQ0Fo6u(7H<<BMkcEkC'_6oeCh1#QmrQT4Bk+M[EZJmXI)8X16m6[i[*djk.Jk76Z>oKo4mWYXn+o5`IfD<`/LYn8%"J_?dW@kpT[a9:CrJ+=VlBmDN^ucS`Z*F.GdsWa=]0(F2k;=ieW!BtBQQXH>SXrIa/4klj14\0mo$L;/-oo]nR4=t?C5_Wq1m2-$+2oSQhE_NLO.@+Ug:`EKffX)kH6[B!T3M=XLU2q(S%K`0A1H#T_f`&M=XTI4+Xp?)ZNH$"pLQ4U>dQll6mm#L+A85lqah*Gj7a7q(Z8VTqOF!X\VI2NdiF;MqgMR0&u?.5,I(3@`a2q2ij#+=(Cm.#qL%KKLNJj#jD5h,$^fAC[hf*lG^W,!6('F,cn^8hLp%n*R6LH`YIU<<*T42SnSnmFb:B[Q?6`k?Ug=,920_poO&O^buupmA3"eGlMq1PR?+H30N+(hpqu>HgbR!QVZ:5[bXZ@f=]3)@[VPqDbFg;>i15`j9-`2=!;mRVn+UO-sm6B>]DZF-T5JUd-u:oq&,3S>$ee?#TB3Cg4"[qHLhkj5\$#2ZMnI7@]u"!r=JLU4pn0.Z#DK=U[<d.P_1;ZeU19'V0kSR3)'[I"CAs1%_NIXsTMO&>b\3p?"emnbm'Uh8s?S>Bu\?^IB+594'oU@ht;JP+:'Fn!Yler!REUef"~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 704
|
||||
>>
|
||||
stream
|
||||
Gas1\fl!<6'Rf^W*6hCpFZd)e?LjD^N>a6<2qjTn+"6Y1/H[G`-rQ=[mq!B(1hcquI(B!AEu.IT\n1W]G/3DHMfT3(OK9Q^f17Oa2IX?<=%6=5K1A[)?@i!P]hkh6J_4r%Q'ZI,Eb@8:PMA1eTIA)hBi,&8:7)0n7TcS)SW&Oa:Z<U:PTjke@q5GOnUj$Wb4FBsl:>Oe&/]]6+fQ&2dTjlG`^k84\pFDL!q49SW3]3$k4mA(+knhU.V3UC0gD`)D;Bk\]Erd*05l$-BTA5Q6up^r*T1_8GYIZLj'RNZ1'T=Q;m(H!&*\CEUsK?++/u,a+JT.7`bHumo[F8gV3'P1_8`l9Hs89o($6tm]>oqGC\qS%[[_%Zg;(feG/S?nKfufQ]hcWp0?^ISZ0n:Y2UB(B?$CX7.gMtL_.Y!jBp1@LEphkpR)$YYPqb"Ma_<PG*TEu5VU(3<=>sCMh%.<\4G*#K=E$4Ca?e]eAuQ2\bY;VRf<nUlZH_t([`Vt9&$e%HKN$)qAX.sp\!u>bH&Uuk/8u9&?!>U"@!Qi7<p=JX/<?DFPs&[mE>pA"*+olORo<LkE0o_Ala.f9OV%X*'/PbWI/\dZ2PD<JcqI`=%b]%$MYbqrU)+FoauN13od%_3Zma+7dlS@!F7%TZkLZNqq@qh!2s%6I.uW*TK*7$gIa<M>24_Le&DOL8FE#%p!mdba'E~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1519
|
||||
>>
|
||||
stream
|
||||
GasIg?$Dc-&:N_C9_5'U8*g*TQM0seBp;)dl_q8OIN5!?K@!f=7Nd=7fD5*1?!tJY[OfP91,F8ipYM<L5k2bYs'u6$IL\ji)))QlCLE;rR%M0)mbN/A#<\57&>?%Y306[u5:BT>%XpZpLdqjf%>bE<8B$B_OTLPl@E:b`2-dof(m$GEToqYTo)f_Tk:]BA3DM/_*sBu4rs#)Nn0/K%kR_A)`F*AqR\X1f+UM_/kehcMUXLk4;ts=nFHrqc,(m)2;CCcN>]Ln&nT9.`<GAki9DG1S3\.WXSaYFPr!)<PbZqjV;KCbA]Vk9qF9Tkn]MuN&\dgf[=(r_.hi%(RY%\h'i!n%p.O#!)EA(X(E)5E/-m8[1^rGCuWGgTpk4+O0.TLDGX2[aE('_E9ijUh&kJm"G.E5%02\_rkjF`@B=Dd'uZi^U9D!s0@iEOmHUb34$ZQpUZ:h9@Z5W6n?NF(b(\+VANbbs=oUQ:-kQFm5q%6]#A`[Kf'E<-L.$KWeMAYQXeK[n9Dr7[&Nf_?';ho@[7?kMu'8c2/o:Wjq@9S.[tVXZuc3^6#e+JOl)GPt*PF\GnL`G#o]K*_"shf8mq1VM5i:Nd'qBPT>S36RDT&+L-1HEGX*UfG[dDahCi-(Ia0SmTaV^-A%bH3N&$?`3pd-TD?nqOo0coP</9M@4qqGLr:tQF9m4#d5%Xj2j!VCP3YM4K59!m"Sag#mceQm#S*r-Lok._tkO1?oF5go+@N-cj",bUo]\Gi'oD"pL'@-.b/l?]3r"8bE,eJ3RA7Oj54Fsh%#TL5\.DoLU\:cLbg(Pjs>DFfM_&!#gRS'fB'N`ZUYkM?Dgr;)D5-/KHMI:.`H7XD-8,dX,,'qjND@[H9Al%SkZD;TD\;,&3hGS9co##CaF)37&X:jVj!%\)(^[nllU0[JqSaIg@*:7V'3rYPg>hk?h`TjkGDZi^SJrO*SfpMp]^Lsp.=+ON7f8.gg6I<Wa1Q-8osbas5O6O;mjTdoFEY+-mPj8pm11cXS%SnP.<4J-_@pPjl]bMkZ'^fQdLL>':j^BeXJ$;[V<I%;8fV/8h'Ws?>K9LU7fuoQqQ&e)_r@rO:Rb@aE2jSle/[cP3QF[/mgJMMmP'\CnO1kJ>h;CF'KU]_PT,@=c9LAr@?07Xik-_/i^++PfEKm.*@7pfs56cX_eH,2)67AkL\dcQ,W;XfqlY\8nfG,p)iob.ip5-06qpSRlGi4c_54R-@#73J;Q9P)0R`TCbY+%WZM]NC5e4YeNdr^MM=P;kK+W(c]9bAWt#,Fr9#!;HN7-sUjh6pk+L8u-cKpBB5fl:eK?B*34jQb!0ise!0RrCBDgSYHEjIW<"6kmY-<[eKfXglh`To96`Z,>a'a;;i7r'fYW>;e&13fFjfecNcG7NPSH5>5$`<3@d2iJV_o$/B4WbJKN2I;u9lYf'Zmf`#3kNqNFPtM,]n(kt/Z6(9)"#b/^qU%2RW;9^Vmfdo[P]^Gfl/W([I;+LbHr,0ANdc>06ZmW'*WX\"5I;DC&~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1952
|
||||
>>
|
||||
stream
|
||||
Gb!;$=`<%Qpg(Yo6\0,h'/QEoMX%DHBt@d,dbAPEr5)"3X]U?Oa[t)k^Q5sS[OI)M[1/%iaR;=7JDunqs'Z9(F:>(n))*#L;d_4t@=/BrO0+HYZ+986*Y:h<-a8.)Nqn3s=W)ISnA#MN#jN)^]AO#cKEq_5Gg?&O%pHgAZ,M7gSGL)+,+-CSh`C3;DVL!$,p)6ck4s`q@E8f^Dl?#YlQ62G*NW\LKBft"1=,]&SAN[s=77g#O*mg^E1Z5!r*=%Pc:aPW[L[!eUU-\NMc*"59\lWm?`#;'+2R^j`fCS3!IWM,M21/o_nT.S0G!YMPkhZjqc_;)9EH<OH%Rkh!HG^pe>ImX%eW[8#6f!*-lCpQguXW$[R]Uk\!C.):FLA)@*KJ3/se8agVXc0h%NuJ)@i-Bb^euE!o+T:d(sm-]r**J>mQS.G+;YChm35-m/YND95)_EM?G:IOj2jFN</4927G')1>c:.VpC@i<6!+<BZstZ1.I*PR-al@\;p&HV)!7f(W'oADa"EqGm-DTp,7;.&M-2Z?%[X@dRC<)cHqd_paM251&hF&o5pB:9#F@(:M%&'l$Sab?SW37d*>%<=$lji[sXF46!UrUX^0W,g(H/BC0ErLWtZ7nY"P$;Zk7O#WtO<VT2dY+e-TYt^5C+g\0t<s-)Dr('^hh17fsY?8p$!P:4],d\WP<HXIY\f]GP%Qea$7OSK-uKTXEqd@(^6-\_#m8&3?'CK_bUR,16*IL6g]Tg'1Z3kt)no(5#5nO>;AHK51W?MG)IYjJYChqV5d[*1a"TnHGH"H)["fMrnjamqD9*06mVQGCMA]ESXIrn,CGG\^'fJNiCY#2;Cf_gaBN"_QD5oS=&uq`bM8n3\:iGm"kOK\]&\]Q=$_V1*00MUfMeM$t/(>X?q0]O"sh9""o1>R,/pg4g$lcVQnPu-HTd4k%Q<OW.5SI.$mSthoOjiDth?,2f\O4A*ak2b#ea$1Xe*J7V=)FA1BmiL3&bk20&c`dk]%QdOO"GjNYD8lT*t:L"\]s_:`Y<lIchk?/,=dO;luMdTTn"oY%^aN%*t/FjBoqJ0pkr1`V3@^5ARK;n^=<*O*j3+MF9[gTbm%'a\QXkn9cV9M1P(_@VCu.U><(mkE5j2P8dP/RM&Z.)?TYTWeL/dLp*ka'dn&>iHl5R^DY^mJe_mgl]jrA-Sk[:ihbb103'76jbr4<#3KrUm.U<(l*9gSKB>4]N=qZ2f-<)//u'Pg.<Y^W8U]&nI-pS<N_VmCS;gqU"j+$RUQr2YtrVPWarZg-B0RQ2Yp+IK'3@)GI5k7q=Q2U$2A^;.;(Ebq$uK?8/Z"Pe+oKYQVm+C)"c-X!EJ=K@V]Z/4]Z=.^0b5'FDH`p$>L(E9ANkV-e,!lE(.-\6EB#*';'9jX[':0!=<SW]%<m3qlJK#;:)EfW.&3E?XJW*CYAlc4YG4`MOk(,e(U>\\`nl2qd0S[9)`p6qeIlARtL#=meCSBC"m8`!W:H,_urG<3-bZ&[-8uXj[5Q/?H1[`e!^O/5iF]@GAMUHa'=oFr"d=!6<moG.W49%S>-g3fO3:L#GcX@^4aEorL!JU$HZoUnt\SBDB;Tsj4?!]%3E1!^^$>J*umbu4q/"SnTd!o!m\C\_2dNV<*8+EZ.d[JGI<Y%MU(3G=q@gd%#^*C?=?:$<EN_kMO3I?$r<^5B"U#X$40H/_QiSd?8oB,F*KqB+<9#s1P`OZ.YbPu&C,Xd3"GI05*?*o%V^9C)Fu#84FBkm%J;rfkR:l0+!*d]4iHuu`<uuc+K,Z;S0"+P$$s'=YQs6ME)L'<I<A8@@>F5"oGfcjIP<mh])((rgT`@rPE8>G0u0T#U>=(galmnE%<f3@^M['JA?ur)5I%p`L9h8h*`I.UJh@V6qT8u-M-ACV2DAs9/Rh$Je]_Rl*@Olc_3J-bm&Al;@$.@s6g<-;qc]mm&7GGE=&9f,~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1559
|
||||
>>
|
||||
stream
|
||||
Gau`S9lo&I&A@[:m,ZO'R0k9+O9F=%e&H8dC#do^Ep3`O+UJbfW$GrnrUjIc9<p.;DEH<NZ63Tlmk&]!D]oHO^1d,+('&9NE.1B0176@<El)8]!T$Pa5<Qe,(@r).+T*?j5a&q]6+QInKFs%RQ;A-A9LR*U$VH(C">F'sa#&NK_>K9_J]*Zm$P#SO0G;cso`9md6Y]B5-qYPan,*jE]nAeEGN`Z97PM3Bh51D+J.nQ0E<t"pc@5TakrnrX(TL)of&lI+?)i!L_s`Q'E:JBt;,t3-<<b[d4-ePpThPDB9NNorj1#Tg3G0U*pE&=D:_h=gE2[M5Ddfj265p7$!R@kcdiKoHSYL2?L^8Eh\Sr^iD299p%sR7?GJ*+*/o",7KF>uVB5*<&-1"UF<Y'usWJ;=ETfAis0&*lg')NA=(a$'L?G-g2a)"<+dZ0fB2"mu;5Dkt!rr>#jUU^6cVFGOXd9R6l9u1:Nr$F5$/(fLH79D+B.@f-l)-L^$]\!9,Of$-Eo-kIDJZ7PZoo'TJB*_Yg&fR$f<cnRDRZ4BuAY@VT9UQ5cMo1o0*R=Dff[rf]a3BamDbnB4R`i(H-.k;\b7Nesa0Y>:Oi_A6*UIkXIqj(6-YliL'kC@93-]c\3oi"+"Du@HR[I>0PJKT11nO2XaAk73c#3%g7i40sT2@BOPa2s4_n]O!H<[Cf+I-Oo;\N<iRrj=e"*uKHoqJftFSB6a;M-1/;sDN0AL[3j'=q*gma7jFJtB^qU$OIb0CVlj2Y5D#=tlQVRAj)R"^Ks#S[IPf,HJk#-lLd?PH(8EIt_nk":J%i)"s#`kp-=1$?!"/'#JMtSRR'kh.$r(NTM5rglTQ%Uo9ZZHH-J.1)Tpn7D?&W;bnSGRh@6P#$3U,Fn["f-bST`Ti4aTC"'M[7c*>=@GSP#^tCe#N[I$K_5h"@^ppX`=nkhO!JG3_;E`WHNA`:S?Jle-rubctl;Jj@V6P%9lo'($ptHUh">O=nd]a;Jdj2e^(rj!u3JI0'T$\,4'.>saa),5nF1K>FmhY>hL?aVOci*WX7eAU<2p;H,!U63fL/QW=>1<P!<DSM0ELe%hO-3V$B$)4Hp^SfC,o:psr4Np&P.dLbYGo#'SogNrlN'0]:V/kY&k6"\N:%CoqIJ+X.OE8[$+3doe_#;(j6>d6n@*K+Nl)5[*RKmU1kYfmdg6dJMo4a4hap)m5fD'3n0I@Z"qQG+Qi,fN."PgHhh1`OY5c!QU4dKG3nkFgcVgo3TjXNGCj-4uV)29n>us%)98+@Pp`;.XL#kVI2Gn-[bTWk7e>:-EQ%:i_D=%Z?Ap?*cY&h_0k'SBiMU^1C&UYX_.#:;"J#W#XmhR=Xf648'CF6Su<Ah5B8kl\/)c!5FIAaku!/V%Jl^_ADcVjs!08t#nG^1^HHAscSrMCo\eNUlMl?oL(7e#$K2M2@?;RtOoo5inj-g`?"es$!-do$l.]JER<(^f%j.a+n;E4rB>DJ*5f0SXb'+"ir5F,Nt`V.1knYB`!3'jb6X=b#:@Z@)$nf=%cG,B)>!lfbo'R+^j*OWo>M(C"&T7K~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1871
|
||||
>>
|
||||
stream
|
||||
Gb"/'8TWWE'Ya0?]L`WR1(f+l$^g0h`b@>,4fRHkm_ousKI#7s_31&3o[)LP#O[a$=`RgB;TU/HO21Kj]Yt:-ITcUgV>tP3nFA^E`aLn1AeQ,\VsnS=rGE58EmJoZ#YB`rJb[*0TU8q$3%f3!mPBsS,LWp7QAeB!9Am>+o9]c:K%Y4'!V'F[8K3IrN[rpiqZUt"8(KS<>h<??ho[=+GWp+OktoUd&&E2gCR+0^KhWt:49q`4*.@\FiWMI1\LPLNed6`'$5G0C\E)Ftj-.T)=iP$L,'^/+<2/t`A^rT(?GZ9EiAJK]D,)&bD\`:OQ,"BT93LL^"+(g-j"(0j_YK-Km=8Hu$L1J[KV0C_@].$oJVK<(G&8$nM`[`Fm:hEr4[Q,"7l2jQ<An9WbJt<p>8(1'%<%VEbWthH+4p;uo[naQCO-"B6AP\dpPHlY/K8$E*f0Mp6Al=O,l#^ofl$6.(hk$p$`f::o5.b4o"BmN3d=pSg[H&jN)@G<irU(W$L,XcA^8@MKJd@J^Q.g>)W)q?2SP"Rk%!2Li*WE,<!brB:\8iE:ZYl5i;0ek"EX:0q-/hs[$$!D%kMQ-XHBGk(RMAQAJJj!aHN9ckotgPlN08L5:U\R6D]9u^r;ZTjEZT&65c]39(3\)6B`.82iEW=,E[2=nWjBmf4pou]i2ncHgLg9ZL?,Lj1-E.[!pj,XLA`/doj.eJh&l,D`r#C,Tb^1D7>U$g[$V53lmk)bAmVb$8Z(m+U`l'5/c!ljk$UC-.ia&'Al/)qgLT^e0.FNS\jGF*(LT1i&>_(P30?SN.u;3qNc,tMsi_ajU\)bfL+,JdquT!AC,=,$TRuC`d2#tpB%L]p_mm:K)eD[lPSNQiI`Oln"J\<4Sb=:-U'*_1Q3ei5n`''pi:h0Mr!Q5H,FX_khp?f0&L3q%n^CEl>.6^+k':N-`qeY.?C,t^6K$UV(fFUA@JX^)H8FL!diue8'hLaDa0ea5`hYT11[S#G;H^^95MXc)?WI6`*atC?^lkM)Zsoi#]E=3C>#31FC@<aZ#!"V`duXKC+c2<ou7^!XU2,$k4tl?UJ;9KXi%1N*4%p=/k*CNQ_:6!eF6jl"&I(eCH@!nJX:']jIcSqA8V&Og'kG0gB.J*m0C5%47J*Q)973I,E=>YX,D1njXJ,%R7!1F2YVgZ<nee#n=$Z?XG92Ro='(5&B#6PYA@G=quD#9DIUhP9hg;1!98N2'Nc:tMMp<[*)12%kV<!GOr$.t_<YbP<jBqR%_E=blNg<u2io\VU]l_L[k,om"9`(n7#+'+$[o9t`/X:>I";Uls$$3tqdB/*mXrhd@^p</+]ehp/V5NYg_gcse&B8A<OaU^Bb4NNj1Go"I]OVCdh&^LBpDr(<_eTqW#s@2f8IlpAV@[/HADOpM4EP'>pm0n9Z-L6@o(^-1S!Xo7D/ge`?b$RThj`4KaqL4i:Y0J*;'pkPLn2@a.>%F]sKD$bfCV0pp8q3?0KsMg!KDKD+hud2ObhuHnLJ_e-&8od^I&<HhhJ:X)Vk-Y6qD44]=YS/C_j@"U,,%WnQKcE'VOVLX$QtCXU?\Bk3V_92us>MfDnSY>h'`K?i":YHN^HRk\kK0mW%!4DBfH$@]qep@XW]Z5"1g46QXN>Pp(Oi4=`Jju<P$4U<ja*Z,tQ#<V?@$lYJVo<uetQ-Wl@k=3)oRPiB+&#dACl_:JF3LjA7f7V*OaC9l!4\;*I+B"COSo&DLEO_0mVt@dj+JmruiUsk1b:J8*\S:f,,)2eM;;.9f$q^&(-HrYZ;!]*fl/QJ?n2a,#?`W*g/T_UC:]D<55%caL3E=%Gq`-nN+&Tk$kuSCt:GImWNB2hO^<1DBc7>GM7HN$9mkqF-=FUD\9N>[~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1724
|
||||
>>
|
||||
stream
|
||||
Gatm;>Ar7S'RnZ;35@5`D.A<r.$;YN]<5EAFC!Y3gOOD`A=9lkaHA&Nl*as66cl(`pnBCA*J?2eo:>c4$n-R-o0:n.HoH]$QOF^dfb0Yp!bSXp`Q\5?&KYsA![,4OI`-e*^AU=-no>"&&k4umTVs,.$4r(,V'6'_l(hi&8e&ta*WmTR.[:".=:hM%JDMXskK$hg+:P%BoK2l5'e^ci:-')qrBi8$_0/F2<*p3Z!ZAdi"-YZ.rGi@NMNt?-F\u]=_3?u`)u.LbKP/ocB2t3t7PE\HIAaD\]@"07"LB&74gr!-pd/p4TQpA/YB:""!*h!P/6gc9nLDdnL^#j-d&%g[aEN5.Weg+BM7trbE6h%l-sqWOKT4"SdQY7Ubi=j_.b%jeX5,3jQjie0-g8i$T%h-fVAQX9DC`SF;#h*\BC-Qa5dulKJDG;/0mq1@Qdc%5a!L7h01CU[S<,gAVoD!n`*7R_9\F@MqTG3`G+.=uM;![(^h&H;Xr-na+g/!Me[S9_r'FM$k54HE5A]g6_@Q0R7'HsWFq=V.e@_gEn16;.O]V&MH7TQCLq5RMW<_hJIX%WM/:*M`[*;Z&d,]o+OTg-&4>1`=k&jO&E1Ir].V$[%j!.+G3k9r5)98_-\2M;1hdAK1Z%X,+8[6VFISB?T'?Ac@A8,r>$-_]l>ED"qNsh"E"6%S[o0=,T^;bG^:'gW"Pdei;pcQjY%kr0C1>3^W'YT&da/s222*$=iMX]2$)c'#X(+cq]GsYA$fPe=#((r*q*:Bcn9]G`Q0Ch]QRGU9?]aud2"6dOG,hk[]!k3eYr=>Ae;Q7KJg4ReEQd$f+lTff"GP&R,rSu:!nf_a16'!<iA@Z^(98SN4hNkloJ?2YTY32.B];j%"-%Bj=is!q`"Sh'$"s]0fqJ&5`N_p'NW6\YU7=I+7g.+6`C(CD_lR_q>e=UJ]R5%$g-K6l7;$+tb361BbY2K%KSE><[O>G)`(-'%YHAirB7*nIeO<7DY";p'_r6-T-Os!XSPFH#fN<mKidA5>gW2=fuOTZYV:%imtF^@ei:JfC'gsje(2JE%$&:otA=QD`O.rRq3`JtSQYicEKKt=Q*N8*oMfdK\Hk38ER_.)'\,`RglD=d/WZ3?b!g=4lTGQ%jb%1!>oTjRkm0j@*]_S#$V>Mq\h\u\eFd-*SPef?;t=Dq!iUQL%pA'lC+8/Mp^PiD^CM*M\("M:RbjZ.IPE-`Qn"=3W9\qcEdkg.g'kK/.E8=st2B*jO?Q57o]13&VrFLj#]Irmm!MWArKApQ#IM6[f[#uoY?8,Tu'O4-$NGALIPH0@<Gs)#9\`WfIiY3XMd/l-Xp2d18rd[iC7??ZjsbpD0$,!($dTmerUS/cTGLc_,Lf]dYa"`&naNmf.V$dsY;"tc4db&k&>AK0=91)"p0*p4f=Z#I4V[gO`t(\Z6WGKAgS"C%g#8qm6<X!l9.5]Q<J`S]9mISB2@:$K[^0K5r(0($$URr$Q$=iP)rk$LPirsNr2&(C>e'5u9rLIud8ikcFd-82",)m!=IZMOM'2Q)H[i@g4emCk`tME/TkR,c$60QhMON)a$E>jng+)"XT3_/D4AE..W_5b*%rAos(-gqIOi8]T=W4E`P;e!ic`Y88^+Y(05,D"*a#Cdr;.j)UW'N)+\ns"`Z4!q\&#B!d,[5(9EUrRN]&'lQHCB*L[`<V0KLaO7r/Fh,NXaMN(RM4JJN3s*'E!L(:nUA~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1906
|
||||
>>
|
||||
stream
|
||||
Gb"/'968iG&AIa;CbHeVWF,]/8^O$mN:V#K2GFid8M@eS,UCb]$&+'!8Uc((+@X^3g!2Q@e^Bfr*oF[qliL]_n\a/^ZikV6L&E5#N5K`I9Fq6RGOT50IIRRRGpEG6^8-5%J],;$K6lKV&;2<fH-AP%Lr`,;OGl^J9Am<4MHC"hK)'JK!Js,]8HD5b+I(imof`[B8'unk@2N?WhruMjH8,^fm82($MdSMsF[l&&^*&XD;#o&Xp>5iJq&%uRKkf&4ecgFV[7Zbi:-"Bnn9B!+1jNS1#-ZRf.eL;OQ$ES_1RLA8Z:=W+QNV/^0&s]F%Boj%d25#b@6Z#q]Lm_%&^X$il4ti0SYd!a`:mu!_:\!lcUZ;P?\BVVdc1@BRseZ&nVC!kjJ\-_DO)Cr$Mh1ZfZm(%^(2/V11m6VXr-?@!VG#.CZk<sCcbNoE8*!-L8/Q%Ucf:VV5,.QZmbE6Y@P,P2e-BDl7G.h.f3pi^OKe9j4G:`g$rPd1I_\;N;LC%f9_&10pABB&D"Ggl>qV#3rGB7lhH'qJAk(79DC1+Ue6uXY-h-+a`V_B&&O_-4Ks/Cg@&U%UQ[7?gT\'@YZ@I2InWaO8Q'3*8h.n^VJ;qW=DsWS4t7P'1<D$JT=(i(;V;8>;K.a7E2FaN$rjbmjH>ZbX,3s@RQEZV:$Xu>/kgU>Rf[e/?K'`k$Ii"XiV2-?h99b:UYjr0[K\Uf)r?=MpC6Riflk%3l#.SilW1Vdo<H'`m@Frtb7'$!#DM_:XYhrXR(QTZ0_l/]-7RP1bVH-ECUZp&&3bu&r#Q&[-!c:A5mGe-0'ILKQ';X,M(u$5%_tZj5OuDiH^:cqN"h=^Ujin"`V18GC8oW]Dq7rA#5?:fa1@Spegq00E*kN'0ecOINP20gZ"P\3fZbMWDK-3!q%'aB0o=R?OP3hH(l\Z3RVq[3DpK'0;$EgkG]aE2\mEVhIBg[?O5cD^*_pu[T=i`OB`gqX`kh#Bo`PI@bS'.pe*!`,!n[)@G[#1u\9t[Er+8.5#i(u-76nU@'*mK+K^G!mdB-%#nKVFdf]Zm00QQW'<FTBZ75[I3+sZtg\h1)QZr_!Ri&jfn_oXO&b,;RjOS,:3oARV*Me$q_#cUo_Fr)cGNOJ!$\N%`+o]`FrA3J#F,@.3^@KSQHj$Lut]iljbB=k6l6K>f'[[0Rs_C08_Y(mCt^bg#(l4jLTfbe!A-HF;*8'qN9pV(@C3gj#;G5CS)#tERjSis^cIogp4]1W!Rmje>];&WAK?,Fp!Q8Hcl@BQh2A&1@LA2$r,.`#S2L5WrTQPgiB,%rMI;&D`a%#/Cf(e59kT&d"GV'i"9I>-&B'qUN:/f_J!pC&4=8B5_2gs/L)^5r(E@3Q1%e@&/GocZaa*B4^]4/pi)mm3o2LZM,Rj_1"k@!%KAkop?Okb)_#I^!EthFJG)@M_ab'3eoYF2j>p$lL/7fEIHNJE'=3kj2<QMrW'#1]$c$+=mq+?GVgi^su\!"(/2?VN#2idX7XeePDO[`XSTGO+tWZ"n`K"eY[4\,JCO9Zh>%$!`E<i3@:dCh8#igH35_<cjp6lhSKVQD/YAPkUtPk<b6E:j9?r-j9YM^At-HSG,'dH&OK+X:]j,aXKYS:n4d'?eTn;M+B.SmIt\4;"_)sBH&"?144Oca@^TbW3(2[ho!<b>OqLqtF+>=Qe0N6#F&]Qgn_D_&g^Pe9')GJ;Ij;m[F,o,3-rh6HQf8N[i1,P:5iRAt.oYWr%Suj`M1!U\QQuk7QTGE0\H#Fe=)MS./EaO^m-D8AD[h`n1WQ;4A)<.PdiDEQSrVZl^n6=gD:3`'YMi/LP4b964&H-MGGZb3KA6%ji'ZH[5KiPlT&jZq?_OuHG3T=V$/HP(eTUO)+NhX#;!-<J?pRgn2DKN0O/l=lnGWM\J,.U~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2014
|
||||
>>
|
||||
stream
|
||||
Gb"/(968iG&AII3ChGdB-&j#KI"@&b`_N#AD8pQ9-1/N))2a(rfHd/@P[%SlgUdS;MN[Y:26K]6SVVk5]DLtJ53sa5/Gu4(h^ARd*Pq4c"j14p%FcO2N'>+^UYb_d@35"\(Pp<f4((;T]D;Ujl/giL`!][4JQk=s^&nkCK_']r_gT6pIF&%u?hGfYhm7^u!9)H#\G'CG9g$GBZpS*E$eG&2rpe.EJ9[&tk[sdOb#![JTN\J+6S.O!/rVRLT7?]`"s6egMJ/_`$=?CWU.GYmN;7F52E3,m=d1+b?c63FC7:!lHjQPe(qlqcFn@aR-]#`Kec/J\5[D0lU/]aPg5@YIGmKXX'`ESK?"ie2j/Hs:@b[30ak.L$SrJlk[c9@hA3K(<qQX=!Hd!k'jm^P6j,K9jW:."ZMS"t,HL2V7:ek`$Wa,s./o[N7D:;[cF^VHcTnA9dZq'Xb])9oi'j7Z(W+R]q8F]F?A@&h#d6SiH-9Y:,ePDb5iOc+79_E^b2tbS?R>9/u(uptk$oE1=aVhKIM'96doD37CJ7f8nKKV0M0]LL\.sJtJKi[^p.]!u\\^5O%b97Jn^2!/IeQ0T^4atfP,Wh:1:)lqAqJTA2(+TU$-JP<T\k^l<Y!\9IR'f'@"6QtG]ZEYi'6p!785L=U`3AshD"?7c]ad7l06;=%?)6(-ESHR[X@CBd\,TUp\W"I=@L+BuE7o[jrD!_$R!lYNT%T&>P$?$-)<SI@)TJK[e!2u!ba*sdBPP/[S/P[?KoSR4a3dBMK,Tg>iMrO6Y`o;bKeAV4;gEG2ol?,ZNJ=nlbKumr+pDMl2q5.iT>ES3j#\-FPKc^]J:+^#]duCl`c-MW7HMK0T:8!fZ7O(-50Q@#K$I/)j&AK%6bbF67)%:G7a\3JJOHOO1F9nSMfn)&]*:=6fPJZBlXD*N)V1sUY6"G!huCd!^!8Jb:;c92SXUe>'4d-/J=e><5Ag!IW+ecX?J-qo\N&@(BJ'3`V5]8^!k"eM4KJ5;A55bF&ZnUc2uJ#TKZ,sXOZaOfppRa03V!J]^@)XMmM>GV/^G]V9B!59VN$8m1@Nm%n["$`>*ep[3RMhka6NL->[r]RIZ>??dc_H:CRq@"l<dEGhs)KDW@l./ZssL/h_!"8WcBX"\/"`AS:CBW2?K:6PY/$\W`k+tiV_<B7c,i4S:3SIqe`01=5DeBoAa7bg3OB4,4[`c<>;;F:6N[r-FpuTi2T"C8r)"09gj(RnfN$!A_-C5/o3[M=K>S6m)/u%Q`:IVa%TsM848p*Gu!X2e3H_/gX1r&(Br%#*$13b0(_X:Io)Hsq[P=3;QIIqhP#bT'H8Z$)8ouJS74:/*088V+lfsgXR]mPs#BB@)R<!]gG&n]5QB@d4kUMDa0D6]=d3`FdBT.VB5-Ul>fC,+)66TS0R/4[f/-M%ftK4h(23%Hd+EorPa7/V4/%d5@Y'57_5[LL^jA.!D2*Y]>bLX(]=@MoYhG/RC*1AP^SbM=?c,,';'@-;B_.[-bRL;cGUClVEeU('j,]Xgo91%#ruZoT3='iDU%g8FiD\guI-]kJ;BD\6,5Y/F4R+#n%;Cebn[tBM^IrTG^pPSc8Tc\S_ij'F9gP"=L9ZVn@5sefc,i8._7c<%!Nt?g'38p(N5ICj//7HD%dG(>>A9';(m$..3$->q?'0)dfXeH0SeqbRfSoBM27D%2<JPGn10(e-S4XEn>pu+^7$J'h:IJ@3c;:@FL/pI"aK-SuC_o;$Lbi%HlnEetBD,&-LZ;EO&14Gb2Qrn=7q=Q-(qD5%\i.$=ZXAkqM`];]8#.X70W&U39sp?]ES!$ifeU$P@##bR@C'YZ5H;PBO2tAi@:ZcSd[GqpL>u6L#tIL-d#.7+I#gg1p0h47qNYXTKI='qbf0k!aG8`,Y;@?Xe46.4=7fAh=KH!Qk0X;B!=gggelL:(9N<6/*/-d8`6$q@-8Sr`hmEeg$pnL<9]uU$AWGBRn\&tWNu*2UrI1,fH1cZ#ae5mgi`=*cR0%^7![57XjT~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 30
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000002079 00000 n
|
||||
0000002285 00000 n
|
||||
0000002491 00000 n
|
||||
0000002697 00000 n
|
||||
0000002767 00000 n
|
||||
0000003059 00000 n
|
||||
0000003189 00000 n
|
||||
0000005193 00000 n
|
||||
0000007291 00000 n
|
||||
0000009468 00000 n
|
||||
0000010263 00000 n
|
||||
0000011874 00000 n
|
||||
0000013918 00000 n
|
||||
0000015569 00000 n
|
||||
0000017532 00000 n
|
||||
0000019348 00000 n
|
||||
0000021346 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<2d70597e08491c5e3793c7c8ec02df61><2d70597e08491c5e3793c7c8ec02df61>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 17 0 R
|
||||
/Root 16 0 R
|
||||
/Size 30
|
||||
>>
|
||||
startxref
|
||||
23452
|
||||
%%EOF
|
||||
430
Les15-Cursor-Vercel-Deploy/Les15-Slide-Overzicht.md
Normal file
430
Les15-Cursor-Vercel-Deploy/Les15-Slide-Overzicht.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Les 15 — Externe APIs + Cursor agents + Vercel deploy
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Nieuwe kleine Pokédex (PokéAPI, geen key, visueel)
|
||||
**Vervolg op:** Les 14 — RAG + Embeddings
|
||||
**Aansluit op:** Les 16 — MCP servers
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 15 — Externe APIs + Cursor + Vercel
|
||||
|
||||
**Visual:**
|
||||
- Background: CREAM
|
||||
- "Les 15" in BLUE
|
||||
- "Externe APIs + Cursor + Vercel" in BLACK
|
||||
- Subtitle: "Van localhost naar productie — met Cursor agents en preview deploys"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Vorige lessen:**
|
||||
- Les 11: AI SDK basics
|
||||
- Les 12: Tool Calling
|
||||
- Les 13: Agents
|
||||
|
||||
**Tot nu toe:** alles draaide op `localhost:3000`. Geen wereld eromheen.
|
||||
|
||||
**Vandaag:**
|
||||
- Externe APIs aanroepen (geen LLM nodig, gewoon data ophalen)
|
||||
- App naar productie met Vercel
|
||||
- Feature branches met Cursor agents (Composer + Background)
|
||||
- GitHub Actions CI (lint + build per PR)
|
||||
|
||||
**Visual:** localhost icoon → cloud icoon met branches eruit.
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Welkom + Terugblik | 10 min |
|
||||
| Theorie: Externe APIs in Next.js | 15 min |
|
||||
| Theorie: Cursor agents — Composer vs Background | 15 min |
|
||||
| Theorie: Vercel preview deploys + GH Actions | 15 min |
|
||||
| **Live Demo 1** — Pokédex bouwen met Composer | 30 min |
|
||||
| **Live Demo 2** — Deploy naar Vercel productie | 15 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — Feature branch met Background Agent | 25 min |
|
||||
| **Live Demo 4** — GitHub Actions CI | 15 min |
|
||||
| Composer vs Background — wanneer welke? | 10 min |
|
||||
| Lesopdracht + Huiswerk | 10 min |
|
||||
| Vragen + Afsluiting | 5 min |
|
||||
|
||||
**Format:** Demo-driven, Cursor en browser side-by-side.
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is een externe API?
|
||||
### Data van buiten, in jouw app
|
||||
|
||||
**Het idee:**
|
||||
- Andere mensen / bedrijven hebben data of functionaliteit
|
||||
- Ze stellen die beschikbaar via HTTP endpoints (REST)
|
||||
- Jij doet een `fetch()` en krijgt JSON terug
|
||||
|
||||
**Voorbeelden:**
|
||||
- PokéAPI — Pokemon data (geen key)
|
||||
- Tavily — web search (key nodig)
|
||||
- Stripe — betalingen (key + secret)
|
||||
- GitHub API — repos, users, issues
|
||||
- Open-Meteo — weer (geen key)
|
||||
|
||||
**In Next.js — twee plekken om te fetchen:**
|
||||
|
||||
| Server-side (RSC / API route) | Client-side (`useEffect`) |
|
||||
|-------------------------------|---------------------------|
|
||||
| Snel, key veilig, gecached | Nodig voor user-interactie |
|
||||
| Voor initiële data | Voor live updates |
|
||||
|
||||
**Visual:** Diagram: Next.js → fetch → externe API → JSON terug.
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Externe API in Next.js — code
|
||||
### Server Component met fetch
|
||||
|
||||
```typescript
|
||||
// app/pokemon/[name]/page.tsx
|
||||
async function getPokemon(name: string) {
|
||||
const res = await fetch(
|
||||
`https://pokeapi.co/api/v2/pokemon/${name}`,
|
||||
{ next: { revalidate: 3600 } } // cache 1 uur
|
||||
);
|
||||
if (!res.ok) throw new Error("Pokemon niet gevonden");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default async function PokemonPage({ params }) {
|
||||
const pokemon = await getPokemon(params.name);
|
||||
return (
|
||||
<div>
|
||||
<h1>{pokemon.name}</h1>
|
||||
<img src={pokemon.sprites.front_default} alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Drie dingen om te weten:**
|
||||
1. **`async`** Server Component — fetch direct in component
|
||||
2. **`next: { revalidate }`** — Next.js cache opties
|
||||
3. **Error handling** — `!res.ok` netjes afvangen
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Cursor — twee soorten agents
|
||||
### Composer vs Background
|
||||
|
||||
| | Composer | Background Agent |
|
||||
|---|----------|------------------|
|
||||
| Waar draait | Lokaal in je editor | Cursor cloud |
|
||||
| Mode | Synchroon — jij wacht | Async — runt zonder jou |
|
||||
| Output | Diffs in editor | Branch + PR op GitHub |
|
||||
| Goed voor | Pair programming, snelle changes | Welomschreven feature, tijdens andere taak |
|
||||
| Aanroep | `Cmd+I` (Mac) / `Ctrl+I` | `Cmd+Shift+P` → Background Agent |
|
||||
| Repo nodig | Lokaal | Verbonden met Cursor cloud |
|
||||
|
||||
**Beide kanten:**
|
||||
- Composer: jij stuurt, snel feedback, niet schaalbaar voor lange taken
|
||||
- Background: schaalbaar (5 agents tegelijk), maar minder controle in flight
|
||||
|
||||
**Visual:** Twee tracks naast elkaar — synchroon (jij + composer) vs asynchroon (background agent + jij elders).
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Vercel preview deploys
|
||||
### Een URL per branch — automatisch
|
||||
|
||||
**Hoe het werkt:**
|
||||
1. Connect je GitHub repo aan Vercel (eenmalig)
|
||||
2. Push naar `main` → **productie** deploy op `je-app.vercel.app`
|
||||
3. Push naar elke andere branch → **preview** deploy op `je-app-git-{branch}-{user}.vercel.app`
|
||||
4. PR krijgt automatisch een comment met preview URL
|
||||
|
||||
**Wat krijg je:**
|
||||
- Stakeholders zien een nieuwe feature voor de merge
|
||||
- Cursor Background Agent maakt een PR → preview URL → klikbaar
|
||||
- Reviewer test live in browser, niet alleen code-diff
|
||||
|
||||
**Environment scopes:**
|
||||
|
||||
| Scope | Wanneer | Voorbeeld var |
|
||||
|-------|---------|---------------|
|
||||
| **Production** | Deploys vanaf `main` | Echte API key |
|
||||
| **Preview** | Alle andere branches | Test API key |
|
||||
| **Development** | Lokaal (`.env.local`) | Dev API key |
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: GitHub Actions CI
|
||||
### Lint + build per PR
|
||||
|
||||
**Waarom:**
|
||||
- Voor merge weet je: code lint clean én build slaagt
|
||||
- Voorkomt "werkte op mijn laptop"
|
||||
- Background Agent doet PR → CI draait automatisch → groene check of feedback
|
||||
|
||||
**Minimaal `.github/workflows/ci.yml`:**
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: pnpm }
|
||||
- run: pnpm install
|
||||
- run: pnpm lint
|
||||
- run: pnpm build
|
||||
```
|
||||
|
||||
**Tijd per CI-run:** ~1-2 min op een kleine Next.js app.
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Wat we vandaag bouwen
|
||||
### Pokédex — Next.js + PokéAPI
|
||||
|
||||
**Doel:** Een mini-Pokédex om de hele pipeline te oefenen.
|
||||
|
||||
**Stack:**
|
||||
- Next.js 16 + TypeScript + Tailwind + Shadcn
|
||||
- PokéAPI (geen key, gratis, geen rate-limit bij normaal gebruik)
|
||||
- Cursor (Composer + Background Agent)
|
||||
- GitHub (repo + Actions)
|
||||
- Vercel (productie + preview deploys)
|
||||
|
||||
**Features die we bouwen vandaag:**
|
||||
|
||||
| Feature | Met | Branch |
|
||||
|---------|-----|--------|
|
||||
| Pokédex list (eerste 151) | Composer | main |
|
||||
| Detail page per Pokemon | Composer | main |
|
||||
| Search met filter | Background Agent | feature/search |
|
||||
| Type-filter chips | Background Agent | feature/type-filter |
|
||||
|
||||
**Visual:** Folder-tree + GitHub branch-diagram + Vercel URLs.
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: LIVE DEMO 1 — Pokédex met Composer
|
||||
### ~30 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `pnpm create next-app pokedex --typescript --tailwind --app`
|
||||
2. Open in Cursor
|
||||
3. **Composer (`Cmd+I`)**: "Bouw een lijst-pagina met de eerste 151 Pokémon van PokéAPI, gebruik Tailwind cards"
|
||||
4. Composer maakt: `app/page.tsx` met fetch + map + cards
|
||||
5. `pnpm dev` — werkt
|
||||
6. **Composer**: "Voeg detail-pagina toe op /pokemon/[name] met sprite + types + stats"
|
||||
7. Composer maakt: `app/pokemon/[name]/page.tsx`
|
||||
8. Cards op homepage linken naar detail-pages
|
||||
9. Klein bugje fix-en in chat: "de cards zijn te smal"
|
||||
|
||||
**Belangrijk om uit te leggen:**
|
||||
- Composer is een TAB in Cursor — niet hetzelfde als Chat
|
||||
- Multi-file edits zichtbaar als diffs — je accepteert per file
|
||||
- Terminal commands vragen toestemming
|
||||
|
||||
**Visual:** Cursor screenshot met Composer panel.
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: LIVE DEMO 2 — Deploy naar Vercel productie
|
||||
### ~15 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. **GitHub:** new repo `pokedex` — public
|
||||
2. **Lokaal:**
|
||||
```bash
|
||||
git init
|
||||
git add . && git commit -m "Initial Pokédex"
|
||||
git branch -M main
|
||||
git remote add origin https://github.com/USER/pokedex.git
|
||||
git push -u origin main
|
||||
```
|
||||
3. **Vercel:** Add New → Import Git Repository → kies `pokedex` → Deploy
|
||||
4. Wachten 60-90s op build
|
||||
5. Open productie URL — werkt!
|
||||
6. **Wijziging:** title aanpassen, push naar main → 2e deploy, ~30s
|
||||
|
||||
**Wat studenten moeten zien:**
|
||||
- One-time setup (Vercel verbinden)
|
||||
- Daarna: push to main = deploy
|
||||
- Build logs in Vercel dashboard
|
||||
- Custom domain mogelijk (skipping vandaag)
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: Pauze
|
||||
### 15 minuten
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: LIVE DEMO 3 — Feature branch + Background Agent
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. **Cursor:** `Cmd+Shift+P` → "Open Background Agent"
|
||||
2. **Verbind** repo aan Cursor cloud (eenmalig — accepteren in browser)
|
||||
3. **Prompt aan Background Agent:**
|
||||
> "Maak een nieuwe branch `feature/search`. Voeg een zoekbalk toe bovenaan de homepage. Wanneer iemand typt, filter de Pokémon-lijst op naam. Gebruik client-side state. Push de branch + open een PR met titel 'Add search bar'."
|
||||
4. **Background Agent gaat aan de slag** — Tim laat zien hoe je 'm volgt in het Cursor panel
|
||||
5. **~3-5 minuten later:** PR verschijnt op GitHub
|
||||
6. **Vercel** comment in PR: preview URL → klik → live preview!
|
||||
7. **Demo:** zoeken werkt op preview URL
|
||||
8. **Code review** in GitHub UI: wat heeft de agent gedaan?
|
||||
9. **Merge** → main → productie deploy
|
||||
|
||||
**Tweede prompt — parallel:**
|
||||
> "Maak branch `feature/type-filter`. Voeg chips toe bij elk type (fire, water, ...) om te filteren."
|
||||
|
||||
Beide branches kunnen tegelijk draaien.
|
||||
|
||||
**Visual:** Cursor Background Agent panel + GitHub PR + Vercel preview URL.
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: LIVE DEMO 4 — GitHub Actions CI
|
||||
### ~15 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. **Vraag aan Composer:**
|
||||
> "Voeg een GitHub Actions workflow toe: lint + build moeten slagen op PR's naar main."
|
||||
2. Composer maakt `.github/workflows/ci.yml`
|
||||
3. Push naar nieuwe branch `chore/add-ci`
|
||||
4. Open PR → GitHub Actions tab → workflow draait
|
||||
5. **Demo failing CI:** introduceer typo (`<dvi>`), push, zie rode X
|
||||
6. Fix met Composer ("repareer de TypeScript build error in de PR"), push, zie groene check
|
||||
7. **Branch protection:** main beschermen — PR's mogen pas mergen als CI groen is
|
||||
|
||||
**Waarom belangrijk:**
|
||||
- Background Agents kunnen lint-errors maken — CI vangt dit op
|
||||
- Voor je merge: weet je dat het op een schone runner ook werkt
|
||||
- Geen "werkt op mijn laptop"-discussies
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Composer vs Background Agent
|
||||
### Wanneer welke?
|
||||
|
||||
| Scenario | Tool |
|
||||
|----------|------|
|
||||
| Snelle wijziging tijdens coden | Composer |
|
||||
| Complex refactor over meerdere files | Composer (kun je begeleiden) |
|
||||
| Onbekend gebied — wil zien wat agent doet | Composer |
|
||||
| Welomschreven feature, kan op de achtergrond | Background Agent |
|
||||
| Parallel werken aan 3 features | 3x Background Agent |
|
||||
| Tijdens vergadering een PR voorbereiden | Background Agent |
|
||||
| Repo nog niet in Cursor cloud | Composer |
|
||||
|
||||
**Mentale model:**
|
||||
- **Composer = pair programming** — jij + AI samen aan dezelfde plek
|
||||
- **Background Agent = delegeren** — je geeft taak, gaat iets anders doen, komt terug
|
||||
|
||||
**Risico bij Background Agent:**
|
||||
- Agent kan vastlopen op rare config — debugging is moeilijker
|
||||
- Verbruikt credits (Cursor Pro of Business)
|
||||
- Te vage prompts → matige PR's. Specifiek zijn helpt.
|
||||
|
||||
---
|
||||
|
||||
## Slide 16: Lesopdracht + Huiswerk
|
||||
### Pokédex zelf bouwen + deploy
|
||||
|
||||
**Lesopdracht (in-class, 30 min):**
|
||||
- Eigen Pokédex repo opzetten (zelfde setup als demo)
|
||||
- 1 feature met Composer toevoegen
|
||||
- Push naar GitHub + connect Vercel
|
||||
- Productie URL openen — werkt
|
||||
|
||||
**Huiswerk (voor Les 16):**
|
||||
- **A:** Feature branch met Background Agent (1 feature, jouw keuze) → preview URL delen
|
||||
- **B:** GitHub Actions CI (lint + build) in werking
|
||||
- **C:** Branch protection — main mag pas merge na groene CI
|
||||
- **D:** `DEPLOY.md` met:
|
||||
- Productie URL + 1 preview URL
|
||||
- Reflectie: Composer vs Background — wanneer welke werkte beter?
|
||||
- 1 voorbeeld waar Background Agent verbluffend goed of slecht was
|
||||
|
||||
**Bonus:**
|
||||
- Eigen externe API integreren (Open-Meteo, GitHub, jouw keuze)
|
||||
- Vercel environment vars demo (verschillende waarde in preview vs prod)
|
||||
|
||||
---
|
||||
|
||||
## Slide 17: Volgende les + Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- Externe API integratie in Server Components
|
||||
- Cursor Composer voor synchrone changes
|
||||
- Cursor Background Agent voor async features
|
||||
- Vercel productie + preview per branch
|
||||
- GitHub Actions CI (lint + build)
|
||||
- Wanneer welke Cursor-modus
|
||||
|
||||
**Volgende les (Les 16): MCP — Model Context Protocol**
|
||||
- Wat is MCP (Model Context Protocol)
|
||||
- Bestaande MCP servers gebruiken in Cursor/Claude Desktop
|
||||
- Eigen MCP server bouwen + laden
|
||||
- Demo: eigen tools beschikbaar in alle MCP-clients
|
||||
|
||||
**Daarna in deze leerlijn:**
|
||||
- Les 17: Externe APIs in diepte (OAuth, webhooks, paid APIs)
|
||||
- Les 18: Supabase Auth + RLS — multi-user apps
|
||||
|
||||
**Vragen? Feedback?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Externe APIs | Theorie |
|
||||
| 5 | Externe API code | Theorie |
|
||||
| 6 | Cursor — Composer vs Background | Theorie |
|
||||
| 7 | Vercel preview deploys | Theorie |
|
||||
| 8 | GitHub Actions CI | Theorie |
|
||||
| 9 | Wat we bouwen | Intro demo |
|
||||
| 10 | **LIVE DEMO 1** — Pokédex + Composer | Demo |
|
||||
| 11 | **LIVE DEMO 2** — Vercel deploy | Demo |
|
||||
| 12 | Pauze | Break |
|
||||
| 13 | **LIVE DEMO 3** — Background Agent + preview | Demo |
|
||||
| 14 | **LIVE DEMO 4** — GitHub Actions CI | Demo |
|
||||
| 15 | Wanneer welke Cursor-mode | Reflectie |
|
||||
| 16 | Lesopdracht + Huiswerk | Praktijk |
|
||||
| 17 | Afsluiting + Les 15 preview | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **PokéAPI:** https://pokeapi.co/
|
||||
- **Next.js fetching:** https://nextjs.org/docs/app/building-your-application/data-fetching
|
||||
- **Cursor agents:** https://cursor.com/product
|
||||
- **Vercel preview deployments:** https://vercel.com/docs/deployments/preview-deployments
|
||||
- **Vercel environment variables:** https://vercel.com/docs/environment-variables
|
||||
- **GitHub Actions for Next.js:** https://nextjs.org/docs/app/building-your-application/deploying
|
||||
- **GitHub branch protection:** https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches
|
||||
BIN
Les15-Cursor-Vercel-Deploy/Les15-Slides.pdf
Normal file
BIN
Les15-Cursor-Vercel-Deploy/Les15-Slides.pdf
Normal file
Binary file not shown.
BIN
Les15-Cursor-Vercel-Deploy/Les15-Slides.pptx
Normal file
BIN
Les15-Cursor-Vercel-Deploy/Les15-Slides.pptx
Normal file
Binary file not shown.
BIN
Les15-Cursor-Vercel-Deploy/lu41eglzw.tmp
Normal file
BIN
Les15-Cursor-Vercel-Deploy/lu41eglzw.tmp
Normal file
Binary file not shown.
1
Les16-MCP/.~lock.Les16-Slides.pdf#
Normal file
1
Les16-MCP/.~lock.Les16-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 11:11,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
412
Les16-MCP/Les16-Docenttekst.md
Normal file
412
Les16-MCP/Les16-Docenttekst.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Les 16 — MCP (Model Context Protocol)
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 16 van 18
|
||||
**Onderwerp:** MCP — eigen server bouwen voor AI-clients (Cursor, Claude Desktop)
|
||||
**Duur:** 180 minuten
|
||||
**Demo-app:** `mcp-polderfest` — TypeScript MCP server tegen Polderfest Supabase
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES — Setup (45 min)
|
||||
|
||||
1. Test je werkende `mcp-polderfest` op laptop — alle 4 tools + 1 resource werken
|
||||
2. Cursor + Claude Desktop beide klaar met config voor demo-server
|
||||
3. Open MCP Inspector in tab als backup
|
||||
4. Browser tabs: modelcontextprotocol.io, docs.cursor.com/context/model-context-protocol, github.com/punkpeye/awesome-mcp-servers
|
||||
5. Backup screenshots: Inspector UI, Cursor MCP-config, Claude Desktop MCP-icon
|
||||
6. Test Supabase env vars werken via stdio (server kan crashen als env niet doorgegeven)
|
||||
|
||||
---
|
||||
|
||||
# HET SCRIPT
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik + Waarom MCP (15 min)
|
||||
|
||||
`[SLIDE 1 — Title]`
|
||||
|
||||
**Vertel:** "Welkom bij les 16. Vandaag een hot topic — Model Context Protocol. MCP. We bouwen onze eigen MCP server."
|
||||
|
||||
`[SLIDE 2 — Terugblik]`
|
||||
|
||||
**Vertel:** "We hebben al heel veel gedaan. AI SDK, tool calling, agents, RAG, deployen naar Vercel. Allemaal in onze eigen Next.js app.
|
||||
|
||||
Maar denk eens na. Die handige tools die je hebt gebouwd — `searchBands`, `getStats`, RAG search — die zitten **vast aan jouw app**. Wil je dezelfde tool in Cursor? Apart bouwen. In Claude Desktop? Nog een keer. In een script dat 's nachts draait? Drie keer.
|
||||
|
||||
MCP lost dat op. Eén protocol, alle AI-clients begrijpen het. Bouw je tool één keer, gebruik 'm overal."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Drie uur. 50 min theorie, vier demo's, lesopdracht en huiswerk."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Theorie MCP (35 min)
|
||||
|
||||
`[SLIDE 4 — Wat is MCP]`
|
||||
|
||||
**Vertel:** "Model Context Protocol. Open standaard, gelanceerd door Anthropic in november 2024. Wat is het idee?
|
||||
|
||||
AI-clients — dat zijn programma's zoals Cursor, Claude Desktop, ChatGPT. Die noemen we **MCP clients**.
|
||||
|
||||
Tools en data-bronnen — JOUW code — zijn **MCP servers**.
|
||||
|
||||
Het protocol regelt hoe ze met elkaar praten. Als je het eenmaal volgt, werkt jouw tool in **elke** MCP-client. Cursor leest 'm, Claude Desktop leest 'm, jouw eigen custom client leest 'm.
|
||||
|
||||
De vergelijking die overal gemaakt wordt: USB-C voor AI tools. Eén standaard connector, werkt overal."
|
||||
|
||||
💬 *Vraag: 'Wie betaalt voor het protocol?'*
|
||||
|
||||
**Antwoord:** "Niemand. Open standaard. Anthropic heeft het in elkaar gezet, alle code is MIT-licensed op GitHub. Cursor, OpenAI en anderen hebben het overgenomen omdat het simpel is en werkt."
|
||||
|
||||
`[SLIDE 5 — Architectuur]`
|
||||
|
||||
**Vertel:** "De architectuur. Drie rollen. Client — de AI-assistent. Server — jouw code. Transport — hoe ze praten.
|
||||
|
||||
Twee transports. **Stdio** is de default — server runt als child-process van de client. Communicatie via stdin/stdout, JSON-RPC berichten. Snel, lokaal, geen netwerk nodig.
|
||||
|
||||
**HTTP/SSE** is voor remote — server runt op een cloud, AI client praat over HTTP met Server-Sent Events. Voor productie-deploys.
|
||||
|
||||
Onder de motorkap: JSON-RPC 2.0. Standaard sinds 2010, niks nieuws, robuust."
|
||||
|
||||
`[SLIDE 6 — Bestaande servers]`
|
||||
|
||||
**Vertel:** "Eerst — je hoeft niet alles zelf te bouwen. Er is een heel ecosysteem.
|
||||
|
||||
Anthropic heeft officiële servers voor de basics. Filesystem, GitHub, Slack, Postgres, Puppeteer. Allemaal via npm te installen.
|
||||
|
||||
Community heeft er 500+ gebouwd. Linear, Notion, Asana, Figma, Stripe, YouTube. Daar is een aware list voor — punkpeye/awesome-mcp-servers op GitHub.
|
||||
|
||||
`*[Toon Cursor config voorbeeld op slide]*`
|
||||
|
||||
In Cursor laad je ze via `~/.cursor/mcp.json`. Eén JSON met server-naam, command, args. Restart en de tools zijn beschikbaar in jouw chat.
|
||||
|
||||
Voor mij persoonlijk: GitHub MCP en filesystem MCP gebruik ik dagelijks in Cursor. Scheelt enorm veel context-switchen."
|
||||
|
||||
`[SLIDE 7 — Eigen server bouwen]`
|
||||
|
||||
**Vertel:** "Hoe maak je er zelf één. Anthropic heeft een TypeScript SDK. `npm install @modelcontextprotocol/sdk`.
|
||||
|
||||
`*[Wijs naar code op slide]*`
|
||||
|
||||
Drie regels. Eén — maak een McpServer. Twee — registreer een tool met naam, beschrijving, schema, execute-functie. Drie — connect een transport.
|
||||
|
||||
Dat is het. Echt. De rest is gewoon je tool-code."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Demo 1: SDK + eerste server (25 min)
|
||||
|
||||
`[SLIDE 8 — Wat we bouwen]`
|
||||
|
||||
**Vertel:** "Vandaag bouwen we een MCP server voor de Polderfest-data. Vier tools — searchBands, getStats, addFavorite, getBandByName. Eén resource — bandenlijst. Eén prompt-template — daily recap.
|
||||
|
||||
Werkt straks in Cursor én Claude Desktop. Eén server, twee clients."
|
||||
|
||||
`[SLIDE 9 — DEMO 1]` `[SCHERM: terminal + editor]`
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les16-MCP
|
||||
mkdir mcp-polderfest && cd mcp-polderfest
|
||||
pnpm init
|
||||
pnpm add @modelcontextprotocol/sdk zod
|
||||
pnpm add -D typescript @types/node
|
||||
```
|
||||
|
||||
`*[Maak tsconfig.json en package.json type: module]*`
|
||||
|
||||
**Vertel:** "TypeScript config: `Node16` module resolution. Belangrijk. Anders krijg je `cannot find module` errors door .js extensies in imports."
|
||||
|
||||
`*[src/index.ts schrijven — minimal server]*`
|
||||
|
||||
```typescript
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "polderfest-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"searchBands",
|
||||
"Zoek bands op dag, stage of genre",
|
||||
{
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
|
||||
stage: z.string().optional(),
|
||||
},
|
||||
async ({ day, stage }) => {
|
||||
const bands = MOCK_DATA.filter(b =>
|
||||
(!day || b.day === day) && (!stage || b.stage === stage)
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(bands, null, 2) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Server started");
|
||||
```
|
||||
|
||||
**Vertel:** "Let op `console.error`, NIET `console.log`. Stdout is gereserveerd voor het protocol. Logging via stderr."
|
||||
|
||||
```bash
|
||||
pnpm tsc
|
||||
```
|
||||
|
||||
**Vertel:** "Build genereert `dist/index.js`. Als je 'm runt direct met `node dist/index.js`, lijkt er niets te gebeuren — server wacht op JSON-RPC over stdio. Lijkt verkeerd, maar is correct."
|
||||
|
||||
`*[Inspector starten]*`
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector node dist/index.js
|
||||
```
|
||||
|
||||
`*[Browser opent localhost:5173]*` `[SCHERM: browser]`
|
||||
|
||||
**Vertel:** "MCP Inspector. Jouw beste vriend tijdens dev. Links: lijst tools. Rechts: call uitvoeren.
|
||||
|
||||
Klik searchBands. Vul day: Zaterdag. Call Tool. Resultaat: JSON met bands. Werkt."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Demo 2: Laden in Cursor + Claude (20 min)
|
||||
|
||||
`[SLIDE 10 — DEMO 2]` `[SCHERM: editor + cursor]`
|
||||
|
||||
**Vertel:** "Nu laden we onze server in Cursor."
|
||||
|
||||
`*[Open ~/.cursor/mcp.json]*`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/Users/tim/novi/novi-lessons/Les16-MCP/mcp-polderfest/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Absolute path, geen tilde, geen relative. Restart Cursor. Cmd+Q en opnieuw."
|
||||
|
||||
`*[Cursor herstart, Settings → MCP]*`
|
||||
|
||||
**Vertel:** "Server zichtbaar, groen vinkje. Test in chat."
|
||||
|
||||
`*[Cmd+L in Cursor]*` "Welke bands spelen zaterdag op de Beach Stage?"
|
||||
|
||||
`*[Cursor roept tool aan, geeft antwoord]*`
|
||||
|
||||
**Vertel:** "Werkt. Dezelfde tool nu in Claude Desktop laden."
|
||||
|
||||
`*[Open ~/Library/Application Support/Claude/claude_desktop_config.json]*`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/Users/tim/novi/novi-lessons/Les16-MCP/mcp-polderfest/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Identieke JSON, andere file. Claude Desktop herstart."
|
||||
|
||||
`*[Claude Desktop herstart, vraag stellen]*` "Welke jazz bands spelen er?"
|
||||
|
||||
**Vertel:** "Eén server, twee clients, geen duplicatie. Dit is de kern."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 11 — Pauze]`
|
||||
|
||||
`*[Reset server lokaal — switch naar werkende Polderfest backup voor demo 3]*`
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Demo 3: Polderfest server (30 min)
|
||||
|
||||
`[SLIDE 12 — DEMO 3]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nu echte data. We koppelen server aan Supabase."
|
||||
|
||||
```bash
|
||||
pnpm add @supabase/supabase-js dotenv
|
||||
```
|
||||
|
||||
`*[Voeg supabase client toe, env vars]*`
|
||||
|
||||
```typescript
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!);
|
||||
```
|
||||
|
||||
**Vertel:** "Env vars komen uit Cursor mcp.json — daar geef je ze door."
|
||||
|
||||
`*[Pas Cursor config aan met env block]*`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/full/path/dist/index.js"],
|
||||
"env": {
|
||||
"SUPABASE_URL": "https://...supabase.co",
|
||||
"SUPABASE_KEY": "eyJ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`*[Pas searchBands aan naar echte query]*`
|
||||
|
||||
```typescript
|
||||
async ({ day, stage }) => {
|
||||
let q = supabase.from("bands").select("*");
|
||||
if (day) q = q.eq("day", day);
|
||||
if (stage) q = q.eq("stage", stage);
|
||||
const { data, error } = await q.limit(20);
|
||||
if (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: error.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Errors retourneer je als data met `isError: true`. AI weet dan dat het mis ging. Niet `throw` — dat crasht je server."
|
||||
|
||||
`*[getBandStats tool toevoegen]*`
|
||||
|
||||
`*[addFavorite write-tool toevoegen — let op user-intent in description]*`
|
||||
|
||||
`*[Build, restart Cursor, test:]*` "Hoeveel jazz bands zijn er in totaal?" → AI roept getBandStats aan, antwoordt met aggregaten.
|
||||
|
||||
💬 *Vraag: 'Hoe weet AI dat hij Supabase tool moet gebruiken en niet z'n eigen kennis?'*
|
||||
|
||||
**Antwoord:** "Description. AI ziet 'Zoek bands op dag, stage of genre' en denkt 'oh, dat is wat de gebruiker vraagt'. Bij vage descriptions kiest AI vaak fout. Daarom zijn descriptions zo belangrijk — net als bij tool calling in Les 12."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Demo 4: Resources + Prompts (20 min)
|
||||
|
||||
`[SLIDE 13 — DEMO 4]` `[SCHERM: editor + cursor]`
|
||||
|
||||
**Vertel:** "Naast tools heeft een MCP server twee andere primitieven. Resources en prompts."
|
||||
|
||||
`*[Resource toevoegen]*`
|
||||
|
||||
```typescript
|
||||
server.resource(
|
||||
"bands-list",
|
||||
"bands://list",
|
||||
async () => {
|
||||
const { data } = await supabase.from("bands").select("*");
|
||||
return {
|
||||
contents: [{
|
||||
uri: "bands://list",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Vertel:** "Resource is read-only data. AI mag het inladen als context. Verschil met tool: tool wordt aangeroepen tijdens chat. Resource is statische data die je in chat invoegt."
|
||||
|
||||
`*[In Cursor: @polderfest:bands-list]*`
|
||||
|
||||
**Vertel:** "Cursor toont autocomplete voor resources. Selecteer, resource wordt in context geladen. Vraag erover — AI heeft alle data."
|
||||
|
||||
`*[Prompt toevoegen]*`
|
||||
|
||||
```typescript
|
||||
server.prompt(
|
||||
"daily-recap",
|
||||
"Genereer een samenvatting van een festivaldag",
|
||||
{ day: z.string() },
|
||||
({ day }) => ({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Geef een recap van ${day}:\n- Top 3 acts\n- Drukke momenten\n- Bijzonderheden`,
|
||||
},
|
||||
}],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
**Vertel:** "Prompts zijn herbruikbare templates. In Cursor command palette krijg je een MCP prompt-menu. Selecteer 'daily-recap', vul parameters in, AI start met die exacte prompt.
|
||||
|
||||
Nuttig voor team-templates. Code review template, bug report opmaak, vergader-recap structuur. Eén keer schrijven, hele team gebruikt 'm."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — MCP vs Tool Calling (10 min)
|
||||
|
||||
`[SLIDE 14 — Vergelijking]`
|
||||
|
||||
**Vertel:** "Wanneer MCP, wanneer gewoon tool calling?
|
||||
|
||||
Tool calling — Les 12 — is function in jouw app. Voor app-features. Snel, lokaal, alleen jouw users.
|
||||
|
||||
MCP server — Les 16 — is library voor AI-clients. Voor interne dev-tools, voor distributie, voor herbruikbaarheid over meerdere clients.
|
||||
|
||||
In productie gebruik je vaak beide. Tool calling voor je product-features. MCP voor je dev-workflow. Geen conflict — twee verschillende contexten."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 9 — Lesopdracht + Huiswerk (10 min)
|
||||
|
||||
`[SLIDE 15 — Praktijk]`
|
||||
|
||||
**Vertel:** "Lesopdracht — half uur. MCP server skeleton met één mock-tool. Inspector test. Laden in Cursor of Claude. Test-vraag werkt.
|
||||
|
||||
Huiswerk. Vier dingen. A: 3 read-tools naar Supabase + 1 write-tool met user-intent. B: 1 resource. C: laden in beide clients. D: MCP.md met 5 secties — tools, resources, voorbeeld-prompts, setup-instructies voor anderen, één observatie.
|
||||
|
||||
Bonus: prompt-template, npm publish, HTTP/SSE variant, of je eigen eindopdracht-data als MCP server."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 10 — Afsluiting (5 min)
|
||||
|
||||
`[SLIDE 16 — Afsluiting]`
|
||||
|
||||
**Vertel:** "Wat hebben we gedaan. MCP — open protocol. Tools, resources, prompts. Stdio en HTTP transports. Server in <50 regels TypeScript. Werkt in Cursor, Claude Desktop, custom clients.
|
||||
|
||||
Volgende les — les 17 — gaan we externe APIs in de diepte. OAuth flows, webhooks ontvangen, Stripe checkout, Resend email. Veel productie-patronen die je in je eindopdracht direct kunt toepassen.
|
||||
|
||||
En les 18, de laatste — Supabase Auth en RLS. Multi-user apps. Echte productie.
|
||||
|
||||
Vragen?"
|
||||
|
||||
`*[Vragenronde]*`
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| `cannot find module` errors | `"type": "module"` in package.json + `.js` in imports |
|
||||
| Server crash bij `process.env.X` | Env vars niet doorgegeven — check Cursor mcp.json `env` block |
|
||||
| Cursor toont server niet | Cursor restart (Cmd+Q), absolute path checken |
|
||||
| AI roept verkeerde tool aan | Description specifieker |
|
||||
| Inspector werkt niet | Build eerst (`pnpm tsc`) |
|
||||
| `console.log` breekt protocol | Switch naar `console.error` |
|
||||
301
Les16-MCP/Les16-Huiswerk.md
Normal file
301
Les16-MCP/Les16-Huiswerk.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Les 16 — Huiswerk
|
||||
## MCP server uitbreiden + Supabase + MCP.md
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Deadline:** Voor Les 17 — Externe APIs in diepte
|
||||
**Inleveren:** GitHub repo + `MCP.md` in root
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouw je MCP server uit naar productie-niveau: meerdere tools incl. een write-tool, een resource, geladen in zowel Cursor als Claude Desktop, en gedocumenteerd.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Tools naar Supabase (verplicht)
|
||||
|
||||
Vervang de mock-data uit de lesopdracht door echte Supabase queries.
|
||||
|
||||
### A1 — Supabase client in MCP server
|
||||
|
||||
`src/lib/supabase.ts`:
|
||||
```typescript
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const supabase = createClient(
|
||||
process.env.SUPABASE_URL!,
|
||||
process.env.SUPABASE_KEY! // anon key voor read, service voor write
|
||||
);
|
||||
```
|
||||
|
||||
`package.json` install: `pnpm add @supabase/supabase-js`
|
||||
|
||||
### A2 — Drie read-tools
|
||||
|
||||
- **`searchBands`** — al gedaan, maak hem nu echt (query Supabase)
|
||||
- **`getBandStats`** — counts per dag of per genre
|
||||
- **`getBandByName`** — exact lookup
|
||||
|
||||
```typescript
|
||||
server.tool(
|
||||
"getBandStats",
|
||||
"Aantal bands per groep (dag, stage, genre)",
|
||||
{ groupBy: z.enum(["day", "stage", "genre"]) },
|
||||
async ({ groupBy }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("bands")
|
||||
.select(groupBy)
|
||||
.order(groupBy);
|
||||
if (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: error.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const counts = data!.reduce((acc, row) => {
|
||||
acc[row[groupBy]] = (acc[row[groupBy]] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
return { content: [{ type: "text", text: JSON.stringify(counts, null, 2) }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### A3 — Eén write-tool
|
||||
|
||||
`addFavorite` — schrijft naar `user_favorites` tabel (uit Les 12 huiswerk).
|
||||
|
||||
```typescript
|
||||
server.tool(
|
||||
"addFavorite",
|
||||
"Voeg band toe aan favorieten van gebruiker. Alleen bij expliciete user-intent.",
|
||||
{
|
||||
userEmail: z.string().email(),
|
||||
bandName: z.string(),
|
||||
},
|
||||
async ({ userEmail, bandName }) => {
|
||||
const { data: band } = await supabase.from("bands").select("id").ilike("name", bandName).single();
|
||||
if (!band) return { content: [{ type: "text", text: `Band '${bandName}' niet gevonden` }], isError: true };
|
||||
const { error } = await supabase.from("user_favorites").insert({ user_email: userEmail, band_id: band.id });
|
||||
if (error) return { content: [{ type: "text", text: error.message }], isError: true };
|
||||
return { content: [{ type: "text", text: `Toegevoegd: ${bandName}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] 3 read-tools werken tegen Supabase
|
||||
- [ ] 1 write-tool werkt en heeft 'alleen bij user-intent' in description
|
||||
- [ ] env vars worden gelezen vanuit MCP config (zie Cursor config voor `env`)
|
||||
- [ ] Alle tools getest via MCP Inspector
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — Resource toevoegen (verplicht)
|
||||
|
||||
Voeg minimaal 1 resource toe — read-only data die AI mag inladen als context.
|
||||
|
||||
Ideeën:
|
||||
- `bands-list` — volledige bandenlijst (text/plain of JSON)
|
||||
- `today-schedule` — vandaag's optredens
|
||||
- `stages-info` — info over de stages
|
||||
|
||||
```typescript
|
||||
server.resource(
|
||||
"bands-list",
|
||||
"bands://list",
|
||||
async () => {
|
||||
const { data } = await supabase.from("bands").select("*");
|
||||
return {
|
||||
contents: [{
|
||||
uri: "bands://list",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
In Cursor: `@your-server:bands-list` om resource in chat te laden.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] 1 resource gedefinieerd
|
||||
- [ ] Test via Inspector: resource lijst en preview werken
|
||||
- [ ] Test in Cursor: `@server:resource-name` werkt
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — Laden in twee clients (verplicht)
|
||||
|
||||
Server moet werken in **zowel Cursor als Claude Desktop**.
|
||||
|
||||
### Cursor (`~/.cursor/mcp.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/dist/index.js"],
|
||||
"env": {
|
||||
"SUPABASE_URL": "https://...supabase.co",
|
||||
"SUPABASE_KEY": "eyJ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` op macOS):
|
||||
Identieke JSON structuur, ander bestand.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Cursor toont server met groen vinkje
|
||||
- [ ] Claude Desktop toont MCP-icon, server beschikbaar
|
||||
- [ ] Test-prompt werkt in beide
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel D — `MCP.md` (verplicht)
|
||||
|
||||
Schrijf in repo-root.
|
||||
|
||||
### Sectie 1 — Tools
|
||||
|
||||
Tabel met je tools:
|
||||
|
||||
| Tool | Description | Type |
|
||||
|------|-------------|------|
|
||||
| searchBands | Filter op dag/stage/genre | Read |
|
||||
| getBandStats | Counts per groep | Read |
|
||||
| getBandByName | Exact lookup | Read |
|
||||
| addFavorite | Toevoegen aan favorites | **Write** |
|
||||
|
||||
### Sectie 2 — Resources
|
||||
|
||||
| Resource | URI | Inhoud |
|
||||
|----------|-----|--------|
|
||||
| bands-list | bands://list | Volledige bandenlijst (JSON) |
|
||||
|
||||
### Sectie 3 — Voorbeeld-prompts
|
||||
|
||||
5 prompts die je tools triggeren — getest in Cursor of Claude Desktop:
|
||||
|
||||
```markdown
|
||||
**Prompt 1:** "Welke jazz bands spelen op zaterdag?"
|
||||
→ AI roept searchBands({ day: "Zaterdag", genre: "jazz" })
|
||||
→ Antwoord: ...
|
||||
|
||||
**Prompt 2:** "Hoeveel bands per stage?"
|
||||
→ AI roept getBandStats({ groupBy: "stage" })
|
||||
→ Antwoord: ...
|
||||
```
|
||||
|
||||
### Sectie 4 — Setup voor anderen
|
||||
|
||||
Hoe iemand anders jouw server kan installen:
|
||||
|
||||
```markdown
|
||||
1. Clone repo
|
||||
2. `pnpm install && pnpm tsc`
|
||||
3. Maak `.env.local` met SUPABASE_URL en SUPABASE_KEY
|
||||
4. Voeg toe aan ~/.cursor/mcp.json:
|
||||
{...config...}
|
||||
5. Restart Cursor
|
||||
```
|
||||
|
||||
### Sectie 5 — Eén observatie
|
||||
|
||||
Iets dat opviel:
|
||||
- Welke tool koos AI vaker dan verwacht?
|
||||
- Was de description duidelijk genoeg?
|
||||
- Verschil tussen Cursor en Claude Desktop in gedrag?
|
||||
- Eén keer dat AI verkeerde tool koos — waarom?
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 600 woorden
|
||||
- Concrete voorbeelden + tool-calls
|
||||
- Mag wat informeel
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — Eigen prompt-template
|
||||
|
||||
Voeg een `server.prompt()` toe — bv. "daily-recap" of "compare-stages". Documenteer.
|
||||
|
||||
### Bonus 2 — Publish naar npm
|
||||
|
||||
Maak je package npm-installeerbaar (`bin` in package.json, `pnpm publish`). Anderen kunnen je server installen met `npx`.
|
||||
|
||||
### Bonus 3 — HTTP/SSE variant
|
||||
|
||||
Naast stdio: maak ook een HTTP/SSE versie voor remote loading. Deploy bv. naar Vercel (zoals Les 15).
|
||||
|
||||
### Bonus 4 — Andere data-bron
|
||||
|
||||
In plaats van Polderfest: maak een MCP server voor jouw eindopdracht-data. Eigen API, eigen DB. Direct nuttig.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **`MCP.md`** in repo-root
|
||||
3. **Screenshots:** server werkend in Cursor + Claude Desktop (in MCP.md)
|
||||
4. **Een korte demo-video (optioneel)** van een chat-vraag
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — 3 read-tools + 1 write-tool werkend | 3 |
|
||||
| B — 1 resource werkend | 1 |
|
||||
| C — Geladen in 2 clients | 2 |
|
||||
| D — MCP.md compleet (5 secties) | 3 |
|
||||
| Server runt zonder errors | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — Supabase tools (3 read + 1 write) | 45 min |
|
||||
| B — Resource | 15 min |
|
||||
| C — Beide clients laden + testen | 20 min |
|
||||
| D — MCP.md | 30 min |
|
||||
| **Totaal** | **~2 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Env vars niet gelezen | Check `env` block in Cursor mcp.json |
|
||||
| Server crash on Supabase call | Check service key (write needs more permissions than anon) |
|
||||
| AI roept verkeerde tool aan | Description specifieker — wat doet het, wanneer gebruiken |
|
||||
| Inspector kan server niet vinden | Build eerst (`pnpm tsc`) en check `dist/index.js` bestaat |
|
||||
| Resource niet zichtbaar in Cursor | Cursor doet niet alles met resources — check Claude Desktop ook |
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Test in Inspector eerst** — sneller dan via Cursor heen-en-weer
|
||||
- **Descriptions zijn cruciaal** — AI kiest op basis hiervan
|
||||
- **isError: true** bij failures — AI weet dan dat het verkeerd ging
|
||||
- **Server is gewoon Node** — log via `console.error` voor debug
|
||||
- **Eindopdracht-tip:** als je MCP server voor je eindopdracht-data maakt, kun je 'm in Cursor laden en tijdens dev al data-vragen stellen
|
||||
137
Les16-MCP/Les16-Huiswerk.pdf
Normal file
137
Les16-MCP/Les16-Huiswerk.pdf
Normal file
@@ -0,0 +1,137 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 5 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521111146+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521111146+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 16 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Count 4 /Kids [ 4 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1844
|
||||
>>
|
||||
stream
|
||||
GauHK96Zjb']/F3m\Zn"-<Bd>+hC=pUrbWf:-8,j2VDJ7*>/bj&#V4Aq<`5,=C=41>YdG$%NlqNQgXh?#6TiJem+t1_!(u.?SGk--=;>7`.c<[31i2)%cNTJXp7?C.KU)*&EXL\R?N1LM%"XE5Z+.%hZ[(9U/[e9j$)@54qnpL*]XXb0<)-[+GtGBafYMn8Bt)?90=YUc2F2]b*WtVfia)B[f7`a=0Tt!.oj*41=,]&15F84hnE@3ANnRSFHbl:r)"1i\uFVVF!"/[`[>>cPa.9fX^:9L?d*Xn]n4?X9TG(7JUCOD`"gd/QiCBY^a*198bo$;]2n@?E0b#XY^rHC(t=fraZE.l@)NR<JVUKdif:s*F3!se2Tuk5*!CpnC#09809tJo=@2*$!Fp^XcXUR*5:;<W=TDJ@@mR`L.^Lpn1O,HOSME8oHYKig(-*)R8FbE9mqu_QPL-j[n=0e`M@9'IA$1V7CD97ua8+!aMA3cCqTaLU(%kPp3tJo&<gfL5^>"53h9OAR)XWggBA\\&O*HU4eiW",m]cbMAQ<7&f9X;>$e-=o[:!J6Y$;/[N$Zbs_$0UI,,K`B9]7=>=*_Gb!Y%])"sh,^\H*!r1DZY9?dCo&WJdT*TMDM$Tp&Skn-aWH(Z@2%2*U"4168'0&esdRR)(6i0!GNJ\_JgIGA-So'bE3^2MQ:2D6*UL-+$n'VtPT\;)#V@-RPjaO9Qgt?c'E6CISi:$8@lA#kjP.en%&V8aGXi#^)4BDflnp8Z&-SA>N[<BIg+W@:0.+.F/B8Em&BBlhjt`MbM%1a/]i9%,sZ.._W_U:(Qp^.SDL8L!H96D-m_kL\*3u0'ar%i$,X5Ccj3<I+Ls[KA;k?\+(N2*9>1**9t,f')lT4M(M.,HQQ=F<Z@mf00m0R/_KLj^V@\.LBc?Ebe]Q1$Y]mGnG2V.G.kZu]Q[[U20N$!_J-#)r)!VpROelD0MMUg>M:FO^q0/k>&P$YaF8qC#7Z<R9m1&4h!Kf'Ha(rg3PrIS907u)/*aQ>,:PD3K!/mtTY8dnc3S=GcQgN$(HRJ`Hm&M"'ZHF_P'VrT,\[!=f+6rXr%S]8-EoGkiOLcD83(2?):UJ2d/I`SK(tJoZq274ceY[OP>8e7UXQl)J6NKK4TViB/7;?9E?/&9*bWSmL\2URCL+k\^ec1a1Je[[">i#tLE`_ifZYBqj$K)X>t"O6Q,05`:.*KB0M0RTU-o.$_-%]-Ysp])n#CbTCo1f*cD0)9X3jB0Y5m1L`(/,HA-KJB@=o'>Z^acu;CLKr=!G>oZ=tEJ*0L`+'\$-83NfqnVVb!0SFKWq,0/!f"#>XdX4\r'3.]XbfglgG2:d<.7"bcISl:F'>LpQb<Y2ZB%,:;ulY19J\(S>In@Tu3Rj<_G$&*J!D3Ks0+Q_\>&5sd+6HWll`l[-E$#@GkGot+G8.m]08&u+%M3_MNae(DhfHdOE0\7OCj@,NqpVca*k?*ZKW6L7h`p#-Y1LW1oGoke$j0/k62Vlo2alqTQqcXem0"804gDG;V(pc#)O7fFEGacMfSL<O%<bmhf.sIY-Do>NU-M:n,mEnD,RI<@S,j'6mZ4CJ_BEZb_EJmnQ59n*GHA:$;ZBRC$U79UcMp#T5TZTBFY/71\Sc8:*SEl/>qQq#sT'cLS@-l&f2hJjM38F]>^U?X@*++BBgq:.GKa;&%b:lX"Lej4=W3Ie1'*R?sHJ)+^r*1n<7?ureN`br?mH;OL5c/dt/R]M4QkXfX2gm((J)_QMfebtCaR`orcq*01NfJa0ZACmk:O`agC1'noWY-N)!bU3?"MTHGaIM=W,:QVR,BfsJX9r%jR?gm2T)u~>endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1049
|
||||
>>
|
||||
stream
|
||||
Gat=)@;jmY&H/b=EAR;3.FP7f;'_F$"YqVp$P_8$Gl9B.W?T_">?T*:3c,].^[ESmP1'n\43OkP'CWWHGL&*uQnHs<(2JG-k4%_h!TPk@!YZtirW^[2dGGOVad#Bt'-/G<#L&A#Jp%mZO@k`TcPYkJTiM5JAuKjLJQ&.<9L3H%q'9F"9#aI25g<3+jYuuA9$W8s?n,a]FoG40%cq7ML[(h^r_6e-K+1P7W/8<WV(.2p)NHG"HhSFb2(R^ES#31C&)Wi8`l!CL"_Y$2SiLGS2^SB:mS)0dY"e;E@K$;i:BK,h^.;-98ZWum^G-4cKNB,Y5+2+SO8F:<Z?6Y)&*PU%\jMX.W&0bH<PfCf7YE=)R3]bkH`qfn/0]q5V\IHb*=8Qg[DuAa#p"f>.5[&Q@Otur+47\`75W#O&u[,(<r4V*Yu1np^mWXLVQk_-6;Z.*MARhFpE%?g=?LR<;*5Rld:02"aIg&3^)#*je6+;,]`Xj3,`m-aN_p<6fc+1N)/!1U90'hO?,PHfC%"`RSij8;RcS++Ol&$uok&C<hr>eU"HdA`FXR6rGE#bAF^CEm)Zt@l_m2WbhUDgD<Fni5fCf<6caT/k66%o%rm]N+_rF$HXt`jQ%".eTo(b>UeLCEKcF:5g/WLUeBL47t46anubh)"9%bMhs@UVarY-m&5W=)fJm86X*YU.($0B)m1A=Oj-l)o>_U8a]]0$;u4VnHtPGQIW<gj3q/g\a*V#+'25VQkfpDRMNRS0L\J)k!9W!77.V'V6F`8bm$MqpEcjVTNOM'D05*30ag@4F`lTIMGF(kQotKfHk8';h-&<>2qPY9molg@B?Brk<8Sn[kr])2kN6qL)[6IaT8V$G>2A3H3W-=:;FI4[Va_snEks`4]$sY:^F3!2j0U0nq``CMHA!;k(dMB'o%1j^9"ai%)9-eQ0E/\Ps)9dfhWB#)/O>d+-E9kP^]QHf?96=g*P\231$hms%kRE_F_tL1;97RrL?U_R\s:cgHXTa]X</`<?.grFOl2]]Rb6Q_!/a"Cjr>KgBaO'!?u1U:&~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1967
|
||||
>>
|
||||
stream
|
||||
Gb"/'gMYb*&:N/3%.`Jd2FCB,7![2![V:Y3d<j]`XS7:3(e9E6\WUbf/b8?leC@.WglMa(U9P;''KOc$LI3ehpgN:a?j?+:"FDd>0FjjF@"JY$]nR=Jl`3Iab<=eaYpHf+%`Gj$+(S;#!QQLhc[?"\H:3&\KOdJC?NS8jT#Wt71`S]b(_G#iSeOF8F'Rs9>&P(@F&X`Ij1>6/osK4rR-,-l[LUQ`^*jD&YhH'WW\"KfNp"2k#/.M7^)XqhFK4rj-r"4H>\#lT.U^K2#>Yj7:\-]k$Q[**^!q$_TBW0(L!_W6*Z87!nqHHB@8lX-r7<t7k/+/fPco1P;TX?jVGX)'?5)r3KA&k:H#9NN-*`HN@!6,"/K5oZD;bg<AeoS]FkMeo(AA*26EZ;f=jI_6GqNGGELJn=eW/SlPDgAVm?!D#Hj<j.2QPuI"uHD_)VA<8(;m&OHUq@,[+,dk['LsrS(>Z,EU1W5kCIbdQ(Tn).ee9C%<tM;\jY-E=Nd+.)"TbS^eh0c*EhOkfih\fX298SG>5eUb4c4W/73AU0HYjm:B!bSVi*+Q:o&E*lD[Ndgfs&[*_-LLna:P(E.-HW_NZ6Dht(g-Ql0IHrR.]pNIAK!;%h-<MG9%E4,,Fl&t#UCbQfks]oRo%l?%P&Tu0=#qiDjc?77@f6'N@djICc?9m#mI'9nj)AJAX)[GLm`#VM6tj?Rr)UR<DjYMtC^/@L5-AmmXJP)2i1T\fO$Cutk$43!Xt6LU57g+eXJ_W[tQ,7:GBXTe(tMX#'fGWdM-*n*k$m;s6Db0C7GfikTJ]]h%-in"CL9HN!u?_9.BJo@Cm?^;#ejZ5a[OX@X!hG0EjSN^"s_nEb[?KJ(VJG:)P_NR4Ca(B4)'O^uY7j._h,(Le1ncF8*W!W]@n-%6570Vb1\XKI%PJ\rB2>VO:]-Q"5%LO2$KQhoNXig<n&5r(&<Mk[CQCok[=Wdt46hog_@i^aLgLdhrJ414_0D^1+#iQ?Hp1,[^(<uL8FH.*BUZSFY<=u(LhJo,6f86eMin9'$)Hhdn<XdsU:[d/']Z?gX)BGE0HR=u*8/?4##<q"X7OUblRiSaLHM6g\2,S4>c2=p>3S7TW\=#3sr:Ls`]YloapQj1n]ng^"%(C7I]0?[A>ph3G2s&DL)/i`AB"\=O2N5.gX5%1H3^JdokA<'h2_BgD(:C%*<p\S'/kgDd&!;gYcGH^[Y[sO>k>\AF<_eW2ZG84nE00.%/FYJ8G&G=I0g<Q.DP$*(UfUsX+Iud!nH1h"GCQ@jp(pkLI/U+?ggT77+Ajq#qH7&YiPW)E;qTaXEpESjocCKlg],C/hJ3iMp(8jp8o"^laR!3AC\It']Gb=.h13fYPoWKKcj[T`,rB%rlCtFD@n`l-X-qi=FJ\"9c_08;p1(K92'.)7P9QRLdi/J(m1!HIaMP"'kl2HUf5W9<CdfJM",uc@AgrIY)'pZNqU]?OoBEGAIC&s&&7PIdDg\.D6Z[?);(DInl$P2*<cq`68BL64*JKiD\<XaEL@$"Xni(n[.!n2Yam>"K-aOB$1g$+]5M5AhgMo1HSJE$N#3M99(sh$j:dmF[6/Tn6[\i@+PUl.u-rIF$<tM`f,M$P^^5;ASS(K,`j8@m`<4tmSe-'tJ_nLt*ZTa&`8Qdl/a0IlCGZ;S.AQje7X"5amKnQ!E.ed8d^i'bgpMODQhA]`>MU*@Ti%_Oi;7a:&BHcf_@J^6=Mn;"I,u(_&eo=g@o+o@eW#nADR?qXo(;LUVFK.-V-nl7LP$KO.?<8Gl$Dh'SijtJ\L`;#$h^pCOFKq4N1,ZaK&fN,Va!F@K6H-3<Xb"%.(+#5NiVL64mbGVL]/fk"Cca2!FCH."j45!\[=NoZ+J%PL1ThSLZmne.]3"Yar=UW5'>=1DiTWWMF:i=sLom.1oBa)Qq]#2;>Y:\XUJ;*]Vb*!76C3N\;3/u_j3#:?e!OJN.p2fPAHLQm~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1937
|
||||
>>
|
||||
stream
|
||||
Gb"/'8U&oI&AIa;9FpB/P%TFJ2]t7D[aV`>D4Od'0>>4I/A^=XJlLJ%q@:#lembK?96t8'=X&^=2[a44"W(OQrJWO_=#5*kRK@%S]*7I5+(fHMI-03GKS:uJ"!AY/4i)XSci-3fGB*sU&dfs6E&St'o"jLRE%X+(g_WC9JkElH1PZ$g69!mf&5Ik>LKjX=&i,3CI1h4,f\GB-a/sT<KY;IUB(nbKUnGD!,HBo?QNg:eZODkCq=VW/]IE!c`/C?\iO?BrnY1bR`ZdCQ3'K&dRp%+WVH,/ub"Upi;jcCWm$Zrm_=tWnpmrEf`.+frck,%jg3clMZGXA?F.Gtn-U@.IVuor=H:]?J'@0"bfqT=,4L>Okr6PrtF'(]=bOJa#Y_]flcR7$Un#:\$UQA3B"9VBbN6h!FE_7q5pdcNu#[kUEBLaic0Kb.g^u7=s.'D"kU,rFe,F@&&f2ADh1CDfR*n?Wk*]jb-FXm:RcMkdulTD<DL4o=#0f_$U!nV(D"%?IU#J<4+o?"NSg6m6XH\*?Jpk#_eI%QOO$?Y&.pofRf0lVP?PdN,aV:+\-HTYa/g!C*s:UkW(G'OW"eTG(.fGL3tIZ=]iQ:K_C+tjkg9%^/o@C@>/FA8OoJV[5JnK7pka#<MB#4,aKY\:**?-NUqck_,5bu#9,9$^KIrL,[H)-*e<7EVXOb51t5\)2:KXg0^I[G!,g#EO?rdgWn'a>@_BC%=$3ZFNQ7=eIRm4Aci^C>%Z.I]]tFJj2N`VNtqrndQ[fBF<mN4&ZR#^;q#FDH"39bq<@.?(Jl*<a+WCjAMEb9V(D,0t"cif)J!LJ%*ijT3ai##d>e9$LSTt9:ci/;FrEJa,-lD3_#jKU7k#E]%6KAG.>B#%@P9-'*sZ+g%bU4o9H<d(ZAEfH2Y.OO$[JN<3#OMH3\aqfloNY,#&DuB`u<9LF\OKG4(p]5WaeV6GDjgO-r5@J%_sf)W0F@6\Oh7;=fe9@2EO>6c^JL!oSkT`:\=(FT@cqe8i`J*Z"CiRA(2rX?FVQ9%U)k9t$r?FjW(O],BidS%MetKQsh]&<H<kU;]*Cn$>EN`=M2.Q'07/NBOV!k=G6mMUUF7D040d%a!72HFrF'WS=i-Y=>C&,%ZY/MUgn\kTT.R*gi@">E18s.[:=k:S$7)SSE!8blK.S#2@0CLt_O.OU!4NOsLZLo9(SWd<?:4,*-NAb_*F[`N0=!CGZG\]7/;\KaQ-G4r(L`!**#fF\%jJqd+Vl7lDl4.FSAq*@Z+YA2]GFK`?7`fJmLb-4GXXT@_A>*)d-Us3VtjHlOq/[Z2,MS&3_TEol$^28Y`tPdE5XH-@98m[Y^a$E#SVj\;bJ/Wk;5V&_d?(\3R&Nj"@Gr<UHe^uO?:Qt<s&AdKSdhf*!_%<mDuHN"gNphOuaNcD8,\oigRkmgp&M\P2QJBMh+d!`L-S1uSQpLfRL:G<WoF@!NrL]tN.q:_9abX,d-KuB(,5'sB(2L9R(1kF<CBP?/Jln)[coDtj3gUo(qXK=T8R4]GLp3FdfiGUVCes@_sODVbj4C?dmcTnWfShPoL-]I#mWl5>\00pbt9j6Hi*r0MK)=3]q\0Q9qkZD!;%]^,2\AD@%)gqrm9dt"V;,]U3GA0`<9D'[6%*6*&mI^\5aTYXt_YUjLfh,]'Kf26*5`%79V:uRn9W;KLX47h#B6't0AU6S:]iMmD)V_kC.BA:\'A6,Zfc[1KAX7I96;]9u^ZG&l@?3qFN?LcDOl/G96U\G9@lbh9<QAF'Lp"_m=!D?sRtWk*Qfd`?btR)PKVs$V>PG2GM9n[ch[GrYiRCsq,.h!`F0c=b4BoG%6*IrE0l!.Z@r3'35D,f@>gNp7K!^)_`k[_+m_(X;CNFBOeOujA7VrT!2C<@=:)e!R2Ls)No1uL3QWlgTpNo4c)RWGp9h4M7C<QXKJ-"gdJdq&1~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001325 00000 n
|
||||
0000001618 00000 n
|
||||
0000001696 00000 n
|
||||
0000003632 00000 n
|
||||
0000004773 00000 n
|
||||
0000006832 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<a5e9ef526371aaece66bc9dca30f24bc><a5e9ef526371aaece66bc9dca30f24bc>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 10 0 R
|
||||
/Root 9 0 R
|
||||
/Size 16
|
||||
>>
|
||||
startxref
|
||||
8861
|
||||
%%EOF
|
||||
218
Les16-MCP/Les16-Lesopdracht.md
Normal file
218
Les16-MCP/Les16-Lesopdracht.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Les 16 — Lesopdracht
|
||||
## Eerste MCP server bouwen + laden in Cursor
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Duur:** 30 min in-class
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Werkende MCP server met 1 tool, geladen in Cursor of Claude Desktop, en getest met een echte chat-vraag.
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Setup
|
||||
|
||||
```bash
|
||||
mkdir mcp-demo && cd mcp-demo
|
||||
pnpm init
|
||||
pnpm add @modelcontextprotocol/sdk zod
|
||||
pnpm add -D typescript @types/node tsx
|
||||
```
|
||||
|
||||
`tsconfig.json`:
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
`package.json` — voeg `"type": "module"` toe.
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Minimal server
|
||||
|
||||
`src/index.ts`:
|
||||
|
||||
```typescript
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "demo-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Mock data
|
||||
const BANDS = [
|
||||
{ name: "Lost Tigers", day: "Vrijdag", stage: "Main Stage", genre: "rock" },
|
||||
{ name: "Polderfunk", day: "Zaterdag", stage: "Main Stage", genre: "funk" },
|
||||
{ name: "Jazz Collective", day: "Zaterdag", stage: "Tent", genre: "jazz" },
|
||||
{ name: "Echo Bay", day: "Zondag", stage: "Beach Stage", genre: "indie" },
|
||||
// ... voeg er een paar toe
|
||||
];
|
||||
|
||||
server.tool(
|
||||
"searchBands",
|
||||
"Zoek bands op dag, stage of genre",
|
||||
{
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
|
||||
stage: z.string().optional(),
|
||||
genre: z.string().optional(),
|
||||
},
|
||||
async ({ day, stage, genre }) => {
|
||||
const results = BANDS.filter(b =>
|
||||
(!day || b.day === day) &&
|
||||
(!stage || b.stage === stage) &&
|
||||
(!genre || b.genre === genre)
|
||||
);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(results, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("MCP server started");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — Build
|
||||
|
||||
```bash
|
||||
pnpm tsc
|
||||
# resultaat: dist/index.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — Test met Inspector
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector node dist/index.js
|
||||
```
|
||||
|
||||
Browser opent automatisch op `http://localhost:5173`:
|
||||
|
||||
1. Klik "Tools" tab
|
||||
2. Zie `searchBands` in de lijst
|
||||
3. Klik "Call Tool"
|
||||
4. Vul `day: Zaterdag` in
|
||||
5. Verwacht: JSON met 2 bands
|
||||
|
||||
Werkt? Door naar Cursor.
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Laden in Cursor
|
||||
|
||||
Open `~/.cursor/mcp.json` (maak aan als 'ie nog niet bestaat):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"demo": {
|
||||
"command": "node",
|
||||
"args": ["/FULL/PATH/TO/mcp-demo/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Belangrijk:**
|
||||
- Vervang `/FULL/PATH/TO/` met je echte absolute path (`pwd` in terminal)
|
||||
- Geen `~`, geen relative paths
|
||||
|
||||
Restart Cursor (Cmd+Q + opnieuw openen).
|
||||
|
||||
Check: Cursor Settings → MCP → server "demo" zichtbaar met groen vinkje.
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — Test in Cursor chat
|
||||
|
||||
In Cursor chat (Cmd+L of Cmd+I):
|
||||
|
||||
> "Welke bands spelen op zaterdag?"
|
||||
|
||||
Cursor roept `searchBands` aan en toont resultaat. Werkt? Klaar.
|
||||
|
||||
---
|
||||
|
||||
## Alternatief — Claude Desktop
|
||||
|
||||
Als Cursor niet wil:
|
||||
|
||||
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"demo": {
|
||||
"command": "node",
|
||||
"args": ["/FULL/PATH/TO/mcp-demo/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop. Chat input toont MCP-icon. Vraag stellen werkt identiek.
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Project opgezet (package.json, tsconfig, ts source)
|
||||
- [ ] Build succesvol (`dist/index.js` bestaat)
|
||||
- [ ] MCP Inspector toont tool + tool werkt
|
||||
- [ ] Geladen in Cursor of Claude Desktop
|
||||
- [ ] Chat-vraag werkt — AI roept tool aan, geeft antwoord
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| `cannot find module` | Check `"type": "module"` in package.json + `.js` extensies in imports |
|
||||
| Cursor toont server niet | Restart Cursor volledig (Cmd+Q). Check absolute path. Check JSON valid. |
|
||||
| Tool niet aanroepbaar | Check tool name + parameters in Inspector eerst |
|
||||
| Stdout pollution | `console.log` → `console.error` (stdout = protocol) |
|
||||
| `tsc` errors | Check tsconfig en moduleResolution |
|
||||
|
||||
---
|
||||
|
||||
## Klaar?
|
||||
|
||||
Verder met huiswerk (`Les16-Huiswerk.md`):
|
||||
- 3 tools + 1 write-tool naar Supabase
|
||||
- 1 resource toevoegen
|
||||
- `MCP.md` met tool-lijst + voorbeeldprompts
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1-3 — Setup + minimal + build | 10 min |
|
||||
| 4 — Inspector test | 5 min |
|
||||
| 5 — Cursor config | 5 min |
|
||||
| 6 — Chat test | 5 min |
|
||||
| Buffer | 5 min |
|
||||
143
Les16-MCP/Les16-Lesopdracht.pdf
Normal file
143
Les16-MCP/Les16-Lesopdracht.pdf
Normal file
@@ -0,0 +1,143 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 7 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 12 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521111146+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521111146+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 16 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Count 4 /Kids [ 5 0 R 6 0 R 8 0 R 9 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1722
|
||||
>>
|
||||
stream
|
||||
Gb"/&h/h((&:aF]Z-sSX1c1*(/BL"?'53d7$KVUJI;sYWjZ)/NZ$h2sYO>)l-/[f,);=&K8LCl0p:%Z^mVg\c./l"%^K:U94G2,G$lFcQ,qqn9b[//m=`U&EY.0cH&7MNY306hD+(uG3##[lh`\RgRC1=OQBc&QO1Vcj_CSmf1E-[s-+BhWZ,S7jkA^i;W5nrL\>*;jaCgVXqcT(MOU'Br1bl/-hE!I>P$E-9(b#4eIC^in<Is_1sI]XBo/Zu+RKKm&f(0;OSagm/(02A+ImBZ-G*7!r2/ZX272r0NKqZsN<Ve^l;\Kj;$H5HUXD1O.O7odm)4@$['(X#M_CkD<qcZK@hHn\S-7Waq']T96;HD45RSOf=F-V^;Q5t3h6DImhjap9'/$Ap);(__S".<De7:(a1&"p'^gXPXbWKpa-Q/=4JO/BlIs7\d75TkHoPs+gQj_p)X1-<CL[2.=LDJ\H16(Z>J:i+`+5^2nje6CXtC>SCjcC81/Z)g2Br@E/*a#/U$f6R*M/b5Sg;%P/nT@tmFm0G%0K@D7\7k'm2D3.6fJPY2i%/3[Ug,`4>"R20%X7M@6$feeS7jA&$e2\A/P0M`GCX3_a+3-.P3nh]$.HGQYg2pg:@`\qd5D*Q\_4G#>\j)_b^ZYe/lc(18SptDt^B$,3_aSSr+Yi.X:IX+$i[#TIUH(_ZE*Y4Y3,3W#+[nA7h@EoU3Z3,pGaeSV+lLBn^;l_Y^.!Gmi`Fca7G;I^N3csANHoU#$RsR-b9g/hVg>ur$q+En#b.MS0BmRCh1#-rP.H(Jp@dgbc#0"H6gNDZQ?Z>S":EK[SO=KQP#;[1Q*=a0ZV#g'HH#R2S^nJq)8rICaFAS4Fg(c9]77[q=E04cDPmXk@79lcN7+;Mr0ddZ)N[nWC5o'gkpg0UE50<oR1Ys6@*E%HL'US'gU&Rp%SHW97LL\s^&+!nWb86>hbYUDXbgmKMKOfMa)/)N7bWip0cSbGl.guJ;`6D&Y+MV?hoHE53d5/q39-;/eAP4+):7plsLHktY:=5/u&%5W7ZTF,P[N2C9Yl+M($bJnj:gLq@R;Ys`gpX8dWnjPt[[ibk\CWSHFq!3%i[XClDON[pOSag>1l>tJo3Vo=.35DJ;;]n/#eXRjUll'X,*mu_29]K;_D4MV?_$.gf]<BBn``_.mO=;QLG)bHI!lN(Qp(SR&(juhr17<33)Q?"j*R1J>\.o;"s=hk\-eWnlY/1r&.WSO4<FpnIc4d#3j8cB#HF!J'8G%o+g*M$VHnJ9p1K'Oa1VGBi;:BKh9LT:-TUF,ndbZ20!B9B(Y3f=gX^RK3M?k)RB5i^)+?q=\+#=_R`O+b\CO4>FU#"lTh[::7E-KM;E^R*j.8i[UbpImV/k9D.9G62n+_6Kc%\s/JhVgRESs/L)`(Wo&WB1b/fIFUJOWiKq<1P#4W"X\LA8rdLOrt$:h31GS*$kg]p^U:.uO16DV#UcHjI\R$kIh#N%b>/=E*-We=1cDoWnD3B>c49nOVKV:6`l`F*P!nk-ch@9kQ\7F]3E_;3e'HHj,"$o</6k]<6[.EXpo421GH[bIW<Rrr$qk3hU7Qh\h>$%C(/+T]2"X5*GB!QB,A6q$NC[/!nE_q:J^ec_p!a[^;+S2;3TFoZ184K=Bp_=kCCt'9C2!?><S`gg0*^.UQ;=H^fb.H\*?.rk%`NG5\$!b.e1nnt',o@EP_DEiAdq+T]\Lg[_q-~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1715
|
||||
>>
|
||||
stream
|
||||
Gau`SD0)19&H9tYfVgSD3H\-l`nfU64,o_ARPrF6=NuUhq1&Lf`!B>XE?*%!]mG8>jL?D+>"^WkZM3^l[sNH0",\2Or/]D/-ZbY<A\^:LQbXl/0RjSIcFptU_A1\N#9jQk!='5CqZl6*bJ@7J"[$1#W$Xu,OG4?W!WsG*C!AK!#qR0D%#@q`)[XG:?Q&OeHpJJ0BNc$UWSm4BNh<TK!hq`!8@/7=(i$;#.>KAuU!2+Pbh"Osnaap\poRXE<HMO])>mL$8T\:VJo_B^TDe8hWm[nB@X+&`9X8-6:@e5]jr\"1aP%Qj9.gM]Uu'(?SlEK>i2,)EAa#doKr[$@JIetkj19-E'^s+8Th+3`[.Xd#/5Wiimb0;->r3'nR;a80!N\G`Fh?R,Q?gnT40]U4W$jtYeO,2Hl9q5-$0i2AQ"cn]bRc8N9SY35d-<Rl:4c%8:$+1)N>XjU%ZC%H,*2Fs1;%U#]BR!^r@mff]dC5TVbM`q;ZT1J_j$N@9*ULd8AH*E^l9I?RO2q04d]MUjm4PCB1`(7nfgis&%e=4ED7]sURnVI79RM;r#'?HPO`3]_C[=^@L\Nnf*IX17:%@6l:kF279!g(.__9MNT+apD9.KIo&GNd+%JZd>!&kHZtSjOb:p+<nM_Dim-JXF/T%05*D\m]6_B9]'EYVp?4gV,P2/s)[W[aV<C=qDNZh3$qR'U&D]fSBaUq5+A6hk=!JONWj="Dh&itTu`PEB;`_#@C*[OF8Uk'L^3dLY_aXJuk[i_1Z6[[[kbtJ+-pj%E-o5o\>O`qdOU[1^CIYblVhQN*TZs%#Q?(_jd,;unQIUM5B)m>,M:<M[0Ic3u5%I9hF[F%)Y\"R>2R5u:n)%],(VYD-TX);Xb*si%5\4$i7p;_`(6NXAna>PJ<NTGLASQ&0_7cu.-*npjh]If9S34!g*Cs_tm'[,(hadAu"'<5agZ"E)]PH$7aI'o=o%S3k(1!@GXhRJ:;=,laX`Oa*S^PuT&?0;gPj@7<5]M2orrX!K9,6\.L@@mi+D6DT`e)W#^VHIapc5D6$LVcqBI#1Aj((-/O"(VOA^;(VkKB)!%9+dRTHtXV19Uj)icmglI`PI"5.NhV-_k"'[D<ktHfeGF\9^9eVa)Eo$0!C>oh$qlc)k5iqnmPGKb7XZ$qXR&kWcWO"R$NMh?Y7t7E#YhOE'%N3qEtX,3lAH(Pm*a1K(E%?)etKus0UXt$+<nC).&$][QG2p4AV\t0st$P^,*T;j"?18HOg_@6^9U4a.@b+'A/Wh+dH$ZH>oL`6RU&>N]W(i1Q^SpD$Q53e`&rQ^X\0'-c5s@N*.PV_T"kE4udAp=rg+@.u9;FG&/*.+^+i,3[!^hfXA]dg!cO'2qb_rnGM.O_mZ^BhkBK(40T[_L2hd@L'V5c2ESi"/;1FSI.o\;<t0UDpt,;(PI;dk_*>'>%2*Tjd$)X0MrB#-L3\=f;j=dNWd6KT&/YpDs8VOZV'#g3I5^F`-+4R_C$(j^WB6rJ_,b$7;Kq/i;CA/$)!u2=f3RX6(ecmaAdrktNKjH/A]Qg&i;V:\F[<Lm/]@FUK<dK4KK\7,$6\MK$J;gRBYV/t>ki)4Y'Geum5q-:F*^_"='2Nm7)f9;=V6A@3VGa/51>,!+'EmjF$.^J6`ar=;G9o2E3:;"04C/(.^2\5p0ca#1^)Y\jct8A3ncVL[A=oTL?^Op_ltILcFrqeqZkiGPkb~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1896
|
||||
>>
|
||||
stream
|
||||
Gb"/'gMYe)&:N/3$l0,S8RmW:;,gj/a/L![DNiS>jIn1-=\J5+ek1gqFkRPO[s+d_]tQ\K5ll<K.uB]T*0Q(iL1W^@_s6,m"n5t*mOeU]@<N&Q&dbmmpgrT8EX5b%QQ?^3W<bJF5hJ:L(/>4.+tDLO"&43<I0ie-U33,[US%!2*sr+j:E9YX=4Gh7Lh<7,C;XBi`Ar)nM5O<ldD(Z:g[G9=g?:67k)3V\_:h3JW'V->4PteE$DtSQI_Q,kZ@sa&AX4Y3Xt!,PXPf1]+1CpP[`^EY.l2sIk0,)R^+sEq"D0#8HYWEriCbd0&#=[rJ)?L+']::\k=+#j^Bk]r)M/f7]u>9a^_C^>or09):`n<Lg.?Jk:6$)f8HgW"H_e4>gCf;=KMnd>D-XPNOt^85EJrH%6r)Wo2bZ#>U&(Vdm/dfECr%$Z5(KdZ_>q4F`*rG,%52E+7/L:7LCR0,ps99!3qpN/NBD6I:<b<dBpRZ4[J[!1XGCaC10QS*$.k+D@#->?Vs@oXUNI`]RHBFZ&\b:8"2["e,ps`AOLs]F&#q;gfhc*AS=:5P'R&%rR2%Y863X`/`2@TN3(4UPe[r3F\($.d5rIP/!RoX\G#e:pmY]S'qTU<`h6jkPEcP&iBku*^9V7pblI)n!]^mH22,P71+li"YY74oUU7rV=hFu&6OhT^$6f41h;H)m@2"gF5#aNm]\*2*aEr%]64?M#lL?dN@i^qm#$dN_!C83tE^'13b#Kp+Sld9K/JC1&D93498JI\rnrkiE(gIpc\+K3s]04Sc_!m\<-5TiOhlNNp[Hat8A[Q"N/P=Sek$S]Cc4tssD.E]bR\A;5M6qXS,l\jV@Zc[1ZoBZkl_0S?<N3^t`9]tV%8[Gsq4M)Y$H:/'HNtK6<@:T:XipOP8hfmsocX+/gf9WgK2Cs'4;XMKgh#*-_T)<^o^(A4!S.?Wf^##>*Q#-/.m;K5sY1EC6'jJ8q82=$?hYH4p3\m'ATF-_mK8D4\+A1YKk!eoG+^nA<=:,?u@,CL.I2F>7TnZ&JU<sH[eu=/9.8Y$b;//A:qZqW?P-g%fEkcaj(+I+YLTbE-d/;<Qc3Ta*i@1SJ,EpD3`e'?bp`0#>-@X3nf+nk'NhD;J$Qkg":[8>Q2Fs`<N;)F)0BU]kD@0l@`(h1IYZ\)e<sh#_?^N*4(I`^lg2!CpW2=`07j@aM2\l2)Ln(\)'nf(gQE2S=GuuZWE4R_n`B$3=OQ$-/r1`U#4sBEr6-9-"$l8P??s$EVemOaGr\.mu(=e,g0g3^C`6@6Fp9q;rs7@(H2AH)V<D"ll4_!Fl'?nbM7&SAcGi6A:<Lb\aDQL-J\f,%(a?=ZSaR'rAD<K1"S]Y-ei3=#La7\4@M@=.b/5Au:biTUU%UC343V=GnXa@1Y]_eY9]VaDOiJne5/Vo^$F]67*n`\H,_B+,O\mMa'2FtB&Y,c_5mR).+p1Ed_aXq6XJS@)?gACEJQ/L]&$;W^QU#:*m@T6*ZVN;Fc&un\:SI`,1pK]Y9P4I[d8PT^7@7='\R@=>%,0Sq<=IEV>/q0DI`!YJ.ISoOWMO"mR:3hQ!'c,3f73+r:VLXe.=dE*ep>n?7_Q^V*F:%6_V:pQDh5bXsmjqc!$1]giS8XEBX!ZeODW%??:TH%]M4e,N4X9Agd-[XqF=W>*Oa`=S>6kt-4pa?SJ%ST4A/^etSN?ruH)-57Aq((S?I_N_+g>a]%tWQSE!Yf6kQ")-^@#.L(iFVr]]Kdh](!u#O,o1&prD49o-B?X2-C7klTELNb&,5[7ePm']]%5X-qfW]`Gk%UqEXXkiZ(^`s0I@?YWn8Wh>Y'n,kP`/F"0RoWP0)lO5NYN/ZYX#/8T`7=$(DjYA]W;Zlnqu^RY%i]_q5g]RG]1PFc!,E;fs\,$N=~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1280
|
||||
>>
|
||||
stream
|
||||
GauHJ9mbVQ%)2%/i7,e])b-VOMHWfX[s$HRXi]kiP`$a5[UNB=.GAY5lNc^uMA+`?G"A-*$q7hE$\/4*q"UQ-GIe"R0S>:E'I\Ue*%30SAtS%kRJ4rN9X'>.5Q^0T0`r$nmK^tQ.j5Tp`%7c.rJ8ON".,6C),"?c(I^.^UL=)TUHg$^8!3t:1_`hMOq3@V$rq?]$5)aMGP4U@?;*b"*Lb[\+rHoE*N9&si5DO%'Z+YqPJ=ie>f,rUU$?a!FTMMEUXGum>K.k3IsM%-Y4LAI%";2\<Tn2"8p^cKH^MKi\jlIbEPc0+4iIB%C7kXtTrZB_"8#U!aNF_]#jWE-qT)b^Hm1qM@m'7ghn3iP23IEuaKQg?]tB#s'0-])OmtVSBC<`/emgVCT%qaBH=5abaFKG8;FsBiT`%"R:*5'p)1A\d$bBN?Ihb"V;C1QW=X\;tlgcX[3n<=S?apj#H=KiqHZmaSM,&CXau`nTUASG`(E(_l1fa2ae*V=A@<u5?AYso!CUd<N9bMf;AftH@k"aO7B`:$*K$1I&0Y5tge[i-U]7;j0?($s@_N+FFa)7g@=mS>O,0<djGmVrZWKEmG_mDrmRDL^VYH4me&UZlnXfBEd/]s++a8mu_/e`TG2+b)eK@SR?0NqI^*0[1GV#G\d%)McY7O8?B5B><pCE-Z2"^VC<Duja8KqquaH\KKaiqlu@p6b2AEC(nd`]IUY-M<*V>bGT*K,V1-4;Wc,@&*Uf1P5`,7Z%EDlb]FlQL8)U"->`\8[2ZY46uj9Oe(u,A&LM&Q<r#Zh5;q!X9fc!?P(m*qb^=l:RIq/!/e:;$t[n6j=!?J[VjrONpoJi*u">bT/00>)#Nb(r#;JiXk%*\Ypj/I=%;'^$J22%)NT+<E+5%s3P;0*r,BsV0KO=DEG&bNe.E>*OTN)]4Zf?nS]'X\J)E)2'Xr;\_p1Dc2C=4:>ggaGTBci&g\U/["#GellkBoL^Jop,Br,'ncL28PnR8P%0-!TQPbkfBa-HjDSS\^V6@C6UlJ"e_GP5[Gg=7k'5$NthNTK%T3VC:iZHcJK1c!qCW.F;mCYM$fA[]`HRB$G]f3hE&IA;]eAbcB0J3DGmnRB)ThH\s0]nPOMf#>X<c6d^[a9p-WLGO#EcDbqTc]u;X,V^Z?kX;=AQ*+Ma-_k:PPc50I&2$'tmP`N5$Pa<,fNaajPdNh;.Np0.$GCsk-8FX'oNTHT4gs!KaZo:`*m9/"/ASm<E!QuCEEqL?KYK`s,_YXbj]_+.)ZM>GCKKi<q[`F`dVS~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 17
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000122 00000 n
|
||||
0000000229 00000 n
|
||||
0000000341 00000 n
|
||||
0000000446 00000 n
|
||||
0000000651 00000 n
|
||||
0000000856 00000 n
|
||||
0000000933 00000 n
|
||||
0000001138 00000 n
|
||||
0000001343 00000 n
|
||||
0000001413 00000 n
|
||||
0000001709 00000 n
|
||||
0000001787 00000 n
|
||||
0000003601 00000 n
|
||||
0000005408 00000 n
|
||||
0000007396 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<335984f2468d85fa1a2d1b1369df2cab><335984f2468d85fa1a2d1b1369df2cab>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 11 0 R
|
||||
/Root 10 0 R
|
||||
/Size 17
|
||||
>>
|
||||
startxref
|
||||
8768
|
||||
%%EOF
|
||||
523
Les16-MCP/Les16-Lesstof.md
Normal file
523
Les16-MCP/Les16-Lesstof.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Les 16 — Lesstof
|
||||
## MCP — Model Context Protocol
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Vorige les:** Les 15 — Cursor + Vercel deploy
|
||||
**Volgende les:** Les 17 — Externe APIs in diepte
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Wat is MCP en waarom](#1-wat-is-mcp-en-waarom)
|
||||
2. [Architectuur — client / server / transport](#2-architectuur--client--server--transport)
|
||||
3. [De drie primitieven](#3-de-drie-primitieven)
|
||||
4. [Bestaande MCP servers](#4-bestaande-mcp-servers)
|
||||
5. [Eigen server bouwen — TypeScript SDK](#5-eigen-server-bouwen--typescript-sdk)
|
||||
6. [Laden in Cursor + Claude Desktop](#6-laden-in-cursor--claude-desktop)
|
||||
7. [Resources + Prompts](#7-resources--prompts)
|
||||
8. [MCP Inspector](#8-mcp-inspector)
|
||||
9. [MCP vs Tool Calling](#9-mcp-vs-tool-calling)
|
||||
10. [Productie + distributie](#10-productie--distributie)
|
||||
|
||||
---
|
||||
|
||||
## 1. Wat is MCP en waarom
|
||||
|
||||
**Model Context Protocol** is een open standaard van Anthropic (gelanceerd november 2024) voor de communicatie tussen AI-assistenten en externe data/tools.
|
||||
|
||||
### Het probleem dat MCP oplost
|
||||
|
||||
Stel je hebt een handige tool — bijvoorbeeld 'zoek bands in onze database'. Je wilt 'm gebruiken in:
|
||||
|
||||
- Je eigen Next.js chat-app
|
||||
- Cursor (tijdens coden vragen stellen over je data)
|
||||
- Claude Desktop (research-werkflow)
|
||||
- ChatGPT plugins (in de toekomst)
|
||||
|
||||
**Zonder MCP:** vier integraties, vier configuraties, vier onderhouds-aspecten.
|
||||
|
||||
**Met MCP:** één server, alle clients begrijpen het protocol.
|
||||
|
||||
> MCP is voor AI-tools wat USB-C is voor apparaten — één standaard connector.
|
||||
|
||||
### Waarom dit nu hot is
|
||||
|
||||
- Anthropic Claude Desktop ondersteunt het out-of-the-box
|
||||
- Cursor ondersteunt MCP sinds early 2025
|
||||
- OpenAI heeft mei 2025 MCP-support in ChatGPT aangekondigd
|
||||
- Honderden bedrijven publiceren MCP servers (GitHub, Stripe, Linear, Notion, ...)
|
||||
|
||||
Voor jouw eindopdracht: optionele bonus, maar erg handig om interne tools te bouwen die je in elke AI-client kunt gebruiken.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectuur — client / server / transport
|
||||
|
||||
```
|
||||
┌──────────────┐ JSON-RPC ┌──────────────┐
|
||||
│ MCP Client │ ◄────────────────────► │ MCP Server │
|
||||
│ │ stdio / HTTP/SSE │ (jouw code) │
|
||||
│ Cursor, │ │ │
|
||||
│ Claude │ │ exposes: │
|
||||
│ Desktop, │ │ - tools │
|
||||
│ custom apps │ │ - resources │
|
||||
│ │ │ - prompts │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Drie rollen
|
||||
|
||||
- **MCP Client** — de AI-assistent die jouw server gebruikt. Cursor, Claude Desktop, of een custom app die jij bouwt.
|
||||
- **MCP Server** — JOUW code. Een proces dat tools, data, of prompts beschikbaar maakt.
|
||||
- **Transport** — hoe ze communiceren. Twee opties:
|
||||
- **stdio** (default) — server runt lokaal als child-process, communicatie via stdin/stdout
|
||||
- **HTTP/SSE** — server runt remote, communicatie over HTTP met Server-Sent Events
|
||||
|
||||
### Protocol
|
||||
|
||||
JSON-RPC 2.0 onder de motorkap. Je hoeft dat niet zelf te schrijven — de SDK regelt het. Wat je wel zou kunnen zien als je inspecteert:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "searchBands",
|
||||
"arguments": { "day": "Vrijdag" }
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. De drie primitieven
|
||||
|
||||
Een MCP server kan drie soorten dingen exposeren.
|
||||
|
||||
### 3.1 Tools
|
||||
|
||||
Uitvoerbare functies. Vergelijkbaar met tool calling uit Les 12.
|
||||
|
||||
```typescript
|
||||
server.tool(
|
||||
"searchBands", // naam
|
||||
"Zoek bands op dag, stage of genre", // beschrijving
|
||||
{ // input schema (Zod)
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
|
||||
stage: z.string().optional(),
|
||||
},
|
||||
async ({ day, stage }) => { // execute
|
||||
const bands = await query(day, stage);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(bands) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
AI kan deze aanroepen tijdens een conversatie.
|
||||
|
||||
### 3.2 Resources
|
||||
|
||||
Read-only data die AI mag inladen als context.
|
||||
|
||||
```typescript
|
||||
server.resource(
|
||||
"bands-list", // naam
|
||||
"bands://list", // URI scheme
|
||||
async () => ({
|
||||
contents: [{
|
||||
uri: "bands://list",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(allBands),
|
||||
}],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
In Cursor type je `@polderfest:bands-list` om resource toe te voegen aan context.
|
||||
|
||||
### 3.3 Prompts
|
||||
|
||||
Herbruikbare prompt-templates.
|
||||
|
||||
```typescript
|
||||
server.prompt(
|
||||
"daily-recap",
|
||||
"Genereer een samenvatting van een festivaldag",
|
||||
{ day: z.string() },
|
||||
({ day }) => ({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Geef een gedetailleerde recap van ${day}:
|
||||
- Top 3 acts
|
||||
- Drukke / rustige momenten
|
||||
- Bijzonderheden`,
|
||||
},
|
||||
}],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
In Cursor commando palette of Claude Desktop slash-menu krijg je deze prompts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bestaande MCP servers
|
||||
|
||||
### Officieel (Anthropic)
|
||||
|
||||
Geïnstalleerd via npm:
|
||||
- `@modelcontextprotocol/server-filesystem` — file system access
|
||||
- `@modelcontextprotocol/server-github` — repos, issues, PRs
|
||||
- `@modelcontextprotocol/server-slack` — Slack workspace
|
||||
- `@modelcontextprotocol/server-postgres` — Postgres queries
|
||||
- `@modelcontextprotocol/server-puppeteer` — browser automation
|
||||
- `@modelcontextprotocol/server-memory` — persistent memory
|
||||
|
||||
### Community
|
||||
|
||||
- `linear-mcp`, `notion-mcp`, `asana-mcp`, `figma-mcp`, `stripe-mcp`
|
||||
- `youtube-mcp`, `arxiv-mcp`, `wikipedia-mcp`
|
||||
- Lijst: https://github.com/punkpeye/awesome-mcp-servers (~500+ servers)
|
||||
|
||||
### Gebruik
|
||||
|
||||
Voorbeeld Cursor config (`~/.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." }
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/tim/Documents"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Cursor → servers actief.
|
||||
|
||||
---
|
||||
|
||||
## 5. Eigen server bouwen — TypeScript SDK
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
mkdir mcp-polderfest && cd mcp-polderfest
|
||||
pnpm init
|
||||
pnpm add @modelcontextprotocol/sdk zod
|
||||
pnpm add -D typescript @types/node tsx
|
||||
```
|
||||
|
||||
`tsconfig.json`:
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal server
|
||||
|
||||
`src/index.ts`:
|
||||
```typescript
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "polderfest-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"searchBands",
|
||||
"Zoek bands op dag, stage, genre",
|
||||
{
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
|
||||
stage: z.string().optional(),
|
||||
},
|
||||
async ({ day, stage }) => {
|
||||
// Echte DB query of mock
|
||||
const bands = MOCK_BANDS.filter(b =>
|
||||
(!day || b.day === day) && (!stage || b.stage === stage)
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(bands, null, 2) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
### Build + run
|
||||
|
||||
```bash
|
||||
pnpm tsc # build → dist/index.js
|
||||
node dist/index.js # runt server (wacht op stdio input)
|
||||
```
|
||||
|
||||
Eerste keer: niets gebeurt zichtbaar — server wacht op JSON-RPC over stdio. Test via Inspector (zie sectie 8).
|
||||
|
||||
### Best practices
|
||||
|
||||
- **Logging via `console.error`** — `stdout` is gereserveerd voor protocol
|
||||
- **Errors als data** — return `{ content: [...], isError: true }`
|
||||
- **Schema's zo strict mogelijk** — Zod schema = AI weet exact wat het mag invullen
|
||||
- **Description = belangrijk** — AI kiest tools op basis van description
|
||||
|
||||
---
|
||||
|
||||
## 6. Laden in Cursor + Claude Desktop
|
||||
|
||||
### Cursor
|
||||
|
||||
`~/.cursor/mcp.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/Users/jouw-naam/dev/mcp-polderfest/dist/index.js"],
|
||||
"env": {
|
||||
"SUPABASE_URL": "https://...supabase.co",
|
||||
"SUPABASE_KEY": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Belangrijk: gebruik **absolute paths** (geen `~` of relative).
|
||||
|
||||
Cursor → Settings → MCP → check server-status (groen = werkt).
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/Users/jouw-naam/dev/mcp-polderfest/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Op Windows: `%APPDATA%\Claude\claude_desktop_config.json`.
|
||||
|
||||
Restart Claude Desktop → MCP icon zichtbaar in chat input.
|
||||
|
||||
### Beide tegelijk
|
||||
|
||||
Zelfde server, beide configs. Eén keer onderhouden, beide AI-clients gebruiken hem.
|
||||
|
||||
---
|
||||
|
||||
## 7. Resources + Prompts
|
||||
|
||||
### Resources gebruik-cases
|
||||
|
||||
- **Documentatie** — handleiding, RFCs, design docs
|
||||
- **Database snapshots** — read-only views
|
||||
- **API responses** — gecachede externe data
|
||||
- **Logs** — recent errors voor debugging
|
||||
|
||||
```typescript
|
||||
server.resource(
|
||||
"recent-logs",
|
||||
"logs://recent",
|
||||
async () => ({
|
||||
contents: [{
|
||||
uri: "logs://recent",
|
||||
mimeType: "text/plain",
|
||||
text: await fs.readFile("/var/log/app.log", "utf-8"),
|
||||
}],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
In Cursor: `@polderfest:recent-logs` voegt logs toe aan chat context. AI kan vragen erover beantwoorden.
|
||||
|
||||
### Prompts gebruik-cases
|
||||
|
||||
- **Code review template**
|
||||
- **Bug report opmaak**
|
||||
- **Vergader-recap structuur**
|
||||
|
||||
```typescript
|
||||
server.prompt(
|
||||
"code-review",
|
||||
"Doe een grondige code review",
|
||||
{ file: z.string() },
|
||||
({ file }) => ({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: { type: "text", text: `Review ${file} op:
|
||||
1. Bugs / edge cases
|
||||
2. Naming / readability
|
||||
3. Performance
|
||||
4. Security
|
||||
Geef per punt concrete suggesties.` },
|
||||
}],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Cursor command palette → "MCP: code-review" → file selecteren → AI start met die exacte prompt.
|
||||
|
||||
---
|
||||
|
||||
## 8. MCP Inspector
|
||||
|
||||
Debug-tool om je server te testen zonder Cursor/Claude.
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector dist/index.js
|
||||
```
|
||||
|
||||
Opent browser-UI op `http://localhost:5173`:
|
||||
|
||||
- Linker panel: server tools/resources/prompts lijst
|
||||
- Hoofdvenster: parameters invullen + call uitvoeren
|
||||
- Result + raw JSON-RPC zichtbaar
|
||||
|
||||
**Workflow:**
|
||||
1. Schrijf nieuwe tool
|
||||
2. Build (`pnpm tsc`)
|
||||
3. Test in Inspector
|
||||
4. Werkt? Restart Cursor en gebruik daar
|
||||
5. Werkt niet? Inspector toont errors direct
|
||||
|
||||
---
|
||||
|
||||
## 9. MCP vs Tool Calling
|
||||
|
||||
| Aspect | Tool Calling (Les 12) | MCP (Les 16) |
|
||||
|--------|----------------------|--------------|
|
||||
| Waar leeft tool | In jouw Next.js app | Aparte server, los proces |
|
||||
| Clients | Alleen jouw chat | Alle MCP-clients |
|
||||
| Setup | Function definitie in code | Server proces + config |
|
||||
| Distributie | Niet — alleen in app | npm publish |
|
||||
| State | Per-request, geen state | Server proces heeft state |
|
||||
| Best voor | App-specifieke features | Herbruikbare tools |
|
||||
|
||||
### Mentale model
|
||||
|
||||
- **Tool calling** = function in mijn app
|
||||
- **MCP server** = library die elke AI-client kan importeren
|
||||
|
||||
### Wanneer welke
|
||||
|
||||
- App-feature (alleen voor jouw users) → tool calling
|
||||
- Interne dev-tools (jij + collega's) → MCP server
|
||||
- Open-source tool voor de community → MCP server (publish naar npm)
|
||||
- Beide patronen zijn complementair — productie-teams gebruiken vaak beide
|
||||
|
||||
---
|
||||
|
||||
## 10. Productie + distributie
|
||||
|
||||
### npm publish
|
||||
|
||||
Maak je server beschikbaar voor anderen:
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"name": "@jouw-org/mcp-polderfest",
|
||||
"version": "1.0.0",
|
||||
"bin": {
|
||||
"mcp-polderfest": "./dist/index.js"
|
||||
},
|
||||
"files": ["dist"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm publish --access public
|
||||
```
|
||||
|
||||
Anderen installeren:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@jouw-org/mcp-polderfest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remote servers (HTTP/SSE)
|
||||
|
||||
Voor cloud deploys — anders dan stdio:
|
||||
|
||||
```typescript
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express from "express";
|
||||
|
||||
const app = express();
|
||||
app.get("/sse", (req, res) => {
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
server.connect(transport);
|
||||
});
|
||||
app.listen(3001);
|
||||
```
|
||||
|
||||
Cursor config:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest-remote": {
|
||||
"url": "https://mcp.example.com/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- **Authenticate** — als server gevoelige data exposeert, auth-token in env-var
|
||||
- **Validate input** — Zod schema's strict
|
||||
- **Audit log** — log elke tool-call
|
||||
- **Rate limit** — server is gewoon een proces, gebruik standaard rate-limit libs
|
||||
- **Geen secrets in code** — env vars only
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **MCP officiële site:** https://modelcontextprotocol.io/
|
||||
- **Spec:** https://spec.modelcontextprotocol.io/
|
||||
- **TS SDK:** https://github.com/modelcontextprotocol/typescript-sdk
|
||||
- **MCP Inspector:** https://github.com/modelcontextprotocol/inspector
|
||||
- **Cursor MCP docs:** https://docs.cursor.com/context/model-context-protocol
|
||||
- **Claude Desktop MCP:** https://docs.anthropic.com/en/docs/build-with-claude/mcp
|
||||
- **Anthropic launch post:** https://www.anthropic.com/news/model-context-protocol
|
||||
- **Awesome MCP:** https://github.com/punkpeye/awesome-mcp-servers
|
||||
257
Les16-MCP/Les16-Lesstof.pdf
Normal file
257
Les16-MCP/Les16-Lesstof.pdf
Normal file
@@ -0,0 +1,257 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R /F4 12 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521111146+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521111146+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 16 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Count 10 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 13 0 R 14 0 R 15 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1746
|
||||
>>
|
||||
stream
|
||||
Gatm;bBB@G&Dcpm[E1Fh1MQRcRO1,:G%Q-]bW)lIfm&.B/MT^;"1$t%:Gj7[08qcoHmCT7HJIZ6Xb$Sm(G"Bq)8PDIS:"b\3/m9Vfu/Wm'jQ^!7uM?.?/#B('GVYoiX,2]fu-LuO[ONNA"]bnP\YoR(fAFS^sUGMR1#F;qdYNbE@#am%7Dp@._/t@Lu]OCgVf6&>IUg-;^OYga>uCVZhd1)\:`>))R%")PW>lKUDY'MrQjjWr0:$!*_b$>L4>+T#L`bB3-X?Ro&FGU7sUdq37gqe:?OF('^oK&+,l4SXH%ebk/>+.<rHCi*1-m,aG]])EdR1LU;R;fnaddIFQl?e3`.=CEBQhM^$%Uu9;:7dZ<a@hAI:.E4Mn'S^N<4X.`'lIKYV=<N!VfkLDek?RO#a]*]2^jo;Z(jYi\PcFaf@Q3`pHW[bb[:/?gP"&`\5O^]#ogOb;67_V7bO;Ht>fN`6T:Bb[/YiNRBS/qp@;fXXYRU5iunF0FHN"Db.Wm*29@hHZbV2qeK5hq3oELH(5AecN/aGF4/nZ7Ia%W[cug$c\:*=HHXtY*KQMZ,2nZ\,[=DUt#(K$ndtFmHf.ar1uN[;BA60994=fRGd<!>8lcDcI/N8^C%KrdNT.)oZ@G#RcjlpSNZ3G`p)J#=&3P!R9C$Y#cfV.[;7OqCLd[nVRrN6!!b#s\`(r1jj2*`_N\a@LGrDG[&<,]1-q_ds/NpYh$cJgbdEX40TU,MLJKElGI-k%?05OLqIZeB?^I_AE"muZ$cLZLcX00se2c9*%]=8i,[dmui;P$`oY]M^*Yb.4G:SbWBfprgr]6@=+kEl^p#8qe5$MPXEq]ZDg]0>Vl?So6oYK5l'gg>?W$0^oma8fegL.Q,XG^G>c4USaf9\Kc$&"G4(O@=qpU(-m$?`RAEeUO%<h\mbe@"Cmbou&dT6Tn:e3=*mYbbDm+%e3ACbL(-]Z/p*okpk9"%2%.@uWMe'S+Ue2j9#2-E-C=BdT\S91Pr2B#97<Qfth_0GNc]]/4PH[r.S<>Qum?!:A$D>e;lNBk'Y+LOg$Zgd16heN5AS%:BN-O!1db+U'bX&W_6qin:%'3nP1'=3V7us'T+P+3gg#)hUQkfthDiXcS?9AnY/+V^-*3-$G>(L1*+N;]Y2"A<I77=#LLb_=4MMkNIr:AQ&Xp$`6dPW/Z0ekTn!QEHbA>N0C92S.qB`jRY2YC1/j2.M(]FUg]i`Z`&[V6PE"qQnk0;FVO-%',3DDk'u=:jQQa3(r+A&F>g=,;=!D>CgJH\eHRq\"cQdMqBj:XGl]ke?e5^)WpR'PiClPf\q,dGfO&#pTBlEK8EXc#-IO2L6JT(tU;tq2H<<)L:lcEV21sWr<>:dc:H=BXaF!'iEKj/T=VpbY[g&II?MM6sQ53I1%u^"/UBH(*A"DTQ%rM+#3e/Iu/l&o<F[4mC^YI35d':GNfna27AAB\r]1f*d,2B/XNDu9"[>d2V&5X+?G[$co`LN']3SLihZKQsfPDK82$]+BE?K++pp3ib,c=eV%l6(oX!(<ASDju5;L#7YR*X%<:Jjf$^jBP'^VF,c^m"5;DLB+XS"12&iqB5<\5]VTEiql$QfR,aE"*>AGO@'$8.0s!j*Tt>KL9hoV@C3ik!,bSHpp"YeM!DS#YIDD0N'O<BE;%e:Q"JnUj=nI/%03]3/o6q=HJfc4'Gqk=<VOC`g^4hu.Q6iK`$b(+)D?'[KBY(OL("E9.X.2I!H6"$#M*hX3W2e~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1186
|
||||
>>
|
||||
stream
|
||||
GasangMYb*&:N/3bY(ejfJ"R>;:N=Vm^<SOFAo9Q"VuBGBuX0uM697Y7D?K'l=/l+D<-N)3BEZjS7+\Q@H">(JCdFRHcpqZ=Pa#k7)8?qr3_Kt7>+=*M%iLTBTdEZH/ejsh*_^JP/s0V$l$sBaoi!hLf1bbNcnRF12%-PS4!cV->B=[&B.PHj<OPT8C$bSJtu<,&+'UNb1"Oj*7uFZreFmkK+3U):_Q,C9=#=u28gU:rq?KNgPtIif%LHp]@Q2JK58ag357F9n%,fGAdr\&_,PSZS&W-nj]>QUpi"FHL#=4FHM?qoQk#JdCcZ3IHJ#9-;`4gK)9sW%km/R15W#6n,RM^L(;Fs9Pb;Mc,,C;s7m&)GGFGQVSe4SKRa#h0lk4t_P0[dhEK?-W3UAH=R"q--Enm"Z0OZ?1h_Pi]-SuGUqc1RRPoU\*"@T/kk_L9_<&g79%`ZsPAgc3X2@>oD:ETG>[pD-^]TT3Z*j^q_9"8=m!J\+@8lZ-7S&L)chTZSc)86OS3T6%h]cpR=]"Nn!V/T7SE4NI[e%k^PBPm]t;NR<:V!r7#$2b=4/DSK8LjDBYVuO.DLbJ/L<3WTd1Huja[FSJtX74AQ^OnuA8Ju&LGPLH.dB&Ja)@f1<kDe6)G#rFgNZ3=!ouaYW.2UU>i*^C0CBN;AotDktiCYmI-[,Bi6SuGH7=UG]6K=,d6ql>P=;S,"F*R:;EmFtdHU4Tu(OdBoUP"6ocV+[3#VgX6<>e_^#-]rpO'UGar<<R%KYJ6/p/EH+rhsdK$[f_%ZfW$(kihF\HnR#FC1(OPmCp5%&!`+rRrX/[@^l4:(AkPH/[eZa7t89.h6""CO(m_$<&YgK[Y9'P[@A3Ss(@fY"q$Xh-QGbFQ0A3AlQamk\AUaiV<]Do2'3oeVuY9f9`'3:VL4i0o;K<()9d!V$M5sJC$e/ejj%L][&QEbO@(Ii:uMD\G+/3B]L4%93(uU[#9rb/djW9%+c[Zo5GDXpUFeCSJ;@JlRS]'@Yt8r'%NG7'%6QLaiQDNad0nF)+.\,H1fkAa9[9nXjk08e3>=+bG`3V<ER<;e)Vd75NDUXR:F1-U9#An<+g11J4_MI<SuW51J,\X(l(Yqbk82Ta(CrCfUH;*N4tg=k(sGamh8Cl+TSFJQ:m(u3$,mcsg+sY9ZA>q`K+>7a#jP?5+SlCu:BnW~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1196
|
||||
>>
|
||||
stream
|
||||
GauHJ9lo&I&A@[:lqu&1&AA0g3h:8]A<KV)4#,2jFO<phV3DZ/I\`P6qCDpcUa4^[j:IJ!Jd^N(_rC&M$n0u6qb;-^h\0,)B#$AOpV8&:OM$?M8b=ka>]V,)&7MPo3KR@M(R,HX%[8;$jtQs2%C&BS[#A*+>MqKe(hTF_E;q+k5`DS[8g'EKD=u"u5r@cg=q[ZSHp#1,B3T[:d60RekoU&G&Zf>=5S]GYVMp*PLY7HP5Ms>Iik\XZVC44p>aI)!e?J#h^t%38<E3n:g9'>1qrY1nT=\BW!Qc-K?F7Enpel.,OPV/[HWL6sJ?Pt*mQKq(l=%;/JXGAC`0_$"ReK2Z[6t:8E%ddlGU+ss<_+f"j:PJ32hN]?\07)/40`e[O-5/6c)2=7N(KdP_ZmH+[8=[Rj1&2W^_Y.T#m:H"M?J]X(,>/e@j_^N1jDS:3jQir,EV7>LLj.[Sjo3Jdo:\kX^A_Y[F9t6'AL]?Z=St-DAa8+&6DtF].N',I3Hgiq=m+hf,]<6paLZm#Ss1Q&+HdFFL/)T=uT1?"d*D$PSGaBEof5]@\YPW]4t];of8"Fe;."iTrgd$7;nPmiU+&$B`tc(E6fklUbP%VK.W"WV<u4K'XCaQ.]Jh>lnBcGJh:R_J"q)j2:r$d_AZBV%j%d#:gahc#W7(<1,7?T]'0Ji7piArm'm6\Ep`%NWu7Cf.RcVXD'XO'-o$Q=iu3#5UpQG3qtS5)/R81D"8I"&LNdr$+6K25Y@0Wp%8C^>?+6Mg(Q8Ao)5<o<X94/4fCMg:&M8s.flD8lPZ:Re%TBES]e*&b_mQ"_.@P`//_4fbnN<sf6dN:J`^R)"[DRl(rEU6:96cXG[u7l1<s.q3C+Q43o8jq<dM?m7R5S.gF'.i(eah_)FjI>R=%-&bmL8OQr]e4ee9tK\qmnhTAd\2mUtO?U<FM8RAZKAs.70T2AhV?0K<iQR[l\OjFW:lA`M\`1f0)[5/RTSO&*NL;hh&'qf;B.>-b+KHm]<$4R5%L#`"K3i(@9uF'[XaVYZN4M;a*kgW,j[GBGQWcPs9_Aoe/"tq,i%Ykrd'%s*;t`(QQ>`%tnFj02BCSmcO+J?++(JWl7AVor@_b[_UH$gqW`\Epi/<3/YUL%)WTE+ZVOX:4f]!*;ntR%!g^r[](e5%L\[XD/4@1C9i.`O@kiUojN"7A3Y5r7,J#<L24C~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1644
|
||||
>>
|
||||
stream
|
||||
Gb!#[D/\/e&H8h>E<u9S.2&)F.#Q0o!=cVX*K%;k]RFN7TSgTG9(h17EjdO?J)?B7[GtE_$?V2Y830\qh=9oYG[Wl-_sH8/"ZW'hoE>?:@<MrN&-9+=nDha'E<qod(EO,h;)dDu!n[?8Z#DJf,`r*<(b"Y_jU2WW&Z#O4*8g@9k!Pj$nqk87plbJG!)^t(8j7RY-'E=k/VkccYk`_]T/,@/6oNG$R.W+ai<n-]eY0R+;!.QpbYjh@Ie3Obr1#E^)-u?L!l%fjNijk!F7-rPQcIFp1+k)*!RLG@:@_trZg&*6f._P\Hk#S!i5EP&f(!,3&srg7PZ<08_nHQrE>F-Ci;AIZg&ak&"R8oS>aP:hMk(gT\@=99'iIRn*U]'d[^.7ipBdZ_%;]9,WrukFj?njWY)/[oL,Vd\AtV1`JEm7I3c(/5CUsLL6\GKKpPHfW>Ln9q4!<7&M&b5>Ol/SCAFji.`ZXlo\T2L^R87,bkmPs%[Ws/r9m[:@`[Kf'E>KJO]#ms[AF?*CK[nhiGobg9M]WN0(uKE<$a=R$n=OL+-t3Y/,#7/J`kKU83^G7LFi)Bo:oknH0^]-l@!?!6__s167U3p_D6Afgd*QKl72S_/(<Y394CfbU3n^rP'GEkUGLsDhD-J4S$*Q91FbK58)UR.M7poNq@W1Ob64tcH5I@A4:#pCSL+rjp8dj2&4DYeW[0:2@er.*F3X\:I&#M.2V$:;U*fTg,9QR3t#MR5P]\dANhem:+(BReW\udmn&objqirsn6=C>@2eA\OB8;/@[:U\GCpE#JHn>5N'$,Aj+_]lZQ%C:u`i!B>XB0(_t%+>f-L8=QXd"6bgok-6CfT7cF,$Z*R=bR4m.!Z2k'tSCAi9*RDMtm-,c5%X"-uQckg'mCn"T:<83*WO85Z]uUGIn/d/5i-E2h9lES\ST#B<-,<q0;BMK>id'K7a\CV$h?o2ErJ_ZAS6)`'VZYO*8.NhK1o3$uK;&bqVu9Wka$:&GtD-J-_h_Dt"f;Q?teMhH@0I/FTkMk*(.UBj7Nkq&qm!!Ub.V$'.X/='ZkESs<;Spnn]&&]PR@XE8G@TCge-_s_fE\h<MYc?n21=0!O_MBdS(#OVnG]SGciM-G."ks$]"Kt3_h0>G2Gh<\QtM,%n5R*!5`4H)8]W#''5B_QJA8Xc\_]UmGMM%m\U8`\LgofHIAX!5uOAf!M@36nkk%]`Zbj(dsjokj7!HaUk7_aZC\CZMu>_X5mB2W=@Eh--m`[h$/U[hd\MdGEJ6<pMW?2elVT"e8?uCk!KR3)LSSYtlarC%L-NZ=Y<OF"\,=W)=P:lm2l:X!0a\N+P9p8E3[qC4:o\AJ.M,dB`"ZR:g#+O3#),fjU&6f&RmggZ8%\aSI'd;$=Xagb')</Yg%Ggi&*5>uhEBYAb1+<q0faO&\5H-@"Bp_*H!AS=g&[rWa-MSf)Y%#;Tof/Jnn,3%WfQ4iLW`V0Mma;nTNb*fTg,^+mUYk(SQL93Xk2pdH#IGK9J3A@OH)IqNZW2U"GP$nc[*`OB8bVhr)6LAQ-Bl0!cJ;h%.VRe+6VkXXarO\2GE0.`^W<a_TA'1;I.k)Xe2a7Pt`")L-M5Q.DN,o#1dI<U`l57g.FmAV?AUZC;HMtu>H*s9=S)i>^#1]~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1415
|
||||
>>
|
||||
stream
|
||||
GauHKgJZcs&:N/3FSX&#Rdo/i)Wb[aKaeb489".*Cf*NXD'cb:aX#A:p=m'!Qbh>O^k+")T'8Fek"LiI@6jA#WkWucE%lq/0a#F(2?tRcK\7doc)a!bE5`i3!tgt/QNM4rq#f<eMSW=]\_LbhWC+$;6#&lrFu=72Eua=-AmkVh@R(o%*A)hUAh1!Tp^<fUa3^"%9"C#])h,Aok7Z=L6^m[$JtW?`2V"K.8LUnc)eK\@cd':q_#%YNEdsK&+abmtj"CeMinT+`PJ-oW&+[Tm_JeSnKLm)M\9J+^Y:t<-`Z!c.abc#Lf'uO1%SQm5H'-<dfF)U0`#dP5S<'U_$`e=i<:5oi(GXb'aYL!`4:W?ALV:$Tc:0!r,U.$]*4a)tl<$$-e&cIf7YDfp-r^bfOI<&`p5SSLTJ/Qcl=&SNP>,hP;9(;j/'0@C>dG#<XFp+/Q.dZJ@9]57t"mTW>bb,Or1+8tiAbag5/("#a%%$$9D_DU?T(ubm>lfuRPi*6PgAUZ`On5Y22')iT[bR!??hQGfZR(Vq$pt!5&LDQ&Am+u:KY$=elG.DS$K,3dUYq,te<2_VjT?l\O*uG[.eTa;2Q<B@T526Aa`+a>dArXrp9Qu,*/I,g"[G360W`mC$h92Pcf7<=ARfE!4r!`TDZ[)Y3YF+?49+pj=Asc"mXJ^S+bk,!oY]t9_Dmk2dY>K=U69g;7o:nj_Ue(D/SCXUF2\/6=5N)D]WoEcb*S36^>_h#54Q"1Ps"$s*Ek\+^D60'<@hBEue]h\L16X'\"G/aC)&t:nD%f8"f+Xm<oo_Z&U$$p[XM\V["Q,F0>Fp/TfcgKqY9uXUlQo!8o.CfHaZ&O)dB(4@b%-(VQ4<a,Fnd%h=J_uGN6+!DE0DU*d1X)lO[NLf,k2P6+!7cT8cX$"_\1<pg*d52(Z[9,U/`0I&+:VL@cM#5Lm'Un"MENJ]a;Eb((h%%OTh`#b$\0E93[sq'SW-McnAFn^*H9nK*CNU%Tcf)A:SD>^YL.<<i5/UCmc-Zieos32qFBr[7o5;&mcC;aHDQ^pSZhPBLsbbkh&[R9roO<;J.ta>8uJbk*U)oPHLpYg,Y"<J>*mY!G><[7NI,!#,1lHA_6!V4]nRE)!tguNK`f)5^TYnJ%qKPEF\#S^obn7DX*]re<k7B`YeDu%X"h\AibWqVSo[`CE!!tq3nKupEkB)';k+G#KD73[rhlFF`hO/0Z"*^F`NN!_%'i%?e?!tYP(ER\p!K7<%a;2CYj?2YE)`kgGa3q`T`H2A35D1O>8X3,'_e8AGt\)mpS&(d=8+#UVCu*OZ$(RdDI@Z^+VDNUYejZmlqn!?Q=L*4?qRb^"tt94M&2<St1?j3\,EZ=Jj]'O:hEG*^^4m#gW;i47"u)IJNnHA&#pm*D>FbCH!98IfYQ5>2T~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2015
|
||||
>>
|
||||
stream
|
||||
Gau`TD/\/e&H8h>ED9<Gko-"d.#Q0/TkVtbh>4^/)pl,4'U_OWaHA'q8^fNrI7Ho(jQ=D^#.1gIM'ql)j5"c=;@Ft&RJfqD!)FpqIa3I`QlH)>7)7k04I^Sf4MiBT70(8-e;lSNa>Y1h&%rqa8S#aa#)\.'K`Zp:7&`]Xa)PP/67=]24S0JP:sr/H+Gp>#aXcD"+ur0DJcnt@hi;XB;oZJ+aOWC/ZhYa]p0d9`*3(XtAO.0KA.GBdDuP%`^D;U98recQK0S["-V$$f3=fQm`R\p,V,aX1Ybt9><\3AoY;]m1%$QH9f)T.`:'*7j(&t,5J4[OL<>PlLm+_:hc!uh;S9O2G%S60Ad#]a:q_Z5nh!/k)8&m#b80MF0nUNI*:6p/f,a8WnOE&hE&qkt%%jaNn-h3]8DjY"X5WM\prLs93[a1C+c@AZOTRkpIjtV8+qM3Q$;5Y`1BQ$=o%;035fM,j0o>E`]/>p_$$2sY0iJo4f,c[]]+CpEDXr-na+g/!MAO@4Ai^-c-k&7F*Hti*q_QVr<7^*/D]7U&64k)Jk\ieLhlkdi$SZcOT_uqd<[nL0D0emh+[:+`"!p1E[NR#'^_QZTn-d+WGW#=6G>]*/#"V$5"eb)Kr-OVU\N)=Jd3gh1Vis.X7+DNtu$_.?nqM$.\W79`[mD3J>bO@gjF.g4KmJ),grU%"?n0,*rU=[d9+q\H-8`c/_kIcZ&,%&P;c-mW;^)Ou;(H+Tt9K\f\j*<B.=]4=gr`h,!U+VTQ)cs?Zpeo=R#OJTDG[#MkT.b/c.apfqO6um*7ibE-T@]53PFs/JV_u\1XqD"M_'"lAhaXfr,P*n\=?IU(AMR$PRJonFC:^rbb-f7a@>([m."=@SO_[?5[DHV&"kjh#a(Wb0e&bbN,_j^Li>Cr-Zj3A>Y4'SJ:+6c3ZEYD*^GfJ"j`j#7a#ZEg6_E,4aHRkL7`:<^kDQ(9i<!=3Mt]s<0B&QTk-/hMgW'at`q*_,NiS/3f;K''#pLZre\//!C!e?!P]:#4nXVO=B(TAN948.<Wr%]QKPA%6,2a<Td$fGW07a.&XAOY*=*9j&4-8d7hRh7a"m#2YDo9ShaC`"GcrIdL#k1]V(-.6iTnI=!j`GR7AQW<7:p.-`@6kE<@p*:TWGu]1*Gnb:/sE%XEB^GMJrL:p5(W[0[72q]#a7Of;F$;giq%$^6E7Ujo>Bg+\k.cJNRN_?dt#V!G`e%Id8:#hY0QVAN2I6=3!+8PJW&8+0\-+ei.BQG!&R!Ge%lm6HLpe'J+n+6]At*ZC(l>M"+&9Aq"ba!M7M<mfeFDs3aP9DfX1*hQ+C:f3VpQi2QsUKT*r9:@lOpOdC\PH%DTT+o=FTmH>nm6)"W]=5/QD"V*RYrr)g#lo(Loa+OUdiP.,Tu_ZG,(P1Y#gG+%*hr48\qY1Gn>NU^/AG.Bt:9<Jq]E/I]$XtN3EF/A+#f#9#[1n2<W:bMA4239&Br=H$Q&$LQ$-P6dh2QVHr'#nJ'>R_BUgSqZTPF*9P)Qj9*$15QBYNpPe^ahLR\k;D91V:i!iE<D@jr@G&pYdY]LW*_2'6WW%[ni\OCUbrT2t5+`6Wk,6n:1!Mlt\]u665N-)V9b?L*TD.jtr$lk3,Ikpma[*%6X"^^1e":f"UL?!@3;Z7qPTLMU9%7oH7b8e"PfMJn+>6[>9"`dLMrT.5<o*Hq`9)bs/UZ-?'*;5D7g=T_$j%\KIrJO9ZuHj1aN%d(V-*eq22Y/Sm8AHYOSP`Rp^3Cll2S$/pna_FUdF=&LlZ22p4t(,f*Z!Ot/+0n$=8_P3!Q3_a^-")'OG%*&HVo>?S8XM%3#.77:P/.OCbLN&^%d#Rb1%VGAa1/2jn(8ZY#+.D#,4Y)h1;=+!/m2"F>C!`K?OVPf;Kb^MG\$)_Iee1<ja%)4lG0i.M\kCk^s1R,-qElui.QPrK\AD_^Qth6cRhpi@992l_Z"'t#Hsj(rhW]"i8\%OC(s`9N.ham6N>F;sp>[X)i$cRP3PN'E^9_29rWBMq>ZU~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1514
|
||||
>>
|
||||
stream
|
||||
GatU2D/\/e&H8h>EA.s>ko-"d8;c[kpMK431hl!O)!7MgAB%Ij9<KP<)i"'qmmp$7nlC7-D-56Nm^\*sa#c&LpgrkXh#P)m_!EWT`aNin9Fq5_@sG<c^''*>*XN,ue23+EO>sThW7F68;@sZ8R";+Hd?+F&)::Lr^sOcl=9On?b@^hX&h+`5!CS`C[!H+%'#clB(.9Vehm`(LBhL*+Wagf@gE2$@#4!&d;+!VtPECO;#,^ibIX_U;P\X3q=(Rf;GuL.'WduJZ&^9,Qc0IEk)mb.Jk18VTG%6El&/Nm%G\2A`n:fdg,/(V`a/0'<!&&In?FI)9aZ:pbLdj?G0W%!piZ6F&C(c7X_Hd!mi'E0Md@jB'_'\*'['sA-#<X2p$9aPlh.[24,/qS8g$>GbQf/D&N*.P^MGZ$mi3>Z#l?EBD+@Un\I3((ibD)A5"&\M5%HtSO`s5DG1\T!/XZR&q!`r=ogSTGnf%,j3?:As;*i"rS9"8Iq"GXF[QJ/0/NUfD<)s7%L&\`;\1#WZ5D+1ZGlh1hfEO%>'A-KX"<HVH\:[fi0Ft81k<o5aqG$eB#9mp/lfu&pI*A+A];*\AGVB(BPo_XTCmXDT9\)b+QMrin<,*,WaTm#C=(!7r-'t:Qb[[$,-'RKi=D(ISrLn_<uGZc'n>SJ(d,2Y0jTn5aDM=,N7^rmR\+W#_j-ngL8O>;J1S70f*--sA"&8HHo72fl'H;+Vu3r$"'WeWIYJSSm_XCetjZq`uE>up0!AK$c"1QKFM-"0EW9MYV9]F4DDEJXU'h<G&80WSi`\i!JNGl`8.0n,f1=@92B>&VT3AZAC%2uB]l`^sjg]f"NAj^s\j.l<h3jiU8grtn'l`;4+VLnn3Yl@2Q@k;BDOG$XOM*Yh8PRB=]8/_ZtNjD9N5H^5Bso/E!+jKC`7KHr%+o[oW^DXC&)EcummIlT`?oD/=EZ)(5%R62RJIs^cW08=C0Fq]TU$994+m*Xuu`cg-[qKoUHX&]/m6jrN0&DOVM[pO`>$\JNfRcGYO3*F]7dQH:<6&\h`gaoJ]//3,P^d3n5hKG.2<"IOK-m9TP6tD;T;iCfc$m:]Dk]i_o/RIuX&T;B%67l)m(Q#>0VpPEAa"rE"q"+uPi4p&-#A_/!'LuDc(f)'=LU'"@:rf5AGD$Lo_1K7KopaD?$F`+-5QCKb,-99_nP6Q)/o/ZOQsA*O(?"F`%F^Xj:Ps:[?/N0Q=g+u*j'6;JW+8gfd$alL/ui3#".]J]jiKqsk.AXK2JK=/&(eM6B"p5`JA),W7k4/bA@H/r8Dk`\S>9orZ%OKhZn*i925F<pb`E(ii58K'5tF-gYQ$VVSt:q&qrVAKqef=(3%88POE#ej$m'$t<DrcORr>c]Q==gU!mYuDD-8a&QHLh%Qd;^udcWM\DIu'FlV3P6S1P9-aff>Ac0tk_AELnIY@f6OTl-l9g?a"5F20DB.SSWT^r[?U@1Om'eh?k6pRJ,do@)1?o>UW8\SOhhQhPpccPH`a!?!/jir~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1652
|
||||
>>
|
||||
stream
|
||||
Gb"/'bAu>q']&@2mOiAq_Z]`9]kJ+WR5j4RNiStFCg[mMBMem7J@=49n)[L?6WTIdSYf=W,>oY'E:3Ti'O6%7p,cCX]UZgSCC6<BC_"rB50Qd[kF(q\+Y7/d*rti@lbs9'lnpHCR8ZE`Ot^K&"n"j57m?^<'$ats%+do[NBg.(5BSB9.!-^%"5u'BH$Eu87mb47CS(`<(uJZWoA^:nR"RUaUtL/-$tmpnW"ISOM+`ILJCt;'hS&r2$u])l9sRTqJ<*aSA7=FQ@hH@5hLAs-DQHOjQ+W?8W`HF45JZUeAl"o0-MY=$8:^>Mih&aD1^(H.X6(GhWQ=X.!2&1s5b/BM+mXtS/gm7R7VI\b(%-=F)".3n+2Z0/!U4LBZ,0o]EF#24`Ye/Wq1M3\RuRWhSAZ;cn:ksU)lRa)`d)P(,IU+S3?s)`+@1UsIGn"M9%OU0&]bu:8K;s!U;.iqaaWb.`^KC9=ZENZ2=Ji_#Ho*(Bq71.]Q]Enaf%iLVp#7#\/l0*CU.V_(IC2@hnDkT_\PH`ln3bI&Mutl]n(Y[NN.,QNq"?aC:1(CJ\6Y.Ml"ZOQJ9%rjR24H?a^NA@mKq9ee<J=g6b^YjLMGbjaJD41cHgV8W5A<\nj^MD''9LC%U-I_8^ROXQPDdoA8i[)gHRoD$o"o9VZ8G8bB*7U*#AAh$*.Qf!&i#m\;SjH_ln:$$0Nrfl")2Bs.p&KdbD:k%S.6Z?ujhYE-m`8>XG7&lP!2efP-X5G%ej?G"?5X>i^/!)nct/<i$dCI(UUm]3Yg*A2DYooRPEI6=3Y]lk+d`T])aA1CRhrdRGp9I:rdaAdc([SiG);Uj2`@R$U:?R>?$au7s3_$C%?[F,73Q."mRkB2N5/%`^]__"o!WX?7#aV()bdJM!*QZ,o>U\#`%*5-p`o1Base8o?jU$bk3IOS<<US?>(0STI2flK^@46B:&%_jW?IZ)u91!.,eoHs?q_XPpX8I/_cW[`\XCm8k%(j;/-AY#M[Q-pGPq\*H7Y6t%FL&!!:-89K60tp'[`p,UiFfk[i8Ga#!qN1.))%s>\@0M?W'[g1<AXk-_*1\-['@_\7)p;$3*ZSl$eG`'D8Fn2(M?F`<1Wf?HpJGR?;u(nVrJ40)s+d(WoG0D=9dLI2*n>R/>G/dk.;i>tmDTo36/;8=FV,WiU>rZQNRfKHkXnsdqo\H(W&qtY@tirQ1QDjp6fV$&,d-T*dh+df8XseE@kGdk$XkH\rW1P:cPe_7)WUV;V6PKfGrt)ICYT,pYrQ0tF4Vu>-#9+<gO!f*6/7pI^!GaoM&Bm`G\mje<nHhgL$??C<,&U)_>ptn;8kgbR]1,'6]1&@*)#BN\M.5>'mZRNG0Y:"X/:Y4fBULdb56FB#oj;;Lgm'^E<p7=*H&(3*XsG)r`dqL,VPK^@id76ES*a@3>hTAF&;Ps9p&d"qgB:N2bM?D93E2"gTeg*NV*HnRre=8>njT_UH&*=!Hi95Q.3,;SuXuFlKc<]86T97gL&jQgHRb_Jj8g:@F'Y[U"D.bNP6>n;nfZns/,F/?Dsnl0ZcZE(\?4qL1];WV%4>p[uM[2'0*2e,2I[8`R@-E^WO,[ljRk[p!W9B$q$u\=Lr[2^q1RjQPSnb#ocj`qQIiNrhPhAfU:R[C;BWYJ4/Q#~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2165
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>EA133m:qBO.#KK.?tm6\*P*k/ZPD-@&$em`8n?&+jKunQs1Y77.+tl"[6q8RN?(ZdmiL>Jcb7[6.)&gV)6Eo\T>C%n$lFc+"Y`4FS@sRtc^ZM@E63Oe!HAis!tHSqboc">-O!9Y)=W0-U-;Gg!216,.T!oXchEh<\VA'*LB6okop#NOQm\03U$iW93DM/^-Y9Nchq4icGla%eF_GCc7PL'7NDgVC'ne?gpY]is8$@C?irqQkQF_lQ=dHklAZhe.$0RiUE:RIa;A=+Jl8^uWf/h#3CRpdciTpWk*gjKapd/rZTm=9F06I:h!a^a>>L]mTiA+.q#^SJ6ED9X[aW!TqWeft=+8=dG"a=3V)!))R,ci$SOo!Xh,nBYUV*ajp\8%fB@D:h$&[.IV*'8m69JL.Plqi4QJHltFA[RISTH/j8_"Ks3(k0Z>0<#,LG*^4CSJ;Gp=<ebrlUK4laB:M<Vp)3uqW"1VG:N/UU)J@M[Z")#(#po#`-%?CW\*Q*$Xr]`mpCG:ZGI+X^PQkG_#i]b%fS+W:>rd71IJW!@?SRnEe"I^ZCVbr#p[8V9sf!S2s%m\>I-#K%&\kk878/'h;#V#>(IXspA#N_$8m^aDtg6e$sIMfJZV3&n9Xn+MN*W#@[L#RE5juur[,I,*R4N[raBXrOjcj/X)3$k;3F"5[*iXTAe<a,2Q[Y7gG$hpd<\YVM+3KXhe9\oi?I$jLKF=>8M1>U2k)PV)pOR@R$S]uSR:^\Y`8jN2mQ5B(t2qK@T+M!P+WVN=j\]=fEl=h:CD,)VpT@&@Iit>nY_b]o6@\q@m+:$U[TbaA!Ss7l"1Bom4=*PeW:@[Mam83)4"d]2km32TB>ubX,A\_>3p!2@$>5c*aFYs&q,_3E1LPB]4anFRXO'e(HO+Vq%0\dV,hiDo3&ee04uHJUnMC-c/c1&nc&YHWD_NjUrBUq]2droR)\'nB+FIi^!0180$_^O!cKb=^-5dE4V+RrQcc,CK6^Co^^==,pLY<O09kE(\V+P."=2I91`T?:lcXT\1TU#MU$u:jFJjJ*HPt1_W-aWM.+osah%LfbPT;gA8#V?4Ud;q'g8[u%']I$KYA#4XJ'uKb#ZgHg!Mn"(S^)0K638&RMVV(DZ(VNo>*k$lMopJMa+l^6mUr<./D@3Vel!Kif!<=$MRD4nJQ;)GQ%k\-(bSaS))6k5[!\FhFe-_"`1g:JhC*#=eE-?a)b7T)fi`Y)hnrom7V/*dJ=F3302e#$80ta#A+FXnZ0e82nLP4BO"cVPki>X20!J)3E#GOWe.p_\8p6*#U#-tgL%#CLDt^;qqauj<`758U<o'<9Mp\``adqSII#F6<n/]s=:rPFg#FqQc_psQO?'#@KAT&`lAJh@8"$CZSX3OXuL5_ikR"k"99,%kJ0#DCRf8fA!3ap[G`"@kF`GVWtI"VjlpIA15'00?1a+Kq%>uj8E0onjK>\LqkSfTC*COmKlW:%JUd9!@B2=81m2jY\_l?o7qXc7em9m:rGN,I+Kd6h.I9^]%:*TDC>.G)V\YR.)JE2g)MbL4:^[FLKB&H]"EV>3&DdVu<9O'qNGQ7n<Nd:Tu3P.AjS=P"o2Dhmn2i`48C."DW$]E0mb6">Q\3i4QL)-pUWlEf-AU.K]4+<3C"&TA_fF8Z,fSA;$7BGV&JbIPSu`/-)Xl*#pER_48K\*J*I9"SFfA]%F5eP*:5"Ue#%@>(a];%H')1!>j:L5n9Efb/E]7VrI6.cSh^Y">cj=XpZ>-$XVSLQHC;4;n`5N`ip<MZ7nE$5]0>ceF:Z+gD]F5-lu%Kru3V/8.OIbh<:@ZZ_"F@'lEhNaoNDWM<%LXk-uB2seQ/)@MfodjAM;XLUIl3-fa.Eo2jRNWk!iqYs_GhWSXBa5t?-_V(6Ci>QgErp/,HgL3,=lbn=IrE)'m^-?9V[MiJ#'U)l>Ltcr-/[NVM(."kMdq_'FS"gS(Fk<$Vih_BT=GDGD-ud_m\_57#=`45,c(TqoH6!c/RZaU5rh7Z-/`)4RN'>6C<G:8_:@HKODQ_=@b^!G[OtCJ4W`Ipq5H!XoE1:Wekh1NnP3=!Q>*o(uStfr\kE/)$\Ln9-dg48joaf*a=PUX'J1OE4L@X9m_cJ*;%E5g:a5%e$fN17c4O18ie[o5uc9G#:[PR~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 717
|
||||
>>
|
||||
stream
|
||||
GauI39lldX&;KZQ'mk$YK0Vaoioh`6eC1d!-FB[U*$O(dJ1-SEk2/\k_]NmaWH/%VHN<HFn$=M[6f:_pr5o*!@flWe##$/-"/^l@EW_qsdI.YkN,T[9$:PNG"Ag(U%]C2=&gE'49O\DSi(.eo<+$t>/:b5ic7t)X_>Ibq!HAlo5lh930u8n06c97MW@<]AMr2Nurkuf_N*,8gfk`;hk#UCMC9&7N']=d;O;'DJpAO.g53mnXEiF0k5A[I_d_Z)0*%"U&@SCC89&WB*WE`MY.d[I3IR/"ZagJ$B6\-3b5LdGF"u$/RB<7]6ZF@//<mC[MHU;qZW(0T#Th%QPFj4m/KB@:ae[-W9g-W5Bl/MqT*4.FJqQslu!4joN?^"g'l1F`OF+3%ehdqU@KX&>)AB0S0cH[sbh,Q+9r:TWhX[f'`PtPCdouC+@[T_r9Abr$e"ZV[]nG6C&dO"X,J+]WA>\&#gIRI'Mrngh+qbk!'=(dfk9Y*:$c093&IBGB:VkpAW[(?6$oW2DKJi[AY;>7WHUqU*c`Z]90FK20'Z?b*,GM$aA]^F)2X)NYR'Z73M\^"d4BBrO-!GPS8=)Vh-/+'Xn<@41jW7'QcocMdgSpJ_#Dg8REp'Y#oVudiUS<<P?ZUkZ7%]GW8[K_N^V0Y&_Dh.q%h>9[tjB3@+W*ST\55K#"Xt'+=r)pYQF,fmPWh37-Nr7Zi-]D@4/q"!5~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 29
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000123 00000 n
|
||||
0000000230 00000 n
|
||||
0000000342 00000 n
|
||||
0000000547 00000 n
|
||||
0000000752 00000 n
|
||||
0000000857 00000 n
|
||||
0000001062 00000 n
|
||||
0000001267 00000 n
|
||||
0000001472 00000 n
|
||||
0000001678 00000 n
|
||||
0000001884 00000 n
|
||||
0000001962 00000 n
|
||||
0000002168 00000 n
|
||||
0000002374 00000 n
|
||||
0000002580 00000 n
|
||||
0000002650 00000 n
|
||||
0000002942 00000 n
|
||||
0000003062 00000 n
|
||||
0000004900 00000 n
|
||||
0000006178 00000 n
|
||||
0000007466 00000 n
|
||||
0000009202 00000 n
|
||||
0000010709 00000 n
|
||||
0000012816 00000 n
|
||||
0000014422 00000 n
|
||||
0000016166 00000 n
|
||||
0000018423 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<fd40511b2cfd3fa4f915567dab118c8a><fd40511b2cfd3fa4f915567dab118c8a>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 17 0 R
|
||||
/Root 16 0 R
|
||||
/Size 29
|
||||
>>
|
||||
startxref
|
||||
19231
|
||||
%%EOF
|
||||
377
Les16-MCP/Les16-Slide-Overzicht.md
Normal file
377
Les16-MCP/Les16-Slide-Overzicht.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Les 16 — MCP (Model Context Protocol)
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Eigen MCP server voor Polderfest-data (Node/TypeScript)
|
||||
**Vervolg op:** Les 15 — Cursor + Vercel deploy
|
||||
**Aansluit op:** Les 17 — Externe APIs in diepte
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 16 — MCP
|
||||
|
||||
**Visual:** "Les 16" BLUE, "Model Context Protocol" BLACK, subtitle "Eén protocol, alle AI-clients, jouw tools beschikbaar"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Lessen 11-15:**
|
||||
- AI SDK, Tool Calling, Agents, RAG, Cursor+Vercel deploy
|
||||
|
||||
**Het probleem dat we nu oplossen:**
|
||||
- Tools die je bouwt zitten VAST aan jouw app
|
||||
- Wil je dezelfde tools in Cursor? Claude Desktop? Een script? Drie keer schrijven
|
||||
- MCP = standaard die alle AI-clients begrijpen
|
||||
|
||||
**Visual:** 3 AI-clients (Cursor, Claude Desktop, ChatGPT) → 1 MCP-server → jouw data
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Terugblik + waarom MCP? | 15 min |
|
||||
| Theorie: MCP architectuur | 20 min |
|
||||
| Theorie: bestaande servers + eco | 15 min |
|
||||
| **Live Demo 1** — MCP SDK + eerste server | 25 min |
|
||||
| **Live Demo 2** — Server laden in Cursor/Claude | 20 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — Eigen Polderfest MCP server | 30 min |
|
||||
| **Live Demo 4** — Resources + prompts toevoegen | 20 min |
|
||||
| MCP vs Tool Calling | 10 min |
|
||||
| Lesopdracht + Huiswerk | 10 min |
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is MCP?
|
||||
### Een open protocol voor AI ↔ tools
|
||||
|
||||
**Definitie:**
|
||||
> Model Context Protocol (MCP) is an open standard for connecting AI assistants to data sources and tools — created by Anthropic in november 2024.
|
||||
|
||||
**Het idee:**
|
||||
- AI-clients (Claude Desktop, Cursor, custom apps) zijn **MCP clients**
|
||||
- Tools/data-bronnen zijn **MCP servers**
|
||||
- Eén protocol voor de communicatie tussen beide
|
||||
|
||||
**Wat krijg je:**
|
||||
- Bouw je tool ÉÉN keer, gebruik 'm in elke MCP-client
|
||||
- Het Anthropic-ecosysteem heeft al 100+ MCP servers (filesystem, github, slack, postgres, ...)
|
||||
- Voor productie-apps: complete decoupling tussen AI-frontend en jouw backend
|
||||
|
||||
**Visual:** USB-C analogie — één standaard connector voor verschillende apparaten
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: MCP architectuur
|
||||
### Client / Server / Transport
|
||||
|
||||
```
|
||||
┌──────────────┐ JSON-RPC ┌──────────────┐
|
||||
│ MCP Client │ ◄────────────────────► │ MCP Server │
|
||||
│ (Cursor, │ stdio / HTTP/SSE │ (jouw code) │
|
||||
│ Claude │ │ │
|
||||
│ Desktop) │ │ exposes: │
|
||||
└──────────────┘ │ - tools │
|
||||
│ - resources │
|
||||
│ - prompts │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**Drie primitieven die een server kan exposeren:**
|
||||
|
||||
- **Tools** — uitvoerbare functies (zoals Les 12 tool calling)
|
||||
- **Resources** — data die de AI mag lezen (files, DB rows)
|
||||
- **Prompts** — herbruikbare prompt-templates
|
||||
|
||||
**Twee transports:**
|
||||
- **stdio** — server runt als lokaal proces, communicatie via stdin/stdout (default)
|
||||
- **HTTP/SSE** — remote server, voor cloud-deploys
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Bestaande MCP servers
|
||||
### Het ecosysteem
|
||||
|
||||
**Officiële Anthropic servers (npm @modelcontextprotocol/server-*):**
|
||||
- `filesystem` — lees/schrijf files
|
||||
- `github` — repos, issues, PRs
|
||||
- `slack` — channels, messages
|
||||
- `postgres` — query database
|
||||
- `puppeteer` — browser automation
|
||||
- `memory` — persistent geheugen tussen sessies
|
||||
|
||||
**Community servers:**
|
||||
- `linear`, `notion`, `asana`, `figma`, `stripe`, `youtube`, ...
|
||||
|
||||
**Hoe gebruik je ze:**
|
||||
- In Cursor: `~/.cursor/mcp.json` config
|
||||
- In Claude Desktop: Settings → Developer → MCP config
|
||||
- Beide laden de server bij startup, server wordt vanzelf beschikbaar
|
||||
|
||||
**Visual:** Lijst van populaire MCP-servers met logo's
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Eigen MCP server bouwen
|
||||
### TypeScript SDK basics
|
||||
|
||||
**Package:**
|
||||
```bash
|
||||
pnpm add @modelcontextprotocol/sdk
|
||||
```
|
||||
|
||||
**Skeleton:**
|
||||
```typescript
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "polderfest-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"searchBands",
|
||||
"Zoek bands op dag, stage of genre",
|
||||
{ day: z.string().optional(), stage: z.string().optional() },
|
||||
async ({ day, stage }) => {
|
||||
const bands = await queryDb(day, stage);
|
||||
return { content: [{ type: "text", text: JSON.stringify(bands) }] };
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
**Drie regels:** maak server, registreer tool, connect transport. Klaar.
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: Wat we vandaag bouwen
|
||||
### Polderfest MCP server
|
||||
|
||||
**Doel:** MCP server die Polderfest-data exposeert. Werkt in Cursor, Claude Desktop, en elk ander MCP-client.
|
||||
|
||||
**Wat zit erin:**
|
||||
|
||||
| Type | Naam | Wat |
|
||||
|------|------|-----|
|
||||
| Tool | `searchBands` | Filter op dag/stage/genre |
|
||||
| Tool | `getBandStats` | Aggregate counts |
|
||||
| Tool | `addFavorite` | Write — voeg toe aan favorites |
|
||||
| Resource | `bands://list` | Volledige bandenlijst (read-only) |
|
||||
| Prompt | `dailyRecap` | "Geef recap van dag X" template |
|
||||
|
||||
**Demo:**
|
||||
- Eerst werkend in dev (stdio)
|
||||
- Daarna geladen in Cursor → Cursor kan zelfstandig vragen beantwoorden over Polderfest
|
||||
- Idem in Claude Desktop
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: LIVE DEMO 1 — MCP SDK + eerste server
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. `pnpm create-typescript-app mcp-polderfest` + `pnpm add @modelcontextprotocol/sdk zod`
|
||||
2. `src/index.ts` — minimal server skeleton (15 regels)
|
||||
3. Eerste tool: `searchBands` met hardcoded data (mock)
|
||||
4. `pnpm build` → `dist/index.js`
|
||||
5. Test met MCP Inspector: `npx @modelcontextprotocol/inspector dist/index.js`
|
||||
6. Inspector UI in browser → zien dat tool beschikbaar is → tool aanroepen → resultaat
|
||||
|
||||
**MCP Inspector is jouw vriend** — debug-tool zonder Claude/Cursor nodig
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: LIVE DEMO 2 — Laden in Cursor + Claude Desktop
|
||||
### ~20 min
|
||||
|
||||
**Cursor (`~/.cursor/mcp.json`):**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"polderfest": {
|
||||
"command": "node",
|
||||
"args": ["/full/path/to/mcp-polderfest/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json`):**
|
||||
Idem — zelfde JSON structuur.
|
||||
|
||||
**Demo:**
|
||||
1. Edit config, restart Cursor
|
||||
2. Cursor → settings → MCP — server zichtbaar + groen vinkje
|
||||
3. In chat: "Zoek bands op zaterdag voor mij" — Cursor roept onze tool aan
|
||||
4. Idem in Claude Desktop chat
|
||||
5. Eén server, twee clients, geen extra werk
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: Pauze
|
||||
### 15 min
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: LIVE DEMO 3 — Echte Polderfest server
|
||||
### ~30 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. Server koppelen aan Supabase (env vars via dotenv)
|
||||
2. `searchBands` echt naar database
|
||||
3. `getBandStats` toevoegen
|
||||
4. `addFavorite` (write-tool) toevoegen — let op user-intent
|
||||
5. Errors netjes afhandelen (return `isError: true`)
|
||||
6. Live testen in Cursor — vraag stellen, écht data uit database komt terug
|
||||
7. Trick: ook nuttig in Claude Desktop voor research-werkflow
|
||||
|
||||
**Belangrijk:** MCP server is gewoon een node-proces. Logging via `console.error` (stdout is gereserveerd voor protocol).
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: LIVE DEMO 4 — Resources + Prompts
|
||||
### ~20 min
|
||||
|
||||
**Resources** zijn read-only data die AI mag inladen:
|
||||
|
||||
```typescript
|
||||
server.resource(
|
||||
"bands-list",
|
||||
"bands://list",
|
||||
async () => ({
|
||||
contents: [{
|
||||
uri: "bands://list",
|
||||
mimeType: "text/plain",
|
||||
text: bandList.map(b => b.name).join("\n"),
|
||||
}],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
In Cursor: `@polderfest:bands-list` — bands-data direct beschikbaar voor AI
|
||||
|
||||
**Prompts** zijn herbruikbare templates:
|
||||
|
||||
```typescript
|
||||
server.prompt(
|
||||
"daily-recap",
|
||||
"Geef een samenvatting van een festivaldag",
|
||||
{ day: z.string() },
|
||||
({ day }) => ({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: { type: "text", text: `Geef recap van ${day} ...` }
|
||||
}]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
In Cursor command palette: snel die prompt invoegen.
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: MCP vs Tool Calling
|
||||
### Wanneer welk?
|
||||
|
||||
| | Tool Calling (Les 12) | MCP (Les 16) |
|
||||
|---|----------------------|--------------|
|
||||
| Waar leeft de tool | In jouw Next.js app | Aparte server, los proces |
|
||||
| Wie kan 'm gebruiken | Alleen jouw chat | Alle MCP-clients |
|
||||
| Setup-complexiteit | Laag | Iets hoger |
|
||||
| Distributie | Niet — alleen in jouw app | npm publish, anderen installen |
|
||||
| Voor productie-app | Default | Voor herbruikbaarheid / multi-client |
|
||||
|
||||
**Mentale model:**
|
||||
- **Tool calling** = "function in mijn app"
|
||||
- **MCP server** = "library die elke AI-client kan gebruiken"
|
||||
|
||||
Veel productie-teams gebruiken beide: tool calling in hun product, MCP voor interne dev-tools.
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Lesopdracht + Huiswerk
|
||||
### Bouw je eigen MCP server
|
||||
|
||||
**Lesopdracht (30 min):**
|
||||
- MCP server skeleton opzetten
|
||||
- 1 tool met mock-data
|
||||
- MCP Inspector test
|
||||
- Laden in Cursor of Claude Desktop
|
||||
|
||||
**Huiswerk (~2 uur, voor Les 17):**
|
||||
- A: 3 tools (read) + 1 write-tool naar Supabase
|
||||
- B: 1 resource toevoegen
|
||||
- C: Laden in Cursor — werkt + screenshot in MCP.md
|
||||
- D: `MCP.md` met tool-lijst + 3 voorbeeld-prompts + reflectie
|
||||
|
||||
**Bonus:** publish naar npm, OR maak een HTTP-transport variant voor remote loading
|
||||
|
||||
---
|
||||
|
||||
## Slide 16: Volgende les + Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- MCP = open protocol voor AI ↔ tools/data
|
||||
- Drie primitieven: tools, resources, prompts
|
||||
- Twee transports: stdio + HTTP/SSE
|
||||
- Eigen server in <50 regels code
|
||||
- Werkt in Cursor + Claude Desktop + custom clients
|
||||
|
||||
**Volgende les (Les 17): Externe APIs in diepte**
|
||||
- OAuth flows (Google, GitHub login)
|
||||
- Webhooks ontvangen + verifiëren
|
||||
- Paid APIs (Stripe, Resend)
|
||||
- Rate-limiting + retry patterns
|
||||
|
||||
**Daarna:** Les 18 — Supabase Auth + RLS multi-user (de laatste!)
|
||||
|
||||
**Vragen?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Wat is MCP | Theorie |
|
||||
| 5 | MCP architectuur | Theorie |
|
||||
| 6 | Bestaande servers | Theorie |
|
||||
| 7 | Eigen server bouwen | Theorie |
|
||||
| 8 | Wat we bouwen | Intro |
|
||||
| 9 | **DEMO 1** — SDK setup | Demo |
|
||||
| 10 | **DEMO 2** — Laden Cursor/Claude | Demo |
|
||||
| 11 | Pauze | Break |
|
||||
| 12 | **DEMO 3** — Polderfest server | Demo |
|
||||
| 13 | **DEMO 4** — Resources + Prompts | Demo |
|
||||
| 14 | MCP vs Tool Calling | Reflectie |
|
||||
| 15 | Lesopdracht + Huiswerk | Praktijk |
|
||||
| 16 | Afsluiting | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **MCP officiële site:** https://modelcontextprotocol.io/
|
||||
- **MCP SDK (TS):** https://github.com/modelcontextprotocol/typescript-sdk
|
||||
- **MCP Inspector:** `npx @modelcontextprotocol/inspector`
|
||||
- **Anthropic announcement:** https://www.anthropic.com/news/model-context-protocol
|
||||
- **Cursor MCP docs:** https://docs.cursor.com/context/model-context-protocol
|
||||
- **Claude Desktop MCP:** https://docs.anthropic.com/en/docs/build-with-claude/mcp
|
||||
- **Awesome MCP servers:** https://github.com/punkpeye/awesome-mcp-servers
|
||||
BIN
Les16-MCP/Les16-Slides.pdf
Normal file
BIN
Les16-MCP/Les16-Slides.pdf
Normal file
Binary file not shown.
BIN
Les16-MCP/Les16-Slides.pptx
Normal file
BIN
Les16-MCP/Les16-Slides.pptx
Normal file
Binary file not shown.
BIN
Les16-MCP/lu45lvnjc.tmp
Normal file
BIN
Les16-MCP/lu45lvnjc.tmp
Normal file
Binary file not shown.
1
Les17-Externe-APIs/.~lock.Les17-Slides.pdf#
Normal file
1
Les17-Externe-APIs/.~lock.Les17-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 11:21,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
302
Les17-Externe-APIs/Les17-Docenttekst.md
Normal file
302
Les17-Externe-APIs/Les17-Docenttekst.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Les 17 — Externe APIs in diepte
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 17 van 18
|
||||
**Onderwerp:** OAuth, webhooks, Stripe, Resend, retry patterns
|
||||
**Duur:** 180 minuten
|
||||
**Demo-app:** `stripe-demo` — subscription-flow met webhook en Resend mail
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES (45 min)
|
||||
|
||||
1. Stripe account in test mode + product + price aangemaakt
|
||||
2. Resend account + API key
|
||||
3. Stripe CLI geïnstalleerd + `stripe login` gedaan
|
||||
4. Demo-app `stripe-demo` werkend op laptop (backup)
|
||||
5. ngrok / `stripe listen` getest
|
||||
6. Browser tabs: Stripe Dashboard, Resend Dashboard, docs.stripe.com, resend.com/docs
|
||||
|
||||
---
|
||||
|
||||
## HET SCRIPT
|
||||
|
||||
### BLOK 1 — Welkom + Recap (15 min)
|
||||
|
||||
`[SLIDE 1]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Welkom bij les 17. We gaan vandaag externe APIs uitkluizen op productie-niveau. OAuth, webhooks, paid services. De real-world stuff die je vorige les met PokéAPI niet zag."
|
||||
|
||||
`[SLIDE 2]`
|
||||
|
||||
**Vertel:** "Tot nu toe deden we externe APIs eenvoudig. PokéAPI in les 15 — geen key, GET request, klaar. Maar in productie kom je dingen tegen die je nog niet hebt gezien. Login met Google. Betalingen. Email versturen. Wat als de API een minuut down is?
|
||||
|
||||
Vandaag bouwen we een mini subscription-app met Stripe en Resend. Geen echt geld — Stripe heeft een test mode. Maar de patronen zijn productie-klaar."
|
||||
|
||||
`[SLIDE 3]`
|
||||
|
||||
**Vertel:** "Drie uur. 50 min theorie, vier demo's, lesopdracht en huiswerk."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 2 — Theorie (40 min)
|
||||
|
||||
`[SLIDE 4 — Drie soorten API integratie]`
|
||||
|
||||
**Vertel:** "Externe APIs komen in drie smaken qua auth. Simple fetch — geen key, alles publiek. PokéAPI is daar het schoolvoorbeeld van.
|
||||
|
||||
Key in header — de meeste paid APIs. OpenAI, Anthropic, Tavily. Eén string in je env vars, mee in elke request.
|
||||
|
||||
OAuth flow — voor user-authenticatie. Google login, GitHub login. Complexer.
|
||||
|
||||
En een vierde dimensie: webhooks. Geen auth voor calls *naar* een service, maar wel signature verificatie van calls *van* een service. Daar focussen we vandaag op."
|
||||
|
||||
`[SLIDE 5 — OAuth basics]`
|
||||
|
||||
**Vertel:** "OAuth in zeven stappen. Klikt 'login met GitHub'. Redirect naar github.com. User logt in, geeft toestemming. GitHub redirect terug met een code. Jouw server exchanged die code voor een access token. Met dat token haal je user-info op. Cookie/sessie zetten. Klaar.
|
||||
|
||||
In Next.js — Auth.js is de standaard. Drie regels code, één env var, werkt. Niet vandaag bouwen — komt in huiswerk als bonus, ook in les 18 met Supabase Auth."
|
||||
|
||||
`[SLIDE 6 — Webhooks]`
|
||||
|
||||
**Vertel:** "Webhooks. Het tegenovergestelde van een API-call. Niet jij belt de service, de service belt jou.
|
||||
|
||||
Stel je hebt Stripe Checkout. User betaalt. Stripe verwerkt. Mogelijk gaat het mis — kaart geweigerd, fraud-check. Mogelijk duurt het seconden, of minuten bij 3D Secure.
|
||||
|
||||
Polling Stripe API elke seconde — duur, traag, lelijk. Webhook — Stripe stuurt een POST naar JOUW endpoint zodra er iets gebeurt. Jij update database, stuurt email, etc.
|
||||
|
||||
**Cruciaal:** signature verification. Zonder dat kan iedereen je webhook URL aanroepen met fake data. Een fraudeur stuurt fake 'betaling completed' event — jouw server geeft premium toegang aan iemand die niets heeft betaald. Altijd verifiëren."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 3 — DEMO 1: Stripe Checkout (25 min)
|
||||
|
||||
`[SLIDE 7]` + `[SLIDE 8]` `[SCHERM: terminal + editor + stripe dashboard]`
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les17-Externe-APIs
|
||||
pnpm create next-app@latest stripe-demo --typescript --tailwind --app
|
||||
cd stripe-demo
|
||||
pnpm add stripe
|
||||
```
|
||||
|
||||
`*[Stripe Dashboard]*` `[SCHERM: browser]`
|
||||
|
||||
**Vertel:** "Eerst Stripe. Test mode aan, rechtsboven. API keys ophalen. Belangrijk: `sk_test_` is je secret key — server-only. `pk_test_` is publishable, mag in client."
|
||||
|
||||
`*[Maak product + price in Stripe Dashboard]*`
|
||||
|
||||
`*[.env.local aanvullen]*`
|
||||
|
||||
`*[app/api/checkout/route.ts — code typen]*`
|
||||
|
||||
```typescript
|
||||
import Stripe from "stripe";
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { priceId, email } = await req.json();
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
customer_email: email,
|
||||
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.APP_URL}/`,
|
||||
});
|
||||
return Response.json({ url: session.url });
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Mode subscription voor recurring. Mode payment voor eenmalig. Line items met price ID. Success en cancel URLs. Stripe geeft een URL terug — daar redirect je naartoe."
|
||||
|
||||
`*[Pricing page + button + test]*`
|
||||
|
||||
`*[Open browser, klik subscribe, Stripe Checkout opent]*`
|
||||
|
||||
**Vertel:** "Test card. 4242 4242 4242 4242. Elke datum future, willekeurige CVC. Klik subscribe."
|
||||
|
||||
`*[Betaling slaagt, redirect naar /success]*`
|
||||
|
||||
**Vertel:** "Werkt. Check Stripe Dashboard, payments tab — transactie staat erin. Geen echt geld. Test mode."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 4 — DEMO 2: Webhook (25 min)
|
||||
|
||||
`[SLIDE 9]` `[SCHERM: terminal + editor]`
|
||||
|
||||
**Vertel:** "Maar — we hebben een probleem. Wat als user de tab sluit voor de success page laadt? Dan weten we niet of er betaald is. Webhook lost dit op."
|
||||
|
||||
`*[Stripe CLI lokaal]*`
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3000/api/webhook/stripe
|
||||
```
|
||||
|
||||
**Vertel:** "Stripe CLI doet een trick — registreert tijdelijk een webhook voor jouw lokale poort. Output geeft je een signing secret. Kopieer naar `.env.local`."
|
||||
|
||||
`*[Webhook handler schrijven]*`
|
||||
|
||||
```typescript
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text(); // RAW body, niet json()
|
||||
const sig = (await headers()).get("stripe-signature")!;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err) {
|
||||
return new Response("Bad signature", { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
console.log("Payment:", session.customer_email);
|
||||
}
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Drie cruciale dingen. Eén — `req.text()`, niet `req.json()`. Stripe berekent signature over raw bytes. Twee — try-catch om `constructEvent`. Verkeerde signature = 400 terug. Drie — check `event.type` voor wat je wilt afhandelen."
|
||||
|
||||
`*[Doe test-betaling]*`
|
||||
|
||||
**Vertel:** "Webhook event komt binnen. Stripe CLI toont 'Event received'. Mijn server logt 'Payment: test@example.com'. Werkt."
|
||||
|
||||
💬 *Vraag: 'Wat als ik de URL niet weet? Of geen Stripe CLI heb?'*
|
||||
|
||||
**Antwoord:** "Voor lokale dev: Stripe CLI is verreweg de simpelste. Anders kun je ngrok gebruiken — geeft je een publieke URL voor je localhost. Of `localhost.run`. Productie: gewoon je Vercel URL, geen tooling nodig."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 5 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 10]`
|
||||
|
||||
---
|
||||
|
||||
### BLOK 6 — DEMO 3: Resend (25 min)
|
||||
|
||||
`[SLIDE 11]` `[SCHERM: editor + resend dashboard]`
|
||||
|
||||
**Vertel:** "Mail versturen. Resend is de moderne keuze — opgericht door ex-Vercel mensen, mooie DX, gratis tier 3000 emails per maand."
|
||||
|
||||
`*[Maak Resend account, kopieer API key]*`
|
||||
|
||||
```bash
|
||||
pnpm add resend
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { Resend } from "resend";
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
|
||||
// In webhook handler
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
await resend.emails.send({
|
||||
from: "Premium <onboarding@resend.dev>",
|
||||
to: session.customer_email!,
|
||||
subject: "Welkom bij Premium!",
|
||||
html: `<h1>Welkom!</h1><p>Bedankt voor je aanmelding.</p>`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Voor demo: `onboarding@resend.dev` als from. Geen eigen domain nodig. Voor productie: registreer een domain in Resend Dashboard, verify DNS, gebruik dat als from."
|
||||
|
||||
`*[Test: doe een betaling, mail komt aan]*`
|
||||
|
||||
**Vertel:** "Mail aangekomen. Bekijk in Resend Dashboard — bevat ook delivery status, opens, clicks."
|
||||
|
||||
`*[Optioneel — react-email demo]*`
|
||||
|
||||
```bash
|
||||
pnpm add react-email @react-email/components
|
||||
```
|
||||
|
||||
`*[Maak emails/WelcomeEmail.tsx]*`
|
||||
|
||||
**Vertel:** "Voor mooiere mails — React-email componenten. Schrijf JSX, Resend rendert naar HTML met inline styles voor maximale email-client compatibility."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 7 — DEMO 4: Retry + Rate limit (15 min)
|
||||
|
||||
`[SLIDE 12]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Laatste pattern. Externe APIs falen. Soms transient — netwerk-issue, server hiccup. Soms permanent — wrong key, validation error. Retry-logic voor transient."
|
||||
|
||||
```typescript
|
||||
async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) throw err;
|
||||
await new Promise(r => setTimeout(r, 2 ** i * 1000));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Exponential backoff. Eerste retry na 1 seconde. Tweede na 2. Derde na 4. Voorkomt thundering herd op een herstellende service. Wrap je externe calls hierin."
|
||||
|
||||
**Vertel:** "Rate limit eigen endpoints — Upstash Ratelimit, gratis tier. Hou DDOS en abuse buiten. Vooral relevant voor je AI-endpoints — een gehackte user kan veel OpenAI-credits verbranden."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 8 — Productie checklist (10 min)
|
||||
|
||||
`[SLIDE 13]`
|
||||
|
||||
**Vertel:** "Voor je live gaat. Vink af.
|
||||
|
||||
API keys per environment — Vercel scoping uit les 15. Webhooks ALTIJD signature verify. Idempotency check — zelfde event 2x is OK. Retry-logic voor transient. Rate limit eigen endpoints. Monitor met Sentry of Vercel Analytics.
|
||||
|
||||
Plus security: keys NOOIT in client, geen NEXT_PUBLIC_ voor secrets, HTTPS only, user-input validatie met Zod.
|
||||
|
||||
Tien minuten voor er aan denken, uren bug-fixen later. Voor je eindopdracht: check deze lijst."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 9 — Lesopdracht + Huiswerk (10 min)
|
||||
|
||||
`[SLIDE 14]`
|
||||
|
||||
**Vertel:** "Lesopdracht — half uur. Stripe account, product, Next app, Checkout endpoint, test-betaling. Geen webhook nog.
|
||||
|
||||
Huiswerk — webhook met verify, Resend mail in webhook, retry-logic, en APIS.md. Vooral die laatste — reflectie op welke externe API je in je eindopdracht gaat gebruiken. Wat je geleerd hebt direct toepassen.
|
||||
|
||||
Beoordeling tien punten, voldoende zes. Bonus: OAuth via Auth.js, idempotency, rate limiting, refund-flow."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 10 — Afsluiting (5 min)
|
||||
|
||||
`[SLIDE 15]`
|
||||
|
||||
**Vertel:** "Wat hebben we gezien. Drie soorten API auth. OAuth basics. Webhooks met signature verify. Stripe Checkout. Resend email. Retry en rate limit.
|
||||
|
||||
Volgende les — Les 18 — is de laatste. Supabase Auth en RLS. Multi-user apps met login. Per-user data isolation. We sluiten af met een combo van alles.
|
||||
|
||||
Vragen?"
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| Webhook signature fails | `req.text()` ipv `req.json()` |
|
||||
| `headers is not a function` | `await headers()` in Next 15+ |
|
||||
| Stripe CLI 401 | `stripe login` opnieuw |
|
||||
| Mail komt niet aan | Check spam + Resend dashboard logs |
|
||||
| `STRIPE_WEBHOOK_SECRET` wrong | Stripe CLI geeft nieuwe secret per `stripe listen` |
|
||||
| Resend domain error | Gebruik `onboarding@resend.dev` voor demo |
|
||||
| Test card geweigerd | Test mode aan? Rechtsboven Stripe dashboard |
|
||||
349
Les17-Externe-APIs/Les17-Huiswerk.md
Normal file
349
Les17-Externe-APIs/Les17-Huiswerk.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Les 17 — Huiswerk
|
||||
## Webhook + Resend email + retry + APIS.md
|
||||
|
||||
**Deadline:** Voor Les 18 — Supabase Auth + RLS
|
||||
**Inleveren:** GitHub repo + `APIS.md` in root
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouw je Stripe-demo uit naar een echte productie-flow: betaling triggert webhook → database update → welkomstmail. Plus retry-logic en eindopdracht-reflectie.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Webhook handler met signature verify (verplicht)
|
||||
|
||||
### Stripe CLI installeren
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install stripe/stripe-cli/stripe
|
||||
# of via https://docs.stripe.com/stripe-cli
|
||||
|
||||
stripe login
|
||||
```
|
||||
|
||||
### Lokaal webhook luisteren
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3000/api/webhook/stripe
|
||||
```
|
||||
|
||||
Output: `Ready! Your webhook signing secret is whsec_xxx`. Kopieer naar `.env.local`:
|
||||
|
||||
```
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
```
|
||||
|
||||
### Webhook endpoint
|
||||
|
||||
`app/api/webhook/stripe/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { headers } from "next/headers";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text();
|
||||
const sig = (await headers()).get("stripe-signature")!;
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err: any) {
|
||||
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
|
||||
}
|
||||
|
||||
// Idempotency check
|
||||
// const existing = await checkProcessedEvent(event.id);
|
||||
// if (existing) return Response.json({ received: true });
|
||||
|
||||
console.log("Stripe event:", event.type);
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
console.log("Payment from:", session.customer_email);
|
||||
// TODO: Insert in DB, send email
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
Met `stripe listen` aan + dev server aan, doe een test-betaling. Check console output van beide processen.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Webhook endpoint werkt
|
||||
- [ ] Signature verification actief (test door secret te wijzigen — moet falen)
|
||||
- [ ] `checkout.session.completed` event wordt herkend
|
||||
- [ ] Console toont event-data
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — Resend welkomstmail (verplicht)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
pnpm add resend
|
||||
```
|
||||
|
||||
Maak account op https://resend.com, kopieer API key.
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
RESEND_API_KEY=re_...
|
||||
```
|
||||
|
||||
### Email versturen in webhook
|
||||
|
||||
```typescript
|
||||
import { Resend } from "resend";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
|
||||
// In webhook handler, na event.type check:
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
if (session.customer_email) {
|
||||
await resend.emails.send({
|
||||
from: "Premium <onboarding@resend.dev>",
|
||||
to: session.customer_email,
|
||||
subject: "Welkom bij Premium!",
|
||||
html: `<h1>Welkom!</h1><p>Bedankt voor je aanmelding.</p>`,
|
||||
});
|
||||
console.log("Email sent to:", session.customer_email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optioneel: React Email
|
||||
|
||||
Voor mooiere templates:
|
||||
|
||||
```bash
|
||||
pnpm add react-email @react-email/components
|
||||
```
|
||||
|
||||
`emails/WelcomeEmail.tsx`:
|
||||
```tsx
|
||||
import { Html, Heading, Text, Button } from "@react-email/components";
|
||||
|
||||
export function WelcomeEmail({ email }: { email: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Heading>Welkom!</Heading>
|
||||
<Text>Je account ({email}) is geactiveerd.</Text>
|
||||
<Button href="https://example.com/dashboard">Open dashboard</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In webhook:
|
||||
```typescript
|
||||
await resend.emails.send({
|
||||
from: "...", to: session.customer_email!,
|
||||
subject: "Welkom!",
|
||||
react: WelcomeEmail({ email: session.customer_email! }),
|
||||
});
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Email wordt verstuurd bij succesvolle betaling
|
||||
- [ ] Email komt aan in inbox (check je eigen email als test)
|
||||
- [ ] Resend dashboard toont email in logs
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — Retry-logic (verplicht)
|
||||
|
||||
Maak een retry-helper en gebruik 'm voor minimaal één externe call.
|
||||
|
||||
`lib/retry.ts`:
|
||||
```typescript
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
attempts = 3,
|
||||
baseDelayMs = 1000
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) throw err;
|
||||
const delay = baseDelayMs * Math.pow(2, i);
|
||||
console.log(`Retry ${i + 1}/${attempts} na ${delay}ms`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
```
|
||||
|
||||
Gebruik:
|
||||
```typescript
|
||||
const session = await retry(() =>
|
||||
stripe.checkout.sessions.create({...})
|
||||
);
|
||||
```
|
||||
|
||||
### Demonstratie van een falende call
|
||||
|
||||
Voor je `APIS.md` (zie D): induceer expres een fail om retry te laten zien. Bv. wrong URL of fake timeout.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] `retry()` helper bestaat en werkt
|
||||
- [ ] Wordt gebruikt op minimaal één externe call
|
||||
- [ ] Log laat zien wanneer retries gebeuren
|
||||
- [ ] In APIS.md: 1 voorbeeld van failed call + retry succes
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel D — `APIS.md` (verplicht)
|
||||
|
||||
In repo-root.
|
||||
|
||||
### Sectie 1 — Welke APIs gebruikt jouw app
|
||||
|
||||
Tabel:
|
||||
|
||||
| API | Waarvoor | Auth | Cost |
|
||||
|-----|----------|------|------|
|
||||
| Stripe | Betalingen | secret key + webhook | 1.4% + €0.25 |
|
||||
| Resend | Transactional email | api key | gratis tier 3k/maand |
|
||||
|
||||
### Sectie 2 — Webhook setup
|
||||
|
||||
Stappen die je nam:
|
||||
- Stripe CLI installeren
|
||||
- `stripe listen` command
|
||||
- Endpoint code (link naar file)
|
||||
- Signature verify (screenshot van falende verify als bewijs)
|
||||
|
||||
### Sectie 3 — Voorbeeld successful flow
|
||||
|
||||
End-to-end:
|
||||
1. Pricing page klik
|
||||
2. Stripe Checkout (screenshot)
|
||||
3. Test-betaling (screenshot)
|
||||
4. Console output van webhook
|
||||
5. Email screenshot
|
||||
6. Stripe dashboard transactie (screenshot)
|
||||
|
||||
### Sectie 4 — Failed call met retry
|
||||
|
||||
Voorbeeld waar retry redde:
|
||||
```
|
||||
Attempt 1: Failed (network timeout)
|
||||
Attempt 2: Failed (network timeout)
|
||||
Attempt 3: Success — session created
|
||||
Total time: 4.2s
|
||||
```
|
||||
|
||||
Welke fout je induceerde + waarom retry hielp.
|
||||
|
||||
### Sectie 5 — Eindopdracht-toepassing
|
||||
|
||||
Concrete plannen voor je eigen eindopdracht:
|
||||
- Welke externe API ga je gebruiken?
|
||||
- Waarom die?
|
||||
- Webhook nodig? Welk event?
|
||||
- Email-flows die je wilt?
|
||||
- Wat ga je doen aan rate limiting?
|
||||
|
||||
Minstens 200 woorden, concreet.
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 800 woorden totaal
|
||||
- Screenshots zijn waardevol
|
||||
- Concrete code-references (links naar files)
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — OAuth login met Auth.js
|
||||
|
||||
Voeg GitHub login toe met Auth.js. Documenteer setup-stappen.
|
||||
|
||||
### Bonus 2 — Idempotency in webhook
|
||||
|
||||
Sla event.id op in Supabase, skip als al verwerkt. Documenteer waarom belangrijk.
|
||||
|
||||
### Bonus 3 — Rate limiting
|
||||
|
||||
Voeg Upstash Ratelimit toe aan je `/api/checkout` endpoint. 5 calls per minuut per IP.
|
||||
|
||||
### Bonus 4 — Refund flow
|
||||
|
||||
Webhook ook luisteren naar `charge.refunded` event. Email sturen + DB update.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **`APIS.md`** in repo-root (5 secties)
|
||||
3. **Working Stripe + Resend** demonstreerbaar
|
||||
4. **Screenshots** in APIS.md van flow + Stripe + Resend dashboards
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — Webhook werkt + signature verify | 3 |
|
||||
| B — Resend email bij betaling | 2 |
|
||||
| C — Retry helper + bewijs werking | 2 |
|
||||
| D — APIS.md compleet (5 secties) | 3 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — Webhook + verify | 30 min |
|
||||
| B — Resend email | 25 min |
|
||||
| C — Retry logic | 20 min |
|
||||
| D — APIS.md | 45 min |
|
||||
| **Totaal** | **~2 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Signature verify faalt | Gebruik `req.text()` niet `req.json()` |
|
||||
| Email komt niet aan | Check spam, check Resend logs |
|
||||
| `stripe listen` toont 401 | `stripe login` opnieuw |
|
||||
| Webhook secret klopt niet | Stripe CLI geeft een nieuwe secret per `stripe listen` sessie |
|
||||
| Resend "domain not verified" | Gebruik `onboarding@resend.dev` voor demo |
|
||||
| Headers reading errors | `await headers()` in Next 15+ |
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Test Stripe alleen in test mode** — productie key kan echte geld kosten
|
||||
- **Stripe CLI is essentieel** voor lokaal testen
|
||||
- **Test cards lijst:** https://docs.stripe.com/testing
|
||||
- **Eindopdracht-tip:** kies één paid API (Stripe, Resend, Twilio) — laat zien dat je productie-flows snapt
|
||||
- **Webhook idempotency** is een real-world musthave — bonus laat zien dat je productie-mindset hebt
|
||||
156
Les17-Externe-APIs/Les17-Huiswerk.pdf
Normal file
156
Les17-Externe-APIs/Les17-Huiswerk.pdf
Normal file
@@ -0,0 +1,156 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 12 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521112116+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521112116+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 17 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Count 5 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1794
|
||||
>>
|
||||
stream
|
||||
Gatm;968iG&AIa;lm4G_3eHlG!9VWGVeHdiGhOEDP=mMB"\Xe';C8AVEp7N2Je*Bp(#)GuGsl$HI(KdQI_0&Sn\_s,C]j0A%d.aI0Fm[n@$4'J?bu4&kUn&aE@kZC_GHA6+Gk&e:p6T+@RX85'*a2+@^H8P(bm?l^i?!t9LE[Uk!d4=BLT=k5R`R9;>9blU,YZnP/Ks=a86,=N&*+1C9S2mm/IY+=0TLiO")EK/ClQ"2fSK2T:[ap?+$t4[]ZV3rb?5<X]tS;\V?$cfgXBN9l$*bg4$@s[GBK@I1GkP0Rr&':%5!8M%io2ia:@Cd,&uY]\2'9[`<fgY4==O2?EZ)LH6V.5$4ff,<CYb?H.UF*\#mRM561/A#<)\,U&[-pL\IDap9'N$F7`K(__SB0lq\o9G*t$!Zm?+UgN5KGtAcG?5M"^bY)V7UHr#u;)G&`mpZ1aE)I+]BH?%p'4qXhkW?ge&REm'4H9BeG:jG)BJ]=PCI/Ji:%4]$_^_k69:YI"idLP6)UN.op7HbW?nmKU1FtL4$'/5@aYN"Gqt-k,rW[KnQRs%VHq9Sq#\/`J/[te?-;pk-BaApF43$f!$?RQ03g($bi[,r0bZTJ#TBu\9mM:8&O1)=T''psi$_5eVXhWofir.srfdn4>&k(?3AM?]Q$[f\/MY[L#&a`nckCL(hc>N'LD;7oQR!oFSeGgU'$3li<`#\+CddcANCRZr7]X0=&/>&As?pM84(_tg2m@f,[',0<j;cVAph2:H8ja+C1(MRC<@b7d=JR2X^:cF+;r@,3.b5s,s[cQOu4]MSGeF5A7hsD2(s4]#.5NF-?<Rj:ocqjHncbD*)i6"i@ni)?[Y.b/?iCJfdiAej"Ah_Dh@Q5p#_CrE+2*\fDiP+=S!um=Ol)=Z^#d-rNL"+fYhF5a+(+U$)l(YD,r`KV3O`+MSG3"O=Ppb1D%TP((+h;We_A#ip7*F@45BGm5!sm>T8&oV(HgsrAL`M0[>Gsp3n.F\r3\9L0BkM&_C\[f/b357'P@Irj65)`Vb`IBbA4MgTF`cP=2)0T8PFan/9GVmAN1WXQ,j\)\o783;>g&6m.U."Gj7LIRgiq]k$orZ7mIp&"_KB)G><?'C9a//AKecA<f[eSnQaOAph%\E=,qNl%+tp%u(U$lJDIGb+nEa4YVJT*P].FLEMT4jCgY:_rg,,q,B*.O[?.2aEXJf0F#.=Z(Qcj-UXAeJUB!o<$XA_c`*rf+UU9W2C@Jid$YcK/5d8!5?8/NM8<7hmmEU8HeL8>aq0pQ>pj(;ETWg)LA8Na/t+tph7f%[@FnKQ19J_#0a/?dkJDnmdBd(jX+6k%tYn</)?lF",C+V*)P@-"QF].ri'BH<++5O6/mRkSou6mi*V29oJh7t`SKW43ao0tBWfqI;^<:@PmH!rKYE/V)oXmn+)Z`u\WZQHR.rYd#c:q5R-C*@;n0S,DD1Ho]*\[`C25^-$nH)j&SE5l'$=h5:k_G'/XAVnm0u>eS$Y<)g>@>!T?6^BGZ0J_gq].Hqgq&^)R4Ogk9FE).`Ha6))XIFQ6HNg.,75nqD):eC=Z@`C9hju16S@>sbkEuQ&b3W?i#*[eiLDh45t<1(1ge](9hdf;ji/f1uDpcsJ6_uARg(nQ[jll*JdU8Po;D^Ye?#iWYP7Fe-@qsa`]l0e2VJ'>,2%)^OYi^Q%jkN%),G'NriE-,/F]PZB[Nk8Q;PVAT_<:rGWQa=+eUb"+K<R\YrPg]/FS"C3jI,DH__8WnpY.I1Zo2Hg]'1$i64pS?+DQCE4DR:P_#HVg54o~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1360
|
||||
>>
|
||||
stream
|
||||
GauHJ9lJcG&A@C29QG02KHVe%UqG>IgNCl9`?n<l>:^ni:hA?]@nVhM-Ok\*pDm*Q[bEjsmNIm)=VU\o]p[gf),Ru5qb<@VDoQaB`di%G:Orfr@/L$pA+.tn>]M+l+MO_%aFT"nompT7AIqBV6QS#0<"Z58#gJ1VTa$PCP[Xt,"4L<m"/7&@=ocr$OBVXfhC"J-1bD6@Abc6#L7bX4Kg[icOCnG[0V#rt;&uYi#\APo@(_0/T,3!L_n1;.b%.+,$i%m\-CJ(o3*5VGr8`!'VPQ-`pqtmT7D;robT=Uq.u[h8[^P[Z*t[C0[>Y($P^n/=Xjcjt'K7o;ANWVl)#+ln7s&L(,^I:,KTbg9h4#rY_C,d<HE=a1T!(9=To03fE'/fM-^:XEh2O!KTkUoR1Xq+H&h9j6*msAF`7kDi1e\\>A"!OZ<oL0sGsL[e*J854Tf*DhNtJ5]en_htUDmEo7lOrops="teo0&Sr[-LWF/`/FFU"A-q%1+OVF3@7Z5'm2KUd#gS0J<;FkkF,.PE>8_VE'C;j?bSdir/9@:m7,dq8+=\"BQbLWDIFfacrJ/^fnuYY+Z:MaAj)1_^\Kr`Mpi"#2$jYp@Xf$T1'E%[jkG$qGqMOXI"WM952,p%[hMEqK0Ro_*#Vrqq6;cTu[&6\[$]Jl)3(Oi!s!,@eGJj`1*X,qrV;M,!RM/haBJd@`2&6b"n\CK&sN#Te>O#93iLSFHVFXn#+KGsIAZI@OKB$b+&`KVCcI&qS`/=h"Q7VDH/J"uTQtjpEY#+u=kU:!?@O@M[^@@1K'-K0Dq5P>KAep;0qI@ZoDQ',34%T)(^>,#lsUXt:]@]gW%?AC-d!g*k+E-'3KF"\[]H8Y#E^SQNoW)E\NLqRG(E$uiH/6?$3*mMLD9?Cs]*hHaC!Dpn"1H#tSiINYkY[-52CPT^W*CYl.;Jg@eH*+NZ>@L-uId%=Bj,*bmR)(c8O2KScXb*4FKO)]DTM;$sW3QAp4>)[qk"@4J!os,84OhT6qalb&K5#FN,H7LlEKcuA%L'd'q1d!n'_b'1smcm"%*(tJ*3?,b'?7V"_/b:M34Q:TpdQ96&@X>0$Io+\P/(gcVDC0MXc0tkPl4_)0pYD]@_<'A7=`k!h-/97)_'gm02VbRID0+1l6b6im]%:dt^]m(F,k$aNm<X`t%F+hVSNSfF9QsGS6qjSn,^kCfO(l%6$gR1%E4-/RWSYBtZH4RlEG&>X%(k,]#%,-8VN%]bdWF<2<OahR3_aTI=3Vr/SmeMB8E9@(UhsrHP-eb;P+-i98Vo;-7:FkTY-8`#fs36SpN\KmH;8I4&UdF?$VLA)9&J4m$>c;H9rg@D`&@V2hFDd@pFhc.oV;~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2090
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>EUinABiBb^hP`7;eugVDXR4`e>;<oY/'CVf6nb\g-Ok\*pDk]>QJEfQ$S4_t3C,3galf8U*KL`KJDq*gJ"m_9fbem>0OPm?a1r\S_"\Y'`n"d\E0'mV$cEh1%QKQc<\GjrX:/?pGQA<l#jN)^45^B6KEq^"SEKdnLI/lJ6]2!84mEC[Lg[A5]W^JP`8_hC42gg)?upe7:FLMinJZRAZi3BI*5S):%'&\+b$<hXE=0TBJ,F]t0Coi[7e8`<i30/BdCb&;k<;!kHMk-$j0Fh(GV7<ROtT_$<SK2kL%.OlX+Tln/ob^mYN*B\,p;*3bIG3H`EA2t2EZi&&F9nR4'^8Nal<h/T*f$Xa=+`C`@W$17bEaaG`Z+a-2"%<,o64]V2jcl\7e>lK_Z\X>249pA/Q=M0Ys-.18Y&l"YLX.S(2!WJ^tAU_=fmo(k0Y/?W$toNL._\.i&G?0I3.0-B(^\OYsUi;5p0KpU`(Xn`]92LhQ(KPI#cn(2tmV+g*D%Tl]q/P8D+$A_qh'i-QA,+T-#&LD-!X=L`/Amq!rS)-k8D/5OG=%H@R<_fJs3Or.tQZJARu*nE+<D6E2V;=oB#ah66uU$Dch*oSu/e8frOhM&t8=H9%hilYf?W^j!TrN8Sr@j9+K*1I)\Fd[u!D+K\45(^6$IPYZVTX@1FU2M_&nV-1S9ik_Ej\&U/=N*O,>hf'$RapGXeR:h<P=KODNdrpG[>&\#[\IoQa;-4XZPdp/Q,K.r5s)E$<tg(7pS0rCDk;H+qY'.5n"Y@Tc@F"&S0W@o-@HbUYnQ?@pVSf4CtpjBi,pj$*%Z#h$BoIa\^H*="(P1G2]k`5NJ4ONkfVL?B:L\mBg)uJio5'B(DU1@ZYco)\B%Sce-.ONEd%gDl4W6iNeWG^XK7l5JHO!S70>Rg/rDcSAZ71h]hl-agH@_%)ab*gp2TsX9U>',fX_c\h-E"_YQIVicoZl*R*?7Hli8)!Gg'_6W@eu"%LrtV&C"d.Q7jad,/#t^8<g$17,j=gfXmW=(D+V!Jnp/#72WM-'<,/A8a=C#mRD2EU0'42!da8.@.:UdBtR,J5L63P=NodkjOXT`-I32-4*Q<<0(Eab1mh:W#:@#n=llV65&-*^4ZKlEYRU1>W*E(nAQ0hf&%]6f1d6VJl;f")[5J+@YL3Q)p?1eH#2@kh`TR3=iA3J%Za1"ao=TTP$"93t1/U\0p3.=dZ$2\com1K2fQu&=DHkHMT(B1WN6PPbGE-%DgP2I?;ma$rZ0Tk01l$:';Z1aMK5ihK%EXSJD<D;2776Zi[<)f$)H/0AiIq%+XX$cQ\-uA0`u!7?[t(f-n<#p'0CiS,.?m8(/XOFM=%^#^YQp]]8KqRmX"_AShF[mh1qgY+=2)Bn<\3*"<@NeFh+^kr`jfs%Cabj6nEr$Bmg?3X%q]61f0'E:8=ihS)i$K1PPh6Sb4iLW%VD2=42NVqs-NTV.eS;gc_Z(j>cLDPUMl@kbf/$E2?&XQHlTPW/+D,YFD[QCJK5TDa-fj:1(9+1#sRss#X2al.knR*Fl.dH#iaj4lC>sL>4@7-AJW,P15dUr"t3b2PPaCXafn;)q&X7hR*mM<*[Z?A%0qZe"S*6;5Qp=3U7(3.12&AYWflV@.T%tTI5>/69D's)HKI%8j`?iCQ`nqM7Od_mmbfi,<]cmhmr^$GO%s2pT`a1.EMGF[$rJB=N1T8II+pT2i29)+E)$0O].tAqV=r?&b/CU,TN7;tR9JDoE;^5$>X:ee(!Vl7/;em#?%K+j"2r5>3S"hrUusHd`2_rgIK>8ZUY=>lGR[j6%q[;"\7j^Z4bVEq2(+=[@P["+&GP#ff#7:i4\m>03):m=:N^;rE#$88@+W"$Y1bS4eLf^qQ"G$,QCiJ/D('N!WcOs'2ba<IL5"=^V6%[V.e,QNL7LBRa`akCFh&!1akfkU4ejrXGF^odI8go+0c:Ie=Wt[r*_mA/keE$ueTGeoa+LX6o(OH47=D?.DjJs=J_\a<>'J`39GXp!'jXjKLmh#nFc3TkB9o#/oSqft:5`\B\%d:kBq3.V(a_46I]6k?8Si06Ef9&_lX)FVCbl~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 497
|
||||
>>
|
||||
stream
|
||||
GauI192FS%'SZ;W'kbWB_Y?0@ATf'pU/JrtA7ie-Ui$I60pAD%r-R4pTTfd!/1.g<Z1#6\\L.M$(A2=S"Fd7.4sQ.S\c>;KOj7p77fOYZZnPC/`KnQ$!))=rLSflB.*ZiX&q]5"=X,(A0l%F1&aG;uK>q>>(_N._gfOkp8SP7"l=6]F#d#CO=skEX[sW@OQS?+L$^egA@c7K-9tdUIP&XAh+^O*cipuOPkItnPId_]=5@*=?q[U)%(egesPI<'tcHU@geunR>936=%O?OdMh"2NLb?-J:F0ho3F"II^q5S8'?1U&>?=cQ.B94@3lS-gO;A3Y['pg)&E6\:G_,MKampcsuTtJp23V/s*MjQup*-/rCHm[],qk+I;ad=*e0k<sO&Qp%F_QZ6:Vd8h\0@4uOG9P/4.DqM[GNW"dX$:G=I/QQ>hf:TV0tEb0YiYdaGd[7Z\T=pBA"D.PA7ZL)Ucb\-r9R_(TO$ZiC\MMFqh=5K#9(rY~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2069
|
||||
>>
|
||||
stream
|
||||
Gb"/'939k-']/^ggeHt-m3!t5Z=RXq6T_Yd:=$;$P]!e*j!]b:?e@-%rqj7s.o+.f37(YsLi$;+O&,L!$7:48oc(gLDhi<0ScWIWS-$*T%Dtt\2Df:[_Aq^l*/Gn7b@<e"9E.:A1D8UUEBNA\&>i*RCP+2e&],3sE1Q;m(NKi2<sAif3i4ED+G+W(3-WqX`*j'WgOqYK\%058:MB8\i\])WR.V8Pi=+EgePW_j@T)W7(re9)p[coqHq\1Y#j,h>_)IY&@'3nUQj]hT8#Jcf(.q?[Dg<k.1lGq>?G^X^_cN:.O1oIN@U#_A:B-M/!9O/]L;d'Qf/;A;r[^`ji&sD6&_*,0_\u*P;\2Tc%-"sV)\sVB/DIEML^h?qIG9`M#'5W8g7gOG6o3"5e9+lCKXZ"(UDq)i.O`iC\fhUIGoSm!(9QbA!ji%1$J=3*#0"&np!W14R2qX00NV.?ps9?#/FM$OBc\+4UG==tWV?De]f)J)H6"1JqrmF87k"$I'0q\_-+KoIm-ifF&6cuaH0s"'.f(2pGNmk@E9a.UY0fK4EdgU>$&f(t1=)[+m;;LdO=`G`g"CuN5H)?_2NC%:%[GM3[F8la#3Q;=9U6a]BrVGDKo7Ao1/OYq:=f463HtJ>?.S%P<j,a#4$K_R%IJHB?75)7e(l?4>F1nDSKcd"j(U*C]B!]N/##2b@.7Gkf+N$a$<X+c')X;5R2kC$G[3E,9#3g\\]Q#;#\'dK0@_%K6]`'uVgJR$-\6g(!s85%&<&@3?)'EY1>Au'V96\L_RiV,W38lA=W/Uo'q\$E7LK^(5G8@'.?e54mK8)+#oM5V^u17Uj!mdpZ'<W=MD>HJ;,#.=VGXq[%LcfdUs^L#<#2aUT\'?uoct[4pAi$U*!l$30Z&2Rpebnl\Q&CNm/YpbGX^j*DZ-B&gn;+]"SsAdnG@a?"Tp4aJoDJPP;Jci5/q3iA"!-p#:-YC0J(s4!](o6Z4.s4Gls%Q]n*L,@6N&I<4p&_+,:.Vdqn+f/.p!\9K(=Lf')FSAmIa]3c4.eq7AP0VV6Qip79pqm$VXA[qS_UBHV#d_DFR9g0#;8q:XnJCBtGCceNdQ-QH"8h<B[(O.>44olrWuDY\[[ZRc:sFI1b/#Q71/)P7P6qH/cepe*9gMI3:=MV#c2ZAFOH<66E\$,KjNf78`2S!6#R@*=Z\&l/)_AXW@(^Q<eBB'-9qH,])8.UVsC657G5_K]61Qu?-$r*g.M#020.*`kV&jh=?=rB`@P,uM6*IpC'/M.M6upN;K%k'fLX,d53B]iJ>FF$H'\1;A=c[j`\L[=1XEB8cW'F7)HppWjlQAX39?m1Q/pg/Fm&$7hR@=C=a??6@Gqbb^kQ*&s]JoqBOn@-39uh3gq=<](,QI*3Mhl,+E=IH0%_nsk>c?1nQ8[Q$gEmFS>nT_5,5gE&1!<c?*l4B0tfPjsd_*YYXa$\WEsQSQB:hOD0GM7l&7c2+[.'`Nn9(^g:XlUG]pMK"mVW^K;@<pPi,%4Q)i49mR9<>O2k\r_X6@&,DO@2pn-/<q<b`U7V=%3`0<&b]TIN]fkC2jhc:P;t*NQHO5E*+72+fuXBA\N7"[R'^KV5oOBu`9]5tnFiqnc%3<CCT-X=cDt7^;s!o(!E/SiCA+N=L]%JX(DoJl?l"&8F+nI2Vk=*>'eVZeKYt*:]m+,4qD!TFh3ih6&)Dq>Fi`.l5TS]U\1q<KV(Sd45E7"88Uq7u'P@42'e6r#;tJOED:^!B[fi(6-b3:(brGp-#N=o9m?:%;4aTW9ZWGZH5/=RNM&M7rCUQl09e!jca4FoZU&Ho"3<PYdpK*Fn0=1n$l@QNl0&sAFUk3q.TDCjjCP0R=.qblQIS3\V]oBI`<b9+mF9."EXRC9BI@\AXN5,%\lOphuV!Bi(c,rB-;Qg,O2%3`-KF!s>IB&L>%n6G7Lm%e;d9uSZP-F69Dm50q'0T7`Wg_:l=Oj>O8u1aO^7UoP[:td!nXj[2^nSKo=P>hbZ;#WH7K$JXmqIO;b2pX+eaZS",;YSa>0;55!nCi7NpDMkUEuhaj-(S[$<qP]UN?RtInk!aqu~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 18
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001531 00000 n
|
||||
0000001824 00000 n
|
||||
0000001908 00000 n
|
||||
0000003794 00000 n
|
||||
0000005246 00000 n
|
||||
0000007428 00000 n
|
||||
0000008016 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<b76a3b1dc983906e6c703e8da1e27798><b76a3b1dc983906e6c703e8da1e27798>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 11 0 R
|
||||
/Root 10 0 R
|
||||
/Size 18
|
||||
>>
|
||||
startxref
|
||||
10177
|
||||
%%EOF
|
||||
204
Les17-Externe-APIs/Les17-Lesopdracht.md
Normal file
204
Les17-Externe-APIs/Les17-Lesopdracht.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Les 17 — Lesopdracht
|
||||
## Stripe Checkout setup + eerste test-betaling
|
||||
|
||||
**Duur:** 30 min in-class
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Werkende Stripe Checkout in een Next.js app — pricing page, checkout session, en een succesvolle test-betaling.
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Stripe account
|
||||
|
||||
1. Maak gratis account op https://stripe.com (skip business questions met "test mode")
|
||||
2. Dashboard → Test mode (toggle rechtsboven)
|
||||
3. Developers → API keys → kopieer:
|
||||
- `Publishable key` (`pk_test_...`)
|
||||
- `Secret key` (`sk_test_...`)
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Product + Price aanmaken
|
||||
|
||||
In Stripe Dashboard:
|
||||
|
||||
1. Products → Add product
|
||||
2. Naam: "Premium plan"
|
||||
3. Price: € 9.99 / maand, recurring (subscription)
|
||||
4. Save → kopieer `price_xxxxx` van de price die je net maakte
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — Next.js project
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest stripe-demo \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd stripe-demo
|
||||
pnpm add stripe
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
NEXT_PUBLIC_STRIPE_PRICE_ID=price_...
|
||||
APP_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — Pricing page
|
||||
|
||||
`app/page.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
export default function Pricing() {
|
||||
async function subscribe() {
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID,
|
||||
email: "test@example.com",
|
||||
}),
|
||||
});
|
||||
const { url } = await res.json();
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-12 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Premium Plan</h1>
|
||||
<p className="text-2xl mb-2">€ 9.99 / maand</p>
|
||||
<ul className="text-left my-6 space-y-2">
|
||||
<li>✓ Onbeperkte projecten</li>
|
||||
<li>✓ Priority support</li>
|
||||
<li>✓ Advanced analytics</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={subscribe}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold"
|
||||
>
|
||||
Subscribe nu
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Checkout endpoint
|
||||
|
||||
`app/api/checkout/route.ts`:
|
||||
|
||||
```typescript
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { priceId, email } = await req.json();
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
customer_email: email,
|
||||
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.APP_URL}/`,
|
||||
});
|
||||
|
||||
return Response.json({ url: session.url });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — Success page
|
||||
|
||||
`app/success/page.tsx`:
|
||||
|
||||
```tsx
|
||||
export default function Success() {
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-12 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 text-green-600">✓ Betaling geslaagd!</h1>
|
||||
<p>Bedankt voor je aanmelding. Je krijgt zo een welkomstmail.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 7 — Test
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000:
|
||||
|
||||
1. Klik "Subscribe nu"
|
||||
2. Stripe Checkout opent
|
||||
3. Vul test-card in:
|
||||
- Card: `4242 4242 4242 4242`
|
||||
- Expiry: `12/34`
|
||||
- CVC: `123`
|
||||
- Postcode: willekeurig
|
||||
4. Klik Subscribe
|
||||
5. Redirect naar `/success` — werkt!
|
||||
6. Check Stripe Dashboard → Payments — sessie zichtbaar
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Stripe account in test mode
|
||||
- [ ] Product + price aangemaakt
|
||||
- [ ] Pricing page werkt
|
||||
- [ ] Checkout endpoint werkt
|
||||
- [ ] Test-betaling slaagt met 4242 card
|
||||
- [ ] Success page laadt na betaling
|
||||
- [ ] In Stripe Dashboard zie je de transactie
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1-2 — Stripe setup | 8 min |
|
||||
| 3 — Project setup | 3 min |
|
||||
| 4-5 — Pages + endpoint | 10 min |
|
||||
| 6 — Success page | 2 min |
|
||||
| 7 — Test | 7 min |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| `Stripe is not defined` | `pnpm add stripe` opnieuw |
|
||||
| `Invalid API key` | Check `sk_test_` (niet pk) in `.env.local` |
|
||||
| `priceId required` | Check NEXT_PUBLIC_ prefix correct |
|
||||
| 4242 card geweigerd | Check je staat in test mode (rechtsboven dashboard) |
|
||||
| Success URL 404 | App URL klopt? `http://localhost:3000` (geen trailing slash) |
|
||||
|
||||
---
|
||||
|
||||
## Klaar? Verder met huiswerk
|
||||
|
||||
Het huiswerk voegt toe:
|
||||
- Webhook handler met signature verify
|
||||
- Resend welkomstmail bij betaling
|
||||
- Retry logic voor externe calls
|
||||
- `APIS.md` met je eindopdracht-toepassing
|
||||
|
||||
Zie `Les17-Huiswerk.md`.
|
||||
137
Les17-Externe-APIs/Les17-Lesopdracht.pdf
Normal file
137
Les17-Externe-APIs/Les17-Lesopdracht.pdf
Normal file
@@ -0,0 +1,137 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521112116+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521112116+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 17 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2111
|
||||
>>
|
||||
stream
|
||||
Gb"/'l#5<7'`Y>Nih8VgoJ"0M#8NoLdPTd3AXT$tm03\I'UaqDbE='kC&?fV;$SO3Q]n9A<84XqlQ,'Tr>nN7'YgaKNu%e78$Mr`!TPm1!mDrCN"09GjBOZtq`Jg\+@8s<EZh(&+"5F*'Ki#_+V,G])A!dri(?J7$mfj4(Bc:>6G]"T%Kd@=1ls=$aZNr,=2kX-F,m8+iq\&55/+F#C@adO\(77@bn#M:&(WdQU^EGbjolfb(9hrRf;A`G4Zc_V4O(/75$!ApV\P_B*l.3[@(.\"AYgaC:0jkFb82Y1?GPc3(qnG1HLbA0&g"X#Wr,4a!nrQn/>!W=&Nu)uB5%LM-gLl[CF@?hXg,TJ,R?PalZ)6d4$J[/3,[Y31JK%/6:a*/F\?WVHmuQWq[bid^mH7oo9^S7l[0lX(4q[g=P37$b"POroU6Gk<G$!EPt%m]fV)tJK)]]fp7Z76LX+DcV'\l.0$ng&E"OsH,gOEiq9B/$'S5WJc3$BZoq_8OLM\]>bg^=`9u6UBC8dCeXg7uo],'N@O"nbX@gGJ`KbuIKR-AN&%GU>KLT^daJ]6B"l(JrhS-=KP#gbt3K<UEr)S&!ZY6I<f's+8>ENA;bKUca$;"@1Xb]3l6;\dWbAHep1Q8uSZV$[VmW)Qn/c;YN)Zh41@=@!CIjr98EIKpP^?6Dst$]!<Xh6`i'['lMEP3^-J'[pY"]%-^ZW<G#RS^DN5$E9G6lVfXt=Y[L+<JZabZ`pEF[OV/lDH^%q:-?UQGKXR&JJ/Rb6"&?jdUf7QR&,EpM9>CHV@RG\c"_S+-!Xn]XKAqui%rj@C8O#j%4f3E!@2(SXrfR,K<Ye32o(3'7c9!lr(J8f6PZ.<$3Ml_+AQOZ:1'HLO<Aqg7Q#\jSrg!h`.`,f.('l,JKA>oRR:=1`8alZ^;i2K+%\!1e*PHlTit?Q%hG.DgS+G@[@K$GiU]]5S`_Jn>k*N$1mA^;%-,l*3[%pqGZs+sH<`MH6Gk]#LD>Dk[CBAU$5*f"V1D/Ha-oqE:4A#H9I2Q8Zp(t`D1Dm1:=bN/c`3A-ZO59_#8cu&IB1H(,t*%8OBl2:@UpP$UN?)H@`qXdH<L(_b%2QnnhNk$26J.t!&CAr^`7hKOBkX+U#REU[0I8Q[SjWLlXMuo[*ZIS3_W/5JuC2HF^2Ag1cbHG?fQ9W@`R!W$YGhg\!niU*.cZ[fpGn,5/r,U,";[HpV^Lmb%r:[JuI(kZ_M08Vq/q4Y]6H$H(-YBP9-cEJ_L]\9kMgI_D!c#PUe#VBUtUgHS`O-BjQ;_jOZs<G<4>d5*qH[+0WaMWM1@OCRhe7N8cg$Y*&K:6mP),LjS2;A.m;[oSk9Q+=47mi&71TG403(h('`J)+F.7(*LeoXQP;Uf`FpXb5!TDZXl-i1&Ts=M=H<d=J!7:d2]j%HOPu2"s3AkH2K9EVe9.39OR>tN6ikQA4TIn>?,iqhQZOW.aPCnQ/:fiU\H8rnFM3K2HpmNeS.6lLBfM7hs:#^QHsNZ#N^?7)0Ch,^V#!NS3r"nOhdgk-Ug3J7Im@L8D]r*Meo];S]NC0S&OYFNoYa[`4\.YIkm&V*u7K96o9HORG]-_0&OMTO$if6OPg4Ts!Wa?47I&gW5WS`J38\:B0Vp92^,d<P'W,7*IZ9D@2ftZPYPP#-=7P-qDn6SYdjSJ=SsGU<.3'CgLE,?VE^Pk2&Cj`:;*9)rM3[MSq="3_q!R'WkQ`gle7*PdC[Do`WMta),KS\Bht7ZCG3-gD-sMjfqg@iW#fWQlr>.$/e.Y7ZKouO"2mn:XqTrVhAm`1]dUo3FMb_qm:7b%2(6T_1aE,OHd1%IUK3^NKsX$?CMQ#gH=L<c7>68r?8QsK.(W&1%)+uGhQ0kOjpQtQ'ibT=>dFGPI"+j7^%lEPhLgYgA2"j"hfDf;=B"HN1eYR.;V\SC!S:\6GPX;EUV=+=ou3-/f\6Z)D[C2@UIdE@:.0BO+R7f'7oXe+Ot;WVS$0Ma-7K30>rV\<h'^82alrM-\3E11h`K[:NW7Z2k=p/P+GAMPP=PM"O2f(uD\!BP`i[JDn\O)]pu'tt-GS@cY7K1>iGe_bq!cP8Wq)_Jm2AOEXK6RJV/ul;i;<g?L&Sd~>endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1207
|
||||
>>
|
||||
stream
|
||||
GasaoD/Yn7&H9tY)#7*.ou=VeZ7inu\i*9*5"/&GG#.kK;FL"8VT%HpDAktCml+V_,`TYYb:LQT1XF?/4#.%1(U&=`28FtbN'D=?!bP*`&gbcIiG@Y)pJ<93AH:p5k$%k"rTXL\Xd;AI:g%3e(oG\o$.<cL`YoC&V`!(f4'95f31s;G)[U2&TT&^nhFAjpV3VrHAY6t$r(KQ*U2&<mc2F!EaFcF@C/KD9N!DCofc#l'0)q?TT@p=sU/dL*n>4?7ZmoK:KSK5bhqf/<>a0+,+5IHoA=Ku"b@?ofL"WLV<tAXFM8Dm`)#rA+5^_g^D&.EL6=5GUE>jGm@JroC4b,!/OO_RJmk[]:Gd6J(:W5'&<)UQd'*7NfXY''r7`M7gfn=(P,]^"Th\$oV,-SE%JfDucW7GM$cN_a*qg@k]&i^^P,'[3;*iHK[Hc]G6en0b$f!.d0TdA@p\(g-64RuNZXfLa@[t"=TdSDH&Kr0u'YAKf'0O4Cg$.h+eWS8bHI6kS#o@&$s?e+oe`NT&7@)+@.pC[D3$o]:J.HGI_Vmil:Dr$KpUp2>MijBacj41HImq#A>JIW]XIH%X^[GIOjes:R/YUZk5[>.^)P>(^&k26nuh,1&d"bu4gI@EK=r@2gu/neqJ:+_+L6;ZK*V$Lp3mksFH=I6'$L$A<H3j)2)\!q:2X=/J#FjJ]]R]pHS4"8Dk%&g%Gb=:7'6'T;E!(O^f56BOuXY?t9egTqi0.#qef')Pb-Hf%a\7N0E8m#u[W/S(T+HH9Fr"pIZZ3F:4C>;j7!+Bl>7NG=1]d][<^2a/\8lAq'[ga$:Pbgf?;n%:d&Di*q0m%!6RNIO/i?fU9.i3WW[cQ`G+>TL+X-IL`!S4DYAW&K$]"4EW7^!'V3K4s%CbPc7>MQuE^S[>6=Hkf\.ph)YO+qn`!4`I549.9=Cc<LeRllU6;4pR3i5OBaZg4YF+3l73f@/F9ajo);e`Y'=g%FFd!*AKM"</5c3SZZS'A@9eMXh;P1L+i2LJbK$g,PWX5EI^F3j@H<GT]Q)[#i:`2Ma[(Zl<\[ddSWdIt2C-^ohN=8HHRA=^%e=!)Ws!Hu3'&]/X),GF2o<==Fnb\Q]\!i=>qa5&+G\"4HUKDa?aiqR=grkH<V&.EmiJVuc-E6p/$5Q6i)\/3Q_ekhFjIp5&(l9b5F2n_U;V(tYI6)a<&X`4JMQ*PDCP3#X*=~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2160
|
||||
>>
|
||||
stream
|
||||
Gb"/(>>s<8'Roe[3)067dR6X3CrpeQ:.f339dW\[PA-q:[`0g5#Hqu\08)+YpE',?/F,'^DI:9*CK^MZ7GkYs0VJ3cb.kaoi;3gss1j/)R%+-i7)7k8lqOR&Mf>"Ui;g+t[65#<Zcs:sF"7I8-*G9O#)Z$IU]@)*;9DhQELi=f6Di`&\]'(iQV&QS.-A%^]sliF0'=Wd&BAq,]Ba82PYoaODQOqZ=oBH[c="%W/ZLB.AWCk?Z@K'$q]A;s;_lsME1/$a]Iu#\S/-(^\V+%ZQ`osBbR6RdfD7g(8\38gnI-`d@Ii3plJW+'#ks"bNW3&BJGS7O8S=IK66H!o0_W?N@Jte,D89C_.k@SC[S8(9a',BF2EF@]_A+[S/FJVb@lkH/>S9Y_lA]d`cOtn&QhO"?AklaAA,(U(`XGg.jP`5IJJR!PeXTZkAfs>BS'R59n1!ZEdM@YI;?Dc">"!Va.TnEBc0/:O=Jo3A/+[%`s7QtX5:(kdU2#SX[Z+/$(#q2+`3kl*b&)3Gqa+EHk.B4d^0BTDK\sfdS2e_[mbYf&(Oc]d7>9Pr*LnG!!d$OtPOK@&<cZ&c2F0s>OqWcu3X/K/Xgh5K![Ecq9)?C;;H?*9N^]71e1Bs@g.6>m'ials<&5o+g+fI0;T<4r+&g*VgoRTmR7F;LXTn.u6?P5A(/h-gA(lL]:6o=Cl>3,aY4_Y:RdT3.B%Z%,)b3sA_]p48*9lUCddQ;3Ko7e0$5aR^QspEFOp*tibF9kdY>,HWN5JLS9SN`u8E.:4C*@s,Z+d:_O1*If$$;aP*FFoujD]4F2]_AS*pub"Dt\YWioo'W(kK[T<bf6Y(.O]>h+AR`WO0dnYMoOWQ>o`S8IPk$j2o\LVU'JE:nKC`?5u+\lN,rQo73X%WrOV"Y9D0-5`]M15VN$hJfD)L^\#DFZhQQVQm_FOf!&H0+j>)I/a+/pATVeY.Yj1[%Ob4>cUj44^HgrlkjR1q(JcnT!#E[Mn)+[/O7N@FNFSQm=HA#A?onG=6\>u)Z(?](?K=3;HgQc_c6qdJ5;'h4C'QJ2rYPWZ4X,PW]EJh3H''Y"2D&4H;gETf`0%tdaBF[Q)&ULjQPh"Fq;lakK!,XoVXG&QhJCf-^8lZC%;4R'n,B]'qe#m,";s6AO7qI*8$?k1*0;cmin@:Q(AMmBU\1pJ0Q!:MS+?jVAro_,F-Yu8l33S.Q<B//cVnA4XuUb:V,F_;"E48?EcX+R2a`F,86Y,.cc(<r`SK?<EHeJC5/G/t6j)0Wf`*Gis0(Ug,dPY"]mB)b9$=MuYJ;Hb-aal",Gq'A2f1]r7q`,s4'<N*kPYOJ`p!P:qVi+OjXDISNo@>nd[fdm-jfh81s(XrRo9*=V"5;GU@8T2<Fn2As#d>EKp+4-]S,GWIch5BfM<q3pMF-IJDCaJpi5eR@8"'Uhg&Qe#,RI&b!TN]%&CnR(^AMd2M\D%4U^em<']gI))O4&<%Vt9-,'$De)SBmB(5@q4SN*K,?.u<8!3UemUO/.iNEg=7`Pd%DJ?aeB,fmb_l3@#2`I-`jaWdZpVL1U&+Hti]lVKR]g&u>q7%$jE0Tnd77FOh6*k)2@ICJ:1o.u>j'_2/8bjJpEU+U!)qC/�TVXFrS(((dA:hAR%J;6\4?olp;eYM<S#VcEL'pUC96*P1^Js`)fsr$bd_/YuUJCMD]Q;mcNm$A3JAD0F6]!V#FW:V9[ZGA#ml-e&cp#4ni8O$l[+Lk)AS94j#2%K"X:tKcc_bas5f9@nc-]Wga#)VKMTLd(Fjl1V+c7,RNmC='=turoKiZb+qP'`.ITkXDY>FWrKT1IaC-e8<W<j_9Qhn0a/rFO^8H_>%O%BYQmN<`c[m-*A(T<dD"SC]jmp1^1n#FbaX)=p!0sO,h*qh(Mfp*Nk=*co\AS\U=I*IC6<!#/64^ib[n_fNml5/:ST2]0rOZ@hsEqHLLiNs6!tC6?t,S@X+M"Sl)Oj4oqJJ$cHiAb5F@m/TmU9HSjla*Qr?\hZqYj#f9\OglW`UlQ-=R5UhN7kp&5F2c+cj@j5^7P6aJ-84=%Ob,?V_P19^YBG\e4[lh*r"\+f3!HLUq2\"hb.&m1cZT--Z[F8"gk(_6S)^E51jPi=TaPtNFio)DAjkmN82>/Z!8L5q1P3-Z7U\5&&lr=)2W)d<~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 865
|
||||
>>
|
||||
stream
|
||||
GasIegJ6Kg&:O:SbR5&[Yh`URoa.I0$aci80HaA\.cY=XK.k&!+TB*][M,-'9C-o:aip0@1M<!S`J_.M,R=)a_5D2[(l)c79Fq6P0mnt4]7=k>[?*X?6UT%Jf[,,@=p,""7?lo"JYAML/O1.BN3NRHqJ"22<"A%P!<]&2eOBNn9IcfDq1h@XNhHW=9%fR1I=B26B8Po[__etE_9df,7;Yjg5q%q#6BoC]c]cNs3%PlK:6oIZ_*q;5AD+b:c8A:Up*OpQer?08(\cjnEg=82q#JeO6p!T`#[_O:\/"j#rirL)o>RP2c)Q!j4cL7l&VCp/SKb8@P1>d1,H8J-]LP%PSd2:i\Ce%PYMOqjs-g<R,2oor3:@_*<N.,%D.2U'q5Pmer,"3V@`2.nf@D=MX@+Cn`Q4RX[d0Rpm([&a0'%GD\@REQZXXPG]G%jIR7sN.]_sGUY1\R4k6bn&36a,nW3a!?NaP!Lhsl%,&7u=]O_Bd?$sT8@U`Rek![?TC9k?MO[X\e3Z&,C))Q@u=87`[$N#CNo2`n-.].pf'[]UVX"If*HF4d:aJL!liIQ5[^_eR_UV&33G'Yb&NX+4X,68`R*,f9gDRZ2J+M\?2Kj9%HW-g6I]eg'0)?+WpoPi*bUDqZ.P:YOs!^_3enTEqu20mcn.[`KFe2R+6Whr<@H\lYoXA#!#YF@ps1RMSR:G\+blg2]2O#PGuj\mXo&ek2KYJ:A(FD#_T9\Cu^2=3XtR\SGSuT@T(+\o";(ZOe-HJ"r2De<&P\Q+P&bjVC$HSM#f0\]8/#>a<U,G2^ZqSt-iY\Vim7eIuKT,#*5h1-IZGq2E+meG0c2l+Otu10MlajOr5F2:[NoK"k^$=QB~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001325 00000 n
|
||||
0000001621 00000 n
|
||||
0000001699 00000 n
|
||||
0000003902 00000 n
|
||||
0000005201 00000 n
|
||||
0000007453 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<812bc309659f4e8f5fe133c15b0b8ead><812bc309659f4e8f5fe133c15b0b8ead>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 10 0 R
|
||||
/Root 9 0 R
|
||||
/Size 16
|
||||
>>
|
||||
startxref
|
||||
8409
|
||||
%%EOF
|
||||
520
Les17-Externe-APIs/Les17-Lesstof.md
Normal file
520
Les17-Externe-APIs/Les17-Lesstof.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Les 17 — Lesstof
|
||||
## Externe APIs in diepte — OAuth, webhooks, paid APIs
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Vorige les:** Les 16 — MCP
|
||||
**Volgende les:** Les 18 — Supabase Auth + RLS
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Beyond simple fetch](#1-beyond-simple-fetch)
|
||||
2. [Drie soorten authenticatie](#2-drie-soorten-authenticatie)
|
||||
3. [OAuth in Next.js](#3-oauth-in-nextjs)
|
||||
4. [Webhooks — wat en waarom](#4-webhooks--wat-en-waarom)
|
||||
5. [Signature verification](#5-signature-verification)
|
||||
6. [Stripe Checkout integratie](#6-stripe-checkout-integratie)
|
||||
7. [Resend transactional email](#7-resend-transactional-email)
|
||||
8. [Rate limiting + retry patterns](#8-rate-limiting--retry-patterns)
|
||||
9. [Productie checklist](#9-productie-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 1. Beyond simple fetch
|
||||
|
||||
In Les 15 deden we simpele externe APIs: PokéAPI, geen key, GET request, JSON terug. Voor productie-apps heb je vaak meer nodig.
|
||||
|
||||
Wat onderscheidt een 'productie' API-integratie van een simpele fetch:
|
||||
|
||||
- **Authentication** — key in header, OAuth token, of beide
|
||||
- **Webhooks** — externe service belt JOUW app terug
|
||||
- **Idempotency** — wat als request twee keer binnenkomt?
|
||||
- **Rate limiting** — niet meer dan X calls per minuut
|
||||
- **Error handling** — wat als de API down is? Retry?
|
||||
- **Cost monitoring** — paid APIs lopen op
|
||||
- **Security** — secrets, signatures, validation
|
||||
|
||||
Vandaag focus op de eerste twee — daar komen de meeste productie-bugs vandaan.
|
||||
|
||||
---
|
||||
|
||||
## 2. Drie soorten authenticatie
|
||||
|
||||
### API key in header
|
||||
|
||||
Simpelst. Key in env var, stuur mee als `Authorization` header.
|
||||
|
||||
```typescript
|
||||
const res = await fetch("https://api.openai.com/v1/...", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Voorbeelden: OpenAI, Anthropic, Tavily, Resend, Stripe (voor server-side calls).
|
||||
|
||||
### OAuth flow
|
||||
|
||||
Voor user-authenticatie. Gebruiker logt in via Google/GitHub/etc., jouw server krijgt access token. Zie sectie 3.
|
||||
|
||||
Voorbeelden: Google login, GitHub login, Spotify "login with", Slack apps.
|
||||
|
||||
### Webhook signatures
|
||||
|
||||
Geen auth voor calls *naar* die service, maar wel verificatie van calls *van* die service. Zie sectie 5.
|
||||
|
||||
Voorbeelden: Stripe webhooks, GitHub webhooks, Resend webhooks.
|
||||
|
||||
---
|
||||
|
||||
## 3. OAuth in Next.js
|
||||
|
||||
### De flow
|
||||
|
||||
```
|
||||
1. User klikt "Login met GitHub"
|
||||
2. Redirect naar github.com/login/oauth/authorize?client_id=...&redirect_uri=...
|
||||
3. User logt in, geeft toestemming
|
||||
4. GitHub redirect terug naar JOUW redirect_uri met ?code=xyz
|
||||
5. JOUW server: POST naar github.com/login/oauth/access_token met code
|
||||
6. Krijgt access_token terug
|
||||
7. Optioneel: haal user-info op met token (GET api.github.com/user)
|
||||
8. JOUW server zet sessie cookie
|
||||
```
|
||||
|
||||
### Auth.js (NextAuth)
|
||||
|
||||
Meest populaire library. Installeren:
|
||||
|
||||
```bash
|
||||
pnpm add next-auth@beta
|
||||
```
|
||||
|
||||
`auth.ts`:
|
||||
```typescript
|
||||
import NextAuth from "next-auth";
|
||||
import GitHub from "next-auth/providers/github";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [GitHub],
|
||||
});
|
||||
```
|
||||
|
||||
`app/api/auth/[...nextauth]/route.ts`:
|
||||
```typescript
|
||||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
AUTH_SECRET=... # pnpm dlx auth secret
|
||||
AUTH_GITHUB_ID=...
|
||||
AUTH_GITHUB_SECRET=...
|
||||
```
|
||||
|
||||
Login button:
|
||||
```tsx
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
<form action={async () => { "use server"; await signIn("github"); }}>
|
||||
<button type="submit">Login met GitHub</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
Klaar. Auth.js regelt de hele flow.
|
||||
|
||||
### Sessie ophalen
|
||||
|
||||
```typescript
|
||||
import { auth } from "@/auth";
|
||||
const session = await auth();
|
||||
if (!session?.user) return <p>Niet ingelogd</p>;
|
||||
return <p>Hallo {session.user.name}</p>;
|
||||
```
|
||||
|
||||
### Wanneer NIET Auth.js
|
||||
|
||||
- Wil je alleen Supabase-data + auth → gebruik Supabase Auth (Les 18)
|
||||
- Hele klein project, één provider → custom OAuth in 50 regels werkt prima
|
||||
- Enterprise-feel met SSO → Clerk of Auth0 (paid)
|
||||
|
||||
---
|
||||
|
||||
## 4. Webhooks — wat en waarom
|
||||
|
||||
### Het probleem
|
||||
|
||||
Stel je hebt Stripe Checkout. User klikt "Betaal", redirect naar Stripe, betaalt, redirect terug naar jouw success page.
|
||||
|
||||
Maar wat als:
|
||||
- User sluit tab voor success page laadt?
|
||||
- Betaling gaat eerst pending, pas later confirmed?
|
||||
- Refund — gebeurt dagen later?
|
||||
|
||||
**Polling Stripe-API elke seconde is inefficiënt.** Webhooks zijn de oplossing.
|
||||
|
||||
### Wat zijn webhooks
|
||||
|
||||
Webhook = HTTP endpoint dat door externe service wordt aangeroepen wanneer er iets gebeurt.
|
||||
|
||||
```
|
||||
Stripe processes payment
|
||||
↓
|
||||
Stripe sends POST to your-app.com/api/webhook/stripe
|
||||
with JSON body: { type: "checkout.session.completed", data: {...} }
|
||||
↓
|
||||
Your server: update database, send email, etc.
|
||||
```
|
||||
|
||||
### Hoe registreer je een webhook
|
||||
|
||||
**Stripe Dashboard:**
|
||||
1. Developers → Webhooks → Add endpoint
|
||||
2. URL: `https://your-app.com/api/webhook/stripe`
|
||||
3. Events: `checkout.session.completed`, `customer.subscription.deleted`, etc.
|
||||
4. Krijg webhook signing secret terug (`whsec_...`)
|
||||
|
||||
**Lokaal testen** met Stripe CLI:
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3000/api/webhook/stripe
|
||||
```
|
||||
|
||||
CLI geeft een webhook secret terug die je tijdelijk in `.env.local` zet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Signature verification
|
||||
|
||||
**Belangrijk:** zonder verificatie kan iedereen je webhook URL aanroepen met fake data. Een fraudeur stuurt fake "betaling completed" event → jouw server geeft premium toegang aan iemand die niets heeft betaald.
|
||||
|
||||
### Stripe pattern
|
||||
|
||||
```typescript
|
||||
import { headers } from "next/headers";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text(); // RAW body, geen json()
|
||||
const sig = (await headers()).get("stripe-signature")!;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err) {
|
||||
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
|
||||
}
|
||||
|
||||
// event is verified, veilig om te gebruiken
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
// handle...
|
||||
break;
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
|
||||
**Cruciaal:** `req.text()`, niet `req.json()`. Stripe berekent signature over de raw bytes.
|
||||
|
||||
### Andere providers
|
||||
|
||||
- **GitHub** — `X-Hub-Signature-256` header, HMAC-SHA256 met shared secret
|
||||
- **Resend** — `svix-id`, `svix-timestamp`, `svix-signature` headers
|
||||
- **Eigen webhooks** — gebruik dezelfde pattern: shared secret + HMAC
|
||||
|
||||
### Idempotency
|
||||
|
||||
Wat als Stripe een event 2x stuurt (door netwerk-issues)?
|
||||
|
||||
```typescript
|
||||
const { data: existing } = await supabase
|
||||
.from("processed_events")
|
||||
.select()
|
||||
.eq("stripe_event_id", event.id)
|
||||
.single();
|
||||
|
||||
if (existing) return Response.json({ received: true }); // skip
|
||||
|
||||
// handle event...
|
||||
await supabase.from("processed_events").insert({ stripe_event_id: event.id });
|
||||
```
|
||||
|
||||
Sla `event.id` op, check voor handle. Voorkomt dubbele betalingen, dubbele emails, dubbele credits.
|
||||
|
||||
---
|
||||
|
||||
## 6. Stripe Checkout integratie
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
pnpm add stripe
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
```
|
||||
|
||||
### Maak Checkout session
|
||||
|
||||
`app/api/checkout/route.ts`:
|
||||
```typescript
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { priceId, email } = await req.json();
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription", // of "payment" voor eenmalig
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
customer_email: email,
|
||||
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.APP_URL}/pricing`,
|
||||
});
|
||||
|
||||
return Response.json({ url: session.url });
|
||||
}
|
||||
```
|
||||
|
||||
### Client redirect
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
async function subscribe() {
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ priceId: "price_xxx", email: "test@test.com" }),
|
||||
});
|
||||
const { url } = await res.json();
|
||||
window.location.href = url; // redirect naar Stripe
|
||||
}
|
||||
```
|
||||
|
||||
### Test cards
|
||||
|
||||
Stripe test mode (geen echt geld):
|
||||
- `4242 4242 4242 4242` — succesvolle betaling
|
||||
- `4000 0000 0000 0002` — gedeclined
|
||||
- `4000 0025 0000 3155` — 3D Secure required
|
||||
|
||||
Elke datum in toekomst voor expiry, willekeurige 3-cijferige CVC.
|
||||
|
||||
---
|
||||
|
||||
## 7. Resend transactional email
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
pnpm add resend react-email
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
RESEND_API_KEY=re_...
|
||||
```
|
||||
|
||||
Voor demo: gebruik `onboarding@resend.dev` als from-address (geen eigen domain nodig).
|
||||
Voor productie: registreer een eigen domain in Resend Dashboard.
|
||||
|
||||
### Email als React component
|
||||
|
||||
`emails/WelcomeEmail.tsx`:
|
||||
```tsx
|
||||
import { Html, Heading, Text, Button } from "@react-email/components";
|
||||
|
||||
export function WelcomeEmail({ name }: { name: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Heading>Welkom {name}!</Heading>
|
||||
<Text>Bedankt voor je aanmelding bij Premium.</Text>
|
||||
<Button href="https://app.example.com/dashboard">
|
||||
Ga naar dashboard
|
||||
</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Verzenden
|
||||
|
||||
```typescript
|
||||
import { Resend } from "resend";
|
||||
import { WelcomeEmail } from "@/emails/WelcomeEmail";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
|
||||
await resend.emails.send({
|
||||
from: "Acme <onboarding@resend.dev>",
|
||||
to: "user@example.com",
|
||||
subject: "Welkom bij Premium!",
|
||||
react: WelcomeEmail({ name: "Tim" }),
|
||||
});
|
||||
```
|
||||
|
||||
### Combineer met webhook
|
||||
|
||||
In Stripe webhook handler:
|
||||
```typescript
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
await resend.emails.send({
|
||||
from: "noreply@example.com",
|
||||
to: session.customer_email!,
|
||||
subject: "Welkom bij Premium!",
|
||||
react: WelcomeEmail({ name: session.customer_details!.name! }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
User betaalt → Stripe webhook → Resend verstuurt welkomstmail. Volledig automatisch.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rate limiting + retry patterns
|
||||
|
||||
### Exponential backoff retry
|
||||
|
||||
Wanneer externe API tijdelijk faalt (rate limit, server-side error, network glitch), gewoon retryen werkt vaak. Met exponential backoff: wachtinterval verdubbelt elke retry.
|
||||
|
||||
```typescript
|
||||
async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
attempts = 3,
|
||||
baseDelayMs = 1000
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) throw err;
|
||||
const delay = baseDelayMs * Math.pow(2, i); // 1s, 2s, 4s
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
// Gebruik:
|
||||
const data = await retry(() =>
|
||||
fetch("https://api.flakyservice.com/").then(r => {
|
||||
if (!r.ok) throw new Error("API failed");
|
||||
return r.json();
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
**Wanneer NIET retryen:**
|
||||
- 4xx errors (jouw fout — geen retry helpt)
|
||||
- POST endpoints zonder idempotency
|
||||
- Vereist user-input correctie
|
||||
|
||||
**Wanneer WEL:**
|
||||
- 5xx errors (server-side fault)
|
||||
- 429 Too Many Requests (rate limited)
|
||||
- Network timeouts
|
||||
|
||||
### Rate limit eigen endpoints
|
||||
|
||||
Bescherm je eigen API tegen abuse / overuse:
|
||||
|
||||
```typescript
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
const ratelimit = new Ratelimit({
|
||||
redis: Redis.fromEnv(),
|
||||
limiter: Ratelimit.slidingWindow(10, "10s"), // 10 calls per 10 sec
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
|
||||
const { success, limit, remaining } = await ratelimit.limit(ip);
|
||||
|
||||
if (!success) {
|
||||
return new Response("Too many requests", {
|
||||
status: 429,
|
||||
headers: { "X-RateLimit-Limit": `${limit}`, "X-RateLimit-Remaining": `${remaining}` },
|
||||
});
|
||||
}
|
||||
|
||||
// normale handler...
|
||||
}
|
||||
```
|
||||
|
||||
Upstash heeft een gratis tier (10k requests/dag). Voor productie genoeg voor middelgrote apps.
|
||||
|
||||
### Fail fast vs retry stilletjes
|
||||
|
||||
User-facing endpoints: fail fast met duidelijke error. Background jobs: retry achteraf met queue.
|
||||
|
||||
---
|
||||
|
||||
## 9. Productie checklist
|
||||
|
||||
Voor je live gaat met externe API integratie:
|
||||
|
||||
### API keys
|
||||
|
||||
- ✓ Test-keys voor preview, prod-keys voor productie
|
||||
- ✓ Vercel env scoping per environment
|
||||
- ✓ Rotate keys eens per kwartaal
|
||||
- ✓ Geen keys in client-bundle (geen `NEXT_PUBLIC_`)
|
||||
|
||||
### Webhooks
|
||||
|
||||
- ✓ Signature verification ALTIJD
|
||||
- ✓ Idempotency check (event.id in DB)
|
||||
- ✓ Log alle webhook payloads
|
||||
- ✓ Return 200 snel, doe verwerking async indien nodig
|
||||
- ✓ Handle alle relevante event types
|
||||
|
||||
### Errors
|
||||
|
||||
- ✓ Exponential backoff voor transient fails
|
||||
- ✓ Rate limit eigen endpoints
|
||||
- ✓ Monitor met Sentry / LogRocket / Vercel Analytics
|
||||
- ✓ Alerting bij webhook failures
|
||||
|
||||
### Security
|
||||
|
||||
- ✓ HTTPS only (Vercel default)
|
||||
- ✓ Webhook endpoints accepteren alleen verified signatures
|
||||
- ✓ User-input validatie met Zod
|
||||
- ✓ Geen secrets in logs
|
||||
|
||||
### Cost monitoring
|
||||
|
||||
- ✓ Stripe Dashboard — usage + revenue
|
||||
- ✓ Resend Dashboard — email count + bounces
|
||||
- ✓ Set up budget alerts in services die dat ondersteunen
|
||||
- ✓ Cache externe responses waar mogelijk
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **Stripe Checkout:** https://docs.stripe.com/checkout
|
||||
- **Stripe webhooks:** https://docs.stripe.com/webhooks
|
||||
- **Stripe CLI:** https://docs.stripe.com/stripe-cli
|
||||
- **Test cards:** https://docs.stripe.com/testing
|
||||
- **Resend docs:** https://resend.com/docs
|
||||
- **React Email:** https://react.email/
|
||||
- **Auth.js:** https://authjs.dev/
|
||||
- **Better-Auth:** https://www.better-auth.com/
|
||||
- **Upstash Ratelimit:** https://upstash.com/docs/oss/sdks/ts/ratelimit
|
||||
- **OAuth 2.0 spec:** https://oauth.net/2/
|
||||
213
Les17-Externe-APIs/Les17-Lesstof.pdf
Normal file
213
Les17-Externe-APIs/Les17-Lesstof.pdf
Normal file
@@ -0,0 +1,213 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 5 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521112116+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521112116+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 17 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Count 8 /Kids [ 4 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1670
|
||||
>>
|
||||
stream
|
||||
Gatm;bBDVu&Dcq.As7:@-V!SJ6*NH6ebU7EEGRMcGXGDk7%5A8'!e!UI0Y-uZ=HFijWb*Hk&H0#mQ_1/^0(1P/HO^&i9_lrN5K01bU"l80&bj/q;Dl;\aOi0KVB[K5QYBo+J1_EG`p-u#7M=>M=Oqk'dH<S$96c854]rL0KXCo&78=B\Ngl#UlnsUJk40AZqEM1K2)5(5@r:OLodr#1ol+JL-&KHWq@iH6CI"`0Nl97k:b^Fi6Q5BMeJ5a$2eX\*A%]TEpgj@`S#N6`.cT;pn/^OP-($228?ug*[[dC<mSPHEoLMR6iWLa!6c64KumqlgT,97V]i:9T<DAG`f]>)U/E])#G/_DZ+&YO7(];UqHd05$4/<d_Psp"oVsR0i8%R'jKo<7"JMF-XDN/W'&g.s>&T?'eZLf4HREi[?RJShXM$t_DmTlf3>Bq5n@d[c?gM(ZTX/V,0b^Du(PnV,<CBCsL"/.(FtV.<n5UPlpBajL;]U$&e@XFBm2_+u?*r8=41+TK]3tXX]636JO"nU7?nUCZ$a<^$(:13E'Ae2T(t(;M&rYAtdUOtS3:d:ii+%Y/qE^ai-$)<M@s";W35<$Q+AQ>`l]ol8IOGq`Q&6aY7L;W$REYthHoYQ'`>a'MGM%T[`>_p5n[&X%#%$EEF<-cOT<1GDB;nX5C`\Pq>OfPll#16H^lu.]nACs]f1hei93NOtXl;53'SNJ[&7"lme/9O&Kk-*I4"4b4jBMEKq]P-P<WR6bemb_!2Y?sR:^t;cETggRFT.XRF3%B8@i64p"&$`p=ZuJnhI#ZfE#8`%?`-g3M(V8J3#mRN0\ia2iE]PndicWg2-aMFCdn<6&\Vb\kMaK\f`)mAmP_SLh69%;YQ)D-:Sgr;=Fr&U8gI6/i9'jDE,fJK%Y$IAmPN3=0]?<3;,JM>;%g)!N4-$M8gmos/VR)K64ILm$e[,ZCU[\"B1[SeL!f;C0Pf>EY_reSm5:m!g<[.6USYj6Y9*,)oq(H>QMF"SJFBCt3j8&KDdagj%2?e8Tdk>dT-U?^D6:1HO5nHe.Y/Tl*(UN>ZY@2t7[`@7`LN*nV0MDLpm,5IT4U-`<R?JC/";j$@/8@7F)oHO+$SbjL*Ff]UJoO1Q:$JX7(nDO&1&)5FP7#%ohsJ/$4X<eHNN7r*oF0Ja6So<jPCB!J$h"8<o7[YLFb<D@6-&YMKk_IArZJ1e9O079:M,LiJHhu^?+LsjRGj[qh:%OZF(R1!t,4jjXSYYoCTKa[4]T,Sqh"lU/dM6$0Yq;-^_>.0[JI?r9%4Gc/T(qg0h<hG3YG3:%(Em@Q)uF\8$&9aJPAu)T*fNH;NfTSuqR.%1n(75+Vk#C4O)qc%`o%o)\;jX,c<;R9.OROY3s4FJg[u1;Q)^(NX[3?m@2lCO&+Iif*6JdA?O3],0b!eGGg9R3>ppP#F'`-<faEL4J>^!:_dojC*QCI6ZrgICS(-i"BO=\VK.D1^W?oBC%?PTm^VL;:)g_cnqH)rA>eVi91RB8ZS-<Z/I-"d7Nj@k1Obs0R=F@hABYmYfCjlU%aju=h4jeE?ml2J97GNCsJe=ZJM99N_]?o:`Uqt]BA5r<6%iB2$2<Z,&nVA4R:&2fYm"o:ER-<m!A^Q7jrs$JG<Q'SiLKNJTI>q91QcXE7j0Rq9>Q'h/N^jrr?[&W5/~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1656
|
||||
>>
|
||||
stream
|
||||
Gb"/&D0+/c&H;*)Z.$5oKdj#I^I[e;btU2*'Gt!r^th[s^ibl0e1[f'ZkS8pIOdL/Bq#pm]?@*F19iF7G5#-&]36q^pgr!*S,teo_!Y,*N5K$+bTe/;?GXIn3Hk`>\F=kgY,#sVJV:3)5V[F;n,j;V4CfS6+B!IB8OfKa-#fA%";=T[(d2sX&.q_^4Wop`JIHG@G=fS1ReDOZ"Q@W$F8r%Y0-nBh4*7h.Te_j?F&8u>f-?OU_M:DagTQZ%*5_;pXNGdNk6\d[Ko.5^:,j&oJ,!]l/9P[V&>#g.Q/pCnA`a"%^;/[#[FNapH(=ZnhKAe#<!hbA?:pEBC:2+0=D&68St7kXdmd3MkDfm!M<e&jCU#YQNf/INXqu<`U^m,jIG9c.%Msdr:\Mf<S4e-\M1G=jr'i#Vg+g6KOtj#;*=>XroV'h&Z<F,JTG`R5_<*q$(qsS;9A1kiXd.P4SJMSrZqemo[kN-L@qXe#S?h$ul$&b'mSr6VLn)qh[q(F1/fAkaTjCgsZ<B$7>8P0A0]0P:oEUZ\ORV$$/L+AR]Y13BZ+A)a+d>$Y-:hWXilO5."<hWME8J'STs6X]Q'kXT;X4fR;u__0C`d7cZ-\9")QF2u'ccCLCB+(5VPA9%,m96dHoR%D<hN*AZCrbj]k`Z$?#-dg&,NHVl?*c2`Z<(R_U6So(_^*!=[DH08?7j(8h8-?-VT>m?6[L=L6Ut^/WUoZ'YF=3R:+&3P@.Q\ZPm5f]/l3_V;',=rs!0d?%c2rVQcB`c'!Kh7.IUh](kWc(G/;e*%@bg7L;+7@T<5jQO'qD;B^l+u^+!I(J!T2/A/)]HrYVGp4XZOBa*3++L)jSBDM^.^c-)7.8g_hjLr%%[L7k[q/p4LUUDJ\,@o%V5689CkkcGkc.VpST@'B1E38sgg];k;0FAd*!PD@Ui"l]/1[MBX]]Jjh=qMRu]^\?b?^MKFH"V>5NCBYM=B`p[!`rl@_7BqB%&(J@V$,<Z1b.S@M=#<.]k\dJ'61#EB03u-'KD3gu=WFB,nEX-g,E>FsFU%&%*9(]?grdC^$gi0FPY'l%QYO,j!n(TbQ\+0L.DgPAiCK0GAJNEp$]uE=>CYq5l2LIJkj4^6oW$>0Y@G>sBf,B[GWR=tj2+#mEV[,a((EU3BCX("ZNHe2C]Z-L'Se1RWXW:%S:Js9Z)f!]A!MLPLr=R`(`(iB@6@5/rI\j',_lN*D*ua7hSI;hHZm2NLHa&/;;YFGG:)WG8=\d*L"7:K,cgT5&[gP4\6<GfB>suerKUd$U6:$"FF]l\N37"hI@N!#-+M*N=WOr+mmB)4"drKAN2k"JjlY<4eS>Wt8CmP^=[0,;&T.q98SkfDhe?jlh&^%e0ZWrh&Qh+f-^m8h:Z1rgLlJ'l2I"&/k!r)=o^/'YL`\T,Bf`aAs5'Xbjp^*qol/f`g5<eP;2T'MLlnNp+k/SD1k%^`BS36Rh>r&sAB^#@iPGrHL7nJ1*'3XDhc$#T!69FQ1QM(?;#0a;IIWV].&9t'IMjZcHLaW#NfVH7.dET[W#JT9*bN]B;cO0US7AJKf:OU;,5N+a@\pl/Bo0Yf^P?`$tLZ/2aV[>Se",<:bL-[lD;h`9@\d'NhUO0"?JnNh$G\7@H`K373k5"@c#MuHnAT($DSA,(O$;,d~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1509
|
||||
>>
|
||||
stream
|
||||
Gb!#[?'E_q'Rf_Z\D=&6ZRG[YbVpQ8cn[.(Tm^T8N,c4]fu+B/!YgChhJ:f1*S>7;QsiN@Z$e3rH`hJY;8RkCS,)Mh"7YmjV@OLt%^Rs!P":s'LAS=@F[Wb%YuEuATd&G\'%+@b>"/ZD9m"5c#):.9`"JkS`0eICa62HL`DB\s]+Qh@Z)4dg5R^TFkg,h:7!"Bf,uIhnT0D0&1C_$Nk`-\3Cd/2[,?S#H5S]GYVIB_8%8<@`rqQg@Q:kFpX0Q!JF'%%2j^?AZ%!Ne!]j'*6mHaoBff+([DE#"Ca"ViDZR\bQ&\aL".&=>5s4GnQI.:%FnuROnRP+1U"Ymmi*Tsd7d_$eC;aZ520s8Kl'-7IM(aGiB)E&1/Ucf0lFi7cVWAL@e2r,lR=KM<XUA?TI'Ol=($tB`G#UMG_;%Tr8CW?PZI3:n/rV8K`GaAjuB`OkN9,>p+`>UB2amS+a`i/GZ>")arc+@9Xl%;Ge?G)_o:*-/VA!CrM\,_8n_C4H2CqMBR[F>'<EJ1MYIR<hmpl-J"K"BdU5J=[M/'&*C@c'3:W]h[1D.o$IlN<)Jk[<*))GP`8IrZ(sAmN-)q':^KV;&+(k/Z*I(gP-J2.ogp;IgK"_VPaB<g$dHf^+:#7;j@fa)0^hfK,0t%6cb)JiRL2-&oqWduVVF&7ao1;@)EY6;/Oa.A-oYBKU`??k=Lf2-_tQN/\_2V?7YUE]&G*6Oe.qg-%Z"1Rqc"8-+QS%XO6Y'9[=S2.0]V@:Cl+7c8Q/.l^o!k73LnQY7GEYmu6)h8A'lUPms,LXHKkdMVDG3.X<\,"IE0:o%e/bM94C01f2^VX"G1>,J7CC%G>*q0RHM>N][E0Cm(V6HJ8V<(rOK/Dif8Bgd(lob1G2Hfi,/(<ps-ac#9B?kV'V`,37[dZo]mhiW,kg+E7Kgs4&r^%q*RpQsnH6"7ZU$`F@/HM[Om5_<ojFUaq65+AtOiQ?4GJ1VPk1,#.nd,88kbu'2>[cF\NJ`!L*'ipl;)4LP_(=El1*2YqY..J?GG`Ot%"NrCF.F3'^a>+It6%-@oX(=,*KeA(FdJ-^_2*XPgDRVcF)^5lIa[H./[Xm0IQZCtAE9E?UNGJ\U,sQ(INBZ?=UV@X^UQ<b&N"3D4ZY_PE`O,3Z38#)%%A\mc'Y%mZRuY5Sd^o($"pM;'N(<[C;1&!nSO*ER>l>C,P]+!Aje*D(S5nYTNWR\)lX+'X*EHebT--k;<V[tKfW(OO??Z[$A3=5_-MF"VT9Vk4hug(/@^3q#-[o5oT[&nCUS9oCmMpV-S"]9GjMP&$$U?-&)Z8FBqp3BNS'&eInb#8h+g_RcqlRm.XECUb]1U\`3,B?m;1okd".4eZ+.nKh4uNd[#j(ggAtbB*:gahYNELpm?ksnP#;A4"qT`]XVr=e65'Y>H7,'?*1]<JKgs]rk"b*X)$WMHK2U.d_SPSt@G<i5ir`>Qde,eAP45:-^gSo/HW=$/?^%EY[bn#^Yr[1o&DG7OKp8&5GIp^l?E<~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2347
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>E@Xl8.3+)<W4*Jqd$GHk(,TacXk7$S:h7RH,>4%Ba#/bPpDlh^P4RfO_n(8`D-.EFhAu'V]6e2q']2T6-ZaeS,M7@fJ8lEs!u")<kQhYfcD<n<cNE,,EC9"\*(].j(R.iG#L=cH`$5I0%7h<A&0W9,(kobF>nF4?8dXZV*<meYBb1Uo+nPWY^U;oE53JkJ&C:*GS*M$j6=jIkr5LcSdsOt/c="noXAi74b.PNBd2W@/O+0BI2oS`safHmGpoQ/[194j-GlZ/$GFqr:>1IWpYN1tmMT&i_oaZpE_<@lHeWVWh-Q\_c.K;q>JG/!!Wf(pCp*?6,GmpDpk8jE+($+u+d"$khF_QB2GgPE0MiJ*Li<pai%ekdi[:>WB"%&smHXf0SAdQ<UTD(jgG#%&L0t^p<@nMt+MJun_JX,UX1WLE\%O>^qH_3;,Iq@Gse-_pN9B+=DM@:T.Oj.@INS_qoBBEl$WOE34hJ7fc%:5Z[%6Sq[A!HJEE>KJ7F[Ih3+m[1&0]V;bf:I@sbDuqD/RKZQ7>T\-q&eZ86LCL&?s(B*a;cjQ=j4>u_N1WaKM"QbT1tuR]9FA-\^Y/m19K(75Jio;kQI/G(.F\D0L6'n78\[7>g"mh:s&8X9dHftE`,7Ek>PM!EhTVZF")*@$)t!VR*%4q/8MP"pYD.1Y?dF:$HnC&="sI;eR2Qj)63r"`bTWc"G&!AFt@]&8CbRpaAs+L:dKa58D)3ZS8M@m!p4hrMe^9V'(J4g.O.&`bp7(U*C3QYK7\.UL:s3n-]E5['V`K_&%&[N=l8VD]=em*/jqatoI`BZc&?.1Hc1+1"1Qaj9G\<WAW^@4_K*#>fCr@O<eBY#^ua@-!8.5V)EAPlVLk#ldh1(5XG^Z#)_VsP.HZ!I,2HH@OR6%op;8pNH=,4o7L-Y.Bg%)U:64;L=YX(J_\]9'$b6dDP*nurMIfM$"'&Oc9&ChV=I>]h'nBea9*i(_l\AKK=1bLtE!%$JZJ:5s!_#[4mjtX&:i@-14AN4+a>tL!o$[uW7JZ&2ic-JSD6;6;*?5e1EL]<HZ2N`;QZX6uQUchco?D?)_AN4^jI=?iR>/tK1F*[:<7gS:bk6+!\UKlGGYd]FWapP@9^2`"?lH=M7q?i<]I6j$3SDA6aGM7gAjo_g]9La!1Z?W(M9$uL7/d.4rHIZ%hETf]:Xa#!f1Vp?=CBSK1l6"L?MAud1/rgRDS^;V4?etM(BJ;L-B\'M2'c>9:coUsbqXWoP<K[9HpAb>\a#ka\lG'(NufR['P;*BIDV`IMb])sGLH"F_6nO0)Mu2Nc"78^OqPEPpbQb6QuB"l9OJ&HSjMj-<[IG$?EbbF!V]!8#D%uK??;4$g4Ine?$n?=T5'.hENq?UgZ+,"\ko0UM]3D_AL]`q([8^j-/a4P+F38IRF8$bf&L,1_V(>4pp8$h8<S)Af5M>baHpj2@bj#)0-oIa,"MW3.;7cF%b,_@0.0Y?UO%ceb053Q-L3Wao-nbjcg\qM`H'MgBOD_qdP6#RA#G[bF?*F@%COJko_0VrODDiodo2L\31J^cD=?oY-S03C(*iA<pCf(eKLaEZ=5b@Am%YSNiuU(AX9Ec8[2AdK)iYl3SM8d^qT`B%EtlKj3C9V77=f^YK!C4G6PWiN<f/n"b)rps21ps]D<"g@9\>*8L/&RgVFtck67EL'dY-`]=Y)rG+sOBLq,o5+X*(#Lq(b`F!S%$e4=K=Q5.m5K9B8(m*Vf30Ebl+c'gpB1\"q*G>4Y-EdDW(FXg'olOrBlH*fP<7_8^8/&)KPNTVqgo7?&rWju:dhknLOa#=_MLF/<K]6`(J22@eNEg4K?<]^s#VD_d@uG_0eh'F9tc[J!u;0\'[&E,;b@.a;R.?*:[5QXP(KBOpA;<eK-b>9aTNWJG^h\P28j2.8W",*mOCT7JQ:f23A7T+9Uc$'G#u3?>dt4?<33iS_\Ej5XK2SfYOlIk]Rh/Yl+R#7:Hir51%hB"4GJZ>N]J"8:edL9["T\K-?J\.\pjPgri-N-5]A>To92"hI/I)IC>qkb'WJjK310YP(7Y$J/LAoT0'GNN>#.iRtp)hH-?50Hj,;e#M@RE\bSF1de3O7DNsUT'jd5H6Mlu3lX#eBq7OR<CK(mHr8^+VH7fDO'#Z0YOFiMH=(UNcckusJ%E;QGh)-QoOdP5]L4HOfF[doIrF'97R\3[(93/6ShTo4,C&A.P!\j#11%fM)Gb8'BM40l]VEs4hLQm&FpENcm!(LP=QAq9P,uDb1#5\(NINFs=PL0^D'k.u(g-=S%?^H(::&R]P&pcE!g(X?H8,PNVM?\pcdN:o8g\I;$(Sa13Tj<R~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1636
|
||||
>>
|
||||
stream
|
||||
Gau`SgMYb8&:N_C9S5MW6+Ybh\P!`.OZ3\W,`!IFBi.i`_GG$9M+#K3'fRiEmi?Gj,o,0DMZTk+n1rc^F(XS$Ka]@NX?O[?JEKiJ^LY,>9J:of,%:<P-<'BU,<XmVqET_2,32!U;>hVEH7AUi#so[gcr:UZ,Y619etE;.rKLauEW[+R49e1W<+uY,1I'7&Sc43DS>0jkr#c9[mGIE^'to\3%6LA"rs4?2=;-od'p;DU.j(X((qi*.ob_"H92icq=giqVAT$MN&n[XaG]s#6Z_C!JdOa=1q-mCXAo5ue0hagJoL\/FLG'r$5183)Iq&gN^3D*Lm)$W27a!':_!g6p_jn/#!H3cDWeh9d@4h1"g_6%l/S#t+=F8gNp=@nt*`n"mWI$P+RoY\+(%ArVDqi4%Zf)ABN)`^"'On]$#DXmuD;to/L):6*pFD<`>LpK`%Z!/!Yt$kk.su:]S'EXF?KAA8l.)6&%U8f"b=Xk(GQXZZH1X_K.,gV7!=Wt%p2sZHTN;rHCmr,Pp@*\Q5:$.ZI\d;cU2G2Ecc\H((c])`n`O^m6B[XHR7CgtL'/*-8u*P25qn2C3.fr9$DS@>%QVD.la8+*ZD5b!<h@gJlI_N[&.Jo1<:qiSD%h9AVS[(U;SRs$gYl-<mR%Ab7FT=4E#+,l*\'l4Xa@6c#=^d^Pq7"n"7<TVrOZaSgO!KT+KVV5mo)YG6I%QN"%@Sd"KRHh8N1F@((2PUlJ4Z.Zot6]!hnmo5MSd8U'0X['B:eYPC=[U/<3d._)Z,)RK]gZVJ!rIPPP%8k\N#F^Rk@P=F?^,VC:V\OSq0+[0Q<XjS>j?PLg9!iUpbjibsA8eYQdXV`kjns12a7-C>b/f-R=sB!%oY-)]NVEX\J7d\V;0\o\_AJ^McpGLe'8Ku=La66i"6ZPc9&A/mNAauIG*+_@pnr[a.Rq.c:-]ufgXR]5NqcdqU,.fIc@"#)pZ+I-LOB5(4$6NdlB2:i;uCp_<j(`1"lj`a]$_sHoT)d29nL&8n)_\FFrJL+h*WagpDYA"C]gGCOH/;;Q:oBC(US#ZWN1KRd#.^R071Z4e^)Q0N.'nD;7"u^`>S)_Id0^C,i_(A+r>t7JC]9=c9WQ<N@BK.X0',L@`#J.]=<Er`jRo%NKQo+7>''CSk0t@Da6\27Q'a+58B+&d2(#FBj@YClsoB>Ff$li!9RLU@X.S7lI.F#F`J3TK5*-=3Og.#eEP[=q4j7^e"n/ruSC<[cD]Z'9dg>qid[0JV-"!B\sFTkZZj%=@`D2nlDI_=o<nCd'jr;G<<gMt^+QR8X%A8uj8oP/F=qW.Sh'mb=XI.DH0r8RFfJG7ojD^1(dfW=fDY*,'Gb9?)?Plfle:fKML"J?aXD4[KWM[bQ`1t'$fRTgFa_2!Mjgg;mULR&,!n[N/hC1&RBG/h4%"lA"EeMH/tM?=T7RP0O=YC41A#+tYgde\)J_cE8@)OA!RRC9k"_@>%N2qnj>[@$X]d:gOaHB9CiH[UX<>j(E*TS9?\!t1#K9Dd&%0To]#]!ZUZI<C[3lplf\op9L&1@W\U&J!FSK<2(!me_^pqYI[p;i6S3V[#OqRVS*<9to6rmro37^I2;nJ?(cJI/j1Z`="XanMW=MR(TI1nGWUn*MUg~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1556
|
||||
>>
|
||||
stream
|
||||
Gb!#[D/\/e&H8h>EQol/BPEVR74Lr_VJ(i&/%W1\3Xc9jW%sRJeOq)8Bs8`[I7Ho(ACn.C%-SHGOaK>VGJ&:jU!/&HMn/k%%e+98o+qfPLW(,b5m/`F_oX+4$j_p(&e[a6Bc(n6EG5BF3Y)RZ&W's<1XK)5XTSpSLt8g8NpDp=$7Z-j%g*#(79!t%6o,_Fke$L2M)`Q&K#f?t2qN9LBq&F&U+SN$Z[F&8HE_9c*]4Xb18uNMEgXQ9jhiTn=e;*DrEPX+r74gorS51;k6d5V4[-%7<Sa#g4?(k=.kbpA2#FNP!fMgled,Ug:*c^GNrRh_JGJ3$DT]:$p2+ia.M@P,3:F)K4]')/6l"5t%&q*Mln!Am@UX@,+q[*^E(SbfDp&3b#c0kKCg;0jJhRr0Ysn@MC#EiQ__bj=b*ul4M^<W7OL789J_lJtK)L*50`74G?W%D&I?ZR]6P^Pgn4@e]rAI,,:P6uEj-4u"Xe3A4gi;D7M!jI]C)Ne"CW8_LL`:*.?-C`r08-`EhRnm<,DR?Sr]Ru,"H/DOC\[?mD2KVr<Dq=<g8rF@ahZuZ.[qVM'QOi?JZsf8'VTMur3[i*,6X#HiGId@&5'@ZkA=?E1pm\rP982.:dNXaU3iLh(q^P[/<[k9osU7+>e+im_%l:+Hj8GZ:r<O]$)R=YCH@!n+dSsY*qhjS>eB[HDMHoJZj7gu?)-.V@3[RgP9EL3WjZOV`C19YEf%ZnRTCBnguHYVD#jr1U-d-Gb27Pc/>6'%(Mc2>@Z"raQ"`")hHq;*:4>?OoDIY=gK,JH55F51ANg_n-5f7+B@Q#GgR18HKFKmZ/m$!EM6-t-=Ujh:FW`:-Mm?I:bBM^i&7J82PjH.42Nt5e,TGK)Pi!n]`+DoU:-JbuZ,H>X4L)SR%UaC7on/+G:hO8QM4SRE`qB7RP@;XnCEmQYS))S(%!>q)fR^-.do?LZn?U0B8+=qPb>iQ]#SuR"JNGilarL,HNeV06a\hO*1_GO9il6K2"N=JC)=)HAk/O6=G#^MIL%3G`B=C\_\Uh?pB^ZG(DCS<rmS<N8G'>]4M,4O!#oa(F#+DTmJCE3><@DHGB[+.h!/J!X8%TR/7;O73(YNA:@?5@p?a6E92Z:5mL\'B:)f[EB*M@8rd5/+&MQ[IoQXp>-$m\E,S'1qs_n#BK^W*\9S)7_*CRPKI\j+#`Z.@`*l'c#($d-67[1B/2'/eY]dlPLRdo+3c#9C_&1+pMVNo?1fOE..,p^lSX"s*"]:<32AK9#U?3e5U$erSCeOYR=Xe3..2Wm`$A;^/Op+)+m-9n1SUJ\lg#5Yl#/<LMjA]K;b;&)7DR"<M@!4gcL0QE9pV81W/b9k6Sg2T3M!iW/\4;q/LuZM7BGPo]=k*V.W`+725f-fWO<#^9:<lD_<cnr(^''\Fjj%@sH$5(,/"K0ZQ/4d]EfTc!2W3S5hLLq-XLHWJNA0\998]nP2g*7*u#_CrBs#BRYBh61F*)2pcfg37ZLZf`[U]"&B<iiU7;CHjkX*bIn"D,i3ln3"V5RFShs?s2KT$aQHu&Gu]"Ng.0~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2022
|
||||
>>
|
||||
stream
|
||||
Gb"/'>>s99'RnB331Hhm"sM*o/^r/$mZc!PdgOu+gR#Pi7nF,LS_=F8mI9$-a;<Mp^h#/mG@h.hk?.T+e`V<<",\1$rf:([^J&&(2$:j,1'].^JB8`iS.*+?#kth^>Tf'&:;DD`Nqn6R0hB.RL_oQ2JYYJl^&T;oOYW$6EM)\U+*%PV-NaRkpS]_K&BS0QHFNT0`8_h;$fZJ=E-$MGT/>[6`2g"-1TOjc_\K7rb+^qK;!.U,9NEinp\rVRr0f3:&M:=?JWDind8IlHF!"^0mCk-/\BnspYLL*TZLphdH3Ydp0R7,LFmt+C`'s:#j1eFpTY5RuF>g=1O2FDVL>[8p5%%&'+HP-/qG\*JTg%VuhLH+V%#f:]-TkK?\4`i#.n^*%-^4tkO`=O-&qp'%mn2AI9f0(04COC-"(an%Xp\L=fFFt5=9BI-KEa?taai>Mem>-fLfuCXJ@8K(pr+.SDmjP*4HB]i6!7.K;l+s3[Af_&GWd8:o&um.;8OX>%PYhDUasWQa\H.JX298UfYR;Sr.+:kpnf6;J9?2Js0kfT8E*XNj)b;tZ'KN]$0tE'Q)@&X;0\8XUA3/H+1(^J0Ub)^."a68r4:0O/Ef&ei$=9,ZMD/=k*cldbn-IB"@_KJD)-uTilu;#<K3<PM0D2C<>8I9iVQV"Yd.d^clq2sMJTck#SkkX8n>7T)H[jb,f-@n9;n`6:l6XKFIii0;dM&](0Cl0[9lUQqREksck_SK\kIh#ed'p_>46CCBhu3=Vuo>R&\>)0!toRmZ0Zn"\:H3;3!tSe1kPNMNHC\e<Pk>'?;*lDqi\qf7%(Y""(7_g_fR`kJV+bXS7en4:^AiE_/MEGkDL7rPjE.e$TJ-aDl59TA<$;HYY_N&&mi:0Ri/'0B03PV_"T=te"-GtamcEF/&MSQ;sSXnUYgpV&>Nk6$kL[d,P^Zj;iF9i,]sFbga3F0jp(fs19nr"#VX%s@sh!mT>B(V:k=8Ld:Cfn7A5a-BVET?IRC?r*EV1thWtH&EQF*c[=a)i4hJK@9/#o'!YG+ZrLdI^KTM@@5bfk^1sAY10PK;'4b:)9?BTGS0cfd=`5ZB@gB1K8J`E@NmT8QI`&POnb,Y65lCpl@FHi7E/X2jF/!Xs.$HA>gdiDD4^BWX0cgdi3d$#$]+hkj>ZbC'0a+e(P8\M\BYqc22ca/![)K*u.L!_Y<8^sureE1G[Y5/?RE!"dFs6_$)*2]]53P7F7S5]qdV0C48Q:UgW$YXgrBkq=jQ3t.`N6QeXNi\5SS(-5%FG3lgd*\*RrgcM&]l4jP$*3AM?j5#JMA*Z!bni&H?b`%6--M4(Z^k]t$g\lQ4beYgFk;>D9%Ni6)pB"A4@`U]er?filoOq3qqicodb-[>;f!1uAEaL1+eE-*aL"R7]=@pQpP@])-0IIk>TnNN9qh.63*gGZ[^$kj^5g_qSKI:Z3f_D_Yrf'6J"Yd)(/c+Jl0.0=csa2Lme$*(A0O5<othK_7,W3e(DAcl4=VrU@`#)[=UV=L:bV^b;D0Po/Jr>I4Z;uudYAZXMHo2?2efA!GQi/Y;DA#o;^H?8D_;KG])=K%d3>Vo>\l\`i]b:5RJ9P*:ec+K1JW-M]rZS0;%Ec>5o*'KpF@VIET&T72a"$o*St0)V.u:a[s!iWM.cUnoJoZZL2&D'L*<>2YSXl4ri^VqE-M/.kL?[Zp^N*T']DtSVXC+S0K,r,Nb,kP&-XJ]geTd['"FB;Q3gFm5.VVH1n8(oXMXUM3&X7i:iA#g!dXbSDn!1ff-P[:3rcs(Ggoe'_8#o*!K$Da@tksk9,4!D3!4LX_=;@g4FGFdpf)BN#2?\?72Y=gIeIHr,O/e=EV@J_Z,`3O7rY=XX(S'US^BW7<Tm_0$-l8hf"!i&e\rA$ZIq*\2B,!qAS^mtBT7sEV<:&[d<o=q5-'lYQCVlL8J*URCg4H%F-Xf;9*c1OT;)?+(0VID(m5<F&RHee3%KG+B)9;VPg>+h@\I\SOrKa?A34KF_^\;hS`P("=W%&CHD>d,~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1017
|
||||
>>
|
||||
stream
|
||||
Gb!;bbAQ&q&DcY&Asll!1eCOiFa*'X%1r02G&boh68>n]=\M7F7K:)sVj5nSOY2SsbHU1RH01V7c%lcV?EF#]COd3=/D`'g*3q)&JdBFMi,of`NsM(WM$-ANBq02dC#"01=[k7oP/rlc$oCsP.fhsmU33Jej$'<,K#Y[XZ\SWZOt&&6BU21@GP9'FEfR:+8Yf$%mN;5gQ<m2E^^b)P?Ne7]C_/It`e%$QW!'C`S@j4Koc2i"V=eH^>QnJ$A'HL=2pN/.SNf@1^@]5WnB0V@b2'4;*WCWV)R="XY8P%cS0UYo55p9/J:-nLK=gDW5NL"nc9]cq5%730LaihD:8ks5i&'huHVIdHBU9Gtj^d/-@Aa!h"'M')V]]0CM`6%%m.,PpN@B"2igaQ'8A^a+PHS9-*bGdQq!`-dE:V\N9iHe=SV+&7PCMP.Q@8N:Y-*5F_U@-V]JYZX*m,Bp<FYl%iBLDZ?!`db*_KKu8eN@g`'F5t**tZJG%4A0IWaoGVXUg5FW"FolVk=K'MBAfYA=mM.@Anl/67A;Fg\]-LZ-8Y9I1dP/#bLbA#`_LLC-&!Z/B^TUl_g@GXD8cA?-oW#c0l6Cg7LO"CXpn\Kr-FQtWea!+X&QW6(:`\hnsE)^F.?R0\`UK*957'eu4igIXF,dE.<_,"f<1l'CD7H'0m\DPC.\or@^(bE$jM`@"%d4VZg*nIhUbEiss:DQFN-[/rg1.3bjSo>CILJa=APK^Us8>7F61+l7;0U2b<mlef+87*$hU:MMD=*O2eAc5nVe[^WMpVS'>+Eb3LTgC0(SE5iRKbn/pd$T$!JkT&h!>=\PEhJl*O%ss/:XrG,_l8X,UWY3@93r8]jXBqFc=4n+Vj87ecc9r^LkNM5g3^6bcl_<"`FQ6,&/,+Ru=<C>kkj-@-EYIC(3\RO2*?'`N%>Wfts/V@-?=dc=YTh[dp#Yu3g69D1r?[5^]op+bqLBq)pll"fQ0WJSU7:"%4cp-8rPB@0IM)LP):q&m%Y<g[.0[h<~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 24
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000536 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000002079 00000 n
|
||||
0000002149 00000 n
|
||||
0000002441 00000 n
|
||||
0000002546 00000 n
|
||||
0000004308 00000 n
|
||||
0000006056 00000 n
|
||||
0000007657 00000 n
|
||||
0000010096 00000 n
|
||||
0000011824 00000 n
|
||||
0000013472 00000 n
|
||||
0000015586 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<82345fc9d3ba8a3c7370d1c1e79ce723><82345fc9d3ba8a3c7370d1c1e79ce723>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 14 0 R
|
||||
/Root 13 0 R
|
||||
/Size 24
|
||||
>>
|
||||
startxref
|
||||
16695
|
||||
%%EOF
|
||||
418
Les17-Externe-APIs/Les17-Slide-Overzicht.md
Normal file
418
Les17-Externe-APIs/Les17-Slide-Overzicht.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# Les 17 — Externe APIs in diepte
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Subscription-dashboard (Stripe Checkout + Resend email)
|
||||
**Vervolg op:** Les 16 — MCP servers
|
||||
**Aansluit op:** Les 18 — Supabase Auth + RLS
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 17 — Externe APIs in diepte
|
||||
|
||||
**Visual:** "Les 17" BLUE, "Externe APIs in diepte" BLACK, subtitle "OAuth, webhooks, Stripe en Resend — productie-patronen"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Lessen 11-16:**
|
||||
- AI SDK, tool calling, agents, RAG, Cursor+Vercel deploy, MCP
|
||||
|
||||
**Externe APIs deden we al in Les 15 (PokéAPI):** simpel, geen key, GET requests. Productie is anders.
|
||||
|
||||
**Vandaag — de echte wereld:**
|
||||
- Login met Google/GitHub (OAuth)
|
||||
- Iemand doet een betaling — server moet dat weten (webhooks)
|
||||
- Betaalde APIs zoals Stripe en Resend
|
||||
- Wat als de API faalt? Retry-logic
|
||||
- Hoe doe je dat veilig en netjes?
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Terugblik + API-types | 15 min |
|
||||
| Theorie: OAuth basics | 20 min |
|
||||
| Theorie: Webhooks + verifying | 20 min |
|
||||
| **Live Demo 1** — Stripe Checkout integratie | 25 min |
|
||||
| **Live Demo 2** — Webhook ontvangen + verify | 25 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — Resend transactional email | 25 min |
|
||||
| **Live Demo 4** — Rate limit + retry pattern | 15 min |
|
||||
| Productie checklist | 10 min |
|
||||
| Lesopdracht + Huiswerk | 10 min |
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Drie soorten externe API integratie
|
||||
### Welke heb je nodig?
|
||||
|
||||
| Type | Voorbeeld | Wat moet je weten |
|
||||
|------|-----------|-------------------|
|
||||
| **Simple fetch** | PokéAPI, Open-Meteo | URL + JSON parsing (Les 15) |
|
||||
| **API key in header** | OpenAI, Tavily, Anthropic | Env vars, server-side fetch |
|
||||
| **OAuth flow** | Google login, GitHub | Redirect dance, tokens, sessies |
|
||||
| **Webhook** | Stripe, GitHub, Slack | POST endpoint, signature verify |
|
||||
|
||||
**Vandaag focus op de laatste twee** — daar komen 80% van productie-API-bugs vandaan.
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: OAuth — wat en waarom
|
||||
### Login met Google / GitHub / etc.
|
||||
|
||||
**Het probleem:** je wilt dat gebruikers inloggen zonder zelf wachtwoorden te beheren.
|
||||
|
||||
**OAuth flow (kort):**
|
||||
|
||||
```
|
||||
1. User klikt "Login met GitHub"
|
||||
2. Redirect naar github.com/login/oauth/authorize
|
||||
3. User logt in op GitHub + geeft toestemming
|
||||
4. GitHub redirect terug naar JOUW app met code
|
||||
5. JOUW server exchanged code voor access_token
|
||||
6. JOUW server haalt user-data op met token
|
||||
7. JOUW server zet sessie / cookie
|
||||
```
|
||||
|
||||
**In Next.js — drie populaire libraries:**
|
||||
|
||||
- **Auth.js (NextAuth v5)** — meest gebruikt
|
||||
- **Better-Auth** — moderne challenger, eenvoudiger
|
||||
- **Clerk** — paid, alles inclusief
|
||||
|
||||
Voor dit vak: Auth.js — open source, gratis, werkt overal.
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Webhooks — wat en waarom
|
||||
### Externe service belt JOUW app
|
||||
|
||||
**Het probleem:** soms moet je weten wanneer iets gebeurt op een externe service.
|
||||
|
||||
**Voorbeeld Stripe:**
|
||||
- User doet betaling op Stripe Checkout
|
||||
- Stripe verwerkt betaling (zou kunnen falen!)
|
||||
- Stripe stuurt POST request naar JOUW webhook URL
|
||||
- Jouw server update database, stuurt email, etc.
|
||||
|
||||
**Webhook = jouw HTTP endpoint** dat door externe service wordt aangeroepen.
|
||||
|
||||
**Cruciaal: signature verification**
|
||||
|
||||
Zonder verificatie kan iedereen je webhook URL aanroepen met fake data.
|
||||
|
||||
```typescript
|
||||
// Stripe doet dit zo:
|
||||
const sig = request.headers.get("stripe-signature");
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
body, sig, process.env.STRIPE_WEBHOOK_SECRET
|
||||
);
|
||||
// Throws als signature niet klopt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Wat we vandaag bouwen
|
||||
### Subscription-dashboard
|
||||
|
||||
**Mini-app:** Premium subscription met Stripe + email confirmaties.
|
||||
|
||||
**Tech:**
|
||||
- Next.js 16
|
||||
- Stripe (test mode — geen echt geld)
|
||||
- Resend (transactional email — gratis tier 3000/maand)
|
||||
- ngrok of `localhost.run` voor lokaal webhook testen
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
[Pricing page] → [Stripe Checkout] → [Webhook] → [Database + Email]
|
||||
```
|
||||
|
||||
**Endpoints we bouwen:**
|
||||
- `POST /api/checkout` — start Stripe sessie
|
||||
- `POST /api/webhook/stripe` — ontvang Stripe events
|
||||
- `POST /api/email` — verzend Resend mail (intern aangeroepen)
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: LIVE DEMO 1 — Stripe Checkout
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. Stripe account opzetten (test mode)
|
||||
2. Pricing page met "Subscribe" button
|
||||
3. `app/api/checkout/route.ts` — maak Checkout session
|
||||
|
||||
```typescript
|
||||
import Stripe from "stripe";
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { priceId, email } = await req.json();
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
customer_email: email,
|
||||
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.APP_URL}/pricing`,
|
||||
});
|
||||
return Response.json({ url: session.url });
|
||||
}
|
||||
```
|
||||
|
||||
4. Client redirect naar `session.url`
|
||||
5. Test in browser met Stripe test cards (4242 4242 4242 4242)
|
||||
6. Stripe Dashboard — sessie zichtbaar, betaling staat als "complete"
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: LIVE DEMO 2 — Webhook ontvangen
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. Webhook secret ophalen van Stripe Dashboard
|
||||
2. `app/api/webhook/stripe/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text(); // RAW body, niet json()
|
||||
const sig = headers().get("stripe-signature")!;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err) {
|
||||
return new Response("Webhook signature failed", { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
await supabase.from("subscriptions").insert({
|
||||
customer_email: session.customer_email,
|
||||
stripe_session_id: session.id,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
|
||||
3. **Lokaal testen:** `stripe listen --forward-to localhost:3000/api/webhook/stripe`
|
||||
4. Stripe CLI geeft webhook secret terug — in `.env.local`
|
||||
5. Doe een test-checkout — zie webhook binnenkomen in CLI én database update
|
||||
|
||||
**Belangrijk:** `req.text()` voor RAW body — niet `req.json()`. Signature wordt over bytes berekend.
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: Pauze
|
||||
### 15 min
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: LIVE DEMO 3 — Resend email
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. Resend account, API key, eigen domain (of `onboarding@resend.dev` voor demo)
|
||||
2. `pnpm add resend react-email`
|
||||
3. Email template als React component:
|
||||
|
||||
```tsx
|
||||
// emails/WelcomeEmail.tsx
|
||||
export function WelcomeEmail({ name }: { name: string }) {
|
||||
return (
|
||||
<div style={{ fontFamily: "sans-serif" }}>
|
||||
<h1>Welkom {name}!</h1>
|
||||
<p>Bedankt voor je aanmelding bij Premium.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
4. Verzend in webhook handler:
|
||||
|
||||
```typescript
|
||||
import { Resend } from "resend";
|
||||
import { WelcomeEmail } from "@/emails/WelcomeEmail";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
|
||||
await resend.emails.send({
|
||||
from: "onboarding@resend.dev",
|
||||
to: session.customer_email!,
|
||||
subject: "Welkom bij Premium!",
|
||||
react: WelcomeEmail({ name: session.customer_email!.split("@")[0] }),
|
||||
});
|
||||
```
|
||||
|
||||
5. Test checkout → webhook → mail komt aan
|
||||
6. Resend dashboard — email zichtbaar in logs, status delivered
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: LIVE DEMO 4 — Rate limit + retry
|
||||
### ~15 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
**Probleem:** externe APIs kunnen falen. Wat doe je?
|
||||
|
||||
**Pattern 1 — Exponential backoff retry:**
|
||||
|
||||
```typescript
|
||||
async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) throw err;
|
||||
await new Promise(r => setTimeout(r, 2 ** i * 1000));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const data = await retry(() =>
|
||||
fetch("https://api.example.com/data").then(r => r.json())
|
||||
);
|
||||
```
|
||||
|
||||
**Pattern 2 — Rate limit met `Upstash Ratelimit`:**
|
||||
|
||||
```typescript
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
const ratelimit = new Ratelimit({
|
||||
redis: Redis.fromEnv(),
|
||||
limiter: Ratelimit.slidingWindow(10, "10s"),
|
||||
});
|
||||
|
||||
// In API route
|
||||
const { success } = await ratelimit.limit(userId);
|
||||
if (!success) return new Response("Too many requests", { status: 429 });
|
||||
```
|
||||
|
||||
Beide patronen zijn cruciaal voor productie. Niet doen = service crash bij verkeerd gedrag.
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: Productie checklist
|
||||
### Voordat je live gaat
|
||||
|
||||
**API keys:**
|
||||
- ✓ Test-keys voor preview, prod-keys voor productie
|
||||
- ✓ Vercel scoping per environment (Les 15)
|
||||
- ✓ Rotate keys eens per kwartaal
|
||||
|
||||
**Webhooks:**
|
||||
- ✓ Signature verification ALTIJD
|
||||
- ✓ Idempotency — zelfde event 2x ontvangen = OK
|
||||
- ✓ Log alle webhook payloads (helpt bij debug)
|
||||
- ✓ Return 200 snel — verwerking async
|
||||
|
||||
**Errors:**
|
||||
- ✓ Exponential backoff retry voor transient fails
|
||||
- ✓ Rate limit eigen endpoints
|
||||
- ✓ Monitor met Sentry / LogRocket / Vercel Analytics
|
||||
|
||||
**Security:**
|
||||
- ✓ Keys nooit in client-bundle (geen `NEXT_PUBLIC_`)
|
||||
- ✓ Webhook endpoints accepteren alleen verified signatures
|
||||
- ✓ User-input validatie (Zod)
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: Lesopdracht + Huiswerk
|
||||
### Eigen externe API integreren
|
||||
|
||||
**Lesopdracht (30 min):**
|
||||
- Stripe test account opzetten
|
||||
- Pricing page + Checkout endpoint
|
||||
- Doe een test-betaling met test card
|
||||
- Geen webhook nog — alleen success_url demo
|
||||
|
||||
**Huiswerk (~2 uur, voor Les 18):**
|
||||
- A: Webhook handler + signature verify
|
||||
- B: Resend transactional email na betaling
|
||||
- C: Retry logic voor externe API call
|
||||
- D: `APIS.md` met:
|
||||
- Welke externe APIs jouw app gebruikt
|
||||
- Hoe webhooks getest worden (Stripe CLI of ngrok screenshots)
|
||||
- Voorbeeld van een failed call + hoe afgevangen
|
||||
- Eindopdracht-toepassing: welke API ga jij integreren
|
||||
|
||||
**Bonus:** OAuth login met GitHub/Google via Auth.js
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Volgende les + Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- Drie soorten externe API integratie
|
||||
- OAuth flow basics
|
||||
- Webhooks ontvangen + signature verify
|
||||
- Stripe Checkout in productie-patroon
|
||||
- Resend transactional email
|
||||
- Rate limit + retry
|
||||
- Productie checklist
|
||||
|
||||
**Volgende les (Les 18 — laatste!): Supabase Auth + RLS**
|
||||
- Multi-user apps met login
|
||||
- Magic link / password / social
|
||||
- RLS policies — per-user data isolation
|
||||
- Protected routes + middleware
|
||||
- Combo van alles wat we geleerd hebben
|
||||
|
||||
**Vragen?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | 3 soorten API integratie | Theorie |
|
||||
| 5 | OAuth basics | Theorie |
|
||||
| 6 | Webhooks + verifying | Theorie |
|
||||
| 7 | Wat we bouwen | Intro |
|
||||
| 8 | **DEMO 1** — Stripe Checkout | Demo |
|
||||
| 9 | **DEMO 2** — Webhook ontvangen | Demo |
|
||||
| 10 | Pauze | Break |
|
||||
| 11 | **DEMO 3** — Resend email | Demo |
|
||||
| 12 | **DEMO 4** — Rate limit + retry | Demo |
|
||||
| 13 | Productie checklist | Reflectie |
|
||||
| 14 | Lesopdracht + Huiswerk | Praktijk |
|
||||
| 15 | Afsluiting | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **Stripe Checkout docs:** https://docs.stripe.com/checkout
|
||||
- **Stripe webhooks:** https://docs.stripe.com/webhooks
|
||||
- **Stripe CLI:** https://docs.stripe.com/stripe-cli
|
||||
- **Resend:** https://resend.com/docs
|
||||
- **React Email:** https://react.email/
|
||||
- **Auth.js:** https://authjs.dev/
|
||||
- **Upstash Ratelimit:** https://upstash.com/docs/oss/sdks/ts/ratelimit
|
||||
- **Better-Auth:** https://www.better-auth.com/
|
||||
BIN
Les17-Externe-APIs/Les17-Slides.pdf
Normal file
BIN
Les17-Externe-APIs/Les17-Slides.pdf
Normal file
Binary file not shown.
BIN
Les17-Externe-APIs/Les17-Slides.pptx
Normal file
BIN
Les17-Externe-APIs/Les17-Slides.pptx
Normal file
Binary file not shown.
BIN
Les17-Externe-APIs/lu45m7va7.tmp
Normal file
BIN
Les17-Externe-APIs/lu45m7va7.tmp
Normal file
Binary file not shown.
1
Les18-Supabase-Auth-RLS/.~lock.Les18-Slides.pdf#
Normal file
1
Les18-Supabase-Auth-RLS/.~lock.Les18-Slides.pdf#
Normal file
@@ -0,0 +1 @@
|
||||
,sharp-zealous-planck,claude,21.05.2026 11:30,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;
|
||||
327
Les18-Supabase-Auth-RLS/Les18-Docenttekst.md
Normal file
327
Les18-Supabase-Auth-RLS/Les18-Docenttekst.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Les 18 — Supabase Auth + RLS
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven, **laatste les**)
|
||||
|
||||
**Les:** 18 van 18
|
||||
**Onderwerp:** Multi-user apps met magic link + RLS + social login
|
||||
**Duur:** 180 minuten
|
||||
**Demo-app:** `tasks-app` — multi-user tasks met RLS
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES (60 min — meer tijd voor laatste les setup)
|
||||
|
||||
1. Werkende `tasks-app` op laptop (magic link + GitHub + RLS)
|
||||
2. Twee email-accounts klaar voor multi-user demo (Tim's eigen + tweede test-mail)
|
||||
3. GitHub OAuth App aangemaakt + Supabase configured
|
||||
4. Backup screenshots: magic link in inbox, RLS policy editor, multi-user side-by-side
|
||||
5. **Speciaal voor laatste les:** bedank-slide klaar, eindopdracht reminder
|
||||
6. Browser tabs: Supabase Dashboard (2 projecten, 1 demo + 1 backup), GitHub OAuth Apps, Vercel
|
||||
7. Mentaal voorbereid: dit is afsluiting cyclus, ruimte voor terugkijken + dank
|
||||
|
||||
---
|
||||
|
||||
## HET SCRIPT
|
||||
|
||||
### BLOK 1 — Welkom + recap 18 lessen (10 min)
|
||||
|
||||
`[SLIDE 1]`
|
||||
|
||||
**Vertel:** "Welkom bij de allerlaatste les. Les 18. Vandaag: Supabase Auth en RLS. En aan het eind: terugkijken op 18 lessen samen."
|
||||
|
||||
`[SLIDE 2 — Curriculum recap]`
|
||||
|
||||
**Vertel:** "Even kort. We hebben vandaag de stof voor jullie eindopdracht compleet. Lessen 1 tot 10 — foundations. Next.js, Supabase, Tailwind, basics. Lessen 11 tot 14 — AI: SDK, tool calling, agents, RAG. Les 15 — Cursor en Vercel. Les 16 — MCP. Les 17 — externe APIs in diepte. Vandaag — multi-user maken.
|
||||
|
||||
Na vandaag heb je álle bouwstenen voor je eindopdracht. Niet meer 'volgende les leer ik nog X'. Vanaf morgen — bouwen."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Drie uur. 65 min theorie en 4 demos. Pauze rond minuut 100. Aan het eind: 15 minuten ruimte voor terugkijken en vragen over eindopdracht."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 2 — Theorie (55 min)
|
||||
|
||||
`[SLIDE 4 — Waarom auth]`
|
||||
|
||||
**Vertel:** "Waarom auth. Wat kun je niet zonder. Per-user data — mijn tasks, jouw tasks. Privacy — alleen jij ziet je data. Audit trail — wie deed wat. Subscriptions — wie betaalde, wie niet.
|
||||
|
||||
Twee concepten die mensen vaak verwarren. Auth — 'wie ben jij'. Authz — 'wat mag jij'. Login is auth. Permissies zijn authz.
|
||||
|
||||
Supabase doet beide. Auth via magic link of OAuth. Authz via RLS. Geïntegreerd, simpel."
|
||||
|
||||
`[SLIDE 5 — Auth methoden]`
|
||||
|
||||
**Vertel:** "Drie methoden. Email + password — klassiek, werkt, maar wachtwoord-management. Magic link — passwordless, user vult email in, krijgt link, klikt, ingelogd. Modern. Social OAuth — login met Google of GitHub.
|
||||
|
||||
Voor demo vandaag: magic link én GitHub OAuth. Beide werken naast elkaar in dezelfde app. User kiest."
|
||||
|
||||
`[SLIDE 6 — RLS]`
|
||||
|
||||
**Vertel:** "Nu het belangrijkste. RLS. Row Level Security. Zonder dit kan iedereen met je anon key letterlijk alle data ophalen uit alle tabellen. Disaster.
|
||||
|
||||
Met RLS: Postgres past automatisch een filter toe. Welke filter? Wat jij definieert in een policy.
|
||||
|
||||
`*[Wijs naar code op slide]*`
|
||||
|
||||
`alter table tasks enable row level security` — RLS aan.
|
||||
|
||||
`create policy "users see own tasks" on tasks for select using (auth.uid() = user_id)` — alleen rows waar user_id matched de ingelogde user mogen geselecteerd worden.
|
||||
|
||||
`auth.uid()` is een Supabase helper — geeft de UUID van ingelogde user, of NULL. Postgres weet wie ingelogd is door de JWT in de Authorization header."
|
||||
|
||||
💬 *Vraag: 'Werkt RLS ook vanuit server-side calls?'*
|
||||
|
||||
**Antwoord:** "Ja, mits je client met de user-JWT calls maakt. Voor server-side acties als admin gebruik je service-role key — die bypasst RLS. Voor user-actions vanuit je server: gebruik de user's session client, RLS werkt zoals verwacht. Komt straks in demo."
|
||||
|
||||
`[SLIDE 7 — Session in Next.js]`
|
||||
|
||||
**Vertel:** "Eén stuk complexiteit. Supabase Auth in Next.js heeft een vier-file pattern. Client, server, middleware, en root middleware.
|
||||
|
||||
Waarom complex. Cookies moeten gesynced. Sessies verlopen. Server components hebben aparte clients dan client components.
|
||||
|
||||
Goed nieuws — Supabase heeft een template voor exact deze setup. Kopiëren werkt. Snappen waarom is bonus. Vandaag kopieer ik en leg uit waarom elke file doet wat-ie doet."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 3 — DEMO 1: Auth setup + magic link (25 min)
|
||||
|
||||
`[SLIDE 8]` + `[SLIDE 9]` `[SCHERM: terminal + editor]`
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les18-Supabase-Auth-RLS
|
||||
pnpm create next-app@latest tasks-app --typescript --tailwind --app
|
||||
cd tasks-app
|
||||
pnpm add @supabase/supabase-js @supabase/ssr
|
||||
```
|
||||
|
||||
`*[Supabase Dashboard — projectsetup]*` `[SCHERM: browser]`
|
||||
|
||||
**Vertel:** "Nieuw Supabase project of bestaande. Auth → Email → Magic Links staat default aan. Site URL: localhost:3000. Redirect URLs ook localhost. Voor productie pas je dit later aan."
|
||||
|
||||
`*[Editor — maak utils/supabase/client.ts]*`
|
||||
|
||||
**Vertel:** "Eerst de client. Voor Client Components. `createBrowserClient` uit `@supabase/ssr`. Wrap in een `createClient()` functie. Klaar."
|
||||
|
||||
`*[utils/supabase/server.ts]*`
|
||||
|
||||
**Vertel:** "Server-side variant. Belangrijk verschil: leest cookies via Next.js `cookies()` API. Net iets meer code. Belangrijk: `await cookies()` in Next 15+."
|
||||
|
||||
`*[app/login/page.tsx — magic link form]*`
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: { emailRedirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
```
|
||||
|
||||
`*[app/auth/callback/route.ts]*`
|
||||
|
||||
**Vertel:** "Callback route. User klikt magic link → Supabase redirect naar dit endpoint met `?code=xyz`. We exchangen die code voor een sessie."
|
||||
|
||||
`*[Test in browser]*`
|
||||
|
||||
**Vertel:** "Email invullen, click. Check mail. Magic link mail komt binnen — onderwerp 'Confirm your signup'. Klik link."
|
||||
|
||||
`*[Browser opent /auth/callback en redirect naar /]*`
|
||||
|
||||
**Vertel:** "Sessie actief. Cookie geset. Maar — we hebben nog geen home page met user-data. Doen we volgende demo."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 4 — DEMO 2: Middleware + protected (20 min)
|
||||
|
||||
`[SLIDE 10]` `[SCHERM: editor]`
|
||||
|
||||
`*[utils/supabase/middleware.ts]*`
|
||||
|
||||
**Vertel:** "Middleware. Draait elke request. Refreshed de sessie zodat user niet om de minuut wordt uitgelogd. Code is template-werk, kopiëren mag."
|
||||
|
||||
`*[middleware.ts root]*`
|
||||
|
||||
**Vertel:** "Root middleware. Importeert `updateSession` uit utils, runt 'm. Matcher excludeert static assets — performance."
|
||||
|
||||
`*[app/page.tsx — protected]*`
|
||||
|
||||
```tsx
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
```
|
||||
|
||||
**Vertel:** "Server Component check. `auth.getUser()` returnt of user is ingelogd. Geen user? Redirect naar login. Wel? Render welkom-bericht."
|
||||
|
||||
`*[Test]*`
|
||||
|
||||
**Vertel:** "Niet ingelogd — gaat naar login. Inloggen — terug naar home. Werkt. Inkomende vraag uit publiek?"
|
||||
|
||||
💬 *Vraag: 'Hoe weet middleware welke user ingelogd is?'*
|
||||
|
||||
**Antwoord:** "Cookies. Browser stuurt Supabase auth cookies bij elke request. Middleware reads cookie, parseerd JWT, doet `getUser()` om te valideren bij Supabase. Resultaat cached, sessie refreshed indien nodig."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 5 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 11]`
|
||||
|
||||
**Vertel:** "Pauze. Vijftien minuten. Laatste pauze van het hele vak."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 6 — DEMO 3: RLS policies (30 min)
|
||||
|
||||
`[SLIDE 12]` `[SCHERM: editor + supabase + browser]`
|
||||
|
||||
**Vertel:** "Nu het hart van vandaag. RLS. Multi-user."
|
||||
|
||||
`*[Supabase SQL Editor]*`
|
||||
|
||||
```sql
|
||||
create table tasks (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) default auth.uid(),
|
||||
text text not null,
|
||||
done boolean default false,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table tasks enable row level security;
|
||||
```
|
||||
|
||||
**Vertel:** "Tasks tabel. `user_id` met `default auth.uid()` — bij insert wordt user_id automatisch de ingelogde user. RLS aanzetten."
|
||||
|
||||
```sql
|
||||
create policy "select own" on tasks for select using (auth.uid() = user_id);
|
||||
create policy "insert own" on tasks for insert with check (auth.uid() = user_id);
|
||||
create policy "update own" on tasks for update using (auth.uid() = user_id);
|
||||
create policy "delete own" on tasks for delete using (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
**Vertel:** "Vier policies. Select, insert, update, delete. `using` = check tijdens read. `with check` = check tijdens write. Default-deny: zonder policies kan niemand iets."
|
||||
|
||||
`*[app/page.tsx aanvullen met task UI]*`
|
||||
|
||||
`*[Test in browser — voeg task toe]*`
|
||||
|
||||
**Vertel:** "Werkt. Task in database. Maar — staat user_id daar? Open Supabase Table Editor."
|
||||
|
||||
`*[Toon — user_id is auto-filled met auth.uid()]*`
|
||||
|
||||
**Vertel:** "Magic. Default in schema deed het."
|
||||
|
||||
`*[Open incognito tab — login met andere mail]*`
|
||||
|
||||
**Vertel:** "Tweede gebruiker, andere browser-context. Home page laadt — geen tasks. Even niet vermengd. Voeg ander task toe."
|
||||
|
||||
`*[Toggle terug naar eerste tab]*`
|
||||
|
||||
**Vertel:** "Eerste gebruiker. Refresh. Eigen tasks alleen. Werkt. Multi-user isolation."
|
||||
|
||||
`*[Demo: probeer expliciet andermans data te halen]*`
|
||||
|
||||
```typescript
|
||||
// In Console of via direct supabase call:
|
||||
const { data } = await supabase.from("tasks").select("*");
|
||||
// Krijgt ALLEEN eigen tasks
|
||||
```
|
||||
|
||||
**Vertel:** "Postgres past de filter ALTIJD toe. Geen workaround vanuit client-side. RLS is sterke garantie."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 7 — DEMO 4: Social login (15 min)
|
||||
|
||||
`[SLIDE 13]` `[SCHERM: github + supabase + editor]`
|
||||
|
||||
**Vertel:** "Eén methode is genoeg, maar laat ik GitHub OAuth ook tonen. User-friendlier en common."
|
||||
|
||||
`*[GitHub Settings → Developer settings → OAuth Apps]*`
|
||||
|
||||
**Vertel:** "New OAuth App. Naam: tasks-app. Homepage: localhost:3000. Callback: `https://[project-id].supabase.co/auth/v1/callback` — die URL krijg je van Supabase."
|
||||
|
||||
`*[Save, generate Client Secret]*`
|
||||
|
||||
`*[Supabase Dashboard → Auth → Providers → GitHub]*`
|
||||
|
||||
**Vertel:** "Enable, plak Client ID, plak Secret, save."
|
||||
|
||||
`*[app/login/page.tsx — voeg GitHub button toe]*`
|
||||
|
||||
```tsx
|
||||
async function signInWithGitHub() {
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: "github",
|
||||
options: { redirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`*[Test — klik GitHub button]*`
|
||||
|
||||
**Vertel:** "Redirect naar GitHub. Klik Authorize. Terug. Ingelogd als GitHub user. RLS werkt zelfde — GitHub user heeft eigen `auth.uid()`, eigen tasks."
|
||||
|
||||
💬 *Vraag: 'Als ik magic link met test@mail.com gebruik én GitHub-account met dezelfde mail — twee verschillende users?'*
|
||||
|
||||
**Antwoord:** "Default: ja, twee aparte users. Supabase heeft 'Link Identity' optie om same-email users te mergen. Stel je in Auth settings. Voor demo: niet doen, complicates. Productie: meestal wel."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 8 — Eindopdracht recap (5 min)
|
||||
|
||||
`[SLIDE 14]`
|
||||
|
||||
**Vertel:** "Eindopdracht. Tijd om concreet te worden. Wat heb je nodig:
|
||||
|
||||
Next.js met TypeScript en Tailwind — eerste 10 lessen. Supabase Postgres met Auth en RLS — vandaag. AI SDK met Tool Calling of Agent — lessen 11 tot 13. Eventueel RAG — les 14. Cursor en Vercel deploy — les 15. Externe API — les 17. Alles geïntegreerd in een multi-user app — vandaag.
|
||||
|
||||
Ideale eindopdracht: een app die mensen écht zouden gebruiken. Voor jezelf, je ouders, je vereniging. Doet één ding goed. Heeft AI als kern-feature, niet als afterthought. Multi-user met auth. Deployed met productie URL.
|
||||
|
||||
Tijd: 40 tot 60 uur thuis, verspreid over 4 tot 6 weken. Niet één weekend van 80 uur. Push klein en vaak. Vraag hulp tijdig."
|
||||
|
||||
---
|
||||
|
||||
### BLOK 9 — Afsluiting cyclus (15 min)
|
||||
|
||||
`[SLIDE 15]`
|
||||
|
||||
**Vertel:** "Achttien lessen. Achtenvijftig uur klassikaal samen. Plus jullie thuiswerk. Een vol vak.
|
||||
|
||||
Even terugkijken. Les 1 — `pnpm create-next-app`. Vandaag — multi-user app met RLS, AI agents, Vercel preview deploys. Dat is een hele weg.
|
||||
|
||||
Wat je nu zelf kunt — een productie-app van scratch bouwen. AI integreren als kern, niet als gimmick. Externe APIs verwerken met OAuth en webhooks. Multi-user met auth en RLS. Code reviews via PR's. CI/CD pipelines opzetten.
|
||||
|
||||
Drie jaar geleden was dit een specialist-skill. Nu — basis. En jullie zijn er.
|
||||
|
||||
Voor je eindopdracht: werk er gestaag aan. Niet laatste week. Push klein en vaak. Vraag hulp via Slack, mail, of office hours. Eindopdracht-presentatie volgt na inleveren.
|
||||
|
||||
Bedankt voor jullie inzet. Het was een genoegen om met jullie te werken. Veel succes met de eindopdracht. En als je iets bouwt waar je trots op bent — laat het me weten. Echt waar.
|
||||
|
||||
Vragen?"
|
||||
|
||||
`*[Vragenronde — laat dit echt open. 10+ min over laten als kan]*`
|
||||
|
||||
---
|
||||
|
||||
## NA DE LES
|
||||
|
||||
- Push `tasks-app` referentie naar GitHub
|
||||
- Brightspace: eindopdracht-instructies + referentie-repos van alle 18 lessen
|
||||
- Email-template klaar voor "office hours" tijdens eindopdracht-fase
|
||||
- Voor jezelf: noteer welke onderdelen meer of minder werkten — voor Klas B / volgende cyclus
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| `cookies is not a function` | `await cookies()` in Next 15+ |
|
||||
| Magic link mail komt niet | Check spam, productie SMTP via Resend |
|
||||
| RLS error op insert | Default `auth.uid()` op user_id kolom, met check policy |
|
||||
| OAuth callback 404 | Auth → URL Configuration: callback URL exact match |
|
||||
| Tasks van anderen zichtbaar | RLS niet aan? `enable row level security` |
|
||||
| Loop login → home → login | Middleware werkt niet correct — check matcher |
|
||||
| Productie sessie verloopt direct | Site URL in Supabase niet bijgewerkt voor productie URL |
|
||||
332
Les18-Supabase-Auth-RLS/Les18-Huiswerk.md
Normal file
332
Les18-Supabase-Auth-RLS/Les18-Huiswerk.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Les 18 — Huiswerk
|
||||
## Multi-user Tasks app met RLS + social login + AUTH.md
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Deadline:** Voor eindopdracht-inlevering (4 weken)
|
||||
**Inleveren:** GitHub repo + `AUTH.md` in root + Vercel productie URL
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouw je auth-demo uit naar productie-niveau: tasks per user met RLS, GitHub social login, en deploy. Eindopdracht-ready.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Tasks tabel + RLS (verplicht)
|
||||
|
||||
### A1 — Schema
|
||||
|
||||
In Supabase SQL Editor:
|
||||
|
||||
```sql
|
||||
create table tasks (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) default auth.uid(),
|
||||
text text not null,
|
||||
done boolean default false,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table tasks enable row level security;
|
||||
|
||||
create policy "select own tasks" on tasks
|
||||
for select using (auth.uid() = user_id);
|
||||
|
||||
create policy "insert own tasks" on tasks
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
create policy "update own tasks" on tasks
|
||||
for update using (auth.uid() = user_id);
|
||||
|
||||
create policy "delete own tasks" on tasks
|
||||
for delete using (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### A2 — Tasks UI
|
||||
|
||||
`app/page.tsx` — extend met tasks lijst + form:
|
||||
|
||||
```tsx
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function addTask(formData: FormData) {
|
||||
"use server";
|
||||
const supabase = await createClient();
|
||||
await supabase.from("tasks").insert({ text: formData.get("text") as string });
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
async function toggleTask(id: number, done: boolean) {
|
||||
"use server";
|
||||
const supabase = await createClient();
|
||||
await supabase.from("tasks").update({ done: !done }).eq("id", id);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
async function deleteTask(id: number) {
|
||||
"use server";
|
||||
const supabase = await createClient();
|
||||
await supabase.from("tasks").delete().eq("id", id);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { data: tasks } = await supabase
|
||||
.from("tasks")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return (
|
||||
<main className="p-8 max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl mb-4">Tasks van {user.email}</h1>
|
||||
|
||||
<form action={addTask} className="flex gap-2 mb-6">
|
||||
<input name="text" className="border p-2 flex-1" placeholder="Nieuwe taak..." />
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded">+</button>
|
||||
</form>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{tasks?.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2 p-2 border rounded">
|
||||
<form action={toggleTask.bind(null, t.id, t.done)}>
|
||||
<button className={t.done ? "line-through" : ""}>{t.text}</button>
|
||||
</form>
|
||||
<form action={deleteTask.bind(null, t.id)} className="ml-auto">
|
||||
<button className="text-red-500">×</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] `tasks` tabel met RLS aan
|
||||
- [ ] 4 policies: select / insert / update / delete
|
||||
- [ ] UI: add, toggle, delete tasks werkt
|
||||
- [ ] In incognito met ander account: andere tasks zichtbaar (geen vermenging)
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — Social login (GitHub) (verplicht)
|
||||
|
||||
### B1 — GitHub OAuth App
|
||||
|
||||
1. github.com → Settings → Developer settings → OAuth Apps → New
|
||||
2. Application name: jouw app
|
||||
3. Homepage URL: `http://localhost:3000` (dev) of `https://yourapp.vercel.app` (prod)
|
||||
4. Callback URL: `https://your-project.supabase.co/auth/v1/callback`
|
||||
5. Save, generate Client Secret
|
||||
|
||||
### B2 — Configure in Supabase
|
||||
|
||||
1. Dashboard → Auth → Providers → GitHub
|
||||
2. Enable + plak Client ID + Secret
|
||||
|
||||
### B3 — Button toevoegen
|
||||
|
||||
In `app/login/page.tsx`:
|
||||
|
||||
```tsx
|
||||
async function signInWithGitHub() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: "github",
|
||||
options: { redirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
}
|
||||
|
||||
// In render:
|
||||
<button onClick={signInWithGitHub} className="border px-4 py-2 mt-3 rounded">
|
||||
Of: Login met GitHub
|
||||
</button>
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] GitHub OAuth App aangemaakt
|
||||
- [ ] Supabase configured
|
||||
- [ ] Klik op GitHub button → redirect → ingelogd
|
||||
- [ ] Tasks werken met GitHub-account
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — Deploy naar Vercel (verplicht)
|
||||
|
||||
Combineert Les 14/15:
|
||||
|
||||
1. Push naar GitHub
|
||||
2. Vercel: import repo
|
||||
3. Add env vars: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
4. Deploy
|
||||
5. Update Supabase Auth → URL Configuration:
|
||||
- **Site URL:** `https://yourapp.vercel.app`
|
||||
- **Redirect URLs:** `https://yourapp.vercel.app/**`, `http://localhost:3000/**`
|
||||
6. Update GitHub OAuth App callback URL to productie
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] App live op Vercel
|
||||
- [ ] Magic link werkt op productie URL
|
||||
- [ ] GitHub login werkt op productie URL
|
||||
- [ ] Tasks per user werken op productie
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel D — `AUTH.md` documentatie (verplicht)
|
||||
|
||||
In repo-root.
|
||||
|
||||
### Sectie 1 — Auth methoden
|
||||
|
||||
Welke auth methoden ondersteund:
|
||||
- Magic link (default)
|
||||
- GitHub OAuth
|
||||
- (eventuele extra's)
|
||||
|
||||
### Sectie 2 — RLS policies
|
||||
|
||||
Lijst je policies + waarom:
|
||||
|
||||
```markdown
|
||||
**Table: tasks**
|
||||
- `select own tasks` — users zien alleen eigen tasks
|
||||
- `insert own tasks` — voorkomt inserten op andermans naam
|
||||
- `update own tasks` — alleen eigen toggle
|
||||
- `delete own tasks` — alleen eigen delete
|
||||
```
|
||||
|
||||
### Sectie 3 — Test van multi-user isolation
|
||||
|
||||
Bewijs dat RLS werkt:
|
||||
- Screenshot van Account A's tasks
|
||||
- Screenshot van Account B's tasks (incognito)
|
||||
- Bewijs: account A's tasks staan NIET in account B's lijst
|
||||
|
||||
### Sectie 4 — Deployment checklist
|
||||
|
||||
Wat je moest aanpassen voor productie:
|
||||
- Site URL in Supabase
|
||||
- Redirect URLs in Supabase
|
||||
- GitHub OAuth callback URL
|
||||
- Env vars in Vercel
|
||||
|
||||
### Sectie 5 — Eindopdracht-architectuur (~250 woorden)
|
||||
|
||||
Concrete plan voor je eigen eindopdracht:
|
||||
- App naam + doel
|
||||
- User-flows (signup, login, kernfeature)
|
||||
- Welke tabellen + RLS policies
|
||||
- AI feature(s) en hoe ze user-context gebruiken
|
||||
- Externe APIs (uit Les 17)
|
||||
- Tijd-plan (4-6 weken)
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 800 woorden
|
||||
- Screenshots zijn waardevol
|
||||
- Concrete plan, geen vaagheid
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — Email + password naast magic link
|
||||
|
||||
Voeg klassieke signup/login toe. Combine met magic link.
|
||||
|
||||
### Bonus 2 — Profile pagina
|
||||
|
||||
`/profile` — user kan naam, avatar URL, etc. instellen. Aparte `profiles` tabel met RLS.
|
||||
|
||||
### Bonus 3 — Team feature
|
||||
|
||||
Multi-user binnen team. Aparte tabel `teams`, junction `team_members`. RLS policies die team-leden toelaten elkaars data te zien.
|
||||
|
||||
### Bonus 4 — AI integration met user context
|
||||
|
||||
Voeg een AI chat toe waar de user-tasks deel zijn van de context. AI weet wat jouw tasks zijn (door RLS), kan helpen prioriteren.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **Vercel productie URL** in Brightspace
|
||||
3. **`AUTH.md`** in repo-root
|
||||
4. **Test-account credentials** voor docent (email of GitHub)
|
||||
5. **2-3 screenshots** van multi-user test
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — Tasks + RLS werkt (4 policies) | 3 |
|
||||
| B — GitHub OAuth werkt | 2 |
|
||||
| C — Deployed op Vercel + auth werkt op prod | 2 |
|
||||
| D — AUTH.md compleet (5 secties) | 2 |
|
||||
| Multi-user isolation bewezen | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — Tasks + RLS | 50 min |
|
||||
| B — GitHub OAuth | 25 min |
|
||||
| C — Deploy | 20 min |
|
||||
| D — AUTH.md | 35 min |
|
||||
| **Totaal** | **~2 uur 10 min** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Tasks van andere users zichtbaar | RLS niet aan, of policy `using (true)` |
|
||||
| Insert faalt met "row violates" | RLS check faalt — check `with check (auth.uid() = user_id)` + default |
|
||||
| OAuth redirect 404 | Site URL niet bijgewerkt voor productie |
|
||||
| Magic link werkt lokaal niet prod | Site URL in Supabase staat nog op localhost |
|
||||
| Cookies werken niet | Middleware draait niet — check matcher en path |
|
||||
| `auth.uid()` returnt null | User niet ingelogd — check session in Server Component |
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Test isolation in incognito** — alleen daar ben je écht "another user"
|
||||
- **RLS error messages** kunnen vaag zijn — log policy + auth.uid() in Postgres function voor debug
|
||||
- **Productie env vars** — vergeet niet je Vercel env vars te zetten
|
||||
- **Eindopdracht-koppeling:** dit huiswerk is je startpunt — bouw eindopdracht hier op
|
||||
- **Veel succes met de eindopdracht!**
|
||||
|
||||
---
|
||||
|
||||
## Voor je begint aan de eindopdracht
|
||||
|
||||
Als afsluiting:
|
||||
|
||||
1. Plan je eindopdracht concreet (Sectie 5 van AUTH.md is de basis)
|
||||
2. Start een nieuwe repo van scratch óf bouw verder op deze
|
||||
3. Push klein en vaak — niet één big commit aan eind
|
||||
4. Vraag hulp tijdig — Slack, mail, office hours
|
||||
5. Plan tijd in van min 8-10 uur/week voor 4-6 weken
|
||||
|
||||
**Bedankt voor jullie inzet deze 18 lessen. Veel succes met de eindopdracht!**
|
||||
137
Les18-Supabase-Auth-RLS/Les18-Huiswerk.pdf
Normal file
137
Les18-Supabase-Auth-RLS/Les18-Huiswerk.pdf
Normal file
@@ -0,0 +1,137 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521113057+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521113057+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 18 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2319
|
||||
>>
|
||||
stream
|
||||
GauHL>Ar7U(4Ol=3#J<=dj>JV&k-B:%irlj!BRi`Nn\t:0Mj#A;'S`:0$RoKk-rY[78\s&fP.q'9jp]UiOP'5I-77B;#Fhs1Kss/^P((.(bcHC94/nL@JL[f>R$f&_Bk8\#`);V,TK?!bWfdDJdN\nAASuaMT/''+bY^bV%NqOf>TJ&U![iT113@V8RiJ:;(Rl&5$0Ioj;^mL,a'q9?LRej`k@$rk8h,or't<H_-BfCWsB#C;&mDm%?#`7qr9]LWNUE5?.X3*+1apSf,^qPO5]d-am6Aeg.pi#KDqUgHF)1&G6H)bY^C/$c1Hc8,,(\"IJn),$V\C+^1_G-F#3$.QX1D,$>cU'=5;;&PN:"f.2[o*8DmqCh=3bi\oeS(dBLk08pj29Vp`f5[U1N#VfrGMi*R<7KLQP0CSrmY7*H;5g)*He.<!FkAAN)n%%K%:676Vl16r(Xh'hgBah'2^MFeXi#C+OI.E)J1c7rCRbo2;Jn\:!Q$WJQa234<KKIRn>.<_:B.<!FkR+V@qO55lD8K44ZI<4aE=[XIR'Uo.3$'/2?`A6#3q&;rboI%!h9snA<EE=d!'ZLbuZ/>[mFHP!2RA[:6gbFlYQ[f5GAjJcMeXMYR1ZJM9rM;0ZihUL8"3\'bBd);K@')M8s7SBr"`9eFBaJ@1V/ie8cp2Dt#4#ePi(H7se-gX6W1>!="Poj(FmdXo>YdYa@0oC\r4e:AQsV0UZ`d,\Kuh4D3mGjBdV&;:T=^Cs:77YtWW@;#^rO$(5SmF,Dt55D^cEpE&eV,mW[h1'6FbQ)\lp+;C*,6e4tF#3_2bZ.a7`I?>I"-3FDq!`9]&R(H1:[JndSabq".EBR7;jYs)qaKcO+DiI*/&mca1LaP=+4d&![+*A],s;'Od>,=RfV;4:-\X$U!dKmsi?n>Diptee#u7J-CTPphb4tKQ6>nIL][hBIl1-dYh#`+_2U(A[*t,f`3T29+SBF)J(2DIA#[g:9UU=#.i#0no"^<_at/$XOiKY)6Ur^dV6(-mZp\E.Dr=pVl:=H5,n32K5:8.9;mFY]#l,565#_8ML1(k'HpA;AE^!&;ru7%'7:[JoZ=%_kK[>4ge/.\Dp47c.r5Dn`-f5$,E1-`[#&;ZKnHbY4YZ+W3O8FomVh'JL:$EP:NF.OTeAI*d^4R";s/S[(&F^U^?Dpg_]:If]0Yek!:4+f(gQIQ_=ndg"hEKSoc4pVGk'f6H5]2`C$oqpLJj6OH-N*%*cd*3/5ZXtO2++a,*\ch$@o6tGk>LuLp??q:sI"iE`/b^8Nn5Q7[YgsA,(T1eaaSBKG>f[H`nBXWKlu`MusqY3'NZ0_c\ll;Qkn&.u"X(PP90>dOk0$bT_7!@CsoCRkRCDeAI$2&>Wm-)tHIO:e45Q7t*T_6GeIohYs,2[mU;Nh2[43E\_TqHhrLUO2Zo'rlV\FeH/ssVCXEZ[X-JaXPei?#\cqi0T,]=rVZCh_*L&#+@?Rt/RDsI*]4>'Ta?kf%;@pH;5;@*.P"*W2.TIIPsQ>HB'#\U-$0^h*r$=H28,BqLm=E'dn(FJCmj'T;[dd=eZGI<R3$S7#4aHpFED_A$l"]Cd-eM/\I;-DdgekQlrYi_?\A9&Ch&cORIpPK%N!)!@ZA3h9iJ.Bjpl;$h=1Lt+ZcT)ij9Q,qAiT/4ap?f_HUB5LsMEBOCZF2%JUM[eZKZV^,eU=\kJ6"\d9?[XH!eD`+ht>QPjU_qN#Oh(_%NicO^q&3r5AuQ%f[Z>O&TdmTcW#Wt4Hg&RdF5r><g&s#iehMR@?6FM75@Wqt:7[U3Ge/?00e=%E;C09(f?OD5%GrO<QiM_N'gMUe,\*B'+,QKW's^%Erog/p\BZb@m"]*$'JQl+Ro^[X!%p*)@ed7oU6EX1L0\hrfg^9,,%\oQY4,&)36I60.EH!\9N`rnK63<FJ^0EA]%FA\`p?)HGrD:)WVG-/*?kM,0U3p*?%?2".iDI+F=G8&jNqYUi4AN@fEh.bP:I#[7D/\p-p>mpG?K7&a@C*Z5h]!Q]`p+:YO5D(f=X`)4eoeD]MVm`oGW2m,)0O%mEIuc<e)Ji63,'U&ff#fs9-:Z_>kV$XQR#QN\G1W=f\_2b>kWqm97C/BTpYB-LR%_c/,Y)%hm^nj3`m8<\Zh\-DMel^[GG%H/fb:Mm.i-0d3f.-kpdJ\uoi:agDT4HOT+fY[HISOTh80-jTUpGd%u2&RFF,nif]e2>Vdj/-]"Hi4l?"?Pqa5eop"rd5Z)bn)&Nkeh2*X9IqZ?[[`qQS(c7;#dXM1]lY.".o<k3pOXR<.WCj6tqrNBYL(O1X\jDkF??[]tH1B~>endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1786
|
||||
>>
|
||||
stream
|
||||
Gb"/'>Ar7S'RnZ;3%4LYFtG:D'LcTc!9_cf=I,hldqr3A<08&&N@_&lm%Pp!hfl#GRb3Y6,Ys=#90+93F5aZuJr(RQI/>^Mh%B9d;kOOgQU!Ri0KOf<On%*I9JD7V5V-VQ/d(*:q@D#2&V2NiE1'?bRLYGi#;.HfdZqUeTAr`Q;?`C?$jsobVB\)48SoWiXo0"IN$2t3Ca%U&pK-oW/Y5U',oqkGrZ`8_TTrCaWl9fdD9%CY%!os459.P0GbQsC)TO"kk,IpL3Lhhd^u4_4+No?f=(Bla$Pg\'9L\bu(k%"`$dG`)cRkW*bB.X]+/o[IKK&KFqkDN>')0>6"H`qj"8!(_=sF>S8a;\+=;oV[XgGTHTk1%="W&jKQqb`XL)(]O+f<@mLW3l4MA,7\etq$l)W,4AGXEdR)QFsk\B_LU_7"$fV@fIJpaBNu*uYjNde9Nj!1=94h_87`6@<I"X>W"PF>W`OWDPX?rfh'QJ$#9>n6SCkjWC$LEs\Zq@hL3L>1/_URI,1CL!\8lhd8J?U;1sZ]8b,SZ)NYAHNSa7qgfPM(o%n_Y<i/"IK=)pKh@O_5i4YM^<SBb1`O4)]LG<C`&-)i3u0:3dB!&W*R++(%$KZUmk4>]IKX87QFg;4Y0DNp2[aTUUl'K5`!E=F?LGs/">2Eeo[BRGWJK?2,-U)2jo5s,H4tT;jO<o.n`(M"q?;U'c\#nol2)t,<=2lag)-r:3`]UR8qs>66'Q$V:ql(6)m0<XeaAe0H@&s8??lGaSA%5AJV$Vr[8j?q:%LKhG5D0PP;N97r;7Y"bnKQ_b7pDGn"Ccu$ek>*N"pM\&2RG_0Z/&d0_?.G,tq:6;D2+U(C$8rd1uRYjgTKKqi5k6TXGOF(8/RfcJZajAMBOMC41olgtXMM+3$tB';B<To@`lDksas8@i]PXpu1lkN>KE5V*u5e$14u\n32E5SV@B3G7c>"&X(MQWZCSJ*snE_+Vm]0E0D%("#[=p@DgW#9<F-G!BW-Tb!IsA\Drgu>!iZ,NWfYH[\Tdq'?&t/e"t-3NGgX18o7l66ikib*+F!6VZOn:>V9b:BIF/:53NKnOg=T4Cr+>]QYn!XX+T*J>4$79BlEHV+tR\k"#B?5+^@L)!7sOG<u-SFlhB?lW[`DU:"7uNA%`UO@bD`L:N,p3YE9?^o>o713.`]K7#0/3=IDe%53nb&LMs6`KM+:kOqi57'1\?USZh!b5W?R@<k=U:qe7pXX_hiV["@n`GX2^-FRCNYPXS;/l`H;2gI,/(X:PDq.Y;m&=]pEM]j)"S=i@Rek>ihGM)JGD"5e&W(^J^4kG*8,55t%'N."L:,B4X[H!H]dW\.9niHbq`L(:9)Nf)B[>'ne$^&E=`05!</Hd&ksn+!$Wa5;7)*e.NC@JgiacV*5L8pqH2X2T!,33uhufVjC#o>sULLXej/mUVn`9?XE=eagh>[G4NDnn`LmQmHa@A*QYF($^T&l[Hfrl3t[B:sf=[?Ab`Spu'KGq8qmhdG`sXnFU7q3uB$/j*:(amPfdY\&[[H*W3=^!"Ujeo#D[MbIDum3R3)d%f%q!lcGt@)hnAWiLsZ^q7\>;qCsKF&.@rONh+2\U#LVAB(9ODF/AAc+#<c>0Y:*-V>W2[U6C60gD"iCA1H;95IjWD\'+ZWG_f9ZUQ2Z[8?Cr3`S0/,[97NA&dX,D_Hs@EM6!.A5+OkhYDr2Mh4"O2C(ss)rbXUGHJKs?R/(j([CeCqXU:qGJD!W,-e)u`eOEkgQ%Ao`Dk>pS<_c)a[4=$9)#jXp7[iW~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2152
|
||||
>>
|
||||
stream
|
||||
Gb"/'96Zjb']/F3m\Y'RG+p0(5g6=Xkp)U[:*4I3)fIDY0aSs03,Z'ih`b-$@Jil@P-,]b2?rEX%paVXl;FkV7EoJ0!PBJiX*57+K@'j!:,n2-iU+__AeF/[T=[?.@64Jl&if^ub"F5if-=V;!Xcg<M=<NE6&?/(!/C)mTDDAV\9MFZ+MMLcJccV7on9e4h]X%N.%1h$o7GNgXaWU;(D*e#%b5CP#_jR;CK-A[E+2btG@?`P_*9$f:Z,>\asri0Q,;.aPgQjm0k3BeSK&\N<Dq@&L,\ZmVQ.^J?9a4m'oZI4=Ta'>\:crP^PmXl/"cs(B1XtIj`Q4Q.D/@F&=`dJB67mDjKV`ah\O/XOAWI8j-G=h&J"QF#FWP9dTlM?;N(@h[^d71A"k!_$2$G>]]40F,-/J6(K=52U&%soNXK+UYoVPWknWE"7&XL;SQZ`"d`"Sc#I8\*O_$2Q8F]3RA37=\@pjRm9e@#XqUM\GX*6UL9_<X![.,kQZ[bjj0ID;oXs71[f'LXh>mP<&]_Xe,pC)L9fK)3/`2*J=mLoNHf?l=p@Mg3BaKe>MN48?KIBh]6Frq*<s$?6_NcB0*Q=)VQh/$8seA4_:4/Pi0V,B1%oAo0<.N-cnXSu=b4-\Oh(KYh`agQKMRGZ1r4Z.?Q"UM#)G&-;Zd[_uU:<?NO\_Z\\:=;1."0qnI3"?a6<_jm&oi/mc2c:D,*AguC0%4SB<t\IRb7^4N[^?($ob$+FMTP2p-@67Zq9/"?'FJ=[f7('FBl[5(n.Okl=K2c:=Z-qBlnFnnMV68qbiTElqnjtePJ^OW`[$gA_`CXTe?Z)5qk/g=.J%;Sl3_FG*#O?+Ui>S!*DU"'iWO]8-a8@diJfBdog<@GLo)r=:6TPgcbir,r/FUapn`j\RO]LZkV*:[]EkR<h(Y=(nL0uJn)0%X3T=n=a/Xs=&=N8H/>HoW,9+p?jgmA\raW)?F*%O_`;"W<Sk.s(5\_]F$kD;DR9[lS_P1/]q=BL34elN'o5I2!&:V[jW7R!DfcXgDPH3L*hcSa6FLrJ.W\?!NFPcsrB09%^iS&[]c?T6)FiJ3^cRK?3<G8t-1>Q,FoXURbTF6F"V0^a?N#b-[^:uO-E?/>BX4GJ*n#"FJ,fREna_Qp+Ao4a7@>OQA@;R&o'$RP'nXK<!k'"&uC$qls>pQC;F7i[Gbuh&nOKHC*QC?b)a9q3O,.3317bF!gm%7p;-gRA4O(=WoKad9M.5rV6>mXIa8b--O3WOrm-?t6`*jTA]EEJ".>jrCin?Be'`%8-lG;*1Mo)"_@+&+K=:Sk^;"s;E)F^7;#<o#U.O%<gq"m-rnX>%`@r5S:!O5J6dXR]n)70MIN<dFGh=[edB!u^YBK*.mJBY,QmC;`a(0aYLZq_J\&Jj)6hoTO6c-PfFP6cT$&;<obhL@\,L8>$npB_okq2[_'H+*-:&!8(q2EVW)!p>E69<j%/$EiL03.L)TibX2;/@bFs4ZYW9!bJr2QZDAZC$-th/<W?f^&F>M#Ju82A^L_Ce:&fm/@2b>G0T:7+-s__#NU!lIcK9*pbALL%YBsF@PPTM^JWJs+YM4$N>'R`>(4pCeJ]qq8fSaFg<\akZ^pu!@P=S%m%r^YVe/jGui)YE3hmB8,JR&oqfssZH=_)p<G"nO@5&AWT9#I3tT5QhL:ZPd;0#bXR#qZ57-D=S[OfH;Qh#l19V=n;ak**RMrsBW=#j*7L0OJe>1mA/A$%.#:&JsRm1LuDLUOT.[[QSURRGk#QVXE0Z?-&c=J8a#FM8U$"=&2q9Q+-l,V+IBXfJj9#qHbDu7]G/RM!$f#m%pSh1LM%9U;[WTkXjU6J>.=:!>=l*3aP<92peujLj3WX;f%F-iKoI`m<1bdBcB2[R)6Tk+LdpcK<Ns[ILU\enc4JcGL&apOH"jPNUD15TR&\<W,UO"V8T()`7^U(i8>T92u4YGXYQ)lDSl$lL/mW8XiFMD,TTO1Jk4L'E2:*nZ7QT*F_>lL"_!VP1fNLm1`[-[WWAQ&%C;9S:5mLY7]i)!L("Gs^RR4R-HXf72"\-&=]n&fclYOlZ@Z%a=dh5kH;Xio/(9DYY>Wub>Td_>+gWF8+,lgZ5"j?ne_?a)ns68dN.5b,5tDtqKq%u-`>rN776,/W#[%*/=NLHi~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 726
|
||||
>>
|
||||
stream
|
||||
GarnS9lHLd'YO<E9Fm8U87(c%Z7JL9D3RRgS#F9=j?heD-H>!7@8$RlI1&#X)V#srB6InMbo6i.Qg14-(:]AZ^JRfKi`@N[1u`k0M^Q7e_D>Ms!smdV5Oop@hu-BT-7&^<9]SVL<eeJG)VQZLD.MT[/dta9RdbV\;%TF`U"']T"id!,G[D<6j.j!fN/@PQO8>rA(C;mtA!t6ZA@t9h9tdn0>"BB]!TG].hg[3GE*KHC8_6sBi2Nh-?+5(P]F^"5fqN3OVPO].Bhq1*gJZ^2,(;n,jj#pIf7"jL0>ikKpXDH2gidsaTR&E`4s[2A9VB^f*nf%=8r_B<##b%Hq")H40aqpi;@je/[Rc=(kPSf=(-4:)aV"anlpp!GinSq=SB:irWA)JGhCG5]U/*&SSCmX#ALoLRaN1RU#a(,rjX&j2Eni?2AZmo/)S`FO:+iKh54p*D)-QB/6ltM7icU&Y*t-cf3,4t'0r.).[LL.Bj!sr9HLG9*qPpq"\+fH0DA'[(UJcNOk;OA>jTti-ED2^!<TB'(H'q[EXA1`1r@m('H9dNMDg*Ei$(F*m8biCDZmsOnLf682a^4&H<pb1]i[MSVg[+`6i4A1`fMe><NG=6-r]&eY'rk?"3HYRXLTXci;_[et=&UT:-eSbVlQfVAh%`#Yq-0/g"RM*?FBq$o&8eXq*U0!.JMpI7N88_Kd7^289p_a,I/+=X(Ym(1_W3AN.L>b~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001325 00000 n
|
||||
0000001618 00000 n
|
||||
0000001696 00000 n
|
||||
0000004107 00000 n
|
||||
0000005985 00000 n
|
||||
0000008229 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<f588e83396de72a40617a65dfbfe026a><f588e83396de72a40617a65dfbfe026a>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 10 0 R
|
||||
/Root 9 0 R
|
||||
/Size 16
|
||||
>>
|
||||
startxref
|
||||
9046
|
||||
%%EOF
|
||||
283
Les18-Supabase-Auth-RLS/Les18-Lesopdracht.md
Normal file
283
Les18-Supabase-Auth-RLS/Les18-Lesopdracht.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Les 18 — Lesopdracht
|
||||
## Magic link login + protected home page
|
||||
|
||||
**Duur:** 30 min in-class
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Werkende Next.js app met Supabase magic link login. Niet ingelogd → redirect naar /login. Ingelogd → home page met "Welkom {email}".
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Setup
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest auth-demo \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd auth-demo
|
||||
pnpm add @supabase/supabase-js @supabase/ssr
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
|
||||
```
|
||||
|
||||
In Supabase Dashboard:
|
||||
- Auth → Email → enable "Magic Links"
|
||||
- Auth → URL Configuration → Site URL: `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Supabase clients
|
||||
|
||||
`utils/supabase/client.ts`:
|
||||
```typescript
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`utils/supabase/server.ts`:
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — Middleware
|
||||
|
||||
`utils/supabase/middleware.ts`:
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let response = NextResponse.next({ request });
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return request.cookies.getAll(); },
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
);
|
||||
response = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await supabase.auth.getUser();
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
`middleware.ts` (root):
|
||||
```typescript
|
||||
import { type NextRequest } from "next/server";
|
||||
import { updateSession } from "@/utils/supabase/middleware";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return await updateSession(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — Login page
|
||||
|
||||
`app/login/page.tsx`:
|
||||
```tsx
|
||||
"use client";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
async function signIn() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: { emailRedirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
if (sent) return (
|
||||
<main className="p-8 max-w-md mx-auto">
|
||||
<h1 className="text-2xl mb-4">Check je email!</h1>
|
||||
<p>We hebben een magic link gestuurd naar <b>{email}</b>.</p>
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="p-8 max-w-md mx-auto">
|
||||
<h1 className="text-2xl mb-4">Login</h1>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="jouw@email.nl"
|
||||
className="border p-2 w-full mb-3"
|
||||
/>
|
||||
<button
|
||||
onClick={signIn}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Magic Link
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Callback route
|
||||
|
||||
`app/auth/callback/route.ts`:
|
||||
```typescript
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
if (code) {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${origin}/`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — Protected home
|
||||
|
||||
`app/page.tsx`:
|
||||
```tsx
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<main className="p-8">
|
||||
<h1 className="text-3xl">Welkom {user.email}</h1>
|
||||
<form action={async () => {
|
||||
"use server";
|
||||
const sb = await createClient();
|
||||
await sb.auth.signOut();
|
||||
redirect("/login");
|
||||
}}>
|
||||
<button className="mt-4 underline">Uitloggen</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 7 — Test
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
1. Open http://localhost:3000 → redirect naar /login
|
||||
2. Vul echte email in (jouw eigen)
|
||||
3. Klik Magic Link → "Check je email!"
|
||||
4. Check je inbox (en spam!) — magic link mail
|
||||
5. Klik link → redirect → home page met "Welkom {email}"
|
||||
6. Klik Uitloggen → terug naar login
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Supabase project met Magic Links ingeschakeld
|
||||
- [ ] Niet ingelogd → redirect naar /login
|
||||
- [ ] Magic link email komt aan
|
||||
- [ ] Klikken op link logt user in
|
||||
- [ ] Home page toont user email
|
||||
- [ ] Uitloggen werkt
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1 — Setup | 4 min |
|
||||
| 2-3 — Clients + middleware | 8 min |
|
||||
| 4 — Login page | 5 min |
|
||||
| 5 — Callback | 3 min |
|
||||
| 6 — Protected home | 5 min |
|
||||
| 7 — Test | 5 min |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| Email komt niet aan | Check spam. Productie: eigen SMTP via Resend |
|
||||
| `Invalid redirect_url` | Auth → URL Configuration → Site URL match |
|
||||
| Callback URL 404 | Maak `app/auth/callback/route.ts` aan |
|
||||
| Loop tussen login + home | Middleware werkt niet — check matcher |
|
||||
| `cookies is not a function` | `await cookies()` in Next 15+ |
|
||||
|
||||
---
|
||||
|
||||
## Klaar?
|
||||
|
||||
Verder met huiswerk: voeg RLS toe + tasks tabel + multi-user. Zie `Les18-Huiswerk.md`.
|
||||
137
Les18-Supabase-Auth-RLS/Les18-Lesopdracht.pdf
Normal file
137
Les18-Supabase-Auth-RLS/Les18-Lesopdracht.pdf
Normal file
@@ -0,0 +1,137 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521113057+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521113057+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 18 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2109
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>E@Xl8m:M)+U-s.>d'QNV?toHMVqlk'JLWI4)Hh)+\`lr_^[F]bPaj^Ved^<!`doF6jMO(M\`Z_"5k2bYG;98F3e=CL$lFcA#r&"H0\M)LM0Inj*q7OC5_PjUi]n\rrh)CHEf^;.+s&I81aFRC_,lRD'e/'#0+:thV1t1(*Wm#Z1fQXM`FPWnC\%]936.kD,(h#L3kr'u)=o$b[+:q=bs-g=#hD&u:k_[nPG*ZO$qC,thX/0u`Mp#'Z_GP!YV('=<l5O4+`-V-\#V!\C-@:6qNK3^q+TuE6^W7SaWn.C!8ls'iK537j6u4>Qiao7o4]((kh#?=R`E2J=Dr.c40X7E`Wh$W0TlL`A*p.?!R@0[IOL\VVN>42,+Jl"al!:'-$\;W+Rr]h0gVb&bfoJO![9rHJO<ouC.>bQ>Yj>g*bSfkR-UG/)XS>64Gm`]pt^sK0;]P2kad!De:]9i"o?N,0#A5kiGSR;^2nje,#gqeGt=m.;Y@cqW#at8F$7Nef5SI9*nmC:I^+Ci])e*`18\1B(_T]O#ih`h1\5=T@I"*"&X\)7@du?c"t[bBB;+5b68XhQ0H.7L13Z"FjZp%J_5pUl[EoMB3&=#Hnk4po"L9jKLTJ:QJ0/3+(:?n2bafJ)%J0?oDi>=I%ZSnEPRIf,R$XLe""l]N+*SZUQ;METGOj>0a3CI*'Qg;L?PXAeQZ9q0C("K.EqCtJJ%T=ad&IAnAGj1o2I"2XX@g[ZTbH>E_t1jA]S.$VjFb6u:%LfUF%GnBP/UFDD5B0m<ck5k6V=l*J@KXRrIBF/!S;D:gL%5WdY<d6)>HMI6k)U;*3?#u+%Z]^iSFO,%9)Tf6`)TVOrBK7SX[Q/F9n]dE&qNp,EP5DlOXjuhaGtF-8@<u[>[lU4U<O!6c]',kMde'&Zscp;lDuSn07BWD$MoTeCp>+)8(G%\T_EDdqc[ZLdm9]SmeEe'G;$9.ArH$?srsN)i`.u3inDGXQ@\>=&@Y5/.G$!*NbdcSG&&>L!D5hb^+^og(=o*SU*Xmmr`o9c>)"`T54#&Jp9J7RBGLV0onQ;Q%ms,2'T8siq"uk`@*g=_Y=r"C*2dPqQ*F8:e0KlKD.Dg04s]Bcf-eYqLI@mgqF+dL`-4^Er3S5GMMsf'nO0l<l,CF!0\WSM<<C^@>_pT6ufoSO/mGgOm7H-HXo&^nQ[0m@,A/VhK<6\$+/PtA"'Z4S,lncJd-DmRE+eGlQ9"07,9=>=3BBGg)J^?G&@^NDc>lM'Z_h,YS^4fnruufL3\Y"Y#(Li9qiP,C4nJ!I.]P9[j`*YSeZOtkG65F9DAa74NE48`?+5h1#J5enApH?0g&hXDc,fT[1??bn*:!Q,_]<7"B4bc1U6E6onQA9L;P(_N>[sU&l439b(lgW<.o;iK+;-Um3$%mlTqJ`@Aj_e20_fA0\W^AfV!O4BQ%ZJ^\=V??-*:%8F0d4<Fk<.'nNY?3,d)WT$M&2PWGs)jJXA7'Tsg][]18*7=@(cl<+/JEW+K/h^cik?CM-K_.AL-4#XuX^I*+DESO"SVIN\#VEO/>Bu@g1g)*V`n>'gLD6(8dU\#"AX)]H8n?/P?5>Ll>kFY:G,]SRH?)P&j@2iK-g::]-dbO'Ak+JRogd;CNbkMm]q!d\-FTK0n,a/A?N.9Hu44QcK(V7[l?0q,=egOYj<h"?WUX2lt@KfiF8u#n_Y]df@PP?-Og-Tm-`1g#5ap$3o@g?^")3c&\k%f#g?%15`MSTEBH8[_Y!K2%rND,8+\cqVlm"k.\-N]%hk.\V.qZ.>\QVt=]^s%_7)<<>f4XLcNL._ZPL\>,_?gE^c!Mbg^5[+/G[^KQq,ApOs@;E@W-(W;=ZA(#L8pOSUQcPG)((%^l8Vr_n]d:M/p[`]UgjC8nN')d.Gne!fpqBY])PKB",>K#";Put?g<pgX1cfpg?e-kZ6Xj*'(&S@,NM'UBq'qGc7dGVQP]?<D8:q$,kC>s%Pt`M#4+Bqa?QVJfgO46mE;4bVl1Vs=0rB^JIThfCPeCu=]"S,Ys7l>Z5;s`=ASQajLDs!#R%h?5dP#31>/:().IBl/R^e9?-CU1m17_/rI2SBp^N"i!2o6Lq%f~>endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 851
|
||||
>>
|
||||
stream
|
||||
GasIegMYJ*&:O:S%!bGp)"4X%g'U+X+=^CQ(3mY$)/#-O`HH!7H;WFHBC33P_PN*ah<\\7baC8nVEk&HgGR4V?;2Sj97\jcc@L#s0Kk+Ke)2E8-CNip0[_4%%+%De#HC3hJ<2/g1DDZCW"fQ?.;E]V0H[2,<DZhc*s<9S8YS$He5Ssjs,)k61?MMV'N1Aaj#__$Tk@2Flo`P;f7S>Zg(i/57N;^L9#*9=n_*fC-h+9DfK)/Kb9_OUPtfRF`/H-)5'_<Vf3.M-$sq[]].@$o/_gK+^L2B7jP%K6N$C,`mGabl)Q3kTQ)e&Qo["\C791\L[]hrA>p$j-lc9E#;/i^TRKl@i2:@E6mi1d9d/pL,1?%U)npi6u&\PS)"0P3/310QF80j_(D2Wl48V>kh"[sU<D4u8[j?"P1GQ=X.Cn,eU]u<>JLLi_JBta0u)8O=T.h0QbB'Bghf.MNNB)%buk<V18c*J:>n8/<*SRR2^S!0u2))R:-kpKc3;3JDj$U`"AfkbB+P00N"+Wa]rl/Kh"qqkm[KgZO%@V#;7X;p#I]3/8rZe+W/]ia%C_*-#C15A`cD..:^93[C9p)k$"<!p?t]O&hqB0TTbW+FAo^'rUG>Lm5r?6qh"g4hsDqpH>=#%;VCk5Sb:"JU&n)`%,u-'Qh;71/X/Oh-:jOO>Yg.Jn'gK6RI,O&M/F3S0_-8\j50\#p'/Y*.#rf%h^0-[Vj,WkD38KtMojQOB%?dZ4bJ7AqEMoX5<F=k)h"cPH"t?^8jmVWPSC<LG3e4h&rbHlIe03$,hZnmaI$/ob$?[lIuEZESQEj>-=0cnFnl&)t^pqpR^QmIn!bURek)"MXtM*->c~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1707
|
||||
>>
|
||||
stream
|
||||
Gb"/'?#SIU'ReT:\-r#g'HKnG'M3F=YqBn&!a49Sme/=FW2_P[)Hh),WpLAm^[LI*PjANM$4PT\PG")BH-a/L%s\Z-JDr4trf'tZ5L3a6S,qY(])X16JCuA6Rs?)=_Bi8-!9.eZLE&kKf0<(_]F4j5AB7+BROY%s!21:X3n1r":FW060Q?Qt@mH9S8dcc1"]ENZq*'q2@qe41.Du,^Y!64$(D>DDLMaJL#_jR+2^sOI$US!h!gp)to((<Q0AtH0@ENh_k:+'OKo)-3k+!rHo&>EPAXSe6SAS:sae\`YAMST<ZO\a::tHWD$SD\qL"aL12'jpcprM(M7HC+K7uJXN^`Qt_NpF7[PIt[3;,)G1.bp(h(Z"\&EO7BpOKe;#5h]9U90KE`V*Y]%>^)qG@7p*!,hS-64Vu!PVAZG.>U[O1@/pelBC-Pf^bua6KA6.MA,%YA^SD?q\gjsTq^teEi0s"qh)#<lO1hCAn>cJ)[t"7ek"e?P8T[Ib_6u'j\7XM$L:Fr-FL,gnYV1[;bImipi6)<h,lB^;i$;\REd%cgNK)L3=t6')8-N*c$St6VJ]ea"*Ah;7'OgM7\S"QmL(&=f#]Inh4J>`$qHPTEKVim$+*'kN&h9gu7eCjtm.CTd@6UI!JT<J?I*V$;hLZtTgHhtE(I>#!6d5k4p#'W>K@.D69aTY%JbHd#q>G.+f,H0t6eL`B]nm?2`gh#oR*^N_X?FT/<G<RN'0Q7!5Mgbg\3+,]qH9^BX*%`N\:V4EOYnKq!,USfaB*?Me$QDP>#*7U*PIM+1VI:LqPN4.fWBZ5,-.ebkZBP\a'uR(m2U$^6n&^)'MUmbV($Y,/tsi'E)C2O:%GB^nU*)VKWEj#19F,u>]%gfi]J_Cr+/o+P!4QIfOsm3hf.po1nPa-bR[2jJsQQG67k7JW;J$$qrZ%3OhJ8/G>^8qXi&ic\(r6ECUfWei`K[k7s6B@!2Ik;dK>Z2Z:iX>pLds%YO$R!)=(-BEAd'/"DF-?,Rs4n:e<B4bLcFXP`Lnj%DnMn:J_&Y/:Rfk^;!;333!4F]L[!c2q,@kBcNu6q^B1+#FhuU%#%!p^<t4#Em]Ck)%;f]2$L/S_9(-DQghhuNIEh3LsX1(TP)4*p<e,Cq16=s]cCAq>88%"-b!a1O9hO;GMO<KF4oU.P6+2<8o1^B7nL.l/:<?'arFbG1Yb6eSLgOPJV!X8+ENSc]AnGC@\[C@/89.eLDuV@PSF4]^='p!mji2DFC:e!M(apn=rM9"s+]qeL=7E[4IW]g7^.<5.%g)Pr+LD3L#AmL[@uol`cne:f#'FV@d.[Ym*.KPFK`t=\.@a:[*Ip3]83-$.m*$f3#0,lra4Ip6_C-MA4-+0<..arhmk,3F?@Qdi$,RUEZ^fuEY7#XdD"0p];H\&Rnb,1YBSN,g2%\4j]?-O=5'Y%'VA&RAq>,9d8bF*^=Y5?Y#-3d99irTY,DHe];+)&c>(\:r:4Y^T+pEtAP2h*cJ"V)+1%koXc'_V!Ju\Yq)U""<V[20=i]bI6Y8O"gTsg[MH!#*H6Ma6Am6P+cYK",X@,m`:r;O:`cZW(;j.SgiAu%-cI[aBRWiP\f2k#CIGqi\^mQiqUX^aEkjuH3A',ECDqk%XJN`ZGE@Q=8ff><@?V/Yu\G68%gt'*Nqeu.m8aN4<Rr@kDO7Fdh6XF?uIoJjSO[S=OQMG8s?GZLT@9]kR~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2004
|
||||
>>
|
||||
stream
|
||||
Gb"/'lYkN9&HAoJi`;^;q?TenPXoq"LTYFEDFn2T>$qUiW%o$eQ6fUqRheHmpDk]>OqU'K!m@L\8L2]rhnAK'@R0J$m/LT+nCndL:Dkhrj"k(p#T+Z#PHjl0,<Y$b"@.?'rJ1Q4I.II%\X,YpOtbl-"%=M5;LhT&-l"2h81,^I:hhu%lS0M;Uf%LAM?T[\YL*.lj;_;1&qj\8RD+q^)=\m`[aLk:bqF\-%b<,k:jkVX$Y07t3UFG(q=Zg2/'`:&9T(*IPIf8c["_N),s6Lfs/;sGWn%jY?%::*Z'aji^'!C+0YV=I%IaN;KL\A3iZIFmQiao7Mb+dJkZ@;"R`?NT=Kg,T*E^Be@7*7'/.;[X_]t,G-q'i@CL_a%1_6]^G_#)lbWR+\@rSoCGsY@qfIo7gKS0[^LF]CZPB^u)TehAj1CN<q!YPk!6O1o`j*$WM^o\_Ys,_Mb8>QMtdiOJA]Fp;!_IWH"A8URXD>N#*RsI!(bbkc!d8M,2)o\&?2GOIjMb(c(%fu@o"(Z4R2]=up=H"$c\L)IlH+hfkatP#+.)dd7rk'Z%)CG;3?=l(5eX&CLZXo`XBouo4c1U>XkoKQ1*AW^%YXU=.!(i;S2,qafr'%8?quZCWSCWdd3j//9/B,C]W'>2*'kh)aWYNa]0T#,j!FD+X0&[au3qSFQM3mn"SEp"SGC0W7h^?k8]]53I_<KfP"s)5Hod%L%6YUIuZ3&M5ekS2bnRaPkKe%;Z/9<e<a(bPB@*0P)e(Lgi_cOa,BO&is*K4t9?t?b55jti[r7eB-$mMbt,*4]+[o>Z>a(hGu&W.,5Op@enF_dDc-)";:_&?DB/jYj5q/@YDp*?56_],J?8Z$qc_L58]%bkX@*[a4^OJ%-[]u``NYl7;\J>hL;;DIC3VnJ0DYu>/*q:\e)"l8<M\1SRP6b-[]#DTb&$CVfp"2>DuR/-;qJ0.tmM"%J?Js3'*XPc<7/"LVJ^$?Z*i6M'=4_hb"Ql<dm;sp)b8$;o&_iegBR"9p(=sK52UqsEtGOLbf\K^$089A.QcqsmOd/7b]B<0t2?"'/e)NM$`-#f76ZO5.92TL'>ZcP])$Db0l'mj9=;Xm25WYor`l:bUc+Z/<$'r3G8"l[^?fCQS>G)ag'oGQ9g#Zq\DpVckC[=!+<CA#6BV:K!V3h,@RMFF9Z]$*-RMc-)%S5MMRCFNhT!+69P:_uQ,2gs;(D4=ijW7DJnFct>>STAo(Ke@">e*6NOG-7_M&JEQCs)4kqfi$@Mgf!$ESUDuF''kY)-Q);`nNPFnh/1`@]F0K8P9r/^@6_3eF5\IheiIsbB'GaI,VgSll?_^K;FTP>@**lu=/_\"I(N.E7omqT"+O_VZq'TF0Co@5R/-R\0GDkSds\_(jWZ3oi6BRQq_'%4U+KVa1\<lqqbM*UmV3D=.mQaQ]SPa49Q`,&DrP?6`0!VU!1fRYEdT]m[3!@^DO19Ir[H.@M/2AU/B_i7l^%*$XYlW]3Q0bUO)Wa&9%Pq(U>!O?BVpp4EL+;!PF86^<:&Po;B<aHbYFL:Xr8T<`i(GS[2Ck]>Ki%,c&7:mF@:J)fnV#RfFGnQfhjeuAGRLiOKq=/3p;$&EgXm)hNXKh7i!6sMHQtL>Za9uTPrXo]0h+'\fFrn$p+Wf!\a3/)H^rsJ7-O:TD8-]9,!?94r+,NQ14/k,D@EEhYc>p!%qN2chj4eNu8pUDE'bQ7&oAd\[DW\?b$Uf^"`"`Z#^pqr;mX5OC[Um]VttU=,%/k_c=d6KRp=WY.XK!#Lt)MUjis(&@n\EeIt):^jC19:QQ>F=c8*FZm27K.D\Z-?dj0co:K2H':?YK]9%BKYo6usKg[aq<.Gj?"0CQ/lJF02J75:lLSc8DpB6/oM7$uRQ#d)7g!C]\Ce8+@?usFKa2'_fJ$ji:3/V=_4/RN7T3`5u60/61<bA4s0%7srqu_1bGRX>n!YnHhBEt*edsRS6l+VRq!.Up#]Wbm\s&hu^(6^P[iB.5!^Ur=LTE~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001325 00000 n
|
||||
0000001621 00000 n
|
||||
0000001699 00000 n
|
||||
0000003900 00000 n
|
||||
0000004842 00000 n
|
||||
0000006641 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<2b2591697dfee3e383d5e69da893bb63><2b2591697dfee3e383d5e69da893bb63>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 10 0 R
|
||||
/Root 9 0 R
|
||||
/Size 16
|
||||
>>
|
||||
startxref
|
||||
8737
|
||||
%%EOF
|
||||
542
Les18-Supabase-Auth-RLS/Les18-Lesstof.md
Normal file
542
Les18-Supabase-Auth-RLS/Les18-Lesstof.md
Normal file
@@ -0,0 +1,542 @@
|
||||
# Les 18 — Lesstof
|
||||
## Supabase Auth + RLS — multi-user apps in productie
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Vorige les:** Les 17 — Externe APIs in diepte
|
||||
**Volgende les:** Geen — eindopdracht thuis
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Auth vs Authz](#1-auth-vs-authz)
|
||||
2. [Supabase Auth — drie methoden](#2-supabase-auth--drie-methoden)
|
||||
3. [Magic link flow](#3-magic-link-flow)
|
||||
4. [Social login (OAuth)](#4-social-login-oauth)
|
||||
5. [Session in Next.js App Router](#5-session-in-nextjs-app-router)
|
||||
6. [Middleware — sessie refresh](#6-middleware--sessie-refresh)
|
||||
7. [RLS — wat en waarom](#7-rls--wat-en-waarom)
|
||||
8. [RLS policies in praktijk](#8-rls-policies-in-praktijk)
|
||||
9. [Protected routes + server checks](#9-protected-routes--server-checks)
|
||||
10. [Eindopdracht checklist](#10-eindopdracht-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 1. Auth vs Authz
|
||||
|
||||
Twee verschillende vragen — vaak verward.
|
||||
|
||||
- **Authentication (Auth)** — "wie ben jij?" — login flow
|
||||
- **Authorization (Authz)** — "wat mag jij?" — permissions
|
||||
|
||||
Voorbeeld: jij bent ingelogd als `tim@example.com` (auth). Dat betekent niet dat je een admin task kan deleten (authz). Authz checkt: heeft deze user de juiste rol/permissie voor deze actie?
|
||||
|
||||
In Supabase:
|
||||
- **Auth** wordt geregeld door Supabase Auth — magic link, social, password
|
||||
- **Authz** wordt geregeld door RLS — Row Level Security policies in Postgres
|
||||
|
||||
Geïntegreerd: Supabase Auth zet `auth.uid()` beschikbaar in Postgres, RLS policies gebruiken dat voor filtering.
|
||||
|
||||
---
|
||||
|
||||
## 2. Supabase Auth — drie methoden
|
||||
|
||||
### 2.1 Email + password
|
||||
|
||||
Klassiek. User registreert met email + wachtwoord.
|
||||
|
||||
```typescript
|
||||
const supabase = createClient(...);
|
||||
|
||||
// Signup
|
||||
await supabase.auth.signUp({
|
||||
email: "user@example.com",
|
||||
password: "geheim123",
|
||||
});
|
||||
|
||||
// Login
|
||||
await supabase.auth.signInWithPassword({
|
||||
email: "user@example.com",
|
||||
password: "geheim123",
|
||||
});
|
||||
|
||||
// Logout
|
||||
await supabase.auth.signOut();
|
||||
```
|
||||
|
||||
Voordelen: bekend, werkt offline (na login). Nadelen: wachtwoord-management (reset, lekken, weak passwords).
|
||||
|
||||
### 2.2 Magic link (passwordless)
|
||||
|
||||
User vult email in, krijgt link, klikt → ingelogd.
|
||||
|
||||
```typescript
|
||||
await supabase.auth.signInWithOtp({
|
||||
email: "user@example.com",
|
||||
options: {
|
||||
emailRedirectTo: `${location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
// Email is verstuurd. User klikt link → redirect naar callback → sessie actief.
|
||||
```
|
||||
|
||||
Voordelen: geen wachtwoord, secure (link bevat one-time token), modern. Nadelen: vereist email-access (geen offline login).
|
||||
|
||||
**Aanrader voor demo + meeste apps.**
|
||||
|
||||
### 2.3 Social login (OAuth)
|
||||
|
||||
"Login met Google", "Login met GitHub".
|
||||
|
||||
```typescript
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: "github", // of "google", "discord", "apple"...
|
||||
options: {
|
||||
redirectTo: `${location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Voordelen: geen account-creation, vertrouwde providers. Nadelen: vereist OAuth-setup bij provider.
|
||||
|
||||
Supabase ondersteunt 20+ providers: Google, GitHub, Discord, Twitter, Apple, Microsoft, Spotify, etc.
|
||||
|
||||
---
|
||||
|
||||
## 3. Magic link flow
|
||||
|
||||
```
|
||||
1. User opent /login
|
||||
2. Vult email in, klikt Submit
|
||||
3. Server: supabase.auth.signInWithOtp({ email })
|
||||
4. Supabase verstuurt email met magic link:
|
||||
https://your-project.supabase.co/auth/v1/verify?token=...&redirect_to=...
|
||||
5. User klikt link in email
|
||||
6. Supabase verifies token, redirect naar je app /auth/callback?code=...
|
||||
7. Callback route: exchange code voor sessie:
|
||||
await supabase.auth.exchangeCodeForSession(code)
|
||||
8. Cookie gezet, user is ingelogd
|
||||
```
|
||||
|
||||
### Callback route in Next.js
|
||||
|
||||
`app/auth/callback/route.ts`:
|
||||
```typescript
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
if (code) {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${origin}/`);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom email template
|
||||
|
||||
Supabase Dashboard → Auth → Email Templates → "Magic Link". Pas onderwerp + body aan. Variabelen: `{{ .Token }}`, `{{ .ConfirmationURL }}`.
|
||||
|
||||
Belangrijk: voor productie eigen SMTP setup (Resend, Sendgrid). Supabase's default SMTP is rate-limited en sender-reputation onbekend.
|
||||
|
||||
---
|
||||
|
||||
## 4. Social login (OAuth)
|
||||
|
||||
### Setup GitHub
|
||||
|
||||
1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
|
||||
2. **Application name:** "Mijn App"
|
||||
3. **Homepage URL:** `https://yourapp.vercel.app`
|
||||
4. **Authorization callback URL:** `https://your-project.supabase.co/auth/v1/callback`
|
||||
5. Save → kopieer Client ID + generate Client Secret
|
||||
|
||||
### Configure in Supabase
|
||||
|
||||
1. Supabase Dashboard → Auth → Providers → GitHub
|
||||
2. Enable
|
||||
3. Plak Client ID + Client Secret
|
||||
4. Save
|
||||
|
||||
### Frontend
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
async function signInWithGitHub() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: "github",
|
||||
options: { redirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
}
|
||||
|
||||
// Render button
|
||||
<button onClick={signInWithGitHub}>Login met GitHub</button>
|
||||
```
|
||||
|
||||
Klik → redirect naar GitHub → user authorize → terug naar je callback → ingelogd.
|
||||
|
||||
### Combineer providers
|
||||
|
||||
Same app kan magic link **én** GitHub login hebben. User kiest wat hij prefereert. Supabase merged users met dezelfde email automatisch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Session in Next.js App Router
|
||||
|
||||
Supabase Auth in Next.js is iets complexer dan andere libraries. Drie redenen:
|
||||
|
||||
- Cookies moeten gesynced tussen browser + server-side rendering
|
||||
- Sessies verlopen — middleware refreshed automatisch
|
||||
- Server components hebben aparte client nodig dan client components
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
pnpm add @supabase/supabase-js @supabase/ssr
|
||||
```
|
||||
|
||||
### Vier files
|
||||
|
||||
**`utils/supabase/client.ts`** — voor Client Components:
|
||||
```typescript
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**`utils/supabase/server.ts`** — voor Server Components / actions:
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return cookieStore.getAll(); },
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**`utils/supabase/middleware.ts`** + **`middleware.ts` (root)** — zie volgende sectie.
|
||||
|
||||
Supabase heeft een **template** voor deze exacte setup. Niet zelf uitvinden, kopieer van docs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Middleware — sessie refresh
|
||||
|
||||
Sessies verlopen na een uur (default). Zonder refresh moet user steeds opnieuw inloggen.
|
||||
|
||||
`utils/supabase/middleware.ts`:
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let response = NextResponse.next({ request });
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return request.cookies.getAll(); },
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||
response = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await supabase.auth.getUser(); // refreshed sessie
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
`middleware.ts` (root):
|
||||
```typescript
|
||||
import { type NextRequest } from "next/server";
|
||||
import { updateSession } from "@/utils/supabase/middleware";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return await updateSession(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
Elke request → middleware draait → sessie auto-refreshed → user blijft ingelogd.
|
||||
|
||||
---
|
||||
|
||||
## 7. RLS — wat en waarom
|
||||
|
||||
### Zonder RLS
|
||||
|
||||
```typescript
|
||||
// Client met anon key
|
||||
const { data } = await supabase.from("tasks").select("*");
|
||||
// → Returns ALL tasks from ALL users — disaster
|
||||
```
|
||||
|
||||
Met alleen anon key in browser kan iedereen alles. Niet acceptabel voor multi-user apps.
|
||||
|
||||
### Met RLS
|
||||
|
||||
```sql
|
||||
alter table tasks enable row level security;
|
||||
create policy "select own" on tasks for select using (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
Now:
|
||||
```typescript
|
||||
const { data } = await supabase.from("tasks").select("*");
|
||||
// → Returns alleen tasks waar user_id = auth.uid()
|
||||
```
|
||||
|
||||
Postgres past de filter ALTIJD toe, ongeacht wie de query stelt. Sterke garantie.
|
||||
|
||||
### `auth.uid()`
|
||||
|
||||
Supabase helper functie. Returns:
|
||||
- UUID van ingelogde user (uit JWT)
|
||||
- `NULL` als anonymous
|
||||
|
||||
Te gebruiken in:
|
||||
- `WHERE` clauses
|
||||
- `default` waarden bij INSERT
|
||||
- Custom functions
|
||||
|
||||
### Hoe Postgres weet wie ingelogd is
|
||||
|
||||
Supabase JS client stuurt JWT mee in `Authorization: Bearer <jwt>` header. Postgres parsed JWT, extract user-id, beschikbaar via `auth.uid()`.
|
||||
|
||||
Magic onder de motorkap, voor jou: werkt gewoon.
|
||||
|
||||
---
|
||||
|
||||
## 8. RLS policies in praktijk
|
||||
|
||||
### Basis: per-user data
|
||||
|
||||
```sql
|
||||
create table tasks (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) default auth.uid(),
|
||||
text text not null,
|
||||
done boolean default false
|
||||
);
|
||||
|
||||
alter table tasks enable row level security;
|
||||
|
||||
-- Read
|
||||
create policy "select own tasks" on tasks
|
||||
for select using (auth.uid() = user_id);
|
||||
|
||||
-- Create
|
||||
create policy "insert own tasks" on tasks
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
-- Update
|
||||
create policy "update own tasks" on tasks
|
||||
for update using (auth.uid() = user_id);
|
||||
|
||||
-- Delete
|
||||
create policy "delete own tasks" on tasks
|
||||
for delete using (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
`using` = check tijdens read (welke rows mag user zien?)
|
||||
`with check` = check tijdens write (welke rows mag user maken?)
|
||||
|
||||
### Variant: shared data per team
|
||||
|
||||
Voor team-features waar users elkaars data zien:
|
||||
|
||||
```sql
|
||||
create table team_members (
|
||||
team_id uuid,
|
||||
user_id uuid,
|
||||
primary key (team_id, user_id)
|
||||
);
|
||||
|
||||
create policy "see team tasks" on tasks
|
||||
for select using (
|
||||
team_id in (
|
||||
select team_id from team_members where user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Variant: admin role
|
||||
|
||||
Voor admins die alles mogen zien:
|
||||
|
||||
```sql
|
||||
create policy "admins see all" on tasks
|
||||
for select using (
|
||||
auth.jwt() ->> 'role' = 'admin'
|
||||
OR auth.uid() = user_id
|
||||
);
|
||||
```
|
||||
|
||||
Set custom JWT claims via Supabase Dashboard of code.
|
||||
|
||||
### Best practices
|
||||
|
||||
- **Begin met `enable row level security`** — anders heeft policies geen effect
|
||||
- **Default-deny:** zonder policies = niemand mag iets
|
||||
- **Per-operation policies:** select/insert/update/delete apart
|
||||
- **Test in incognito** — eerlijke check zonder admin-context
|
||||
- **Hou queries simpel** — complexe policies = trage queries
|
||||
|
||||
---
|
||||
|
||||
## 9. Protected routes + server checks
|
||||
|
||||
### Protected page (Server Component)
|
||||
|
||||
```tsx
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Dashboard() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <div>Welkom {user.email}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Protected layout
|
||||
|
||||
`app/(authenticated)/layout.tsx`:
|
||||
```tsx
|
||||
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
Alle pages onder `(authenticated)` zijn nu beschermd.
|
||||
|
||||
### Protected API route
|
||||
|
||||
```typescript
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
// user is ingelogd — handle request
|
||||
}
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
async function logout() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
<button onClick={logout}>Uitloggen</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Eindopdracht checklist
|
||||
|
||||
Wat je voor je eindopdracht moet hebben:
|
||||
|
||||
### Technische stack
|
||||
|
||||
- ✓ Next.js 16 + TypeScript + Tailwind
|
||||
- ✓ Supabase Postgres
|
||||
- ✓ Vercel AI SDK met Tool Calling
|
||||
- ✓ Minstens 1 externe API
|
||||
- ✓ Multi-user — Supabase Auth + RLS
|
||||
- ✓ Deployed op Vercel
|
||||
- ✓ Code op GitHub met PR's + CI
|
||||
|
||||
### Feature must-haves
|
||||
|
||||
- Login flow (magic link of social)
|
||||
- Per-user data (RLS policies actief)
|
||||
- AI feature met tools (chat, agent, RAG, etc.)
|
||||
- Externe API integratie (geen alleen-OpenAI)
|
||||
- Productie URL die werkt
|
||||
|
||||
### Documentatie
|
||||
|
||||
- README met setup-instructies
|
||||
- ENV vars template (`.env.example`)
|
||||
- Beschrijving van je AI feature(s)
|
||||
- Architectuur-diagram (mag schets zijn)
|
||||
|
||||
### Tijd-indicatie
|
||||
|
||||
40-60 uur thuiswerk. Verspreid over 4-6 weken. Niet laatste weekend.
|
||||
|
||||
### Inleveren
|
||||
|
||||
- GitHub repo URL
|
||||
- Vercel productie URL
|
||||
- Demo-account credentials (test user)
|
||||
- 2-5 min screen recording van app in actie (optioneel maar aangeraden)
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **Supabase Auth:** https://supabase.com/docs/guides/auth
|
||||
- **Next.js setup:** https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||
- **Magic links:** https://supabase.com/docs/guides/auth/auth-magic-link
|
||||
- **Social login:** https://supabase.com/docs/guides/auth/social-login
|
||||
- **RLS guide:** https://supabase.com/docs/guides/database/postgres/row-level-security
|
||||
- **RLS policies:** https://supabase.com/docs/guides/database/postgres/row-level-security#policies
|
||||
- **Custom claims:** https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac
|
||||
- **Auth UI library:** https://supabase.com/docs/guides/auth/auth-helpers/auth-ui
|
||||
238
Les18-Supabase-Auth-RLS/Les18-Lesstof.pdf
Normal file
238
Les18-Supabase-Auth-RLS/Les18-Lesstof.pdf
Normal file
@@ -0,0 +1,238 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 10 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 17 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 17 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521113057+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521113057+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 18 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Count 9 /Kids [ 4 0 R 6 0 R 7 0 R 8 0 R 9 0 R 11 0 R 12 0 R 13 0 R 14 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1812
|
||||
>>
|
||||
stream
|
||||
Gatm;>BcPr&:WeDbY-8R02Zim+o7LBD`QR!]\OC=&<LC2N%!V[mB>CZ@),N)W7Q$LD$fHrq4sEs`XF90=T';C!FF(0q&9Ns_e]@t,qdEu_s<`^h'$$Dht;L*77G"C"N(.(ETL`h8Kn6X0Mp7Hb'1[%N$oDhi4.,-R1G"+`=n"-nKmjn$Uc^F.X>[A+Hge&m1N4;@"3[lT<dkg7(1sGS'=0Li<n/!l;31-.._fsAnsC%q!$Nn]JEX#N0_JZn5!'JBN`0AF!&]Q:X@0=.4ZFV.t+0kPHT+04bhWIbaqp@"+8_^`'a9Bn_`XFQid16/$ins5DN<3('5H5I).Vp!_M60.Kq&Gi!I&Sd0"S<mTVcUmkq":+iQ`430j3Zi:NiY]H9e&S8Q-0+Op"Mh:p7I,((.kLW3HCXQkae1QaJ<S3!dWDMIjuZo8a\'\u=M]j9NEfNF/!BBe>3';cH[aIQh>LcT2`T#U<1F+KFi=$uE2-WEr=p"Xi>Y3Lsg1R<AHkdYgmZq$n)Z2`))JpG:E9X11V9=W"Dba!%&fiO@DcekB)Bp/]&Mmq@pLg\GGjpNfW7i*WIK4+o7OaF@T_oo:6E42a3n.`UN^Xu88=tWo/(Lkl^5Q]641MGab-EWH[9rSL+o=TaaXru=b1D>OD#YAaYYY%U?\<.RShF(A<O^o=/Hn^W.Wc*"co4YY,;ZmV0GIueF6gMeI,=A);c%gN&\(sglU"P#sS,Zd9_>(>>mCB\K[=Xut$Wf!)S/utk@(=mRR>T74YohPu?#p^53f(`3BimRC@Oa@L$P$f'57t-Q'X7@8-@.P!4Ml^!_lc<B2^Z3>P<[9&WeikkFTVA5jM(Ujkp"%Cq0ZL8P!PW%][C;s%V(3WF;AX_on5DqBOd+S>HQY%e4CFF4>&s)332-f.IWZ%Z=gbl9b49N="&$7\_/W$M;'YmgFmr&Mj(WX'Im4;.t^:p;2*ugpW5&c*q6f5kYbnFL+9i0XdVWm6tqh=-H+IMP^;$5Q+@T!:T3*\_M.?3LtW@Vf'@E;o[/)rLLD7k%G3t"qsA%M_V4IcgIH%paN>!qT^A31S_!d0Y_7Lu5it2GbJ1rO">iphcOQ5l_ZE,UVNkgf8aXdRf2FbG.`c-\`402l..)Zu@s'<OKM<JeFb.';UTl8CMqI5mB6>J&ZYm+I$W4.uebaDCB>+nDc.L&_BiCYd-b!2%/hp&'PO&$S\1-b>?hQcf_)f5D*n3)!>QaPs^g4R;%P,uU`!E`u=f/MAOG@CmCPqnT77e[oPAKn#8eWLZ\d.u'>6'T2gnN6pdT)!'(j7"oO,@2/HTNip(eBXGbk]B8UjBM\KMU/4A.b5GO^)`1?*pLcfXQ&Bmi+.!KH"D.^aaY_L%K(@MdP%,*2iWk/G't`eoSo,RR@Hs(7o'!>i23P29`Y8d^CLkmJL4V$6p"<:16&GDMn3XA,.dac:qmkg=u7G+<7<,;Fdc%$Puc3g[f:H9p-)3-1S6A^/mdWT\Y4IT.`:".m%mUIq.t;q9H=Cfu3X-C8WnInq#'-H%_nY`Llu%q^TD&]fs$7rJ`Qdok/,r9oEjTh.m4+9c\opT0QsqlAqnA51R\5aeNef;`Mn:3:2RJ(`E&rADAVEg]K3C<tALY*94tu_TA:aM''f1A!:eb70(X(*55VH/B4V/7iLG&C'RAKLT\3Fm9s_.g.#UMk]/]#m5S#&:S;m(;V:PB;mPcmf#?At/BiEnd;ah+9ji(9:@&[BN5`F8=&j:S)W>cX]a,s(3`%I&>EKLMa3b2_V\Ik&a:)8ukZXbo9#k?sV0+bVd*';Y(AaWR%\3P!%bMdQ~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1366
|
||||
>>
|
||||
stream
|
||||
Gb"/&?#SIU'ReT:\0q^6BfDKV.$D^oL(%k&!83Mnme5Xq.^EM6PpJpu&th\mq^t<oOqYlR;`*5r/]3aSaE,,ATkFWE&(^Zu97YnH0RgoiEL<6u'1BXs#[U<>994&6PmDp8Ree04')L_mjkS$&O@4gcA>#nG=pYb/&ZnD0*9Sci'U/oo^[r?S-)o;oLh;4fo%nbd8C#Vj+G#d9h>(o8g4U7Tfg^a*g+S':#hC?a:jkVX$WI+i-&hgarqd9+.-=)908^s+pN;nBM]/I_.l]Ndq4Ob9W_R0qHZGZf`G1h%oc?dZ@.Mb@)W2">8V#d%iu]Q^0F`9,lJgU9Sj`0,O+7a[hI92@+N`A'("L7sWPVtX/\'tR@C.\`OKeA%5jY>RAKFp+;P==RY1lg*0I?PA&p.Vmce7f>BuURG1PIe@%)?<Kb`6ZDFG:V7m&i.*iD\;lU^i2A0<n%E-o/GT6+2t_)M?7DDrERD15B`Fpl46-6XE?+992B^(aWECmK5g]?O+/V)&r4%0O*\Ok5i;5n3b(]iu:Il&:(mBIrJC@i48u2rq$0q**K,tNWYUhD'!oH9`g;qmW@.,8#]?IAIEJ^O^a/ACe[53ZdJV_b*6';E>Tt00OWNekNb=)L$<$N9(lK7D'9he5*_71aGZNEJ[6VWMtH#\(Q]@[(c]j!Rg3H>KV'sU8#Q7KTmWURQI3@iGEnE9'Gm>bYO2Scp.eG%[?,qV0P00fA);#bn&$,?Y?'%R<$hEL$(==1U,7TUbs.5h,#4nL]S``SVC6)qZF:_EmcY@h4XWZucc`+.&([ope[u]Wc@;p2:UmRtn%>fG"L@r>GWdQSa318b'XK(BostN1fWjFV7t@(>D!TJJA+Y'bV&?ptNiouC):4HnTc6TZMTOi6`18J;)bT+%L2`X[dM#7+I>tRET$Yt\lqMBh,7,)a%5#.1'78"?"JuHh2c-&>'?]hI*I&.(K3SQ)Jl)E*@AVCun<ioW[.e-fWV[ZK+)<sQc_eJ485(,BV@Rb^01-@m`f](@;*O0/2UP6`ekid8+VBlm<9ZMp^RC0H6Xi+PPA;H!:A94j]`7943>Tm+'"<H7k5V7MihT"kS>5Mbc-Po[4WUXLD5<e<F\BJfjo1lid>$m6MXiNk-095e[flU?-(o4nqTh#A'i>CsB1!,!;f:.`RBYV,[=_]ec=/WgDO^p$L%\o]p[uHC&-7p-g+EELdc9%0@65:W'Ep(V^Hs:J`MtinNI>K:n1L-*pfFDc'`7CYq<+JGE=[4S2R/8%.*Ko1pu;Uhh;MnCOkVP9JT%",Eq!:eE8#,2:^"i$L%HYui7bkqIL+k],_2bA?R^P+=^7l7o'O?$3FZ6a4aXOWb3ptenGWS5Rd.<~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1520
|
||||
>>
|
||||
stream
|
||||
Gatm;gMZ%0&:N/3%"^Z&M_.Y$'M5,,g24FS#"\KOFkRWrXB)$326u:6EfqWf^NZUa/a$R^g";$*O2s-?E/'l;",_"tJ&cV\ha]@_B#$Bb-GT`r0KOp^,O`c">]M+l+MO_%aFT#YI6fU\1'f.f+V,H(Wea[WKP!X<:]na`8hgL'J]Sjl!E#"0/V%LM81bq.pPIrN>mh99)*XSJo$`n287]mRBf7%9_\&sbj\U@k-jruSUH&`HIerpErlh:;N+[F-_Kf.KGZMYJEs;#-IXY@a8QhGhpqp@+1geI$<pdfg?PpJ]^3]Q@E"X;,Fo50:!n2[dZH=O(m,r&NA.eT"S1hCL*K]>6;+3A4IB"t:)].<$EFH4-Ul[;)GX.^%B1kCj#c0l^Cg=HE"CXpn^*W!_`1F<o%F^8=-t`m,>Sr"/p/s2k&a0WX'(#$bO5X$GH:T%fF^AO<Y#qLp_i\a_3uL3CYrD:>S?hT1oYY1.4*C*dU>,>(KoAmG2<-kS`:\8Gl]Cf"6<LS<&$J-D=lY9j5K:/M_*ZB#2>k3Cr%Y!=FpX?h^Nh.6>e8q/E*7/AdJ@kGhFaa&F13s;q&6?=HHq9#l&iqb[e+</%;<H2#E5`=luNlJc1ISXBKjaMZZYX+dM.crE)4;(c#Hg8al%K5<ab&eP!Y<plPoNE=4,'4d77Q@n#In?9lU0pEY3_\8jf=a2'ZZfbtbQoP%s]i>)W]1Xk<O@rW]e,<Q+_qLt+0.g1gai>ZT$Zd04N-.[[i<h]Pe1MF=_%7/6rLHC6+HeM\l9<MTaZ8.e'-X^mT^nh&GBMZerF"^)NS0XRf?N2&6.*lXJ\`qb<K,ij5;=PR7PZeRk<$W!7e<X]]<Z8)YmDW=la9at3EE1Q.qPZXgsZU*ZQBN.',I%eJ@^J#&D4(G;"YdF4pP^&-^Z$2UB4S&@1JTo^l"NY(QF>d4:VpmUn=$NtVcRO.QrI>L*\?+F+7^7JQTBNH&HIm!E2O9"T"a9;?V4&ISfhtUZC+$/XXq?qP$EEq<h2R7SB4FOF/L@HkS.5?9V0qLBRF9>&a1tghCA#Jf5"b.tA%D`:.poa\d45ZJ`O28O;R<r_ZMF#6XM[;ql?tu3er.`RNj,Mnh-`3qBX([sqSP@1Uai!SHQ<T.N7KP]EK^,Aa7K<@ike*K3D'rhipU;RjE&uND7H[1ZlF6hEELe'Epo>H*j@d9Pn`O*5+:6kb`n#J_o&SIOJ-Yq<+\K_CYg""d=<ng1qq4Om5lftb@6hiZa`Wu$pM<GN<b0J>+2`XJ@"BM]FT4UrJ*SV0[pOei),`(<`Wq/,+a4CMJ/#?F'^-#3P'u5aNP*`T:J#EA6G_gr@K3JH2U"dNpSp-:VFY':Ln4?nR0[,g"Qi`6R%37]EM%!hp&s^go`Y6`3Nh=b`5Md%nJ1]M3;U*HcC]?KLA)$YI$Z65h::cm`[m^U;qQLeXlM\ee-g!_"W]V3U])cD][0n`3=_mgX1_SQ:86JYG<\Zj2&3sm[>9U&&2uCd9S?nrRuf0hZ$V#W@n~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1807
|
||||
>>
|
||||
stream
|
||||
Gb"/'=``=U&:WfGfLbt5,S!3^"@77cWU%KZB@:$cdYGCiTd`NA9G%U<3ULAThn-Pc6\eUjIAs%tTdBL$KtU$n`=FR#7rs2f7PHs!5T,M-5Qq`RH3qnec=_ndaaE%JiQC6_!tt+_&$i0uJgZB`=?o0_5#K#oTdWbf:r)Q\hkrYK)CCLb$H/:6Y65D*4fRM%k;eZ`+_sA+.>-s4fCGn-]R\(,3Y\ps+rKa@[8JS+(W</An%iZIK3,6@*5`.?eTiu--@tnLe'ZCV.:YO]*'ZI)o/O4*$r?PM:<[1M;u7+qT4%;ii75^]Je8O4]8HVdk<Kd?-+@Ja_7g5RE>jGmiOhUK2F*GUK)2VoS@^u]@]dHeNeUD980MF00TT2VQBjMS-a"t!O2b/49+^A]eu*c;T#bV[\=e`-!idOM\1d?E`TFP]dgFG5+r%7a-P",-[(GS25]4akEnjEI"FmdFedo6)oJDW!R8]]Vi`!C:GZ:3(o'f8GlJp95)0H6aP]<@PbE\ttQhC;DBP$>lf,hXWnH6XGiYNto#'b$hi1HAb%MMAAScUkl]KTHOg$-CKLI!E!4UdrAa@4iC('VaM)+l+^::+:Zi-B-G/C1!M%gS$3'aa^l[#lRf#BV`2jmC&#Kf)L=XA'>Po*PW(`EmtM=,:-jQAK6>a\!/!>K:!h$**G$BR1E)[KRSh`p#=8-U0HhY;=7HeCIgbY?1SD8s(g^m6k`uL"6^2RrB*5Vf0m60K9K)j=H88iVgBTqbXR9*q=R>?tSdMh]E>X?Y&;_NpIi5?S94%0\7R*_[u2h]V*0@``Q`+SX=tH@>8^lrCM7-$YQ'#7BFnmLImFr*fcl0j(<_6UJ0D]!kc2n8/nY%Ouj'&6^(:)kn5KfVMgYl".Nd89@:5f.Ta6$f.$CkPsSsJpF'YM*j7YNXGfI;fUL)i`+,@'Yulc8Y+DKK=gHaL(msaiWS$aPPDX:AnGEbDZ)?>>WqO]r[XBEX5j^+cN\Zf%SW6cdeoF3S&LkAmo:dkgE,8P/CLto!o>9@MYc>^h*T?lqS(l@(7j&F.fTFlo=;"]FI\3@'4d"4A.(6pCF':o<1_6G#9%SSlm'Fl0XTU3ZTt2=7oAqLQ]BscLDTAgA^Jq+`gW<V`heCOkDsb;!"Jl4UgY/i]"H5)BW#,2iES2@dq-Rh=P@YZcfVFVLe+>Wu/[hu8^/#!3&)ljA*I*2a"!WCrXMb3s9U]1!C)?dR(dXkG<Bq:D5HKY3I,^E'WSkWH@GJ\]U(3>]b,UpMClhJdc+1#m7"@^saS+T/'LA$t&,+QVE)(f+=GL9"Kt6c&pklE*36!,]Nm'\7r;Nb=@VdkSX+K/,l]6"qJHNg-<X`1iLWbZhC9GPTb>$eD0334Gh*$BIY!aWg9P/?>ZB&3/U(*\51JRLJ>nZkhBq.[?)t+Mi>=&9HN\ZffM;88NUXb>m-SE[TKl6o$@eVm=H2mR\L-p6KA$E._V>mp`;dLFTG\<=CB:DXMo=a_N`d_;<3:#0#N\D1E\d"GCXse6lQ3/bO6@`j\IE!kCmQ(%9Zc41pbTRseiTDQ"%\(ENoM(C8Dg%32)u'9Z[c'T46bt$CFM><OVNQ+=n'H1"HQ/(.`_hlU2C^=rSE-e6L/D)9M9Y@[Yu,C8^#?OVVOaIlc$Wqt>cfpAY$MZU1Fe<Rkb%2-6>,WW5:Q@!oZ&RJ/A%?nQIsGTK5ILIbqBO7Z,AaGNt_I<bk+MCA9sF'Hj4kYD`-b>>U/Y-;Wm?MR@VtrD#`)ENb6q:pE293@]NZN@&hPYAUJCS421[SK/m[J)6?#Y<R>'9!1^e)mm^cc~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 827
|
||||
>>
|
||||
stream
|
||||
GasJPD,8n?&BE\k;k/(9'm"64O$f,N2UKgtSd:N,oh?4W2HuWS3%u]/n%Qtd@>Ha?h4/o.qqq3c"r&01r$EA52\F80)))QJ&ssdCU2r;NK&6UfMJFAdKSG5I"0l;5JT;Ld+;Ib>>6]gs@^H,D(bs#Ki97'rdni)-c2n2</#nR/@[L+arT>78`ArYg6n2aiqfhm1/^G'[WC**@(Lah*F\*%l:_:dFQ4Rn@s5U"qBCNW;3M3C2,(n(=FNM+G"m6eacX%T<TDH]9_)ll,ZJp2jo'-4LT+K94=?CmR30>WX+og>C?j:T><O$AFTArq?`uoRc?a-Cj8F]HRe<j46%IQYM(hH`V_1o3HHsNrV.nmH@]7is%Zu1dbfWmaZ>!%bA@7M$*K'HX"l.72s?t6.W.]OE:@$?7XbcQXVV:n@jK#*&;nKeP[dDkNHdh,'i=@kbJ0c@\6>!)a=S+1D0XFmCVlDJKLq.U,Cl!R+Wf-<BSAr5W..r6u22U")6Q>Wj`Z@[Eo8f(Rs_NhU5I%-tBqlkcc=_qL7[D084J&L3g]eB,?T'hJec4.$^M+@34pm#<DoZ5hD<V*VVY?^<>M+eS_BqtEj<>5hAA/j.V20!.+2c!h,P:'@6jB[X[Ef9n8!Sfm]fS3LECK_uk%_8dS9J3=H:Za0`s28Vio/1S5([S+t$G8T,AEg+`14Q%lMuCi&kD/QcjSRo#8nEhj`paHE]pb9dN`)'E$6'rc<j"L/ZF2ZD\1qDIc6'mrn9M[.m?o$[^@P)M\+*1Kq5cJ@qB--hk*,9g,Wb"6Uea!i4O!J`g,WssGH)bNp"K]f(^0j=drB_J~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2131
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/e&H8h>EALG@$!Ndn7#G_VC`V6(/^gdTmL!bO$:Ug426rJ3V.n<M^[ESnPao[3118q)`IT=5eFCV,m^\*b5k2bGmplUklP([A(bcHK"gEL[0Kjs,;t#o?\E%'@JG>#u&I/sEjq#V2Q\1Af7Qb.QBLl)>5bJrk.T4(P]eEt?PSt>7*Wm$I)QEdn@R;PiiI.=hc>i-"#UuP?ms"a[j'G,ak8h-Js3V[D@,7QI<BhUjM,;!0"lkrarVd4s(/%pZ7n&QV]=QM6;%5>,6[8Cmm_c%sX'3j.OoDGgiaenWH4Qkp@CgC>)V>Q%:'A/;C&^G"^_G_'Wf)Qgq^PpLGmn_$k;D\lg5tBdUENXUpM#Gg\8oJM+#c[bJIEg*4>)H'Fa][u6UGL[#(9a!UB8;!abN#g4XT](+5S]jQnd^6+rhC*E411,a#F=T*=4d:^<\or:Kd^Z3^_EAP'FA.,/].VZ?t7VA$GOB47ZQKR?%gUjGC]J$XQD(#EM0l(aWEAmK5hg^(RFTfe>%3fkip&*S?^\rJ9s$p'i3JKXfjBs!"#[1OU/dL0HW:LlG@YU_fl1Ncot?7b!"+j<<1U9s!B8RAnp$V)EUYGN61"<#'f3-":$?J2#JtBpoZHj1/L79ifU6#Rl+Ir5'/(S%;_@Fc*.b2Vbsp;.5!kIG(?];;;CMGB^GCDUFC?5e,XJPo#+IV$bh@H%D^P`P0OcM'OpNiNPhSa!@8B@tG:B"D^`B4s*]qqQMm'KWp*!#He;+oaM#]:oV9@g)Hgc>R&*G?N]\DmG@;5e)+Jh@3;4%K_.#*7jR)Q@q&j67%nG75+YGUjT8!<$=d9U"U=[@k&N?s7P(?4CEm,U'P._\TftQcn5=b8hrmjqY%m_AMm?I$.*'%nV+Us?kl)(8<"LBFY\CX.RE)^Q0j3S_ln=R"::"\52(.p&>j4hpP[2a+(^:SOG+.fn&-BQO0StcFqFg-J>b0s_/=J]#NA(6p\8uiHpP+#KgOl\2]-8I4"A83-'#5cmRkfco1f!A]Fh7rlHTP7U6=S_^Xi^$5Nsu5SI]0F+#i@#'caas36rN)Gqn5Oen6jit3G+JSaPA>.*0MOPhLe$_PM(1=rqWH&(*kcC7e4e^dT*s0h\PbF'PATaKm^3kgDg*6B1HpQCV0H?]4b*haKa'h9;p5N@S`'f,),RIm+Cg!KmPo1j@QStUbaW6pZ*=7%_smYcApt(CokGUK>T8=\>URqQRG.co4sG+J9M&Dp4)>r6`!0F02%'Z#B-_V3/q^h\pYL;aI-7QTbkA(FC=n)h)a%65G=)J1BYu,ZTUrWN9nQn<`o?s/UnHiCnRjF^1e!54JnBZ$:kBoR$j:G$i'rVOu":nD]S9[Hl"G=B+](HqA!`r-$U$sR!&Su2f8Lei!A3.6gidYq>A*5:gc[7(I<`;$H7G@SP*Y^_:lN5fW`C%g2>P(/hR7:470PN#H%5>UKC8t;[N_p[?Rs3!&4DIaQJrC(f-Lm_0HqS+Z7g-\hge=r"ZGZ`)nQ4FN%cr-Ae^nZgmEDY)]R;aJkHME'pjBs7X%2oS^&aY>7N(gA4gc_h/&)B<]A/1^ufP(7"KuMXdoM?6lK$gPF"=C4OR0F68(cFXc#kkkKk7h2Yg`N_"sOj?`:m9@J^UR5#Cr-qtpbQ;o?(1lXm^aEc]iN&5Yd-V[kWm>]MBXrf2h2l'9?+[3Jj[6W0Nc/.Fo13:IT;OK@Q>+aV=%OIr@8HgJ1Q6p5uZIjrl1`o#GY7Y!'col%lXJMkQnO.FdRWOR!AIgT>5ktMjFt'\cX".0108n;+2c*H)9D;;q[*^#P8,K'PrXYh>HKIW;7PKU>E%]F!+2h;ocB!.$9f6,CTlG=]\\$o@-DSc*)kUkP'ihq8SHUC^5-RR^Lcmkf5(!87l9aAo#V:+7Iar-q6s3T__<3eMa)MU0`N$Y,T].@%"qKLKVu08\g:B#*<c9=>1LIap.DUhqmH.IJ[tRd?M?41'X\PT''+B$8@'1G;mbPKfO%^gh[+/4HcGg<&rV??T'cVfuK`8IbY34MfP41+d8\BFpE,_tUN\P6%EUk;<>89kTebXaQo);M]VAbcsY\_`tD*eNsT0@_qp^VD\,?8q1BrW(__YRMcENrO\JG1u`Fui_~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1766
|
||||
>>
|
||||
stream
|
||||
Gau`Thf%7-&:W5<ETiTb/.>*I,aG&6/\7Y[MQP^I4p;c;R<I*DLEjo8ZbLrZ8tqQgNg^0nil&8Ro"4@4+7G4u5k2hKHWgE^=i/9B#)pH?%87<6bE)/I1:RYsQX#@Q8>p5>F!,I9J"YX$,t*p;R)]Ul<CA><68P?YXF]CmV3lkg_"qWXJ1+!S(-j^7UmCc+LI?0CV?MgPN:3q<q.WJT-F.9>1qR)t_\oP"b+j]C:_p#9Q3Aggs5V9-G[G;]P?q3g%Y(sq8M=7,j'9Fd&'(lC->QekK"'m3afB8eb4Z+,?X`cYg7$q-_+B=PDW?4'%587ddBqH8KDh$NZ8)G<_>0&9F9%K:-`X*l@7V-QikYj@K_Sqs.&;r$"?[ma=$MuU>S0S^Sr@[FcOtm[aepB20II>-(k,n^0am)u@[n6)i3>Z#c1(pkJJ8D#ht)BW>rLQ8*f,+mBg>0(3F6rnrF568`:Ggq#b7dYWsemJ40'AL?:f5lO%ID0-!W5I!^ka>8(rYj:Z=]CpR(Du:@P*tNsWR^D\^)teEnjc5<o,NgFCOt%(24_YcX,42Z%e\-O6WMQc%>^1ZineYVmj>_EEXmRfm;N$<pFNmi#-B.VY)Np@g<R+s^#ZV%Y,LkSikss4I_-#nj^0iLT:'+I)/7^E:W$J\L'ZEQG$`8Z8:s3hhBiBeUkHcABo:4"*<?OCrCY9aAtSlm?u^IKZ@a$`cjqp#J"O_<>RUb#49ad#i+YIe77M\lY1@`%DmKT.4=`F2h)<PS-8l(ViICS"qpg-KhpfD_;tg>O5-R_g81;M(g8o4[?H-o5'&qcB9/)QPE)?"./04QPgJ`1$2[3-rqr"+Ok:aDe7Hc\]]%fa(nVC1.=JV]Cpj/=j>VD>FceNb,ge4hY&mIBQ82PB2]/8\DCh5^:2qQ?uU"3`%`BLi4Q'^"6Z'-9i?9ulQgDm%#i!sB\(Qj48O)32J0-p8S@[H=U`cUU)dj/\%)100f>f>W2j^TD8CeCUq8['Ds!#-LbK^k(lq$`jUfUA;(`O<^l-4n->fHf/mgkDG'"e6M>Joa"K%^W.BfcjHB;ErPoJ692!".@d*D!APVEbf_sO.cPmHq/RZSe[-(E9J6*@Qf!n%I??N*&g&J5m1]oVGr]<[caTGiPQKq.H8eK3e.:R#@H[%5Z0ROa?Mj(Jpp,Kh.g0eE&8)!i8t!ctcIN\T.:gAK'k9qYU1]AIo:$YS9uE)!C&YX%ecKX&Cs9\EgR8lc)kB`lXiPenue2/YoOk)3dfX,/K*/(\,@bh?WTMHMV^@#a^2eRkBSrXmaXrSQ]TGskn+Ea#VU.sA%j0@t3n:\"qlLb76X_O#%&6:W^jg3Vn%rD;'[N9N[lG1RB_f:<'Aj1,Hr*!9EWZ0.W]H7'BZTAK2?ZEC7&]-p?mNo_=b0<b]N%Do/M"of2!akE1fE'"1#5[r\-*-T2_>A)El[V&Cj%/ZTJS'W69R8fqqlL#.n)NkNhra7l?f7<6]a7f#Emf;)Pe"t4+),ofC7m)Dm$r:-bf&;S2p1<#<"qS"9R.aiM4kRdpjJ!f\cfae2bIb(sSD=C!gGP&4=9re>iSUY?@K5K9EgPEbQC01b').c-%r4O"W1VDfdFP(?rIeh$$4hqaRpilHS3631^e*-FSTOhP8`'LngSA^/E1>>>F*k.a851c)p-r%pAQirnXKB)6flTAB+7WlAAE@f7Q1e&FOLrj&m8#b>^=pER%Zc:AhBjb]\5>>+X(%Qfmq1)C.qn6Z@tr\2#^pkL#lXpIWPmB~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1759
|
||||
>>
|
||||
stream
|
||||
Gb"/'hfkPJ'Ro5K\BC)jY-K;aLp#K8f!K'MbM'=C],/4Y/DINK*5G.PSG_if&k69LNgmHL*nK6_i$*UDf6=D0$n*1@r/YIj^C?k9JcXmc(^'Ac#D3\75&edf_L3JM!f/A+)fd'pX8ei[3#N!op`U.c8/`$WGi2._U^SPgS/2;ZJ3q*ITu2B`4q4:m6o*^os42Rapme*oNsOVRfB4gSPM3[2pe0r"bkd*&3.``QZDaor-kh/^_$kWC?bJOUht)[Sj(Y*_n?sfG4KW#4GlWW?Nt9Dh.4[9-,B]^.Hp1J3o`,1<@.7"YlJR"1-N_Os`BR3G0F;tRX*e0iSjnnhMa2H)?=QYJ+J1:r%B(PT9*<DWSH3q>%#f9kht(:=E'`b^.n^m(&5#+=O`@oOjUi:']IOT=R+SZ!]O6mW%:qsONXH"mCmW[^X9";'7K].Ge7&-:[1B5?U/jqRd+4'gDlf)EajuH3kHQgc%&`_pVnW0tF2sBo-ZVe>:<FD">Vb^K6X3\"Bi5^inj6G!k4Nd#h`(<RiOA>7,?Tc7?,8`Wnk-li;anF!O<9OV<5jP@+J,V.A%@L5^8.&1k;t5*[\>pe]+Y_hNfG^_BVJne40'rYQI"]=L0Pfa=>=H`6,Jm6$&5@:U;pBgp0@4>n.[qbiH7P#NY_h@;m<RWn^'.`fSXM.n-Sc$d$;[a9^Gk!6S`].@g:)u6K/=163GMpH=i8H^e,0<^!TdgV!L4@/Jr>IBsu18#MRnCSDSYA?>a;S$_bZOh*fhUKUd$5!@eBHKums\_))lA>XMbpW)Csi"EOc+0J;o6*;nMt<(U9ebJcSi+-ij5oBbD5boNn5&@uOd.+^9iC6W+ECsb'3WTn-D75B_",gUY-@Qn&U+Ys^Qq^<QHlUf3XDY;-8*iX.d81TM=X"9"q,)`im3/YlCq`H>t_qsk`>i2Yh"@ObQ"rAE-Za,000(:YbQVgY_6rB`IE6/d3G6>C-Gn$OIMOraOJ^MJFh,s;=*JkQ^PY>'L(.Wb,)V69dbB]j1_.BG:K18A?S#d$F#Q6L"Fld!Y*65qCbNKSA:t;;]i9;Z#puO/oI`0`BCN-"[;3KcumHY"h(pc>?`1('295&c-gVqe1?\bQ"F[+^<Y<8m/.KoY;qc1`,Pp$]f[pOhMgG888^C%r#OL;krFMG+?[U3/DD8BndCX;ro;amlio'*3]_6@">o-a#sSM*rFrLXJOP[gRC0bu&f_PP-'I[b$dH'/$,7L)/P/S,YsMt.0pRDI-r9>9;=Bp=TTL"]51^LD>t8CFBF^9kIInd/@hmXjR%0$o@,h[=bV"atp.L"Oo,XquVRMI#NJWQFeq/EE9mZr?\m/GQVFgf2j91<EiS4pPo+atAloP[BS1O3RP6R20Or(,kLNf3D5$!tkkOIbuT'URe-?d0qm.<d&MBIcIQiF%rA(W/1'n$*u]ePHroa#"gHd^9e%S<'8Eg&p<Ld-iUW`)7L])I%u)"!)W9sM1ON()fT]0,W51ubOMFFgBkO3i#:'lSa$Ye&8X3:W?`;nVkXXbh\L1>$N:[I4QJl/1\h:\=*&L"(Wj><2u'PW$\#-k$%]QMs&r_&o'`YoUo/K@9aXnWiK!F%k>&)D?LGI)7BBBH4GL2;5@C?<)!Gmcc_<6jZH4-$\T326[CYefgReeTHn%K9!g6FrRF5<44+-%0Im,a$D?JHDXZ%9(d3m`2-]cMf[eWa7l$/!l0c&H3PI%k@gnTB!.V2+bT&s+a=3]o);tH<PfB*(r+7ZR<WW~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1319
|
||||
>>
|
||||
stream
|
||||
Gb!#[6$JeX&AI`dk-et_aH@)Wp?#tVf'd0K2g'JiKa??Y_;Ia+l2LQ#^gIeYS[\p>[J^MP,gH(31I<lOT_N*C^KLYcR)/!L!TPl'!u")<lj>r%SA-)]"!.dkLaT/<E?LBFJ"U%+3VWU]+s&J#1aFgj+=&107M7\`$UY[rWF>e_@6h\[L/J#6bR\6gZeco-k6NYq*sC!7oc&@cVAGA&_[^Ch5BMW9fLAj/eJ!Tk5P/BmK!Xbr5:5m1=D4Iq7?PE1>ACRQE6EM4j,#iU-8egOn>C`b=g;;tgqJEo4j;d7=9JRiE8i+UkPhZl!@@h#`YS0sm5/.FGmr*9cW?(i%aiY`7/Bkj/o[19_l]d_/%!D*mrQpZa"XtQ%O>c(Rt@T,N\5V?Z:XZ@/ITI/%#5fc7XW3$gHn4'gZ+bdpO,ma)N$D"9'tg,%T_<#o]A(7^Ve$$#P76hm!s<MTjeQT_HM(8YPZk-L669h?0^'C\>rYqZ0jAre7PoX5!K(5^QtKgS1[*3XJ7Om<T"pPGud&DmF_h*XF`"J*ru&>.9%3u\*s$"9+954"E(K)&ieF<>8^^5'&+6\hHYJ]FciLM`@!QE2V%DO&j:M$M-I'V,`X6+n$Y);@2@0JK1aZL[c\2Tn0C)NS+3CtcrQ9]@Q?U1M*c\`m)P`!Qaqk@F96/ooqIC"LF@A?FUiDWiPkc$f(jQ2=R,qbm$sA1;UtXX^>E0]K%DAU51m=dRsmb\a6%C*Zh-HW!sE`K$<:)X>umr(#nZm*9=$6nRR$D!c]rGOAG2[;!nSl=^FYBs<`6<T`e<]<c^#uFIF"Zs:%V9"iHWul85B@4J<e$#dba]+bi'_=<60WMbKVS(=;^P$`UDq;VmkUoArP9CH;fLg+s2-gR+GqF:S9Kl,hUpc%`;/P*Mg@ng7p&I-N9;oh3-PEmUZfk.qe*'4]VIJb"uI,![9RWBTV9TUD\&(JY?FHqQag<(`T:5E)g`TJd*>AHl&GnM-,FjgG=?jAWc.:J$&Y]SG284Wk:*5_Xc0.m<WUICrm/.2)tJ6XlI%Gi.4n]A_S#iNJXTC2+e3e7Z/`jZl-hBjhR!):KWK8X<Z8%l^sKu)_fuHlSka_@:dGG`&23Gjgb)7Im8\de-X;sLNR,-rp!]9o+q/pTA-_YDS>1/X*):2eR9K1eXM:*ZDOIG9CA;FDDV;h#Bms:]#-R,H/2FXb$L39pRq2pBO)Ie.+(p.:Ms4JEk4`@W_DeE#9puZGi&DN(1?^`86k)"<NuUj`TaCN!]m9d,GLnN05g*pNUt(?MbLbUUr87X_RF>_'tHc6>Md>6:K%DA^JFOd.f~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 27
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000123 00000 n
|
||||
0000000230 00000 n
|
||||
0000000342 00000 n
|
||||
0000000547 00000 n
|
||||
0000000652 00000 n
|
||||
0000000857 00000 n
|
||||
0000001062 00000 n
|
||||
0000001267 00000 n
|
||||
0000001472 00000 n
|
||||
0000001550 00000 n
|
||||
0000001756 00000 n
|
||||
0000001962 00000 n
|
||||
0000002168 00000 n
|
||||
0000002374 00000 n
|
||||
0000002444 00000 n
|
||||
0000002736 00000 n
|
||||
0000002848 00000 n
|
||||
0000004752 00000 n
|
||||
0000006210 00000 n
|
||||
0000007822 00000 n
|
||||
0000009721 00000 n
|
||||
0000010639 00000 n
|
||||
0000012862 00000 n
|
||||
0000014720 00000 n
|
||||
0000016571 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<b5514340f90596be571d23fd606fedfe><b5514340f90596be571d23fd606fedfe>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 16 0 R
|
||||
/Root 15 0 R
|
||||
/Size 27
|
||||
>>
|
||||
startxref
|
||||
17982
|
||||
%%EOF
|
||||
407
Les18-Supabase-Auth-RLS/Les18-Slide-Overzicht.md
Normal file
407
Les18-Supabase-Auth-RLS/Les18-Slide-Overzicht.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Les 18 — Supabase Auth + RLS
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven, **laatste les!**)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Multi-user "Tasks" app met magic-link login + RLS
|
||||
**Vervolg op:** Les 17 — Externe APIs in diepte
|
||||
**Aansluit op:** Eindopdracht (thuis)
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 18 — Supabase Auth + RLS
|
||||
|
||||
**Visual:** "Les 18" BLUE, "Supabase Auth + RLS" BLACK, subtitle "Multi-user apps met per-user data isolation — productie ready"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik + Curriculum recap
|
||||
### Waar staan we?
|
||||
|
||||
**18 lessen — wat we hebben gedaan:**
|
||||
- Les 1-10: Foundations (Next.js, Supabase basics, Tailwind, Shadcn, deploy basics, Auth intro)
|
||||
- Les 11: AI SDK basics + Polderfest
|
||||
- Les 12: Tool Calling
|
||||
- Les 13: Agents
|
||||
- Les 14: RAG + Embeddings
|
||||
- Les 15: Cursor + Vercel deploy + GitHub Actions CI
|
||||
- Les 16: MCP servers
|
||||
- Les 17: Externe APIs in diepte
|
||||
- **Les 18: Vandaag — Supabase Auth + RLS multi-user**
|
||||
|
||||
**Vandaag:** alles wat je hebt geleerd, multi-user maken. Klaar voor je eindopdracht.
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Terugblik + waarom auth | 10 min |
|
||||
| Theorie: Supabase Auth basics | 20 min |
|
||||
| Theorie: RLS — wat en waarom | 20 min |
|
||||
| Theorie: Session in Next.js | 15 min |
|
||||
| **Live Demo 1** — Auth setup + magic link | 25 min |
|
||||
| **Live Demo 2** — Middleware + protected routes | 20 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — RLS policies multi-user | 30 min |
|
||||
| **Live Demo 4** — Social login (GitHub) | 15 min |
|
||||
| Eindopdracht recap | 5 min |
|
||||
| Vragen + afsluiting cyclus | 15 min |
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Waarom auth + multi-user
|
||||
### Echte apps hebben users
|
||||
|
||||
**Wat je niet kunt zonder auth:**
|
||||
- Per-user data (mijn tasks, jouw tasks)
|
||||
- Privacy (alleen jij ziet je data)
|
||||
- Audit trail (wie deed wat wanneer)
|
||||
- Subscriptions (Les 17 — wie betaalde, wie niet)
|
||||
|
||||
**Auth = 'wie ben jij?'**
|
||||
**Authz = 'wat mag jij?'**
|
||||
|
||||
In Supabase: Auth + RLS doen beide. Geïntegreerd, simpel, productie-ready.
|
||||
|
||||
**Voor je eindopdracht:** als je app ÉÉN gebruiker heeft (bv. demo), kun je auth skippen. Maar als je app meerdere users heeft → must have.
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Supabase Auth basics
|
||||
### Drie methoden
|
||||
|
||||
**1. Email + password (klassiek):**
|
||||
- User registreert met email + wachtwoord
|
||||
- Confirmation email
|
||||
- Reset-flow voor vergeten wachtwoord
|
||||
|
||||
**2. Magic link (passwordless):**
|
||||
- User vult email in → krijgt link in mail → klikt → ingelogd
|
||||
- Geen wachtwoord te onthouden / lekken
|
||||
- Modern, secure, simpel
|
||||
|
||||
**3. Social (OAuth):**
|
||||
- "Login met Google", "Login met GitHub"
|
||||
- User klikt → OAuth flow → Supabase regelt sessie
|
||||
- Geen account-creation nodig
|
||||
|
||||
**Voor demo: magic link.** Snel, geen wachtwoord-hassle, productie-grade.
|
||||
|
||||
```typescript
|
||||
await supabase.auth.signInWithOtp({
|
||||
email: "user@example.com",
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: RLS — wat en waarom
|
||||
### Row Level Security
|
||||
|
||||
**Het probleem zonder RLS:**
|
||||
|
||||
```sql
|
||||
-- Anyone with anon key can query everything
|
||||
SELECT * FROM tasks;
|
||||
-- Returns ALL tasks of ALL users — disaster
|
||||
```
|
||||
|
||||
**Met RLS:**
|
||||
|
||||
```sql
|
||||
-- Same query, maar Postgres filtert automatisch:
|
||||
SELECT * FROM tasks WHERE user_id = auth.uid();
|
||||
-- Returns alleen JOUW tasks
|
||||
```
|
||||
|
||||
**RLS = filter dat Postgres ALTIJD toepast op queries**, ongeacht waar de query vandaan komt (client, server, anonymous, authenticated).
|
||||
|
||||
**Setup:**
|
||||
|
||||
```sql
|
||||
alter table tasks enable row level security;
|
||||
|
||||
create policy "users see own tasks" on tasks
|
||||
for select using (auth.uid() = user_id);
|
||||
|
||||
create policy "users insert own tasks" on tasks
|
||||
for insert with check (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
**`auth.uid()`** is Supabase's helper — geeft de ingelogde user ID, of NULL als anonymous.
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Session in Next.js
|
||||
### Cookies, middleware, server components
|
||||
|
||||
**Supabase Auth in Next.js (App Router):**
|
||||
|
||||
```bash
|
||||
pnpm add @supabase/supabase-js @supabase/ssr
|
||||
```
|
||||
|
||||
**Vier files:**
|
||||
|
||||
1. `utils/supabase/client.ts` — voor client components
|
||||
2. `utils/supabase/server.ts` — voor server components + actions
|
||||
3. `utils/supabase/middleware.ts` — refresh sessie elke request
|
||||
4. `middleware.ts` (root) — gebruikt #3
|
||||
|
||||
**Waarom de complexiteit:**
|
||||
- Cookies moeten gesynced tussen browser + server
|
||||
- Sessies verlopen — middleware refreshed
|
||||
- Voor server-rendering: auth state moet vooraf bekend zijn
|
||||
|
||||
Supabase heeft een **template** voor exact deze setup. Kopiëren werkt, snappen waarom is bonus.
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: Wat we vandaag bouwen
|
||||
### Multi-user Tasks app
|
||||
|
||||
**Doel:** simpele tasks-app, ieder z'n eigen lijst, geen vermenging.
|
||||
|
||||
**Tech:**
|
||||
- Next.js 16
|
||||
- Supabase Auth (magic link + GitHub OAuth)
|
||||
- Supabase Postgres met RLS
|
||||
- Tailwind + Shadcn
|
||||
|
||||
**Features:**
|
||||
- `/login` — magic link form + GitHub button
|
||||
- `/` — task list (alleen ingelogd)
|
||||
- Add / complete / delete task — RLS bepaalt scope
|
||||
- `/logout` — logout button
|
||||
|
||||
**Schema:**
|
||||
```sql
|
||||
create table tasks (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id),
|
||||
text text not null,
|
||||
done boolean default false,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: LIVE DEMO 1 — Auth setup + magic link
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `pnpm create next-app tasks-app + add @supabase/ssr @supabase/supabase-js`
|
||||
2. Supabase project: Auth → Email → enable Magic Links
|
||||
3. `utils/supabase/server.ts` + `client.ts` (template van Supabase docs)
|
||||
4. `app/login/page.tsx`:
|
||||
```tsx
|
||||
"use client";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
export default function Login() {
|
||||
async function signIn(formData: FormData) {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signInWithOtp({
|
||||
email: formData.get("email") as string,
|
||||
options: { emailRedirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
alert("Check je email!");
|
||||
}
|
||||
return <form action={signIn}><input name="email" /><button>Login</button></form>;
|
||||
}
|
||||
```
|
||||
5. `app/auth/callback/route.ts` — exchange code voor sessie
|
||||
6. Test: email invullen → mail krijgen → klikken → ingelogd
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: LIVE DEMO 2 — Middleware + protected routes
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. `middleware.ts` root:
|
||||
```typescript
|
||||
import { type NextRequest } from "next/server";
|
||||
import { updateSession } from "@/utils/supabase/middleware";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return await updateSession(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
2. Server Component check:
|
||||
```tsx
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
return <div>Welcome {user.email}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
3. Test: niet ingelogd → redirect naar /login. Ingelogd → home page.
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: Pauze
|
||||
### 15 min
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: LIVE DEMO 3 — RLS policies
|
||||
### ~30 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. `tasks` tabel + RLS aanzetten:
|
||||
```sql
|
||||
create table tasks (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) default auth.uid(),
|
||||
text text not null,
|
||||
done boolean default false,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table tasks enable row level security;
|
||||
|
||||
create policy "select own" on tasks
|
||||
for select using (auth.uid() = user_id);
|
||||
|
||||
create policy "insert own" on tasks
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
create policy "update own" on tasks
|
||||
for update using (auth.uid() = user_id);
|
||||
|
||||
create policy "delete own" on tasks
|
||||
for delete using (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
2. Add task — werkt voor mij, niet voor ander account
|
||||
3. Open tweede browser/incognito met ander account — andere data
|
||||
4. **Demo: probeer SQL injection vanaf client** — RLS stopt het:
|
||||
```typescript
|
||||
// Probeer: "haal alle users hun tasks op"
|
||||
const { data } = await supabase.from("tasks").select("*");
|
||||
// Krijgt alleen JOUW tasks terug — RLS magic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: LIVE DEMO 4 — Social login (GitHub)
|
||||
### ~15 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. GitHub OAuth App aanmaken (Settings → Developer settings → OAuth Apps)
|
||||
2. Callback URL: `https://your-project.supabase.co/auth/v1/callback`
|
||||
3. Supabase Dashboard → Auth → Providers → GitHub → enable + client ID/secret
|
||||
4. Login button:
|
||||
```tsx
|
||||
async function signInWithGitHub() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: "github",
|
||||
options: { redirectTo: `${location.origin}/auth/callback` },
|
||||
});
|
||||
}
|
||||
```
|
||||
5. Test: klik → GitHub → authorize → redirect terug → ingelogd
|
||||
6. Database: zelfde RLS werkt — GitHub user heeft eigen `auth.uid()`
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: Eindopdracht recap
|
||||
### Hoe combineer je alles?
|
||||
|
||||
**Checklist voor je eindopdracht:**
|
||||
|
||||
- ✓ Next.js + TypeScript + Tailwind (Les 1-10)
|
||||
- ✓ Supabase Postgres (Les 10)
|
||||
- ✓ Vercel AI SDK met Tool Calling (Les 11-12)
|
||||
- ✓ Agent met `stopWhen` (Les 13)
|
||||
- ✓ RAG voor documenten (Les 14, optioneel)
|
||||
- ✓ Cursor + GitHub + Vercel deploy (Les 15)
|
||||
- ✓ Externe API (Les 17 patterns)
|
||||
- ✓ Supabase Auth + RLS multi-user (Les 18)
|
||||
|
||||
**Ideaal eindopdracht-stack:**
|
||||
- Multi-user app waarin elke user eigen data heeft
|
||||
- AI feature met tool calling of agent
|
||||
- Minstens 1 externe API integratie
|
||||
- Deployed op Vercel met preview branches
|
||||
- Code review via PR's
|
||||
|
||||
**Tijd:** ~40-60 uur thuis. Inleveren via Brightspace + repo URL + live URL.
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Afsluiting cyclus
|
||||
### Bedankt en succes!
|
||||
|
||||
**Wat we samen gedaan hebben:**
|
||||
- 18 lessen, ~54 uur klassikaal
|
||||
- Van localhost naar productie
|
||||
- Van simple fetch naar agents + RAG
|
||||
- Van mock-data naar multi-user productie-apps
|
||||
|
||||
**Wat je nu zelfstandig kunt:**
|
||||
- Een Next.js app van scratch naar productie
|
||||
- AI SDK + tool calling + agents
|
||||
- Externe APIs integreren met OAuth + webhooks
|
||||
- Multi-user apps met auth + RLS
|
||||
- Code reviews + CI/CD pipelines
|
||||
|
||||
**Voor je eindopdracht:**
|
||||
- Werk er gestaag aan (niet laatste week 80 uur)
|
||||
- Push klein en vaak
|
||||
- Vraag hulp — Slack, mail, office hours
|
||||
- Eindopdracht-presentatie volgt na inleveren
|
||||
|
||||
**Bedankt voor jullie inzet. Veel succes met de eindopdracht.**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik 18 lessen | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Waarom auth + multi-user | Theorie |
|
||||
| 5 | Supabase Auth basics | Theorie |
|
||||
| 6 | RLS — wat en waarom | Theorie |
|
||||
| 7 | Session in Next.js | Theorie |
|
||||
| 8 | Wat we bouwen | Intro |
|
||||
| 9 | **DEMO 1** — Auth + magic link | Demo |
|
||||
| 10 | **DEMO 2** — Middleware + protected | Demo |
|
||||
| 11 | Pauze | Break |
|
||||
| 12 | **DEMO 3** — RLS policies | Demo |
|
||||
| 13 | **DEMO 4** — Social login | Demo |
|
||||
| 14 | Eindopdracht checklist | Praktijk |
|
||||
| 15 | Afsluiting cyclus | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **Supabase Auth docs:** https://supabase.com/docs/guides/auth
|
||||
- **Supabase Next.js setup:** https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||
- **Supabase Auth UI:** https://supabase.com/docs/guides/auth/auth-helpers/auth-ui
|
||||
- **RLS guide:** https://supabase.com/docs/guides/database/postgres/row-level-security
|
||||
- **RLS policies in praktijk:** https://supabase.com/docs/guides/database/postgres/row-level-security#policies
|
||||
- **Magic links:** https://supabase.com/docs/guides/auth/auth-magic-link
|
||||
- **OAuth providers:** https://supabase.com/docs/guides/auth/social-login
|
||||
BIN
Les18-Supabase-Auth-RLS/Les18-Slides.pdf
Normal file
BIN
Les18-Supabase-Auth-RLS/Les18-Slides.pdf
Normal file
Binary file not shown.
BIN
Les18-Supabase-Auth-RLS/Les18-Slides.pptx
Normal file
BIN
Les18-Supabase-Auth-RLS/Les18-Slides.pptx
Normal file
Binary file not shown.
BIN
Les18-Supabase-Auth-RLS/lu45mkbrn.tmp
Normal file
BIN
Les18-Supabase-Auth-RLS/lu45mkbrn.tmp
Normal file
Binary file not shown.
Reference in New Issue
Block a user