fix: add les 11

This commit is contained in:
2026-05-19 18:50:11 +02:00
parent b053fc7206
commit 634789e615
37 changed files with 7587 additions and 209 deletions

View File

@@ -0,0 +1,814 @@
# Les 11 — Vercel AI SDK
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
**Les:** 11 van 18
**Onderwerp:** Vercel AI SDK — AI features bouwen, gekoppeld aan eigen dataset
**Duur:** 180 minuten
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = thuis.
**Demo-app:** Polderfest 2027 — fictief muziekfestival, 500 records in Supabase.
---
## Hoe deze tekst werkt
Dit document is een **lopend script**. Je kunt 'm letterlijk volgen op je laptop terwijl je lesgeeft.
- `[SLIDE X]` — Klik naar slide X op de beamer
- `[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 (60 min)
### 1. Tools open op je laptop
- VS Code / Cursor — leeg
- Terminal — open in `~/`
- Browser tabs:
- https://supabase.com/dashboard (ingelogd)
- https://platform.openai.com (key paraat)
- `localhost:3000` tab (nog niets)
- Dit docenttekst-bestand
- De slides PDF / PPTX
### 2. Demo-repo `polderfest-demo` voorbereiden
```bash
cd ~
npx create-next-app@latest polderfest-demo \
--typescript --tailwind --app --eslint --no-src-dir --turbopack
cd polderfest-demo
npm i @supabase/supabase-js ai @ai-sdk/openai zod dotenv
npm i tsx --save-dev
git init && git add . && git commit -m "init"
```
### 3. Nieuwe Supabase project
- Dashboard → **New Project** → naam `polderfest-demo`
- Wacht ~2 min op deploy
- Settings → API → kopieer URL + anon key + service role key
- `.env.local`:
```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
OPENAI_API_KEY=sk-proj-...
```
### 4. Schema runnen
- Supabase → SQL Editor → plak `schema.sql` → Run
- Check Table Editor → `bands` tabel bestaat, leeg
### 5. Seed test (verwijder daarna)
- Plaats `seed-polderfest.ts` in `polderfest-demo/scripts/`
- `npx tsx scripts/seed-polderfest.ts` → 500 records erin → check Table Editor
- **Wis** alle records vóór de les: `delete from bands;` (zodat je live kunt seeden in demo 2)
- **Verwijder** `app/chat/page.tsx` + `app/api/chat/route.ts` (maken we live in demo 3)
### 6. Backup
- Zip van werkende eindstaat → op USB
- Check OpenAI usage dashboard — key werkt + credits aanwezig
---
# HET SCRIPT — Lees mee tijdens de les
## BLOK 1 — Welkom + Terugblik (10 min)
`[SLIDE 1]` `[SCHERM: slides]`
**Vertel:** "Welkom bij les 11. Vandaag de Vercel AI SDK — eerste keer dat we échte AI features gaan bouwen IN onze apps. Geen ChatGPT openen meer — AI in onze eigen code."
`[SLIDE 2 — Terugblik]`
**Vertel:** "Even kort terug: vorige lessen hebben we Supabase geïntegreerd. Tabellen en relaties opgezet. RLS-policies bekeken — wie mag wat lezen en schrijven.
Vandaag iets anders. We gaan **niet** voortbouwen op QuickPoll. We beginnen een nieuwe demo from scratch. Nieuwe Next.js app, nieuwe Supabase, en dan koppelen we daar de AI SDK aan."
`*[Wacht 2 sec, laat het landen]*`
`[SLIDE 3 — Planning]`
**Vertel:** "Dit is de planning. Drie uur. Eerst theorie — 30 minuten — wat is de AI SDK, welke modellen, welke functies. Daarna vier demo's. Eén: nieuwe app opzetten. Twee: 500 records in Supabase via een seed-script. Pauze. Drie: AI SDK installeren en chatten met de data. Vier: vragen stellen aan die data."
`*[Wijs naar de gele rij]*`
**Vertel:** "Belangrijk — dit is een **kijk-les**. Jullie typen vandaag niet mee. Pak je notitieboek of laptop voor aantekeningen. Thuis bouw je zelf een versie, met je eigen thema. Daar gaan de lesopdracht en huiswerk over."
💬 Verwachte vraag: *"Kunnen we niet meedoen?"*
Antwoord: "Liever niet — als jullie ook typen, gaat 't te langzaam en haakt iedereen op een ander moment af. Vanavond is voor zien-en-snappen. Thuis is doen."
---
## BLOK 2 — Theorie AI SDK (30 min)
`[SLIDE 4 — Wat is de AI SDK]` `[SCHERM: slides]`
**Vertel:** "Wat is de Vercel AI SDK? Een TypeScript-library die één unified API biedt voor alle AI-providers. OpenAI vandaag, Anthropic morgen, Google overmorgen, lokaal Ollama als je dat wil — je code blijft hetzelfde.
Open source. Gemaakt door Vercel — de makers van Next.js. Daarom: naadloze integratie met Server Components, Server Actions en streaming.
Wat zit er in:"
`*[Wijs naar de bullets]*`
**Vertel:** "Unified API. Streaming out-of-the-box — geen WebSocket-gedoe. React hooks zoals `useChat`. Tool Calling — komt volgende les. En type-safe gestructureerde output via Zod."
`*[Wijs naar het code-blok rechts]*`
**Vertel:** "Kijk hier. Dit is alle code die je nodig hebt voor één AI-call. Vier regels. En zie je `openai('gpt-4o-mini')`? Als ik dat morgen wil veranderen naar Anthropic — verander ik dat in `anthropic('claude-sonnet-4')`. Eén regel. Rest van mijn code blijft hetzelfde. Dat is de waarde."
`[SLIDE 5 — Modellen + kosten]`
**Vertel:** "Het modellen-landschap. Loop ik even langs:"
`*[Wijs per rij]*`
- "**gpt-4o-mini** — je default. Snel, goedkoop, $0.15 input / $0.60 output per miljoen tokens. Goed voor 80% van de use cases."
- "**gpt-4o** — multimodal, kan plaatjes lezen. 15× duurder dan mini. Pas pakken als nodig."
- "**gpt-4.1** — beste reasoning. Voor agents. Volgende lessen relevant."
- "**claude-sonnet-4** — Anthropic. Beter in coding, 200k context — dus lange documenten."
- "**gemini-2.5-flash** — Google. Ultra goedkoop, multimodal."
- "**llama-3.3-70b** op Groq — open-source model op snelste inference platform."
**Vertel:** "Vuistregel: start met gpt-4o-mini. Werkt 't niet goed genoeg? Probeer gpt-4o. Pas daarna iets exotisch. Premature optimization is een valkuil — het is letterlijk één regel veranderen om te wisselen, dus geen reden om voorbarig te kiezen."
`*[Wijs naar de blauwe callout]*`
**Vertel:** "Onze hele les vandaag, inclusief Polderfest met 500 bands en 10 vragen? Ongeveer 1 tot 2 cent. Echt. Schaalt prima."
`[SLIDE 6 — 4 kern-functies]`
**Vertel:** "De vier kern-functies van de SDK. Deze tabel is je cheat-sheet."
`*[Wijs per rij]*`
- "**generateText** — wachten tot AI klaar is, dan krijg je een string. Voor korte server-only calls."
- "**streamText** — streamt karakter voor karakter. Werkt met useChat. Dit gebruiken we vandaag."
- "**useChat** — React hook. Complete chat UI in 10 regels. Ook vandaag."
- "**generateObject** — type-safe data via Zod schema. Voor database-inserts of classificatie. Vandaag niet — komt later."
**Vertel:** "Onthoud: streamText en useChat — onze combo voor vandaag. generateObject zien jullie volgende lessen terug. Tool Calling — onderaan — dat is volgende les."
---
## BLOK 3 — Live Demo 1: Next.js + Supabase scaffold (20 min)
`[SLIDE 7 — Polderfest concept]` `[SCHERM: slides]`
**Vertel:** "Voor we gaan coderen — wat bouwen we eigenlijk?
We bouwen **Polderfest 2027**. Een fictief Nederlands muziekfestival. 500 verzonnen bands. Allemaal namen die niet bestaan. Geen Spotify, geen Pitchfork — pure fantasy."
`*[Wijs naar de gele 'Waarom een fictief festival' callout]*`
**Vertel:** "Waarom fictief? Omdat **geen enkele LLM** dit kan weten. Geen training data over Polderfest 2027 — bestaat niet. En dat is precies wat we willen demonstreren: AI alleen kan dit niet. AI mét onze data, wél."
`*[Wijs naar het schema-blok]*`
**Vertel:** "Onze tabel heeft deze velden: naam, genre, sub-genre, stage, dag, starttijd, stad, members, bio, tier, populariteit, ticket-impact. Genoeg variatie voor leuke vragen straks."
`*[Wijs naar voorbeeld-vragen]*`
**Vertel:** "Dingen die we straks aan onze AI gaan vragen: welke bands spelen vrijdagavond op de Main Stage? Vat de hip-hop scene samen. Welke acts komen uit Groningen? Allemaal vragen die ChatGPT niet kan beantwoorden — want hij weet niets van Polderfest. Maar onze chat straks wel."
`*[Pauze, ademen]*`
**Vertel:** "Goed — laten we 't gaan bouwen. Eerst Next.js en Supabase opzetten."
---
`[SLIDE 8 — LIVE DEMO 1]` `[SCHERM: slides]`
**Vertel:** "Dit zijn de 6 stappen die we nu gaan doorlopen. Ongeveer 20 minuten. Volg even mee — niet meetypen, kijk."
`[SCHERM: terminal]`
**Vertel:** "We beginnen in de terminal."
#### Stap 1 — Next.js scaffolden
```bash
cd ~
npx create-next-app@latest polderfest-demo \
--typescript --tailwind --app --eslint --no-src-dir --turbopack
```
`*[Druk enter, wacht ~30 sec]*`
**Vertel terwijl het installeert:** "Standaard Next.js 15 met App Router, Tailwind, TypeScript. App Router omdat we Server Components willen. Tailwind voor styling. Niets bijzonders aan deze setup — dit kennen jullie al uit eerdere lessen."
`*[Wacht tot install klaar is]*`
```bash
cd polderfest-demo
code .
```
`[SCHERM: editor]`
**Vertel:** "Editor open. Niets in `app/page.tsx` — standaard Next.js welkomstpagina. Standaard `app/layout.tsx`. Tailwind config. Niks bijzonders."
#### Stap 2 — Supabase project aanmaken
`[SCHERM: browser → supabase.com/dashboard]`
**Vertel:** "Nu Supabase. Ik heb nog géén project voor deze demo — we maken er een nieuwe."
`*[Klik New Project]*`
**Vertel:** "Naam: `polderfest-demo`. Database password — kies wat, hoeft niet kopiëren. Region: West Europe. Submit."
`*[Wacht ~2 min — gebruik deze tijd voor de uitleg hieronder]*`
**Vertel:** "Terwijl het deployt: waarom een nieuw project? Omdat we van scratch beginnen. Geen vermenging met je QuickPoll-data van eerdere lessen. Clean slate. Voor je eindopdracht en huiswerk geldt: één app = één Supabase project."
#### Stap 3 — Schema runnen
`*[Supabase deploy is klaar]*`
`[SCHERM: supabase → SQL Editor]`
**Vertel:** "Schema-tijd. Open SQL Editor. New Query."
`*[Plak inhoud van schema.sql]*`
**Vertel:** "Dit is mijn schema voor de bands-tabel. Naam, genre, stage, dag, tijd, members, bio. Een paar indexen voor performance. RLS aan en een policy: bands zijn publiek leesbaar. Voor onze chat hebben we read-access nodig, geen schrijfrechten."
`*[Klik Run]*`
`[SCHERM: supabase → Table Editor]`
**Vertel:** "Check — tabel `bands` bestaat. Leeg. Klaar om gevuld te worden."
#### Stap 4 — Env vars
`[SCHERM: supabase → Settings → API]`
**Vertel:** "Drie dingen pak ik hier op: de Project URL, de anon public key, en de service_role secret key. Die laatste is belangrijk — straks bij het seed-script."
`*[Kopieer alle drie]*`
`[SCHERM: editor → .env.local]`
```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
OPENAI_API_KEY=sk-proj-...
```
**Vertel:** "Belangrijke nuance — let goed op de namen:
- `NEXT_PUBLIC_SUPABASE_URL` en `_ANON_KEY` — die staan in client-bundle. Mag — anon key heeft alleen leesrechten via RLS.
- `SUPABASE_SERVICE_ROLE_KEY` — geen `NEXT_PUBLIC_` prefix. Server-only. Deze key bypasst RLS — daarmee kan alles. Lekken = ramp. Gebruiken we alleen lokaal voor het seed-script.
- `OPENAI_API_KEY` — geen `NEXT_PUBLIC_` prefix. Anders zit-ie in je client-bundle en kan iedereen 'm gebruiken op jouw kosten. Server-only altijd."
#### Stap 5 — Supabase client
`[SCHERM: editor]`
`*[Nieuwe file: 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!,
);
```
**Vertel:** "Standaard Supabase client. Voor onze chat — dat is alles wat we nodig hebben. Niets bijzonders. Kennen jullie."
#### Stap 6 — Dev server check
`[SCHERM: terminal]`
```bash
npm run dev
```
`[SCHERM: browser → localhost:3000]`
**Vertel:** "Standaard Next.js welkomstpagina. Werkt. Supabase staat. Schema staat. Klaar voor data."
---
## BLOK 4 — Live Demo 2: Seed script — 500 records (20 min)
`[SLIDE 9 — LIVE DEMO 2]` `[SCHERM: slides]`
**Vertel:** "Demo 2. We gaan onze bands-tabel vullen. Niet handmatig — met een seed-script. 500 records in ~30 seconden."
`[SCHERM: editor]`
`*[Maak folder: scripts/. Plaats seed-polderfest.ts erin]*`
#### Stap 1 — Het seed-script bekijken
`[SCHERM: editor → seed-polderfest.ts]`
**Vertel:** "Dit is mijn seed-script. 200 regels. Laat me even door de structuur lopen — niet alle regels lezen, alleen de aanpak."
`*[Scroll naar top]*`
**Vertel:** "Bovenaan: Supabase client. Belangrijk — met de **service role key**. Niet anon. Want we gaan inserts doen. RLS blokkeert dat anders."
`*[Scroll naar de bouwstenen arrays]*`
**Vertel:** "Hier zijn mijn bouwstenen. Adjectives — 'Lost', 'Velvet', 'Iron', 'Neon'. Nouns — 'Tigers', 'Wolves', 'Mirrors'. Cities — Nederlandse steden. Genres — muziekgenres."
`*[Scroll naar generateBandName]*`
**Vertel:** "Hier de naam-generator. Vier patronen:
- 'Lost Tigers' — adjective + noun
- 'De Wolves' — Dutch prefix + noun
- 'Sanne Van Dijk' — solo artist
- 'Sanne & The Wolves' — solo + collectief
Met 30 adjectives × 30 nouns = al 900 unieke combinaties mogelijk. Genoeg voor 500 records."
`*[Scroll naar generateBio]*`
**Vertel:** "Bio's. Drie blokken — opening, middle, ending — gecombineerd. 'Begonnen in een garage in [stad]', '[band] experimenteert met analoge synths', 'Debuut-EP eind 2027'. Compositioneel. Geen handmatig getypte bio's — 500× zou krankzinnig zijn."
`*[Scroll naar bottom — async function seed]*`
**Vertel:** "De main functie. Genereert 500 bands, dedupe op naam, insert in batches van 100 — Supabase trekt 500 in één keer niet altijd. Done."
#### Stap 2 — Service role key uitleggen
**Vertel:** "Even pauze voor één belangrijk ding — de service role key. Die zit boven aan dit script. Drie regels die jullie moeten onthouden:
1. **Alleen lokaal gebruiken.** Niet in productie code. Niet in client. Alleen scripts.
2. **Nooit committen** naar git. Service role key in `.env.local`, en `.env.local` in `.gitignore`.
3. **Lekt-ie?** Direct draaien in Supabase dashboard → Settings → API → Reset service role key.
Vergelijk het met een root password. Behandel 'm zo."
#### Stap 3 — Dependencies (was al klaar — kort tonen)
`[SCHERM: terminal]`
```bash
# We hebben deze al uit setup, maar voor je eigen project:
npm i @supabase/supabase-js dotenv
npm i tsx --save-dev
```
#### Stap 4 — Run het seed-script
`[SCHERM: terminal]`
```bash
npx tsx scripts/seed-polderfest.ts
```
**Vertel terwijl het runt:** "Daar gaat 'ie. tsx is een TypeScript-runner die geen build-stap nodig heeft. dotenv leest de `.env.local` automatisch. 500 bands genereren, vijf batches van 100, klaar."
`*[Wacht ~10-30 sec, output verschijnt]*`
```
Genereren van 500 Polderfest bands...
Schrijven naar Supabase in batches van 100...
✓ 100/500
✓ 200/500
✓ 300/500
✓ 400/500
✓ 500/500
Klaar! 500 Polderfest bands staan in Supabase.
```
**Vertel:** "Done."
#### Stap 5 — Verificatie
`[SCHERM: supabase → Table Editor → bands]`
`*[Klik refresh — 500 records verschijnen]*`
**Vertel:** "500 bands. Allemaal verzonnen. Laten we er even één openklikken."
`*[Klik op willekeurige rij, toon bio]*`
**Vertel:** "Kijk — 'Begonnen in een garage in Groningen, De Tigers experimenteert met analoge synths en gefluisterde lyrics. Polderfest is hun grootste festival tot nu toe.' Compleet verzonnen. Geen Wikipedia, geen Spotify — pure fantasy. Maar overtuigend genoeg voor onze AI om mee te werken."
#### Stap 6 — Quick check met SQL
`[SCHERM: supabase → SQL Editor]`
```sql
select genre, count(*) from bands group by genre order by count desc;
```
`*[Run]*`
**Vertel:** "Genre-verdeling. ~30 per genre. Mooi gespreid. Klaar om mee te chatten."
---
## BLOK 5 — Pauze (15 min)
`[SLIDE 10 — Pauze]` `[SCHERM: slides]`
**Vertel:** "Pauze. 15 minuten. Tot zo."
`*[Coffee. Stretch. Check je OpenAI key nog even.]*`
---
## BLOK 6 — Live Demo 3: AI SDK + chat-route (30 min)
`[SLIDE 11 — LIVE DEMO 3]` `[SCHERM: slides]`
**Vertel:** "Welkom terug. Nu de echte AI-stap. We bouwen een chat-route in onze API en een chat-pagina in Next.js. Daarmee kunnen we vragen stellen aan onze Polderfest-data."
`[SCHERM: terminal]`
#### Stap 1 — Packages
```bash
npm i ai @ai-sdk/openai zod
```
**Vertel:** "Drie packages. `ai` is de SDK zelf. `@ai-sdk/openai` is de provider — we gebruiken OpenAI vandaag. `zod` is voor schema validatie. Vandaag gebruiken we 'm niet, maar volgende les wel."
#### Stap 2 — Chat API route
`[SCHERM: editor]`
`*[Maak file: app/api/chat/route.ts. Typ live, niet pasten — geeft studenten tijd om te volgen]*`
```typescript
import { streamText } 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 { messages } = await req.json();
```
**Vertel:** "API route. POST, want we ontvangen messages van de chat. We destructuren de messages-array."
```typescript
// 1. Haal alle bands op uit Supabase
const { data: bands, error } = await supabase.from("bands").select("*");
if (error) throw error;
```
**Vertel:** "Stap 1 — ALLE bands ophalen uit Supabase. Voor 500 records werkt dit prima. Voor 50.000 niet — volgende les lossen we dat op met Tool Calling. Vandaag: simpele aanpak, alles meesturen."
```typescript
// 2. Format bands als context-string
const context = bands!
.map((b) =>
`- ${b.name} (${b.genre}, ${b.tier}, ${b.day} ${b.start_time} ` +
`op ${b.stage}, uit ${b.origin_city})`
)
.join("\n");
```
**Vertel:** "Stap 2 — we maken één grote tekst-context. Per band één regel met de belangrijkste velden. AI kan namelijk geen SQL, maar wel tekst lezen."
```typescript
// 3. System prompt met context
const system = `Je bent een festival-assistent voor Polderfest 2027.
Hier zijn alle bands die op het festival spelen:
${context}
Beantwoord vragen van bezoekers over de line-up. Verzin niets — gebruik
alleen bovenstaande data. Antwoord in het Nederlands. Wees beknopt.`;
```
**Vertel:** "Stap 3 — de system prompt. Dit is de **rol** die AI krijgt. Drie belangrijke instructies:
1. 'Verzin niets' — voorkomt hallucinaties.
2. 'Gebruik alleen bovenstaande data' — niet uit training-kennis halen.
3. 'Antwoord in het Nederlands' — anders krijg je Engels.
Een goede system prompt is je hefboom. 50% van de kwaliteit komt hier."
```typescript
// 4. Stream naar OpenAI
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages,
});
return result.toDataStreamResponse();
}
```
**Vertel:** "Stap 4 — de AI-call zelf. `streamText` — onze keuze van vandaag. Model gpt-4o-mini. System message, plus de berichten van de user. En `result.toDataStreamResponse()` zet 't om naar het juiste streaming-format voor useChat aan de client-kant."
**Vertel:** "API route klaar."
#### Stap 3 — Chat pagina
`*[Nieuwe file: app/chat/page.tsx]*`
```tsx
"use client";
import { useChat } from "ai/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, status } =
useChat();
return (
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
<h1 className="text-2xl font-bold mb-4">Polderfest 2027 — vraag de AI</h1>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((m) => (
<div
key={m.id}
className={
m.role === "user"
? "bg-blue-50 p-3 rounded-lg ml-12"
: "bg-gray-50 p-3 rounded-lg mr-12"
}
>
<div className="font-medium text-sm text-gray-500 mb-1">
{m.role === "user" ? "Jij" : "Festival AI"}
</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Stel een vraag over de line-up..."
className="flex-1 p-3 border rounded-lg"
disabled={status !== "ready"}
/>
<button
type="submit"
disabled={status !== "ready"}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Stuur
</button>
</form>
</main>
);
}
```
**Vertel:** "Client component — `'use client'` bovenaan. `useChat` hook regelt alles: messages-state, input-state, submit-handler, streaming. Vijf properties, geen extra useState nodig.
UI is bewust simpel. Tailwind classes. Berichten van user blauw rechts, AI grijs links. Input + verzenden onderaan. Disabled tijdens streaming via `status !== 'ready'`."
#### Stap 4 — Testen
`[SCHERM: browser → localhost:3000/chat]`
**Vertel:** "Naar `/chat`. Eerste vraag."
`*[Typ in chat-input]*`
```
Hallo, wie ben jij?
```
`*[Druk Enter. AI antwoordt streamend.]*`
**Vertel terwijl AI antwoordt:** "Daar gaat 'ie. Karakter voor karakter. Streamt. Veel sneller voelend dan wachten op heel antwoord. UseChat regelt het, je hoeft niks zelf te doen voor streaming."
**Vertel:** "Klaar. Werkt. Nu de leukste vraag: vragen aan onze data."
---
## BLOK 7 — Live Demo 4: Vragen aan onze data (15 min)
`[SLIDE 12 — LIVE DEMO 4]` `[SCHERM: slides]`
**Vertel:** "We gaan vijf vragen stellen. Eén voor één. Bij elke vraag — let op wat de AI doet, en hoe dat anders is dan een SQL query of een gewone chatbot."
`[SCHERM: browser → /chat]`
#### Vraag 1 — Filter
`*[Type in chat]*`
```
Welke bands spelen zaterdag op de Beach Stage?
```
`*[AI antwoordt — geeft een lijst]*`
**Vertel:** "Filter. AI heeft door de tekst-context gefilterd. Even bevestigen met SQL —"
`[SCHERM: supabase → SQL Editor]`
```sql
select name, start_time from bands
where day = 'Zaterdag' and stage = 'Beach Stage';
```
`*[Run]*`
**Vertel:** "Zelfde resultaat. Maar het verschil — onze chat geeft natuurlijke taal, kan vervolgvragen aan, kan samenvatten. SQL doet alleen filter + select."
#### Vraag 2 — Sort + Reasoning
`[SCHERM: browser → /chat]`
```
Geef me 3 headliners met de meeste popularity, en hun bio's
```
`*[AI antwoordt]*`
**Vertel:** "Sort. AI heeft op `popularity` gesorteerd en de top 3 gepakt. Plus de bio's erbij."
`*[Vraag 2b in zelfde chat:]*`
```
En welke daarvan zou je aanraden voor iemand die houdt van techno?
```
`*[AI antwoordt — beargumenteerd]*`
**Vertel:** "Dit is **reasoning**. AI redeneert over genre + sub-genre + bio-tekst om een aanbeveling te doen. Een SQL query kan dit niet. Dit is waar AI waarde toevoegt bovenop pure data."
#### Vraag 3 — Aggregate
```
Hoeveel jazz fusion acts spelen er totaal op Polderfest?
```
`*[AI antwoordt met een getal]*`
**Vertel:** "Aggregate. AI heeft geteld. Even SQL-bevestigen —"
`[SCHERM: supabase → SQL Editor]`
```sql
select count(*) from bands where genre = 'Jazz Fusion';
```
**Vertel:** "Klopt. Goed."
#### Vraag 4 — Samenvatting (waar AI uitblinkt)
`[SCHERM: browser → /chat]`
```
Vat de electronic-scene op Polderfest samen in 3 zinnen
```
`*[AI antwoordt met een echte samenvatting]*`
**Vertel:** "Hier is geen SQL voor. Dit is **samenvatten**. AI heeft alle electronic acts gelezen, gewogen, en in 3 zinnen samengevat. Dit is de unieke kracht van AI bovenop data."
#### Vraag 5 — De mist in (bewust)
```
Wie was de hoofdact van Polderfest 2025?
```
`*[AI antwoordt eerlijk dat hij dit niet weet]*`
**Vertel:** "Goed. Geen verzinsels. AI zegt: 'mijn data is alleen 2027, dat zit er niet in'. Dat is de kracht van onze system prompt. Zonder die prompt zou-ie waarschijnlijk wat hallucineren."
`*[Pauze, kijk de klas in]*`
**Vertel:** "Zien jullie wat hier gebeurt? Geen LLM ter wereld kent Polderfest 2027. Geen Wikipedia, geen training-data. Maar onze chat beantwoordt alles — omdat **wij** de data leveren. AI + data = product."
---
## BLOK 8 — Data + AI = kracht (5 min)
`[SLIDE 13 — Data + AI = kracht]` `[SCHERM: slides]`
**Vertel:** "Reflectie-moment. Drie scenario's:"
`*[Wijs naar de grijze box]*`
**Vertel:** "**Data alleen.** Wat heb je? SQL queries. Filter, sort, select. Geen taal, geen interpretatie. Gebruiker moet zelf SQL kunnen."
`*[Wijs naar de roze box]*`
**Vertel:** "**AI alleen.** ChatGPT zonder context. Generieke kennis uit training. Hallucineert. Geen privé data, geen live data."
`*[Wijs naar de blauwe box]*`
**Vertel:** "**Data + AI.** Wat we vandaag bouwden. Filter via reasoning. Antwoorden in natuurlijke taal. Samenvattingen, vergelijkingen, aanbevelingen. Schaalbaar — voeg data toe en je hebt nieuwe antwoorden mogelijk."
`*[Pauze]*`
**Vertel:** "Quote om mee weg te lopen:"
`*[Wijs naar de gele callout]*`
**Vertel:** "*Een LLM zonder jouw data is een gewone chatbot. Een LLM mét jouw data is een product.* Onthoud dit. Dit is de fundering van alle volgende lessen."
---
## BLOK 9 — Lesopdracht + Huiswerk uitleg (20 min)
`[SLIDE 14 — Lesopdracht]` `[SCHERM: slides]`
**Vertel:** "Lesopdracht. Voor thuis — niet vanavond per se, maar vóór volgende les. Je bouwt **een eigen versie** van wat we vandaag deden. Met **je eigen thema**."
`*[Loop checklist langs op slide]*`
**Vertel:** "Eisen op een rij. Bedenk een eigen thema — moet fictief zijn. Nieuw Next.js project, nieuw Supabase. Eigen seed-script. Minstens 100 records. Chat-route en chat-pagina werkend. Drie vragen stellen die alleen werken dankzij jouw data."
`*[Wijs naar pink callout]*`
**Vertel:** "Inspiratie: fictief restaurant-aggregator in een verzonnen stad. Scriptie-archief van NOVI met 1000 nep-titels. Museumcollectie met verzonnen kunstenaars. D&D NPCs. Cryptid-sightings in Nederland.
Belangrijk: **moet fictief zijn**. Als je echte restaurants in Amsterdam pakt, weet ChatGPT die al — dan zie je niet wat we vandaag demonstreerden. Het hele punt is: data die geen LLM kent."
💬 Verwachte vraag: *"Mag ik echt elk thema?"*
Antwoord: "Ja, zolang het fictief is en minstens 100 records heeft. Twijfel? Stuur 'm op Brightspace, dan kijk ik even mee."
`[SLIDE 15 — Huiswerk]`
**Vertel:** "Het huiswerk bouwt voort op de lesopdracht. Drie onderdelen — alle drie verplicht."
`*[Loop A, B, C langs op slide]*`
**Vertel:** "**Onderdeel A.** Pas het Polderfest seed-script aan voor jouw thema. Het script staat klaar als bijlage. Open 't, lees 't, en pas 't aan. AI mag je helpen — letterlijk: open OpenCode, plak m'n script erin, vraag 'pas dit aan voor [mijn thema]'. Klaar in een paar minuten. Daarna jij review. Minstens 200 records.
**Onderdeel B.** Voeg minstens 1 extra veld toe aan je schema. Iets dat een **nieuwe vraag** mogelijk maakt. Niet zomaar een extra string-kolom. Concreet voorbeeld: een museumcollectie met `acquisition_story` veld — dan kun je vragen 'welke kunstwerken zijn op een veiling gekocht?'. Update seed-script, re-seed, test in chat.
**Onderdeel C.** Schrijf een `AI-CHAT.md` in je repo-root. Drie secties:
- Mijn thema — wat is het, waarom kan een gewone LLM dit niet?
- 3 leuke vragen die werken
- 1 vraag waar AI moeite mee had + hoe je je prompt aanpaste."
`*[Wijs naar gele callout]*`
**Vertel:** "Bonus, geen verplichting: deploy op Vercel, loading skeleton, vergelijk gpt-4o-mini en gpt-4o."
💬 Verwachte vraag: *"Hoe lang gaat dit duren?"*
Antwoord: "Lesopdracht ~2,5 uur. Huiswerk ~1,5 tot 2 uur. Samen een lange middag. Loop je vast — Brightspace of plan een korte 1-op-1 met me."
---
## BLOK 10 — Vragen + Afsluiting (15 min)
`[SLIDE 16 — Volgende les: Tool Calling]` `[SCHERM: slides]`
**Vertel:** "Eén ding voor we afronden — wat komt hierna."
`*[Wijs naar pink callout: Het schaal-probleem]*`
**Vertel:** "Wat we vandaag deden: ALLE 500 bands sturen we mee als context bij elke vraag. Dat is ~30.000 tokens per call. Werkt prima voor 500. Werkt **niet** voor 5.000 records. En al helemaal niet voor 50.000."
`*[Wijs naar blauwe callout: De oplossing]*`
**Vertel:** "Volgende les — **Tool Calling**. In plaats van alle data meesturen, geef je AI **functies** die hij zelf kan aanroepen. Hij hoort vraag 'welke bands op vrijdag?' en besluit: ik roep `searchBands({ day: 'Vrijdag' })` aan. Krijgt 60 bands terug. Antwoordt. Schaalbaar tot duizenden records."
**Vertel:** "Daarna in deze leerlijn: Agents met maxSteps. RAG met embeddings — semantic search op heel grote datasets. Testing, deployment, performance. En de laatste twee lessen: eindopdracht-werkdagen en je pitch."
`[SLIDE 17 — Afsluiting]`
**Vertel:** "Vragen?"
`*[Open de vloer. Verwachte vragen + antwoorden:]*`
💬 *"Wat als ik geen schoolkey heb?"*
→ "Eigen OpenAI account — $5 starter credit zit erin gratis. Of Groq — gratis tier. Of Anthropic — $5 gratis credits."
💬 *"Hoe weet ik welk model het beste is?"*
→ "Start met gpt-4o-mini. Upgrade alleen als het écht niet werkt. Premature optimization is een valkuil."
💬 *"Kan dit lokaal zonder OpenAI?"*
→ "Ja, via Ollama. Niet vereist voor deze les. Komt eventueel in latere lessen."
💬 *"Moet ik de Polderfest demo zelf ook namaken?"*
→ "Nee. Wat we vandaag deden is jullie laten zien. Voor jezelf bouwen = eigen thema, in lesopdracht en huiswerk."
💬 *"Hoe duur is dit nou echt?"*
→ "Onze hele les vandaag met 500 bands en 10 vragen — onder de 2 cent. Met gpt-4o (de grote) zou hetzelfde ~30 cent zijn. Met mini blijft het peanuts."
`*[Sluit af]*`
**Vertel:** "Zorg dat je vóór volgende les minstens je seed-script werkend hebt voor jouw thema. Dan kunnen we volgende les meteen Tool Calling toepassen. Tot dan!"
---
## Backup-onderwerpen (als tijd over is)
1. **Andere provider tonen** — Open `route.ts`, vervang `openai("gpt-4o-mini")` door `anthropic("claude-sonnet-4")`. Werkt direct. Eén regel.
2. **System prompt fine-tuning** — Verzwak de prompt ("Help bij vragen"). Vraag iets. Verzin de fout. Versterk weer ("Verzin niets, gebruik alleen onze data"). Toon verschil.
3. **Token-kosten dashboard** — Open platform.openai.com/usage. Toon je verbruik van vandaag — letterlijk een paar cent.
4. **Privacy / data retention** — Wat gaat naar OpenAI? Zero-data-retention via EU-endpoints. Belangrijk voor productie.
5. **Hallucinatie-test** — Probeer met zwakke prompt of de AI iets verzint over Polderfest 2025. Toon dat sterkere prompt dit fixt.

View File

@@ -0,0 +1,181 @@
# Les 11 — Huiswerk
## Seed-script aanpassen + uitbreiden + reflecteren
**Vak:** AI-Assisted Development
**Opleiding:** NOVI Hogeschool Utrecht
**Deadline:** Voor de volgende les (Les 12 — Tool Calling)
**Inleveren:** GitHub repo + `AI-CHAT.md` in root
---
## Doel
Bouwt voort op de **lesopdracht** (eigen thema-app). Hier:
- **A.** Pas het seed-script aan voor jouw eigen thema (mag AI bij helpen)
- **B.** Voeg een **extra veld** toe dat een nieuwe vraag mogelijk maakt
- **C.** Schrijf een **reflectie** over wat werkt en wat niet
> Niet klaar met de lesopdracht? Eerst die afmaken — daarna komt dit. De huiswerkopdracht heeft de lesopdracht-app nodig om op te bouwen.
---
## Onderdeel A — Seed-script voor jouw thema (verplicht)
Het Polderfest seed-script is je voorbeeld. Pas het aan voor jouw eigen thema.
### Stappen
1. Open `seed-polderfest.ts` als referentie
2. Pas aan voor jouw thema:
- Domein-arrays (in plaats van adjectives + nouns → wat past bij jouw thema?)
- Veld-namen + types
- Bio/beschrijving-fragmenten (de samengestelde tekst-generatie)
3. Run je seed-script tegen je eigen Supabase
4. Verifieer 100+ records in Table Editor
### Pro tip: AI als seed-script writer
Open OpenCode (of Cursor) en typ:
> Hier is het Polderfest seed-script. Pas het aan voor [mijn thema].
> Mijn schema is: [paste schema]
> Genereer 200 records met realistisch-ogende variatie.
AI doet dit in 1-2 minuten. Daarna jij review — controle.
### Eisen
- [ ] Werkende seed-script in `scripts/seed-[thema].ts`
- [ ] Minimaal **200 records** in Supabase (was 100 voor lesopdracht — nu meer)
- [ ] Procedureel gegenereerd (niet handmatig — gebruik combinaties)
- [ ] In je README: korte uitleg hoe je 't gegenereerd hebt
---
## Onderdeel B — Extra veld + nieuwe vraag (verplicht)
Voeg minstens **1 extra veld** toe aan je schema. Iets dat een **nieuwe interessante vraag** mogelijk maakt.
### Voorbeelden
| Bestaand veld | Extra veld | Nieuwe vraag mogelijk |
|---------------|-----------|----------------------|
| Restaurant — `cuisine` | `dietary_options: string[]` | "Welke veganistische opties zijn er?" |
| Scriptie — `year` | `keywords: string[]` | "Vat scripties over AI samen" |
| Festival-band — `tier` | `collaborations: string[]` | "Welke acts hebben samengewerkt met X?" |
| Museumstuk — `period` | `acquisition_story: string` | "Welke kunstwerken zijn op een veiling gekocht?" |
### Stappen
1. Update je `schema.sql` met het extra veld
2. Run de SQL in Supabase (kun je `ALTER TABLE` gebruiken — niet alles opnieuw)
3. Update je seed-script om het nieuwe veld te vullen
4. Re-seed je tabel (eerst wis bestaande records: `delete from items;`)
5. Test in chat: stel een vraag die alleen kan dankzij het nieuwe veld
### Eisen
- [ ] Nieuw veld toegevoegd aan schema
- [ ] Seed-script gevuld voor nieuwe veld
- [ ] 1 vraag aan chat die specifiek dit veld gebruikt — werkt
- [ ] Screenshot van die vraag + AI antwoord in `AI-CHAT.md`
---
## Onderdeel C — `AI-CHAT.md` reflectie (verplicht)
Schrijf een markdown-bestand `AI-CHAT.md` in je repo-root met:
### Sectie 1: Mijn thema
- Wat is het thema?
- Waarom **kan een gewone LLM** deze vragen niet beantwoorden zonder jouw data?
- Welke velden heb je gekozen en waarom?
- Welk extra veld heb je toegevoegd (onderdeel B)?
### Sectie 2: 3 leuke vragen die werken
Voor elke vraag:
- De vraag zelf
- Het antwoord van de AI (screenshot of plak-tekst)
- Waarom dit een goede demo is
### Sectie 3: 1 vraag waar AI moeite mee had
- Welke vraag was het?
- Wat ging er mis (vaag antwoord, hallucinatie, foute filter)?
- Hoe heb je je **system prompt** aangepast om dit op te lossen?
- Werkt de vraag nu wel?
### Vorm
- Max 600 woorden in totaal
- Concrete voorbeelden (geen vage reflectie)
- Mag wat informeel — geen scriptie-toon nodig
---
## Bonus (optioneel, niet verplicht)
Iets extra's? Mag, geen extra punten maar wel leerzaam:
- **Deploy op Vercel** + production URL in je README
- **Loading skeleton** in de chat UI (terwijl AI antwoord aan het streamen is)
- **Vergelijking gpt-4o-mini vs gpt-4o** — beschrijf het verschil in antwoorden
- **System prompt variaties** — drie prompts proberen, screenshot per variant
- **Themed UI** — Tailwind aanpassen zodat 't past bij thema (kleuren, fonts)
---
## Inleveren
1. **GitHub repo URL** in Brightspace
2. **`AI-CHAT.md`** in repo-root
3. **Seed-script** in `scripts/seed-[thema].ts`
4. **Updated schema** in `schema.sql` (met extra veld)
5. **Screenshots** ingevoegd in `AI-CHAT.md`
Optioneel: Vercel deploy URL als bonus.
---
## Beoordeling
| Criterium | Punten |
|-----------|--------|
| A — Seed-script werkt + procedureel + 200+ records | 3 |
| B — Extra veld + werkende nieuwe vraag | 2 |
| C — `AI-CHAT.md` aanwezig met 3 secties | 3 |
| Chat werkt end-to-end (geen broken pages) | 1 |
| Reflectie sectie C is concreet (geen fluff) | 1 |
| **Totaal** | **10** |
Voldoende = 6+. Bonus telt mee bij twijfelgevallen.
---
## Tijd-indicatie
| Onderdeel | Tijd |
|-----------|------|
| A — Seed-script aanpassen (met AI hulp) | 30-45 min |
| B — Schema uitbreiden + nieuwe veld + vraag testen | 30 min |
| C — AI-CHAT.md schrijven met screenshots | 30-45 min |
| **Totaal** | **~1,5 - 2 uur** |
---
## Veelvoorkomende valkuilen
- **Thema dat LLM al kent** — Yelp-clone, Spotify-data. Werkt niet voor demo van data-power.
- **Te weinig records** — 200+ vereist, anders is variatie te klein
- **Vage system prompt** — "Help bij vragen" werkt slecht. Wees specifiek.
- **Geen reflectie op slechte vragen** — sectie C wordt vaak vergeten, terwijl daar je leercurve zit
- **AI verzint feiten** — system prompt versterken: "Verzin NIETS. Gebruik alleen onze data."
---
## Tips
- **Schrijf je AI-CHAT.md gaandeweg** — niet aan einde. Sla goede prompts/screenshots op zodra ze werken.
- **Maak de prompt-iteratie expliciet** — sectie C wordt mooier als je echt 3-4 prompt-versies probeert.
- **Niet bang voor AI in je workflow** — laat OpenCode het seed-script schrijven. Tijdwinst is enorm.
Succes! Volgende les pakken we Tool Calling — dan kun je dezelfde demo schaalbaar maken naar 50.000 records.

View 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:20260519160527+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260519160527+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Les 11 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 2227
>>
stream
GauHL>BcPr&:WeDbY-8R]="@@#gnu%Bp(A;VqYFgksNo)%#RE0_$/Wlr>fkip_q2t2U8Qn8>H!VDe;2LJDu@7hpDkjob`_H0OPm?2o&\X_Y?9aO.DmQiKF)@#=Ksi!Y-Iejq!?KPCY*<%!]QQ;4f8o"]mFQ<1nd%38\@4'H1jq3"1TrWNTPLMKr+A[IJi>c>dTcGZ"aHj'2PJEA&H?oa]eorm;SV?nEdB<Od];chCg0K;/r!YMR)a51O4dS*)6u>8931Xl,Xh+&:[P:<G=@.ilNqrh\(k51<[-*6*1VIUV@WfQW$>>XA="qsSY6N5<IW.IVELUTs\FS0hAECgcH_/o##jo%$_g)"@](i+D=\EC]#uRH+-MZcj+]]EnkAdQABu^JogZpC\[jhCp8^ofQj+p2,ruT*8W)0buUZlt!ONEX%S'Vk)&?d,#].-]8t%DP['`p2>FS/!A8iF`<rZ;i1t$cB0WOArX[XGWP1rTLn+C+a38eWi2hAlo\s.fu\&g_/=0BG2#?1CUB`#\$6l^*q:(GX9[l#m]cbEV2I)"BgUeR%F-+kZllVu)_M4"@u)pJDui/68?![>.9n[<j7.-qe4G8B8ODdN)j#S+irPU^e/21oR*EOSe/A/fMkldU3f30l6JnS"WcXr@#GVFHq^0QqPRKY+JG3;d48a26rO?&CFH4q4F.:pqnJ!Q36P*2f^RS/_>m?-ge^d9^+HN\B+0V!#*dSPDREQ<1CRZp1IQ/W*:Id^LK0XgSVmmO72LQFb^,)iUfogZ*5iM>X-tbT"dj3BQ,M@9m'dQk+?GNa,Sa%cWOCGk-2c7J^W#6P!VdYHX9Wt)W!dG9jG?Kes^e!,\i'I.p-Ip(EJh]:30f5#EV\^^j15d<tTZH`t/c319]o?#&M^);jc;nD^msk_Hl`_uP^:_G)q`=8X0EYA\D9dbJ/iO_k<F?:(e1F>Z+RMMIie"X-b&t:<M>J0e5F?G=H(.7i(O<5&E:c1ugfMRn]VlsF"4F0?Yi#$sUfTa@Mm*SShTuk_:+&S!W,D8Z*/4"[olC1I7A9hY0i&H&UEN-j(9pVW7^doR\^P,+!9Vs&UN1o8S>7(f&60:<akTEF;hVD.8m?u7Z7.r1QA[F5ZG/i/K=^0662&Tg::E1An_Q<57lV96dak.*MInr')G?47hY<JH*L^nMq\d4%I!l$`o*iNZ%1A=On@@]d830[o79=3:JTr$j9iGBmXsU[EN,P?lMV/"XG[!ZECO!DdCFF5+Mm@p8%3W<OKg49XBq0-DBk@!_^lo:cY2DegL'>fnP,Z3iYoY-$>,qcYYg7/6?mdftFb/VT=h;nPXhcZd1<N*M-t`_OQP-HC,q:uM#gK2_6[aZ'MRmQL``:d:8.E[r/RI$VEbl\$TP1?:WboUX+p&:VD:D+!+6u2D&>=a4Fm:Z.b5-nC)PVBj,P8Y*HeLR7%^n%qaBSVPWo$pl\Yu=S]$:WgWbp%@-7;,.<1??jSIB[T40Paa=s'JPhk9"+7lA_8ahA@-UG=<^H'>br%Ooi%+A><anZ3G=:&OLZji=-3PTf:h4N5fnUQN@R^o=,_4'fHVd5m6nT"dmm4BK5PSdp)`3?PiBF#>nAm:PtTXZO+q0L;n`gPn31;qGF*]e^.iEQ$n;hI_&L[]bT^gS8XNe"\hg,N<XMC2UM@<4Fr&BheXtdo;1\3^&dc-UGiJ5j08DE!t2M0MEZZ)5$0[-!'1c*bF<S>YgN1FAmik,O9K\6S=g;lVjGmH_BPWU&VF>9]uq<Z-l+7@,p?X*_sRmfBtg/4Eh<\LS;?.7<4T*?/W8ERNlU4(NF.Wn[K>LkAaD]`Imj2`]<XGq="@5>*9?&Q5%MG9?Q*P?5p##5FpB*?`UP$W3LHSefk,('Ld9hE7FW\\:S=F>K%lnmZ6l"M%l:a)\pt*P$nn<3E4,u'cqege267XI?@L?fNW[P):*]$k-7*6>ngjVTYa<_6s-<E(U00Vokg#M[1`L$J]l,>]Rk(n&@9K&9D5-iQs,B5S/jPF5nN55#e\:kQ&RcI'%D/=-jI!hmZKZuq^^(gO3i$nICc.tSs9,:7)cc^BTF^$]t4!0S8ul%J#ed3"IiJ5#:c.F.'r4U#tl0@lXYED*nSZogAS4t?1idJ3O%=EO$)n1P@Vcd%+DUomXV]\a';l.4fSbeKA[9h#"9TtG_&/L_UOgh3aQ=G##RRR,[#rWVgJf=3N_+l=ZjTV:%+.3^]`gb\n^BI~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 444
>>
stream
Gas2E92EGZ&;9NJ'm$,m@D%&E>7j:HnQ+St9.<O1%O;)S5&Se89*JYF(OKc/oakXbC<kcX36?,]7NdEO"_<9PG)9Ok]Hom)jc+u@60_E8UjBRJk/d\U$(%ZV&>U,Y)jil7KJYq_SbM2B10tG'K()P3?m#MR(Yk$W$`?s?D1m$arN0GsKdC%Tgkm^n[QBqs(UP+GgJo19aa/k]Nm@=Bis!_dS?(VI-t[_&9-0+.oTf"2?6"-hC0]<eN)T`6=n+Q97D4auK'ZmuCW#%C.u]dCg]*Ee&E3P8>^^WQD1-=-q04WEg+^*rT=RDT[1^;J'>oXXZFOk.C1<<"/F#<][6FS2X8"'=%<)W[6p3S0CWe'f:7o2V,2?_Y,V2fcT2oY"+5XBlIGa@^?M0=5/]sOP5-AOg@oP-/N4g,ikIf`9\U;DQTB[GU];C[;!?j[u_>~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1935
>>
stream
Gb"/'gN)%,&:N_Cm%]`&&H]UrH-+]qe"e39NNFH:FXKOTCP`ZW1.bWV^V1)=+t6Yr8+e-uqBh'=0$[o:c7ZmuVf,o>oco!N*kZm@JIC[I"#lVS]e5iKZl=u2><&J`b`KEp$=L$1^*"G00j$???/]gc]#1=RJgi^DdZkrrc>g>r.Dkb!N<=6<h2s$<aXp`N&*sa;$UI^\bd))0ftP<N'g.i9'fpqLrfAoS_5j5DYD1/@DE/L),3XFPo^r=!:Bm3^_^#QI9:hkBbi)G*#ED"g*G8N:jk,?tG`,ucM(m5I6#Qn>kA%/cJ((WhC$<j:s"7p!3P:sgfaQ>Ll1Y^,&B#V,/OuFI;<ONe;'C((aUI:ZO#F/NB$e[k_mE,W,#)1@Hs8fC2;p5p2eD<COOYbCEQ8j^-e<[(YLUt_%@7c"fQuW,37ic9\&$6B21YK)T)d0oLDm)jo&cQjm7=EnnMS!G&^lFQA7]#$d>H`2L0?kmoj-_ZjZ>o&K"X(+'gGk^EuF/D&<]pZ$S<r5jQr\-(WOX0?@Hbe[gW3g'+b&2Jm$kTi\(!jrB!e<+?K2S"iLF:B*buGk7-@TF,T9.%#BJ$caL*rG4h:EC.CVdZcIGr3]1QTNV.<2/!um2#nA'b@cDQ)$jCH$jU!^;J9_?I0*fF?N\Q?29JO^QHE@0;Zua)l1ks*C`]77R]G`W?COt?P\dr8KP:<OLP4T(f1<+)WY'7QjfF+af6,1K9k`'6r$+&3`MHXbRCTeYn>btpEajOs9jG9pe:&,(0_VF@!:=YL..u6e:9/fN7A!VIJgb61A>3WQ!1SL:5D:9&B[=KJl&ktmG"s7'FDjfh.pc2[7T'P?-?8suIBW!:6Z;aksF>eqp$gu0I+(4N5GMeTU*[Gb!$F2c'ik_7:D2FuBs+]L"@_dO2;i(dYJ067AR(ZhnJ<\S5/2-oX:]jA7GNbl-!*=>R]UL3]Is7]Rh,i=N<6MR^g'f_i>1bV=+DDc/6r5-W!0YG^J<c+dKV<UTUu$P.>$71"lM75<W`G9<PsEqRVd#OZ9f&;/=[&sflNGZZfgafGeFRSm@2.Ga=e;F5F3%PcQe%cUPl2`#i@r&e6DE:*W\ip`qAk^@8LY&\)aUTd;)CG!j9Nt[e!L<F\XGq2(8akN>SL)E;FN1;\Ph'XM:p#PVX)e8IT0o4Ec3_d7:H_XBMH(NR?tjiZ'QSmXfB#<B=ju0p#X6DX-TiTBs2:IAO<4)6#H/5A(aQ^&a$3a\TVEgp-E.$42b_q0"1@c$l>PJ<8HMB3q9AcN-)%?h!LZ(6gsfGHb.^`/;@dY,WoRlnB;tI*Fcbr5Y.WLd381SL>`>Odu!0/nX2JW-Q\[>4ArF]aq3#QLH^G`JR,/fAWUDdD+rhQU9f$!mW$)9W4XWiqEloc<OJ>r[DAW>;0=:e"Eg^6pc]PFSb/r\\5l01gH(]uSd*k#1+B$j317/?6Y,MG43h#kF==IT*Pkp#m?OoMZ)`ot"2(HpI@u%pd`tQdPYW*$1Y5s\?*m"QPU#'V\X&!6:D[.if3XU0C3-@JWQeI\XeMF+i)MfIkgL)eNq`n_IU"5uPQW6N-@$@$$YkcSNY+'.;tcL;:quqjrGdleH\dM4/Oi6(rr7DkPMXp(q3UPc)Cj+EqDq$Z34eKu<C>N>-H=:;I[7'O=P@5jip&3,7b:I1o(&>KNThuK['B$Irf3)U:6MA/8TqY2&\?LZ\8**AlA`t'A?o;\?HU;k0T%%2./n@'j@$*S`IFpFINqi%Z9gs;eTh%.f>;HtVcq;!'b&WM%<.WO#i4ndFeJKJe]si#3R$FhkJBhemZY/AWOc9g\,sE1c(*\kW;B)b+1#EB2TGBq=%*kOdS95DKtH[cH0Z]0E,td@@@1hg,1iAQ.uPg@`IJMtj-jcK@%c5>?$^HmW1EAE/Mo\?%c>R8k3k%;?BZ\^rW1$n=_@~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 771
>>
stream
Gb!;abAQ&g&A7ljk*Q8X,O.QlV]pNl$'7?&]'[HQem?OW-..uk]D_W0AB0gLfKaH_>)A=NeF(WK/4U<ohm.Y?$fGkDQe)ku7t1pbM@f_JI-C<Ron9OH3/es]m0/=WOT`p_.d`LWPYd4/88'Xl3Xua:.C;i,I#a+p)5AC7c[$3J<aMjp#V@9teR!VGPj`\X:ddo%]CBZa\q-1R[781j=,W:4(NN5bP;NW"-_iXf^VIshceiY\@oKC_'5MZ:Eto-3%KkULEHQVJ<S(C@BS?,eT\Rg".buQM&*TWC[6"-$+;/,@`S:4WW.pPHR:l2UWp)#%(R&I-6.ebNcV<4_)KX5IjFo,hDgUmFMGu*af?3)k7!XaS5's>l,;OD'HO(RlQe24Hc;s.mJW/V@Lg?*n'TnV`G%tHn%O@Cqa`#8:P\t2P28[4pgh[Bol=!+\ee@@Wa-bt#HJrO@]><_t+O'NoahESs%q@(n@$EZ*4/A03KFnq7=hRo.I]'XFf@.O]c85g;@^l6k720Rjapt/L1isW*Ail%dMoqD$9?,ZK:ftj[Tch4jN"1#B1T*jFQd'6??;J/1>0kZXngU^;oB++&#5\"[4Onc?'KWJeUnA&`<qC$00WB,Z37$bT2C+lMF,]LVV&s?\UM=aoN:R!V16s;TIc>pI<W?JT0/2Fm3+MP7AP+aW];$AGH#)B2MIVYVjW$q9pW4EIP:4ga5C$SkO6/BckFqQep,$EQ0tG(]>;d8bcB1"c[kK9$c[)+jINs$OU%]Au-gWJ~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1607
>>
stream
Gb!kt9m<'d']&Xf]VXB4":5,"@M[6ZS'8uJgOs[6%07?i_*C<)dojn5T-qFHZ&S/@a_#!04rA;.]6_oUHQceVMc>1Y$,D6"kFW%l$Jl="1Dk@\E:%dsFq!Y4Qe>bKgpHnlN4u-<+HJPifT+q_BqGAa?%]`t(afONI1?s`F'[cl/n"IZ6bmX]e)^uH:2G\IDk=EHLJjp[L@$u!p#hh>\.5]\3)+K3B)P1Q*'kK?F&\&8-F$hq]Er][r;,FhDUM&i_'i#fGZ;3Z7LG_8F^]7D)\duWPR-5?]S\\n-6c;/^`.q#;0+>J1Wq\DA\+r;GG5((i22aX<9,\imPd@nM%B:md!)#0)3R@V86tKtgbp#sA+uKHKjfS.M9eEHZrIc,X/pFsd\JGPWLIKlVMPlSdt\\7jT+FAj\WZ*J6C^2l)Jl7<pP@_=2jNO%dnm&Z['bPAf^IVES[`_>/[WOiFIcQlLu'5Z3l%,Z8hddBa-di*f(ZY+Nr6<%.sJ5j8C">"R`Gu=+=InNIg+h]q<XTf0Mal;EpC;"N?:TKgKXT.iA'h*cH6.ZhXD(>AtT.%.b6k=H6aq\gUK?QM)X\OmlO2QTOh:oDS;5\.6s]n-be"U0$G%;t0)8dT9?19l)[o(8%6*EZpTTPUaaB?B7_.Nm3R3hbj2H_S))!T]JAoU/]I*nPghYpjNj4bV*o^dPM2_A,Hck^TW>H2cI=a:M-K58W:\U/s:AcAXjcRU"h[tRY\4+6tS@?e;WWD>Rdt=?-4C6^IWKG53#M1V:mOPSqX&E[[E%Y7O(oDINWg88*;-ikoNHfj-4!nE-X8/N(LNNUn3*CSu-7X7!93"efGIWE=s@H#^bb]1/jZ!ep!#C?*82\M/3]ZoJh\g=m50D%P']L%\re:^JXjWSHoDY09uEbj^,IG7Sfc1hF$ao)4_iX`MSTsXm*MMPVIWuH;pPqLNn1o;7hZ_G>I[&oQ(LLF<!H>Rl)/]2&jHcVD1PIG9Ia'J'OY&K=hZ(mi2ApdT0V];,doUh5G@S>9-%Pr=E.)Sccp.<?H:M9-nU]::;uDAs8n@phs`<<fY5nPHV`RPP-Mc6/=+E=VL!?9%B2Q+Mnam\?=ScBqD$,!#JV^(12]2D'"JnB&0t&eE#5&00gT'/s9$r,"4L,n='6'e4/*Kd;=P=,ukU@D`O;(W=,;spfg4\4ir:B"M4GFTki#`b-Tom:"TF<A>5`g8pg>ap(sE6^iQ*A9ZVVJMH:d:'4Cu6[LqN5eG&FN0WkO83oCW]!C</fQVoX%@9a:tXcs:U<m!J@?E/B;DLngC&p%cS[\)IIGMFnYkL;hjTe9tYp!3u*c3*fJ3ee*m\WKQ$RnR2/n?C_8WP].&3OX+:bla=h+)lHpPf@-8XjGt@3A[=GqNZjO/g:>BEXZ\4a$UG/imj*"O_d72((h."-)1G!qOS[^4fmdWTNdh-j6'B?Zh>aCs(]kl[1KoV1$<eMMRN1A)mAjPS2iM4Zb`-YYHU/?E#[D?eDM*3AXp/GkF#nL4\RI$mk*,@)"U*p`Bbs'F"V!ZXCm=@EW]pA],2K^H#(Z.?EmO<[JEn(^k7($+7dO.*]D\_R31g(\0I:J%'LHG"eC,D8pg`<~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 830
>>
stream
Gar>DflEQI&:E)2oSZA&'<cVH@j,d9iRh/Qi<k\t<IVbt,[k]6J>I;#5-ocm/.2eAlqC2n:ToB29=<(k#<kSd\5H-Ad2^:D/trZd7\clDd(VE@#N5g6#rP-7fH7-4W.=%=5LcAn+H6Yc$TCm+#f6S1l\n[F'k)PL;@pj7-?(+0jnXK-VVVp!f%esoT'qHTof^(*dL1cdZX]H[c!['8qG$?S*EPen)%TT)diUfP2n1IV<%#kpbj_O0-s8I<f@o0Rk5;Nfe%24j^<hV?Eh-.V]L2=Ikfi`]^ipep\;iDLnL%:'.9o*8op2UXL\iT.UdY^l!9f_;h')U*B53r5GY/-%N)p>_Xn[q1-TmA`*f+g``"*`j6:leT9@m3@Y0,]A^RKgC`W6J03ArjeNoLYDLL^[TFDTJ=H9=64J5J'=)V?1kHMc!6=SlInlXUBjnCut3g#mHQg%%kA`8);gd0UPo2g]#sKLU<+aNb+!j7U]]*KM5aEuT7&Of`_r,?dmdf$es6kaf0)[9m-$%u)T[(3Z:L4TZF$g('AKK^*r7r2pepSJ#M'ZE.(QRq>.B`7aE9c)><rDFq>iK^Nc]Z"=j*S_bl,&1o>l0oHuE-JoDJ=Kr#k+8r$PI@at\5p,TC>rLsF7Gs2Cet>R5JaJI8jeZWp>4`l"Ybe]t(KStiRaiFMQL[0s<"W@6W+$Yu#k\QIKcKEYbh#"3nMPC'6i)U5`@8bCgU;U&fjf+,nr9U+NcSNB;F7q^e#4qf+R<q<1UbMd2\F(\956ae0AQVQH"0qTV(B]XdUn9]'BCJ%>HqKV0qPh!XY0;Ae\SR>laFJ\q@=ad6HK~>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
0000004440 00000 n
0000004975 00000 n
0000007002 00000 n
0000007864 00000 n
0000009563 00000 n
trailer
<<
/ID
[<eafbb3f2da8b68eeafb9581da56c62a7><eafbb3f2da8b68eeafb9581da56c62a7>]
% ReportLab generated PDF document -- digest (opensource)
/Info 12 0 R
/Root 11 0 R
/Size 20
>>
startxref
10484
%%EOF

View File

@@ -0,0 +1,195 @@
# Les 11 — Lesopdracht
## Bouw je eigen AI + Supabase app
**Vak:** AI-Assisted Development
**Opleiding:** NOVI Hogeschool Utrecht
**Wanneer:** Thuis, vóór volgende les
**Inleveren:** GitHub URL + screenshot van werkende chat
---
## Doel
Bouw een complete versie van wat Tim live demonstreerde — **met je eigen thema**. Tim bouwde Polderfest 2027. Jij bouwt iets anders.
Je oefent end-to-end:
- Next.js project from scratch
- Nieuwe Supabase aanmaken + koppelen
- Eigen seed-script schrijven (mag AI bij helpen)
- Chat-route + chat-UI met `streamText` + `useChat`
- 3 vragen kunnen stellen die alleen via jouw data beantwoord kunnen worden
> **Belangrijk:** dit is **niet** dezelfde Polderfest-demo namaken. Je kiest een eigen thema. Anders leer je vooral kopiëren.
---
## Vereisten
### Jouw thema moet:
- [ ] **Volledig fictief zijn** — geen Spotify, geen restaurants in Amsterdam, geen Wikipedia-data
- [ ] Minstens 5 velden hebben (waarvan 1 categorisch, 1 numeriek, 1 tekstrijk)
- [ ] Minstens **100 records** bevatten
- [ ] Vragen oproepen die je écht niet aan ChatGPT kunt stellen zonder jouw data
### Verboden thema's (te bekend voor LLMs):
- Restaurants in een echte stad
- Films / muziek / boeken die echt bestaan
- Sport-statistieken uit de echte wereld
- Klimaat / financiële data uit publieke bronnen
### Geschikte thema-richtingen (kies of bedenk eigen):
- Fictief restaurant-aggregator in een verzonnen stad (bv. "Polderstad")
- Galactische bestuurders archief (sci-fi)
- Verzonnen scriptie-archief van NOVI (1000 nep-titels)
- Fictieve museumcollectie met verzonnen kunstenaars
- Fictief NPO-programma overzicht voor 2030
- Verzonnen mysteries / cryptid-sightings database NL
- D&D campagne-NPCs voor een fictieve wereld
- Fictieve nederlands ondernemers ecosysteem
---
## Stappenplan
### Stap 1 — Thema kiezen + schema ontwerpen (30 min)
- Bedenk thema
- Schrijf op papier (of in Notion): welke velden? Welke vragen wil je kunnen stellen?
- Vertaal naar Supabase SQL schema (zie `schema.sql` van Polderfest als voorbeeld)
### Stap 2 — Next.js project + Supabase (15 min)
```bash
npx create-next-app@latest mijn-thema \
--typescript --tailwind --app --eslint --no-src-dir --turbopack
cd mijn-thema
npm i @supabase/supabase-js ai @ai-sdk/openai zod
npm i tsx dotenv --save-dev
```
- Maak nieuwe Supabase project aan
- Run je schema in SQL Editor
- Vul `.env.local` met URL + keys + OpenAI key
### Stap 3 — Seed script (45 min)
Open `seed-polderfest.ts` (zie bijlage) als referentie. Je hebt twee opties:
**Optie A — Met de hand:**
- Kopieer de structuur
- Vervang Polderfest-bouwstenen (genres, stages, cities…) door jouw thema-bouwstenen
- Pas de `generateBand` functie aan naar `generateItem` voor jouw thema
**Optie B — Met AI hulp (aanbevolen):**
- Open OpenCode / Cursor met `seed-polderfest.ts` als context
- Vraag: "Pas dit seed-script aan voor [mijn thema]. Schema is [paste schema]. Genereer 100+ records."
- Review wat AI maakt — vragen om aanpassingen waar nodig
Run je seed:
```bash
npx tsx scripts/seed-mijn-thema.ts
```
Verifieer in Supabase Table Editor: 100+ records.
### Stap 4 — Chat route (20 min)
`app/api/chat/route.ts` — gebruik Polderfest-versie als template:
```typescript
import { streamText } 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 { messages } = await req.json();
const { data: items } = await supabase.from("items").select("*");
const context = items!.map((i) =>
`- ${i.name} (${i.category}, ${i.rating})`
).join("\n");
const system = `Je bent een assistent voor [thema-naam].
Hier is alle data:
${context}
Beantwoord vragen op basis van bovenstaande data. Verzin niets.
Antwoord in het Nederlands.`;
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages,
});
return result.toDataStreamResponse();
}
```
### Stap 5 — Chat UI (15 min)
`app/chat/page.tsx` — kopieer Polderfest UI, pas titel + placeholder aan.
### Stap 6 — Testen + 3 vragen (15 min)
Browse naar `/chat`. Stel deze 3 vragen aan jouw AI:
1. Een **filter-vraag** ("Welke X hebben Y?")
2. Een **aggregatie-vraag** ("Hoeveel X zijn er in totaal?" / "Wie heeft de hoogste Z?")
3. Een **samenvatting-vraag** ("Vat de Z-categorie samen in 3 zinnen")
Screenshots van werkende antwoorden bewaren — die heb je nodig.
---
## Inleveren (vóór volgende les)
1. **GitHub repo** met je code
2. **3 screenshots** van werkende chat-antwoorden in je README
3. **Eén alinea** onder de screenshots: waarom kan een gewone LLM deze vragen niet beantwoorden zonder jouw data?
---
## Veelvoorkomende problemen
| Probleem | Oplossing |
|----------|-----------|
| `OPENAI_API_KEY is not defined` | Dev server herstarten na `.env.local` aanpassen |
| Supabase insert: `permission denied` | Gebruik `SUPABASE_SERVICE_ROLE_KEY` in seed-script (geen anon) |
| AI verzint dingen | System prompt verstevigen: "Verzin niets. Gebruik alleen onze data." |
| AI antwoordt in Engels | Voeg toe aan prompt: "Antwoord in het Nederlands." |
| Chat hangt / streamt niet | API endpoint moet `result.toDataStreamResponse()` returnen |
| `tsx command not found` | `npm i tsx --save-dev`, run met `npx tsx ...` |
---
## Tijd-indicatie
| Stap | Tijd |
|------|------|
| Thema + schema bedenken | 30 min |
| Project + Supabase setup | 15 min |
| Seed script (met AI hulp) | 45 min |
| Chat route + UI | 35 min |
| Testen + screenshots | 15 min |
| **Totaal** | **~2,5 uur** |
Loop je vast? Vraag in Brightspace of plan korte 1-op-1 met Tim.
---
## Tips
- **Begin klein.** 100 records is genoeg. 500 als je extra wil.
- **AI laten helpen bij seed.** Schaal je productiviteit 10×.
- **System prompt is je hefboom.** Goede prompt = goede antwoorden. Slechte prompt = AI verzint.
- **Test met simpele vraag eerst** ("Hoeveel records zijn er?"). Dan opbouwen.
- **Token cost in de gaten houden** — onze hele les kostte <2 cent. Jouw test ook.
Succes!

View 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 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 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
5 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
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
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:20260519160527+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260519160527+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Les 11 Lesopdracht) /Trapped /False
>>
endobj
12 0 obj
<<
/Count 5 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages
>>
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2031
>>
stream
Gb!;d97,A^']&@2mNu;31mU"oGBZfG\4N5MEAhV;0u+VhLa;>jrq/E1#YBQ_`-'ef@"b>Dq`OW..i?ra1%Q9\6neCYnsU0Ne/o?F/XQ=soW$gGhQs`<"4nd@$#M'LA4;OXULMZpaR^uJiXqs4%]h@'aUOXQL/CJ]qgs>%<(IollS5[Hh[Vu>XH7^Vk\oj4/>%!^FtM&7UWrbFIA&PPZM`E;R(7T3(fmlpWCEoJ=uPsRO8::G^&N'ALAo7AZAb],T$Z)hOR]]P!TsKS^,\A/93##56ndYe9_*HYZg'"40<+tFlfuQ8\SL`V%G'X5bE<\e*:ip(&<5n7@\PVDX<[$Eijocbo@d=(@9*qb%e?k`kWL$bd[hD&b=LBPi$>V<mHZj)%/?O6gN0"H??<uNA(dHoo\T;1D`IiVW0,)%Zs;-0CTbUhcL%E/.r2<?XYdXi(ADX4Ol3QP?Q\o?i350-+k7>96gha6%_+J&3$G$dE5$Au,+TkCqT]2d%W*k8IHOJ@W_q>PdCqOeNUP+,]9R!;b5S[7>*rrJN.n?U$paGeKR-ddR=6`giT)k<ppsi5/7)CO#K<W^(-(*C!%-EC&9UidOP>?WH6F-*@"p/$]qQ`%i_19laY35(2-ih;C*GGJ*;rK:(3;2EV'Idf03'WC<`>ED46m&R!mNa(QgTI&qE:0($bl@OF*eW?fOu6$Gsc\SrAGoP\%oE5H_"Jn1[U9aD_8o3jjAdLrO-<[f;]+\dZHLg2HuMr2a=FFZcNOBjhuq",1T4qPIk*\<5XG@4*Z"2/)7k*/)Na\8Qjj9aJ&/`*9g68WdIQC"35WXImI.]!r.d$-*l=hcb8+ZP9ibI+gV7j\sGWoWqp3[Up$E:;4;'$8=>al]V91Eb`#Wn<,AJsdtS<+I<t+;49V'he7ZAnFLGAW4Wf&8_o6CYHS2/UqGGL^PgGcO"hJ-l#i%MOX9HL[U2?b8^'r$)>UDPmghQJONlEr]>$>*b[LI@,,rHnkaqX"fb-$n\r0@ZpOjUS8W39)ke<Qhl9_5>RTu[:\0MH2>CI*!*b,T%)Q9\[)8!]8rK[Puf\T[U":I_W,bHaGPTPr4^F_TE+Uh!Jg5iR^Mg)5cker#&S.%UhnVB2bU0TkQnECFlAk'H%9%3.O]D]6-"\\@6[lOB@GIX;F$SXpY]:!K)D]5B.;j/mENO0KLVUmC6/L0JT$[A6pr4`9$IK&rnIm#5XNhk!]rHNNt\q#\W,!Vb@TOO[([=`nG^1O'o"AD*C^mQZ41j0o%=kQ6Xn%G5,_<,Wfe^18Fn8YIbL%2@Sl&F-'70_-8V#MO'+1'Z*/E#`Wr;C59B.^8,:M_^2c%7J!WZHrlib4@qF1s>&sJ>e"&[aDFF1`W,N=:&>b/9T"Wm]L%*mAet[dp5(Q_Q&kgm9<lH-8^tLZJ*RS:$HWClY&L"-t-bU3%Rh62jOIPJt5A?G$L&t.p^m24-87[\;NI@j@E!YXPujo1#8eOCm19QN&K'_pgp$uCAc]Tj]MWJM+&RC0G3O01dsH`O[s\b)g8*AT-'8D:-e8IVI<VZA2),bi9+&LLH#^&n;JVB3QVY_l0(-<A;aD&9$[)'9$Ycc:sO]kHNMPh1I")(.p]bQEGOTM!jL@rQu-Vnc&5H0Kor(A'%O^2%bnPW?h!u"RD1/'-I.!FjDp86,;aaDAu8^4T]uK:$1$qfT4X"_,(p60PL3+-$'m2$YDbjq%VJ^>eUu6ffQ<LA<il<o$*[lH)+m%1)U>8Po69<SklH1Ph!":th%+,mo/^Aq'%9W8'%mtoa%]dA<K`Yi6L!J6KS2"r>\DrEf_gmje@pjS"o9<[dgpb]##nRBE2:A;TT"L2V.ZM`O*DQEB0Tu@qD2!J^sg7/H?(@<fSjM]D)Jd>?g!>UU-5D_OlI%"4/"_BLN3#qKQF#?+AakIgb@#nTb)^=:%g^M2\ZoBL2m/6VO0l(d+@q9eIG*i@PTCn6r[Q&O:Ij+DbT0Y,+\`s<6mrVi%0C$%D+/,KmD5\"e^DX%$&J4*KS`&"47aVeGC0dEk%>~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 596
>>
stream
GauI1_/J$](rbtA(%4!,9";=ifT(.?b0VU]ek0XP>d@a([_uTo5hc56R=mmD.T.q<<\2r"Ic#L$@A&(Rp77MbQPao[#0%6._#t@+Vt_/@ILc^N>_s";cnpP<8/1D3eChV]PXIu'@aJ'dXTX;h7-dBrq`:R@6>`s>*AA$NWR:i9ZVYC^<Z_mp\Sj&j-tW%#rEc,Qabtu1X7J*Yl!aQ![Pr?%#(fYeWXs0,iU$I-,JI0GETsL'$G84'8QQA`S1EAZNp`I]?"4WeS#n->F_PZ5KD6hRgKh!(?Z8;U,h+8mC)e(C?`Ndn:(HKc,g1$+hIA_GHT`#mPpSPf_WTm=Bm\Y2C6P?.1Am6W>J9``Y2hoI4n04q-hhMqhjA93_`:Z\6t0M6^duP6B"_O7ju[O*$@ar+ho],4iR0bo2n9ePFh5`?&oB2k+et:=Re<W$)cO9Bc+@.9!e!5La'KiC4NUN?EbeKhSCs-@<>9&SK@2U/*cC#i`qj&f-;!SWpuV-oIZM0+jS,AIj&96B>b8'dgh%%SUe-"\c<rQDAF59"\lS",NcMMVZ]="S"V3hW59inFFcol`RQWg(a7\3t%/p9p>reP~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1846
>>
stream
Gb"/'>>s9G'RnZ;3,^,-GgTj)>32S(C""i6#mjB';#7Ce$PqqA8N%j/^1VSDJ7I$e:1g^nem?\qgN1YtJr=!ir.c`:M`L.<U-KU?:Oris@"&*jbaje)@?UH'"2^>H!sU%/p&jEEPCY*<$s5b\\NWm)!21f<.T!p"TC5]#8ctte*=B`OY-.s>76RX2(]-4TN$2t&4=PoPq`FV<'e^]8P7GQJIn0uLcq^Qk.Mp%"T'sKXKq6h654$1*q3e5!7@kmr^+N-iW8X*u+85D@HW:e^[>EgSZSG*YUaE.7_#,27:G&aVIDH%pJafkl5;3gK$M_]#W71q66[]__)Yf!:>I80A9X7Dqhc5>Nf11=l27g]Q3/S(m,btqA8D>-(&r6;$FbQ9C1"EB=K_gj+8%a:FkR:tRTF,g5-:7g:$To8>F5!c7"R$(W'X2L'%`MR3::^NNGMj)pmO1MUSO(>7o:X)ko?^W"%Bf',FoUk[Mk:3hP,Jj7'=Ui*0LZjL0;5))V]*%lM-gO*c$]+CTe@A]!F]?`(Q<Sq5jVgP5@9dW_iK>HZ]3ucmQqh)a%u-=_Z4$-M#e,/*OhI)"gQ3,K!=4(pA5SDeS\LlU5@B5DC]@p>YDC`q`DjJpTKE5'b]?e;0:ZJ(UUq=Lst\.?^SoF(Jn>"grT\OLq>fE7UGi;j="8tnbFWp"VeP(:&,2M9laYB@gMAp'E$PW8puH-Fh2HfeSin.K.1T<'?IO5iFlBX'3uJ8H@+")q[:,VDt"hmS\tWY*;miB\&CVQ*]VLeQMJ&tCCG=f30&3][l2kJORO)Wf=/;O\k!eKL>gfPUO`PK(kf<@;b-V3>AO^XM'J7#2i^6m73[R=>^nf/qDFs&N;8%,6X6f<eZZk<kKJRM\Dl>=6%WMJ8W=aK$=BdSZo\nA7pZrJC=L'?AaT2Pg5O9\J\`LCr8ujjMgtqh1->k,nHA$^EEcIK6!,%2Ros@+lBs8^k6^j^)Ei;9c0@3(*T(01od#5],kkLc!6f'JVZQ1c1+m=rd;a1EB+[%a3rYTuM).5Wjg1rqB2MpfA]sWi06DHDSe^G,kDZg]_It.TqL&b]DemKjlP*fQ??Jfpm2ND'$8bNAGZI6L]6Rg?m!d6TosFVZ8@kt,+NeJ`X\c>rYKSOQX08@IU&=m(gahE7#C5l_CmLE"$k:/e/\8ft>\9DG^?5Vjd+K\d6d1[lWT!ff"4ZbUI&c]+#(a.jDgRtO8V)Xh:nKu7afX\srkNgMaP\)FMiGb=@`&<td*=ckmZ#HdV(l)Gf!23Gh22,nQ0V.IXYmr9R1JJ?:,p"n9OY-<$3,RS+u6CL=nHhc\G@!Ks.3^LDG$kmEZ*HOAI0lX6%tVs'fjk,jXK=N;HF"*Wl:qn36@MRGl,Mm"KOX<i>ha^rIeu0X8K.Qp0tBGL5j/-.1e"5B.%;V-fua^DANWnjTYpbl;e;HJFt[YbjNYc=eRup-:DX7>+[s1*su:>'\BHU4"R.KXm,h"bkCcA"HM3_Sf2Z^<d+L)Z\m!R>\^<?YJO&7ogQQFR@+Bf`G>#D?R9_NXUdFTTpV3oWa(R<+dYnpaQ6M4@bVps\<b^3lbDc9b#MB@Zqd]G)ip"*'qUPk74*u:6jiDQ^"%;TP^)+Col>V=[*4lE]uI>Mn#Su0TP]!+gcK3LC/2o_Hi]!iKV#;>=^a/:8HlLn$[XNME/;?.egDf4AQl"?K)C:a41-AGg;]fq@L6%nfJE"&g22GmgBM?Kn)*0E"-oV$_)GF5,@HXXLX3Ig#so:K)k'2O^F:k9:S]K_X8_K1;a)ljH1qX_m$e`;ClZjb)";ME\'l+HOnih?B(hK`ek9G0[ALmugV8"Y!9YH0r\i^~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 649
>>
stream
Gatm7a_oie&A@rkhCWUFdiq4P<%if\ejt=aD%JR1cjO^>Zr1%#.Dc8boc;4K][T4Y6mAE/3H*ckiJ?^Zm<:YthA,5b3Oh=/"d_oW\Aj;uo=Mn..PdEB`@?ad!LCdL#MsW=,UlB-O`RHL@k*\(MDM)L"[R96`\Vjll$8&:ASIAX$)Vs1Wq<5RAuQ?k>-]DJo>CD^5II;RZZOHPBW`,%d0[N&[7-'$Mg1C2.-B`Br,d.:94psTSiKm29CQOU$hu/=L;?l.cno;IbQRCZ_Gor3DX-]q`BB+MAZm,%.3oS;bkCS19`r[,5PHl%H40[SgToCl4'4H&lOhf,O-iN`8U8[+e8DI3[E1gR11H`rVHuR.X-+ing*%P(0'W(b@Y<?Gh;rI6i%@G9X^uB2*Q,!E*^Q2r05o5YQ;oWQ\+!SU[`P;;mTZ302:X@4DjZs?aOL!6EH[KOh5aTh/"Rnb]M.\->&UJPV)^""da8L['hJlfBO+o6ThdS2L+G%-!4nbfDBaNll,9V>&O40hQhbU$Bn-s[KX/$6S[d,L','"<lX6gcA3mQ'6Wm@B^L^]?qUgFi"/`'MN)1jE7VIS&J$%Z+<nr_K0m,9nTu"hJd@P$Y"$C:M)<'GFG*C?`iLD9M'r=HV%NRt#+"<79,l~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1821
>>
stream
Gb"/'gN)%,&:N/3lq>>n=u-9ehD3R%.#F*8FV;_mh!'c04HKhAPY$HM+h>ib^V($EU5O/6PI+,nA5SSqkNi9&K3](nnNkCb,R&EIKVu![&.6;o+O":K4GVdto;,qoO8@'(b6_u4UgSn:re"\6ah[R#0;KbB_"OnGB>=RX"[Nt*76BBS4qn'9Sob6?^1[<D""PLs(O5]@*Qt2lWL;jo?FXiZq\Nr?6HcFC6T;%nK&Hu5#M#P0bd*)k].cq;oY\_M5.m.Z;,1_$-Lb.Q@PUk9U==s9s/]FjEN)>/YJa<a1/C?9*lDq,_^Cmg%A-L0d?+0npue@S63&YoNsa`1?gqd"EH'O652j.4?llG@or2R-Y0CoK%Sutj-T64<qrqmd3AWVD/DOr(9/)/gA/h?1[-\iYDQlTGS$+=TZQX.k+_\J.)Z'N%o?TO1XeunGjXIJ.]#a5aFrR=UE#$G$e=uWBR)8*W=M8"!E<G=NE3/%ZKiBIf$^f"+m,c8^r'NfH%g\;H>DNcRP<t),S4Q`;$Za?F\.ci`a<D%8`YHF[Nr$'5IJ(<u+RU<a[e#$R^Fea-aDjcU4Gbl1\"eBVGFTI)Kj0KWVZs$X:1aRQceh)8^'[sXI#9"Y0lDQIi0h0?ZAA"6Y7g#tJ@Sq?6,XLfIaA$i>B.$\Mph:9Eq`1^A;Mqpb1%tI?H@+1dYHm=@h4fc9\Xj/9!6L%]MWGBBZ;a7CQ'!C$$TeSg/q5L.!"O""0bR^ij,+@*PnR6UMOq0(=I%O>['f2LeGu9E+'MTUq7-I&dFZj\m[\uA"H4RG-^Adph^<I&$XKdK":)nkk;3l(]EZ,HbVpQ67*H8p)?\eka3kX#+g-;M?6Q_R#.P:]MKPODNi_UcFgt;k@7g*'Ge&UcQF#m)Il/j6[DG2U[/H:kDSbdm\"bekZqNf?hUWU5G%UC0N0\Y(@gd@?NO[J\pZj$^W%,qNYB!BI*5ca%K=:%5Li>kk32ieQ,,0>:nI$KFT$s"OUi\=4etfp,J:GOPn[.n;BTP[`m>8Q'Vu<AALQM?[,1o.@L_m+C\j!&dliCW93RB07(GjQV;s.k[fP&E4M;G?/ogDNVk_;Bn("/Rp.oh=;cNRJfj?[YOW@Z`as,#-LdBddSX"ROc6BMu1Xo%j%fb]4MZa'':pML8MN0,(!u*K"I+,?*\8*rN?0/-$9jk24S!apl%eEpWMRs,GZlSYLi]W7`d92u.nV!rfIqd:[2T;EZ6/;Jjp:mEU"3'jpA\MFllHN/=\DJ[`_jO%Ur.J(>Gr*H6ch9ucMSHTCCe3T]RZ'b`>YGj;[-Cn*?69W^2CIo9P"%;4p.8A4`]5q;Vn/6b/Y"DUI.[1>9b"lJZIBe,jK4LO,qt_4r?KCkO`M)E(;cb1e^)H+CmNEEiG6WSdo3HU<1G=+n>`13d@X]H/;jI$%4T+_S^De!fI@N-:-7B,kL!C>pBldf<J*ktgM2ni\aJ8#$@.''ijFo6q:M+I:&t"-cN)7ZDBS60-4%"VEu,GtO0(Z6[R/%/'=83kC_\/.[2Tc`,>I[ein&?:TR+#*T+dU.DbhMA4Ml^H?7\(>Ther0Jn:gkeX1;]A&:]F*o%nPS[^cX)`QPeE/84I81!r`=5E<Rj*]D4Z0c^*Dk"06B1.+h?C4a)9a&2!+!HGWl]ABo.!9fkjUp&c##h!m7[V%!f4crQ8WK-W4@eMK6oPB"=h(GrY*>["7)d>OM-(qjaJ&SKdRI*1q69+RM4:cg[`j7rV5QEorV\DBmA^oUK%&B[AXqm,1$opP@Uk0>.SA%/>:Glj0K$[05J]*%_Tks>A*i6!1egt^5j*'n9RVT~>endstream
endobj
xref
0 18
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
0000001531 00000 n
0000001827 00000 n
0000001911 00000 n
0000004034 00000 n
0000004721 00000 n
0000006659 00000 n
0000007399 00000 n
trailer
<<
/ID
[<e7826657c82f631c6aae1c8477e154ba><e7826657c82f631c6aae1c8477e154ba>]
% ReportLab generated PDF document -- digest (opensource)
/Info 11 0 R
/Root 10 0 R
/Size 18
>>
startxref
9312
%%EOF

View File

@@ -0,0 +1,527 @@
# Les 11 — Vercel AI SDK
## Lesstof
**Vak:** AI-Assisted Development
**Opleiding:** NOVI Hogeschool Utrecht
**Onderwerp:** Vercel AI SDK — AI features bouwen, gekoppeld aan eigen dataset
**Demo:** Polderfest 2027 — fictief festival, 500 records
---
## Inhoudsopgave
1. [Wat is de Vercel AI SDK?](#1-wat-is-de-vercel-ai-sdk)
2. [Het modellen-landschap](#2-het-modellen-landschap)
3. [De vier kern-functies](#3-de-vier-kern-functies)
4. [Project setup van A tot Z](#4-project-setup-van-a-tot-z)
5. [Seed script: dummy data in Supabase](#5-seed-script-dummy-data-in-supabase)
6. [Chat-route + chat-UI implementeren](#6-chat-route--chat-ui-implementeren)
7. [Waarom data + AI samen krachtig zijn](#7-waarom-data--ai-samen-krachtig-zijn)
8. [Best practices & valkuilen](#8-best-practices--valkuilen)
9. [Wat komt hierna? Tool Calling teaser](#9-wat-komt-hierna)
10. [Bronnen](#10-bronnen)
---
## 1. Wat is de Vercel AI SDK?
De Vercel AI SDK is een open-source TypeScript library waarmee je AI-features in je webapplicatie bouwt. Gemaakt door Vercel — de makers van Next.js — en daarom perfect geïntegreerd met React, Server Components en Server Actions.
### Wat krijg je?
- **Unified API** — Zelfde code voor elk model (40+ providers)
- **Streaming out-of-the-box** — Geen WebSocket-setup nodig
- **React hooks** — `useChat`, `useCompletion` voor instant chat UI
- **Tool Calling** — AI kan functies aanroepen die jij definieert (volgende les)
- **Structured output** — Type-safe data via `generateObject` + Zod
- **Multi-step agents** — Via `maxSteps`
### Eerste indruk
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: "Vat de Polderfest 2027 line-up samen",
});
```
Wil je naar Anthropic? Eén regel veranderen:
```typescript
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-sonnet-4"),
prompt: "Vat de Polderfest 2027 line-up samen",
});
```
Dat is de waarde.
---
## 2. Het modellen-landschap
| Provider | Model | Sterke punten | Prijs (in/out per 1M tokens) |
|----------|-------|---------------|------------------------------|
| OpenAI | `gpt-4o-mini` | Snel, goedkoop, default | $0.15 / $0.60 |
| OpenAI | `gpt-4o` | Multimodal (vision), krachtig | $2.50 / $10 |
| OpenAI | `gpt-4.1` | Reasoning, voor agents | $2 / $8 |
| Anthropic | `claude-sonnet-4` | Coding, lange context (200k) | $3 / $15 |
| Google | `gemini-2.5-flash` | Multimodal, ultra goedkoop | $0.075 / $0.30 |
| Groq | `llama-3.3-70b` | Ultra-fast inference | $0.59 / $0.79 |
**Vuistregel:** start met `gpt-4o-mini`. Werkt het niet goed? Upgrade naar `gpt-4o`. Dan pas exotisch.
**Wat kost onze Polderfest demo?**
- Context = 500 bands → ~30.000 tokens per chat-request
- 1 vraag = ~$0.005 (een halve cent)
- 50 vragen = $0.25 (kwart euro)
Met gpt-4o (15× duurder) zou dezelfde demo ~$4 kosten. Daarom: start mini.
---
## 3. De vier kern-functies
### `generateText` — Antwoord ophalen
Wacht tot het AI-antwoord compleet is, returnt dan een string.
```typescript
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: "Geef 3 koffie-poll opties",
});
```
**Wanneer:** Korte antwoorden, server-only, niet-interactief.
### `streamText` — Streaming antwoord
Streamt karakter voor karakter. Goed voor chat UI.
```typescript
const result = streamText({
model: openai("gpt-4o-mini"),
messages,
});
return result.toDataStreamResponse();
```
**Wanneer:** Chat UI, lange antwoorden, "ChatGPT-gevoel". Vandaag gebruiken we dit.
### `useChat` — React hook
Aan client-kant: complete chat UI in 10 regels.
```tsx
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<form onSubmit={handleSubmit}>
{messages.map(m => <div key={m.id}>{m.role}: {m.content}</div>)}
<input value={input} onChange={handleInputChange} />
</form>
);
}
```
**Belangrijk:** werkt alleen met `streamText` als API endpoint. Vandaag gebruiken we dit ook.
### `generateObject` — Gestructureerde data
In plaats van een string krijg je type-safe data terug — gevalideerd met Zod.
```typescript
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: z.object({
question: z.string(),
options: z.array(z.string()).length(4),
}),
prompt: "Maak een poll over koffie",
});
```
**Wanneer:** Database inserts, formulieren vullen, classificatie. **Niet** vandaag — komt terug in latere lessen.
---
## 4. Project setup van A tot Z
Dit is wat Tim in de les live deed. Voor je eigen project (lesopdracht/huiswerk): zelfde stappen.
### Stap 1 — Next.js scaffolden
```bash
npx create-next-app@latest mijn-thema \
--typescript --tailwind --app --eslint --no-src-dir --turbopack
cd mijn-thema
```
### Stap 2 — Supabase project aanmaken
- Ga naar https://supabase.com/dashboard
- **New Project** → kies naam
- Wacht ~2 min op deploy
- Settings → API → kopieer:
- Project URL
- anon public key
- service_role secret key
### Stap 3 — Schema definiëren
Pas `schema.sql` aan voor jouw thema. Bv:
```sql
create table items (
id bigserial primary key,
name text not null,
category text,
rating int,
description text,
created_at timestamp default now()
);
```
Run in Supabase → SQL Editor.
### Stap 4 — Env variables
`.env.local`:
```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
OPENAI_API_KEY=sk-proj-...
```
Belangrijke regels:
- `NEXT_PUBLIC_*` = client-leesbaar
- `SUPABASE_SERVICE_ROLE_KEY` = server-only, voor seed script (geen `NEXT_PUBLIC_`)
- `OPENAI_API_KEY` = server-only (geen `NEXT_PUBLIC_`)
### Stap 5 — Packages installeren
```bash
npm install @supabase/supabase-js ai @ai-sdk/openai zod
npm install --save-dev tsx dotenv
```
### Stap 6 — Supabase client
`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!,
);
```
---
## 5. Seed script: dummy data in Supabase
Een seed-script is een TypeScript bestand dat **éénmalig** je tabel vult met dummy data. Geen handmatige inserts — procedureel gegenereerd.
### Het Polderfest script (voorbeeld)
Het volledige `seed-polderfest.ts` zit als bijlage bij deze les. Kernidee:
```typescript
import { createClient } from "@supabase/supabase-js";
import "dotenv/config";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const adjectives = ["Lost", "Velvet", "Iron", "Neon", "Silent", ...];
const nouns = ["Tigers", "Wolves", "Mirrors", "Clouds", ...];
const genres = ["Indie Rock", "Electronic", ...];
function generateBand(i: number) {
return {
name: `${pick(adjectives)} ${pick(nouns)}`,
genre: pick(genres),
// ...
};
}
async function seed() {
const bands = [];
for (let i = 0; i < 500; i++) bands.push(generateBand(i));
for (let i = 0; i < bands.length; i += 100) {
await supabase.from("bands").insert(bands.slice(i, i + 100));
}
}
seed();
```
### Runnen
```bash
npx tsx scripts/seed-polderfest.ts
```
### Waarom procedureel?
- **Met 500 hard-coded records** = 500 regels handmatige data → mind-numbing
- **Met combinaties** van 30 adjectives × 30 nouns = 900 unieke namen mogelijk
- **Met seed-random** = reproduceerbaar (zelfde data bij re-run)
### Voor jouw eigen thema
Open `seed-polderfest.ts`, kopieer de structuur, en vervang de **bouwstenen**:
- Domein-specifieke arrays (in plaats van bands: restaurants, scripties, kunstwerken…)
- Domein-specifieke velden
- Domein-specifieke bio/beschrijving-fragmenten
**Pro tip:** vraag een AI om dit te doen! "Pas het Polderfest seed-script aan voor [thema]." OpenCode of Cursor doet dit in 30 seconden.
---
## 6. Chat-route + chat-UI implementeren
Dit is wat **alleen nieuw** is — Next.js + Supabase kennen jullie al.
### De chat-route
`app/api/chat/route.ts`:
```typescript
import { streamText } 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 { messages } = await req.json();
// 1. Haal data op
const { data: bands } = await supabase.from("bands").select("*");
// 2. Format als context
const context = bands!
.map((b) =>
`- ${b.name} (${b.genre}, ${b.tier}, ${b.day} ${b.start_time})`,
)
.join("\n");
// 3. System prompt met context
const system = `Je bent een festival-assistent voor Polderfest 2027.
Hier zijn alle bands:
${context}
Beantwoord vragen op basis van bovenstaande data. Verzin niets.
Antwoord in het Nederlands.`;
// 4. Stream
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages,
});
return result.toDataStreamResponse();
}
```
### De chat-pagina
`app/chat/page.tsx`:
```tsx
"use client";
import { useChat } from "ai/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, status } =
useChat();
return (
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
<h1 className="text-2xl font-bold mb-4">Polderfest 2027 vraag de AI</h1>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((m) => (
<div
key={m.id}
className={m.role === "user" ? "bg-blue-50 p-3 rounded-lg ml-12"
: "bg-gray-50 p-3 rounded-lg mr-12"}
>
<div className="font-medium text-sm text-gray-500 mb-1">
{m.role === "user" ? "Jij" : "Festival AI"}
</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Stel een vraag over de line-up..."
className="flex-1 p-3 border rounded-lg"
/>
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg"
>
Stuur
</button>
</form>
</main>
);
}
```
### Voorbeeld-vragen die we live stelden
| Vraag | Wat AI doet |
|-------|-------------|
| "Welke bands spelen zaterdag op de Beach Stage?" | Filtert door context |
| "3 headliners met meeste populariteit" | Sorteert + select top |
| "Hoeveel jazz fusion acts totaal?" | Telt |
| "Vat de electronic-scene samen" | **Samenvatting** — alleen AI kan dit |
| "Wie was hoofdact van Polderfest 2025?" | Eerlijk: "weet ik niet" — perfect |
---
## 7. Waarom data + AI samen krachtig zijn
### Data alleen
- SQL queries: filter, sort, select
- Geen interpretatie, geen taal, geen samenvatting
- Gebruiker moet zelf SQL kunnen
### AI alleen
- Kennis is generiek (training data)
- Verzint vaak (hallucinatie)
- Geen toegang tot live data of private data
### Data + AI
- AI filtert via reasoning op tekst-context
- Antwoorden in natuurlijke taal
- Samenvattingen en interpretatie
- Domein-kennis = jouw data, AI redeneert erover
> "Een LLM zonder jouw data is een gewone chatbot.
> Een LLM mét jouw data is een product."
---
## 8. Best practices & valkuilen
### Doen
- **Begin met `gpt-4o-mini`** — upgrade pas als nodig
- **System prompt is essentieel** — "gebruik alleen onze data, verzin niets"
- **Stream alles** — `streamText` voelt 5× sneller dan `generateText`
- **AI-calls altijd server-side** — keys blijven veilig
- **Loading state** — AI duurt 1-5 sec, zonder feedback voelt het stuk
- **Foutafhandeling** — `try/catch` rond elke AI-call
### Niet doen
- **Geen `NEXT_PUBLIC_OPENAI_API_KEY`** — wordt zichtbaar in client
- **Niet de output blind vertrouwen** — AI hallucineert
- **Niet alle data altijd meesturen** — werkt voor 500 records, niet voor 50.000 (volgende les)
- **Niet `gpt-4o` als default** — 15× duurder dan mini, vaak onnodig
### Veelvoorkomende fouten
| Fout | Oorzaak | Oplossing |
|------|---------|-----------|
| `OPENAI_API_KEY is not defined` | `.env.local` niet geladen | Dev server herstarten |
| `Cannot find module 'ai'` | npm install vergeten | `npm i ai @ai-sdk/openai` |
| Seed: `permission denied` | Anon key i.p.v. service role | Gebruik `SUPABASE_SERVICE_ROLE_KEY` |
| AI antwoordt in Engels | Niet expliciet om NL gevraagd | System prompt aanpassen |
| AI verzint feiten | System prompt te zwak | Voeg toe: "verzin niets, gebruik alleen onze data" |
| Chat laadt niet | `useChat` zonder `streamText` API | Endpoint moet `result.toDataStreamResponse()` returnen |
---
## 9. Wat komt hierna?
### Het schaalprobleem
Vandaag sturen we **alle 500 bands** mee als context bij elke request. Dat is ~30k tokens. Werkt prima voor 500. Werkt **niet** voor:
- 5.000 records → te duur, te traag
- 50.000 records → past niet in context window
- Real-time data → context wordt steeds opnieuw gebouwd
### Volgende les: Tool Calling (Les 12)
In plaats van **alle data te sturen**, geef je de AI **tools** (functies). De AI besluit zelf welke te gebruiken:
```typescript
const { text } = await generateText({
model: openai("gpt-4o-mini"),
messages,
tools: {
searchBands: tool({
description: "Zoek bands op dag, stage, of genre",
parameters: z.object({
day: z.string().optional(),
stage: z.string().optional(),
genre: z.string().optional(),
}),
execute: async ({ day, stage, genre }) => {
// Supabase query
const { data } = await supabase.from("bands").select("*")
.eq("day", day || undefined)
.eq("stage", stage || undefined);
return data;
},
}),
},
maxSteps: 5,
});
```
Workflow:
1. User: "Welke bands op vrijdag?"
2. AI: "Ik roep `searchBands({ day: 'Vrijdag' })` aan"
3. Supabase: 60 bands terug
4. AI: "Op vrijdag spelen 60 bands. De headliners zijn..."
Schaalbaar. Slim. Multi-step (combineer meerdere tools).
### Daarna in deze leerlijn
- **Les 13:** Agents + `maxSteps` (autonome multi-step taken)
- **Les 14:** RAG + embeddings (semantic search op heel grote datasets)
- **Les 15-16:** Testing + Deployment + Performance
- **Les 17-18:** Eindopdracht-werkdagen + Pitch
---
## 10. Bronnen
### Vercel AI SDK
- Hoofdpagina: https://ai-sdk.dev/docs/introduction
- Voorbeelden: https://ai-sdk.dev/examples
- `streamText`: https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text
- `useChat`: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
- Tool Calling (volgende les): https://ai-sdk.dev/docs/foundations/tools
### Supabase
- JS Client: https://supabase.com/docs/reference/javascript
- Row Level Security: https://supabase.com/docs/guides/auth/row-level-security
- Server-side usage: https://supabase.com/docs/guides/auth/server-side
### Inspiratie
- v0.dev — Generative UI in actie
- chat.vercel.ai — Officiële demo van AI SDK
- Vercel templates met AI: https://vercel.com/templates?type=ai
### Tokens & kosten
- OpenAI pricing: https://openai.com/api/pricing
- Tokenizer: https://platform.openai.com/tokenizer
- Usage dashboard: https://platform.openai.com/usage

View File

@@ -0,0 +1,334 @@
%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 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 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 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
<<
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 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 22 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 22 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 22 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 22 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 22 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 22 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 22 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
17 0 obj
<<
/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
18 0 obj
<<
/Contents 35 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
19 0 obj
<<
/Contents 36 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 22 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
20 0 obj
<<
/PageMode /UseNone /Pages 22 0 R /Type /Catalog
>>
endobj
21 0 obj
<<
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260519160527+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260519160527+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Les 11 Lesstof) /Trapped /False
>>
endobj
22 0 obj
<<
/Count 14 /Kids [ 4 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
16 0 R 17 0 R 18 0 R 19 0 R ] /Type /Pages
>>
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1834
>>
stream
Gatm;>BcPr&:WeDbffEG]<s*p&>Cg@8^hQug?iJ32WsGK$NW9-k#T:^[_I8]J@WTQTjHJ;[O%_fjqBQ.$k+Pu=nV1&!,$@tre:I60OFgdL^!DEn9Su"1C+fPZ"tP[Uo)=d\>F^@IVU3:$R/jD16C!/d]pTNN(E2*i4.*QQijmN`D_BiE<LE["[k+M8?oMY,_C(oD%TtXp\t035H3I[M.jOh2sV!+i<n.tC/I]`:hDdIb[WX.nF>fJ]M3<Ei@iY:JhL]<r+-2dS3(\90fptEYUEKOkNYf2BbmbFHiimSbb&WS4abH4`('KUq!V2`MuinlA\+gK5C'_HiW8B(I)[tu!X[^C.SN;Of/E/*N#oJPE*mE`Na884Ka7o9iDf"k^!deGjP<pgDmTVd9L<Tcl+PoC+K!%]B[IZ<lW('lG/cIeQQr7[f$;VPNHW<hK;':sIQ-S^+$GN:BIjV*c%J[1:#sSS<6Qh*,bE$G\8r<,9h4ofF,*R`D:rplDR6?#f$7ssm@`RtcHm&\G/jM?G7A-tRk`#G@M[o\F"pb).Al!F-9Qd12M=g@$nU7G9.tlO_jP"T'-)+V;TJ/pE##oj<UQmno)^Pjh[S&-&dhYGKfKM.&skEA#%E))4g0N@Q*=/Z_HW9ji0@W4->=0mI%cE135om1iq1W_5r4Oe*))e"Ji<FAbgPm1D[*<rDbgV/U_NpelJ-PI7>,spEiVDlFhpo'N:[]@+I-'#I.c&D##=p+E\Xll;,"r@6gVkTBkWNY.1[Wm0oK!JKZWWA<2Nj/7qI_iO&4g-+=ch(YBZSV<,f8R%]=8o@_u`@_r=/uWqE\JE+_HXhPoT4aV^k,(nGGa0?Y]H3f);a1ot_=&c-(;(8.SC4"kma2T8'^I[UM\N[(1b3Rjq,NO=pBPdc#(g+>R@gFh.G(B4:H-bi_L-m8df\?uO8#/fQ?q/9\7Ym(&;r-"j>'LV-@6qVf5Jff]7DF&AE6(o*3<N586YY@-Z.bk`o&5f-&4<;,9)sV0GFYF4CS(<*2ppa]27LcHu:YU<)!q`l5i<n;?WP<0/Y7F:9H4#N!RnA8Q:;j!3Uj.']^,D8^ims)W;b/BV[_C&ZW3.5+'b[9q2$#T@.d?/qWai#ch5Ws2DeF1.;0e8'80q7YBrb:JVi=p-iRPJ[Em5R/Y#L8Nj#\$@5Rf4YKE^R4.U"@@>,scR/N/Fqi<#>($C4:9C9c1%2;DPQTH\Je(3rK%ZK,T$hBWEeA._kE!rr0!la5#._aH.+bJrWRj>C7;6FHWli.Lm8,c^u#<H7'\-M),f6K-hA8'ag.*V?ua;7!@c56NL$$>,!aLKOqGqM@$'a_SZZdPS/u0\F>6O$P[I+Ks/<=e9M'D-B"aqf5P6huY4VMs':M?J#ra08)#Kl.8?mjZDn#WS9O?A_!2qGoG;2]Acb6\-LTqLDl1Q4kDV85^kXo7MMGEQg[QD?BHpIXQe@BmhH/XQL0:O8!?5HMH3HPk6G=oh*nVC2o/fLD0:NbZtFH#Q=-R_o@Nnf=S3Qbl_?n<kDu46?W-ePIH-6V/:+kY,-hhO@E#.K&r3`<M_Q:AjRa9:lR8:I.@dKW31VS0$Sf=!MDdq\+,,k8Kb?V/d\f35l(V1VEg+<[MT.*0BaOfud@s.)AP]!g&#49(\dc&b^M*8MP"Y.*[\[Abi,LS@9]1,fA3<soG)ODYm#Ih]c3/:q9#oJ,I=n0NC5h%iF@8m^\$`RN[r&phbkfT6adI&71LsF=5'B?/A(jB;9c/lH[Gsf74+Yq58tsM]3fQf!n5(1>QS@#eQERqL_C&J7'rG5"_XBHqRSMdb(qm7<eQq((4pm8K!9*,7H2~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1749
>>
stream
GauHK>Ar7S'RnZ;367<4`[j\Y'%LITZ<jtI?'?mY$OhJ4,L_Zhh'UN@];#=RLQZdO@QVXb,^+#"M#,DNkOO`9`>>R>UD2XQ#s<RXJ8pt#JI*N.ndaMMSA-)]I3&Y)Lb#GDEZh&ks.EUV)0e@86QS"E1a"Ob+=&10L(ZI0(Bc:>;T&Eh_ghkR%AWtJRHI6CcLW[Y3ChVOGeP9VIXYe![-!(\hcPYG2K&`9'l;o0L[#45R2:*%8R_8jo[=-EQ7m=p?06D6+&/`09*8,<"#rI^+'2HI)*NW(qFW,\Om:.1_INt:-W\h!?O0Ch2")dj4'_ksJ7"S`k7-dG>/Zg15/?uE0l;Jh^+hI;P.3+RE%gnhgi5&&dM9Lc+a(:bR`eHl3'8=J$9=2f\QL3a$[b.&GjUs@l"#%T,l3W36m8]@4m9+Ueu\*EBE\tdfNop8_n3"b8NS'KBo*h`>ERj)cH+8JSJq[W,NmLSF3;4cXag&qk"_J[5*;?,(i1(bKM0<jg`[*5oOPXHCp5RG2.=5t4i$f-r/gE[pbj@!Lj==Ls0FHl="r+m8XGQ70[8PRp$XR&9*[]N]6(7l#gN6YQK*IsV64%Ve?(TE9F7JC-l>X\@<>]:Cg?(;<"G12Kg(R?\1j#_(SrUr_6m0PW\l?8/V1H5AmJjea-i2tlO-],8K9/mCUhI;f7q)E3`Tp+@$]tel^#fgegFY%SKVmB6D;T5Nf6FmUDIL$PA13goiFMHeIe>;nk,8<.Sksb_h.2Ca,u5MX<KT4[67\[%\pFU+8auQ-?`\Ke4EU-fW'ms$hXJ\:=WX&nZW,fWbHmn9pfg"Gh5UCk9XMADt\?eZ1"o?Yg]?r$\Fp=@bH#8MlbBM(U/JT@0`2)UQ8P?O0K]HT:OImHB1NiF8JHTF%ksLa,_a?%6@WXMSChg7Y3gXU<Kh#R4bQ)iD(mLi7*"Y:pdeT(lEAS'e53L"lsGTcKA\YWQ,hNfBmQ\BUutPI;tiK.rE:k2X=h5s.978rjTH[kl78Y70[^GY,^kAY:KS:;OO<V!c'gD[Wn:CLpP7&bWpghi8_qN8@\6\,15\%?mI(b3P86]K#t5['\MEg>[r\_Y;:!'fM%RQ@"sYZ))#tDbi:8*\jYC0]#p4jcEMU-]tZrpID!A&\fP0%lc9QsRB;T\D72sTi!:6&C9D!BN.S*4@C]ne-\FUr=Q690VsmbOgD>c<%)m^R`#n<``+*>@"fR+qC"jk@>u0#&F'Mf&1$-<U=m&Z^J"(S!YVXn+Guh(LpQ&HY9&^5^iM_\ZgHe`pI5ec7F&Y7*&DjXbe.G!n*!eT.iWs6I$L:.:WPKr]csS8am@),\A./tV_KAuje]Bq\::SZ&1H^fLM`M5,#t";L/56SL*8IDS)!uHKVCHp7BQ*5!q;.V?E[t(2+fom=DBl/)#s%$0TO4jWMJF7N,<Nc&n\(ug$1]J=!HZGh%TIiF@f&Hf"nP)*KTuB]=H:m-`?!1K$F=oaHIdo@KCf6)eT!mrh5^[ud1%!L*$LfXC/&48:S=jjCi6a>*IZhKeTFIAEo$d_6T1NS*1;fs(53WaI#O7M5+!B4mDn4R3VJEB_*YGj(?RUsI)7Ul4o$X)AsU-)c(&7V@`R*\FTIcEKAjX0f^6/%r\6Q`E?F.`"5;;V*l9S=nbpj^jP"RCREI?=ioh]$RCXH_nDLrE-AGK'iJ8qFP14@39,fEKaRns0CmpQteK;?bheHAb47G\HRT^+$YIek94\s;oD:/p>^C#kQCB~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1636
>>
stream
Gat=+gN)%,&:N_Cm"7*hU_/P9$"hlr\#CBlP3/X;1EF6W'L^EI8=?:0:;=LN&>WuF_PWVMOPoP9&,%PtaaiB+\_hK<nFH]VcP\DHM\Rp;,V7fhYD_=&+?\_I<'Z,6,3D."@K3"GJeD$R_O/@P1l[<O,Y619f!,F>H;VU)W"#h7":Rg8dR["RR]0,=Hhr>ISE&%\W%a0Sot>k/\egRWH\ZK^?SmKMb_$h8$:uXEOb1OjB"u+\H3nMibV\QY<>G#"X\F,j_A25GS6aFdRO2rVZ\6rmd1k,CD9DQ4/8end_QGc%0-qtQh0Wo:X_E;?^nGJC<G2"mP3$6V@V"XHmV-SfjPJ:?C*Ee?0G`R$n3GpOBH]_\_E-V4FI0mM&b(TQ.eRJ8CG-t?MaBV!;8#XA%!-[01u]oQ;_cG6$3o7:FY\ROrBuMQp=DaKn1!*UUk9gOXHEd0lU?@`K9KVeJ2'B^"uh+nPl5]t5<ZL2ET@Rcp@K2UN)@GLirUHY)m,BK+lf?-&Ct^imPqKo3r:Yjqt;Td=H'n@@fMF%@9kHZ;4)B1,ioZ<QY_\D@:64#F<JIhrh3Ys[n/OAEl^2S2blib6S]pqM24]H`a4SP8]u,K57C&0d]P\<C;MSfLiSP&e4&mm%$>Ug]=<9fX2fU%lGMjNA8O"!cA)+u/<^[&K]SbkrS3k<cG8S9Ce]39GbN3#7HMU]r3h0W=^I\#%kVTk%IDp$o8tqRW]`lWnM`/mfGT+P1$.*M'LFICD;)P%k3K\(Vf6sAc_W)K#KSC$]W8&,G8r6[.X<G&SZ2d.DrQE_VVi/-%-\?o0q@Mji-=2jc4gmEr!km80oM6k^_f2r9jX)\2ZFm(YC(rb!jrlt;$<?dG5tt#q+oWG!&+8l,Y_;$AbHb"$u9$&iOW)PiRVjob\IqRWO&nDWN/ab:,q[\dis'>RQ:4C)MBdF^ao@]/Y2W\J7]#6$8^j`/DDsUl.okm]T!T`JtS.?_#r5F>uXg)QjG<M2D[63jr!%PW^rSJ#r#\$`28lOf6&k0a93,o!4.5G4tX]M;KWk!;-3K*?ol,u8Ckmf&2B70o`U@Oj,8&.^.Oh0noA;V;l3(YD#c8#[>I'?UNiSC$fQ%!IuBRoCG)jNEU4KqlPqe55koF5&-[Om-#L=Ymd4ilT<Xu:4PX<nCdKu!>)>-0YQ-SS]tjS71XIX^S[+>H]nbo9AmZXaOfV:NUaR4&9^;/Zqo5&i`*I<L$5YTBh7([G0hH-H0X/`>$_<js0=?"3D[jnrI+`%QZG[rO1lrC\PaGhr3U5qWQq0./p"?ahjqc\3\hI6%dh^@Uhq4eI[4jL9fYrSSA7U?f]+Q%GP;`V!C*+.cgRF:-V;Y/l"m"6*(iHJ9Fc*ai']($?dU%W29.:>X+&ai>rDMfk$9.Ip$RgX?VHiOt612](=#.e)(/)cCeHj3D&)ME1`blOJBf3=\b&!2%&95P++m8QA_M7t:)6mq@rttN-("fUh%-HQtXK,FlUPn4BQg!=8X?1Hh5;q4?@OOk-g,>onRq.Sq3AeA-Ok:h@Ifb5Y6!N05Z>n\lRM8jSm2OK0LYW)6_oLK^U$l@L$4hN#it[,09sI%a>2j`6^eaBt,k*idl>$9t-hFG(;?YV/-[9i=5k$EZ+_Oa~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1785
>>
stream
Gb"/'?#SIU'ReT:\F>\\`%4JW&sZl9?+Y7\g9k_P%N,,\b-\9SQ5k`VeeS#L]@7'3VciCD7F?n05pXk-iSil]LR2F7n\aY,aTg[i.6N"#J8pr/J-dE-qAasTH5@X]+cf"k`0\u73KVn&5>WuG*.j9*0om2C)A*hn&<[VS@gO.4M]rp&WF>>4@mHcM7T'5K/-oh^g&!r>3QIfs$,SC1q0pL_>'_n0>BWQDq*d"(_:hI@<*p3ZJgc06#0+EmI!6(8'h_gYX.m\nXXSCu1,.`NJk,!f7obgT7aL%M^=6;jh<<Xa_"7_&+5!h&pd/p4+ME4$kIL`7!&'Td((KU\[7kWF@Y"%T`j>gTGtTugP5%5#E%Sd@E6lS?87.#oKT4"SdbMFBbi9;/<6.?EQYeBA=;:=79@3?'4P,<H9]A1?5uWa@iW't,c^I%X5hD)?_<sMY(k0[)0<#&JDNr)74c9^j*6Inc2iYYt'6!!_3FtG"dQ`+oF/MdiON9=C#"uraiKA1&<sSm["'I:F5?J`=RP<X3%e.@S5:6;LI%(^!`k:-FJ"kmWeKq;>R84U14>Eh<%cp#*WQU!R"14GZZ*4<?U/8m<][XJEQ-iP/GF.O(\LNJW%`NUf17dm=\XcO+`s39`rr!?/2Um_Ae-,bM<aqp[W%_'(f]u51'%3eR<,/`iEs%[VdsO^s1C@rOXBSf70T'j?HZ@Y,276W7NsK[TA`JUqC95ObAY).4I'IU1rY6%qBJaOf?(]($HUT2h@3B<`Xt:tTO(p=a^a%EREXZ1\6M1ji0ThTPYA"M:qm!57A3U=QF)Bh'B@GbpWYlo<HEHCJP-H5fNj]O]d#XWK:5Z\>n-P&uVec&Fe<NW8%+B#NrcGbfG'L_s,+nH2&7AG\F`rN(W,n6L`hVYH9LLVgUj5"iSXb-HMdsuu51*hVb@7e8B/"Y/o`?.D>q?bjSns<9_Z?k67L&q*X2V]BSc!S#X9%1#WH`E@+;u-I\u]HtOH1,HSnhW'eV!MB)'eUuBI=UeT0fBt93k;M4Ss@1H)f+7bmh\J3m-G!al$4Mo=U]pTMDoKoTo'FIR`Q_`^fN,H[P6n.a<JJeuD\bMum8DT^IZP4-e-W>%1[L06cYakA[8+Ssgn`,WI/<areE&U=^5L1`0DS^%so'S@(o&mGo+o(2GMi_-)\3Tfj7[a84*4&k!uV^<[#?;X/"`)0D0.b76SUd%TlA-o]BWM`HJ+j'?KbL2@naR:c&+VbKkT6lFP9pM4&O0dM''5/N*Q=Lc#WYJ,4GI_8R\\6j%D(r1`KI98U\Zq'+nU4/W/=E%'ekTOT#TT<_(4YX-/hO[*'gYSc5lE!$5`a%LD'E?8Nqoa5;klXMjR%jaE)!X$8CiRqAd\=q!CKTM4%O76&/8Yk<!ZNW`8>UL[N)Ma6flh"'@>,XkVU+W3EljZZCO3=6^VP6ZqKRDj>$3q`UP-iYiA1/qe[@*^##pn&h"*GX$qq?V]fHChcaO&n%`^!=%u39]aXQ<4q[Y`%IH?kdDS$%r%kF;5DU7Nn8$HI]f10KMEN:IP=u:k?%9r7dVcY3LW[s]F(3R^t.cs6S8&g3kLjf>V@4\W1c>[GNN*/U'EE[=9Vq/GCA)LOl_70__0Wp-'@'h$A3u^l[,=X6'Sna01Rm'&j1hh-;C;=6Qf!`:]fmq+";=AlA<gBcY7\.@[Uc=CXSq@+D>jP9/BouVYk$2`YirG#93XG!q%gL3dW3TAEBB(/fm"F<mIq_riZ++RTFT'`;ZPQ69_:hh/.(ckd*N;hKpH"^npFj.ac8Y~>endstream
endobj
27 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 402
>>
stream
Gar?-b>,r/&4Q?hMRtF)R+dRmb9Em`;ur7N=)=V^9`oZ+')6pG#H.gHE[#ekbfL7u$P+?)I0n[S=KFKl5]R!o,`un^QUDo-#q_*8T\-R&$':)BKdM^--r&D%a'kAZCF#_;A?E0L+jV!Cd!-Pn&I02uN0PO/Q+$NrIS<q$:g0%1gAT9gr+rS"85FCpF5-d3,5Ce/^XQ?uj2rbq-"[[K]/B$Bbd[IJ>o.bR)S\LZGS.f#\\;CYD;KI8J6W'8Mu(-Z<Hj/X(!4+lFiKbGO:$<#r/GX23PqialJm\K9!c0&U!>XPd*j(u(6b\R2-In/QpnMK/&`WX3$7:f=G6sa^',dZh.2o?dcupk)62JQ_S[XS\CmhN<gOgfhgB%W.hl^moI,<iYNS;i)^5%^SQpY.~>endstream
endobj
28 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2096
>>
stream
Gb!#\D/\/e&H8h>EF/nq=j<%m6r=s3bir@`=HK>ddr'd=6]i6jM(H3#o!T)+m5sTeOrYoFL*2t&fHS:]m`<"emOSPVhlQ_c(B=[bf`/m?E>Vs]F,0pn,8cB@mhXl6H]AKPQIPUg&Du%laC\HSXrtUE4p+A>,!?/NabZ1q"HIMoa#qEW>jPT%LaT,K*?^NU6Ac$=2A)ISC59;cq!7LYI8ZQt'2_thAl]uV_\K90PX2\t6CI/M0Nu9;n]BBMpbeVA4Ii<`$ti#+_Kc/lc9:A5pA0%Ujb9/?0Bu$Gl#NJ\@M_M#0\Y!e3r"`QM2UMuiZG->(^']XMtE`q4Ul"#&qK3(I)[sZJ-<%bor'.E9g/-fP6Z!YGVIQhKHjl5EL0XkAhLO%7jrLjB9(/]Ps^odG^%/EeqB?W_[@f2+)tr9a*#*(#2<b?)Il#g&Yaj$F_ieFbM&H8!)]L;ZtO5DN!!R#/SqlCfm.8UXYQdG]5mlL-QP9[n-UB*nE@B@;8OX>2L5W>>.V"]RFC[6@F[*9s*!m#M=2,W&77p1o.b,an5$C_;M>;8WjlBP9oqU^MR7mu\&F!hWJLkNJl$kr_]01[CFSk@<dC_Tg\I;8)-eXfd7bjjXDJgC`%"QLdk&;-hk+7Gq5qfB`k1]u0,7hf($%ff>bXIpkAV@S0T$VW7lLdP8kmEb`LC_aX,bG#*p8/-)"3a(gDQ_;Ge;1n@s(k7b8g(,\@>)iWCjVRiqaC>Pki6GH3O\sr)S)CjqBKciK`RAQK*6*H'u4qE[91em?D$=6)[/amYku5KU/$I0tJ6t]S(%^,bZ2bd"&`W]uX;`Ae:s_k`kY6m+eHi(i=E[XHMrlBPVB8hsRATgmD)"pn_3YVPNd>cPadQ[#&F:?H%;t]Rqn:6(rT2HEN#holH(e]RWFmRIlBI=p-N#Dl2(iP2fE)%b-R?]j>]n7,lLdAYB>p']2C%'JC%>^Ju=FM8#1L]fZ[LdjW\gY-[_A)2)'elY:mVWc6[sEa+j@_D+p].M:"mLl^'YXb,O)1,oebeYM]0@;kG%9=kDUnoj%b6PA1=p-rA?/K:R4[.ZVfLG&+URHH_J,ZXr4+m4&k]nk50qD:a10(u2/5!MhqC^R#QrD1_--/97.SM_7[P?U$&EhM(!d'l)u<nE6>jI7K:pmYd/-BKjBVO):Ks4!XsE(g4G7mg8j;)6=%maV7#iQHqXdUf4R#%q;'i("'91k@0"O-dE"DPBs%Z8>U.lG7p65?;tJ:V8;)fG,V>_icn)dDO:t][NfMZgCa(]-W'KnGg301?Rl189CmT@ifV1XeuA9fNOPuN!3f_=$\Vr<RAt:(6#]5hREph2IcI0WTm#W!MKeE0nc8f/TP[CSh<BT0"1(RGZ5tBRY#gU7l-+G.#>,B7#N2"f2;A3BfP&ba,uP:E(R91%:"I,)%u:'PNt3b!orQe[;As^KXe'cM?_Qa>GN:+#htQNE#.[0Y&(mo]g[+@WrSs)Wr,ASVU*W%ej*46QqjlhKd'].2\IYuAR5MkYs+*#n]lCRq/*unpSVlQJ^'u^^/q!0VkO6eOrG%3@1[?)!,`0rIkC[*D6WMca+Wc##sd\fou`$o'tD<L7E,BbUC"^E$iu_5g4I^Qf,GAg2qX/r^Q0_>B*Uq^+"+t4e,DX_31+*Iq(OIZAc@..s6f<8FBY+LUr#Ubrpe,#jL;rI*sa#C8.6_-*.7R.pi/"`EcO!]*_nX7:/2&7rPJqBiDMSKlKOVB[u.Gq@qBNsU#2socIE>=>fE/"rmr(.d8E1V6E*CV?-BIF&ECPR_R9'0qp<G#mEgQk*I+_&1*__iiNs_j[chrVU9)jJ.R^8<Y!o9Y$MD.c]M7\qeMf)I$t0Y'p)pVh;"tQ4I4pt4^;mKX!@m-<><bZRV$2j$4WIGI]q4&t,@@[DCK:b<HoP-64=.0b%Ic=t_9RUhK4L9:QWL&&._2M,b^jqOCJNOs2Z6_+0<W7XhHC;q"!@/TW2/Xr9',5%OC<PG@N6Y4URtmMm9%WU>2a'NO,!-.Q+F5!=YEM/qfe<o>cHs'"foJQ!VQ6KjD\'^2X,,T?!#eP4%RA&-QufiCq.`RaRC[HWI2r'Yl4[dhuW'~>endstream
endobj
29 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 900
>>
stream
Gb!#X>u03/'Re<2\DmnBlMCBFBG-#ML/o$kdqP#-WfXe'Q8W$4<G9k\qjaI%>/n^VVDVB@h"mUhI!i('@/#cJ2kC&?K[;k05U_:a+<5A,qf!;T?NW\B<EoN5)Da_])-22oGo+[j80eX%">j.M.h5etUp\T8?(Oq^_:$3m0Hbn1`(G)9RL#nSDtsf58mQ.kKTtV5rgKH3kntbull=:HlbM9_ZJLg9AK5,&P^m]Wqh*(Q([s&hZ[$qjALWRqV2apO\=lkfn"S:So-?!U"Q%jt9s_g_cAMc1+!<ZsCMi!N:6BMf[dj5Q#=?38H'(g:fW43gR7Eo.E3>b/%?RB$U=/7?k0>/9";<#N@",220*M`R_MAO82_YWL;RP.@Q3LUS19h;a'9NQAX>V<7=VD$(`YgO6#Z<rG0o60ub(2J/C;$QWcpZlVU@K-(.BPQ.`[r$FCW-<lae\@DF)CLHDVAeEg]s+Nb,7-_pRHf6*/p"dHXu0l$o4&D$.S@CFB<U$o>M-$H!WmQ$HMtO7<BL1[bFJ:%lm#a*(NRJT7#m8$TiR9hm:Grc=e[aj3n(#:*-NRB4=q=jGu":$9MV.BF]a03F"e3])S@^gF_@%/W4i!bEf.BWV"gb(=<1oD9h*dNtA52[b%^T-R&[\(p!\PV8QXUS?XZ4&hK.*S9^G6G:-CK49HZS?0Ye0976rP@(<?3O'V&E`IoaHRAeko1qE:f6q+iEJC5Rc[AHrE10\;4!e*DIZr(7-X5er,0O89HfR)3AHUk^<KgMLH`SpiB'mCiWV(32PYp'cmXqdV/"E1_.h<aU5ar"$8dM`[)Z-6[$gEP6:BQ3Yo*++\#qA6'iiTR:cdMnd;7j%T2?LNX(\dSK=Z8]95N8L7UIL15i#.QZQg1i5GIK;kg\@2~>endstream
endobj
30 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2295
>>
stream
Gau`TH#q`M(&snuimlMKaA*-E+:3YVH<SATF?SsGG[i+h:=[J7,V2mQTotYG]:"'@4oi58>>j1G8L7S##lNP1LNeg\`@j$^E=VL9)Q#/)0Fl^D^c^Vmo`^jk49'I0(Hh27+KhMXaFT#Ir]i>b$Le32+V,G])A!d2i(6D6$mflJ(Bc:>9#9nG_ghlA#1<StR4k@\YhpaYF!U,b?\n4Cr@NLsZPO8%EeeJb56Sk=X;.a*FZi'Z+8BfG_rR_^DpI>=Ej"^&Xt2ARh,Gl<CLG/-!%6rb-Zu!2NCr+j[8pG@bZ3'W_t=)6%lCC#^?9Lq,CB1T02rC>!B,(!hHJRTBV#tOa:Z_DW7)5In<VIP9&dE=^pD[nE5tDd8CnNHJDB*IZ/'j]F*Kqup#KeHbBB.@=<RB``p_Ks:-XLS-;fe>1_o#]d"`[sATgeo5W99"K3^]_(d;NK6eVXiSkA@rJVspA4d59q?At$-Z#f(FS'kYjo[=W1G)Yqu,$XXU*>tW*9?(7"+]Pi@V)^;\F=r5L21OJCn:e[h$%Ic7/V8[i\+fBq'Uhr@=6["NJn1/0^HiJi&YoS8Xd7@/<3';j+Tg%qL;&USs6^ggrYpIO$<);*233qW<BuO;**O[s]uh6q-to=oQ%;h%*bFRUEn--$,Ofo)!d@M(jAD,d(%PS0Wi<*p0<6j/af85gl^4<geQ`grVlGF*PN["S4?8^AX5X?r4Y'rjcK:?=h]qHRAX9:[<n0npk81A<a"0?KA+_eEF/4%RF'WCG9!iUIZ0;Y*>raY9?M5q4VJ]IW=;1HmD'6u9\4IK>KFld&U4-1*WLCU5W[cFbWMA9Y\Cq#kf=28Zd/_S?p8rHZ`bhAY-JTMQmW_$*I<7]?3M*$mj"T"FE^?WoKCOoef#5klmNjfpcp78FLrR\7$dPTQMg>m0)2&n4g2dBpNZQXMN"35%o8nS5FPsR_5=dSP+*AVWKZ9nQA%j05+_Md#OcMXa0K2)<QY"^_7u/Z12<m"k0j;@?\?3mnpAS+%[sQhO%N"fp[uX%C*Zf5'FdYJ6hb2*]($fR=(:WOU-\<k)D@p:",-DYP/K)L/)sEZBj*Te0-9K:':-*j?\qR^Ba=b#LI<kD]jQc*flW4mPHF<p0;W?*NgBM_Ap?Koul.N[M0/(W-rG_Ik?'n;XihQ/cbh;t*[:-ioIOMnsH=OfJQYrV.-s<LY8_4gSO5Z^X(M2Fh4kk=YZF/,Oo;X@Gq+9fT#U06%`JDjfD?FfFU*s)3%HaEW>bf39C'8rh+q`Z,0s.HR:ONfU%h2dLE/17Q)oFpC2JuS"MHCX%*'DrpEsrG_8NPU]O#r.P9G[aK[Kg$9AlTR-2c62$iHNiQ:dhiTJm\NTELFi8B9PXCZi*\Hbo/1m`%O4=,';3?ZmT_;;5ai\/W(DgbVPV-BPkW&F?:I'3nO*[<*?_,41$Y-id"FBZi&;G[=1@t1>@7USkk"YfN_oIKl[p)F`qa<2=V]UjZ4kfV0"dNf-Q!k\V7RG/!s$dN,U4L*;pYB^E?dDe0Iu3]XcKI:QIBuDA1qF$$7*C.Ob-)V1Z6#g/lQHHNnk9[SL_*0q29[FdC#S%IG%/:CXS"pUG!'=7>13PDUu5rm;G@eZ(#sYrc(RpgJ4"s*(\jq)D&X$GdSqIN>F=LA:*B':E,\`+85`ILjt2?TAOUO1ABl$;LJ7Rq((!%Ct(+45a53["n3BmGMgql0(@SO8lf=./FRA"XEln4RKN'kOQ8=`k1V_%'lo>2HG_T=<?'!1DU*4GsopP%'-`IXUWg9pLs*U;i4"3PoReTJoqW*gLZZ6:oPO9=>=1fg_Uj:[`HoA3@n5,9j5b"nqD?4rHc<k[/MT7:J38B9%J;sQ]BOKR>\rAPR[*%&>DX9UamQ@;(q1nrANoTU@A2Hm(2OHXRKcj2B:aKj,HT10c#O'GXG00;lRZLp!@:L]^_:q>0u/iDf4&E@iX((RF5-d5b+ZDrAFs@jlj@1F^>b;*/$`TXr`O/9kb^$%=O%&%[?3Pr<OVmk!;.d&-FueA))u:efkIg);?`B,K8=1pmAJtln:5D`c)0$&"mmU;+:%@()#%l;@g.8o?gd*E/5#CHc5n,%dZ9_0D6H39T226q:]qc.t+sWRq54L9m-`)bGp4B>opi(\LD44mi"AhBqq]Bp@UU&U\2`iMW0sq[!qspMV_fg\uP&$,&ir=mmm&!lfd:V/BZKJ*8RSV,4MIeUA5B`=[k7o]!fTd[k9D!ajUD3MI,qNIkNNB?BZ!8S0oX`_HU-CR)!oZRmdGfA"DpbI<bN2_jVr4rrM0ur:'~>endstream
endobj
31 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1632
>>
stream
Gatm;968iG&AIa;Ccsf"D?Kt_8:]s#e$O!>l"#r6<cZX)O:9<SpbGG&;31^'(Z85P8*lq8`e7CFDng]%@R0J&h#C(UnG<h60,ZGRbA1L;#["fl5F:5t7sN+O#_;]-q@NrFq=Y?AXd;<bOtbl-"%=M5;LhT&-l"2hW$`mT:`?i-o,&#V;Q0d1`I^p?5Nqtij;^l%&qj]c:qu+i/A6jm/N]c]]FbQrYh#cpeJEnE5R+Gd@J9ENVq:DfhjS;$PNN7"X@on1WF<cVKGUiT0<-M)C3'RAk)2h%+uNMO$HlXUSlo8BHm(HUOPV/[)cHcIJ<-_UY8c-gH5gK8U(P].(t/sCj=O5#(%H]%0c*=^meI\:jblrgOEs#3c*EZ^*g_OXTggf?g0K:a)=k$Xkk8i*?A8ibd#V]_MJuo:5`b<=Aku3q'5Wb<[n]&?hm236g&oaZb/U1YMZc9:,[9BeNKNBCn&BE+ARA0s(GBXm\DdsSC<L+O1.DRON;Jtc%($A+93F6s$Xlbs+$N3iTu2osrt4-h"H0L%&+KV/dIk/rGI<!o@:MNLHjt(!,fUUd.8;^#(h#i0_RLO(`(4#EbVcD9?#&%9\)pt6@<N^D&6J@$fAQ!T-eu.5(7X.3<nQZ^E&3'Sk$(Pt.jTMI/+d"H#!SM-1E#!9OsDrY'6:nbESe2DGTW"6".)KVFGZNYRjN;2OOL(T)NqlHK^0pR0e552qIe8UY*X*2&`.%Mon8RSi9Fm1-PWtGK;bD[+eaM1Ys`_]2I8&S![`I`5Ei@r-SiL2m_F5_1n/dq]`b$`&%8%);PUG[!(I2t[\M@1"!+n[qL^`Eo$NKg+`^_YO#9m),0m]8mYJBM[.V.8J1c_50&hT0fYV3YeCESh["lH',7#uE(';)m05J(Q$HKJJQWT6OL[-]kiC(^6^Dr$g;*[HArZP6IQqYO`b\oP6=')"1NHc(6@>ond5d"m^*cmI^lU<1m`0=@5JifDkFp5DHV6b!P3Qo#1So*D3QYl"Icf"c6Mre\SR_*5%_;+EO<Nf0VI_rNiK_hbV&\oa>.dKrha-O`O&NtFMqQ%W`J[dZFnn^0fSPD3+LVuB;qno\q3:/8,YqQ6_ZqfOOf,CtP@>!N0q16"TI0OJX?&bh-M'+]<X4`@,ra^.D.[-5oNKUW3Br<@*Jk"b0=%k%Ui(t<(iWNTMAVsDf-^>u]@hBeAKkDG5+sd_hCGjKg8,js"l2l#]>@Whgs#L/t$9&H!5_.$<4Lng_9W)J5X-m739id_];^\bY;msk"rI)FA]pukK3)1.?&]VoKjtToZa_I)88!XIO.>gP-Oi9s[,XX:SegGG,*UdYj'U[K].]<dE/!<@,6*2((kA^6`JMj%9@.7jA`n$!]k[^ic941d6671&n5Zej[m%3"Aak@h<ML:r(?(O9Knt.lD4GrI)\LqdE]%k8sZ#12tC7Ti/aB<$HN@T*k3>#@UAUJ\/*"?_?,?lS8%g#[uC7!R^)[>bG2DIi+l-::"=':u!RH.L4E3U=i76MI_4H6ZaX5`A`1d'AV?`G0K8_):=1ejqG32_2M48o,"J)_*K5;HDrdaYlaCZ@\Fr#>ffiF;>n<!C:_r.dSj4Y>`Dl>jqAl>J0Pf-RD8^Gm:iP/-r2G,@p%~>endstream
endobj
32 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1540
>>
stream
Gat=*hcJPj'Rod`EDKpK>u;kM[c@q0e#m7nVe9d87UP_\3_EkCmEBaH/LUSqY%5Q_/`<0#_-#oMpU`Y35;Ai+c2@#e#C&CcaXI>3@CcFcLk+`:GWQB6#mcU&'G6F'd4jNEP0eVC[1!Cu@M1%N@A'PE8dZ,e&Z$*D*:9P4&h4!AB)r)Y@k*`DU0&,1p]p\X`13T<6)$>6^:3S+.7Z$7EQ8Q'[Qb%P4'3:uNuA?l=X55Zk&Bh1T,2/0Y$@DV?'V7?^RNBAWs31+96bT&oW#(#\hS8NA$1eCf=<?!6^V2soU5<YLG(t_0G\pMs1PC_0u-<8P`$=NK3i/D#"J?;5%7E6829S#7T9spKu@F'KMe&RR'HYDB&7)M^_@G'iHbQN+J`h[h65Ks.-_4rV7n30Tn:Gc't)e[#".?'W%\OSL[*JFUJs4r(7\k=r2-&O^Of;"M[^Mc9'uSH%UaDJPm2=QA+?DYg=["a%"G.j.e'S%^=s-bS;*.#(8aKB9sRj1KI!)X)"8=J=^/>V"S@"'8QWKkXS(#'VZ(Ucfm@L,M4f^pk"dnCJ^e;a/5L_Dgl<gP,-7TLbOTdY2/iq:s"!r#/M*nWLF5-P<(<eGBJLo3U-+?(G/_=BR.k=/Gb9&`cl:`T@T0's<YjWkA#GdK_<__N8)-QR.jEa+!Re9P>nf;B4X/D%+Y2-5juKMb0t6;h]c_^-1qTUUc0%4,E(A[h/>-HDk6BjTQRG@E/2^[p7)h<KQ"`-V!OICj.jjdce%t-cY-[-LL_5UPM^-Ug!Sm*`6#sY.[Keg)p#P55s!5P&mL%Z6+bt>d%9M]Xa_aeYq=Z*uWm[@'$Zn`iq$m&Rp3;2$7Yp&$%O6OGHU+2Dc43@e&19jOV>-Y4nY3+*.#YNo-b&tQh2[SQ,]OgVh%5"T^pUTiF)pSR[YWgabmo6,OgZq]E?cY?LJmN'SiG:GAmn5@aSB4f4S]L.^i;h)WTpQSRre=GgUC[ui9'E%(/Tlk/%sh)n#@H5ha3N*Vl"nYqFPpN;3oaFA*j-W]#+.l@%J7G0jq>'""8P?DMgN'p)+=&)W"opf]@WE2'9d!Ll;6fcqS>kLXZ.oOMorf0ZNRQSQ-;`!_`MWE.o4Ljc@IC<<uN!IHpWh,37pN0E4J;p%&*"iK=esW9Eu)6A]r0#4p:0?V79S^@TK.mCV7)D>!=\Yu_,nj/K&P?5nf^n4bj=Rd28aCD7"uNUjE-o*1]FQSEY(k\4W@!i6'I%/a6(o"a)'9d4Kg_TM)QbmID"-m&nI%!EeqbOrm?.VlbYq!N+H;::Epr=dZt:"hj2Xrm3T[E%oFM,ufLiDP1&L,)jm&OaaCdBq<TN^_ct9T=HSaDT@2BIC:IGu\F<V+5ZC$9W2e^N8&qo?opp]<0\rBsM3))PX>k=>F]H/+&@91HTu?6`XaigaUi4i15'H`uoBK(@\$9dp=U[iHUjNd9+19dT]Z'm.0EF4M<IOYNuURbVW,iCu)c!*C#lMW_bQ>*"3hUr1Un(.i];nf5C\Hi2/a7q/DYTX+bUZGWl<Cr!U*Z"BY~>endstream
endobj
33 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1376
>>
stream
Gb!Skh/D%+&:`;=E@=Z!bn?><,`3MELC@n+U=i%5YC4NN,%#0*ZKOi$1?>XrOch":R?OKrJn;A5M%oE&GJ#T#!)A%&n+d=Kk:+&l00i'qSqDi$"Ogk*2[qAA-C%9!(g*_^QqCb#k^H@SK*l),<%:fIRO:cV&O]eVX]#[lk`S=Z:a;M!"Thf#GbQEj>/X%6Hh8I[;A/;O`CctNkr\^J'/*psPE*Rt0*7m`U/S'^%3rk]SA[*e_;t>T5(A)iboE4#@8$uWfc'I9Ki9E63;d`7fJH$,E<\pXKr%qUYd/1TH3)JXa(p>MKpn,Va_grcCd89!7q9eh$i=Qp0<0u.7mJH/WEUVo4%U*g=W$^_N:Ghd#9iJn%O2];=9UHlm;"pf"u9VW7s[!>Fgii0,#NAC'5uL`5sIjm6B`VTBI]j$Bad.J.,$Pl!8E@#"Pg_XJY92UprIe)hK-d@5"JQ!TGOmYmaW.gSd11h4-f]P[X\223KNo@`H:8b,F]"bU_Y^2>L5k*=F6M`,J,YWDY)]I#[_lTPf`gtO^t^fa`27XJ'#"MXC.@I%"9<HK-hhPJn:L8k7r'J.5%X@>XN5^]>iqW;MmZ>,KG;@.=Oo:7o#/I/.lg,*Ad,Y8'*JqmQYVK_el?mQ$oCZdVB,2-gPY?hB?Zml''HS<$]R\+j$Qi5n5a2"mr]">hfP`FE.eV)lsE`IY@<eZrlca@qnY\/2=Uq3E'\Qc_oX8lUgC?63`1=,-BERg<:>B%s'[BZ`NM"]dmE#eeZg6+`3iKfl,C5XPOXaXC5\e_@;goi68gg<7Sanq8jZFI@S9Ur,-LDOoDac(5MI1)eC+rWeq#ERUbH2D$d*R>A$An53K93l'n"[BV2QDTf@\*/9ABSaQf7b8j5]!_lE\0UWR-UKCb$QAW;DL\3cj0c+5MUBqVrl'7u-4hm\EPCAN0o78eP<E5h,<$oII"P(b=)["XFNYK5ptS2J=,Q!&aYARc6k6P\PJLJ$5-a<?>(]f)aZq18eb=dr,k()KX9[HqI;O&)M`9`!SIfe5@8*17NWe\SPn!'e;Rfj+EEZ1!^h23L,M\LuIq$L:1qajAJ8nY,UJ),c)USo@^SJ`[a=Z<4`YP,MCm-*$?%3Ku@n`\@tZ@,.T5C)FsP%g+*t;*PDeJW0jFV$;$fc\[r/AVWg[HqV<s;GN:?54`YXHfn_\*#A$T&$"PF<jR.a;!4>q16,8tS@&dcY4Ld^Jus,"1h[iiYKh,u-de@UQ8q=km+GRiH]2nD]/)?=:,[jl!OXp<7(>]9(C8MTGD@7fK[8L"#6JQ(6+Y1=GNAV@a1oHMWFg76l^c!8*F'bVj0bitnjR0:N+K1TZ3(t8=/TIXm![<)D.]8b969"2+SZ0e[FZf~>endstream
endobj
34 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1796
>>
stream
Gb!;dD,]1K&H;*)Z(J2+"0*Z^ao)uIg<1K='N4(2L)EJ58n)76WNCh(^]%9Bj#Cb:5c;L."A#8#?_5g5g"fK/nNj5U;$Lu%K_M)e:^4fL:m\[G4GEd]qIU?%ZbZ6a38A[D"se<7%hrM(#L=cHa?g6h%L<SME&f57LE%/R"T](*O>(un;':F.#M]L"WTS@&#C2+"N1jsPGeP8;auB<>j'G:S`]J:Kr^CD.K/TB+Xc?%jl3/4;1H7?U^E11_C+RPAU/VJ,FWmN:L)\,6kREPuAXScd`c*+<10(YSk5$Y<mpZ":f]q[E*DR/=Yce"*Q9ZnW;lS<G<dQ"08M2]u*CX<hW(TiKCHN&B:)q:an1]#Tgi>)(gi7boLWj3KWF*)LWPnpJgFhrr1#D8;#n;:5?1F5-,-,"e(R-/+['LJ+^<hL5[mTIEXTQb##T[0a:+_?qdaa+rX2o1\7$bOW,/lLCPDsMhUOt\7-<?C2k#%cLqbJPaWpKBbDt9ZEB%//d;s*!M@A(0OX#&e,F!7n#oB(*a^L-49$2[T7bD8YU9\_N(a0O2K.n$1s#h.fE=GV&[:K!VL9*h0ED*iX)iu;hgZYQXc6;[pGi'_G7lWS*X$XUH*7D&D@M;?+R;TN$J=NY^#'Wmq]T!qpoXE@QKF)XL!@&oKY5p"!ml.CUaGDgLQY5DD]4bQLD(shi:X,aJ2:[0GkT8a3j9:1PK6s;p[iaf^P&Iq?P08=X]lWIo)^*A\MR!1$LBh1i]8jY:+UeGRJDoV@NQmYf8S4]3%ar_qJ6AmcaJ:%:=!FON+*cRosO]A@&)JN?V%-;sW(.tGB]GnmBk7[M'44$%djj:/H4UZ+F!"hOTBrdHp(eRp^A8tQqR*?B@WVg)>?(0P%n;J/=8TuL>ZX444b1<m1T.1F#q%un-BXo\L&C9Nn#(#?="JS8.<-^#&(eGV&M.kV5qZQA:WTV`9!I-p.^BFS#RVOXH#fN'=(d<I&^;#_^N[@RFBTVIXRSLk5`=G.[g$ki?1nMU7>GX>Q0[0'Nb)P`A^K,5Y!=sT:UjCVlB6\dnp%WXunb2mTJ(>Rop+?QYJ+ruF1\X0MZ%g:%HdFht$GZ]XV9+&4q$L9(:&tZgX0]>poRiQg]VGomJ&&9>Ds[[kF<2a=`/e4`.ohC"c)F>!P)V8S^#-J),(>WmeC5U@J7mVlO;jnUm1aFJ(+L=t]\%b2lfRSMAuR7"FdlAg<3[F"H.pJF['%/uZ40CG!kU4(`Lb@UY/>]->?t`+A<(&tZ7>Jkbh8mj(ZT9pll(t&1XWA&iZmTo"*$-/<-p=jI^4`^Al;)t)paLCkjMLkY[>cCo"BA&DG86!Bq<1(-\BefX!]/kdsaJl;_/L(G(Xu(\LJYSR/VKBA%o=S6[,qbQ7AEgo5;aX=_5Gjrn6sD^VS]^)%[4kNKC^1I?$6ILs-Y\<EPRQCHn+6:)s'n+H?#M>VrQ'2DGBD5)%i?lN?!I,BJR`cNfiZ,"jTo.S#9AnihOBiEhNGfK2DdAn41Wj#Fb^O-.RJ4tk01^$$L2Y55V/rQFNCa8YJfq.\a)Dk#Kf:g4ukaRt1c.O3N^IZC*OpNYN8<j\VW3'5+S/d`cA>Mus82M18DOq9WKK&u\3j3;IH`=mMjEfHW%ZLpUh0,q0=A(\^`54$PX7eFrJAZn_"ndqBJ_Q>g><a%[^KSEG3%'WU9a6Qo@?HPmAl)[rBeK2bHBrt.WXa2h9PiYU#V7\guH&JJLoeBg3S0?Y%nF%EdSTEp9X0R8f:A+9'8"PFXcPH'/Tr$"M77?9T$<Ksh!:,+GFl/k~>endstream
endobj
35 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1941
>>
stream
GauHL=``=U&:WfGf^Y/qfE_J/,j8"WELNcH9VtN&24<^m"em--2@l4/iZ+0]p+F-J<$[qn3jE4Z"G(L+4g;OP",\2_r['Y@UFA<M_LQWn/qF<Q@/:jlRsN[-i?=7M!5t5RO#`<S[m*]k]E3\HKJAN*8/`'X#nW73.:WZ(f0p:0T[L'Wk:n)V80]R68/?k6gfs62OE2*f8.^OGkNc?$daF%FfWml5bnn38#&BLh;11_Y$_dWu3DBGIoCXna>-;t<>a/e(J"'%X(C"KA7r\IooXq?Ug?8]&eo,\3Y&dm9A!gT>oU_=*^_a^1W(XLUs'40k(uV5Kjg'GcTiPm$JGB;?QY=kY.HE)0Le?\(1dYj1BlVHhb=Gun,)"b3!GQHKZ-;5?TiB+L(mjV@6!+iliCM>?8\BK\DjWp]ZGB@roATarCnES+H32nfJcr^_b[^F6D-t^4M"X^ZO)A*2ps>4@(\_YQ1f(alcNTU41qnJ/`!29\hN5f:4pF2\P]4aSO"Nadb<S+\`<3jdesQ:)lMs.kpbCb)pk"][`>H*HphuCJX"lVQOm8rC^VJ>ghQJEPdUEALX'/S7Tb0oNLW8um9@e#s@HA#Yi_2+QWXWA2/![6;Eh7!9mmoYo?\AOW4$o=`P.>,^dKd1ccofR^>/B:-.MbXYTJi93(N?,MQ.HRD$?LSp6a#fQ%7s\>rQ`2@]`am3C#&L8)dOBas8)?p9R3ImR_TkJ>o?aGBs-B2=]Q;pF@o5q\*hZ.meBJ5<1A'4j#C`h@m!]*h&9+:8%ubW9`AteG[4o3>q@gC\j"jXNEMH?q!<6q;@r:ng04M!LX#s\f-]\,@'8g3P8a4hZR%ZRg4$R]&sK&"<.RmR<N4T]3#edT#E4<kE*8,sg^feHqeMD?>Sb4D6$T_CE_f)#;`"9TbS(ic\8XOZ(RC.Z6)3Cpm,fSM[esNJp1$=-;#M6^"Y/l]#+T0EGE[=Wr<P`,+)E-ZU`@%E>;3XRATO9pl9Mg`g\"cf[^QK_R1HRo&NuB$@M3b%0f(R[I+AuRgc`&rO5RIn-[5p^7\NI0]QC!/T&T6Uo'pZZlg\or#TC<7R/l=06>'q-U?>N;RiJQ]k#@SCH!7"6Zf;_S\94pXR\&;ob+p9`HG2g*)!)V$,?1lM&.Ilb$:(^nQ!?GW)<8qLN,$6siI\@j!*EXnYL9gMbMiqjr[IM?RtJj@/C`qfP8#Kg@]`\qUG9F`N_YSP!.V+#Q#!-FjS4:jo1PX5>Fc(BZ=H=oaAfj^&@JJk)9aG6@o>AA.^KXqbcnOq;(-T!SSI&5aftD-FNF%%p`^Uf('[,MqOLA>#)T;TI?@p+qe1+h/T2fkiQl#M&c9(^L;@;J\dbl?7L_7@]ihBr<`u`0arH&dq>4hQWud@Q)8csfk4,,J!)i.,\b,),64eR)GKBdI`9K$<ron=I0IB+Wa`g!#NHMLFX@JQ]&[@SQciYNd(BFj@8G4RR4m)Veni?0)N!\F0=`'YbAIS8"Q-jB+ra98FGI?a#m98ps?,<D=XXeTp\>C;".rfLMl=u`P2eTA2Fpm/-fT5Z!K%QVUj$EHCX3eSs,D)tKAseK]Jc'8YX"(eN#%?*bI5QO)bRM!6$Z(i0\Sc;f</$ei9Cq$0PC/R^oFt(XlRupen989un*4X/,8WK6U_qYYKC*MtQ)0hdXW72d)JB&8"*JOoHIs0epZKtB\O:s#/oEg2h*QJ/gan>#jbP:OJDkdMQaI@VFi!uP([RSNs(nkF4i^H1C:N/>*R'=SA'1Oi2/F:X7i*SiQTn7D:=cnQ0D<rrlhktV?0?:B=C>"3g=JC+kY@=bQXMVO9'ef[JulAZn$E4q5L'n4XDb@3E6?%G@ncEs@pB!1n5um#o"G#%"$XBEeZu:'.#GZ2ocL..[jrMj1:aU`DeZI)f8E6I(Zt>o:N4sEiDC1e:JGl78*HfHJG1,r:g@f~>endstream
endobj
36 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1102
>>
stream
Gb!Sk>E@5m'RnB330+/>*SYRMB;F'm(m_!+>*a$aW@oE7gTYBsihXB)kN(&J1Yp=b#`VfPW,Z[HF2%+ui\I0OZ1^94!'Ha's+1:30OFkp7)8^Xr%[q0H%\hn6jP'b1l+,Q-tke@Y.Og&8S#aa#+@$9PlRKqd5o^k;,/R;TJWm#l7jDR;PaO.bsJDkOaKD>k@e%\:s/"1I#h&^n00''F9o4b;&Se04fL__+:)PP$=UZ"Mne/^T>F751+l^6S&;k<1s*=\"m65qVNWOn109kXSsu:mm<2usl3Y[!_Ye+XCSY;?RNNDh;u^XnJ2+ipX6d,jqbOlIMATKU'YQr5mPRSm,?7^,+J9'oEMpjj!'R(Z+bbT/a$\8aLUl4#MGPepBTDD-QQSsn5+W)THP17a_ZA4W,Tf`L\1\1tclK?.cN%?;OW_I<3;48mZGRk,'[a`him@jc*.JA>Hf9OqLjA!^]f)jYX,F[kU.Lq7HR7'fIZ.__d@1a2)FK1<etHtk`ong)KQVaXr>Z]:Xau;t@fq=HO@>PI2[hf!D,H\[^@4ciL[]f)#7)8@=#;6n`*:q3mnK4;*&ALFcX5K^4>o@PLnoE/R^UCd`opa_C?@lFfZS'[d%`apXs_S>3nkEkZ!^1p"GqsK1/*mo/a2U9b1TMN0<JNWgU7;>+jMDerK!adkGL4XITBFE:$\UtCiL)=L-RsEc%E.eU?SnG`LFF6;B<(pc-hiR8;uH.9G1PJ^QY$a-uoQ8:*5Pn_:Eg-YFW41`t.-7B0L4),e3_(b)aX=LBu4q=4_Sbk?5'A`Pp-b.oW5JEoRp2NnpqHFcj[<MEAR>;0R]Zm4E]Q:C<B^Bg$02DrVA51\\AROGP&p7qYYrJ'Hn4cgY%V$*JIX8aPS__L@GfYtN5UHfha3YPjEX+ZrE6bTq>!<F>cepSuM:j8?Eiep_<t:\$taid-H&0%H@=GG^lij9N)N=6;^CmND#g1<&;+:Fjn.iO2mGmG\/#._,Cf$["t/%b_r^s"oFD>d?0TDL1.Cft@4'Y!!hh3:#eA=Y@$+>6JAr)bA5nSo_mD8)MI*[Wu-:s08Wh-1lk[MAm=[YJ+o!DfKrpbeX)[BUEfX~>endstream
endobj
xref
0 37
0000000000 65535 f
0000000061 00000 n
0000000122 00000 n
0000000229 00000 n
0000000341 00000 n
0000000546 00000 n
0000000651 00000 n
0000000728 00000 n
0000000933 00000 n
0000001138 00000 n
0000001343 00000 n
0000001549 00000 n
0000001755 00000 n
0000001961 00000 n
0000002167 00000 n
0000002373 00000 n
0000002579 00000 n
0000002785 00000 n
0000002991 00000 n
0000003197 00000 n
0000003403 00000 n
0000003473 00000 n
0000003765 00000 n
0000003917 00000 n
0000005843 00000 n
0000007684 00000 n
0000009412 00000 n
0000011289 00000 n
0000011782 00000 n
0000013970 00000 n
0000014961 00000 n
0000017348 00000 n
0000019072 00000 n
0000020704 00000 n
0000022172 00000 n
0000024060 00000 n
0000026093 00000 n
trailer
<<
/ID
[<c8e42541392c429be860bac76f53b1b7><c8e42541392c429be860bac76f53b1b7>]
% ReportLab generated PDF document -- digest (opensource)
/Info 21 0 R
/Root 20 0 R
/Size 37
>>
startxref
27287
%%EOF

View File

@@ -0,0 +1,375 @@
# Les 11 — Vercel AI SDK
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken mee, gaan thuis zelf aan de slag.
**Demo-app:** Polderfest 2027 — fictief muziekfestival met 500 verzonnen bands in Supabase. Studenten kunnen vragen stellen aan dummy data die LLM's onmogelijk vooraf konden kennen.
---
## Slide 1: Title
### Les 11 — Vercel AI SDK
**Visual:**
- Background: CREAM
- "Les 11" in BLUE
- "Vercel AI SDK" in BLACK
- Subtitle: "Praat met je eigen data — vandaag bouwen we Polderfest 2027"
---
## Slide 2: Terugblik
### Waar staan we?
**Vorige lessen:**
- Supabase geïntegreerd in je app
- Tabellen + relaties opgezet
- RLS policies bekeken (wie mag wat lezen/schrijven)
**Vandaag bouwen we niet voort op QuickPoll — we starten een nieuwe demo from scratch.**
We laten zien hoe je een Next.js app aan een verse Supabase koppelt en die data combineert met AI.
**Visual:** Twee icoontjes (database + Next.js logo) met pijl naar AI-icoon.
---
## Slide 3: Planning
### Vandaag — 180 minuten
| Onderwerp | Duur |
|-----------|------|
| Welkom + Terugblik | 10 min |
| Theorie: Wat is de Vercel AI SDK? | 30 min |
| **Live Demo 1** — Next.js scaffold + Supabase koppelen | 20 min |
| **Live Demo 2** — Seed script: 500 records in Supabase | 20 min |
| **Pauze** | 15 min |
| **Live Demo 3** — AI SDK installeren + chat-route | 30 min |
| **Live Demo 4** — Vragen stellen aan onze data | 15 min |
| Waarom data + AI samen krachtig is | 5 min |
| Lesopdracht + Huiswerk uitleg | 20 min |
| Vragen + Afsluiting | 15 min |
**Belangrijk:** Vandaag is **demo-driven**. Jullie kijken en luisteren. Thuis gaan jullie zelf aan de slag met jullie eigen thema.
---
## Slide 4: Wat is de Vercel AI SDK?
### Eén SDK, alle providers
**Content:**
- TypeScript-first SDK voor AI features
- Werkt met OpenAI, Anthropic, Google, Mistral, Groq, en meer
- **Unified API:** zelfde code voor elk model
- Streaming out-of-the-box
- React hooks (`useChat`, `useCompletion`)
- Tool Calling (volgende les)
- Open source · gemaakt door Vercel
**Code teaser:**
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: "Vat de Polderfest line-up samen",
});
```
**Visual:** Logo's van OpenAI/Anthropic/Google met pijl naar één AI-SDK doos.
---
## Slide 5: Modellen + Kosten
### Welk model wanneer?
| Provider | Model | Use case | Prijs (in/out per 1M tokens) |
|----------|-------|----------|------------------------------|
| OpenAI | gpt-4o-mini | Default — snel + goedkoop | $0.15 / $0.60 |
| OpenAI | gpt-4o | Multimodaal (vision) | $2.50 / $10 |
| OpenAI | gpt-4.1 | Reasoning, agents | $2 / $8 |
| Anthropic | claude-sonnet-4 | Coding, lange context | $3 / $15 |
| Google | gemini-2.5-flash | Snel + multimodaal | $0.075 / $0.30 |
| Groq | llama-3.3-70b | Ultra-fast inference | $0.59 / $0.79 |
**Vuistregel:** start met `gpt-4o-mini`. Upgrade pas als nodig.
**Voor vandaag:** `gpt-4o-mini`. Onze hele les inclusief Polderfest-Q&A kost ongeveer 1-2 cent.
---
## Slide 6: De 4 kern-functies
### Wat je vandaag gaat zien
| Functie | Wat het doet | Wanneer |
|---------|--------------|---------|
| `generateText` | Wacht tot AI antwoord klaar is — string terug | Korte server-only antwoorden |
| `streamText` | Stream karakter voor karakter | Chat UI, lange antwoorden |
| `useChat` | React hook voor instant chat UI | Client-side chat |
| `generateObject` | Type-safe data via Zod schema | Database inserts, classificatie |
**Vandaag gebruiken we vooral:**
- `streamText` + `useChat` — voor de chat UI
- Onze Polderfest-data als context — AI beantwoordt vragen op basis van onze 500 bands
**Volgende les (Les 12):** `generateText` + `tools` — Tool Calling, waar AI zelf besluit welke DB-query te runnen.
---
## Slide 7: Wat bouwen we vandaag?
### Polderfest 2027 — een fictief festival
**Het idee:**
Een fictief Nederlands muziekfestival met **500 verzonnen bands**. Volledig fictief — geen enkele LLM kan dit weten uit training. Dit is precies waar AI + jouw data sterker wordt dan AI alleen.
**Schema (Supabase tabel `bands`):**
- `name`, `genre`, `sub_genre`
- `stage`, `day`, `start_time`, `duration_min`
- `origin_city`, `members`, `bio`
- `tier` (headliner / mid / opener), `popularity`, `ticket_impact`
**Voorbeeld-vragen die we kunnen stellen aan AI:**
- "Welke bands spelen vrijdagavond na 22:00 op de Main Stage?"
- "Geef me 5 acts uit Groningen, gesorteerd op populariteit"
- "Vat de hip-hop scene op Polderfest samen in 3 zinnen"
- "Welke headliner is qua bio het meest interessant voor electronic-fans?"
**Visual:** Festival-poster mock-up met genre-tags + Supabase logo.
---
## Slide 8: LIVE DEMO 1
### Next.js scaffold + Supabase koppelen (~20 min)
**Wat ik laat zien:**
1. `npx create-next-app@latest polderfest --typescript --tailwind --app`
2. Nieuw Supabase project aanmaken (dashboard)
3. SQL Editor: schema runnen (zie schema.sql)
4. Supabase client installeren: `npm i @supabase/supabase-js`
5. `.env.local` met `SUPABASE_URL` + `SUPABASE_ANON_KEY` + `SUPABASE_SERVICE_ROLE_KEY`
6. `lib/supabase.ts` aanmaken (client)
7. Tabel-check via Table Editor: leeg, klaar om te seeden
**Wat ik NIET uitleg:** Next.js / Supabase basics — dat hebben jullie al gehad.
**Visual:** Badge "LIVE DEMO" in PINK + screenshots Supabase dashboard.
---
## Slide 9: LIVE DEMO 2
### Seed script: 500 records in Supabase (~20 min)
**Wat ik laat zien:**
1. `seed-polderfest.ts` openen — uitleggen wat 't doet:
- Procedureel 500 bands genereren
- Combinaties van adjectives + nouns + bio-fragmenten
- Insert in batches van 100
2. Service role key uitleggen — alleen lokaal, niet in client
3. `npm i tsx @supabase/supabase-js dotenv --save-dev`
4. `npx tsx seed-polderfest.ts` runnen
5. Supabase Table Editor refresh → 500 records zichtbaar
6. Een paar voorbeelden tonen — "De Tigers", "Lost Mirrors", "Sanne Van Dijk & The Echoes"
**Sleutel-inzicht:** dit zijn 500 namen die **niet bestaan**. Geen enkele LLM kan ze kennen.
**Visual:** Terminal log van seed + Table Editor screenshot.
---
## Slide 10: Pauze
### 15 minuten
---
## Slide 11: LIVE DEMO 3
### AI SDK installeren + chat-route (~30 min)
**Wat ik laat zien:**
1. `npm i ai @ai-sdk/openai zod`
2. `OPENAI_API_KEY` toevoegen aan `.env.local` (schoolkey via Brightspace)
3. **`app/api/chat/route.ts`** schrijven:
- Haal alle bands op uit Supabase
- Format als context-string
- `streamText` aanroepen met system + user messages
- Return `result.toDataStreamResponse()`
4. **`app/chat/page.tsx`** schrijven:
- `"use client"` + `useChat` hook
- Simpele Tailwind chat UI (messages list + input)
5. Naar `/chat` browsen → werkt
6. Eerste prompt: "Hoeveel bands spelen vrijdag?"
**Belangrijke uitleg-momenten:**
- Waarom we **alle bands meesturen** als context (volgende les: Tool Calling lost dit op)
- Hoe `streamText` zich aansluit op `useChat`
- System prompt — hoe je de AI 'rol' geeft
**Visual:** Code-mock-up + chat preview.
---
## Slide 12: LIVE DEMO 4
### Vragen stellen aan onze data (~15 min)
**Vragen die we live uitproberen:**
1. "Welke bands spelen zaterdag op de Beach Stage?"
2. "Geef me 3 headliners met de meeste popularity, en hun bio's"
3. "Hoeveel jazz fusion acts spelen er totaal?"
4. "Vat de electronic-scene op Polderfest samen — wat zou je aanraden voor iemand die houdt van techno?"
5. **Slechte vraag:** "Wie was de hoofdacts van Polderfest 2025?" — AI antwoordt eerlijk dat hij dat niet weet (data alleen 2027)
**Sleutel-inzicht:**
- AI is **slim**, maar pas écht nuttig met **jouw data**
- LLM weet niets van Polderfest 2027 — toch krijgen we precieze antwoorden
- Combinatie = `context (jouw data) + reasoning (AI)`
**Visual:** Chat screenshots met antwoorden.
---
## Slide 13: Waarom is dit krachtig?
### Data + AI > Data alleen, AI alleen
**Data alleen:**
- Supabase query: filter + sort + select
- Geen interpretatie, geen samenvatting, geen taal
- Gebruiker moet zelf SQL-denken
**AI alleen:**
- Gebrekkige kennis over jouw domein
- Verzint vaak (hallucinatie)
- Geen toegang tot live data
**Data + AI:**
- AI interpreteert en vat samen
- Antwoorden in natuurlijke taal
- Filtert + reasoneert + presenteert
- Schaalbaar — voeg data toe = nieuwe antwoorden mogelijk
**Quote om mee weg te lopen:**
> "Een LLM zonder jouw data is een gewone chatbot.
> Een LLM mét jouw data is een product."
---
## Slide 14: Lesopdracht
### Jouw eigen thema-app
**Voor thuis (niet in de les) — bouw je eigen versie:**
1. Bedenk een **eigen thema** met data die LLM's niet kunnen weten
2. Maak een nieuw Next.js project + nieuwe Supabase
3. Schrijf eigen `seed-XXX.ts` script (mag AI je bij helpen!)
4. Seed minstens **100 records** in Supabase
5. Implementeer chat-route + chat-pagina (zelfde flow als Polderfest)
6. Stel 3 vragen aan je AI die alleen kunnen door jouw data
**Voorbeeld eigen thema's:**
- Fictief restaurant-aggregator in een verzonnen stad
- Galactische bestuurders archief (sci-fi)
- Verzonnen scriptie-archief NOVI
- Fictieve museumcollectie
- Fictief NPO-programma overzicht
- ...
**Beperking:** **GEEN echte/openbare data** (geen Spotify, geen TheCocktailDB). Het moet fictief zijn zodat de demo-kracht zichtbaar wordt.
**Visual:** 4 voorbeeld-thema's als cards.
---
## Slide 15: Huiswerk
### Polderfest seed-script aanpassen + uitbreiden
**Voor volgende week (Les 12):**
**Verplicht — onderdeel A:**
- Pas het seed-script aan voor **jouw eigen thema** (gebruik gerust AI om te helpen)
- Run het tegen je eigen Supabase
- Push naar GitHub repo
**Verplicht — onderdeel B:**
- Voeg minstens **1 extra veld** toe waarvan je denkt dat het interessante vragen mogelijk maakt
- Update schema + seed script
- Stel een vraag aan AI die alleen kan dankzij dat nieuwe veld
**Verplicht — onderdeel C:**
- Schrijf `AI-CHAT.md` in je repo met:
- Jouw thema (wat is het, waarom kun je dit niet aan een gewone LLM vragen?)
- 3 leuke vragen die werken op jouw data
- 1 vraag waar de AI moeite mee had — wat veranderde toen je de prompt aanpaste?
**Bonus:** Deploy op Vercel — preview URL meesturen.
**Visual:** Workflow-diagram + checklist.
---
## Slide 16: Volgende les — Tool Calling
### Hoe schaalt dit?
**Probleem dat we vandaag introduceren:**
- We sturen **alle 500 bands** mee als context bij elke vraag
- 500 bands ≈ 30.000 tokens — dat is veel, kost geld, traagt
- 5.000 bands? 50.000 bands? Werkt niet meer
**Oplossing (volgende les):**
- **Tool Calling** — AI besluit zelf welke query te runnen
- Voorbeeld: AI ziet vraag "Welke bands spelen vrijdag?" → roept tool `searchBands(day: "Vrijdag")` aan → krijgt 60 bands terug → antwoordt
- Schaalbaar, slim, multi-step
**Daarna in deze leerlijn:**
- Les 13: Agents + `maxSteps` (multi-step autonoom)
- Les 14: RAG + embeddings (semantic search op groot corpus)
- Les 15-16: Testing + Deployment
- Les 17-18: Eindopdracht-werkdagen + Pitch
---
## Slide 17: Afsluiting
### Vragen?
**Wat we vandaag gezien hebben:**
- Vercel AI SDK basics
- Modellen + 4 kern-functies
- Next.js + Supabase + AI SDK end-to-end gekoppeld
- Live demo met seed-script (500 records procedureel)
- Vragen stellen aan een dataset die geen LLM kent
**Vragen? Feedback?**
**Visual:** Cream achtergrond, blauw rondje met "→ Tool Calling".
---
## Slide Summary
| # | Title | Type |
|---|-------|------|
| 1 | Title | Opening |
| 2 | Terugblik | Recap (Supabase + RLS, kort) |
| 3 | Planning | 180-min schedule |
| 4 | Wat is de AI SDK | Theorie |
| 5 | Modellen + kosten | Theorie |
| 6 | 4 kern-functies | Theorie |
| 7 | Vandaag bouwen we Polderfest | Intro demo |
| 8 | **LIVE DEMO 1** — Next.js + Supabase | Demo |
| 9 | **LIVE DEMO 2** — Seed 500 records | Demo |
| 10 | Pauze | Break |
| 11 | **LIVE DEMO 3** — AI SDK + chat | Demo |
| 12 | **LIVE DEMO 4** — Vragen stellen | Demo |
| 13 | Data + AI = kracht | Reflectie |
| 14 | Lesopdracht — eigen thema | Praktijk |
| 15 | Huiswerk — seed aanpassen | Praktijk |
| 16 | Volgende les: Tool Calling | Preview |
| 17 | Afsluiting | Closing |
---
## Bronnen
- Vercel AI SDK docs — https://ai-sdk.dev/docs/introduction
- generateText / streamText / useChat / generateObject reference — https://ai-sdk.dev/docs/reference
- Supabase JS client — https://supabase.com/docs/reference/javascript
- Next.js App Router — https://nextjs.org/docs/app
- OpenAI pricing — https://openai.com/api/pricing
- Tokenizer — https://platform.openai.com/tokenizer
- Vercel templates met AI — https://vercel.com/templates?type=ai

Binary file not shown.

Binary file not shown.

42
Les11-AI-SDK/schema.sql Normal file
View File

@@ -0,0 +1,42 @@
-- Polderfest 2027 — Supabase schema
-- Run dit in Supabase SQL Editor voor je het seed script gebruikt.
create table if not exists bands (
id bigserial primary key,
name text not null,
genre text not null,
sub_genre text,
stage text not null,
day text not null check (day in ('Vrijdag','Zaterdag','Zondag')),
start_time text not null, -- "21:30"
duration_min int not null default 60,
origin_city text,
members text[],
bio text,
tier text check (tier in ('headliner','mid','opener')),
popularity int check (popularity between 1 and 100),
ticket_impact numeric(6,2), -- bijdrage aan ticketprijs als extra
created_at timestamp default now()
);
-- Maak indexen voor de vragen die we vaak gaan stellen
create index if not exists idx_bands_day on bands(day);
create index if not exists idx_bands_stage on bands(stage);
create index if not exists idx_bands_genre on bands(genre);
create index if not exists idx_bands_tier on bands(tier);
-- RLS (we lezen public, geen edits voor anon)
alter table bands enable row level security;
create policy "Bands zijn publiek leesbaar"
on bands for select
using (true);
-- Optioneel: tabel voor straks (tool calling demo in Les 12)
create table if not exists user_favorites (
id bigserial primary key,
user_email text not null,
band_id bigint not null references bands(id) on delete cascade,
created_at timestamp default now(),
unique(user_email, band_id)
);

View File

@@ -0,0 +1,258 @@
/**
* Polderfest 2027 — seed script
* ----------------------------------------------------------
* Genereert 500 fictieve bands en zet ze in je Supabase `bands` tabel.
* Run:
* 1. Zorg dat `bands` tabel bestaat (zie schema.sql)
* 2. Vul .env.local met:
* SUPABASE_URL=https://<project>.supabase.co
* SUPABASE_SERVICE_ROLE_KEY=<service role key>
* 3. npm i @supabase/supabase-js dotenv tsx --save-dev
* 4. npx tsx seed-polderfest.ts
*
* Service role key is bewust nodig — alleen voor lokaal seeden.
* NIET committen, NIET in client gebruiken.
*/
import { createClient } from "@supabase/supabase-js";
import "dotenv/config";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
// ────────────────────────────────────────────────────────────
// Deterministische random (zodat seed reproduceerbaar is)
// ────────────────────────────────────────────────────────────
let seed = 42;
function rand() {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
}
function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(rand() * arr.length)];
}
function pickN<T>(arr: readonly T[], n: number): T[] {
const copy = [...arr];
const out: T[] = [];
for (let i = 0; i < n && copy.length; i++) {
out.push(copy.splice(Math.floor(rand() * copy.length), 1)[0]);
}
return out;
}
function range(min: number, max: number): number {
return Math.floor(rand() * (max - min + 1)) + min;
}
// ────────────────────────────────────────────────────────────
// Bouwstenen voor band-namen
// ────────────────────────────────────────────────────────────
const adjectives = [
"Lost", "Velvet", "Iron", "Neon", "Silent", "Wild", "Glass", "Paper", "Sleeping",
"Honest", "Crooked", "Bitter", "Sweet", "Drowsy", "Drowning", "Restless", "Sober",
"Midnight", "Morning", "Yellow", "Crimson", "Hollow", "Heavy", "Floating", "Slow",
"Burning", "Frozen", "Cardboard", "Plastic", "Analog", "Digital", "Forgotten",
] as const;
const nouns = [
"Tigers", "Wolves", "Horses", "Rabbits", "Mirrors", "Clouds", "Echoes", "Ghosts",
"Lights", "Roots", "Stones", "Foxes", "Riders", "Ships", "Tides", "Anchors",
"Maps", "Letters", "Postcards", "Radios", "Telegrams", "Diaries", "Highways",
"Cassettes", "Polaroids", "Cathedrals", "Stations", "Lanterns", "Compasses",
"Saturdays", "Tuesdays", "Mondays",
] as const;
const dutchPrefixes = [
"De", "Het", "Van der", "Polder", "Noord", "Zuid",
] as const;
const soloNamesFirst = [
"Sanne", "Joost", "Yara", "Lex", "Mila", "Tess", "Bram", "Lotte", "Ravi", "Imani",
"Marit", "Stijn", "Liva", "Noor", "Casper", "Anouk", "Mees", "Pien", "Daan", "Olivia",
"Niels", "Fenna", "Tygo", "Saar", "Cas", "Maud", "Roos", "Vince", "Lieke", "Floris",
] as const;
const soloNamesLast = [
"Van Dijk", "De Boer", "Visser", "Jansen", "Bakker", "Hendriks", "Mulder", "Smit",
"Peters", "De Vries", "Kuipers", "Brouwer", "Postma", "Hofman", "Van Loon",
] as const;
// ────────────────────────────────────────────────────────────
// Fest-velden
// ────────────────────────────────────────────────────────────
const genres = [
"Indie Rock", "Electronic", "Hip-Hop", "Jazz Fusion", "Folk", "Punk", "Soul",
"Ambient", "Disco-House", "Experimental", "Singer-Songwriter", "Synth-Pop",
"Garage Rock", "Neo-Soul", "Drum & Bass", "Afrobeat", "Dream Pop", "Post-Rock",
] as const;
const subGenresByGenre: Record<string, string[]> = {
"Indie Rock": ["Shoegaze", "Lo-Fi", "Math Rock", "Slowcore"],
"Electronic": ["Techno", "House", "IDM", "Glitch", "Trance"],
"Hip-Hop": ["Boom Bap", "Trap", "Lo-Fi", "Conscious"],
"Jazz Fusion": ["Funk Jazz", "Cosmic Jazz", "Nu-Jazz"],
"Folk": ["Anti-Folk", "Sea Shanty", "Modern Folk"],
"Punk": ["Post-Punk", "Hardcore", "Surf Punk"],
"Soul": ["Neo-Soul", "Northern Soul", "Funk"],
"Ambient": ["Drone", "New Age", "Field Recording"],
"Disco-House": ["Italo Disco", "Nu-Disco", "French House"],
"Experimental": ["Noise", "Sound Art", "Avantgarde"],
"Singer-Songwriter": ["Confessional", "Storytelling"],
"Synth-Pop": ["Vaporwave", "Italo", "Darkwave"],
"Garage Rock": ["Surf", "Power Pop"],
"Neo-Soul": ["Alt R&B", "Jazz-influenced"],
"Drum & Bass": ["Liquid", "Jungle", "Neurofunk"],
"Afrobeat": ["Afro-Fusion", "Highlife"],
"Dream Pop": ["Bedroom Pop", "Ethereal"],
"Post-Rock": ["Cinematic", "Math-influenced"],
};
const stages = [
"Main Stage", "Tent Stage", "Beach Stage", "Acoustic Bar", "Late Night Tent",
] as const;
const days = ["Vrijdag", "Zaterdag", "Zondag"] as const;
const timeSlots = [
"14:00", "15:30", "17:00", "18:30", "20:00", "21:30", "23:00", "00:30",
] as const;
const cities = [
"Amsterdam", "Rotterdam", "Utrecht", "Groningen", "Eindhoven", "Den Haag",
"Tilburg", "Maastricht", "Nijmegen", "Leeuwarden", "Arnhem", "Breda", "Haarlem",
"Zwolle", "Enschede", "Delft", "Den Bosch", "Apeldoorn",
] as const;
const tiers = ["headliner", "mid", "opener"] as const;
// ────────────────────────────────────────────────────────────
// Bio-fragmenten — combinatorisch zodat 500 bios uniek voelen
// ────────────────────────────────────────────────────────────
const bioOpenings = [
"Begonnen in een garage in",
"Ontstaan tijdens een blackout in",
"Een vriendengroep uit",
"Doorgebroken op het kleine podium van",
"Geboren uit een jam-sessie in",
"Een collectief van producers uit",
];
const bioMiddle = [
"experimenteert met analoge synths en gefluisterde lyrics",
"balanceert tussen melancholie en dansvloer-euforie",
"mixt traditionele samples met breakbeats",
"gebruikt veldopnames als ritmesectie",
"schrijft songs in Nederlands en Engels door elkaar",
"speelt instrumenten die ze grotendeels zelf hebben gebouwd",
"draait alleen optredens op locaties zonder Wi-Fi",
];
const bioEnding = [
"Debuut-EP verschijnt eind 2027.",
"Hun laatste album werd genomineerd voor de fictieve Edison Polder Award.",
"Polderfest is hun grootste festival tot nu toe.",
"Vorig jaar speelden ze nog in cafés, dit jaar op Stage B.",
"Spelen voor het eerst op een buitenpodium.",
"Beruchte live-show met 12 backing vocalists.",
];
// ────────────────────────────────────────────────────────────
// Namen genereren
// ────────────────────────────────────────────────────────────
function generateBandName(seedIdx: number): string {
const pattern = seedIdx % 4;
if (pattern === 0) {
return `${pick(adjectives)} ${pick(nouns)}`;
}
if (pattern === 1) {
return `${pick(dutchPrefixes)} ${pick(nouns)}`;
}
if (pattern === 2) {
return `${pick(soloNamesFirst)} ${pick(soloNamesLast)}`;
}
return `${pick(soloNamesFirst)} & The ${pick(nouns)}`;
}
function generateMembers(): string[] {
const count = range(1, 5);
const out: string[] = [];
for (let i = 0; i < count; i++) {
out.push(`${pick(soloNamesFirst)} ${pick(soloNamesLast)}`);
}
return out;
}
function generateBio(name: string): string {
return `${pick(bioOpenings)} ${pick(cities)}, ${name} ${pick(bioMiddle)}. ${pick(bioEnding)}`;
}
// ────────────────────────────────────────────────────────────
// Hoofdfunctie
// ────────────────────────────────────────────────────────────
async function seed() {
console.log("Genereren van 500 Polderfest bands...");
// Wipe bestaande data (optioneel)
await supabase.from("bands").delete().neq("id", 0);
const bands = [];
const usedNames = new Set<string>();
for (let i = 0; i < 500; i++) {
let name = generateBandName(i);
let attempts = 0;
while (usedNames.has(name) && attempts < 10) {
name = generateBandName(i + attempts * 7);
attempts++;
}
usedNames.add(name);
const genre = pick(genres);
const sub_genre = pick(subGenresByGenre[genre]);
const tier = pick(tiers);
const popularity = tier === "headliner" ? range(80, 100)
: tier === "mid" ? range(40, 79)
: range(10, 39);
const ticket_impact = tier === "headliner" ? range(25, 60)
: tier === "mid" ? range(5, 25)
: 0;
bands.push({
name,
genre,
sub_genre,
stage: pick(stages),
day: pick(days),
start_time: pick(timeSlots),
duration_min: tier === "headliner" ? range(75, 120)
: tier === "mid" ? range(45, 75)
: range(30, 45),
origin_city: pick(cities),
members: generateMembers(),
bio: generateBio(name),
tier,
popularity,
ticket_impact,
});
}
console.log("Schrijven naar Supabase in batches van 100...");
// Supabase insert in batches (single call van 500 kan timeouten)
for (let i = 0; i < bands.length; i += 100) {
const batch = bands.slice(i, i + 100);
const { error } = await supabase.from("bands").insert(batch);
if (error) {
console.error("Insert error op batch", i / 100, ":", error.message);
process.exit(1);
}
console.log(`${i + batch.length}/${bands.length}`);
}
console.log("Klaar! 500 Polderfest bands staan in Supabase.");
}
seed().catch((e) => {
console.error(e);
process.exit(1);
});