add les 12
This commit is contained in:
BIN
Les11-AI-SDK/Les11-Slides.key
Executable file
BIN
Les11-AI-SDK/Les11-Slides.key
Executable file
Binary file not shown.
68
Les11-AI-SDK/page.tsx
Normal file
68
Les11-AI-SDK/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Polderfest 2027 — chat pagina
|
||||
* --------------------------------------------------
|
||||
* Les 11 — useChat hook + Tailwind chat UI.
|
||||
* Plaats dit bestand op: app/chat/page.tsx
|
||||
*
|
||||
* Werking:
|
||||
* - useChat() regelt messages, input, submit-handler, streaming
|
||||
* - Praat met /api/chat (de route.ts)
|
||||
* - Disabled tijdens streaming
|
||||
*
|
||||
* Vereist:
|
||||
* - app/api/chat/route.ts (zie route.ts)
|
||||
* - npm i ai
|
||||
* - Tailwind aanwezig in project (standaard in create-next-app)
|
||||
*/
|
||||
|
||||
"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>
|
||||
);
|
||||
}
|
||||
1
Les11-AI-SDK/polderfest-demo
Submodule
1
Les11-AI-SDK/polderfest-demo
Submodule
Submodule Les11-AI-SDK/polderfest-demo added at ac06b4d59e
61
Les11-AI-SDK/route.ts
Normal file
61
Les11-AI-SDK/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Polderfest 2027 — chat API route
|
||||
* --------------------------------------------------
|
||||
* Les 11 — Vercel AI SDK + Supabase context.
|
||||
* Plaats dit bestand op: app/api/chat/route.ts
|
||||
*
|
||||
* Werking:
|
||||
* 1. Haal alle bands op uit Supabase
|
||||
* 2. Formatteer als tekst-context
|
||||
* 3. Stuur naar OpenAI via streamText + system prompt
|
||||
* 4. Return een stream voor useChat
|
||||
*
|
||||
* Vereist:
|
||||
* - NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local
|
||||
* - OPENAI_API_KEY in .env.local
|
||||
* - npm i ai @ai-sdk/openai @supabase/supabase-js
|
||||
*/
|
||||
|
||||
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 alle bands op uit Supabase
|
||||
const { data: bands, error } = await supabase.from("bands").select("*");
|
||||
if (error) throw error;
|
||||
|
||||
// 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");
|
||||
|
||||
// 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.`;
|
||||
|
||||
// 4. Stream naar OpenAI
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system,
|
||||
messages,
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
}
|
||||
@@ -15,13 +15,27 @@
|
||||
*/
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import "dotenv/config";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{ auth: { persistSession: false } }
|
||||
);
|
||||
// Laad .env.local (i.p.v. default .env)
|
||||
dotenv.config({ path: ".env.local" });
|
||||
|
||||
const SUPABASE_URL =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||
console.error(
|
||||
"Ontbrekende env vars. Check .env.local:\n" +
|
||||
" NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co\n" +
|
||||
" SUPABASE_SERVICE_ROLE_KEY=<service role key>"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Deterministische random (zodat seed reproduceerbaar is)
|
||||
@@ -189,7 +203,7 @@ function generateBio(name: string): string {
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Hoofdfunctie
|
||||
// ────────────────────────────────────────────────────────────
|
||||
async function seed() {
|
||||
async function runSeed() {
|
||||
console.log("Genereren van 500 Polderfest bands...");
|
||||
|
||||
// Wipe bestaande data (optioneel)
|
||||
@@ -252,7 +266,7 @@ async function seed() {
|
||||
console.log("Klaar! 500 Polderfest bands staan in Supabase.");
|
||||
}
|
||||
|
||||
seed().catch((e) => {
|
||||
runSeed().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
716
Les12-Tool-Calling/Les12-Docenttekst.md
Normal file
716
Les12-Tool-Calling/Les12-Docenttekst.md
Normal file
@@ -0,0 +1,716 @@
|
||||
# Les 12 — Tool Calling
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 12 van 18
|
||||
**Onderwerp:** Tool Calling — AI besluit zelf welke functie aan te roepen
|
||||
**Duur:** 180 minuten
|
||||
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = thuis.
|
||||
**Demo-app:** Polderfest 2027 (verder bouwen op Les 11)
|
||||
|
||||
---
|
||||
|
||||
## Hoe deze tekst werkt
|
||||
|
||||
Dit document is een **lopend script**. Lees mee tijdens de les op je laptop.
|
||||
|
||||
- `[SLIDE X]` — Klik naar slide X
|
||||
- `[SCHERM: slides | terminal | editor | browser | supabase]` — Welk scherm op de beamer
|
||||
- **Vertel:** "..." — Letterlijk wat je zegt (mag in eigen woorden)
|
||||
- `*[stage direction]*` — Korte instructie voor jezelf, niet uitspreken
|
||||
- Code blocks = wat je typt
|
||||
- 💬 = verwachte studentenvraag
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES — Setup (45 min)
|
||||
|
||||
### 1. Voortbouwen op Les 11 demo
|
||||
|
||||
- Open `polderfest-demo` repo uit Les 11
|
||||
- Check Supabase dashboard: nog 500 bands aanwezig?
|
||||
- Zo niet: `npx tsx scripts/seed-polderfest.ts` om opnieuw te seeden
|
||||
- Open `polderfest-demo` in editor
|
||||
|
||||
### 2. Schema check — user_favorites tabel
|
||||
|
||||
In Supabase SQL Editor:
|
||||
|
||||
```sql
|
||||
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)
|
||||
);
|
||||
|
||||
alter table user_favorites enable row level security;
|
||||
|
||||
create policy "Favorites zijn publiek leesbaar (demo)"
|
||||
on user_favorites for select using (true);
|
||||
|
||||
create policy "Anyone kan favorites toevoegen (demo)"
|
||||
on user_favorites for insert with check (true);
|
||||
```
|
||||
|
||||
`*[In productie zou je dit aan auth.uid() koppelen. Voor demo: open.]*`
|
||||
|
||||
### 3. Tools-demo bestand klaarzetten
|
||||
|
||||
- Plaats `tools-demo.ts` ergens als referentie (open in editor tab)
|
||||
- Niet kopiëren — naslag tijdens demo
|
||||
|
||||
### 4. Reset chat-route uit Les 11
|
||||
|
||||
- Open `app/api/chat/route.ts`
|
||||
- Wijs naar de oude versie (`const { data: bands } = ...` + grote context-string)
|
||||
- Klaar om straks live te refactoren
|
||||
|
||||
### 5. Browser tabs
|
||||
|
||||
- `localhost:3000/chat` (dev server draait)
|
||||
- Supabase dashboard (Table Editor + SQL Editor)
|
||||
- https://ai-sdk.dev/docs/foundations/tools (referentie)
|
||||
- platform.openai.com/usage (toon vergelijking later)
|
||||
|
||||
### 6. Backup
|
||||
|
||||
- Werkende eindstaat (route.ts met alle tools) op USB
|
||||
- Verwacht: 1 of 2 typos tijdens live coding — geen ramp, fix klassikaal
|
||||
|
||||
---
|
||||
|
||||
# HET SCRIPT — Lees mee tijdens de les
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik + Probleem (10 min)
|
||||
|
||||
`[SLIDE 1]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Welkom bij les 12. Vandaag gaan we het probleem oplossen dat we vorige les introduceerden — onze chat schaalt niet. We doen dat met Tool Calling: AI besluit zelf welke functie hij aanroept om aan informatie te komen."
|
||||
|
||||
`[SLIDE 2 — Terugblik + schaalprobleem]`
|
||||
|
||||
**Vertel:** "Vorige les bouwden we Polderfest 2027. Vercel AI SDK, 500 bands in Supabase, chat-pagina die vragen beantwoordt. Goed werk.
|
||||
|
||||
Maar er was een probleem. Bij elke vraag stuurden we **alle 500 bands** mee als tekst in de context. Ongeveer 30.000 tokens per call. Werkt prima voor 500 — werkt **niet** voor 50.000 records, voor 5.000 al moeilijk. Te duur, te traag, past niet in context-window."
|
||||
|
||||
`*[Wijs naar de pijl op slide]*`
|
||||
|
||||
**Vertel:** "Vandaag draaien we het om. Niet de hele database mee, maar **functies** waar AI uit kan kiezen. AI ziet een vraag, denkt 'oh, dan roep ik functie X aan', krijgt het resultaat, antwoordt. Schaalbaar, slim, en — bonus — ook write-acties mogelijk: 'voeg toe aan mijn favorieten'."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Planning. Eerst 30 minuten theorie. Dan vier live demo's, verspreid voor en na pauze. Lesopdracht en huiswerk leg ik aan het eind uit.
|
||||
|
||||
Dit is opnieuw een **kijk-les**. Jullie typen niet mee. Notitieboek wel."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Theorie: wat is Tool Calling? (30 min)
|
||||
|
||||
`[SLIDE 4 — Wat is Tool Calling]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Tool Calling. Het idee is simpel. In plaats van alle data meesturen, geef je AI **tools** — functies die hij mag aanroepen. AI leest jouw vraag, kijkt naar de beschikbare tools, kiest welke relevant is, roept 'm aan met de juiste parameters, krijgt resultaat terug, en formuleert dan een antwoord."
|
||||
|
||||
`*[Wijs naar het flow-blok]*`
|
||||
|
||||
**Vertel:** "Voorbeeld. User vraagt: 'Welke bands spelen vrijdag op de Main Stage?' AI herkent: 'Aha, dat is een zoekvraag, ik roep `searchBands` aan met `day: 'Vrijdag'` en `stage: 'Main Stage'`'. Supabase returnt 12 bands. AI formuleert: 'Op vrijdag op de Main Stage spelen: ...'."
|
||||
|
||||
**Vertel:** "Wat win je hiermee?
|
||||
|
||||
- **Schaalbaar.** 10 records of 10 miljoen — voor de AI maakt het niks uit, hij krijgt alleen de relevante set terug.
|
||||
- **Real-time.** Geen verouderde snapshot van data. Tool draait elke keer opnieuw.
|
||||
- **Type-safe.** Via Zod schema's weet AI exact welke parameters mogen.
|
||||
- **Multi-step.** Hij mag meerdere tools achter elkaar gebruiken voor complexe vragen."
|
||||
|
||||
`[SLIDE 5 — Anatomie van een tool]`
|
||||
|
||||
**Vertel:** "Hoe ziet een tool eruit in code? Drie delen, alle drie verplicht."
|
||||
|
||||
`*[Wijs naar code-blok]*`
|
||||
|
||||
**Vertel:** "**`description`** — wat doet deze tool? Dit leest AI om te beslissen welke tool relevant is. Vaag beschreven = verkeerde tool gekozen. Cruciaal om dit goed te schrijven.
|
||||
|
||||
**`parameters`** — wat heeft de tool nodig? Zod schema. Type-safe, gevalideerd, geforceerd door AI. Hier kan ik enums geven — 'day' is alleen Vrijdag/Zaterdag/Zondag. Probeert AI iets anders? Krijgt-ie een error.
|
||||
|
||||
**`execute`** — wat gebeurt er als de tool wordt aangeroepen? Async functie. Hier zit jouw Supabase query, je API-call, je business logic."
|
||||
|
||||
`*[Pauze]*`
|
||||
|
||||
**Vertel:** "Eén tip die later gaat schelen: **schrijf je descriptions duidelijk**. AI kiest de tool op basis daarvan. Schrijf je 'searchBands' met description 'iets met bands' — dan kiest hij verkeerd. Schrijf je 'zoek bands op dag, stage, genre of tier; gebruik voor filtervragen' — dan klopt het."
|
||||
|
||||
`[SLIDE 6 — Multi-step met stopWhen]`
|
||||
|
||||
**Vertel:** "En dan multi-step. Met `stopWhen: stepCountIs(5)` geef je AI toestemming om tot 5 keer een tool aan te roepen voordat hij definitief antwoordt."
|
||||
|
||||
`*[Wijs naar voorbeeld]*`
|
||||
|
||||
**Vertel:** "Stel: 'Vergelijk de top headliner met de drukst geplande opener'. Eén tool-call is niet genoeg — AI moet twee queries doen, daarna vergelijken. Met `stopWhen` werkt dat in één request:
|
||||
|
||||
- Stap 1: `searchBands({ tier: 'headliner' })` — 50 bands terug
|
||||
- Stap 2: `searchBands({ tier: 'opener' })` — 100 bands terug
|
||||
- Stap 3: AI verwerkt + vergelijkt + antwoordt"
|
||||
|
||||
**Vertel:** "Default is meestal 1 stap — geen multi-step. Je moet expliciet `stopWhen` zetten om hem multi-step te laten doen."
|
||||
|
||||
`*[Pauze]*`
|
||||
|
||||
`[SLIDE 7 — Vandaag bouwen we]`
|
||||
|
||||
**Vertel:** "Vandaag refactoren we Polderfest. Stap voor stap. Eerst de oude chat-route slopen — geen alle-bands-meesturen meer. Eén tool toevoegen. Dan meer tools. Dan in de UI tonen welke tools AI aanriep. Tot slot: edge cases en errors."
|
||||
|
||||
`*[Wijs naar tools-tabel]*`
|
||||
|
||||
**Vertel:** "Zes tools gaan we bouwen — vijf read en één write. searchBands met filters, getBandByName voor exacte lookup, getStats voor aggregaties, getScheduleByDay voor tijdschema's, addFavorite om favorieten op te slaan, listFavorites om ze op te halen. De write-tool is interessant — eerste keer dat AI iets in onze database **wijzigt**, niet alleen leest."
|
||||
|
||||
**Vertel:** "Klaar voor de demo? Daar gaan we."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Live Demo 1: Eerste tool (20 min)
|
||||
|
||||
`[SLIDE 8 — LIVE DEMO 1]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Demo 1. We refactoren de chat-route en zetten één tool op."
|
||||
|
||||
`[SCHERM: editor → app/api/chat/route.ts]`
|
||||
|
||||
#### Stap 1 — Oude code wegsmijten
|
||||
|
||||
**Vertel:** "Hier is de chat-route van vorige les. Bovenaan: Supabase client. Dan: alle 500 bands ophalen, formatteren als string, system prompt met die hele lap erin. Weg ermee."
|
||||
|
||||
`*[Selecteer de hele functie body — alles tussen { en } van POST — verwijder]*`
|
||||
|
||||
**Vertel:** "Wat blijft staan: de imports, de Supabase client, de POST-handler-shell. Rest gaan we anders bouwen."
|
||||
|
||||
#### Stap 2 — Tool importeren
|
||||
|
||||
`*[Boven in file, naast streamText/openai import:]*`
|
||||
|
||||
```typescript
|
||||
import { streamText, tool } from "ai";
|
||||
import { z } from "zod";
|
||||
```
|
||||
|
||||
**Vertel:** "`tool` helper uit `ai` package. Zod voor het parameter-schema. Beide gebruiken we straks."
|
||||
|
||||
#### Stap 3 — searchBands tool definiëren
|
||||
|
||||
`*[Boven de POST-functie:]*`
|
||||
|
||||
```typescript
|
||||
const searchBands = tool({
|
||||
description:
|
||||
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre, " +
|
||||
"of tier. Gebruik dit als de gebruiker iets zoekt op één of meerdere criteria.",
|
||||
inputSchema: z.object({
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional()
|
||||
.describe("Festival-dag"),
|
||||
stage: z.string().optional()
|
||||
.describe("Bv. Main Stage, Tent Stage, Beach Stage"),
|
||||
genre: z.string().optional()
|
||||
.describe("Bv. Indie Rock, Electronic, Hip-Hop"),
|
||||
tier: z.enum(["headliner", "mid", "opener"]).optional(),
|
||||
}),
|
||||
execute: async ({ day, stage, genre, tier }) => {
|
||||
let q = supabase.from("bands").select(
|
||||
"name, genre, stage, day, start_time, tier, popularity"
|
||||
);
|
||||
if (day) q = q.eq("day", day);
|
||||
if (stage) q = q.eq("stage", stage);
|
||||
if (genre) q = q.eq("genre", genre);
|
||||
if (tier) q = q.eq("tier", tier);
|
||||
const { data, error } = await q.limit(20);
|
||||
if (error) return { error: error.message };
|
||||
return { count: data.length, bands: data };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel terwijl je typt:**
|
||||
|
||||
- **Bij description:** "Schrijf alsof je 't aan een collega uitlegt. Wat doet 't, en wanneer gebruikt AI 'm. Twee zinnen meestal genoeg."
|
||||
- **Bij inputSchema:** "Zod schema. `day` is enum — alleen Vrijdag, Zaterdag, Zondag. `.optional()` want misschien wil de gebruiker geen day-filter. `.describe()` op elke parameter — AI gebruikt dit ook."
|
||||
- **Bij execute:** "Standaard Supabase query met chained filters. Returnt `{ error }` of `{ count, bands }`. Limit 20 zodat we niet teveel terugkrijgen."
|
||||
|
||||
#### Stap 4 — POST-functie aanpassen
|
||||
|
||||
```typescript
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const system = `Je bent een festival-assistent voor Polderfest 2027.
|
||||
Gebruik de beschikbare tools om vragen te beantwoorden over de bands.
|
||||
Verzin nooit data — als je 't niet weet, zeg dat. Antwoord in het Nederlands.`;
|
||||
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system,
|
||||
messages,
|
||||
tools: { searchBands },
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:**
|
||||
|
||||
- "System prompt is **veel korter** — geen bands-context meer. Alleen instructies. 'Gebruik tools, verzin niets.'"
|
||||
- "`tools: { searchBands }` — voor nu één tool. Later breiden we uit."
|
||||
- "`stopWhen: stepCountIs(5)` — geef AI ruimte voor multi-step. Voor één query genoeg, voor complexe vragen straks ook."
|
||||
|
||||
`*[Save]*`
|
||||
|
||||
#### Stap 5 — Testen
|
||||
|
||||
`[SCHERM: browser → localhost:3000/chat]`
|
||||
|
||||
`*[Refresh pagina]*`
|
||||
|
||||
```
|
||||
Welke bands spelen zaterdag op de Beach Stage?
|
||||
```
|
||||
|
||||
`*[Druk Enter, wacht op antwoord. AI moet searchBands aanroepen.]*`
|
||||
|
||||
**Vertel:** "Daar gaat 'ie. AI denkt na, roept searchBands aan met `day: 'Zaterdag'` en `stage: 'Beach Stage'`, krijgt resultaten, formuleert antwoord."
|
||||
|
||||
`*[Wijs naar het antwoord]*`
|
||||
|
||||
**Vertel:** "Werkt. Maar op dit moment zien we niet **welke** tool aangeroepen werd — dat gaan we straks in de UI fixen. Voor nu: het werkt, en — als ik in mijn terminal kijk —"
|
||||
|
||||
`[SCHERM: terminal — dev server logs]`
|
||||
|
||||
**Vertel:** "Daar zie ik de query gerund. Veel sneller dan vorige les met 500 bands meesturen. En als ik nu duizenden records zou hebben? Maakt voor de chat geen verschil."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Live Demo 2: Multi-step + meer tools (20 min)
|
||||
|
||||
`[SLIDE 9 — LIVE DEMO 2]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Eén tool werkt. Nu meer tools, en multi-step in actie."
|
||||
|
||||
`[SCHERM: editor → app/api/chat/route.ts]`
|
||||
|
||||
#### Stap 1 — getStats tool
|
||||
|
||||
`*[Boven POST, na searchBands:]*`
|
||||
|
||||
```typescript
|
||||
const getStats = tool({
|
||||
description:
|
||||
"Geef statistieken over de festival-line-up — totaal aantal bands, " +
|
||||
"verdeling per genre, per dag, of per stage. Geen filters — overzicht.",
|
||||
inputSchema: z.object({
|
||||
groupBy: z.enum(["genre", "day", "stage", "tier"])
|
||||
.describe("Hoe te groeperen"),
|
||||
}),
|
||||
execute: async ({ groupBy }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("bands")
|
||||
.select(groupBy);
|
||||
if (error) return { error: error.message };
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of data) {
|
||||
const key = row[groupBy as keyof typeof row] as string;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return { total: data.length, counts };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "getStats. Eén verplichte parameter — `groupBy`, een enum. Execute: kolom ophalen, in JavaScript tellen, terugsturen. Voor 'hoeveel jazz acts' of 'verdeling over dagen'."
|
||||
|
||||
#### Stap 2 — getBandByName tool
|
||||
|
||||
```typescript
|
||||
const getBandByName = tool({
|
||||
description:
|
||||
"Haal alle details op van één specifieke band, inclusief members en bio. " +
|
||||
"Gebruik dit als de gebruiker naar een specifieke band vraagt bij naam.",
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe("Exacte band-naam"),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("bands").select("*").ilike("name", name).single();
|
||||
if (error) return { error: `Band '${name}' niet gevonden.` };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "Voor 'vertel me over Lost Tigers'. ilike voor case-insensitive match. .single() — verwacht één resultaat."
|
||||
|
||||
#### Stap 3 — Tools registreren
|
||||
|
||||
`*[Pas streamText aan:]*`
|
||||
|
||||
```typescript
|
||||
tools: { searchBands, getStats, getBandByName },
|
||||
stopWhen: stepCountIs(5),
|
||||
```
|
||||
|
||||
`*[Save]*`
|
||||
|
||||
#### Stap 4 — Multi-step in actie
|
||||
|
||||
`[SCHERM: browser → /chat]`
|
||||
|
||||
`*[Nieuwe chat — refresh]*`
|
||||
|
||||
```
|
||||
Hoeveel jazz fusion acts spelen er totaal? En geef me daarvan de top 3 qua populariteit.
|
||||
```
|
||||
|
||||
`*[AI roept eerst getStats, dan searchBands aan. Beide tools in één request.]*`
|
||||
|
||||
**Vertel:** "Hier zie je multi-step. AI dacht: 'eerst telling — getStats. Dan top 3 — searchBands met tier of popularity filter.' Twee tool-calls, één antwoord. Dat is `stopWhen` in actie."
|
||||
|
||||
`*[Volgende vraag:]*`
|
||||
|
||||
```
|
||||
Vertel me over een specifieke band die je interessant vindt. Kies er één.
|
||||
```
|
||||
|
||||
`*[AI roept searchBands aan om opties te zien, kiest er één, roept getBandByName aan voor details]*`
|
||||
|
||||
**Vertel:** "Twee stappen weer. Zonder `stopWhen` had-ie maar één tool kunnen aanroepen — moeilijk om eerst opties te zien en dan detail te kiezen."
|
||||
|
||||
#### Stap 5 — Hidden complexity
|
||||
|
||||
`*[Open Vercel-style network tab, of dev console]*`
|
||||
|
||||
**Vertel:** "Onder de motorkap: per gebruikersvraag stuurt AI z'n plan terug. Hij kan na stap 1 zien wat het resultaat was, en op basis daarvan beslissen voor stap 2. Dat is wat 'autonoom' AI doet zonder dat jij elke stap voorprogrammeert. Volgende les zien we hoe ver dit gaat — Agents."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 10 — Pauze]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Pauze. 15 minuten. Tot zo."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Live Demo 3: Tool-calls in UI (25 min)
|
||||
|
||||
`[SLIDE 11 — LIVE DEMO 3]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "We hebben tools werkend. Maar in de chat zien we nog niet **welke** tool aangeroepen werd. Dat fix ik nu. Twee redenen: debugging tijdens dev, en gebruikersvertrouwen — 'ja hij heeft echt de DB geraadpleegd'."
|
||||
|
||||
`[SCHERM: editor → app/chat/page.tsx]`
|
||||
|
||||
#### Stap 1 — Messages.parts uitleggen
|
||||
|
||||
**Vertel:** "Tot nu toe gebruikten we `message.content` — één string. Maar als AI tools aanroept, krijg je **parts** — een array van delen. Tekst-parts en tool-parts (in v6 zijn die genaamd `tool-<toolName>`). We gaan die parts mappen."
|
||||
|
||||
#### Stap 2 — Refactor de rendering
|
||||
|
||||
`*[Selecteer de hele messages.map(...) — vervang door:]*`
|
||||
|
||||
```tsx
|
||||
{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>
|
||||
|
||||
{m.parts?.map((part, i) => {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<div key={i} className="whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// In AI SDK v6 zijn tool-parts genaamd `tool-<toolName>`
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
const toolName = part.type.replace("tool-", "");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="my-2 p-2 bg-yellow-50 border border-yellow-300 rounded text-sm font-mono"
|
||||
>
|
||||
🔧 <span className="font-semibold">{toolName}</span>
|
||||
({JSON.stringify(part.input)})
|
||||
{part.state === "output-available" && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs text-gray-500">
|
||||
Toon resultaat
|
||||
</summary>
|
||||
<pre className="text-xs mt-1 overflow-auto max-h-40">
|
||||
{JSON.stringify(part.output, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
**Vertel terwijl je typt:**
|
||||
|
||||
- "**`m.parts.map`** — loop door alle parts."
|
||||
- "**Text-parts** — gewoon de tekst tonen."
|
||||
- "**Tool-parts** (type begint met `tool-`) — geel chip met tool-naam en args. Detail-element met collapsible result."
|
||||
- "**`state === 'output-available'`** — alleen als tool al gerund heeft, tonen we resultaat. Tijdens streaming kan dit kort `input-streaming` of `input-available` zijn."
|
||||
|
||||
#### Stap 3 — Test
|
||||
|
||||
`[SCHERM: browser → /chat]`
|
||||
|
||||
`*[Refresh, nieuwe chat]*`
|
||||
|
||||
```
|
||||
Welke bands uit Groningen?
|
||||
```
|
||||
|
||||
`*[Antwoord komt — nu zie je gele chip: 🔧 searchBands({...})]*`
|
||||
|
||||
**Vertel:** "Daar — chip met tool-naam en argumenten. Klik op 'Toon resultaat'..."
|
||||
|
||||
`*[Klik details open — JSON resultaat verschijnt]*`
|
||||
|
||||
**Vertel:** "...JSON-output van Supabase. Volledig transparant. Studenten — als jullie thuis vastlopen, dit is je debug tool. Zie je wat AI aanroept met welke args, en wat 't terugkrijgt."
|
||||
|
||||
#### Stap 4 — Multi-step visualiseren
|
||||
|
||||
`*[Volgende vraag:]*`
|
||||
|
||||
```
|
||||
Hoeveel hip-hop acts zijn er? En geef me daarvan de populairste.
|
||||
```
|
||||
|
||||
`*[Twee chips verschijnen — getStats én searchBands]*`
|
||||
|
||||
**Vertel:** "Twee chips. Twee tool-calls. Multi-step zichtbaar gemaakt."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Live Demo 4: Edge cases + errors (15 min)
|
||||
|
||||
`[SLIDE 12 — LIVE DEMO 4]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Tools werken. UI toont alles. Maar wat als dingen mis gaan? Vier edge cases."
|
||||
|
||||
`[SCHERM: browser → /chat]`
|
||||
|
||||
#### Edge case 1 — Ongeldige input
|
||||
|
||||
```
|
||||
Welke bands op Donderdag?
|
||||
```
|
||||
|
||||
**Vertel:** "Donderdag is geen Polderfest-dag. Onze enum is Vrijdag/Zaterdag/Zondag."
|
||||
|
||||
`*[AI's reactie — meestal antwoordt-ie 'er is geen donderdag, alleen vrij-za-zo' zonder tool-call. Of probeert iets anders.]*`
|
||||
|
||||
**Vertel:** "Mooi. AI kreeg de enum-restrictie mee uit het schema, weet dat Donderdag niet kan. Geen verspilde tool-call."
|
||||
|
||||
#### Edge case 2 — Lege resultaten
|
||||
|
||||
```
|
||||
Death metal bands?
|
||||
```
|
||||
|
||||
`*[AI roept searchBands aan met `genre: 'Death Metal'`. Krijgt lege array.]*`
|
||||
|
||||
**Vertel:** "Tool returnt 0 bands. AI legt dat netjes uit — 'geen death metal op Polderfest 2027'. Geen verzinsels, geen hallucinatie. Goed."
|
||||
|
||||
#### Edge case 3 — Database error (simuleer)
|
||||
|
||||
`*[Open route.ts, voeg tijdelijk error toe in searchBands execute:]*`
|
||||
|
||||
```typescript
|
||||
execute: async ({ day, stage, genre, tier }) => {
|
||||
// Tijdelijke testlijn:
|
||||
return { error: "Database timeout — probeer opnieuw" };
|
||||
// (rest van execute hieronder)
|
||||
}
|
||||
```
|
||||
|
||||
`*[Save, vraag opnieuw:]*`
|
||||
|
||||
```
|
||||
Welke bands spelen vrijdag?
|
||||
```
|
||||
|
||||
`*[AI krijgt { error: "..." } terug. Antwoordt netjes.]*`
|
||||
|
||||
**Vertel:** "AI vertaalt onze error naar een gebruikersvriendelijke melding. Geen stack-trace, geen tech-jargon. Dat is precies wat je wilt voor productie."
|
||||
|
||||
`*[Verwijder de testlijn, save]*`
|
||||
|
||||
#### Edge case 4 — Write tool met confirmation
|
||||
|
||||
**Vertel:** "Laatste edge case — een write-tool. We voegen addFavorite toe."
|
||||
|
||||
`*[Plak addFavorite tool uit tools-demo.ts in route.ts:]*`
|
||||
|
||||
```typescript
|
||||
const addFavorite = tool({
|
||||
description:
|
||||
"Voeg een band toe aan favorieten. Alleen gebruiken als gebruiker " +
|
||||
"expliciet vraagt 'voeg X toe aan mijn favorieten'.",
|
||||
inputSchema: z.object({
|
||||
userEmail: z.string().email(),
|
||||
bandName: z.string(),
|
||||
}),
|
||||
execute: async ({ userEmail, bandName }) => {
|
||||
const { data: band } = await supabase
|
||||
.from("bands").select("id").ilike("name", bandName).single();
|
||||
if (!band) return { error: `Band '${bandName}' niet gevonden.` };
|
||||
const { error } = await supabase
|
||||
.from("user_favorites")
|
||||
.insert({ user_email: userEmail, band_id: band.id });
|
||||
if (error) return { error: error.message };
|
||||
return { success: true, bandName };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`*[Registreer in tools object:]*`
|
||||
|
||||
```typescript
|
||||
tools: { searchBands, getStats, getBandByName, addFavorite },
|
||||
```
|
||||
|
||||
`*[Save]*`
|
||||
|
||||
`[SCHERM: browser → /chat]`
|
||||
|
||||
```
|
||||
Voeg de eerste hip-hop band toe aan mijn favorieten, mijn email is tim@novi.nl
|
||||
```
|
||||
|
||||
`*[AI roept searchBands aan (om eerste hip-hop band te vinden), dan addFavorite. Returnt success.]*`
|
||||
|
||||
**Vertel:** "Multi-step + write. Eerst zocht-ie de band, daarna voegde-ie 'm toe. Belangrijk om hier op te wijzen: de **description** zegt 'alleen gebruiken als expliciet gevraagd'. Hierdoor doet AI niet random favorieten toevoegen. Voor productie: zet write-tools achter expliciete user confirmation."
|
||||
|
||||
`[SCHERM: supabase → user_favorites]`
|
||||
|
||||
`*[Refresh tabel — favoriete is toegevoegd]*`
|
||||
|
||||
**Vertel:** "En in de database staat het ook. AI heeft echt iets geschreven."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — Tool Calling vs context-all (5 min)
|
||||
|
||||
`[SLIDE 13 — Vergelijking]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Reflectie. Hoe verhoudt vandaag zich tot vorige les?"
|
||||
|
||||
`*[Loop tabel langs]*`
|
||||
|
||||
- "**Tokens** — vorige les 30k per call, nu ~2k. 15× minder. 15× goedkoper."
|
||||
- "**Schaal** — vorige les max ~1000 records. Nu duizenden, makkelijk."
|
||||
- "**Live data** — vorige les snapshot bij chat-start. Nu actueel per call."
|
||||
- "**Write** — vorige les niet mogelijk. Nu wel — addFavorite, addNote, anything."
|
||||
- "**Multi-step** — vorige les beperkt. Nu native."
|
||||
|
||||
**Vertel:** "Wanneer toch context-all? Snel prototype, hele kleine dataset (<100 records), of als je écht zeker weet dat je nooit gaat schalen. Voor alles wat richting productie gaat: Tool Calling."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 9 — Lesopdracht + Huiswerk uitleg (20 min)
|
||||
|
||||
`[SLIDE 14 — Lesopdracht]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Lesopdracht. Bouw op je eigen thema-app uit Les 11. Hetzelfde stappenplan als vandaag, maar dan voor jouw dataset."
|
||||
|
||||
`*[Loop punten langs]*`
|
||||
|
||||
**Vertel:** "Refactor je chat-route — weg met alle data meesturen. Definieer minstens 3 tools voor je dataset. `stopWhen: stepCountIs(5)`. Pas system prompt aan. Test 3 vragen die meerdere tools triggeren.
|
||||
|
||||
Voor jouw thema betekenen die tools iets anders. Stel je hebt een restaurant-aggregator: searchRestaurants, getMenu, getStats per cuisine. Stel je hebt scriptie-archief: searchTheses, getThesisById, listSupervisors. Bedenk wat voor jouw thema natural fits zijn."
|
||||
|
||||
`[SLIDE 15 — Huiswerk]`
|
||||
|
||||
**Vertel:** "Huiswerk. Drie onderdelen, alle drie verplicht."
|
||||
|
||||
`*[Loop A, B, C langs]*`
|
||||
|
||||
- "**A — Write-tool.** Voeg een tabel toe voor user-acties — favorieten, notes, votes, watch-later, wat past bij jouw thema. Schrijf een write-tool. Test dat AI 'm gebruikt op de juiste momenten."
|
||||
- "**B — Tool-calls in UI.** Refactor je chat UI om tool-invocations te tonen. Wat we vandaag bouwden — chip met tool-naam, collapsible result. Verplicht."
|
||||
- "**C — `TOOLS.md`.** Documenteer in je repo wat je gebouwd hebt. Lijst van tools + descriptions. 3 vragen die 1 tool triggeren, 1 vraag die 2+ tools triggeren. 1 edge case die AI goed afhandelde."
|
||||
|
||||
**Vertel:** "Bonus: loading indicator per tool-call, mooie kaartjes voor results in plaats van JSON-dump."
|
||||
|
||||
💬 *"Wat als ik vorige week geen eigen thema-app heb gebouwd?"*
|
||||
→ "Dan eerst die afmaken. Dit huiswerk bouwt erop voort."
|
||||
|
||||
💬 *"Hoeveel tools is genoeg?"*
|
||||
→ "Drie voor de lesopdracht. Voor huiswerk drie + één write-tool. Voor eindopdracht? Zo veel als nuttig, niet meer."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 10 — Vragen + Afsluiting (15 min)
|
||||
|
||||
`[SLIDE 16 — Volgende les: Agents]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Eén ding voor we eindigen — wat komt na vandaag."
|
||||
|
||||
**Vertel:** "Vandaag deden we Tool Calling. Maximaal 5 stappen, dan moet AI antwoorden. Werkt voor zoekvragen, vergelijkingen, simpele acties.
|
||||
|
||||
Volgende les — **Agents**. Daar geven we AI **veel meer autonomie**. `stopWhen: stepCountIs(20)` of meer, of zelfs custom stop-condities. AI plant, voert uit, evalueert resultaat, beslist of-ie verder gaat. Eén user-request kan 30+ tool-calls triggeren. Voorbeeld: 'plan mijn volledige Polderfest weekend' — AI kijkt alle bands, ranked op jouw favorieten, maakt schema per dag, voegt toe aan favorieten, optimaliseert voor overlap. Multi-step in 't kwadraat."
|
||||
|
||||
**Vertel:** "Daarna in deze leerlijn:
|
||||
|
||||
- Les 14: RAG + embeddings — semantic search op heel grote datasets (denk 100.000+ records, of vrije tekst-corpora)
|
||||
- Les 15-16: Testing + Deployment + Performance
|
||||
- Les 17-18: Eindopdracht-werkdagen + Pitch"
|
||||
|
||||
`[SLIDE 17 — Afsluiting]`
|
||||
|
||||
**Vertel:** "Vragen?"
|
||||
|
||||
`*[Open de vloer. Verwachte vragen:]*`
|
||||
|
||||
💬 *"Hoe weet AI welke tool te kiezen?"*
|
||||
→ "Op basis van de description en de parameter-schema's. Daarom: schrijf descriptions zoals je 't aan een collega zou uitleggen. Vaag = verkeerde tool. Specifiek = juiste."
|
||||
|
||||
💬 *"Kan AI tools combineren die ik niet voorzag?"*
|
||||
→ "Ja, dat is multi-step. Hij kan creatief zijn — eerst searchBands, dan getBandByName op het resultaat. Soms verrast 't je. Dat is goed, betekent dat tools generiek genoeg zijn."
|
||||
|
||||
💬 *"Wat als AI verkeerde tool kiest?"*
|
||||
→ "Twee opties: description verbeteren, of system prompt aanvullen met regels. 'Voor X gebruik tool Y.' Iteratief proces."
|
||||
|
||||
💬 *"Is dit duurder?"*
|
||||
→ "Per call ongeveer hetzelfde (kleinere context). Maar omdat AI vaker antwoordt zonder veel context, juist goedkoper. En je betaalt alleen voor wat je nodig hebt — geen heel grote context erbij stoppen."
|
||||
|
||||
💬 *"Werkt dit met andere modellen?"*
|
||||
→ "Ja. OpenAI, Anthropic, Google — allemaal ondersteunen tool calling via Vercel AI SDK. Code blijft hetzelfde, alleen `model:` regel verandert."
|
||||
|
||||
`*[Sluit af]*`
|
||||
|
||||
**Vertel:** "Zorg dat je vóór les 13 je eigen tools werkend hebt — dan kunnen we Agents direct toepassen. Tot dan!"
|
||||
|
||||
---
|
||||
|
||||
## Backup-onderwerpen (als tijd over)
|
||||
|
||||
1. **Parallel tool calls** — Sommige modellen kunnen meerdere tools tegelijk aanroepen. `experimental_continueSteps` parameter, niet vandaag.
|
||||
2. **Tool result transformations** — Bewerk tool output vóór AI 'm ziet (bv. velden weglaten voor privacy).
|
||||
3. **Streaming tool-args** — AI begint args te streamen voor execute aangeroepen wordt. Te zien in dev-console.
|
||||
4. **Anthropic vs OpenAI tool calling** — Subtiele verschillen in hoe AI tools kiest. Code blijft gelijk, gedrag iets anders.
|
||||
5. **MCP servers** — Tools as a service. Externe MCP-servers leveren tools aan AI. Concept van 2025, snel groeiend.
|
||||
260
Les12-Tool-Calling/Les12-Huiswerk.md
Normal file
260
Les12-Tool-Calling/Les12-Huiswerk.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Les 12 — Huiswerk
|
||||
## Write-tool + UI visualisatie + reflectie
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Deadline:** Voor de volgende les (Les 13 — Agents)
|
||||
**Inleveren:** GitHub repo + `TOOLS.md` in root
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouwt voort op de **lesopdracht** (jouw thema-app met 3 read-tools). Hier voeg je een **write-tool** toe, visualiseert tool-calls in je UI, en documenteert in `TOOLS.md`.
|
||||
|
||||
> Niet klaar met de lesopdracht? Eerst die afmaken — het huiswerk heeft de 3 read-tools als startpunt nodig.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Write-tool toevoegen (verplicht)
|
||||
|
||||
Voeg een tool toe waarmee de gebruiker iets **kan opslaan** in je database.
|
||||
|
||||
### Stappen
|
||||
|
||||
1. **Nieuwe tabel in Supabase** — voor user-acties. Voorbeelden:
|
||||
|
||||
```sql
|
||||
create table user_favorites (
|
||||
id bigserial primary key,
|
||||
user_email text not null,
|
||||
item_id bigint not null references items(id) on delete cascade,
|
||||
created_at timestamp default now(),
|
||||
unique(user_email, item_id)
|
||||
);
|
||||
```
|
||||
|
||||
Andere ideeën — afhankelijk van thema:
|
||||
- `user_notes` — eigen aantekeningen per item
|
||||
- `user_votes` — like/dislike per item
|
||||
- `user_watchlist` — bewaar voor later
|
||||
- `user_visited` — track wat al gezien is
|
||||
|
||||
2. **Write-tool schrijven**
|
||||
|
||||
```typescript
|
||||
const addToFavorites = tool({
|
||||
description:
|
||||
"Voeg een item toe aan de favorieten van de gebruiker. " +
|
||||
"Alleen gebruiken als de gebruiker expliciet vraagt om iets toe te voegen.",
|
||||
inputSchema: z.object({
|
||||
userEmail: z.string().email(),
|
||||
itemName: z.string(),
|
||||
}),
|
||||
execute: async ({ userEmail, itemName }) => {
|
||||
const { data: item } = await supabase
|
||||
.from("items").select("id").ilike("name", itemName).single();
|
||||
if (!item) return { error: `'${itemName}' niet gevonden.` };
|
||||
const { error } = await supabase
|
||||
.from("user_favorites")
|
||||
.insert({ user_email: userEmail, item_id: item.id });
|
||||
if (error) return { error: error.message };
|
||||
return { success: true, itemName };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
3. **Lees-tool er bij** (om de favorieten weer terug te halen):
|
||||
|
||||
```typescript
|
||||
const listFavorites = tool({
|
||||
description: "Geef de favorieten van de gebruiker.",
|
||||
inputSchema: z.object({ userEmail: z.string().email() }),
|
||||
execute: async ({ userEmail }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("user_favorites")
|
||||
.select("items(name, ...)")
|
||||
.eq("user_email", userEmail);
|
||||
if (error) return { error: error.message };
|
||||
return data?.map((r) => r.items) ?? [];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
4. **Test in chat**:
|
||||
- "Voeg X toe aan mijn favorieten, mijn email is test@test.nl"
|
||||
- "Wat staat er nu in mijn favorieten?"
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Nieuwe tabel in Supabase (zelfde regels: RLS aan, policies voor demo)
|
||||
- [ ] Write-tool werkt — favorieten worden opgeslagen
|
||||
- [ ] List-tool werkt — favorieten worden teruggehaald
|
||||
- [ ] System prompt aangepast — "alleen toevoegen op expliciete request"
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — Tool-calls in UI tonen (verplicht)
|
||||
|
||||
Refactor je chat-UI om **tool-invocations zichtbaar te maken**.
|
||||
|
||||
### Hoe
|
||||
|
||||
`useChat` returnt `messages` met **parts** — een array van delen. Tekst-parts én tool-invocation-parts.
|
||||
|
||||
```tsx
|
||||
{messages.map((m) => (
|
||||
<div key={m.id}>
|
||||
{m.parts?.map((part, i) => {
|
||||
if (part.type === "text") {
|
||||
return <div key={i}>{part.text}</div>;
|
||||
}
|
||||
// In AI SDK v6 zijn tool-parts genaamd `tool-<toolName>`
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
const toolName = part.type.replace("tool-", "");
|
||||
return (
|
||||
<div key={i} className="bg-yellow-50 border border-yellow-300 p-2 rounded text-sm">
|
||||
🔧 <strong>{toolName}</strong>({JSON.stringify(part.input)})
|
||||
{part.state === "output-available" && (
|
||||
<details className="mt-1">
|
||||
<summary>Toon resultaat</summary>
|
||||
<pre>{JSON.stringify(part.output, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Tool-invocations zichtbaar als chip / badge / box
|
||||
- [ ] Tool-naam + args getoond
|
||||
- [ ] Resultaat (collapsed) zichtbaar bij open-klikken
|
||||
- [ ] Multi-step vragen tonen meerdere chips
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — `TOOLS.md` documentatie (verplicht)
|
||||
|
||||
Schrijf in je repo-root een markdown-bestand met de volgende secties.
|
||||
|
||||
### Sectie 1 — Mijn tools
|
||||
|
||||
Tabel met al je tools:
|
||||
|
||||
| Tool | Wat doet 't | Read / Write |
|
||||
|------|-------------|--------------|
|
||||
| searchItems | Filter op X, Y, Z | Read |
|
||||
| getItemById | Detail van één item | Read |
|
||||
| getStats | Verdeling per groep | Read |
|
||||
| addToFavorites | User favoriet opslaan | **Write** |
|
||||
| listFavorites | User favorieten ophalen | Read |
|
||||
|
||||
### Sectie 2 — Voorbeeld-vragen
|
||||
|
||||
**3 vragen die 1 tool gebruiken:**
|
||||
|
||||
1. *Vraag*: "..."
|
||||
*Tool die triggered*: `searchItems({ ... })`
|
||||
*Antwoord (samenvatting)*: ...
|
||||
|
||||
2. (idem)
|
||||
|
||||
3. (idem)
|
||||
|
||||
**1 vraag die 2+ tools combineert (multi-step):**
|
||||
|
||||
*Vraag*: "..."
|
||||
*Tools die triggeren in volgorde*:
|
||||
1. `searchItems(...)` — krijgt 5 items
|
||||
2. `getItemById(...)` — detail van item #3
|
||||
*Antwoord*: ...
|
||||
|
||||
### Sectie 3 — Eén edge-case die AI goed afhandelde
|
||||
|
||||
*Wat gebeurde er*: ...
|
||||
*Welke tool was 't*: ...
|
||||
*Hoe handelde AI 't af*: ...
|
||||
|
||||
Voorbeelden van edge cases:
|
||||
- Ongeldige enum-value
|
||||
- Lege resultaten
|
||||
- Database error
|
||||
- Vraag waar geen tool voor bestaat
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 500 woorden totaal
|
||||
- Concrete voorbeelden
|
||||
- Mag wat informeel
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
- **Loading indicator** per tool-execute (spinner naast tool-naam tijdens running)
|
||||
- **Mooie cards** voor tool-results in plaats van JSON-dump
|
||||
- **Confirmation UI** voor write-tools (bevestig vóór insert)
|
||||
- **Tool failure recovery** — als tool faalt, AI probeert andere tool
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **`TOOLS.md`** in repo-root
|
||||
3. **Schema-update** in `schema.sql` (met nieuwe tabel)
|
||||
4. **Updated `app/api/chat/route.ts`** met write-tool
|
||||
5. **Updated `app/chat/page.tsx`** met tool-call rendering
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — Write-tool werkt + list-tool werkt | 3 |
|
||||
| B — Tool-calls in UI zichtbaar | 2 |
|
||||
| C — TOOLS.md aanwezig met 3 secties | 3 |
|
||||
| Chat werkt end-to-end (geen broken pages) | 1 |
|
||||
| Documentatie sectie 3 (edge case) is concreet | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee bij twijfelgevallen.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — Write-tool (tabel + tool + test) | 40 min |
|
||||
| B — UI tool-call rendering | 30 min |
|
||||
| C — TOOLS.md schrijven | 20 min |
|
||||
| **Totaal** | **~1,5 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
- **Write-tool zonder permissies** — werkt voor demo, maar in productie crash je als anon-user iets probeert te schrijven. Voor nu: RLS-policies open zetten.
|
||||
- **Confusion AI tussen read en write** — sterke description: "alleen gebruiken als gebruiker expliciet vraagt om..."
|
||||
- **UI part-types niet ondersteund** — check welke versie AI SDK je hebt. In v6 zijn tool-parts genaamd `tool-<toolName>` (niet meer `tool-invocation`). Oudere versies hadden `toolInvocations` array.
|
||||
- **`details` collapsed in dev maar uitgeklapt in prod** — Tailwind / browser-default. Test in productie.
|
||||
- **`useChat` returnt parts undefined** — check je AI SDK versie, run `npm update ai`
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Schrijf TOOLS.md gaandeweg** — niet aan eind. Sla goede voorbeelden + screenshots op zodra ze werken.
|
||||
- **Test write-tool eerst los** — voordat je 'm via chat triggert, run de execute-functie handmatig. Sneller debuggen.
|
||||
- **UI parts is nieuw** — als je oude tutorial volgt, kan API anders zijn. Check `ai-sdk.dev/docs` voor de actuele syntax.
|
||||
- **Edge case 'goed' afhandelen** is subjectief — schrijf in TOOLS.md wat **jouw** definitie van 'goed' was.
|
||||
|
||||
Volgende les: Agents. Met je tools werkend gaan we dan zien hoe AI 30+ tool-calls in één request kan plannen. Tot dan!
|
||||
181
Les12-Tool-Calling/Les12-Huiswerk.pdf
Normal file
181
Les12-Tool-Calling/Les12-Huiswerk.pdf
Normal file
@@ -0,0 +1,181 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 7 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260520093344+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260520093344+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 12 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 6 /Kids [ 5 0 R 6 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2310
|
||||
>>
|
||||
stream
|
||||
Gatm<gN)%,&:N_Clq>?=C*U6N"P\]*1.`D1GiGT$P!3P+6W:3XRj&T1ZC8PsJers#AP$n/MaW04ffb8,&I^)eZK<s2"2#YlWt,UUL[d5U8L08@iVhF+i=R.#;#3G1U*AS^,%5qn]^^%3!tn!a@\@C*<%($^7[/cEE*O36/;-p=oh1>H#m2(q#t-L4:r.eWJcqU=BM&+ELc3k/qC/1G7"S/q<3uNj0V#s?;AN8'6CAql0V62Ge&BoppfQ>8.[_#?#%-Xn-aO^<S;6Urm.="PV,aXqn4qm,XB<\h?6@)[(</"eX9$$L5*:GOlhKYS1A:UT9WOF2monM]igVccJIR,nUJZB6J9RFdJrqbX"`D=TMgGK-RsQp_>;K4!n0T?L#3O%Cr.,L9h$9$dp4b:fW:)OKer9i$KId1_``Ua9ZGPs!iXHrA?73sW:kqN2Yf*<87Y[Pkr9haeU(_i/&,2JGV78eY*.:74?DGjccc;RaKG:*uEp"U>MYbJ8k>=1]$F:t3]=kK)m0IiJe-Z$0g^TWHI)ZkA`$F;*M_J90(c/pD.(oD,1'/+@kZGJ*Q[oNSLpKos!?nI6Bc%:b\FJA2O4Qal8J8*r&n35+6mBp8"pk+.9P]&[S"TWf^7<#f=tSbcXl;Cl3)]&-9IG(u2b4KC1g($8A2qEF].^^cnAD$Gj5(iU)f>602RW5D.3c,lOAbtbSl!eE$k["!%]%4-UTa4DmqYN+[>N=tN!0^1B'\+H1X`io$:6n4`5NqLkqpY'p/<tN_?Q8q1@G]&S3DeP3uq,'1tVVm*%G?Kak*78iP*BNF?\6:@CT"bTFkHI_$>*?3Sm3l8^,KN%"4euq#T!:`9bgWrj]VX=BlOWP2hIYo1Lj26CL8E'G%\<o7h3'UH[6sXtYcN`2K/UH*!Y-YUfMG:E3Usq+edHA.Wm_%PkQL%Ob=f!I(t&CK:_BR@Be"G7IU9lqmjHM7sft>_=nIo?O5Y`e1sTdG&-@WmQZ\ZA)fhkXUV#\)ek,X(!e8j$#pPcj9U;(=to:e+<>k?7]KeCI!40q+ZT4k9KK(co2J3=#LFN!3HWW@^EAkAT*0_R=1OB=]QD9pi:NZ(rBj1HekHT*i&Is9l0C!3F.seK`cD1\C0"b5oj<D)JLKT,[%]4]qVM*=HEVhrL8*Rpd2)aIkCeHMl;8[)4F"fcI?[p5^`"HnN+Lopm#uX^8]]>&o*L@;`F2#m3]34[*^[\)16c;\b?.=`<.aVp//nZ)55fSMD+f=mgm!q;nQi1qS@:_h2_#B:)OP4P'7)"YVn6p^Q";gf4@t"^^hU&T#Z%9\(`DlrS$W.@,F2Fka-Kr*1)Y?%\R,qMa#,>h^$Ga5+hLRo1\K9p=[fl+iaP^NluaRbj1$Jok6B!^,.F/g=o[:gcm)q+9QF70h&L:Fpo<QD!S$!K/'O?=f1?gpi+1?k9QW>_[GhfRXWp"@<N<H%i-+R[:YG!TH?^$_>ZLe(d;O!+gjSb8hh?an$Jufa_nA[0jriqU77,fCGm,9HM+N$M[6ciS=>iI,)#NVr&8pXfV-@abi1R@._\&^Bdg6]CO=hor'<i0\a(<cS^&rI@YJ&t.Jh[d"lAAY,-[*tKJii%CG-]/ki?feiC71>bOePrJA,C,?(>DRY:p+Ma-31FLT7%34)MF.=_P:Cfq?`(NJ4'aeX`+pF$:.]E1XmI.e.<)G'd0**k.''h4(g9oP6$kWg>5Q19\tsl+1+PiiBT+N&+X<])7!th\X:f.X2-A5[Fk:m^jE)aGsR:/',&aJKJ8(q(sl9ihS0]pJ%0re*4C;d$nDj7K+hP`;p\KdjUgJ.oOP%"7I_Q7=*V)e;(;U(38sGE_k^RelX<IH]qUfSPpPs>R1:r]Q44cQU`QRJqYpsk0;B0*4#4FWa?hg?6`SuT.fkb5!Lp,m?Q8U'\5WgIhE0p%efA[KMftjfIGSCG&Nga/`#*83=lNVc9oh/%Q+XMO?3mq<75_FUA?mf,;La)'Oq#sN;=f](l:C8UL?7+C1'OCb2&G:*@`pk9H=smZ:rMDNWXF8G3^.fGIb+n$](6GU"`4`HSgAfYW]k*2&/!bAujKgX1FE;-_p(lTKNk#Ue-E6C]ag%:;Qp7V)O3q`Yg>MF<6c5iD0D8fa[,"`,G"i_DTKogJ41<eGA&@0NCKSbd'Ac&e/[&hE%KJ4oaAQL.\;Q__DBmk1)LYWb,El)5Yg(0bcX^S7NV@P/L?5:\>c[6BG$2<iD;9>'0T]6PJa,NhIH=c0O5/]Zc?(eRUKsh?1mIouoF@Di-[UZ.*%cCJ8cs'3e+N>EFU&EKfEIem]6(rrCV(K,4~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 642
|
||||
>>
|
||||
stream
|
||||
GauHH9iKe#&A@sBm#=78+j%#8pI;&70U&3,\8]R/9fA#9gG64#[/[k'D*e:;VXtEm@i]C!O520<TPh"%4Y6[u-#e%LJ8n-B^]Y72N8Yre*Ws8*=!@\YC)Y)380%I2ROkB[P!m;PAtZ+I=pYb/&ZoOPh!J$t.;0:T-NO=D,ZS"Jd4CGa^=jYAagn/^,a'p3\UVu(]tB`R6(O4m:C$%]@"YEfC+'@!Obt81@<D,&rYhq5L@'(k9EF$fK@>O.$0MZ@Y><L!2K(1mi%qO?0Qi5;4bi#F,U]1!$X\Q!&Kt/dpg\,grN;H3]>iu7O8CfZngXgW"a''fqLg-iTN$W8k_KSt$t"),fD+I=CQjA>`+PFNCgGr*YKQ,RDmA">gnrj@7n-sGP'3IEJh@A:6fa^V4RL`1@8OL!)1,?VQsI-i';j8cOWbq]A"%7S5sP*oZX*'i*1(FDS,U2)5/=Yt*Rpt-Vn)-@A7TGV?tE"61G$f3GcqMbTg#!uB<HH(I;!YZVq+AlkACRt4C+@%8*c\J9f$jKWEo.S1j^Gdmb#t*q0hCOQf#&0V>jrT@^F96>bl'gHctmZrVi/>]0G^].\U`R=kf!VPSOFKHNmOUZ`^K^^[!N-(>X-/\AZr>]omHk%1)q7:2",c~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2054
|
||||
>>
|
||||
stream
|
||||
Gb"/'>>s9G'Roe[35\*u;S:kUUf@fJ,\]#*-OELedMG,9aGVXEM^-=Tb4+;8<]A2<&9M*Q&&jJRYP*A,IaKpDpgM.N*s'"k_sfMc`aO6!9G.ZlGOTA&qpm"Ik)Qg!#XsHjJ[iSp5s">N@+iJA\][68LF>ZE</`6e.c<k\36ZqQ_qEK["6.t_;%e[oQEBL*H9lAmOgQI#D7c;0Q$g`1k7YCi%`L"!&I`lf8uW"p84&a+=>5c.s5EbE>Bl@UFQUo5,_Ss=Wr72Q'@%VCrc[cnbCV:s;trdgkV-.-3mRJ.Hk,27L0"ai$B%G@STXjS3]$JVZOF-4SUN/1J+I8<m"&gNI70j]82dXsFf6M$5l/m!(@MR:N"XR)2FK]GOn,q`$[^YPPY0J0j/UfZ$$0q>?ErcYI]lnl)l=H.:sG/]qcjLICr+7WN<&X'Nrmo,@?9AG[X(I`+PLf)HZUQ&Ld)e$/)j$XQKEX7hCF3#HqN9,?^ot&U=U`lMN=(L>f)lO\H7Mq6<U>Xfo)/'#V404H#;Np"hmY)2SS7j@P.lQ)ZCQUD;]bLQpD;$Vmha8Z&W8@^X23h$o%T+qd#n(]<hm(Df(eR/&R#T\N5*iJ5IO58lu"i1[$JJrGq=kEEjc6Z)*LaPqUTGLIc'GLFId53E3V8]kcT'H+$.F`-&5tB)/+P!#&eG(c=Z\KD6Y\]f\jFN3)9O;_oS5@L5[C6-gsJ&'I@^/VCeDHE8WMQfa9RdW*DBe#\6dWMgtF/N%[l-<*m@]qXdTI]lnl80aEgB8>k35o^:(YQbol.Q93t1b/j>qI/C?;6';VY/'7YQO/)JdXeQ3Xlrj?XDW6:^&YWu9Y*=V?-]t%6C/D>@fl85G!(Vo`M>`_8!A?\)I[1E/pRm3ep5G*h\cuU_eF&j?hMssF[UiGc00%SmTROuLKDCo86(>iZW[_-lMuN7QKo#;KdN(b!<WVQTS+H`eO%1$d1Pa,qKor3G[4\eH<kQlKa(P?[$1u:7b8qMU>?k"n\OGP%U]\5K[[fOkoc(F:r1kK%#8e1s&<T4R<!A>@i>)?X;m\t%pl&%?TO9'h9>OMK0:G^DUACho(f)r=+I(1mU85<iQ:>qnU.D=3Rk@N&N"l+WY_All\<F\llhuR6S_Efc-eSM.qgr>6rI*hdDD>Z_%4411QigHS^%F@3Xh4qJOO!%^`&Fgj/JX<FbD\PMHEuf2V:9#=64aop>]n9,CB<,R,r#pcUQNooe=mqV+L^`f9>5*kc$CpB>N8$jhDpE/?tPsXL46t1-/NX?+"AR9Z7&;"!Fc[-X#R;3UeZ6+S8Dr]a%?)FBY-dZDd.pnAa0C8#WN4L_e%jkA1N.?t@G/o$XQNp=1"nT.Rt9o5_35[GHHddDUq0Oq+`5W?,mo[f[2>(tZ$:$O^OaQZbKAGSu5@^cMN6:08"^/Kq_"E+l'6^n;HO[^AaVXB>4;Jh+(]m"uDiiGM8(St]n9)lZl&b.C5^CemqX^.-gZ(+c@sVX49c(7VT]S9Eegb)#Ra#<.2c8!ka2-GJ`O#fjqo?=iD^Gn3Qt=$pE]n'0Q7[;1l@[tf9kqURCBDG6+n\[ee*YC]Hg\'Am9WtcGX`KD!tlZ&_l'cCrH,&;LY#aLX`^ZF5pAVjP`]DW%WS6L]cpC3&>gI7$%1D9-ZBC"L9NDUJj1m@d8kG@+q,k''3"UsA>S'JCO-Jra,"D:!mGj;GV4^?gqo-3rA]QBIU9Om+>Vfugnl/QKciq4FrI]p?c:*O-1/M_^[Du=jHC6,=Hhbrf(WYGrJ>VG"Z`=Y;:?7I2%e#?)b&YI_lpQ)esKpf3bS7;DGo^JeL%kmtMUPImiG:+Z)54#M)3OB*I]+,e4Y/>u-J"?l-B@+l1eWE-Lb19EV@"u0[1.[TS^W1U#PH#qaIG:9*16f2PkX>;nTQ*>=SFJd&02F:1o4D26d5KB>CTVf\_r@o>$fP?W4OHU[H2s:6XelCgquI&B<.!*QAK'WUZ@<tVd!sh1]JRps,Y:e>%]=rbhK085b-'#Y+$(D>kDQOBC[SP8*of.pQ`'('pq"?sA^,qc?eZbtY5~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 704
|
||||
>>
|
||||
stream
|
||||
Gb!;__/c#!&A@rkp/WPT'M/R%o]EU(fJ?*g9PJS<7=d=n_2o#Wl/u,um:(F*RH0HDJUn\HhnJjY/Hb96f%uSo^rI;;/f68PnI1d-!ocj4NsEE(KM/I9#UIbRLcCJEBF_9"TS-,S)=`N6VM]%55\NWoN\k]^V\E';&SB&c#"2%_ZiHh'.()19%uB<%[h^6R1>8p=o@"/^Lr_G8:U@YqR/_f@7;Ym(5q!+@#k7tN?bZN,^pqOk:?GPV^isEGR6-dVY$a(H*S#:=4&"ThY-=iG(0J-mLjqa=h"9mjM]a8hTP>;C''mQPnM79idGB)Wf-\[6"/6$Bj%V[EWk@LUUr!Y[J#;Vr:/j:jg5^l*LU*oR51?[qR5+B%;\0DiZO=L`C^3+n18T.Bp.5IIKT'WrlNQ,;"?>->B<\\]A<>pZquiNK^P8OfguTiVo`H$`QA*LX^%i2fUF0c#X`#W\<D'IBG$7,;.U0m;k#p9V4Fu5]9Wa7pH's44h.'I<oRP-0*[f(])=dH-7L8<Zmm$Q7#$VmGO6JSsS0o+p.6UH_KZ)Bm1*0.4X8F$+g"_f@(+DH3f/CB!G\)=+rH:R,gZq:,]ep9T]YgR?&^W]Xd!pN_Dm?gm2P'm>U5^[V9fSWT^2lK?0A%Nsqj[5`hH/huKTng8]i]S6SM:;!:K<fb7/,Pn`P5^s^Jbn\7D!8ENuST-!.!M/aT~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1544
|
||||
>>
|
||||
stream
|
||||
Gb!ksbBDVu&DcY&AuRlq'As7'FC?Okll7rZ@QE7h6^+__Pp-nTPg_8]m2b\-]h$8&N]35690*.BSVtOZ)%%-CC&'u^!M#/.j9#S.@!iL7+V'GaiV2m#a+,k)q2Pj$k*@#A+G"ANOA,p.\/t/A(/WqT:UokD_^XP,i4s<'mo(;`!V`c&'Su3\5'MNrl?_7?Qu<Y!0r/uKlZp>Cr&R#Z_@)0$7u(MR0*K0)Zkh2dg((BdlanjB_=7'HpBrr^'9Pcq8gSGO1D)c`\7Nm>du2UfT$(As(uC1)8CW#6;Z-sB;]*8->YFjPm1RIJr)NYfOdl#*04b&1ZG.mE0B#<r+h101?ZAp^,`gK<qTL2>[2dt\r\\$i_ZJ-qT7"Fc.i3r*]/DcMBp$=#:sHCIW=[2=WtkKm!8=:?SE0aPo&kn54;V%D/m*HMRe[p8Sen`^``uqQ1#GmdmgRXA`2/^IQ>7/<JpDqoXF=^og_gjH17`qh@i9u(AL5fhXs)"X"r\#k8Gj0!Tg`TB*,PUpH5j#"lMX(!.3Ju(38[QD:o]hMM7cB$%[,&XK#";MX3[QNR*Bub1[F/02dF\Yr_B<a17O![kekZaG5[Jie7)rh+n&A,Jmo8=3]8m>+3LBEP=.3*$^(D$Tt'`g0ZhLDa5LI$hkK#E4B$GjA/`<A(>7i4s1/t^lW#"6Noj=XBnC5lM0-3o\J@9NTt#N0NMN@+7)u=V5f<6<IJbMdH#\U$3*1(jm;c\AAI<(<S36"_*_(S7j;gZ<gI%raR*tNTe`AZV`S2XVq4gV4h':9,MElM!J^.[*YG2=W$V//u%dP[OVD9e0OShV#)*4%WGSHgg]A=fm<cf&3c5sRM@CgAL_(fI-ZR=.iH"SJrP3qu)f4-4OpJC^%6DK<4T?P30&c6XMg+pc>@?TQj2@bW[aSm7HGXnh?"m3I",hiCQE*3tCC$qsgkOGI-ItVo<Z#?5Y(rt;fL'4@]Y[RLDR>'mT(NSFS!:7%_\B\,P4)ikRPq?mAV:^*[>1u(LW6<=IigmV+R4Mqjbt!s%S+0jC<3PB/p!aiNE3c.T33B365MHPQD]ll6W;:>aR2(IYiRC]tp2S/b<IN:u`AMP97rE/5joYOEfT"^@@CD)m`aN.@=AP4J=S(#kVnA3Oj<E#Mj?#>9Le]P)?W!I&PcLP[Y;e/c#iBejf2HB"1u9kVqg0%mV?=e(<n'EFoJ21mV,8R-=)6O:e1dFMeRjf+`d2S/<&KnD9>c^.I)<Y_J5FOZN=Ne/\[Ta?T;-&7[uUXN_II?^Z`!+t^*[gTD17F"@QU/s4D=TlcY3V$PA:&ACMofG\`(.m@V$MsIKT$JJZ<#,,11X'"Ph*JkXRYH(Wf&rn9g,#4./3"`:uqm^Z*jT3K6OOY:@0-^"0KgAaRP:Il19-4:&hTPD*KiG+JA>]\/(XCpg^49io"KQF.dFCMg@\jEdZnlZ"/iJsku0iRX0,G==a`Y<R?7amS8iNFgpOZ*)@4FKmVreC.PRa0X%T?i&?-U<!hQ-p7&n%Y`ed%>kD/Z@)(A"pKYZ!2g"L:B~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1057
|
||||
>>
|
||||
stream
|
||||
Gb!kq9lo&I&A@C2lm4HF;o&G?IeDhMc,<Pi:<&66nV%!F'J,,P#Z+.alam-G99JU/:,t_jO9!+Fqlor=,_4?FpoE*Kr0>1q>6,puHjN+p$2G>6S^5$o=:b^oN.?U2&$l7<(:k%KeZ=<MAoSSs!`GVo1m&nZ_9t/5+eZ=j&IJ(8bHmT:QGbmW-*U?:E>h'!%p9kTPZk@@NjI40M-oQ3ALsG*&^Bi9@.]2@2YDS=9#a;a7$)=prnmD9-+n"D1htRL212boWg/:M+@tGMEcGNH2ASr1K3PG3DM*ps$2HaFB.hdJ^'l556_=43Itbnmn(jY]%f>pTqnB"N;a>^5oRVnrLhTPX1ua[e6-CoF,DC\#51`=/:_m$44'+(mg42lVZ,do"RSZ/AcqTOY+3'(M"R7[41DdEjAV0b&%j79&T[R"?/D]RtObS6*_B"f6>=?Y^H==I,cVqXe5+OtqH\"oeBJ1We/.!MgOW`'pN$^i1cX>(:6'JSRg_XdCZPP+c64FuH]!&&om]!DL;9+Jik*dR.Y0Z!gd"DL/@MJFu]rtgmMcaoP[U6M>KG%]teE]EPf_Ac6'%0,MduRsCLC`b6G9TW:;m.c*XAdpKQ>h06Yhs+,)sP=W`$KsI2_DBW2Le!D\)j9a7PH+8VXjO&oEb6'D3fh%D*t$$3uESZ'r`V-G:q>M8NQ:k8>&HF]!M&"$-:.[k_e4Lm'i!g1`@rjEYLK0[lfIMTj#&R2fJ\H/ZfV[]D4@4FnbG0D5p.:htUUKor>5,Kpj([PQiN:/p2eFR\niTd$4cS0D#c=EDH<`h=mJ!K=tc*`$-@&fBXe9'h.-@L#9aF/E9l*7gR9>3b$35fm%!+&n=*%Y6"6"GNS'NFJDmM^0a3UI=67U2C2<k5pG#k2+bmRQQ?1mVEE7<NlJs(?p\rZLUoA"G9f8[-ZA=&jA'4>l`.hY5&D)<g7)[+*BU(D$WF32M;.X-<?j+1appu01#EmRHp*%mD<o?:.5dPYiI])#0_gtmi@#U`ru\D]h_f\+LCj#XIV#41R[31<U$d=HBP6ZL_nbVg$HrD\~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 21
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000122 00000 n
|
||||
0000000229 00000 n
|
||||
0000000341 00000 n
|
||||
0000000446 00000 n
|
||||
0000000651 00000 n
|
||||
0000000856 00000 n
|
||||
0000000939 00000 n
|
||||
0000001144 00000 n
|
||||
0000001349 00000 n
|
||||
0000001555 00000 n
|
||||
0000001761 00000 n
|
||||
0000001831 00000 n
|
||||
0000002124 00000 n
|
||||
0000002216 00000 n
|
||||
0000004618 00000 n
|
||||
0000005351 00000 n
|
||||
0000007497 00000 n
|
||||
0000008292 00000 n
|
||||
0000009928 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<f3027a8d2312895e62b8ae303848b605><f3027a8d2312895e62b8ae303848b605>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 21
|
||||
>>
|
||||
startxref
|
||||
11077
|
||||
%%EOF
|
||||
228
Les12-Tool-Calling/Les12-Lesopdracht.md
Normal file
228
Les12-Tool-Calling/Les12-Lesopdracht.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Les 12 — Lesopdracht
|
||||
## Refactor jouw thema-app naar Tool Calling
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Wanneer:** Thuis, vóór volgende les
|
||||
**Inleveren:** GitHub URL + screenshots van werkende multi-step chat
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouw voort op je **eigen thema-app** uit Les 11. Refactor de chat-route — weg met de hele dataset meesturen. Vervang door **tools** die AI zelf kan aanroepen.
|
||||
|
||||
Je oefent:
|
||||
- Tools definiëren met description + inputSchema + execute
|
||||
- `stopWhen: stepCountIs(N)` voor multi-step queries
|
||||
- System prompts voor tool-gebruik aansturen
|
||||
- Multi-step vragen testen
|
||||
|
||||
---
|
||||
|
||||
## Vereisten
|
||||
|
||||
- Werkende app uit Les 11 (Next.js + Supabase + chat met data)
|
||||
- Eigen thema (geen Polderfest namaken)
|
||||
- 100+ records in je Supabase
|
||||
- OpenAI key in `.env.local`
|
||||
|
||||
> Heb je geen werkende Les 11 app? Eerst die afmaken. Deze opdracht bouwt erop voort.
|
||||
|
||||
---
|
||||
|
||||
## Wat moet er staan?
|
||||
|
||||
### Code
|
||||
|
||||
- [ ] **Chat-route gerefactord** — geen `.select("*")` aan begin van POST meer
|
||||
- [ ] **Minstens 3 tools** gedefinieerd voor jouw dataset
|
||||
- [ ] Eén tool met **enum parameters** (vaste keuze)
|
||||
- [ ] Eén tool met **optional filters** (`.optional()` parameters)
|
||||
- [ ] `stopWhen: stepCountIs(5)` in `streamText`
|
||||
- [ ] System prompt aangepast: "gebruik tools, verzin niet"
|
||||
|
||||
### Werking
|
||||
|
||||
- [ ] Chat werkt nog (geen 500-errors)
|
||||
- [ ] Minstens 3 vragen die elk **1 tool** triggeren
|
||||
- [ ] Minstens 1 vraag die **2+ tools** triggert (multi-step)
|
||||
- [ ] AI verzint niet meer — tool-results worden gebruikt
|
||||
|
||||
---
|
||||
|
||||
## Tools voor jouw thema — voorbeelden
|
||||
|
||||
### Restaurant-aggregator
|
||||
|
||||
| Tool | Parameters | Wat |
|
||||
|------|-----------|-----|
|
||||
| `searchRestaurants` | cuisine?, price_range?, neighborhood? | Filter restaurants |
|
||||
| `getRestaurantById` | id | Detail + menu |
|
||||
| `getCuisineStats` | (geen) | Verdeling per cuisine |
|
||||
|
||||
### Scriptie-archief
|
||||
|
||||
| Tool | Parameters | Wat |
|
||||
|------|-----------|-----|
|
||||
| `searchTheses` | year?, supervisor?, keyword? | Filter scripties |
|
||||
| `getThesisAbstract` | id | Volledige samenvatting |
|
||||
| `getYearStats` | (geen) | Telling per jaar |
|
||||
|
||||
### Museum-collectie
|
||||
|
||||
| Tool | Parameters | Wat |
|
||||
|------|-----------|-----|
|
||||
| `searchArtworks` | artist?, period?, medium? | Filter kunstwerken |
|
||||
| `getArtworkDetails` | id | Detail + provenance |
|
||||
| `getPeriodStats` | (geen) | Verdeling per stroming |
|
||||
|
||||
**Voor jouw thema — bedenk:**
|
||||
- Wat filtert iemand vaak? → `searchX` met filter-parameters
|
||||
- Wat is een unieke entiteit? → `getXById` of `getXByName`
|
||||
- Welke aggregaties zijn interessant? → `getStats`
|
||||
|
||||
---
|
||||
|
||||
## Stappenplan
|
||||
|
||||
### Stap 1 — Backup huidige chat-route (2 min)
|
||||
|
||||
```bash
|
||||
cp app/api/chat/route.ts app/api/chat/route.les11.ts.bak
|
||||
```
|
||||
|
||||
Voor het geval de refactor faalt.
|
||||
|
||||
### Stap 2 — Refactor route.ts (20 min)
|
||||
|
||||
Open `app/api/chat/route.ts`. Verwijder:
|
||||
- `const { data: bands } = await supabase...select("*")` aan begin
|
||||
- De grote `context` string
|
||||
- De grote system prompt met alle data
|
||||
|
||||
Voeg toe:
|
||||
- `import { tool } from "ai"` en `import { z } from "zod"`
|
||||
- Tool-definities boven je POST-functie
|
||||
- `tools: { ... }` en `stopWhen: stepCountIs(5)` in `streamText` (importeer `stepCountIs` uit `"ai"`)
|
||||
- Kortere system prompt — alleen rol + tool-tips
|
||||
|
||||
### Stap 3 — Eerste tool: searchX (15 min)
|
||||
|
||||
Hier het patroon — pas aan voor jouw thema:
|
||||
|
||||
```typescript
|
||||
const searchItems = tool({
|
||||
description:
|
||||
"Zoek items in [thema-naam]. Filter op X, Y, of Z. " +
|
||||
"Gebruik dit voor filtervragen.",
|
||||
inputSchema: z.object({
|
||||
category: z.enum(["A", "B", "C"]).optional(),
|
||||
minRating: z.number().min(1).max(5).optional(),
|
||||
keyword: z.string().optional().describe("Zoekterm in titel of beschrijving"),
|
||||
}),
|
||||
execute: async ({ category, minRating, keyword }) => {
|
||||
let q = supabase.from("items").select("*");
|
||||
if (category) q = q.eq("category", category);
|
||||
if (minRating) q = q.gte("rating", minRating);
|
||||
if (keyword) q = q.ilike("title", `%${keyword}%`);
|
||||
const { data, error } = await q.limit(20);
|
||||
if (error) return { error: error.message };
|
||||
return { count: data.length, items: data };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Stap 4 — Twee extra tools (15 min)
|
||||
|
||||
Voeg minstens 2 meer toe. Inspiratie boven.
|
||||
|
||||
### Stap 5 — System prompt aanpassen (5 min)
|
||||
|
||||
```typescript
|
||||
const system = `Je bent een assistent voor [thema-naam].
|
||||
Gebruik de beschikbare tools om vragen te beantwoorden.
|
||||
|
||||
Tips:
|
||||
- Voor "welke X met Y?" → searchItems
|
||||
- Voor "vertel me over X" → getItemById
|
||||
- Voor "hoeveel" of "verdeling" → getStats
|
||||
|
||||
Verzin nooit data. Antwoord in het Nederlands.`;
|
||||
```
|
||||
|
||||
### Stap 6 — Test multi-step (15 min)
|
||||
|
||||
In je chat, probeer:
|
||||
|
||||
1. **Single tool** (`searchItems` triggert):
|
||||
```
|
||||
Welke [items] hebben [filter]?
|
||||
```
|
||||
|
||||
2. **Single tool** (`getItemById` triggert):
|
||||
```
|
||||
Vertel me alles over [specifieke item].
|
||||
```
|
||||
|
||||
3. **Single tool** (`getStats` triggert):
|
||||
```
|
||||
Hoeveel [items] in totaal? En per [groep]?
|
||||
```
|
||||
|
||||
4. **Multi-step** (2+ tools):
|
||||
```
|
||||
Geef me 3 [items] in categorie X, en vergelijk ze qua [eigenschap].
|
||||
```
|
||||
|
||||
### Stap 7 — Screenshots maken (8 min)
|
||||
|
||||
Voor je inleveren bij Brightspace:
|
||||
- 1 screenshot per single-tool vraag (3 stuks)
|
||||
- 1 screenshot van multi-step vraag (waar je 2 tool-calls ziet — als je UI ze toont)
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub URL** in Brightspace
|
||||
2. **4 screenshots** in je README (3× single + 1× multi-step)
|
||||
3. **Eén alinea** in README: welke tools heb je gedefinieerd? Waarom deze?
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| AI roept geen tools aan | Description verbeteren — wees specifieker |
|
||||
| AI gebruikt 1 tool meerdere keren | System prompt — zeg "kies juiste tool" |
|
||||
| AI returnt foutmelding "Tool args invalid" | Zod schema te strict — gebruik `.optional()` waar nodig |
|
||||
| `Cannot find module 'zod'` | `npm install zod` |
|
||||
| Tool execute crasht | Error throwen → niet doen, return `{ error: "..." }` |
|
||||
| Multi-step werkt niet | `stopWhen: stepCountIs(5)` toegevoegd? Default is 1 stap. |
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| Backup + refactor route | 22 min |
|
||||
| 3 tools definiëren | 30 min |
|
||||
| System prompt + multi-step | 20 min |
|
||||
| Testen + screenshots | 23 min |
|
||||
| **Totaal** | **~1,5 uur** |
|
||||
|
||||
Loop je vast? Vraag op Brightspace.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Begin klein** — eerst één tool werkend. Dan twee. Dan drie. Niet alles tegelijk.
|
||||
- **Schrijf descriptions zoals voor een collega** — "Doe X als gebruiker vraagt om Y."
|
||||
- **Test direct na elke tool** — werkt 't? Goed. Werkt 't niet? Beschrijving aanpassen.
|
||||
- **Gebruik enums** voor vaste keuzes — AI respecteert ze automatisch.
|
||||
|
||||
Succes! Volgende les zien we hoe ver dit kan met Agents.
|
||||
162
Les12-Tool-Calling/Les12-Lesopdracht.pdf
Normal file
162
Les12-Tool-Calling/Les12-Lesopdracht.pdf
Normal file
@@ -0,0 +1,162 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 8 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
9 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
|
||||
10 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
|
||||
11 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260520093344+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260520093344+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 12 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Count 5 /Kids [ 5 0 R 6 0 R 7 0 R 9 0 R 10 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1977
|
||||
>>
|
||||
stream
|
||||
Gb!#\=``%_&:W67kb<sO)qI>3UQZK$_NcTo=hp+8^J*^n/><MdSF#h4[qBgZ[^1N2G3hbgl0ba9YBrUO_ACUl@1)L5!`*,9+;?/D+LZ"%j;kn%(uGac]%"&,0UZjaJ:\Xmo`Jc9ADjtp)pj+cDgA5KfjO]qo_lQ1MYfMsGYea7T,@c*ca>mm#1Fr[lan,5RH[V,Wbp4)h-bY\A"TsK$V\%7%aD*4-k'1=##lt8fqq1Xc[eXpUHdXc\U_/7,cYgGk6ZO%V$IkMVNcDrr*oT:Wo?X.)8$YB,0>aBAF_[HI"f\1dP[,);qn+!mB+N\-S"4Br5sq#aZIn"nL2[BOFaJDkgrk&9,hAH7SGIS(rg'hdg[,fV2"@t[APTIn00Qcjf0\\#e3'nrO!$,d9R=#9ZqF3g=tM+:$.Q<3ba6Mor:biG:,3ONt\9cf%mTWBi_Dg1b:I._9Z_Ao;1ka*Z>Cg6Z!InV[Th/9GaQ>a%(('+*Pe',kLpWG3YK]D`N9-SHd?=:0Dd2@IGg0m+i\_QY,p5r\J^DbV:,)I*r,77!(tMp!T.oHI^R8j>H4A<P`'PdAC@W=c??U4@U35BVK'`_(^dijpk:7[1G/p&5<mUYNWb1-rEI,jH!cq6^a;12.,3l>_nW*Gu6iHGmP-aOlnhui[d,m-n*qNA:oRk>gSbLA(,>32oF()>G%R.Q8Y7V%+jpV:dVi!1"G_m?GQbC8<dU'+ds`J?hj<C'r-Ob"di*."inb4X0N6Le#GG:R"$Ipmu'TP1N`Y*.*$()K<g,[pHYG'SY06Y//+a?%-1OdCHs5)AR,0D2cJiooio:-SjXcl#tRIl&'pg)>\"-F^(f64JF8*R9e>0SYMf#mGOQHLYS8:^(:gO)L1bHUb%hG<>k$!?I98;$.oHV!,P@2Aer$hAJ7R4`fUNF8^;6;JRe1De_01Hcmi[0WgBH;[cjAJh[]F_lm9Hf,$X.o<AM0.:cL*PNCnPc:XHJl-rpBYIL^T/J-lTb?o27n]^fLP?@3>H1LbYfRU21m[Z:;'W-"sk:-?S>&"DV3(9.=To^D.?V6E3N`OU.=oaXbcXEg-o.aX-)9$"$)W0oo^sAo60FZ"U0I.qZ'%km>(^A.G,BAM+=(mdsdLRK_Q`G7Uk[%S7f=(#"GE+<aG<64JaNhd[%;]7-qF[-KDq[GOpFp6R1McltW\WOl&6g?sYS[$Qf&LU\/8P")1@qK'!^;NaAs-1J1i/>tcbD.a+^\3,DfG_l$uJH^*Wn>Kh%R,b3eZaQhpK/mM%(Nh)i\nb`7&\9Or]jV,nVI,gg/q`h8Q&li1Tc-WA@XC7N<CdA"^MKl8[IIZ`^-Ms;/qD9?_(/A`;J-?/V]>H\M)c7,"a%ZQ("S!?;.;9DWn:">SRfWCEc7e(aX*an:mO?m0r(?]-3BDqeA'JQ]@^FO>$6FQch!Pa>X"A\amSA'nKFR'3GEh5RM*s$BdA_.oopA2b=h^T0qS.;'k'_c`&=Yp-;p8^,UdI"d&`>[69-&L3.!@XD1eN9L/@IL10qH>C`hAfnRqWoeTIU)reNA3d&bqDB*R,T3>+,&O0][ccZEICj7c^ff]%j=?N'E>BQ]m0N)rs?OA#c.%;qqlB$1kq??1FL%($7U5LKITYcY'T#+HDac82'-4AHBZmDn'*O1*TH)_Kg![ZYoU\\O=D3ER!Yq(e-MeTBkg.;u<R$;V9sSVY&T-1jtY^:TB`TDh-XR':.n(/hD<,RRl"Vsl)I=623OY"K`F*hrRmW3N;_UZ7gh$-9e;.Fn7G:[20]Pks"XSbgk95sHa:9D'`^4Hbff&PP^+3B<B_(1io#<KjJu%ca(8YNn%SDG%(W,&2]CaYiM61aL7(;B7gq`OP'53/PMo,7Ti#Y,eZ..5AR$16M*/&^oO_rpiI0a0h@&Uui4O(=hH1JikYSKg7/u`Mj5thN6%iB7E]!mHbK:Gl'Had6Zt$(O[:+NE%p@KLf\o#mePcde0dE~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 874
|
||||
>>
|
||||
stream
|
||||
Gb"/$;,>q#&:X)O\AuEd'0D6A4<YPO@mV,eN+inXVDY>URWOPtins3pVJXIZ3c.62=d%?no\K?7GB[CB7!R2CIkV'db[Bn,!bIJL!9Jl$Fp`/nhY/("Jk;O6M,b7f$qM:l$XDO=+V@,</>]!`8X/;O'dZJK$5gcS8L6=u2nM3[7#FMb%Re]4..L5`9E\k82(+4ll/VQ)T*k3g,$K.\F7rus7*tX(Ml\W\&PZd;fbe%Cpiq&cn#G0mK2GV`nC]lZ(jWW#9Z"V;s.HN3mEH3&bag2_ak2i,#I7RTG/IXWr1%cD;D)&BT:.h)LJ'KB3k\(4VK5sV@%CPp:tKtJ_-4-"AjdpWa*ImAktL6[lfq%di*l)fj.KXU3YeH9EGfNpVbWSmYOgP;>l2[G&Em"okU#5e9Aukm8!&U>[JPn9$6AOM,2#!_8OEU<OWLWA&6E*-1B&PpBbjYnC(t)nZZK^kfPaL/12MX:25VQA/A`=7a><5UfQcHAolq1*#aNWm?Z`c$U'<Ge7NpS]/<NYSD6t7@Q=\jUl[i[e2ON4PQ=g.39Um,V[59c`>uqM?I^oD7oesjq/b#.nT`W%>-oZTHmdT1G\:n9/AiJZ1'O'.On_+cJ&o\&QS!^!m,-PX7^@6HL9XC*:N&'MFcI6$1fLt7ZVQaXg!JedBO2"nh;PdKS,Jqg]il:$n%LnrCm?%P>k<gq0[O]',E7#*P=r.`$_5*<M&R5]7;Rlf*WD-V+S=On?qOuTD\_6B^4AEE!s*'9Klk%jkeE.Rli;k1M&aJW!\3!nriT6PS\?F[pG(r9)ed'g7a3W[uDs2+#_dug^AnZ5f22Y\rC%#,9F1k"i`M5YY5@gVsmJs17ghQ5J_^;&N!u\C-MZ~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2087
|
||||
>>
|
||||
stream
|
||||
Gb"/(D/\/e&H;*)EA1G/BbQ5s.#H*n!H$G3_2@p1lakXNWlVQL`0M*o:7;C_mlOD_A`q<0JS+l-fSSZFq:tN.\`6Oq5k2h[hdcr\\.@??(bcHu&MQKRoO#D_Icmq&Gec]*"7[Z0+q>mhbR]ru:'Q/4Jquo*,RoS="UN5+P`Z*/o>6TC:b!=<mk>km5XQuN8EL.4\1leb!U$Cb&3gGpoCEdl8Rl'pJq\>\B)P1Q*5NDKEi>QPPkh!`r>G8,HMMFprlE-[8g\gIKthc0P)4nok92tAh0N%#Br0>kITK$SRMRcFkmckF6iXmJ[=g3C+?U:q.fKfU)?8YA08=')TO5[u1lK1S_Kq2jD864e0K,_iGTI;K*nNHdj.UOi-NXmqXM]Hah`BGt_a?biO@q>](S_+A@DJ3UIPW>83#UR,ad@X"U'Se.4udA>eRP*MN?CLhS,6oTiO>oeOua`NZm6[G=u!u,cH*t8[u@/K-_3iLo#eW1n8CNIr36A7Lg3be.;XqU/fFD6+g*2@Bu@rVi^-c7k4@1Nr4q;^KO;Y6SG4X%m]PJ4Ng#*@!L%di9ZIL'8Z]DDI'+1mH5pO22V;-PN$+2bF/NUSUSn:Da^&\:=Ctt?&RO3Gl\I6r;pi^a*?hDMfSWqJ#)!=%/k]_2-L!LXa"9jR$"4ZK^-VFR?]@t0KLhJgiBSff'i?oF)CuPIfigmM.i)&e458L_,O[p/"%+^LHpf1(Gi5_O/4fk^;/F9A\nsd>D*pX3Fd[?O.L`;ZaBj7u2HH1U^5k3;ZhQ/ZR"+9MI\TAPU@T!;:G@.[%FZ-f`eG+<5DAsI\n@_ON$>k?ds;Zc[e8LVni5GIWcD`@c*AP&h>&6u;FKp(c(Z40G<:.-+#?hR[TuJWb=q#uV@H]F?>1Z_a(Ir--!f."_-7(=nuU8Y$H#ud40TN6_[=]I+iME@WhGjtnQoHL6:drM.a`lTpO;jABO7mDRFAcPF0"V-Z2\+G\BJrj^r:*^7;l2;9XF83Mn6e`IFF@Ng\"MnfP&O&..^T8@AYm"e#BXX]qG/n3<NMiq8SY;:9.WZ60IM;=XAIqgTns4mOfE;Y)@ff9UegNNe1Ru/+V,aPr9ZQ)2p6nd#HMWe_%%.#7k2cC2hn;?erZ(q652T4+G1(F,h&boY]6VNRBB..5pgFq2rhXe]YH#XjK7(;"fO,PZn@I`4=U<TOF[MZPu<CdWtB/6tJH1.9epVkF(2/#!N"_gA7S2Tg+[MW]XXEluC9L+q/aRq=)1s`Kj8WlK2YL0TY.,`QnbX:)cIe4#_[c<Ja\[KYUP`ELBMW@h6ZYS+V<q.`[ENZh7>"b<4*cIMOZ0;;r"EEpQ9ioD(-)M42CbDe.,tjon.DaoDBN,a*cK-sAW)EZrj!<4LC&pLue3=l:RfAEc:fo;.Yt(6#]5meF$.bsN[MlXrS-pKH$X(Fr77SZAI4TQb_X,:82_3:U)[!\dSR;Po.Co5&'a4A(I'O]Y9[k^LV'!Mib?R__KeRSO'XV,OIKO;ID8'E3]5"<RnkiPG3V9VR$!0J33D[9(Z&K:8)N$[PdlA!N2Ejr1<T+FYRT;q%h8laM`41jgVFhS*jifD(1+_Qqr7Pf-aboCBh-W,+X:oKIF?FZ6pnnb5WLdpRgherMQ*@'Ho]7%Y\m_Ub3Do5A)t]srV`oK?1m(h.5GO-uL4MqoXsV*uN#,\u"NTMCO=EbKRES\N]p)apdP;&D-5T<4a$E*ilb@tOQO&j5UM5[9jj$'r2%kL[u0#Fg^H7KGXL%G&LUqLf$$lcgk"!"L_.gaUNjPB_\cCrK?.lC%(lld6pfF17tZ&o*)S2eJh97aiMXLBuL&]:7.W=G)\-b4>f)-7el:),A"M/(@C!@<]gtCD?sa&[kB:-s4"3_5GcS^aRR/Sgq\YI.*EN`L*uYT@YR_6XghIg3d$a?Z1NG92+C>I!nP_kq(BIRE&KEiKM%bjf8+l&</?.SsL>[Z%APC6>!6tYZAQm)JIGATuVG:mGJW+R-n)Ba])h]4kZop>qK9=b]+FSs);X,APJaI5=mtAouNt609S'tV#RbK][NHKI-7**J#j@886s5Y1U!tH#GL`2=4]2=~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1546
|
||||
>>
|
||||
stream
|
||||
Gb"/%=`<%a&:WfG(uuu`>-I\]/mT0GK>rn/,]++C9j/8;/Sub&4;;ff,Q79USt]ag5&08[Q=*Us]AME$%KhS1<rF*f!7-F>E5I-F(k)N5M1jh<GVSj)G_Gs!9*I^#.KU)*&EP"p;_J=9'0kCU"%+q#>(SSi(_[bEGYW%O+h-t*RE'hkT5#U%(oD]@?TBcqcT/"k7/;;9H+pQ0Pi`.fDlc;^lS!kK%b<ViTaF@>;d.!kJ\dg_cVEG12RUMKQ=VfKmW(C\3>cb=iV7Np\1")gfa7ReQT!,)M+ueI$pVZ!=fJHW0=`>jOEqrIPqC/Ri?MQ#o!!cG/Y#:E#dT6Y(?=,#q7JF_$_J8)-0Cs044N/1f-n^<=d:L@kGjK[i,t9Ym:cUhdTT2Cc,!t7RG"Q6$(Rm3,0E7h=ZF`n-s<n<>&4%d&g=2+Xk?\UT"n;C+arTBic.EPf1m*.>SB`+l,gGLG"KQXche@*mMUAB$t#B1XG#k,!U^=0FP)2qHTD74_&*Vthd_`+.U+LQ%Z#:3`ZADg&`\qtaQ@I)7aL\22tO%!00aU!%Sh86*)$t,[40a7-5e,b0J7l!Xt\!t7AP6t+fl1Fk-Kj3=82f^aVB3FM.-L/dMi+sY?3+X/Fk>p3;<uR/[.nRS3djbUV3.3<%1t\>ZCs&Ll[PO4mVC3WWJT=n61>$8Zcc+k[^qG%4r*n6lg@DKM&9A'%?7dH_$+(lFE.\K'HkIO\,pn_&4T2g=09P8J#Y0UFZgj9Q-?%_!"Bl=W:u7"'sU6S6pQJHG-aF"9_`ik]N^6OFT-I"\0;9^8a5C0[?o?Tr@SW,CKCmHPm\.XA'k>W=+2-ZRgmHgrq,(hrK+WC=*-7('^X*s4O",(XX3L,q9O<1d;M5Go[N/!EfKI4@CO4El$ikFdK%=ZHa]HBY_7d-*^oV_tIA"``X4t.m&L5U,^qfW\c.j=g``$c:X.i>hXF?iVh.;W"'o>aRoND7U5c6dRNR.:tRHDcV?4!`GX4(&RT4XVKllsB4I`B5\A:"rOjbL>$%_Rrq1B=gB&OS(7_Rt0LM_H2SQBWG:mtr36neHb56F--JN0IgR6=dM,HmN/93EN>tEn`W>e+ubW9uY-T7%'FAe3]5MX=BjmC\Sc^f!p@EEPgFZ^&fiRYWE)]PU44^GEuDB$&Pk1t^?Sk&&YXa+,(bj2@lldiB/f<_9t^sQo.$<Mk]5^T1kqN''=>dniS&?S/!]ENV00\SUAVgm2jKsg=+_pQr8<[$opLu\CKNm#I^][qUtj(B;o]utQt+KgW,\BZ2<orA$B^L,P[Jgko3$cD`F?DOg@#jfk9K1%u]+p.KH_!?p27Jh<o/iNYuQN]ES1.19l1s9l(rpW'YcDNR\50r3270nBLFoC^9WECFM$E!eER_S+6lp(3j4O.rh.V>)!+:73j#E8rMFosO4^+G.&hceRl*i1UbA8]k#gfm,<Phk<Piq+B4Gf6uGC*OLT@TY*C?$D/0K;)"*`CNG:CAT<XQ,@;q`LI2^gr6qYaN=]mIm[<;];Nh+KqtKIg<hKg"Q$m''a6/~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1881
|
||||
>>
|
||||
stream
|
||||
Gatm<>?BQ=&:Vs/R$SlB(0k(0DggS09.>46DqaXIac!Al@UdN`XFsmQm/H]!fs9Xk>Z_c7$#VTWHlA!YRDAnC^Rb3Yc2g[1nD]E0%Y<"B37OZVH3\)X_9[[$HkK6d/2srlLk$5Nq?u@p+MdS#59c3lTcI6D5_4ZM'o#Qk"RUfV#s`O@H8]`X$@Fo@.9N;(q4W;YhR._tco&3nhiH@U%DP`gM_*bi6^l71K&I'WCoTjFWF\.ti5$gsiJ3KO0Ap\%iDhulM@@3JalB@P0d-Rf>TB;6<E^iC^h^[B=]+<dW2D>\L=%gEX+sIW[7E]<<W%'Rddb5aU.ciuTKf\Q.N!CnXNqa8(<e.\Cd<@uoPBEJYrh;8-8tYQpPUsI\N2XE5.KR*aD_fUM\W.<[p=r?0C66Wl]2u4F[F`'6BEf?>5Jl0qqmdhNbO.!0QjJ!'WGrDgNpF=f75D</Rj1i"GZ*=H98lpDdVAO/sb6067UnmVN\!Z8f%$#$o!Y_AWu0rRj8O!,_[_6S;V,cW+sdF^RUk-IX_R^Tf[EP)llq31(MXHW?:_WVU?\_qf;UV7i39n$LU'On?=%*NEsjB^p6pY[N:*gE5T""#DJMY3eSHlA<B6rDu&oD5+gGOFdEcLl-V-HGL!N`;92mk&sI8_AT=..r'$I4dX,_^cuslJeDhcqhFb'PDV%^rBoNjANHSb/]sT'X?KB1W!,t3simNgUg4N$Vm]sa4`K%$X?101iE'.%!r29CqGeU^D&3I8Sa:5`B+3UaaoUY<M?e<qDLK>,=0IIYZ$XP<Mi)O\N)&O%a8/U[E=Rp!+WjE=f<T0)k>oK0sFMkA"<@*^TX-Cr?g^K$5Cp.D*iKnSVc4p8mAcb!U1-n<qX]:mNS.*]p5>cNCSbm\H$KWk3^qhQj51aj]M/2a9D1k6R`qd;=i*`?p0KH+/n.qr(aVLq;Ke`QF:O14c>uaLoZ/K-b#?F]WN+M&.ME%ccn:DSC'bd<<E0Z:`nfjKs`#d\LH[-HD;<Gs4;CoukXg\&BX0#IT]B+P3G`K^Paf=Tk5,E=1%*t_#,cI[nDXdF\:==Uj%EHEY3s"_u!$^M3P(JTZ]KL!^"oJP:#5ifeh^86WG9mHW9k)giUcjAMb=WN2O!tku6!M)Rk!E?Qj.SD\MQ+E>I"<rm1%oBa%"1BurE$bPB`7i4s'6-d-Obii6:nB`FmE?j7-T@Xk6&n[Rna:]V+Tsl%)-V_#h2;GeG$<aDdHh8DSc?))5Hgb3PsQDA#@]ArCU-=LhGVkpPp/r=sl3YrZ9bK>Q\#W2_4kKgs%]a"nrTCT7i1p-rt5R")QF#ZqL'H4](@%/=s.l2D;<uPF0k)_V6@E]]T+h)f$FdnoTckGDbXRqN5_rWt[WCGH5J&%U)eX(VUKf<Fp[:CGGQC1%o.UP?%cG!X_>&ms_a`)b]P(GJH^<@23;**kZ+L5L,r-o=97YmcaWCF9`n3l40MtP*.o<e(oG-#2Om'K.!!*a%oM?],"'iXDr6GQ?r1Xg2Gn.I^N5qi+VMg+kS;d]\C';Lq7s>,Akpo71O3r/jU\m7*"1p[#MGi2Mh./h3ut7.E4:W5ISggk1-V[`\<)gcTho1@)IA)0,W:h+<HNXWl)N7T=tLtXTmR-`-(^khCt"8dFpkFe,tQVM!FiKSkhgi\X^q.qY6f6VDk];SKNI3qUB*p:JII94][:8$-kQUXAa=L\t3A!%O#8TDSp3:)QuHk21?8u((>,p*]u\HO`?LpI)6fo""`g./*I/6qr&/'P-KiF*0u%8=9I.+&!F:`"2UY"YM,Q\bPX)b(Het\"'5;/geSY\AU;&*<`jRK-*&LJX>SXOds5pfcOJD.e",EUcYp'A1k2OAK@CpB_@8$^)RiC[0_cG>dsZB~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 19
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000122 00000 n
|
||||
0000000229 00000 n
|
||||
0000000341 00000 n
|
||||
0000000446 00000 n
|
||||
0000000651 00000 n
|
||||
0000000856 00000 n
|
||||
0000001061 00000 n
|
||||
0000001138 00000 n
|
||||
0000001343 00000 n
|
||||
0000001549 00000 n
|
||||
0000001619 00000 n
|
||||
0000001915 00000 n
|
||||
0000002000 00000 n
|
||||
0000004069 00000 n
|
||||
0000005034 00000 n
|
||||
0000007213 00000 n
|
||||
0000008851 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<990b43d766b45755d5f398f80fe317c5><990b43d766b45755d5f398f80fe317c5>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 12 0 R
|
||||
/Root 11 0 R
|
||||
/Size 19
|
||||
>>
|
||||
startxref
|
||||
10824
|
||||
%%EOF
|
||||
537
Les12-Tool-Calling/Les12-Lesstof.md
Normal file
537
Les12-Tool-Calling/Les12-Lesstof.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# Les 12 — Tool Calling
|
||||
## Lesstof
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Onderwerp:** Tool Calling — AI besluit zelf welke functie aan te roepen
|
||||
**Demo:** Polderfest 2027 (vervolg op Les 11)
|
||||
|
||||
---
|
||||
|
||||
## Inhoudsopgave
|
||||
|
||||
1. [Het schaalprobleem dat we oplossen](#1-het-schaalprobleem)
|
||||
2. [Wat is Tool Calling?](#2-wat-is-tool-calling)
|
||||
3. [Anatomie van een tool](#3-anatomie-van-een-tool)
|
||||
4. [Multi-step met stopWhen](#4-multi-step-met-stopwhen)
|
||||
5. [Refactor: chat-route met tools](#5-refactor-chat-route)
|
||||
6. [Tool-invocations in de UI](#6-tool-invocations-in-de-ui)
|
||||
7. [Edge cases & error handling](#7-edge-cases--error-handling)
|
||||
8. [Tool Calling vs context-all — vergelijking](#8-tool-calling-vs-context-all)
|
||||
9. [Best practices](#9-best-practices)
|
||||
10. [Wat komt hierna? Agents teaser](#10-wat-komt-hierna)
|
||||
11. [Bronnen](#11-bronnen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Het schaalprobleem
|
||||
|
||||
In Les 11 stuurden we **alle 500 bands** mee als tekst-context bij elke chat-request:
|
||||
|
||||
```typescript
|
||||
const { data: bands } = await supabase.from("bands").select("*");
|
||||
const context = bands.map((b) => `- ${b.name} (${b.genre}, ...)`).join("\n");
|
||||
const system = `Hier zijn alle bands:\n${context}\nBeantwoord vragen...`;
|
||||
```
|
||||
|
||||
**Werkt voor 500 records. Werkt niet voor 50.000.**
|
||||
|
||||
| Records | Tokens per call | Cost (gpt-4o-mini) | Probleem |
|
||||
|---------|----------------|---------------------|----------|
|
||||
| 500 | ~30.000 | $0.005 | OK |
|
||||
| 5.000 | ~300.000 | $0.05 | Te traag, context-window grenst aan limit |
|
||||
| 50.000 | ~3 miljoen | n.v.t. | **Past niet in context** |
|
||||
|
||||
Daarnaast:
|
||||
- Context is een **snapshot** — als data verandert tijdens chat, weet AI 't niet
|
||||
- **Geen write-acties** mogelijk — alleen lezen
|
||||
- AI moet zelf "denken" om de juiste records te vinden in een lap tekst
|
||||
|
||||
Tool Calling lost dit op.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wat is Tool Calling?
|
||||
|
||||
Het idee: in plaats van **alle data** mee te sturen, geef je AI **tools** (functies). AI ziet je vraag, kiest welke tool relevant is, roept 'm aan met de juiste parameters, en gebruikt het resultaat om te antwoorden.
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
User: "Welke bands spelen vrijdag op de Main Stage?"
|
||||
↓
|
||||
AI: kiest tool → searchBands({ day: "Vrijdag", stage: "Main Stage" })
|
||||
↓
|
||||
Supabase: 12 bands terug
|
||||
↓
|
||||
AI: formuleert antwoord op basis van resultaat
|
||||
↓
|
||||
User: "Op vrijdag op de Main Stage spelen: ..."
|
||||
```
|
||||
|
||||
### Wat win je
|
||||
|
||||
- **Schaalbaar** — werkt voor 100 of 10 miljoen records
|
||||
- **Real-time** — tool draait elke keer opnieuw, geen snapshot
|
||||
- **Type-safe** — parameters via Zod schema, gevalideerd
|
||||
- **Multi-step** — meerdere tools combineren voor complexe vragen
|
||||
- **Write-acties** — AI kan ook iets in je database zetten (favorieten, votes, notes)
|
||||
|
||||
---
|
||||
|
||||
## 3. Anatomie van een tool
|
||||
|
||||
Drie verplichte delen.
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchBands = tool({
|
||||
description:
|
||||
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre, of tier.",
|
||||
inputSchema: z.object({
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
|
||||
stage: z.string().optional(),
|
||||
genre: z.string().optional(),
|
||||
tier: z.enum(["headliner", "mid", "opener"]).optional(),
|
||||
}),
|
||||
execute: async ({ day, stage, genre, tier }) => {
|
||||
let q = supabase.from("bands").select("*");
|
||||
if (day) q = q.eq("day", day);
|
||||
if (stage) q = q.eq("stage", stage);
|
||||
if (genre) q = q.eq("genre", genre);
|
||||
if (tier) q = q.eq("tier", tier);
|
||||
const { data, error } = await q.limit(20);
|
||||
if (error) return { error: error.message };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### `description`
|
||||
Wat doet deze tool en wanneer gebruik je 'm? **AI kiest tools op basis van descriptions.**
|
||||
|
||||
- ❌ Vaag: `"Doe iets met bands"`
|
||||
- ✅ Goed: `"Zoek bands op dag, stage, genre of tier. Gebruik dit voor filtervragen."`
|
||||
|
||||
Schrijf alsof je 't aan een collega uitlegt. Twee zinnen vaak genoeg.
|
||||
|
||||
### `inputSchema`
|
||||
Zod schema. Type-safe. AI weet welke parameters mogen. (Heette `parameters` in AI SDK v3 — sinds v5/v6 `inputSchema`.)
|
||||
|
||||
- `z.string()` — vrije tekst
|
||||
- `z.enum([...])` — vaste keuze (AI mag alleen een van deze)
|
||||
- `z.number().min(1).max(100)` — getal met bounds
|
||||
- `z.boolean()`
|
||||
- `.optional()` — parameter mag weg
|
||||
- `.describe("...")` — extra context voor AI
|
||||
|
||||
### `execute`
|
||||
Async functie. Returnt wat AI moet zien. **Belangrijk:** errors als data terug returnen, niet als exception throwen:
|
||||
|
||||
```typescript
|
||||
// ❌ Niet doen — AI ziet de error niet
|
||||
execute: async (...) => {
|
||||
const data = await supabase.from(...).select();
|
||||
if (data.error) throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
// ✅ Wel doen — AI kan dit zelf afhandelen
|
||||
execute: async (...) => {
|
||||
const { data, error } = await supabase.from(...).select();
|
||||
if (error) return { error: error.message };
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Multi-step met stopWhen
|
||||
|
||||
Met `stopWhen: stepCountIs(5)` geef je AI toestemming om tot 5 keer een tool aan te roepen voordat hij definitief antwoordt. (In AI SDK v3 heette dit `maxSteps: 5` — sinds v5/v6 gebruik je `stopWhen` met een conditie zoals `stepCountIs(N)`.)
|
||||
|
||||
```typescript
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
tools: { searchBands, getStats, getBandByName },
|
||||
stopWhen: stepCountIs(5),
|
||||
messages,
|
||||
});
|
||||
```
|
||||
|
||||
### Voorbeeld
|
||||
|
||||
User: *"Vergelijk de top headliner met de drukst geplande opener."*
|
||||
|
||||
```
|
||||
Stap 1: AI roept searchBands({ tier: "headliner" }) aan
|
||||
→ 50 bands terug, sorteer op popularity, top 1 gevonden
|
||||
Stap 2: AI roept searchBands({ tier: "opener" }) aan
|
||||
→ 100 bands terug, top 1 gevonden
|
||||
Stap 3: AI vergelijkt + antwoordt
|
||||
```
|
||||
|
||||
Drie stappen, één request. AI plant zelf de volgorde.
|
||||
|
||||
### Wanneer welk step-limit?
|
||||
|
||||
| Use case | `stepCountIs(N)` |
|
||||
|----------|------------------|
|
||||
| Simpele query ("welke bands op vrijdag?") | 1-2 |
|
||||
| Vergelijking ("X vs Y") | 3-5 |
|
||||
| Onderzoek ("vat alle X samen") | 5-10 |
|
||||
| Agentic ("plan mijn weekend") | 15-30+ (volgende les) |
|
||||
|
||||
**Default in AI SDK:** 1 stap. Dus expliciet `stopWhen` zetten als je multi-step wilt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Refactor: chat-route met tools
|
||||
|
||||
De volledige `app/api/chat/route.ts` na refactor:
|
||||
|
||||
```typescript
|
||||
import { streamText, tool } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
|
||||
const searchBands = tool({
|
||||
description: "Zoek bands op dag, stage, genre, of tier.",
|
||||
inputSchema: z.object({
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
|
||||
stage: z.string().optional(),
|
||||
genre: z.string().optional(),
|
||||
tier: z.enum(["headliner", "mid", "opener"]).optional(),
|
||||
}),
|
||||
execute: async ({ day, stage, genre, tier }) => {
|
||||
let q = supabase.from("bands").select("*");
|
||||
if (day) q = q.eq("day", day);
|
||||
if (stage) q = q.eq("stage", stage);
|
||||
if (genre) q = q.eq("genre", genre);
|
||||
if (tier) q = q.eq("tier", tier);
|
||||
const { data, error } = await q.limit(20);
|
||||
if (error) return { error: error.message };
|
||||
return { count: data.length, bands: data };
|
||||
},
|
||||
});
|
||||
|
||||
const getStats = tool({
|
||||
description: "Geef verdeling van bands per groep (genre, day, stage, tier).",
|
||||
inputSchema: z.object({
|
||||
groupBy: z.enum(["genre", "day", "stage", "tier"]),
|
||||
}),
|
||||
execute: async ({ groupBy }) => {
|
||||
const { data, error } = await supabase.from("bands").select(groupBy);
|
||||
if (error) return { error: error.message };
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of data) {
|
||||
const key = row[groupBy as keyof typeof row] as string;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return { total: data.length, counts };
|
||||
},
|
||||
});
|
||||
|
||||
const getBandByName = tool({
|
||||
description: "Haal alle details op van één band bij naam (inclusief bio).",
|
||||
inputSchema: z.object({ name: z.string() }),
|
||||
execute: async ({ name }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("bands").select("*").ilike("name", name).single();
|
||||
if (error) return { error: `Band '${name}' niet gevonden.` };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const system = `Je bent een festival-assistent voor Polderfest 2027.
|
||||
Gebruik de beschikbare tools om vragen te beantwoorden.
|
||||
Verzin nooit data. Antwoord beknopt en in het Nederlands.`;
|
||||
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system,
|
||||
messages,
|
||||
tools: { searchBands, getStats, getBandByName },
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
**Wat is veranderd t.o.v. Les 11:**
|
||||
- Geen `select("*")` op start meer
|
||||
- Geen grote context-string in system prompt
|
||||
- Tools-object toegevoegd
|
||||
- `stopWhen: stepCountIs(5)` voor multi-step
|
||||
- System prompt veel korter — alleen rol + regels
|
||||
|
||||
---
|
||||
|
||||
## 6. Tool-invocations in de UI
|
||||
|
||||
`useChat` returnt `messages` met **parts** — een array van delen per bericht. Tekst-parts én tool-invocation-parts.
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
return (
|
||||
<main>
|
||||
{messages.map((m) => (
|
||||
<div key={m.id}>
|
||||
<strong>{m.role}:</strong>
|
||||
{m.parts?.map((part, i) => {
|
||||
if (part.type === "text") {
|
||||
return <div key={i}>{part.text}</div>;
|
||||
}
|
||||
// In AI SDK v6 zijn tool-parts genaamd `tool-<toolName>`
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
const toolName = part.type.replace("tool-", "");
|
||||
return (
|
||||
<div key={i} className="bg-yellow-50 p-2 rounded">
|
||||
🔧 {toolName}({JSON.stringify(part.input)})
|
||||
{part.state === "output-available" && (
|
||||
<details>
|
||||
<summary>Toon resultaat</summary>
|
||||
<pre>{JSON.stringify(part.output, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
}}
|
||||
>
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Waarom tool-invocations tonen?
|
||||
|
||||
- **Debug** — zien wat AI aanroept met welke args
|
||||
- **Vertrouwen** — gebruiker ziet "ja, hij heeft echt iets opgezocht"
|
||||
- **Demo** — voor presentaties, hackathons, onboarding
|
||||
|
||||
In productie kun je dit optioneel verstoppen, voor jullie eindopdracht: **wel tonen**.
|
||||
|
||||
### Part states (v6)
|
||||
|
||||
| state | Wat |
|
||||
|-------|-----|
|
||||
| `"input-streaming"` | Tool args worden nog gestreamed |
|
||||
| `"input-available"` | Args compleet, tool draait |
|
||||
| `"output-available"` | Tool is gerund, resultaat beschikbaar |
|
||||
| `"output-error"` | Tool gaf een error |
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge cases & error handling
|
||||
|
||||
### Ongeldige input (enum-restrictie)
|
||||
|
||||
User: *"Welke bands spelen op Donderdag?"*
|
||||
AI ziet dat `day` enum is — `["Vrijdag", "Zaterdag", "Zondag"]`. Donderdag past niet. AI weigert tool aan te roepen en legt uit.
|
||||
|
||||
**Les:** gebruik enums voor restricted values. AI respecteert ze.
|
||||
|
||||
### Lege resultaten
|
||||
|
||||
User: *"Death metal bands?"*
|
||||
Tool returnt `{ count: 0, bands: [] }`. AI legt uit: "Geen death metal op Polderfest 2027."
|
||||
|
||||
**Les:** lege array is OK, AI handelt 't af.
|
||||
|
||||
### Database errors
|
||||
|
||||
```typescript
|
||||
execute: async (...) => {
|
||||
const { data, error } = await supabase.from(...).select();
|
||||
if (error) return { error: error.message };
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
AI ziet `{ error: "..." }`, communiceert dit netjes. **Niet** een exception throwen — dan crasht de hele chat.
|
||||
|
||||
### Write-tools en user-intent
|
||||
|
||||
Write-tools (insert/update/delete) zijn gevaarlijker — ze veranderen echt iets. Bescherm via:
|
||||
|
||||
1. **Descriptions**: "Alleen gebruiken als gebruiker expliciet vraagt om X toe te voegen"
|
||||
2. **Confirmation UI**: laat user nog een keer bevestigen voor de write definitief gaat
|
||||
3. **Permissions**: write-tool checked of user ingelogd is via auth.uid()
|
||||
|
||||
Voor demo open laten kan. Voor productie: streng.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tool Calling vs context-all
|
||||
|
||||
| Aspect | Les 11 (context-all) | Les 12 (tool calling) |
|
||||
|--------|---------------------|----------------------|
|
||||
| Tokens per call | ~30.000 (500 bands) | ~2.000 (tools + 1 result) |
|
||||
| Schaal | Tot ~1000 records | Tot duizenden makkelijk |
|
||||
| Live data | Snapshot bij chat-start | Actueel per call |
|
||||
| Write operaties | Niet mogelijk | Wel (addFavorite etc.) |
|
||||
| Multi-step | Beperkt — alleen reasoning | Native (`stopWhen` + `stepCountIs`) |
|
||||
| Cost | Hoger | Lager (kleinere context) |
|
||||
| Complexiteit | Lager | Iets hoger (tools definiëren) |
|
||||
|
||||
**Wanneer toch context-all?**
|
||||
- Hele kleine dataset (<100 records)
|
||||
- Snel prototype
|
||||
- Geen schaal nodig
|
||||
|
||||
**Voor productie: bijna altijd Tool Calling.**
|
||||
|
||||
---
|
||||
|
||||
## 9. Best practices
|
||||
|
||||
### Descriptions schrijven
|
||||
|
||||
```typescript
|
||||
// ❌
|
||||
description: "zoek dingen"
|
||||
|
||||
// ❌ Te vaag
|
||||
description: "zoek bands"
|
||||
|
||||
// ✅
|
||||
description:
|
||||
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre, " +
|
||||
"of tier. Gebruik dit voor filtervragen zoals 'welke X op vrijdag?'"
|
||||
```
|
||||
|
||||
### Parameter design
|
||||
|
||||
- Gebruik `enum` voor categorische waarden (niet string)
|
||||
- `.optional()` voor filter-parameters
|
||||
- `.describe()` op elke parameter — extra context
|
||||
- Houd parameter-sets klein (3-5 max)
|
||||
|
||||
### Returns
|
||||
|
||||
- Returnt **JSON-serializable** waarden (geen Date-objecten direct, gebruik ISO strings)
|
||||
- Errors als `{ error: "..." }` terug — niet throwen
|
||||
- Beperk grootte van responses (limit 20 records, niet 500)
|
||||
|
||||
### System prompts
|
||||
|
||||
```typescript
|
||||
const system = `Je bent een festival-assistent voor Polderfest 2027.
|
||||
Gebruik de beschikbare tools om vragen te beantwoorden.
|
||||
|
||||
Tips:
|
||||
- Voor "welke bands op X?" → searchBands
|
||||
- Voor "hoeveel" → getStats
|
||||
- Voor specifieke band → getBandByName
|
||||
|
||||
Verzin nooit data. Als tools een error returnen, leg dat uit aan de gebruiker.`;
|
||||
```
|
||||
|
||||
Schrijf het system prompt als handleiding voor AI — wanneer welke tool.
|
||||
|
||||
### Tools registeren
|
||||
|
||||
Houd tools in een aparte module als ze veel worden:
|
||||
|
||||
```typescript
|
||||
// lib/tools.ts
|
||||
export const searchBands = tool({ ... });
|
||||
export const getStats = tool({ ... });
|
||||
export const getBandByName = tool({ ... });
|
||||
|
||||
// app/api/chat/route.ts
|
||||
import * as tools from "@/lib/tools";
|
||||
|
||||
streamText({ ..., tools, stopWhen: stepCountIs(5) });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Wat komt hierna?
|
||||
|
||||
### Volgende les: Agents (Les 13)
|
||||
|
||||
Tool Calling met `stopWhen: stepCountIs(5)` is een eerste stap richting agents. Volgende les: **AI Agents** met `stepCountIs(20)` en hoger, of zelfs custom stop-condities.
|
||||
|
||||
```typescript
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
tools: { ... },
|
||||
stopWhen: stepCountIs(30),
|
||||
experimental_telemetry: { isEnabled: true },
|
||||
messages,
|
||||
});
|
||||
```
|
||||
|
||||
**Voorbeeld autonome workflow:**
|
||||
*"Plan mijn volledige Polderfest weekend op basis van mijn smaak."*
|
||||
|
||||
Agent doet:
|
||||
1. Vraag user smaakprofiel (Hip-Hop + Indie)
|
||||
2. searchBands per dag + genre
|
||||
3. Filtert op overlap-vermijding
|
||||
4. addFavorite per geselecteerde band
|
||||
5. listFavorites om finaal schema te tonen
|
||||
6. Wijst conflicten aan in tijdsloten
|
||||
7. Optimaliseert en herhaalt
|
||||
|
||||
30+ tool-calls in één user-request.
|
||||
|
||||
### Daarna in deze leerlijn
|
||||
|
||||
- **Les 14:** RAG + embeddings — semantic search op grote tekst-corpora
|
||||
- **Les 15-16:** Testing + Deployment + Performance
|
||||
- **Les 17-18:** Eindopdracht-werkdagen + Pitch
|
||||
|
||||
---
|
||||
|
||||
## 11. Bronnen
|
||||
|
||||
### Vercel AI SDK
|
||||
- Tools-documentatie: https://ai-sdk.dev/docs/foundations/tools
|
||||
- Agents + stopWhen: https://ai-sdk.dev/docs/foundations/agents
|
||||
- streamText reference: https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text
|
||||
- useChat reference: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
|
||||
|
||||
### Underlying providers
|
||||
- OpenAI Function Calling: https://platform.openai.com/docs/guides/function-calling
|
||||
- Anthropic Tool Use: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
|
||||
|
||||
### Zod
|
||||
- Docs: https://zod.dev
|
||||
- Schema reference: https://zod.dev/?id=primitives
|
||||
|
||||
### Supabase JS
|
||||
- Query builder: https://supabase.com/docs/reference/javascript/select
|
||||
- Filters: https://supabase.com/docs/reference/javascript/using-filters
|
||||
321
Les12-Tool-Calling/Les12-Lesstof.pdf
Normal file
321
Les12-Tool-Calling/Les12-Lesstof.pdf
Normal file
@@ -0,0 +1,321 @@
|
||||
%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 7 0 R /F5 10 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 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
|
||||
<<
|
||||
/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
|
||||
7 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
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
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
11 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
|
||||
12 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
|
||||
13 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
|
||||
14 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
|
||||
15 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
|
||||
16 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
|
||||
17 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
|
||||
18 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
|
||||
19 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
|
||||
20 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 22 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260520093344+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260520093344+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 12 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Count 13 /Kids [ 4 0 R 6 0 R 8 0 R 9 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 1891
|
||||
>>
|
||||
stream
|
||||
Gatm;?$FaW&:O;VR$ZW-?m?rm9BLq,(0GljQN[G\Z_oIZUEh1p'kAV%qs/H:48rpIe7@%h,aG.81KU0Y1M/oB`N%60#C%tWVlpsl%b!ElO*E@,#Ht#MGDG[<LRo$p("A4&ON=hZTGL?(IR&L@7goM0M$Q96"=$6$!]\nf+U^S/Qp5a2O>*?$n:1f,#9qg,G=fS1](Uq%"OV)`-9bkUMb)2_LLJbP"N)7]+k<<u*hgdgI@0/9p=K0p([smGA=5!6b2BVR10GO*j?/+eMog6IVPTD2GU^*W;sR[(jq1;%_=Y#*e\h,D:4*IgM#U59pAFE\1hp@7rZ*Z?+pQW9q1*+h!t'8dB](WY7_XfTio#AL!SZXq(]*eR;oini&P`H*9CMKTiUUk,5iT-Y``tiYT39q:>!Ul)7F7b`(=g$#BC1u&l2@,4NO/<cmEfP0/9*(mOl9rZ^\P0:0aF8GK=@7&+h3Hc3Mq[<-&o^h3$!52,,?085'J%\.]>!]mQ>6-[EdJVQ@$5W\[PXRLIO@<s-pV(o81e\?l^t_E1]R$/%ZG#YIY@<^.'2q/J_#2hGd-$K<`9rk].J+%$Asll=Y($\Pl]V^W0KC(5lT9<'%iFST#*K7jR*<7U`"NfO0iXNQgOh9')$`i*'_CN-nPL-C#._"W.PLaYj&u=-(L9;U8og?n:nNAW$fZ#b?`OLTlg8)d5]"Z#k1/^,r^uH$<d93j!)c<r@.7'G5'nA6cm)P(WRCH.m=X5Ulq#q\$*(%>+"<'k+],!ud*BL=d#Pf$GE@<uaZgrAp/,$kqT..UtWsU2l;Q+/$L8;,lCaEVV;HZ/BF?*7!O6b_=;2_u/CIQ)ieM-?D&4l#\G6b=@=9*LC2WAUmsFi,oa98etN7P3=K[RB>OG0AjX1IYi*f_n?=>gkqo0<$-H7(FXL:.^e#]K"'L=5<XDl"#!t^_C-N308O@;6qrD3GF-#oU)*71o?3E;edNn>b#!1j@N`e@[2hhTQU0R@g+H=sKtqoOYfguEka%%Y/JtL3emI,pi,p`t>4*B_7=t7,4)86ks0T2/'jtK>#^=X-:]3kcGS=2rf>g]I^W9lma,*2@&erY%jk.Us6)&\0BGq9Eo9e01h+pLu,Gkc_12DtGN&$kuFV[u&>BPr&k8Tn*;U:TOA5Qp$FmIR9D8q1PY:cEQ9,<9O7N+PVLuW<R'sa/dCpBTXW\F"!R:$s9]\bdKAp_^b6$%Z</$X=SKi6X33pWKZ4YL9>EpQO:9qnbpQ7H)PZ?V+X>p_U<RoEP;FfaV.)Q?A7a<*1j(jQM7qCU*2SegRelan&Id00GG1E4'P;*Q\ZdNZd=aC:@"NF$]43*NK"0%"L9P_ORLR<4#i?k(^[YND7a/g2faUbU+#%Xh.$@]P[E#$f#>M@+\F`s<>',IGA0i:Fo>]2u=mKHr0I"E9RS)Z;;K@TK%*U3)Z5BoXd^2BSpm*2@$e[ku"0MJ!UTQ>is),:B?L$p+^4GaqYjo>!\!ktSa(?q+i:-5$W)I(iZT>^WR(D)h;Y1W\IHe_BAoK@aj_;(U70G-BE(Earb?cKFD#6=lkYcc(0R?XVsO(d6S8[e*i'<(FHngh&P'?-B+,m^RN_5:0.BO,r5NF7iK0^*8uP*6?(i6]?V\0QP2f$BAV<ViVpnjILse2hmK>*2Pk]nZs.F;oV2#pdn$P@n?.M8:?`o3&+#i""Z)]d0c@67B]V46ekG3)Sm8.8#jR8#9%]S]4mg*/&Dn"G+SEdFp#sK:4F*@la=c?Dfs)&Zq_h_@K`eXojTTu@E\(7;UpC"5*.F.Q@<$Y-@-RJM+/%>;FOpE8!gX'U.Z/FW5)0'*K6"mbK"h/Q-'Oe;lP]M3LhaA+tLK#9M^(eg1cq6)*!Yh7p=Ssqjr?MSGrSqI6//~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1652
|
||||
>>
|
||||
stream
|
||||
GauHKhilMI&:X@\Z,?&BQpsWPCs+MSL-gHd9VF:[?h/`mVrXB>O'qjp(5DhU?-0CBb;gB]"%hosafF69=5U13$n-RmrL]>T5:tg0Es@T@@KfF:!K5S!o-3r@&0CF26pO(1_it6p6h>k@*E3Th,YiX-5gk>,7"CAX8=<*.\1U?T!f_rbrI=o?@[]gg7'\5,nW!2bM)e)TJjaUKm]Uj0Uk+kqfV(["r=>0e"ER=`Xe?l%77dW?&n\nmq==_qena)RkB1(G+'kYJo+%(V-h-2-rhOE&[aSmY9RkP\]e+BWh1NZN(AEfj*W8<D&g=pRqHrR>`"+M<=0HqXDq-nP'_!,+^L9e@759!O'.QQjdKK=(epmuK#"F=k(,;$SO%=cdkDFd;KMit;k.#M2K!6N_]/.hWP6HCFDjY(ZW:Ek9b,#\ph'))q4Fgia6\*(&&MSUT`eW%oU>AKXck/]t!tWg3p295ASd";.jcoT7>Q<WliJo4&9VeH(cB^1W\7@])L3c3)8\8PMB3tr1_n:KTd2uTk.H*5Y)b.%GH$`9c9AKWS9rVUVe>J#OjY7st.J6+(Y\O4`@oP4QQo7sD6%`;9?aGL?8HkE5%,1.?Z8pAK>\UDh/Uk<@VM[]-6n+Y+lE#,(#CSBV[0BX<OO[@/s6ef%hgd=31a_4;9AOb%YIQ@S"Ri\KZC+RK$`:\Rp8k]=`d0pq+1F!:8^`q85?@tS;R:>4o(=:U<7#0"7Lh1Vi9)<F(1WS5<Pt=PPiDuQj\b;r49@a"WlBT5F<]hl@lc*7UFjAt3Vl!2XD\"#c$2=lkn$`c3BBUnPbagh9T5&Y+m2#KE7`eR5/WfReAH`ji<qaPFes->ioO43W`G<`;jY\\*W,c[nfDLfTsG5Amc3NYFO_>sEK$[Kh9k)pQ,gR,;QB,)Fpa:[K_KPtqtZIUWmkA]+n*h0VTp^!Zs6YPD%5+E<;R3WkRoJ\J!oU,5387@/>K?/LT'n6B*[T-%ZJY&8!Ar?cW>2Gq37==^bLuJ<1No`Ru7b?eobJcMEjD/EpY6ZBMrk)C]%TUm@h&OfVO\L,Osr%pf#bKa+u!g8qa#&oYpC0GBJgs4K=a+>=F9UfuId#<5s18gC8S/Zat,kkQV.ejuD<gIA*\DZ:lMj"i<S!BoPM`cIU`f0'[_M=8DFDoL_5Z81I"gXF,B$9N*H&36#2m:AP^u3Is"Hifb&R2mh@"ifk+jCLj1bDE-<.^Dm6(`M/-4fWGJD6kSM3TQJTdjY;ilFJ5CDPZn+FbK%3A-H27MTZG=S5kc"_/[/[_$7Aa7"-e)9WHE@%MdF%?+%\t_GgVG`<NSpf]Gg_#=_Q7](&i(>*0cVAR*QbAi6\pM&i1Na]a..3=$sun@rC8(JDY4OR&jI8NDN1p9tco0^uK5B3*t1gos49bWMWSY[e&?W/qH15BP9V=p+!ng?G_Nc@'TTd$G9n@EY'KST+StAe(a_k4k3KOc+F4>rR?9PeQ-G>T%DeX2TiK.Q5GtCGur>^5h60+ip.6UIZYI`*KC8pMSon_s&G8,HEOP+f+Y82^0DEKk2J9`^S@:U`;:o0kQ^J/-XEh3;m#T>0r\HW/Tgi`Fg$^KV,s][bElWO?@,?3YoM%lb.^"b(Zqjm.5j85[>)PgEG*qQe_C&Z!eUFP?oO>D~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1531
|
||||
>>
|
||||
stream
|
||||
GauHJhf%7-&:W5<EQH>Y,%4'#&uiSZmCHa-g21-O()ZVZaL&';5?s%?cjos1GGHc^Bl"UAMSsZ`S]L96ms'%%",\2_s5t-4M`EnF`IMrq07aER@/_'nR7Ekn_G`'U![Lf'c8^n:G(7lAU)K2ZU`%Jq-n:(:!eb#NWD!?5Gu?C(Xb#ToL]NVePtD$R/4iifDgkAXcI'$B8Xa6ucen7kNa>3'*BU(MIh<)ZYaq_C)@Jl7+qp>F0`183&*NFtZSGh#DOM3pgXbG$$Q#=+G^b,8]^(I#RK^>\2l:CLQ)X-qQs[]-Z1Bih(P7hRl.s=.D)rIt]QCSbp99&1<DdOf-TH$!V?9Z;G=3`W,`\kB[5])o.)s'$=JR-p.cLd!DhG0N\0R:G26h//)^PcR.A'e,@8%^G)r<9fR"GaC%*)oc"RMNK@fh4tO<_U#^5NoCA`HE%J8"G87s&9=i^4j!RiGs4PipFTni>eqrR*oUj`8BhDbAM34XNhA.,g'R(`;8P.T(Z:pgdY9lbiYnFb663r/C.Bpb!dnL3\+Ns.d#!F>.q'p9QBpU0^"[<[sn#%B8i2l2lGVQ20/+eOgi*UsjhJ(!Os=fT]Ro%W6@tkgI75>7HiP?k%4$gR@sQ<lQ]Lb$`qUkMtZL[$a@gh9J!iR)-aX\ZKpWR-rrGERVW9`koU.9rmsP'X$tcKj.J^UE:km25NSGTn6lFG7&1s2AUbA5U3h>#IrL!We6:K2-N\3_b?$m0AC>`D./F!>17=]MlNGLS-jq_D45WVC2t\jqr9-T,bE%XGmq*"Oe*h`:T<$JZFTqBa!@N-'hMcVN+,supC#TeW-pC1]-P$bRjo%Kp74"lb"*rG-B=tM?;j/oPuESZ`oL"$1$knaNgYY0[KP9?(^=r6XkO?n%"\YV`WoSk;Q7cR3eGc,QRcc-C7lg'h>C-pGe:G\A&s&el+WB)NC!Hl?f3q`aO^,,.T3;Me0n5@kr%[mjhBQ:H,.sHmXpI5rV]X)oqSt56:mfR[#&L!Cu*ZAE7'lfSkm"-U*pZa9K#E]Zk&g&Z[r;?KWY'rF,gO/t:n\1ChNKsAY`I-n&T:BB`,&+EC$;TNOMatfV#[F'*WZSDfNer]TmucLE2u6Era:>\p54D[tgXU:]*-9PFo08i%E#mrMCt`W.)UmEe4^:#jje?[YTuVP]5:;\NlMeQs`9e)E\<^4Y?rmYI)*u`.?g:u/Kei:7`l#&uR'-KcANeS]kDB@@HVDhj!L*'s8_i8<LAQ8cFWYST[J7IAlWq6U]"HP(]ta*IA(7VpE@\Q8<C8j%`[(Ua7=!E#[rOL3Ap9?,_AGIA"9r5j)1b#30$e?2RJd0U>Nu:!g;8I1D:b/tR)!iQ[^U]IYG&#iVHZ"Q80DC7E.JjRI:j\bT/Yo6e&qiE(lnuB;eYs,=ZH(5\h"U6Oi7q"S3EJ:_:NmGItJc?35a4lB#",`8Mk:6_Wi7^Wlq-VkPCS4#?:Yo7"9"\CX0:O>OIi;Vp0#DcqSQjGj+Jo)s5<[nASMA8,6BhJ`pHT/8`c~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2026
|
||||
>>
|
||||
stream
|
||||
Gb!#[>Ar7S'RnZ;3:KSr+D"+/7V1e<'OA<P`O95^#B]A9,;Y>m>oZF2dM2NG4/B$>0:LW>>O5H+7=OF:mp*e]ieNrZ0EHDYJ`G>j(l-3n0S_VAGm?Q0XD<`5O8FqF`"pi430:r,Iur!Y'O6]D!=p"Ql4`,<i!;f*#R#,%kPNJY>f$60`IR_)on<K90\6upg4#N9S>0bQ\=OL!hI#Rr[!DcCC@+jBlAK&[K%3>uW4#&AF+"iO$&jB4mDE0'-(CqZ]Z@Aqr[`+*?Bd/*gkX@fcVKHC.S;RN=6`8ofcj:-q$\U:0KIXgHMEN<M2UZ$ikJEGp)dUu1-;[IJ!]aTQ/M:RhP3jc+HP-3qG\$\VEUTj:5<%33_%b^kBN(jga1LTSVo)f'1s:mS)^5LjUgR$]KbF7CI5(m_Z:F0BrH2)^ib29/[ip(26?r)7#21.j4Ii9P][LN$Ue2JTg_$p@_?E?X>dV#oILNT$d3K]J#]8.I]^.jaQjF8e"_+tN"R`[[t,^*/R2_EbjM#0@?m<(?[MR>_%-fYa4H8X!V1C;s"p!PQ:)'tA-tr*:a5Fa\DROtjamt,W4o;H7n78"->$s,ZWa?uX'A:rq'(i\gBqOB<[h/`\P];XnJc_i2$@G(9u2X&Y8%WY!AVM!Y=qYAa7lWJDs(TQ&QE]8VV79m&W1&o1SS"c>$^.WEBg5R5db['fUV3u;Lr>.</Xohg)5S/DDVTH+aT2`#=^dXRYIQCJTeWUq`AT)iC4a>&=DLNRogMhJ2<4]Fp+KsX7A`E/@)pA08:J9!9\Wn^dpqi+#[c((<#p&^91l![fH0UC_2/m/Y!_:G-#0:Y?KO398aDi-1gWcV+bM*]J+(ra'T$=;+_fH(t+EFBj$+7Zm_\#4k[N2#shgl?bI%ZoJ'9GE+Xc9$^f'3&JV(3X!="41rftNh_J(i)''/Oq]9_ZdHD:@Me2]SGbGaq!r!s%]2W3!BGg=\'1/gM1\k0l=6VK.NrL/KaJ2DXEHF&6-qfiW_^s>j:X`r%1JX%q_<J9LI9hkDoUT$I0j>#9aui;Z)Ib?^#>$CIVn2EU2rJ+B%t(S0:S0u2(Df]hi[@ee\fciApmWk"^>7BQ$k&1n)oWnR2c,hpcYYL;G:Hj`BW3OHV]jt9lVi.(,H\Z!L)3(k!!r]GF$dnmJ68(UNNl>3(\168MWgFtN^ni4,IFnp]WBdD3lJX]0Z>SV]+1.$#!Kik@Z25#DHb+=;:gVW';Hd+dfY,,5TE9"h?"`?![.[VO*C&6IA6CBmCk%jB^_5W#ZRC.kV:RXhT?*E5iO2Kcq$n:<H;@(ftLQGd/]o'\LNqkMK6qZ8Tc0_2Hk?3n1XiZgVgQ]n$$ReXn5GA`;67`d"Z391i'7?ou!fO)iLld#Aql%*b[gi$@gh!Qo[%kA^o[RDnCG6lf_>)d5;`R-3V><aV&2'&q$RVP(e>/X\;ZIiA+JIq(fO59*GRB:Y8[";9!9bON(@a6H\M?-@KBC6j6h'W*tT`YAL(q\+_I[LX5g_;"M1G`GKD/gR"=?5;iYOl*%O=gKV>IDmuUfpU;W/IBUhubO*F*X-`]+jno5k?=aHf<>L[hcV4I$B\lmbng='W=6TV9<_^rr-a`TJN.$nh)&bS5gtUNmDec0*A=.G`UDRe49o]T[Ej2G#qe^>'s5,C.(G_>%67OXCj,a'fEqf1Ta(ts6Bi>:24tkFW6Co^%foX98a>ha()KQY>,#"$+;W]dJO4'M/]^o-r%4OF#Y6ufG9V.2RNNDNXbMJJqqQ8$&Cf:Mf%JFS)f@3etmKb2lHTG(E=aQWA[p<+h5Adf/(AO&;V3^jjLUFR83bo[;HWuEJSuLN;X@\XOa%R$)*=aLRB)ZU1_[`"]B.tR[)ojU]=XL-?D%;qAoaVD:"rkBp#qq>9I4D/Ci5!Y8QCbo8NQ+>DXC5J^c@%=t0pI0Zohlf\Rh`QWD"QSCdTq.Y(Re()/@]^nk/_E]2bB/bimcn?-@A^FbY_j-l5K6*Y0<k_ZfRT>\FVh'cf62!5=T8B7GqU$~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 845
|
||||
>>
|
||||
stream
|
||||
Gatm8gMYJ*&:NH>R)cRcamOJY77A]#'pYdcV#W/o1!\n_Na6If6EC)Ij%CAi5U-;LPg.M!S2iB<]E1&UUA7O7!3[_'4qf%a9]dH3.[XJVl%FIr.#kT+"%J;@?39u%r]m_r)/+ZB9/")#Mp<eK:7u[bJYY8@KnU_dr/_4MCUeKocj=QZq'A3k,:00eW/^>.j#_^mVWZ?Km"f9N,r"m$>u<b"UlYt$2,)RT\pXD6*8]b7:aX@J:Z6_%KOe6%<([NGkKcrHE`H.K#-YqROQP0\OAuN>q$`J)3nF@=.>aMWmG_K+ZL/edAX1SGfQZjpQ,TC$3*4VY%?Q\m;/N;2a?%b$U#<d,C086ZNNX;@W5C#@ffcF)ZF4UnDbHL=]FN_cH:86_`U2%32EhH5D1X`m_bC6`gGB6f-r1(#N+ni/#/,_`[[WKf[*B,@q%ep-F!Zc`aeZ<.\T[MtC:8U*[UdWnePA7,j0K#=1Vk&:2^8F\4F/HPTcp1@-[[/nGRTQmj"n@f43BV],Kpa)Y!N;u4Cjr7J\/o7+;@7N5XrBC:JT5$3qV"%L-bSUP+*p@6!XE1&t"[jEI%W*0B%?jMQk/RDsIBonuO)PE>?AG`4,9:K(A%brY1\"k=*,QJ?]Ms8?1ofHLPAS4t!=g<ZV0sL3ch:@oF^OXHl;($X'une&F.&`AP=6nKR<oiZf)c]/IdKe%RZ#O]HT"ORJgTN(9_7PCG+Oa,o_hq^'Nq7bt]bkoA^Y1<sL]_X)!>g1!cVr0tVs\+da9iVQ+)BAKdTr$SbE#]1i`)iD>V\Hp_[O\t,XE!/60_o!O+8_6L]R<Q^m=C&hYdHK0%PB"Qlr<-P'R7m~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1816
|
||||
>>
|
||||
stream
|
||||
Gb!#[=``=U&:WfGf^Xm],XQG_TKA]<dMtt/:"i&s3hmK\Jn<XuA<Kuraa`c2#=pk\PpS;X8sW%$(Z-iqrB:2i."5=jIusaoR).V!$lFcQ#r"prbauYG=`=ra*c`hI&7MMn306\@&!X+;#8NCUiXl8[2)/9d1lN7b);s.BCSmf4E,mI:5]fQ)O['=RTaAJK01a/EL6b5]@<Ao*Q_/9^73q6*__g+4"@E^NFipHd8LRM@_(:5\o,"U&_QUr$<f>G$8E!QSg;Rb8F3ZYuIlb8M<Cp3F,s8ORRhk>6k4K>1c<th=`>[].abdn,[dg!Cb)IKLWk(0(@K2-b8KL5Y_YFkcZj-q`Q`-d9#XL>n`Jjs@#TO[+A10gd!j5(@lYU@O25+!<Oc>jU9+n*h5.7EHB+]A-hCuj7KCYlFr?;Cd>Hl]!-dWeJQsbE9"q9Cpi?e.^e43XEFN>ZF#G5PYfQUiTo0mU:1c=C/mF:$AltWFIhN4,-4@W8h.,fo#!=Wt%;5Ta:3LUc$g0q^A*T;b_IRNt_poYfCKQXn\s8-A2-W;Rr2.X-"XGcb@;k*6VL/<Z>)D9$,nCIZHL7Si_WQPT(CB)Ek#/s,-KY*:6pO:O\4pI@t0?%L/5HX&]2*^@gH_^tLX?D+jMi<1tYiqZi8\Td#_oEZ0S6K3L[5gDpM;f@j/<kioUM3X@jYHP2R@GU:Yd>cLT%"(@`d.O>H.6A?"EofVdQ!s.q;`C/.#olOLqgCBBGc;h6[1]d=Wk+iji?-(5T8o+%OSAV+eb(aZ,Q?!Mnn'kL1JmcCnQ*D+BfVASEdm7eN*F6]EP!`#=f,<bigo)^uu?Qn\P]_TL5kfqk5sdm7psnLmkWG]4_e=Lbg)=(_J3ViD%iTMS,rdG:Tb$p_bTZZuJ"1+6r[_o56k\@?`*2$GdVtJ?2l,oG@odNB!>%%T_nA.Z5b,;fXQV\oTZn<$W13FAT-mWakf4Q&Wu+l%:s&<d1cKl^)a![s--ki8aDgZg_N:[W!J:Gn5?e48.TO/-iXXp&.>DO)]U$e#L0o7UP:J5OFX:jjqA1$5\&2C/>9%n40e:bl6Z\o'`-Wl1,c1aLB"r'!jAh0-IAR9jUi.4GLc*[TqKB3BK_K^c>Z;WK!6YP,![i5t'b=]KF5L>M4.Vo4OP%YpDEcT:ZgBVtL'T+`uf=/l[fF_HhlRat&,WSWhC8>F\%T#Hn]q63f2*`;5N0@Z/r2\i$^%[mi@=YK*_4OT8@-SG`OSfa!%Mmad8F6i$H)X\Wt!a2sjPcnBFoY<Ys0k;'l-WOuKlC$L^eKl%/JYWXFO.7;QY^:0SKaaR_AY=QZWGEU>qkiGotRSJa;PFJ/G2;B5gl[A=LWUr=N;0dI$kIK>S]XR-N,KA1haccMW[;+X/qHTM0(,m7;YT9VDUShAn6V9iVk7`Q]>Y*oc0A*BWf<,_*hhN`,GMmLe,At7^D&%<9p(Y!bZl*j2bclmRHd0Zei[.m'C[]!F+?o8&Met9([-=?lY!@182k=b1g"N;=/hV#:&a#9E2pJc]@k?M3R@Gkhe\/l&34mW)!83V*G'&&\Gk_H`m9qa/>M)pEp\k.uF"p\H"i+kn*\WUljeote47l6P6_%Trha3%oOF2PJ%Rj)0]e3.2GhcZbEAUh.-uPl7/31n9-MH6W9+98j2`F0_DRbS$[b;D)7:g3`kj=M5A(GSn4$t4PIS_/@OBGI<kBDQSK"ml[7)Abl,M>n\plrY=L7gV^Jnor#7iaUImWMFL4'_-6MuMAHG9)#Og63@4XN+ZgBG_(J]I*.3_`CL-$#Vggft-TH/!0QOg=8Os"TJIX@JL$~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2012
|
||||
>>
|
||||
stream
|
||||
GauHL>>s99'Roe[36P6.M3-eVjQpu#ThBU_V3F;[G@rZuN?a%kZ%K0S/I_Z-O7*R?R.^Qt`($',F4$oS*5d<dLZRFm'OYhb47="?%`:qI(9q7l#l@!Lef`c,_Qn`[P+"!.igSO2,SMC(gEDU#>m?H!,)$2dA40$k!loFM0EI#^b:S>I&IoQ(![='"8E\*@P;o)-BoCW(j$$dVqBn)n>iM")Tps"SK&I,=#$%Fe(88qf2@QM148SI2!<.j'.eU")3.s)Y./uoWLiWU/#Q<I8%8s=Jn:t2G0V2!=Uc2_,%E#<l<X[fj33glEAc+o\B)0>;WoM8(p=Pi;OU%V7o+?%I!BIf7<)?.[P7$*'[0\cc9T1e[a)YR&E17QNoiPmH5q'-OCQ-GWQ:70[G^IB3k4hD/L,Vd]Q9j%g0$\NTW3K&tbc)V]#(1Ns\do7):Kd:Nej-UNiZ#8p,/c]\-d0JO(p1l-j3#G'Q$nOlhg(Q.-RZ)bA4_j5Q8)"AAD@<B\>HWE^1DJ'?O/11r:o!_GB/(tn>H%!,FFD%?,Ag'Q_(D@A,uUoVu'%Jf*\BIK15p>?)2FE>*C-lLJ[n$Eud'&RE?$n/Il^]?1/:-M`;-q31)*V6\QUQm_T4ijB14e#ZWofEoU:m9M4j)8iLB1UDP@"kPAAd1hS2-J&7++9FXXN643"_d'+e?(,eOf?9!UA<4#/+29.Z@0bZ:PNAk9mbU&Q?bS&[K#PjnDJU.H]RQiQV$7T2?BoPP#9,U*.'*2r4/XJE$1q1dde-%_f+Sm'D?/fnEOlj)XS;`Ck_/pG?)X\b;Xn&>VohBAfgtB[Z21$:g,CPYkK-*SkQ0;ZS4s$co4XuPK1'\>/RD`%Vn:HYWSl`5OWaueqChMF94B%@Ye1eXa5sHCIlkI@.OEg+d5p$9i@iS[c%0I<r"ur1"]F-Am44R(*3pjl+K:&^U')l/GH5DW(+g>Y5[[Xn=H5:^Ain.idnsJZ@6ffZ-=UJil>AoQqP6e4N#(Thhqm]Qm714.Cs6oITph]Egi9g@)g%"Lemcj`Hfu`2+gDu(O%tV^eQU:*]I5j:fBE,_A>T<-*Oi*2;44;2%8Z9btN\m17af_`s.PL=qp:P(/@NmU3O*^Q)ZNYQ(Jr=lI,1tV#LMlu/cQ/qN%@S\nW]cmSr[^>K6='r?3%8?P0Jd)1^)qW!os:TqU_\*Tor2Cu&fXh.2m0mVeFq>ZOUmfMaO`]t_=<<0iM`*AgB!hCgUTrLBG9gc)ooff:slA-/4EP4=H@4-GQZf(0@3fSDTR#>8#,LsB9&6YM2mC.NaaH'9rHlG`275?bMTheR33p5nk4of=En93/,HMAP7A*S'NE23D#L=cP'M#!eI<+qd0jhE4R=3^H4),/MZcD,5\H.k8:QWZA?;LX$eVSk_C<.tUlr>4JgcX6;.ECK"EmE\_!rqVmRM#nOtboRq9kNlL-:fp?0S+d'HDH[I>LSZTuQ^^`<#55hOJBD7BHUmQt?M3:b)FB[kX<8^(pC?5nn-o2EOk>l_\B#fHXC0`K#,CE_s4s.='r@8\i0"3]?8'1HI\);:*]6<b(;35KG<f,<";a4D[5Ce.3Sh(01K8a<k46cTY^8B[ABPURp<+Vhp,CjLdg*M&[+q]b$G:&uFb_<)S81q!=YIenNE`':$k),O,Hq\%61]%p^2)(VFVgD`^#B-OjhHT]b-,n$Ao88-/H(o<7ZQBG-L4Jc%B_X_^VqGIpPn)II1W\E^Hd?C9?JZ.4.,W'Y&_^%+=%L&:9:HGkJ=kp3]$2T%/M[e1?=J8&8*[J-)HTgn/-=k?bLk`Jhub/Z<PZ%Y]SHs/,ji=SNlHAPtmPf2@j?K@*J+*qeG,G-59$8dWk7SW3D]EQ\.e(1g4@$`[?Xc(YmY8:h]l\FI>B"HKQ0<T:b;5D]0/b!Pg-7DVr0<!%EC>,-&d<;o;5%3aYlQo5%]^V0fkH9a?'uU$MXZUI,'(nqPE.]1#8rA*!H2S5/&TIRV)EW(/N?R3s*J%iG<K5LQcd~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2195
|
||||
>>
|
||||
stream
|
||||
GauHL=``=U&:WfGf[3&1B.O;g0/0f)8XlSO-Bk3%Or$7qQ=,P(%u$h?3Ze7o0#0j(ANHAW@`Ka\fP_;e"YubG_p%"0%.NQgL+aDu%\kO^%WPnK#(@Xsh^lW-mQ3$4>>RE>5QY0iK5>-Gi-N<LGecR@T*X<T7kc,T+Fk]/mkZ%@"]nJN#/p870,u*d\Xr<aphf#1NhRQRH=!i!?g/rFH3*Ocm82'u(Vt%oX,LdiD21sMqC[_9keDQ?q%`gqc;ZuqVeH:+MG-b,"cPJ9iVBOjo/mQT6;?Em>,`oU`%!32@.Y1':UI2=^>j1lU3bShDcEn<LR5YEY=dIAEY]u76jr/[qQpItGbq?7"gMq0j'LO,E6C_k#i"kh%q:I/UqCPXOi;`8W?YS'/uhn]@D9e:'XqC)Yo4P;0Ys-2C.JB.$X@ALD-!fR'W;<O<3X)t/;A)rjs=kpBHq(td>\nR4u>l4b`*Jlb9tlr1,5VTb,lc"e?NM8f-@!sO4KUO9"89a#)9XrE.ut#YI-$:Q#>Hl[dZG4*tZ/IY9SW&(q3`E,E/rpR&n_m/Aa6c+\Z:Di7"a0Gjkdg`5B'm*-@93AgT`t0@:i@PsUbYheWMqAZgKOb;+68;O\t\bbS^9,mD4(Tb>fSD[NFDnTr_PAjsGSC4]T#RCn6=e2B]EJT8G$f6K))/24M<Q9_]lEVXL"6FI:dBX5l'X8kOo;LhVd-J".<>,HmC!A$!ZQd1UCa7mc'e'K;^&QCQ(852tP#^**'VMKQC`+itoCD4`#_E]t8Q["la't!B)jcW)^6U@BiK?aL)Lt6?F6d^l5SHNDs5MbO+EGmD]g`B,j)Zg\JoM?##,iFVk-=K6p5$FXIoWF2Y'$J8(5OQSP'0*bBbro>/)A(*pPDgC_i+s=7i*+s.ho-k2XtMi'Z!4_U^a`r,c\j>KAf&OZV3+qG4W@Fdqgj!B^3"5$!C?c"EmJKAo[>7BV,;FWBYl+1,8r7d&F6iW+N<_7_>3meoEt7ed6kQ!0HW[&iGK#4WYB8p.pQH:Q2$0H4r`L>MdJMq?+dNB(@Jbd_%D@SXKBT`#V5@XRB:≻fEErO8Jjl+_.JX=jM?5dihMYK%XVN9#[^rt6+=EPH/%d<,t=*Ynk'l*g11=@!=nFb=aKjH*%e+urM[N)Sr6S1[;e::B!,Cf"4*F%V9V")!N.[e:F_Oj%mKl$;`<2RlaaPhlgR$B"p#Z?2L.e[3p?hFn47@cc>V"*N.;\'r$_<:l_:d"f6eC[6!?me+VEDfK9m0U[Vr\(,YUa^SD3:Y+Qi%[DF&\:N;?*&GpVi-lJBStsO2+U<$6_an*'WX"S+FZPh6Np-dqnCkFQ5cD?=B.h6!l0g-)esU(hbO"p(4*-"CSM-a)ao\V<7IS$8d%c15dLNMuRC_$lD?h<06%SJF<YigX0E$,&aYJ`KJTh)LSC)_1=@:m#Z_>sU","1<H0uiPYWt?b'*9--e=e9V(%18'L24C2c<WN"ALca=XBo;g.GnV%X9.%A[640PHKnnaZ2Jj8W2bU/TakcpFr6_?%J.0[,96T:4q#o3p&Ub`YF7hFg\'.mF^Y"(J_(<;h`=6cSPcaOH'cM4Mj.?_mnSHp&e^Hl4$!X*LGmsf2S)`V\t3j*M3RPmHs6.%N$TtHNut$TW*a#&RCYUp^40*RS=Ei=#F@p2n^3jK)U2WfbN8*[FT"A@rd,jt4Gn`T8X?AF@4(Uc'jW=0^1[7=[IC\#J"H&8X<4hJZc[6_>Mc=>YQJM&10]Lb.atOKi:/r-MGoAC._m%QOd8AGR&TO'5!H^,(I-:^s"Ol?oWSj,Bpo''kZfFd84/Z7_H]j=AMM463e]E$9P$-$hQj'5LL]Ks:<!7!g@)9gJSFY!hWqAXX^U0Z3X]iDGAg*Jq(T7G1Y7.=c.A`UOJCD1hOu5@csN'bTUfl;jRZ,O*;k:+QTOs$emAoqMcS$M]%`NVd^\s@=_B_/l]JqgqaIs.9^S.ei4(I<[^hutp'*LR-lHMOofD/7D#/4-.7A-=AlC6^8NObr/Rd0$0So/$D5!GEYX<*Cp0>e1V"#X,$E#$&<a_@f)'8T5VgE5/Df5XC&6SH?/&N>9bA^!:h+WZEWBVDXf9b.JNl_d>7&qk3ZS&N!7d<ZRJO7*i^)!25X4R.H/*8Jp3-a:+g_Yuf^8_Oa./<*=Mn!_)-V1n,8plNS@oXVdqA7jn(6\~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1744
|
||||
>>
|
||||
stream
|
||||
Gatm;>BA7_'RnZ;37EZB0ZgU:>up4:%Q)NfA[or$kd9Tl7UF7>=Yd%C"(h&\HWE'UM2_s=S4)p_pRL]"k:-ZM5HO[Q/--efiPAFsN5K`IbU5SJT>QklpX(3JjH1Ma&X&$=",Va/!L%g5"<e(]*]&m7+u8`*Q]+HQ9Ak%IMHC!N_u.6hJ4(Q?U^ol!+I(ieo7DXcUM?D(@2Rlahr0b/?;/3G*PuWB;&J_/SPb#=i=j@c."LOp-8/]>I0RHQWNMCQ=m#4n)P&E1S9#afULts8W[b'NT#$h$lb$ISF^^>=qAuO.SZ\*&hrc7MGh;%Af9PA,09n-PXk6])bmFXE8Bu#3ZkjGI%VS6V&V<L9Mm!c]#p_tI5P.<FH9<>;mBDn'4<g$dEjWht\'$-M$Meomkk?@ZY_G)11?Lt69/5jm!uPPVf'XCUoEts,m-f"ViD[118-_G&d#rWqK-J>h"3Bp"@)g:Y`*7I\9Y!sbq?Kf%lnlNt'%quZaCU;C/f>J@:p,n2Cg7a8r'FLYo?HSlIb;`&$,8(L70JN'DQh031.sJMWkujP&[X9hJV>RcGuga<+sN7nLkq+=^p,&NIA_8,7Dm()AWc8*Q6DQq=Oe8Y'4',2';I'nZG^AOr9,&hm=>+R$jbn!q7VW8aVKK']WEIi_?iZLPWB"%@IhttQ(Gk+G<5bsi;smrTNo<9=j47@WUuO(";;FIc*RY0'XA)FIVZ7`q!8js;RY84GBcBLs,bJs=K0G0JHqa!hC.)k^!t,RZ!^l.lN2*t>,X5M%^;MIOmTUqDiJRXDi>aBe's*#7`C+H$)IF7eCfVs]F*497RlhVX?^Qdf=F!G(qmO0H*/ke&4E'nA@U9q4LcH,]->,L((c\/o7GAu2MHj\XRSMl]\^7(j5W(XDYb$W>!)a"jPBp)nEt0WPri061$Z(Q_j>im4-2(QF`'Li%rbEsD;q^gFJc.N^k1!8PA7jQhl"C'_R?d)MDY`FnSi>,9[d.KG8&iaYbgMQ;;9*G[G:B$h0<nF2"^X+:nL5;("r%I]O<CL?&,=D,HBXV=!;eg]l5F39`ouU"\!:B\E4Dq3I4@X_WXIUlS!9g"5RAPc>Tf;ghuL4!oh31FBa*K8^&'mL)Q2i(P4Np?[pKqD=o?Vl>r(qeKom=+2PjeiQLr*U6jjE,SX,163*4E^mas#B'B*OY)?Q$6Vs+!ZV]2Q-6UCbPflf*WH>9VHTjGSqW[=7KHQE8]N:BC?#O[#&8.K&W45tiD(fFVj,Lp7\Cr0qUGL[.f1GC`3?.Z3m'#j4e\3[#F$<"df@$4,9D?0>F=Am>PTMIU!SRAOY&9PY@s#h5=Vk58>sFaN(CG$:A&7S%p!mXE!p/=>./Hl:=#MQ:YR=B9@Z>/8`L/1u0ri*QFG<`3664LB7-os?pj]^5S]RrsEUC#OrNWN43QVVTI14td,J&WigNp\<nQHl5>7CXIVK4!oH%#%6G&<PLcID)R1n2%Jd1I;N4RVfcd*7>ISGkWO"48'lEsCMfjObN^gMp>TgGajndl4_61F0hJ:%VCD!t.3QUHE``Z^WT::*HJ42hR7%b-T%*r7[-<G'?<m(UuQ#Jfd5]s#qo]/W4mdQE&'&2pZBJTg)\Thu_fu.\#CD=:4A8&IS8FE"^1[hAt5]m_jKmJGJ)8r@?*H%,Y0r`W>3onc;J'Z+itF/nmT$G!YD0k*j.=9WrZ:237S-VtHHaQDZ^pj5^EJ9KB00X2ZUHPcK`7-8FHW#EsVtD#~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1469
|
||||
>>
|
||||
stream
|
||||
Gat=*968iG&AIa;lm4HF4%A]`!HpNQERH1R`c[bSNA%li'Pu0o84A3f["nSQ/jSnL=YaW)aW_&ZIet^?Z+CDmI[L/[f)TaPnE)k9`aO<(9G.['$i"icI>nM.\<8QXTrCRCOAr_Sf\T%3`!K"5SheYl&Zm<Lc%srM#*+"%0a1b`[jMaiU(XK5"JT%hkiA/G+:74E>*:j:dXD)0:HNkO8HLuLfmXMA-!5,d7hq1`(98;7CRAE,roG+`ep""5=*7fc?Zp5UQ%]D)$I6pmc^L_EXBECnk/ik\Ao0$p)WpcJ5B4,$pekdW^8P?8$e)Wa_b4M*gD9LDm&m9B?uuPD))nrj8pFaDW$Xi-Ku&"Ra!6-sUP2BP!pbZ_UqQ0Ib(qQ7WRR\#Y(Mra9I5)!&pH*Ve?@g?du6;cIOR^r1km&UYZ@VKcn4<LYe)P5L2Yc7P4h/J[us7'?r>oN:GO?mDN$^(UK-B=b'i!r^No:2a(K<WFNBM?bSVX21G42$Y0aHI1"2o-&CtajrcNmdGl3fqkP14(R&-1[48<l`B^`74/?1dVC*9]1CVJJ#>WIrZII>,=8!af$V\&N?WBY^,>kY'JCljuToH>*8]&SV7mThn+.Ar:gr?'*p1't#lH8U7(&%]<(b?stB$TIb;4qMO(:6kSST/@RO+"7js;W:'s$2Y(G?niUs_;b,rA`8M#:$&N58*+_cm:]G%g@8g;^-Fmu+[7&Be\K1@+lCPdK73s*mt,!hTZAG!hj^]N2UcPc\icopFJFC/E:9::dN6k-W]]s3=8=>h-5d$HH#;M%F,$p=f@sjm`GVK\Hn>7Se^@De):;:MM^JVMb[seLK)AW6D422C\1-hH]s4Jpc*g-*B:p$IcZ8nr9K<]c">o`0fA".\W)5U^KfS.B:GF8'(<430>@^ml5>:S3ll`.D/6aaaB2>qAGs%7pnf#8O\-mE7CaM[Vq6pR!GR/_DCU0h?0_6[E"l2gn*eUhe@8#H^Y=s)/SS7Wd?/80pk90"YM<tG*+F9.j'hoe[G1W53I)?CEVX13ZR(`;ro%_k%<dSXga8(DgpXo5IjoS`pa72ju5ttjqJNl@e,_j)2hQ\,:VL'lQ$6.U[_rFGW`^,V_fXq];2j'uN^t7f#NPRU]Cj&_qG@E/bA[-7H+_m]VQ*b]`>*THX^K>0B42[p4Fd![5&Q^HPf'jNOK5uKN<"B2g6]V$2=pA)Q:O>7i!HYon-p`JZqb'*0**.$,!`ek"X2m)hE.:g*A!u=!m'_=UZuk*4gf)smp8e$r<uUdu\MDb(k9q.NM>A3&I\QRRNB)OLdghu6XJu\p)-c:)n]::-cQ>Zo%pCps[9qF'=Z*FQ/XTH*d@fY5V(&FQ9K$!X(,M"+ql.95TgFNul$ut1X3+GSD/F(ccc5=br*W2%NGf,"e]ZsTJ)<$G](bPa?#Lb+nhJ0GpnOn'307cTK'nsZDP^lV+EL-/AiE-X!LDFG$N~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1716
|
||||
>>
|
||||
stream
|
||||
Gb!;d>Aqt]'Roe[39$b=OhigpgLF9b'btfk+YHD'X]B^Ti_80B*BVp&'NjaPhrEJ0\X"H'65.np>eY9Mcf^[ILEG7J:Z_Yt@sDrZJMJ(7!p,*Qp]Wbt4!,HZ65K(kLbCkTAjBh?rdutKEX4uE`[mu0]SMB"!uI1-&IW!V08aEGV1R0C%L!K!?6hKA6%`!e>Pig]15;M`$,SCQgu%?DC@u$KG1rXYm2*TX"mq8SW'2RM%AEjBF)YL#HM7i>(9+<sZ_GP!:K+:SbeSn:#Tc,J4fTu;))[&eC8nLJ0itor$fc:FhlC;tn@:1Xg(LOJ^JIqt5J0sm3,N)2b>dX;-li<$6)XEI+mUtE4@+APM_GtQ4mr+/)<(1KLG3pS5h]F(Q8pK?T#\fXN/;Ee-Su1`E$XZ!,`Wn_)_*8<U$PU7lf%mq[SPX8<e5]B5m@/_bb7!h=-K3ZnuS(onQ&h(pqnT7D74$b/5MclVb*obe&MC'8Y%t<p4btWq([[Co`u4qq%sK]Zj9&5ZZm#t6<cBe?3g76K-(c8NR&IKBT.e[L4?9S",g&(7OP4I/dXl'fd-?3NBYi5?sZJER,8bL5/oE4otoo:Y5gAmeE9JiYA#]?_&f#+%<_0/l.uNM(E3t1Z9uB6J1iskpLiq9[9Lk0ESLk[U:j0.*X*7aAfqK8miHGZ#6*O+=;TQ"@rn%G8"U03!Ag:E.R.YYZ;Iij1>SuL&tX;op9/"T8,K2>T2hHXH'&/KURG37]&=F<n9pks[rhc9*&=(cdH,tqM9_&0Tif*(VYAIg10+&L1Bek"8GblWP/-O$>$gJ%=;F=3Vp165M$nBFZt7jY%Jq[s">TdcFRWJ"Eis/rB<'DE(D&l_<PVZJ5nMmZ$M:X4!W-R)WXc<;gcdi+kl#CYX7FI07QS%r$km>S>*Kqn<5Fiajm2u8r]ar'hZs-5+-<4B365pq:@@O.\kUu-U<fCq#G]&'>!-SlM,Uh2@Q`!e]22n;rJ=+kI9Kn$<gJ[oALb42'_9npog]&,ji>XM?c5?7npN;@qEp[o1>e]rQ@Lq&J"B9nL'6!e_=Rl[Mft`-IJ"5J<GIrdcZ5JRXYfFT#NM1$]KAYi_Fatn)7(Q\5O.dol'!K+=d0ZBXP2[qImDPJ&)i<UEWlD[H)3)jZnc%7b2V\/2rFVn]qH[2eO#N;OHr=DR^t;q-Fd5e+cdZ+`8,N1VWZ[+dl3h<P6:6nigUtX\"Bm&S7C/LbSOmk3Oc:3T(on#CKb*1@1u(<F_uc[[([N,6e4.uF,*7_qkCoeAIcc[#rf#Am+[>XGhe43,GJP/3-L6iJ'0gL%?qlk#nI(ZH>nbhD&-7`lj,q[Iam1l,fF*E>Ch&M=`jft<]51Q?+^%cCK@o+)djcI-4Jb6r\l/YUC=bL0-E([RaASOk*1Fs%G7lRrjdW[6<^CWjV1p#]p.2@[dQY*6[$3Rl&fHt[>RJ3gfIUa)A.lSI5.!h+If+@j'Bm&XbQ/MC8tf^Y-?\H<kCg&`l&BI-6c(7^FDVnF!4Y\a&k!pLPJ`ZLt&*)=[.L^Fld]-Wi60Ul5qO/^OEfDP7h6sZ9,o#[7>7c$7[@FjQsBG3NN;n8E`QQ9nfo?/X=Jm8oUfS>9hBM;LP0*5%AA;X+a1<WE,S_?R`WGq=rE3/3aV[_!mT=n+dG`[(k1,^/O-+/7\7F5gl,n,7,@UoChuV6)IQ$PrV@/][V\Ks(.1=_"eKV`kY@~>endstream
|
||||
endobj
|
||||
34 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1865
|
||||
>>
|
||||
stream
|
||||
GauHLD/\/e&H8h>ETib#CX)EE&sZlBd[+bRMDZF0p5=KQO_@sY?<i=YqL\_@*@4@ES(Q"KUTkJ('R=oKna<qt(f:.7k&6al4Ia.81BrfUA-P^?$'X!1EoMN<#S2/.I3:jR&TJqUE'I1D]`S+^";-\3-lN^58s"oBV'6'_f7b@UTe\>!`FD0MJ;-Yt;9TPME,3-0!U$Cb&jHYr+4m^_;pN%3"=rfbbk?t3E!BNtlD4hZ..]eJ-97%aHLq+Nr)q<>*A>PjK6OaZn\W/-o+4Z(NDJk$XfY1QKt0*E\]dcV+2R"_9R^um#4eTe"L3WLnm@m?q%#-d/SD3<F$3;,NPbiaDpbPTUk#X"%B1O<ck&-GVoLrOSl%fY14\0?5&"s4Z-;5OTiA&.(g!b@P-?Xu_\'YQ'79ZphCup9'@dU^3%sirQRu<$V`17fLsa$+54]W*Aa>\i(IWU%Yt*DHAGFLj\Q[e+(a+mQL"?Hmqm:_MoZRJ7'>4EDX8!Ou=b,@f3*3mlY#)iIp5ID8`Xp_tq8_ed6-q$9#f!K,EoOD2,BlMk#ArG*bUkAD;\f.Q31XP]eR4B>9slW(!_*!bppA(*en;@3RKrh*]J4_>AuIaFOI>s@1Lo-.XdD<+0]d1^A2#]L6R-B^ZA(1S)PKn\HE$jDEu7VVb!"?ZQ54P_NJ0j\dke7t4MmmsITVus">C.Kcc`B+^8e(UIq@>;lj2#X&;MtTcG\C^#uukNlm1m$(P*%&*OZDo"!*ogTu7YL5dI>[!%.;=PKdiuYL(?1S>G8U/<\M[VC)1'k6jl`l*ndZ-q4Nk@=N-X]Ld#%:Vtj7aupVs/X?$>R3s^3E8c,AkM(M`8]0MClK6#k5K8dsV%B^?eW9N>SJ9EWB?92ZUu$]P"@]F7>^hH#%:7Mk0Y5is;I$=Yd'^!VokQDMgH/>=5h&1Y]dpuF&oknn5[;/r`D-?j2UrRY8:?b+p^O^81-2%pcZVAGFuQsU@Y0D,W>'9h!9@^#Z4eXQVU4RLoO,D?@p+i`Zq)94+$J:/b7BHe\>#7"0c)MK^B#J\^@,2Td@ha`X/0%+7*rV*#XLf9#d>%S4tfWq\f.KQJ#pa#k%+/()3rc+$[n/*j90:AAbI%`0ofM@mMprR&$rLsZ`:XK7Gk#(IbdMJ5372&rI6:JWs`FC"S@s2M_[51;c]6kXM!5YbYi9pZKAYY20XGrEoZ@(P5YbM4O8*MH2=drlS:h'VfMt'AX8]uAh[J.N4D@I_I/!(ND0^aGLPk_lV$efX-N"r#@!G#Q*bhmAPm@Zn!gt-4m"L9*O;eQa;,R&J[4[M.-[aG)Q<L.:2FY]>ZY+T/R4u>>IQ/aEgE/1X58U0Ne*2I#9,f7>A\Hs0:qDPW<!YH&j;#jJ:K/V".2+V/o_D,l7c=QBk@qd#'>--W_>(dGeo22Y\*@>'c[CD]EbUt0O-Y1cLc6^V:^U6m6]Ys.ZH%R-J3ZMi&:!g-]`l)^BZfF(qL.%6QNfXYk+:A@7Fm[#:PdOls[/^'rKUanMk%lg]QPe>k0':LkL[l_CS?"gBu:u[:oXWA$aHQ]+a!t^kML_G8RbI9:P&^2\Dh42Z9_V(:\kfQGU,cMOi\tn7!d/j/1?Ea4U+;cDsn&1\Y*1ZotS9V^nimpZS8qFM-o=6>J]?3HGi)eK#3_OKc1]YGkdr_eQ2J`s@nY$cB)9iXspXGbFJIh%nW`cT8<Ma0XnF-r4`OK)Rt4"7)#i021B8\o.PM;.KG=2N=C^=,tn7\Z@BSMQ15jl!965J"A;S?&/3l+1'n64!kfIm[3ee?C5;)IW><B5+H-C<`\FJGZU0m58!_M^r%m*[t2:fMkjT)Q,[n=pr6:"M*#E5kc'OX>6kDFIfYGIZDI~>endstream
|
||||
endobj
|
||||
35 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1147
|
||||
>>
|
||||
stream
|
||||
Gb!Sj>Ar7S'RnZ;30.2I;'[HM<.JFKRg&+]X::o45g^C5U2>%a<Kt.=^V32Sb=XN;9b@M!/]3`rY2;5]&']Xa`J.`*!'H1U4(H&c`%,3r#p=[#^W4V9LN((@&-DA:ocjqlo(rqSCSuh^&/"GgJYJ;U)*ji3;(>JZd8!:\TeWcti\2KQ:oOL=MM4BRC\Bq(S7Cc5KL/(.mAKk]T3aq(PT?V]IaK$JYR/hGeIMpsTaW(si97Armp<dOe_4-=WN$rp[Z/\((-ZakiT,]>@Vo^]PJX<PR_5X.;msG+r#+KAIB8/&^fLZDaNOQi);EaRDmlcKUY@`L3\(kOrkd&C(3Se3n0q:-(%H]%Yo,K$h!qX)aq$l_TQ#-H`lE?VGISo_#&:E_eYEok-3[bZKKC4Ab\R!h'P"hWGpu(n."p9_cUOHcJ_bpt"F&2&_beML6973&Etn#gI24iWbXkRL1Q2KXm;/;D<;e]JhE1&F1U'pl2=Y2(1P+)83*3oBYHL8MoSg%D@gF%>I.b2Z+GKk.iR\/7S:iF[k$ADaXJs@jPde&--p"j;b:SRTH0YZ_L^!8Kp<btNBTj[9iSsLkV81:$o,\L.01+,R]L.Y>V1WDEO+X":64[#'LZs+-gX51<@ZEW-.1[b^`!I=0A.]3hmfDV<ZL\q-\TQ@nGPu,Kg<:O(P.<LLS"sZ^TtkuTW[h!^<MfBB?N4Ck$@MMcOS?$J"SlSf81j`H3/$Sk)cPUP3#U4aJB[8q6/KYn'4d#R4=Ob2Di9Ciau$!Fs3p.6jGadR=,l`>>IR&]Ru6]c2>EjXFOiS3,^7/`AKI;N)XX](FabXMGu02M\@4cs[S0DZJ'H+ne4m?qnEhnCkBRg=5<,hf!L$mBVSp#nes3i(T("kaF"GWle9TKFWhpmRC*X\E<AAZ>h-nuU92?>+*\AEhQVYbIdo:Ie+WgW[=X8'LPpAH-!8j#N"[7'3T"=r4mDd\%d\UQCi\".+%]lQ%a6F18H96rcnahq='YAAos&EbtHQFsT0D*6ua`=7RTY00foP*,l7Fl'TF+%X@jB:Eu+3uRN3r,"B"+OY4c8R<@:Wp6=c88m:(^$Wq#!:@H59onMhreO>=P[M1lWKDjrM\'?ZRg%b`;FMkS]-5t?&_ki3"+"jiSLp<~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 36
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000133 00000 n
|
||||
0000000240 00000 n
|
||||
0000000352 00000 n
|
||||
0000000557 00000 n
|
||||
0000000662 00000 n
|
||||
0000000867 00000 n
|
||||
0000000944 00000 n
|
||||
0000001149 00000 n
|
||||
0000001354 00000 n
|
||||
0000001438 00000 n
|
||||
0000001644 00000 n
|
||||
0000001850 00000 n
|
||||
0000002056 00000 n
|
||||
0000002262 00000 n
|
||||
0000002468 00000 n
|
||||
0000002674 00000 n
|
||||
0000002880 00000 n
|
||||
0000003086 00000 n
|
||||
0000003292 00000 n
|
||||
0000003362 00000 n
|
||||
0000003654 00000 n
|
||||
0000003799 00000 n
|
||||
0000005782 00000 n
|
||||
0000007526 00000 n
|
||||
0000009149 00000 n
|
||||
0000011267 00000 n
|
||||
0000012203 00000 n
|
||||
0000014111 00000 n
|
||||
0000016215 00000 n
|
||||
0000018502 00000 n
|
||||
0000020338 00000 n
|
||||
0000021899 00000 n
|
||||
0000023707 00000 n
|
||||
0000025664 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<81cf0bff9c8a5b633cd9ac4faf7d01ca><81cf0bff9c8a5b633cd9ac4faf7d01ca>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 21 0 R
|
||||
/Root 20 0 R
|
||||
/Size 36
|
||||
>>
|
||||
startxref
|
||||
26903
|
||||
%%EOF
|
||||
404
Les12-Tool-Calling/Les12-Slide-Overzicht.md
Normal file
404
Les12-Tool-Calling/Les12-Slide-Overzicht.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Les 12 — Tool Calling
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = thuis.
|
||||
**Demo-app:** Polderfest 2027 (verder bouwen op Les 11)
|
||||
**Vervolg op:** Les 11 — Vercel AI SDK + chat met data
|
||||
**Aansluit op:** Les 13 — Agents + autonome multi-step workflows
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 12 — Tool Calling
|
||||
|
||||
**Visual:**
|
||||
- Background: CREAM
|
||||
- "Les 12" in BLUE
|
||||
- "Tool Calling" in BLACK
|
||||
- Subtitle: "Laat AI zelf kiezen welke functie aan te roepen"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Vorige les:**
|
||||
- Vercel AI SDK basics + 4 kern-functies
|
||||
- Polderfest 2027 demo — 500 bands in Supabase
|
||||
- Chat-route met `streamText` + `useChat`
|
||||
- AI antwoordt op vragen over de data
|
||||
|
||||
**Het probleem dat we toen lieten zien:**
|
||||
- Vandaag sturen we ALLE 500 bands mee als context bij elke vraag
|
||||
- ~30.000 tokens per call — werkt voor 500, niet voor 50.000
|
||||
|
||||
**Vandaag lossen we dat op met Tool Calling.**
|
||||
|
||||
**Visual:** Pijltje van "alles meesturen" naar "AI kiest tools"
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Welkom + Terugblik + schaalprobleem recap | 10 min |
|
||||
| Theorie: wat is Tool Calling? | 30 min |
|
||||
| **Live Demo 1** — Eerste tool: searchBands | 20 min |
|
||||
| **Live Demo 2** — Multi-step + meer tools | 20 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — Tool-calls in UI tonen | 25 min |
|
||||
| **Live Demo 4** — Edge cases + error handling | 15 min |
|
||||
| Waarom Tool Calling > context-all? | 5 min |
|
||||
| Lesopdracht + Huiswerk uitleg | 20 min |
|
||||
| Vragen + Afsluiting | 15 min |
|
||||
|
||||
**Format:** Demo-driven, jullie kijken mee.
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is Tool Calling?
|
||||
### AI besluit zelf welke functie te gebruiken
|
||||
|
||||
**Het idee:**
|
||||
In plaats van **alle data** mee te sturen, geef je AI **tools** (functies). AI ziet een vraag, kiest welke tool relevant is, roept 'm aan met de juiste parameters, krijgt resultaat, antwoordt.
|
||||
|
||||
**Voorbeeld-flow:**
|
||||
```
|
||||
User: "Welke bands spelen vrijdag op de Main Stage?"
|
||||
↓
|
||||
AI: ik roep searchBands({ day: "Vrijdag", stage: "Main Stage" }) aan
|
||||
↓
|
||||
Supabase: 12 bands
|
||||
↓
|
||||
AI: "Op vrijdag op de Main Stage spelen: ..."
|
||||
```
|
||||
|
||||
**Wat krijg je:**
|
||||
- Schaalbaar (10 records of 10 miljoen — werkt hetzelfde)
|
||||
- Real-time data (geen verouderde context)
|
||||
- Type-safe (Zod schema voor parameters)
|
||||
- Multi-step (AI kan meerdere tools combineren)
|
||||
|
||||
**Visual:** Schema diagram chat → tool → DB → AI → antwoord.
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Anatomie van een Tool
|
||||
### description + inputSchema + execute
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchBands = tool({
|
||||
description: "Zoek bands op dag, stage, of genre",
|
||||
inputSchema: z.object({
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).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;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Drie verplichte delen:**
|
||||
1. **`description`** — Wat doet de tool? AI leest dit om te beslissen.
|
||||
2. **`inputSchema`** — Wat heeft de tool nodig? Zod schema = type-safe.
|
||||
3. **`execute`** — Wat gebeurt er als de tool wordt aangeroepen?
|
||||
|
||||
**Belangrijk:** beschrijvingen zijn **kritiek**. AI kiest op basis van descriptions — vaag = verkeerde keuze.
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Multi-step met stopWhen
|
||||
### Eén vraag = meerdere tool-calls
|
||||
|
||||
**Het concept:**
|
||||
Met `stopWhen: stepCountIs(5)` geef je AI toestemming om tot 5 keer een tool aan te roepen voordat hij antwoordt.
|
||||
|
||||
**Voorbeeld:**
|
||||
```
|
||||
User: "Vergelijk de top headliner met de drukst geplande opener"
|
||||
↓
|
||||
AI: stap 1 — searchBands({ tier: "headliner" }) → 50 bands
|
||||
↓
|
||||
AI: stap 2 — searchBands({ tier: "opener" }) → 100 bands
|
||||
↓
|
||||
AI: stap 3 — verwerkt + vergelijkt
|
||||
↓
|
||||
AI: antwoordt met vergelijking
|
||||
```
|
||||
|
||||
**In code:**
|
||||
```typescript
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
tools: { searchBands, getStats, getBandByName },
|
||||
stopWhen: stepCountIs(5),
|
||||
messages,
|
||||
});
|
||||
```
|
||||
|
||||
**Visual:** Flowchart met meerdere tool-blokjes.
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Vandaag — refactor Polderfest naar Tool Calling
|
||||
### Wat gaan we bouwen?
|
||||
|
||||
**Stap voor stap:**
|
||||
1. **Refactor** de chat-route van Les 11 — weg met alle bands meesturen
|
||||
2. Eerste tool: `searchBands` met filter-parameters
|
||||
3. Tweede en derde tool: `getStats`, `getBandByName`, `getScheduleByDay`
|
||||
4. Multi-step in actie — vragen die 2-3 tools combineren
|
||||
5. UI uitbreiden — tonen welke tools AI aanriep (transparantie)
|
||||
6. Edge cases: ongeldige input, lege resultaten, errors
|
||||
|
||||
**Tools die we vandaag bouwen (6 stuks):**
|
||||
|
||||
| Tool | Wat | Tier |
|
||||
|------|-----|------|
|
||||
| `searchBands` | Filter op dag, stage, genre, tier | Read |
|
||||
| `getBandByName` | Exact lookup | Read |
|
||||
| `getStats` | Aggregate (count per groep) | Read |
|
||||
| `getScheduleByDay` | Slot-overzicht per dag | Read |
|
||||
| `addFavorite` | User favorite toevoegen | **Write** |
|
||||
| `listFavorites` | User favorieten ophalen | Read |
|
||||
|
||||
**Visual:** Tools-lijst met read/write icons.
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: LIVE DEMO 1 — Eerste tool: searchBands
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. Refactor `app/api/chat/route.ts` van Les 11 — weg met de hele context-string
|
||||
2. Import `tool` van `ai`
|
||||
3. Eerste tool definiëren: `searchBands` met description + inputSchema + execute
|
||||
4. System prompt aanpassen: "Gebruik tools, verzin niet"
|
||||
5. `tools: { searchBands }` + `stopWhen: stepCountIs(5)` toevoegen aan `streamText`
|
||||
6. Browse naar `/chat`, vraag: "Welke bands spelen zaterdag op de Beach Stage?"
|
||||
7. Zien: AI roept tool aan met juiste params → antwoordt
|
||||
|
||||
**Visual:** Side-by-side mock-up: oude chat-route vs nieuwe met tools.
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: LIVE DEMO 2 — Meer tools + multi-step
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. Tweede tool: `getStats` — voor "hoeveel jazz acts?"
|
||||
2. Derde tool: `getBandByName` — voor "vertel me over Lost Tigers"
|
||||
3. Vierde tool: `getScheduleByDay` — voor "tijdschema vrijdag Main Stage"
|
||||
4. System prompt verfijnen — wanneer welke tool
|
||||
5. Vraag stellen die **meerdere tools** triggert: "Vergelijk twee genres qua headliners"
|
||||
6. Tonen: `stopWhen` in actie — 2-3 tool-calls in één antwoord
|
||||
|
||||
**Visual:** Multi-step flow diagram.
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: Pauze
|
||||
### 15 minuten
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: LIVE DEMO 3 — Tool-calls in UI tonen
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `useChat` returnt `messages` met **parts** — text én tool-invocations
|
||||
2. UI uitbreiden: parts loopen i.p.v. content
|
||||
3. Tool-call rendering: "🔧 searchBands({ day: 'Vrijdag', stage: 'Main' })"
|
||||
4. Tool-result rendering: collapsed by default, klik om uit te klappen
|
||||
5. Streaming tool-calls visualiseren — typing-effect ook bij tool-naam
|
||||
6. Bonus: loading-icoon tijdens tool-execute
|
||||
|
||||
**Waarom transparantie?**
|
||||
- Studenten zien wat AI doet (debug-hulp)
|
||||
- Vertrouwen — gebruiker ziet "ja, hij heeft echt de DB geraadpleegd"
|
||||
- Voor productie: optioneel kunt verstoppen, voor demo onmisbaar
|
||||
|
||||
**Visual:** Mock-up van chat met tool-call chips.
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: LIVE DEMO 4 — Edge cases + error handling
|
||||
### ~15 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
**Edge case 1: ongeldige input**
|
||||
- Vraag: "Welke bands op Donderdag?"
|
||||
- AI ziet: `day` parameter is enum — Donderdag bestaat niet
|
||||
- Twee opties: AI weigert, of probeert ander filter
|
||||
|
||||
**Edge case 2: lege resultaat**
|
||||
- Vraag: "Death metal bands?"
|
||||
- Tool returnt lege array
|
||||
- AI legt uit: "Geen death metal acts op Polderfest 2027"
|
||||
|
||||
**Edge case 3: database error**
|
||||
- Wat als Supabase down is? Tool returnt `{ error: "..." }`
|
||||
- AI moet dit netjes communiceren — niet hallucineren
|
||||
|
||||
**Edge case 4: write tool — addFavorite**
|
||||
- Demo: AI voegt favoriet toe
|
||||
- Confirmation tonen — "✓ toegevoegd aan favorieten"
|
||||
- Belangrijk: AI mag write-tools niet zonder expliciete user-intent gebruiken
|
||||
|
||||
**Visual:** 4 edge-case scenarios + fixes.
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: Waarom Tool Calling > context-all?
|
||||
### De vergelijking
|
||||
|
||||
| Aspect | Les 11 (context-all) | Les 12 (tool calling) |
|
||||
|--------|---------------------|----------------------|
|
||||
| Tokens per call | ~30.000 (500 bands) | ~2.000 (tools + result) |
|
||||
| Schaal | Tot ~1000 records | Tot duizenden |
|
||||
| Live data | Snapshot bij start | Actueel per call |
|
||||
| Write operaties | Niet mogelijk | Wel (addFavorite) |
|
||||
| Multi-step | Beperkt | Native (`stopWhen`) |
|
||||
| Cost | Hoger | Lager |
|
||||
| Complexiteit | Lager | Iets hoger |
|
||||
|
||||
**Wanneer toch context-all?**
|
||||
- Heel kleine dataset (<100 records)
|
||||
- Snel prototype
|
||||
- Geen schaal nodig
|
||||
|
||||
**Voor productie: bijna altijd Tool Calling.**
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: Lesopdracht
|
||||
### Bouw tools voor jouw eigen thema-app
|
||||
|
||||
**Voor thuis — bouw voort op je app uit Les 11:**
|
||||
|
||||
1. Refactor je chat-route — weg met de hele context-string
|
||||
2. Definieer **minstens 3 tools** voor je eigen dataset
|
||||
3. Voeg `stopWhen: stepCountIs(5)` toe aan je `streamText`
|
||||
4. Pas system prompt aan: "gebruik tools, verzin niet"
|
||||
5. Test 3 vragen die meerdere tools combineren
|
||||
|
||||
**Tools voorbeelden (afhankelijk van jouw thema):**
|
||||
- `searchX(filter)` — read met filters
|
||||
- `getXById(id)` — exact lookup
|
||||
- `getStats(groupBy)` — aggregate
|
||||
- Voor jouw thema-specifieke acties
|
||||
|
||||
**Eisen:**
|
||||
- Werkende refactor (chat werkt nog)
|
||||
- Min 3 tools waarvan minstens 1 met enum parameters
|
||||
- Min 1 vraag die 2+ tools combineert (multi-step)
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Huiswerk
|
||||
### Tools uitbreiden + UI visualisatie + write-tool
|
||||
|
||||
**Voor volgende week (Les 13):**
|
||||
|
||||
**Onderdeel A — Write-tool toevoegen**
|
||||
- Maak een `user_X` tabel in Supabase (favorites, notes, votes, ...)
|
||||
- Schrijf write-tool: `addX(userId, itemId)`
|
||||
- Stel een vraag die deze tool triggert: "voeg X toe aan mijn lijst"
|
||||
|
||||
**Onderdeel B — Tool-calls in UI**
|
||||
- Refactor je chat UI om tool-invocations te tonen
|
||||
- Style: chip / badge / collapsed result
|
||||
- Reden: transparantie + debug
|
||||
|
||||
**Onderdeel C — `TOOLS.md`**
|
||||
Schrijf in repo-root:
|
||||
- Welke tools heb je gedefinieerd? (lijst + descriptions)
|
||||
- 3 vragen die 1 tool gebruiken
|
||||
- 1 vraag die 2+ tools combineert (multi-step)
|
||||
- 1 voorbeeld van een edge-case die AI goed afhandelde
|
||||
|
||||
**Bonus:** Loading indicator per tool-execute, tool-result formatten als kaartjes.
|
||||
|
||||
---
|
||||
|
||||
## Slide 16: Volgende les — Agents + autonomie
|
||||
### Hoe ver kan een AI autonoom?
|
||||
|
||||
**Wat we vandaag deden:**
|
||||
- AI roept tools aan in 1 ronde, maximaal 5 stappen
|
||||
- Antwoord komt terug naar gebruiker
|
||||
|
||||
**Volgende les (Les 13):**
|
||||
- **AI Agents** — langere autonome workflows
|
||||
- `stopWhen: stepCountIs(20)` of meer — AI plant, voert uit, evalueert, herhaalt
|
||||
- Tools die andere tools triggeren
|
||||
- Stop-condities, retries, error recovery
|
||||
- Voorbeeld: "Plan mijn volledige Polderfest weekend" — AI bekijkt alle dagen, maakt schema, voegt toe aan favorieten
|
||||
|
||||
**Daarna in deze leerlijn:**
|
||||
- Les 14: RAG + embeddings (semantic search op grote datasets)
|
||||
- Les 15-16: Testing + Deployment + Performance
|
||||
- Les 17-18: Eindopdracht-werkdagen + Pitch
|
||||
|
||||
---
|
||||
|
||||
## Slide 17: Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- Schaalprobleem van context-all opgelost met Tool Calling
|
||||
- Anatomie van een tool: description + inputSchema + execute
|
||||
- `stopWhen` voor multi-step workflows
|
||||
- Zes tools voor Polderfest gebouwd
|
||||
- Tool-invocations in UI gevisualiseerd
|
||||
- Edge cases + error handling
|
||||
|
||||
**Volgende les:** Agents + autonomie
|
||||
|
||||
**Vragen? Feedback?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik + schaalprobleem | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Wat is Tool Calling | Theorie |
|
||||
| 5 | Anatomie van een tool | Theorie |
|
||||
| 6 | Multi-step met stopWhen | Theorie |
|
||||
| 7 | Vandaag bouwen we | Intro demo |
|
||||
| 8 | **LIVE DEMO 1** — searchBands | Demo |
|
||||
| 9 | **LIVE DEMO 2** — Multi-step + meer tools | Demo |
|
||||
| 10 | Pauze | Break |
|
||||
| 11 | **LIVE DEMO 3** — Tool-calls in UI | Demo |
|
||||
| 12 | **LIVE DEMO 4** — Edge cases | Demo |
|
||||
| 13 | Tool Calling vs context-all | Reflectie |
|
||||
| 14 | Lesopdracht | Praktijk |
|
||||
| 15 | Huiswerk | Praktijk |
|
||||
| 16 | Volgende les: Agents | Preview |
|
||||
| 17 | Afsluiting | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- Vercel AI SDK Tools: https://ai-sdk.dev/docs/foundations/tools
|
||||
- Multi-step: https://ai-sdk.dev/docs/foundations/agents
|
||||
- Zod docs: https://zod.dev
|
||||
- OpenAI Function Calling docs: https://platform.openai.com/docs/guides/function-calling
|
||||
- Supabase JS query builder: https://supabase.com/docs/reference/javascript/select
|
||||
BIN
Les12-Tool-Calling/Les12-Slides.pdf
Normal file
BIN
Les12-Tool-Calling/Les12-Slides.pdf
Normal file
Binary file not shown.
BIN
Les12-Tool-Calling/Les12-Slides.pptx
Normal file
BIN
Les12-Tool-Calling/Les12-Slides.pptx
Normal file
Binary file not shown.
41
Les12-Tool-Calling/polderfest-demo/.gitignore
vendored
Normal file
41
Les12-Tool-Calling/polderfest-demo/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
Les12-Tool-Calling/polderfest-demo/AGENTS.md
Normal file
5
Les12-Tool-Calling/polderfest-demo/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
Les12-Tool-Calling/polderfest-demo/CLAUDE.md
Normal file
1
Les12-Tool-Calling/polderfest-demo/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
57
Les12-Tool-Calling/polderfest-demo/README.md
Normal file
57
Les12-Tool-Calling/polderfest-demo/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Polderfest demo — Les 12 startpunt
|
||||
|
||||
Kopie van de werkende Les 11 demo. **Startpunt** voor Les 12 — Tool Calling.
|
||||
|
||||
## Wat zit hier al in?
|
||||
|
||||
- Next.js 16 + TypeScript + Tailwind
|
||||
- Supabase client (`lib/supabase.ts` voor client, `lib/supabase-admin.ts` voor service-role)
|
||||
- Chat-route met **context-all** aanpak (`app/api/chat/route.ts`):
|
||||
- Haalt alle 500 bands op
|
||||
- Stuurt mee als tekst in system prompt
|
||||
- `streamText` → `toUIMessageStreamResponse()`
|
||||
- Chat-pagina met `useChat` van `@ai-sdk/react` (`app/page.tsx`)
|
||||
- Seed script (`scripts/seed-polderfest.ts`)
|
||||
- `AGENTS.md` met project context
|
||||
|
||||
## Wat doen we in Les 12?
|
||||
|
||||
We refactoren `app/api/chat/route.ts` van **context-all** naar **Tool Calling**:
|
||||
|
||||
- Weg met de hele context-string
|
||||
- Tools definiëren — `searchBands`, `getStats`, `getBandByName`, ...
|
||||
- `stopWhen: stepCountIs(5)` voor multi-step
|
||||
- System prompt aanpassen: "gebruik tools, verzin niet"
|
||||
- UI uitbreiden om tool-invocations te tonen
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Dependencies installeren
|
||||
npm install
|
||||
|
||||
# 2. .env.local maken
|
||||
cp .env.local.example .env.local
|
||||
# Vul je eigen Supabase + OpenAI keys in
|
||||
|
||||
# 3. Supabase schema (als nog niet gedaan)
|
||||
# Open Supabase → SQL Editor → run schema.sql uit Les 11
|
||||
|
||||
# 4. Seed (als nog niet gedaan)
|
||||
npx tsx scripts/seed-polderfest.ts
|
||||
|
||||
# 5. Dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` voor de chat.
|
||||
|
||||
## Stack-versies in dit project
|
||||
|
||||
- `ai` v6 — gebruikt nieuwere API:
|
||||
- `UIMessage` type
|
||||
- `convertToModelMessages()`
|
||||
- `toUIMessageStreamResponse()`
|
||||
- `@ai-sdk/react` v3 — `useChat` returnt `{ messages, sendMessage, status }`
|
||||
- `@ai-sdk/openai` v3
|
||||
- Tool API (v6): gebruikt `inputSchema` (was `parameters`) en `stopWhen` (was `maxSteps`)
|
||||
97
Les12-Tool-Calling/polderfest-demo/app/api/chat/route.ts
Normal file
97
Les12-Tool-Calling/polderfest-demo/app/api/chat/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Polderfest 2027 — chat API route
|
||||
* --------------------------------------------------
|
||||
* Les 11 — Vercel AI SDK + Supabase context.
|
||||
* Plaats dit bestand op: app/api/chat/route.ts
|
||||
*
|
||||
* Werking:
|
||||
* 1. Haal alle bands op uit Supabase
|
||||
* 2. Formatteer als tekst-context
|
||||
* 3. Stuur naar OpenAI via streamText + system prompt
|
||||
* 4. Return een stream voor useChat
|
||||
*
|
||||
* Vereist:
|
||||
* - NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local
|
||||
* - OPENAI_API_KEY in .env.local
|
||||
* - npm i ai @ai-sdk/openai @supabase/supabase-js
|
||||
*/
|
||||
|
||||
import { convertToModelMessages, streamText, type UIMessage } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey =
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing env: NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseKey) {
|
||||
throw new Error(
|
||||
"Missing env: SUPABASE_SERVICE_ROLE_KEY (preferred) or NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
// 1. Haal alle bands op uit Supabase
|
||||
const { data: bands, error } = await supabase.from("bands").select("*");
|
||||
if (error) {
|
||||
return Response.json(
|
||||
{ error: "Supabase query failed", details: error.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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}), populariteit: ${b.popularity}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
// 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. Geef puur antwoord op de gestelde vraag en voeg geen extra informatie toe.
|
||||
|
||||
Zodra iemand iets vraagt over het schema. Geef dan altijd een volledig schema terug met band, stage en start en eind tijd. Sorteer op start tijd.
|
||||
|
||||
Wanneer een band speelt op bijvoorbeeld vrijdagavond, geef hem dan niet weer als iemand vraagt om de bands van zaterdag.
|
||||
|
||||
Regels:
|
||||
- Zaterdag start om 16:00
|
||||
`;
|
||||
|
||||
// 4. Stream naar OpenAI
|
||||
const result = streamText({
|
||||
model: openai('gpt-5.2'),
|
||||
system,
|
||||
messages: await convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
onError(error: unknown) {
|
||||
if (error == null) return "unknown error";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
return JSON.stringify(error);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return Response.json({ error: "Chat route failed", details: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
BIN
Les12-Tool-Calling/polderfest-demo/app/favicon.ico
Normal file
BIN
Les12-Tool-Calling/polderfest-demo/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
Les12-Tool-Calling/polderfest-demo/app/globals.css
Normal file
26
Les12-Tool-Calling/polderfest-demo/app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
33
Les12-Tool-Calling/polderfest-demo/app/layout.tsx
Normal file
33
Les12-Tool-Calling/polderfest-demo/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
89
Les12-Tool-Calling/polderfest-demo/app/page.tsx
Normal file
89
Les12-Tool-Calling/polderfest-demo/app/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Polderfest 2027 — chat pagina
|
||||
* --------------------------------------------------
|
||||
* Les 11 — useChat hook + Tailwind chat UI.
|
||||
* Plaats dit bestand op: app/chat/page.tsx
|
||||
*
|
||||
* Werking:
|
||||
* - useChat() regelt messages, input, submit-handler, streaming
|
||||
* - Praat met /api/chat (de route.ts)
|
||||
* - Disabled tijdens streaming
|
||||
*
|
||||
* Vereist:
|
||||
* - app/api/chat/route.ts (zie route.ts)
|
||||
* - npm i ai
|
||||
* - Tailwind aanwezig in project (standaard in create-next-app)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto p-6 flex flex-col h-screen">
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h1 className="text-2xl font-bold">Polderfest 2027 — vraag de AI</h1>
|
||||
<a
|
||||
href="/supabase"
|
||||
className="text-sm px-3 py-2 rounded-lg border hover:bg-gray-50"
|
||||
>
|
||||
Supabase test
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || status !== "ready") return;
|
||||
setInput("");
|
||||
void sendMessage({ text });
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
Les12-Tool-Calling/polderfest-demo/app/supabase/page.tsx
Normal file
61
Les12-Tool-Calling/polderfest-demo/app/supabase/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { supabaseAdmin } from "@/lib/supabase-admin";
|
||||
|
||||
type Band = {
|
||||
id: number;
|
||||
name: string;
|
||||
genre: string;
|
||||
stage: string;
|
||||
day: string;
|
||||
start_time: string;
|
||||
};
|
||||
|
||||
export default async function SupabasePage() {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("bands")
|
||||
.select("id,name,genre,stage,day,start_time")
|
||||
.order("popularity", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">Supabase verbinding</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Dit is een Server Component die met de service role key de top 10 bands
|
||||
uit de <code className="px-1 py-0.5 bg-gray-100 rounded">bands</code>{" "}
|
||||
tabel ophaalt.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="p-4 border border-red-200 bg-red-50 rounded-lg">
|
||||
<div className="font-medium text-red-800">
|
||||
Query faalde: {error.message}
|
||||
</div>
|
||||
<div className="text-sm text-red-700 mt-2">
|
||||
Check of de <code className="px-1 py-0.5 bg-white/70 rounded">bands</code>{" "}
|
||||
tabel bestaat en of je <code className="px-1 py-0.5 bg-white/70 rounded">SUPABASE_SERVICE_ROLE_KEY</code>{" "}
|
||||
klopt.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y border rounded-lg overflow-hidden">
|
||||
{(data as Band[] | null)?.map((b) => (
|
||||
<li key={b.id} className="p-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{b.name}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{b.genre} • {b.stage}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-sm text-gray-700">
|
||||
{b.day} {b.start_time}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
18
Les12-Tool-Calling/polderfest-demo/eslint.config.mjs
Normal file
18
Les12-Tool-Calling/polderfest-demo/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
19
Les12-Tool-Calling/polderfest-demo/lib/supabase-admin.ts
Normal file
19
Les12-Tool-Calling/polderfest-demo/lib/supabase-admin.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import "server-only";
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing env var: NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
|
||||
if (!supabaseServiceRoleKey) {
|
||||
throw new Error("Missing env var: SUPABASE_SERVICE_ROLE_KEY");
|
||||
}
|
||||
|
||||
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
6
Les12-Tool-Calling/polderfest-demo/lib/supabase.ts
Normal file
6
Les12-Tool-Calling/polderfest-demo/lib/supabase.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
}
|
||||
23
Les12-Tool-Calling/polderfest-demo/lib/supabase/server.ts
Normal file
23
Les12-Tool-Calling/polderfest-demo/lib/supabase/server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
7
Les12-Tool-Calling/polderfest-demo/next.config.ts
Normal file
7
Les12-Tool-Calling/polderfest-demo/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6963
Les12-Tool-Calling/polderfest-demo/package-lock.json
generated
Normal file
6963
Les12-Tool-Calling/polderfest-demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
Les12-Tool-Calling/polderfest-demo/package.json
Normal file
33
Les12-Tool-Calling/polderfest-demo/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "polderfest-demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.64",
|
||||
"@ai-sdk/react": "^3.0.187",
|
||||
"@supabase/supabase-js": "^2.106.0",
|
||||
"ai": "^6.0.185",
|
||||
"dotenv": "^17.4.2",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
Les12-Tool-Calling/polderfest-demo/postcss.config.mjs
Normal file
7
Les12-Tool-Calling/polderfest-demo/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
Les12-Tool-Calling/polderfest-demo/public/file.svg
Normal file
1
Les12-Tool-Calling/polderfest-demo/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
Les12-Tool-Calling/polderfest-demo/public/globe.svg
Normal file
1
Les12-Tool-Calling/polderfest-demo/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
Les12-Tool-Calling/polderfest-demo/public/next.svg
Normal file
1
Les12-Tool-Calling/polderfest-demo/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
Les12-Tool-Calling/polderfest-demo/public/vercel.svg
Normal file
1
Les12-Tool-Calling/polderfest-demo/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
Les12-Tool-Calling/polderfest-demo/public/window.svg
Normal file
1
Les12-Tool-Calling/polderfest-demo/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
272
Les12-Tool-Calling/polderfest-demo/scripts/seed-polderfest.ts
Normal file
272
Les12-Tool-Calling/polderfest-demo/scripts/seed-polderfest.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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 from "dotenv";
|
||||
|
||||
// Laad .env.local (i.p.v. default .env)
|
||||
dotenv.config({ path: ".env.local" });
|
||||
|
||||
const SUPABASE_URL =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||
console.error(
|
||||
"Ontbrekende env vars. Check .env.local:\n" +
|
||||
" NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co\n" +
|
||||
" SUPABASE_SERVICE_ROLE_KEY=<service role key>"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, 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 runSeed() {
|
||||
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.");
|
||||
}
|
||||
|
||||
runSeed().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
34
Les12-Tool-Calling/polderfest-demo/tsconfig.json
Normal file
34
Les12-Tool-Calling/polderfest-demo/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
246
Les12-Tool-Calling/tools-demo.ts
Normal file
246
Les12-Tool-Calling/tools-demo.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Polderfest Tool-Calling demo — reference snippets (AI SDK v6)
|
||||
* --------------------------------------------------
|
||||
* Dit zijn de tools die we live opbouwen tijdens Les 12.
|
||||
* Matcht met de actuele stack van polderfest-demo:
|
||||
* - ai@^6
|
||||
* - @ai-sdk/openai@^3
|
||||
* - @ai-sdk/react@^3
|
||||
*
|
||||
* Gebruik dit als naslag — niet 1-op-1 kopiëren tijdens demo.
|
||||
* Plek in project: app/api/chat/route.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
convertToModelMessages,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
tool,
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey =
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Tool 1: searchBands — filter op velden
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const searchBands = tool({
|
||||
description:
|
||||
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre of tier. " +
|
||||
"Gebruik dit als de gebruiker iets zoekt op één of meerdere criteria.",
|
||||
inputSchema: z.object({
|
||||
day: z
|
||||
.enum(["Vrijdag", "Zaterdag", "Zondag"])
|
||||
.optional()
|
||||
.describe("Festival-dag"),
|
||||
stage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Bv. Main Stage, Tent Stage, Beach Stage, Acoustic Bar, Late Night Tent"),
|
||||
genre: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Bv. Indie Rock, Electronic, Hip-Hop, Jazz Fusion"),
|
||||
tier: z.enum(["headliner", "mid", "opener"]).optional(),
|
||||
minPopularity: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe("Minimale populariteit (1-100)"),
|
||||
}),
|
||||
execute: async ({ day, stage, genre, tier, minPopularity }) => {
|
||||
let q = supabase
|
||||
.from("bands")
|
||||
.select(
|
||||
"id, name, genre, sub_genre, stage, day, start_time, " +
|
||||
"origin_city, tier, popularity, bio",
|
||||
);
|
||||
if (day) q = q.eq("day", day);
|
||||
if (stage) q = q.eq("stage", stage);
|
||||
if (genre) q = q.eq("genre", genre);
|
||||
if (tier) q = q.eq("tier", tier);
|
||||
if (minPopularity) q = q.gte("popularity", minPopularity);
|
||||
|
||||
const { data, error } = await q.limit(20);
|
||||
if (error) return { error: error.message };
|
||||
return { count: data.length, bands: data };
|
||||
},
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Tool 2: getBandByName — exacte lookup
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const getBandByName = tool({
|
||||
description:
|
||||
"Haal alle details op van één specifieke band, inclusief members en bio. " +
|
||||
"Gebruik dit als de gebruiker naar een specifieke band vraagt.",
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe("Exacte band-naam"),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("bands")
|
||||
.select("*")
|
||||
.ilike("name", name)
|
||||
.single();
|
||||
if (error) return { error: `Band '${name}' niet gevonden.` };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Tool 3: getStats — aggregate over alle bands
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const getStats = tool({
|
||||
description:
|
||||
"Geef statistieken over de festival-line-up — totaal aantal bands, " +
|
||||
"verdeling per genre, per dag, of per stage. Geen filters — overzicht.",
|
||||
inputSchema: z.object({
|
||||
groupBy: z
|
||||
.enum(["genre", "day", "stage", "tier"])
|
||||
.describe("Hoe te groeperen"),
|
||||
}),
|
||||
execute: async ({ groupBy }) => {
|
||||
const { data, error } = await supabase.from("bands").select(groupBy);
|
||||
if (error) return { error: error.message };
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of data) {
|
||||
const key = row[groupBy as keyof typeof row] as string;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return { total: data.length, counts };
|
||||
},
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Tool 4: getScheduleByDay — slot-overzicht
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const getScheduleByDay = tool({
|
||||
description:
|
||||
"Geef het volledige tijdschema voor één festival-dag. " +
|
||||
"Bands gesorteerd op tijdslot. Handig voor 'wat speelt er om 22:00?'",
|
||||
inputSchema: z.object({
|
||||
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]),
|
||||
stage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optioneel filteren op specifieke stage"),
|
||||
}),
|
||||
execute: async ({ day, stage }) => {
|
||||
let q = supabase
|
||||
.from("bands")
|
||||
.select("name, stage, start_time, duration_min, tier")
|
||||
.eq("day", day);
|
||||
if (stage) q = q.eq("stage", stage);
|
||||
const { data, error } = await q.order("start_time", { ascending: true });
|
||||
if (error) return { error: error.message };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Tool 5 (write): addFavorite — user_favorites tabel
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const addFavorite = tool({
|
||||
description:
|
||||
"Voeg een band toe aan de favorieten van de gebruiker. " +
|
||||
"Gebruik alleen als de gebruiker expliciet vraagt 'voeg X toe aan mijn favorieten'.",
|
||||
inputSchema: z.object({
|
||||
userEmail: z.string().email().describe("Email van de gebruiker"),
|
||||
bandName: z.string().describe("Exacte band-naam"),
|
||||
}),
|
||||
execute: async ({ userEmail, bandName }) => {
|
||||
const { data: band } = await supabase
|
||||
.from("bands")
|
||||
.select("id")
|
||||
.ilike("name", bandName)
|
||||
.single();
|
||||
if (!band) return { error: `Band '${bandName}' niet gevonden.` };
|
||||
|
||||
const { error } = await supabase
|
||||
.from("user_favorites")
|
||||
.insert({ user_email: userEmail, band_id: band.id });
|
||||
if (error) return { error: error.message };
|
||||
return { success: true, bandName, bandId: band.id };
|
||||
},
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Tool 6 (read): listFavorites
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const listFavorites = tool({
|
||||
description: "Geef de favoriete bands van de gebruiker.",
|
||||
inputSchema: z.object({
|
||||
userEmail: z.string().email(),
|
||||
}),
|
||||
execute: async ({ userEmail }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("user_favorites")
|
||||
.select("bands(name, genre, day, start_time, stage)")
|
||||
.eq("user_email", userEmail);
|
||||
if (error) return { error: error.message };
|
||||
return data?.map((r) => r.bands) ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// De chat-route met alle tools
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const system = `Je bent een festival-assistent voor Polderfest 2027.
|
||||
Je hebt toegang tot tools om de bands-database te bevragen. Gebruik altijd
|
||||
tools voor concrete vragen — verzin nooit data. Antwoord beknopt en in
|
||||
het Nederlands.
|
||||
|
||||
Tips:
|
||||
- Voor "welke bands op X?" → searchBands
|
||||
- Voor specifieke band → getBandByName
|
||||
- Voor "hoeveel" / "verdeling" → getStats
|
||||
- Voor tijdschema → getScheduleByDay
|
||||
- Voor favorieten → addFavorite / listFavorites
|
||||
|
||||
Als een tool een error returnt, leg dat netjes uit aan de gebruiker.`;
|
||||
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system,
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: {
|
||||
searchBands,
|
||||
getBandByName,
|
||||
getStats,
|
||||
getScheduleByDay,
|
||||
addFavorite,
|
||||
listFavorites,
|
||||
},
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
onError(error: unknown) {
|
||||
if (error == null) return "unknown error";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
return JSON.stringify(error);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return Response.json(
|
||||
{ error: "Chat route failed", details: message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user