fix: add les 11
This commit is contained in:
814
Les11-AI-SDK/Les11-Docenttekst.md
Normal file
814
Les11-AI-SDK/Les11-Docenttekst.md
Normal 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.
|
||||
181
Les11-AI-SDK/Les11-Huiswerk.md
Normal file
181
Les11-AI-SDK/Les11-Huiswerk.md
Normal 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.
|
||||
175
Les11-AI-SDK/Les11-Huiswerk.pdf
Normal file
175
Les11-AI-SDK/Les11-Huiswerk.pdf
Normal file
@@ -0,0 +1,175 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D: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++\"[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
|
||||
195
Les11-AI-SDK/Les11-Lesopdracht.md
Normal file
195
Les11-AI-SDK/Les11-Lesopdracht.md
Normal 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!
|
||||
156
Les11-AI-SDK/Les11-Lesopdracht.pdf
Normal file
156
Les11-AI-SDK/Les11-Lesopdracht.pdf
Normal file
@@ -0,0 +1,156 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 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
|
||||
527
Les11-AI-SDK/Les11-Lesstof.md
Normal file
527
Les11-AI-SDK/Les11-Lesstof.md
Normal 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
|
||||
334
Les11-AI-SDK/Les11-Lesstof.pdf
Normal file
334
Les11-AI-SDK/Les11-Lesstof.pdf
Normal 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]!g1(\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
|
||||
375
Les11-AI-SDK/Les11-Slide-Overzicht.md
Normal file
375
Les11-AI-SDK/Les11-Slide-Overzicht.md
Normal 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
|
||||
BIN
Les11-AI-SDK/Les11-Slides.pdf
Normal file
BIN
Les11-AI-SDK/Les11-Slides.pdf
Normal file
Binary file not shown.
BIN
Les11-AI-SDK/Les11-Slides.pptx
Normal file
BIN
Les11-AI-SDK/Les11-Slides.pptx
Normal file
Binary file not shown.
42
Les11-AI-SDK/schema.sql
Normal file
42
Les11-AI-SDK/schema.sql
Normal 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)
|
||||
);
|
||||
258
Les11-AI-SDK/seed-polderfest.ts
Normal file
258
Les11-AI-SDK/seed-polderfest.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user