fix: vercel
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
OPENAI_API_KEY=
|
||||
6
Les11-AI-SDK/lesbestanden/polderfest-ai-finished/.gitignore
vendored
Normal file
6
Les11-AI-SDK/lesbestanden/polderfest-ai-finished/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
14
Les11-AI-SDK/lesbestanden/polderfest-ai-finished/README.md
Normal file
14
Les11-AI-SDK/lesbestanden/polderfest-ai-finished/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Les 11 — Polderfest AI SDK Finished
|
||||
|
||||
Praat met je data: chat-interface over de Polderfest line-up via de Vercel AI SDK. Run schema.sql + seed-script + start dev server.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
# vul de .env.local in met je eigen keys
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000.
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export default function Chat() {
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto rounded border p-3">
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={m.role === "user" ? "text-right" : ""}>
|
||||
<span className="inline-block rounded-lg bg-zinc-100 px-3 py-2 text-sm">{m.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input value={input} onChange={handleInputChange} className="flex-1 rounded border p-2" placeholder="Stel een vraag..." />
|
||||
<button className="rounded bg-blue-600 px-4 py-2 text-white">Verstuur</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { streamText, convertToModelMessages } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase-server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const { data: bands } = await supabase.from("bands").select("name, genre, country, stage, time");
|
||||
const context = (bands ?? []).map((b) => `${b.name} (${b.genre}, ${b.country}) — ${b.stage} ${b.time}`).join("\n");
|
||||
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je bent een hulpvaardige festival-roadie voor Polderfest 2027.\nLine-up:\n${context}`,
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = { title: "Polderfest 2027", description: "Polderfest 2027" };
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="nl">
|
||||
<body className="min-h-screen bg-white text-zinc-900 antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Chat from "./Chat";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl p-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Polderfest 2027</h1>
|
||||
<p className="text-zinc-600 mb-6">Stel vragen over de 500 bands van het festival.</p>
|
||||
<Chat />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createSupabaseServerClient() {
|
||||
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() {},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
2
Les11-AI-SDK/lesbestanden/polderfest-ai-finished/next-env.d.ts
vendored
Normal file
2
Les11-AI-SDK/lesbestanden/polderfest-ai-finished/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { NextConfig } from 'next';
|
||||
const config: NextConfig = {};
|
||||
export default config;
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "polderfest-ai-finished",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.0.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@supabase/ssr": "^0.6.0",
|
||||
"ai": "^6.0.0",
|
||||
"@ai-sdk/openai": "^2.0.0",
|
||||
"@ai-sdk/react": "^2.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"postcss": "^8.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { '@tailwindcss/postcss': {} } };
|
||||
@@ -0,0 +1,8 @@
|
||||
create table if not exists bands (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
genre text,
|
||||
country text,
|
||||
stage text,
|
||||
time text
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Seed-script voor Polderfest 2027.
|
||||
* Run: pnpm tsx scripts/seed-polderfest.ts
|
||||
*/
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const supabase = createClient(url, serviceKey);
|
||||
|
||||
const genres = ["indie", "punk", "techno", "folk", "hiphop", "metal", "jazz", "ambient", "house", "drum&bass"];
|
||||
const countries = ["NL", "BE", "DE", "FR", "UK", "US", "ES", "IT", "SE", "DK"];
|
||||
const stages = ["Main", "Tent A", "Tent B", "Acoustic Field", "Late Night"];
|
||||
|
||||
const bands = Array.from({ length: 500 }, (_, i) => ({
|
||||
name: `Band ${i + 1} of the ${["Polder", "North Sea", "Lowland", "Delta"][i % 4]}`,
|
||||
genre: genres[i % genres.length],
|
||||
country: countries[i % countries.length],
|
||||
stage: stages[i % stages.length],
|
||||
time: `${10 + (i % 14)}:00`,
|
||||
}));
|
||||
|
||||
const { error } = await supabase.from("bands").insert(bands);
|
||||
if (error) console.error(error); else console.log("Seeded 500 bands");
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
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"]
|
||||
}
|
||||
337
Les13-Agents/Les13-Docenttekst-Autocue.md
Normal file
337
Les13-Agents/Les13-Docenttekst-Autocue.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Les 13 — Docenttekst (Autocue)
|
||||
|
||||
**Fysieke les — letterlijk voorleesbaar. Slide-nummers tussen `[SLIDE N]`.**
|
||||
|
||||
> **Structuur:** ±1 uur presenteren → kwartier pauze → ±1,5 uur "Nu jullie"
|
||||
>
|
||||
> **Voorbereiding — accounts:**
|
||||
> - Supabase ✅ bestaat uit Les 11/12 (polderfest-database)
|
||||
> - OpenAI API key ✅ bestaat uit Les 11
|
||||
> - Vercel ✅ bestaat uit Les 11
|
||||
> - GitHub ✅ bestaat
|
||||
> - Open-Meteo (weer-API) — **GEEN account/key nodig**, gratis voor demos
|
||||
>
|
||||
> **Voorbereiding — Supabase:**
|
||||
> - Tabel `user_favorites` bestaat al uit Les 11/12 — geen nieuwe tabel nodig
|
||||
> - Eventueel tussen demos resetten via `supabase-reset-user-favorites.sql` in de zip
|
||||
>
|
||||
> **Voorbereiding — deploy:**
|
||||
> - polderfest-with-agent gepushed naar GitHub repo
|
||||
> - Vercel project aangemaakt + env vars gezet (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY)
|
||||
> - Productie-URL getest in browser (zowel `/` als `/agent`)
|
||||
> - Test prompt 3 ("zet in mijn favorieten") en check dat er een rij verschijnt in Supabase
|
||||
>
|
||||
> **Voorbereiding — presentatie:**
|
||||
> - Cursor open in Polderfest-with-agent map (voor code-walkthrough)
|
||||
> - Browser tabs: gedeployde agent-URL, Vercel dashboard, github.com
|
||||
> - Drie demo-prompts copy-paste klaar (onderaan dit document)
|
||||
> - Voor uitgebreide handleiding zie `Les13-Docenttekst.md` (normale versie)
|
||||
|
||||
---
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik tool calling
|
||||
|
||||
`[SLIDE 1]`
|
||||
|
||||
Welkom bij Les 13. Vandaag gaan we de Polderfest-app slimmer maken. Niet één tool die data ophaalt, maar een echte agent die meerdere tools achter elkaar kan gebruiken, een eigen plan opstelt, en zelfs een externe API gebruikt om het weer te checken.
|
||||
|
||||
We doen dit op de Polderfest-app die jullie al hebben draaien — geen nieuwe app, geen nieuwe context. Alleen één extra route en een bijbehorende UI-pagina.
|
||||
|
||||
`[SLIDE 2]`
|
||||
|
||||
Even kort terug naar vorige les. Tool calling. De AI kreeg toegang tot functies, in ons geval `searchBands`. Een gebruiker stelde een vraag, de AI riep één keer een functie aan, kreeg data terug, gaf antwoord.
|
||||
|
||||
Dat werkt prima voor simpele vragen. "Welke bands spelen er op zaterdag?" → searchBands met day=zaterdag → antwoord.
|
||||
|
||||
Maar wat als de vraag complex is? Bijvoorbeeld: "Plan een zaterdagavond met indie en techno, geen tijdoverlap, en als het regent alleen onder een dak."
|
||||
|
||||
Eén tool-call is dan niet genoeg. Je hebt nodig: bands per genre voor zaterdag, het dag-schedule om overlap te checken, én het weer. En dat moet allemaal worden gecombineerd tot één eindprogramma.
|
||||
|
||||
Dat is een agent. Eén stap verder dan tool calling.
|
||||
|
||||
`[SLIDE 3]`
|
||||
|
||||
Hier zien jullie het verschil. Maar belangrijk vooraf: er is **geen harde lijn** tussen tool calling en agents. In de Vercel AI SDK v6 gebruik je voor beide dezelfde syntax. Het verschil zit in hoe je het ontwerpt.
|
||||
|
||||
Tool calling. Typisch een vraag van het type "voer deze functie uit". Meestal één tot drie stappen — ook met stopWhen, want stopWhen kunnen we ook in tool calling gebruiken. Jouw prompt stuurt sterk welke volgorde de AI volgt. Per tool gebruik je typisch één bron.
|
||||
|
||||
Agent. Vraag van het type "bereik dit doel, kies zelf hoe". Vaak vier tot tien-plus stappen, en het aantal varieert per vraag. AI plant zelf de volgorde. Combineert meerdere bronnen door elkaar — database én externe API én meer — in één antwoord. En de AI reflecteert: hij ziet een tool-resultaat en beslist wat de volgende stap wordt.
|
||||
|
||||
Beide kunnen externe APIs aanroepen. Beide gebruiken stopWhen. Het verschil zit in mindset en in hoeveel ruimte je de AI geeft.
|
||||
|
||||
In code zie je dat aan twee dingen: een hogere stopWhen-waarde — acht of meer in plaats van twee of drie — en een bredere, opener system prompt waarin je niet stap-voor-stap voorschrijft.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Wat is een agent?
|
||||
|
||||
`[SLIDE 4]`
|
||||
|
||||
Wanneer voegt een agent écht waarde toe. Want zoals ik net zei: het is geen "wel/niet". Er is een schaal.
|
||||
|
||||
Tool calling is genoeg als je weet welke functie nodig is voor de vraag. Bijvoorbeeld een database-zoekopdracht of een API-call. Het antwoord ligt in één bron. Het pad is voorspelbaar — je kunt het in twee of drie tool-calls zelf beschrijven.
|
||||
|
||||
Een agent is écht zinvol als de vraag open is. "Plan een dag." "Vergelijk twee opties." "Vind het beste alternatief." "Concludeer hieruit." Dan moet de AI meerdere bronnen combineren, en het pad is onvoorspelbaar. Soms drie stappen, soms zeven. En soms triggert het eerste resultaat een vervolgvraag — "die band is uitverkocht, zoek een alternatief".
|
||||
|
||||
Risico's van agents — waarom niet altijd. Ze lopen langer. Meer stappen betekent meer tokens en hogere kosten en hogere latency. Ze zijn lastiger te debuggen door non-determinisme: twee dezelfde vragen kunnen verschillende paden bewandelen. En voor simpele vragen is een agent gewoon overkill — tool calling is sneller én goedkoper.
|
||||
|
||||
Pragmatisch advies dat ik vaak geef: start met tool calling. Pas als je merkt dat je system prompt steeds langer wordt om stappen voor te schrijven, schaal je naar agent-niveau. Want dan kan de AI dat beter zelf bepalen dan dat jij het in tekst probeert te beschrijven.
|
||||
|
||||
`[SLIDE 5]`
|
||||
|
||||
Hier zien jullie de agent-loop. Lees mee.
|
||||
|
||||
Gebruiker stelt een vraag. Die gaat naar de LLM. De LLM kiest: een tool aanroepen, of direct antwoorden? Bij een tool: voer 'm uit, resultaat terug naar de LLM, en de loop herhaalt.
|
||||
|
||||
Hij stopt wanneer de stopWhen-conditie waar is. In ons geval `stepCountIs(8)` — maximaal acht stappen. Of als de LLM zelf besluit "geen tool meer nodig" en gewoon een antwoord geeft.
|
||||
|
||||
Soms duurt het twee stappen. Soms zes. De agent beslist.
|
||||
|
||||
`[SLIDE 6]`
|
||||
|
||||
Welke stappen nemen we om bij een eindproduct te komen voor onze Polderfest-agent.
|
||||
|
||||
Eén: bedenk welke tools de agent nodig heeft. Voor ons: searchBands om bands op te zoeken in Supabase. getStageSchedule om een complete dag op te halen. En getWeather — een externe API call naar Open-Meteo voor het weer.
|
||||
|
||||
Twee: schrijf de system prompt. Vertel de agent wat hij is, welke tools beschikbaar zijn, en welke werkwijze hij moet volgen.
|
||||
|
||||
Drie: voeg stopWhen toe. Veiligheid — maximaal acht stappen.
|
||||
|
||||
Vier: bouw een UI die de tool-calls laat zien. Anders is het magic zonder zicht — voor onderwijs cruciaal.
|
||||
|
||||
Vijf: testen met echte vragen.
|
||||
|
||||
Dit gaan we zo live zien.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Nieuwe AI SDK functies voor agents
|
||||
|
||||
`[SLIDE 7]`
|
||||
|
||||
De eerste nieuwe functie kennen jullie al: `tool`. Dezelfde syntax als Les 12, maar essentieel.
|
||||
|
||||
Drie velden. `description` — wat de AI hierover leest om te beslissen of-ie deze tool wil gebruiken. `inputSchema` — een Zod-schema dat de AI automatisch invult. En `execute` — de functie die draait als de tool wordt aangeroepen.
|
||||
|
||||
Voor searchBands hebben we genre en day als optionele parameters. De AI bepaalt zelf welke combinatie hij nodig heeft.
|
||||
|
||||
`[SLIDE 8]`
|
||||
|
||||
Dit is de kern. `stopWhen` is wat van tool calling een agent maakt.
|
||||
|
||||
Bekijk het code-blok. We voegen één regel toe: `stopWhen: stepCountIs(8)`. Maximaal acht stappen.
|
||||
|
||||
Zonder die regel doet de AI één tool-call en stopt. Met die regel mag-ie blijven doorlopen tot acht stappen zijn gehaald of tot-ie zelf besluit klaar te zijn.
|
||||
|
||||
Andere stop-condities die je kunt combineren. `hasToolCall("finishPlan")` — stop als een specifieke tool is aangeroepen. Dat is handig als je de agent dwingt eerst een eindplan te maken voor-ie stopt. Of eigen functies die naar de output kijken.
|
||||
|
||||
`[SLIDE 9]`
|
||||
|
||||
Het mooiste van tools: ze hoeven niet alleen je eigen database te zijn. Externe APIs werken net zo.
|
||||
|
||||
Belangrijk om eerlijk te zijn: dit kon ook al in Les 12 met tool calling. De execute-functie is gewoon JavaScript — een fetch is altijd al mogelijk geweest. Dit is geen nieuwe agent-feature.
|
||||
|
||||
Wat agents wel échte meerwaarde geeft: omdat de loop meerdere stappen mag duren, kan de agent het resultaat van een externe API gebruiken om vervolgens een tweede tool aan te roepen. Dus eerst weer ophalen, dan op basis van dat weer bands zoeken. Die combinatie is wat agents onderscheidt.
|
||||
|
||||
Bekijk de getWeather-tool. Beschrijving: weer-forecast voor een festivaldag. Schema: één datum-string. Execute: een fetch naar Open-Meteo.
|
||||
|
||||
Open-Meteo is een gratis weer-API. Geen account nodig, geen API-key nodig. Perfect voor demos.
|
||||
|
||||
Vuistregel: wat een fetch kan, kan een tool. Stripe-saldo opvragen. GitHub-issues lezen. Wikipedia. Spotify. Alles via HTTP — alles kan een tool zijn.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Drie agent-extra's bovenop Les 12
|
||||
|
||||
`[SLIDE 10]`
|
||||
|
||||
Drie features die we vandaag bovenop Les 12 zetten. Dit is waar het echt agent-werk wordt.
|
||||
|
||||
**Eén: prepareStep.** Hiermee wissel je per stap van model. Stap nul — dat is de eerste stap, het begin van de loop — krijgt het krachtige model gpt-5.2. Dat doet de planning. Latere stappen, waarin de agent alleen tool-calls uitvoert, gebruiken het lichtere gpt-5-mini. Resultaat: zelfde kwaliteit, significant lagere kosten en latency.
|
||||
|
||||
**Twee: hasToolCall plus finishPlan.** We voegen een speciale tool toe genaamd `finishPlan`. Die heeft een Zod-schema voor een gestructureerd eindplan — een lijst van items met tijd, band, stage en reden. En we voegen aan onze stopWhen-conditie toe: stop zodra de agent finishPlan heeft aangeroepen. Voordeel: gegarandeerd een nette eind-structuur, geen vrije-vorm-tekst die je moet parsen.
|
||||
|
||||
**Drie: een write-tool. `addToFavorites`.** Tot nu toe lazen al onze tools alleen data uit Supabase. Met deze tool kan de agent een band toevoegen aan een `favorites`-tabel. Dat is een gigantische stap — de agent verandert nu de wereld, niet alleen leest hij. Concreet: gebruiker zegt "Zet Band 5 in mijn favorieten", agent roept addToFavorites aan, er staat een rij in Supabase.
|
||||
|
||||
Combinatie: agent plant met het krachtige model in stap 0, voert uit met het goedkope model in stap 1-plus, schrijft favorieten naar de DB als de gebruiker dat vraagt, en sluit af met een gestructureerd plan via finishPlan.
|
||||
|
||||
Dit is écht agent-werk — niet zomaar tool calling met meer stappen.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Roadmap + code-walkthrough
|
||||
|
||||
`[SLIDE 11]`
|
||||
|
||||
Dit is de roadmap. Twee kolommen. Links de dingen die we al hebben uit Les 11 en 12 — het tool-pattern, stopWhen, de Supabase-client, en de bestaande tabellen bands en user_favorites. Daar gaan we vandaag niet opnieuw doorheen.
|
||||
|
||||
Rechts wat nieuw is. Zes echte agent-features: getWeather voor de externe API, addToFavorites als write-tool, finishPlan voor gestructureerde output, hasToolCall als tweede stop-conditie, prepareStep voor model per stap, en de UI met tool-call rendering.
|
||||
|
||||
Voor de komende zes slides geldt: ik open de gedeployde polderfest-with-agent, en in mijn editor. Per slide zie je een zoekterm — STEP N. Daarmee springen we direct naar de juiste plek in de code. Ik leg uit wat er staat. Geen typen.
|
||||
|
||||
`[SLIDE 12]`
|
||||
|
||||
STAP 7. getWeather — externe API als tool. Zoek in de editor op STEP 7 in app/api/agent/route.ts.
|
||||
|
||||
Dit is een fetch naar Open-Meteo. Gratis, geen account, geen API-key. We sturen lat-lon van Utrecht, een datum, en krijgen temperatuur plus regen-kans terug.
|
||||
|
||||
Belangrijk: dit kon ook al in Les 12 met tool calling. Het is niet exclusief voor agents. Wat agents wel doen: ze gebruiken het resultaat van deze tool om een volgende tool te kiezen. Bijvoorbeeld: regenkans hoog → searchBands met stage="Tent".
|
||||
|
||||
`[SLIDE 13]`
|
||||
|
||||
STAP 8. addToFavorites — onze eerste write-tool. Zoek op STEP 8.
|
||||
|
||||
Hier zien jullie iets nieuws. Tot nu toe deden alle tools alleen reads uit Supabase. Deze schrijft. Twee stappen: eerst zoekt de tool de band op naam — we hebben de band_id nodig als FK. Daarna insert in de bestaande user_favorites tabel met een vaste demo-user.
|
||||
|
||||
In productie zou je hier echte auth gebruiken plus RLS uit Les 10 zodat users alleen hun eigen rijen kunnen toevoegen. Voor onze demo hardcoderen we de user.
|
||||
|
||||
Dit is écht agent-werk — de agent verandert de wereld.
|
||||
|
||||
`[SLIDE 14]`
|
||||
|
||||
STAP 9. finishPlan — structured output. Zoek op STEP 9.
|
||||
|
||||
Deze tool heeft een Zod-schema dat een complete plan-structuur dwingt. Een summary plus een lijst items met tijd, band, stage en reden. De agent moet hier zijn eindplan in plaatsen.
|
||||
|
||||
Voordeel: in de UI krijgen we geen vrije-vorm-tekst die we moeten parsen, maar een gestructureerd object dat we mooi kunnen renderen als een timeline-kaart. Komt straks bij STAP 13.
|
||||
|
||||
`[SLIDE 15]`
|
||||
|
||||
STAP 10. hasToolCall — stop-conditie op signaal. Zoek op STEP 10.
|
||||
|
||||
Hier zien jullie hoe we stopWhen combineren. Twee condities in een array. Eerst stepCountIs van acht — onze veiligheid. Tweede: hasToolCall finishPlan — zodra de agent finishPlan heeft aangeroepen stopt de loop direct.
|
||||
|
||||
Effect: gegarandeerde nette afsluiting. Geen agent die nog drie nutteloze tool-calls doet na zijn finale plan.
|
||||
|
||||
`[SLIDE 16]`
|
||||
|
||||
STAP 11. prepareStep — model per stap. Zoek op STEP 11.
|
||||
|
||||
Dit is een mooie. We geven een functie die per stap een model kiest. Stap nul, de eerste stap waarin de agent het probleem begrijpt en zijn plan maakt, krijgt gpt-5-punt-2. Het krachtige model. Stap één en verder gebruiken gpt-5-mini. Goedkoop en snel.
|
||||
|
||||
Resultaat: significant lagere kosten en latency, zelfde eindkwaliteit. Want het denkwerk zit in stap nul. De rest is uitvoering.
|
||||
|
||||
`[SLIDE 17]`
|
||||
|
||||
STAP 13. UI rendering. Zoek op STEP 13 in app/agent/page.tsx.
|
||||
|
||||
Hier zien jullie de truc die het magisch maakt. We lopen door m.parts en herkennen drie types. Een tool-finishPlan rendeer ik als nette plan-kaart met timeline. Een tool-addToFavorites als gele badge "toegevoegd aan favorieten". Andere tool-calls als expandable JSON details.
|
||||
|
||||
Zonder deze UI ziet de gebruiker alleen een eindbericht. Met deze UI ziet hij stap-voor-stap denken — en dat is wat het écht agent voelt.
|
||||
|
||||
`[SLIDE 18]`
|
||||
|
||||
Tot zo ver de code-walkthrough. Voor wie het visueel wil onthouden: dit is het stappenplan van Polderfest naar Polderfest-met-agent. Tien nummerblokken in twee kolommen. Voor de details: zie Les13-Stap-voor-stap.md.
|
||||
|
||||
Nu een live demo van wat dit allemaal samen doet.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — LIVE DEMO
|
||||
|
||||
`[SLIDE 19]`
|
||||
|
||||
Tijd voor de demo. Ik open de gedeployde versie van Polderfest-with-agent in de browser. Jullie zien een chat-interface met de tekst "Polderfest 2027 — Agent" bovenaan. Onderaan een input-veld.
|
||||
|
||||
Ik typ de eerste prompt:
|
||||
|
||||
> Plan een avond met indie en techno voor zaterdag.
|
||||
|
||||
Versturen. Jullie zien onderaan een bericht verschijnen "Agent denkt na". En dan klap-voor-klap zien jullie de tool-calls. Eerst `searchBands` met genre=indie en day=zaterdag. Dan `searchBands` opnieuw, nu met genre=techno. Dan `getStageSchedule` voor zaterdag.
|
||||
|
||||
En aan het eind: een gepland avondprogramma. Compleet, met tijden, geen overlap, gesorteerd per stage. De agent heeft autonoom vier of vijf stappen gezet en het resultaat samengebracht.
|
||||
|
||||
Tweede prompt:
|
||||
|
||||
> Wat speelt er op zondag en hoe is het weer dan?
|
||||
|
||||
Hier zien jullie de externe weer-API in actie. De agent roept getWeather aan voor 2027-07-11. Krijgt temperatuur en regen-kans terug. En koppelt dat aan het schedule.
|
||||
|
||||
Derde prompt:
|
||||
|
||||
> Welke band moet ik NIET missen op vrijdag? Zet hem in mijn favorieten.
|
||||
|
||||
Hier wordt het interessant. Er is geen tool die "must-see"-bands teruggeeft. De agent moet creatief combineren — searchBands met day=vrijdag, kijken naar populariteit-velden, en een mening vormen.
|
||||
|
||||
Daarna kijken we in de code. Cursor open. `app/api/agent/route.ts`. Ik wijs aan: de drie tools, de system prompt, de stopWhen.
|
||||
|
||||
Dan `app/agent/page.tsx`. Vooral het stuk dat de tool-calls toont — die details-tags met JSON-output per part.
|
||||
|
||||
En als finale: vergelijking met `app/api/chat/route.ts` van Les 11. Twintig regels system-prompt + alle bands in context versus deze agent met drie tools. "Kijk hoeveel meer kan met agents."
|
||||
|
||||
---
|
||||
|
||||
## PAUZE
|
||||
|
||||
Tijd voor een kwartier pauze. Ik mute mijn microfoon, koffie halen. Tot zo.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Nu jullie
|
||||
|
||||
`[SLIDE 20]`
|
||||
|
||||
Welkom terug. Nu jullie beurt.
|
||||
|
||||
Doel: bouw zelf een Polderfest-agent. Vanaf de Polderfest-app die je al hebt uit Les 12, of vanaf de starter zip uit `lesbestanden/`.
|
||||
|
||||
In de lesbestanden-map staan drie bestanden. `polderfest-original.zip` — startpunt. `polderfest-with-agent.zip` — referentie als je vastloopt, mag je gerust openen. En `Les13-Stap-voor-stap.md` — elf stappen, volg ze één voor één.
|
||||
|
||||
Werk in jullie eigen tempo. Test met de drie voorbeeld-prompts in de browser. En als alles werkt: pushen naar Vercel — vergeet de environment variables niet, Vercel kent jullie keys nog niet.
|
||||
|
||||
Deel je productie-URL in de chat als je klaar bent. Ik kom bij iedereen langs.
|
||||
|
||||
Vragen? Hand op, of roep me.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — Afsluiting
|
||||
|
||||
`[SLIDE 21]`
|
||||
|
||||
Bijna klaar. Korte samenvatting.
|
||||
|
||||
Het verschil tussen tool calling en een agent zit in één regel code: `stopWhen`. Zonder is het tool calling, met is het een agent.
|
||||
|
||||
Drie nieuwe AI SDK functies vandaag. Eén: tool om een tool te definiëren met description, inputSchema en execute. Twee: stepCountIs om een maximaal aantal stappen te stellen. Drie: multi-step loops via streamText.
|
||||
|
||||
Externe APIs als tool. Open-Meteo voor weer, geen key nodig. Vuistregel: wat een fetch kan, kan een tool.
|
||||
|
||||
En we hebben de Polderfest-app uitgebreid. Van een chat met alle bands in de context, naar een echte agent die zelfstandig data verzamelt, externe APIs gebruikt, en eigen plannen opstelt.
|
||||
|
||||
Volgende les is Les 14. Externe APIs in diepte, samen met Cursor agents en Vercel deploys met preview deployments en GitHub Actions. Belangrijk om je leerlijn af te ronden naar productie.
|
||||
|
||||
Vragen?
|
||||
|
||||
---
|
||||
|
||||
## DRIE DEMO-PROMPTS — copy-paste tijdens demo
|
||||
|
||||
**Prompt 1 — basis multi-step**
|
||||
```
|
||||
Plan een avond met indie en techno voor zaterdag.
|
||||
```
|
||||
|
||||
**Prompt 2 — externe API gebruiken**
|
||||
```
|
||||
Wat speelt er op zondag en hoe is het weer dan?
|
||||
```
|
||||
|
||||
**Prompt 3 — creatief combineren**
|
||||
```
|
||||
Welke band moet ik NIET missen op vrijdag? Zet hem in mijn favorieten.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VEELVOORKOMENDE FOUTEN — voor mezelf
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Vercel kent env vars niet | Add in Vercel → Settings → Environment Variables, redeploy |
|
||||
| Agent stopt na 1 tool-call | `stopWhen: stepCountIs(8)` toegevoegd? |
|
||||
| Tool wordt nooit aangeroepen | Description te vaag — herschrijf in heldere taal |
|
||||
| Open-Meteo geeft niks terug | Check ISO datum-format (YYYY-MM-DD) |
|
||||
| UI toont geen tool-calls | Check `m.parts.map` — zoek op `p.type.startsWith("tool-")` |
|
||||
| Supabase fout in agent-route | `SUPABASE_SERVICE_ROLE_KEY` in .env.local |
|
||||
| Hydration error op /agent | "use client" bovenaan page.tsx |
|
||||
236
Les13-Agents/Les13-Docenttekst.md
Normal file
236
Les13-Agents/Les13-Docenttekst.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Les 13 — Docenttekst (normale versie)
|
||||
|
||||
Dit document is de gewone docenthandleiding bij Les 13: Agents. Voor de **letterlijke voorleesversie** zie `Les13-Docenttekst-Autocue.md`.
|
||||
|
||||
**Vorige les:** Les 12 — Tool Calling
|
||||
**Volgende les:** Les 14 — Externe APIs + Cursor + Vercel deploy
|
||||
|
||||
**Lesvorm:** fysiek, demo-driven, ±1 uur presenteren → pauze → ±1,5 uur "Nu jullie"
|
||||
|
||||
---
|
||||
|
||||
## Voorbereiding (de dag voor de les)
|
||||
|
||||
Loop dit door voor je morgen begint. Drie groepen: accounts, code-deployment, en presentatie-omgeving.
|
||||
|
||||
### 0. Supabase — geen nieuwe tabel nodig
|
||||
|
||||
De bestaande `user_favorites` tabel uit Les 11/12 gebruiken we (kolommen `id`, `user_email`, `band_id`, `created_at`). De `addToFavorites` tool doet eerst een band-lookup en plaatst dan een rij met `user_email = "demo@polderwave.app"` en de juiste `band_id`.
|
||||
|
||||
Tussen demos resetten kan met `supabase-reset-user-favorites.sql` (zit in de zip).
|
||||
|
||||
### 1. Accounts en API-keys
|
||||
|
||||
| Service | Account nodig? | Voor wat |
|
||||
|---------|---------------|---------|
|
||||
| **Supabase** | Ja — bestaat al uit Les 11/12 | Polderfest-database met `bands`-tabel |
|
||||
| **OpenAI** | Ja — bestaat al uit Les 11 | API key voor `gpt-5.2` in `.env.local` en Vercel |
|
||||
| **Vercel** | Ja — bestaat al uit Les 11 | Hosting van Polderfest-with-agent |
|
||||
| **GitHub** | Ja | Repo voor Polderfest-with-agent |
|
||||
| **Open-Meteo (weer-API)** | **NEE** — geen account, geen API key | Externe API als demo voor de derde tool |
|
||||
|
||||
> Open-Meteo expliciet noemen tijdens de les — studenten merken vaak dat het gratis kan zonder registratie en dat is een heerlijk moment om aan te kondigen.
|
||||
|
||||
### 2. Code en deploy klaarzetten
|
||||
|
||||
- [ ] Pak `polderfest-with-agent.zip` uit (uit `lesbestanden/`)
|
||||
- [ ] `npm install`, `npm run dev` — check dat de app lokaal draait
|
||||
- [ ] Open `localhost:3000` — de gewone chat moet werken
|
||||
- [ ] Klik door naar `localhost:3000/agent` — de agent-pagina moet werken
|
||||
- [ ] Push naar een nieuwe GitHub-repo `polderfest-with-agent`
|
||||
- [ ] Importeer op Vercel, vul de env vars in:
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
- [ ] Deploy. Wacht tot live. Check beide URLs werken in productie.
|
||||
- [ ] **Bewaar de productie-URL** — die ga je tijdens de demo gebruiken.
|
||||
|
||||
### 3. Presentatie klaarzetten
|
||||
|
||||
- [ ] Cursor open in de Polderfest-with-agent-map (voor code-walkthrough)
|
||||
- [ ] Browser tabs vooraf openen:
|
||||
- de gedeployde agent-URL (productie)
|
||||
- Vercel dashboard van de polderfest-with-agent
|
||||
- github.com — de repo
|
||||
- `Les13-Stap-voor-stap.md` open in een tweede editor-window
|
||||
- [ ] Beamer-test — slides goed leesbaar, code-grootte oké
|
||||
- [ ] `Les13-Slides.pdf` of `.pptx` klaargezet
|
||||
- [ ] De drie demo-prompts copy-paste klaar (zie eind van dit document)
|
||||
|
||||
### 4. Mentaal klaarzetten
|
||||
|
||||
Twee dingen om scherp te houden tijdens deze les:
|
||||
|
||||
1. **Tool calling vs Agent is een continuüm.** Wees eerlijk hierover — er is geen harde lijn. Studenten waarderen die nuance meer dan een "deze is goed, die is fout"-uitleg.
|
||||
2. **Externe APIs konden ook in Les 12.** Niet doen alsof dit een agent-feature is. Het verschil bij agents zit in *meerdere bronnen door elkaar combineren*.
|
||||
|
||||
---
|
||||
|
||||
## Lesopzet
|
||||
|
||||
| Blok | Onderwerp | Slides |
|
||||
|------|-----------|--------|
|
||||
| 1 | Welkom + Terugblik tool calling | 1–3 |
|
||||
| 2 | Wat is een agent? Wanneer waarde? | 4–6 |
|
||||
| 3 | AI SDK basics: tool, stopWhen, externe API | 7–9 |
|
||||
| 4 | Drie agent-extra's: prepareStep + hasToolCall/finishPlan + write-tool | 10 |
|
||||
| 5 | Roadmap + code-walkthrough (6 STEP-slides) | 11–17 |
|
||||
| 6 | Stappenplan visueel (recap) | 18 |
|
||||
| 7 | LIVE DEMO — gedeployde agent | 19 |
|
||||
| – | Pauze | – |
|
||||
| 8 | Nu jullie — zelf bouwen | 20 |
|
||||
| 9 | Afsluiting | 21 |
|
||||
|
||||
---
|
||||
|
||||
## Blok 1: Welkom + Terugblik tool calling (slides 1-3)
|
||||
|
||||
**Doel:** studenten warmlopen + de stap zetten van "tool calling" (vorige les) naar "agent" (vandaag).
|
||||
|
||||
Open met de vraag *"Wie heeft thuis nog gespeeld met de Polderfest-app?"* om aan te haken bij de vorige les.
|
||||
|
||||
Belangrijke punten:
|
||||
- Tool calling werkt prima voor enkelvoudige vragen ("welke bands op zaterdag?")
|
||||
- Complexe vragen vragen meerdere stappen ("plan een avond met indie + techno + geen overlap")
|
||||
- Vandaag voegen we één route toe — `/api/agent` — die dat aan kan
|
||||
|
||||
**Cruciaal op slide 3:** vermeld expliciet dat er **geen harde lijn** is tussen tool calling en agents. In AI SDK v6 is het dezelfde syntax. Het verschil zit in mindset en in hoeveel stappen je toelaat.
|
||||
|
||||
**Wat veel docenten verkeerd doen:** zeggen dat "tool calling maar 1 stap kan". Niet waar — ook met tool calling kun je `stopWhen` gebruiken. Het verschil is subtieler: bij tool calling is het pad voorspelbaar (jij beschrijft de stappen), bij een agent is het pad open (AI plant zelf).
|
||||
|
||||
---
|
||||
|
||||
## Blok 2: Wat is een agent? (slides 4-6)
|
||||
|
||||
**Doel:** wanneer is een agent echt zinvol, wanneer overkill.
|
||||
|
||||
Goede voorbeelden om te noemen:
|
||||
- Plan-vragen ("plan een dag", "vergelijk twee opties", "vind het beste")
|
||||
- Onderzoek-vragen met meerdere bronnen
|
||||
- Vragen waarbij het eerste antwoord een vervolgvraag triggert
|
||||
|
||||
Geef ook **slechte** voorbeelden om te illustreren dat agent niet altijd het antwoord is:
|
||||
- "Wat speelt er op zaterdag?" → één DB-call, geen agent nodig
|
||||
- "Wie maakt account X aan?" → één auth-call, geen agent nodig
|
||||
|
||||
Slide 5 toont de agent-loop visueel. Loop dit langs en benadruk dat de LLM in elk loopje opnieuw beslist: "ben ik klaar, of moet ik nog een tool?".
|
||||
|
||||
Slide 6 noemt de 5 stappen om een agent te bouwen — dit is de teaser voor het stappenplan op slide 10.
|
||||
|
||||
---
|
||||
|
||||
## Blok 3: De drie AI SDK basics (slides 7-9)
|
||||
|
||||
**Doel:** de bekende AI SDK functies opnieuw uitleggen + bredere context.
|
||||
|
||||
- Slide 7: `tool()` — bekend uit Les 12, herhalen voor zekerheid
|
||||
- Slide 8: `stopWhen` — DIT is de agent-knop. Zonder `stopWhen` doet de AI één tool-call. Met `stopWhen` mag-ie blijven doorlopen.
|
||||
- Slide 9: externe API als tool — Open-Meteo voorbeeld. Benoem expliciet:
|
||||
- **Geen account, geen API key** (perfect voor onderwijs)
|
||||
- **Dit kon ook al in Les 12** — niet doen alsof dit een agent-feature is
|
||||
- **Echte verschil bij agents:** meerdere bronnen door elkaar combineren
|
||||
|
||||
---
|
||||
|
||||
## Blok 4: Drie agent-extra's bovenop Les 12 (slide 10)
|
||||
|
||||
**Doel:** hier wordt het écht agent-werk. Drie features die NIET in Les 12 zaten.
|
||||
|
||||
- **`prepareStep`** — wissel model per stap. Stap 0 = krachtig (planning), stap 1+ = goedkoop (uitvoering). Lagere kosten zonder kwaliteitsverlies. Verkoop dit als production-feature.
|
||||
- **`hasToolCall("finishPlan")`** + `finishPlan` tool — speciale tool met Zod-schema die de agent **moet** aanroepen als laatste stap. `hasToolCall` zorgt dat de loop direct stopt. Resultaat: gegarandeerd gestructureerd eindplan dat je in de UI mooi kunt renderen.
|
||||
- **`addToFavorites`** — eerste write-tool. Schrijft naar Supabase-tabel. Tot nu toe lazen alle tools alleen. Dit is een grote stap — de agent verandert nu de wereld. Benoem productie-risico's kort (veiligheid: RLS, audit-logging).
|
||||
|
||||
Combinatie maakt het tot een échte agent: plant met krachtig model → voert uit met goedkoop → schrijft favoriet als gebruiker dat vraagt → sluit af met gestructureerd plan.
|
||||
|
||||
---
|
||||
|
||||
## Blok 5: Stappenplan Polderfest → Polderfest-met-agent (slide 11)
|
||||
|
||||
Korte intro op het stappenplan — nu 15 stappen ipv 11 omdat we de drie agent-features hebben toegevoegd. Wijs naar `lesbestanden/Les13-Stap-voor-stap.md` voor het zelf-bouwen straks.
|
||||
|
||||
---
|
||||
|
||||
## Blok 6: LIVE DEMO (slide 12)
|
||||
|
||||
**Doel:** studenten laten zien hoe een werkende agent zich gedraagt, voor ze zelf gaan bouwen.
|
||||
|
||||
Volgorde:
|
||||
|
||||
1. **Open de gedeployde agent-URL** in de browser (productie).
|
||||
2. **Prompt 1**: `Plan een avond met indie en techno voor zaterdag.`
|
||||
- Studenten zien meerdere tool-calls verschijnen (searchBands × 2 + getStageSchedule)
|
||||
- Wijs naar de details-tags in de UI — die expanden om te zien wat de tool deed
|
||||
3. **Prompt 2**: `Wat speelt er op zondag en hoe is het weer dan?`
|
||||
- Hier zien ze de externe weer-API in actie
|
||||
4. **Prompt 3**: `Welke band moet ik NIET missen op vrijdag?`
|
||||
- Open vraag, AI moet creatief combineren
|
||||
|
||||
5. **Code-walkthrough** in Cursor:
|
||||
- `app/api/agent/route.ts` — wijs naar de drie tools + `stopWhen`
|
||||
- `app/agent/page.tsx` — wijs naar `m.parts.map` met de tool-call-weergave
|
||||
- Vergelijk kort met `app/api/chat/route.ts` van Les 11 — "kijk hoe weinig code het is voor zoveel functionaliteit"
|
||||
|
||||
---
|
||||
|
||||
## Blok 6: Nu jullie (slide 12)
|
||||
|
||||
**Doel:** zelfstandig werken, ±1,5 uur.
|
||||
|
||||
Studenten kiezen tussen:
|
||||
- Eigen Polderfest-app uit Les 12 als startpunt
|
||||
- `polderfest-original.zip` uit `lesbestanden/`
|
||||
|
||||
Met `Les13-Stap-voor-stap.md` als 11-stappen-gids. `polderfest-with-agent.zip` is referentie als ze vastlopen.
|
||||
|
||||
Loop rond, laat ze in eigen tempo werken. Geef ruimte voor vragen via chat én fysiek.
|
||||
|
||||
Doel-deliverable: live productie-URL in de groeps-chat plakken.
|
||||
|
||||
---
|
||||
|
||||
## Blok 7: Afsluiting (slide 13)
|
||||
|
||||
Korte samenvatting + vooruitblik Les 14. Vragen-ronde.
|
||||
|
||||
---
|
||||
|
||||
## De drie demo-prompts — copy-paste-klaar
|
||||
|
||||
**Prompt 1 — basis multi-step**
|
||||
```
|
||||
Plan een avond met indie en techno voor zaterdag.
|
||||
```
|
||||
|
||||
**Prompt 2 — externe API gebruiken**
|
||||
```
|
||||
Wat speelt er op zondag en hoe is het weer dan?
|
||||
```
|
||||
|
||||
**Prompt 3 — write-tool + finishPlan**
|
||||
```
|
||||
Welke band moet ik NIET missen op vrijdag? Zet hem in mijn favorieten.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten — wat tegen studenten te zeggen
|
||||
|
||||
| Probleem student | Wat te zeggen |
|
||||
|------------------|---------------|
|
||||
| "Mijn agent stopt na 1 tool-call" | "Heb je `stopWhen: stepCountIs(8)` toegevoegd?" |
|
||||
| "Tool wordt nooit aangeroepen" | "Description is te vaag. Maak 'm heel concreet." |
|
||||
| "Open-Meteo geeft niets terug" | "ISO datum-format checken: `YYYY-MM-DD`" |
|
||||
| "Vercel deploy faalt" | "Env vars in Vercel toegevoegd?" |
|
||||
| "Hydration error" | "`use client` bovenaan page.tsx vergeten?" |
|
||||
|
||||
---
|
||||
|
||||
## Voor jezelf: 4-zinnen kernverhaal
|
||||
|
||||
Voor de afsluiting, samenvattend in vier zinnen:
|
||||
|
||||
1. Tool calling en agents zijn geen verschillende technologieën — het is een schaal.
|
||||
2. `stopWhen` is wat een agent mogelijk maakt: zonder doet de AI één stap, met mag-ie doorlopen.
|
||||
3. Wanneer een agent echt zinvol is: open vragen, meerdere bronnen, onvoorspelbaar pad.
|
||||
4. Externe APIs als tool is geen nieuwe functionaliteit — wel iets dat agents vaker echt benutten.
|
||||
78
Les13-Agents/Les13-Lesopdracht.md
Normal file
78
Les13-Agents/Les13-Lesopdracht.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Les 13 — Lesopdracht
|
||||
## Bouw je eigen Polderfest-agent
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Werkvorm:** in-class, na de pauze (±1,5 uur)
|
||||
**Status:** klaar = productie-URL in de chat delen
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Aan het einde van deze opdracht heb je een werkende Polderfest-agent
|
||||
met zes nieuwe features bovenop Les 12: getWeather, addToFavorites,
|
||||
finishPlan, hasToolCall, prepareStep, en een UI die tool-calls toont.
|
||||
|
||||
Live op je eigen Vercel-deployment.
|
||||
|
||||
---
|
||||
|
||||
## Wat je nodig hebt (zit allemaal al klaar uit Les 11/12)
|
||||
|
||||
- Je Polderfest-app uit Les 12, of `polderfest-original.zip` uit lesbestanden
|
||||
- Supabase project met `bands` en `user_favorites` tabel
|
||||
- OpenAI API key in `.env.local`
|
||||
- Vercel account
|
||||
- `Les13-Stap-voor-stap.md` als gids
|
||||
|
||||
---
|
||||
|
||||
## Stappenplan
|
||||
|
||||
Werk in jouw eigen tempo. Hierbij de hoofdlijn — voor de exacte code
|
||||
per stap zie `Les13-Stap-voor-stap.md` en `polderfest-with-agent.zip`.
|
||||
|
||||
1. **Project klaarzetten** — Polderfest-original uitpakken of eigen versie gebruiken, `npm install && npm run dev`
|
||||
2. **Nieuwe route** — `app/api/agent/route.ts` aanmaken (kopie van chat-route)
|
||||
3. **`addToFavorites`** — write-tool toevoegen (insert in `user_favorites`)
|
||||
4. **`getWeather`** — externe API (Open-Meteo, geen key)
|
||||
5. **`finishPlan`** — Zod-schema structured-output tool
|
||||
6. **`hasToolCall`** — extra stop-conditie naast `stepCountIs(8)`
|
||||
7. **`prepareStep`** — model switching (krachtig → goedkoop)
|
||||
8. **UI** — `app/agent/page.tsx` met tool-call-rendering
|
||||
9. **Testen** — drie demo-prompts in de browser
|
||||
10. **Deploy** — Vercel + env vars
|
||||
|
||||
---
|
||||
|
||||
## Op te leveren
|
||||
|
||||
Tijdens de les in de chat plaatsen:
|
||||
|
||||
- Productie-URL van je `/agent` pagina
|
||||
- Screenshot van je Supabase Table Editor met minimaal één nieuwe
|
||||
rij in `user_favorites` (gemaakt door de agent)
|
||||
|
||||
---
|
||||
|
||||
## Test-prompts (gebruik deze om te valideren)
|
||||
|
||||
| Prompt | Verwachte tools |
|
||||
|--------|-----------------|
|
||||
| "Plan een avond met indie en techno voor zaterdag." | searchBands × 2 + getDaySchedule + finishPlan |
|
||||
| "Wat speelt er op zondag en hoe is het weer?" | getDaySchedule + getWeather + finishPlan |
|
||||
| "Welke band moet ik NIET missen op vrijdag? Zet hem in mijn favorieten." | searchBands + addToFavorites + finishPlan |
|
||||
|
||||
Werkt prompt 3 niet (geen rij in `user_favorites`)? Check of de service-role-key
|
||||
in `.env.local` staat en of de tool de `band_id` correct ophaalt.
|
||||
|
||||
---
|
||||
|
||||
## Vastgelopen?
|
||||
|
||||
- Open `polderfest-with-agent.zip` als spiekbriefje
|
||||
- Zoek in de code op `// STEP N` voor de exacte plek per feature
|
||||
- Vraag in de chat — ik loop rond
|
||||
|
||||
Veel succes!
|
||||
105
Les13-Agents/Les13-Lesopdracht.pdf
Normal file
105
Les13-Agents/Les13-Lesopdracht.pdf
Normal file
@@ -0,0 +1,105 @@
|
||||
%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 6 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260609144911+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260609144911+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Count 2 /Kids [ 5 0 R 7 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2066
|
||||
>>
|
||||
stream
|
||||
GauHLgN)"=&:N^lqC+j20\23NWS.SiZe9TTRlE,o?DuQr6j'`KUdtP"J,T*)%LA"H=keXmeC2lpqXe@;6).eYIsOb<2^:;/a+4U8c%$bWJC,\@1$X2U0jtCC0S/JKVRlnsX/d=5k.M="Mcm3%3B3JP*8:J2iQQ:H#6_08D/M^m0RM@1^hcZM*<V\H:!9g8f'CNmK!mS)Bj'llqU.PtRHH?+[#W?]r^[nunC":>X)9VNS$iWu-1jELr-AD4UH,4.,)QHsOsT<D1)LC-_taJK`AIisN6+^0h8&6>g7Y:l0K!J-L:s_]'%88#-F-Zo-0K_*J[^kk+quOZQ7,J6FO"^Se-Z)K&GI_Z\*P!@=l?;G5Sl$dR\cU_n?s<B1tNLf^&!>%SS=tCe_9/W`H7T]:i#+H/Yq[Vl8r=Uof20#S(2_i7[qtFk4LPd,G<@\^AOns''jk%*X.jL`t:B./r1Kn:(rE0GWt#=:+CD@C&9N!VE(+01qj@,Pc?[CmjR)ZMq[q%Nn.mmRa%Gj]2E5rPB2L<NYtNXgnjA9P"PZggcp-UHWVa\LfgCqNu!-mVn&o;*GdK.dqlLf06"U4DMuZl)3>X=SehV0W+q[0Jh&':o:'F$JK"aak8;'9'ML6L@@@WO?"`nD#O/Tq4e_4U$e1S]>$<NI]r0tQ.j2nW/[nd17BB[rJaO4FaH>J#]6"UbPkb[2;Ft1$qd8IPlRemd<k1UBjnGacq6o__MrArMqIt]5WX(2#1&A9=K[:e%`&CA-f28idhigttf^ZljLL)+PF9#8AV[0P4h'%aS@ps@@0O_@]`\3DKodI<K%\ucnnEEe&`CVmlH's`20:so6&!^AU6LIA,e'>BZQVEZiR6t]&_rXA'cte4255G"M]",ojJceY"/@VP]</]]2J3qOsL#9A1jGrf:n>u_sKh73f%2O@O^elK`.n0DtEIe&<&*Ng>%e\^b(#(9VCZqQq'o;i!c1^d9B/3\fqc[nQIGd%D'P[Cc@!Ic>2<oq^0Ul8B_)ND+XV*TS=)CCMW]?[^5GFh4gB`EF9G5q-NX(qYOCFRQ6]GfGco;oITctdM=C=Vk4="1_;Sb`2>n9.-SE/e'MZ&82Le;EklGJc!gel6$I9^WfKcrl1U$G:W`\<9#LU;HL]Jk]!0ZWN!#qdnLJ=KA)d5sqln1u=2bl#g`(KZ.r#I'<SLj8U)%"\K`'4[HgFb]Dda<kKMQOB2W#T3pX4d&Fff.^\^i2AZ6i7M=$0)`2pWG.)o1%?q7:,*6QT.5aoYX!CaRM&<[+LuT/Lm>bSan:Wa:Y(9Je=WeWdV.CMo.qN5E%,?`?coHQdG6o[r/?OYV7l<OA66suPP_cj<NA@7ZgLjKk-f]b8/e"UH)oY7?XZqoGuru&CRaj6gqY-=&C%54UfXB?DPW-9lJ)rT4$VT&:eFH4l%B0cjji<njM4+hn._7i]cII.odZ7@RA)1@2s:uV@2G@=3L]@:6-6G`\4SLOG?.^s]bQs**%@+ICLm>8GZB)R&?h-LL3,O@^Uer"r2CU`/d;a%2%(V.Bh(:&7_2sqkp+6hU+h2k]P)FurcOZ,mXK3;^6n.j0r(Q-qN*%aGdrd.A4VOrZfQ:iSs#Phgb5(EdPNCH3kqZg,)7(fi-$^Ae4-)N+SNe(?gho1KN;mK,?&5&bLP+?\:(-tNCBJ`E#D.ShYW[6r#7MRdG-^N#6%>!S6-N5l8JX'?:Mh9Uh2qe5bSX2IQ\UX;WZEDFB"]0[GL]3U)7UUlLj+ZhRr:!K4pY`DhARZCH(q^@1s*PI(NtSO#oO1PuTsh&FM6qb'ql1[s1i[hu-NPMrcNbO/cJ$/6HDFdaU1q8[@pOa^?bU@D!lJ:aCYn?K;Xo0BuJeHC1e`[(8G!:^m:_?kk')C37:,gl(HQ@U`*,<JK=Km\3%9HR<8&/>q`8^5uq'%8t>f.2Ce$rbWAH`FuH84i;ZhOdStX>'gb/oFk%7pS-5AL:<3Oh7.=OZ_9DocuB';&^GGOLesIL?/>5L`,QKqB>MbN!O'P[eXX"XTu%lh-6_NHa>febLAtY9d=ANd5bmS>_u$0l617$>JA,I~>endstream
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1871
|
||||
>>
|
||||
stream
|
||||
Gb!;dD,8nW&H9tYR-UZW.#Ycf^G"g)_B#PSV'Vs9'rbC'8aOCcqUV._o':5FEC,KQ#"@&_&0gW3lncu34>Roq/Gcg@hsgj-FUq6mD6Q%m#%5'eL#+mNlQ,qF`44+qf)t/gQ4n7GO^l*j%?CkcI-*p]Z6?RV('ZD$U!:G.$A$[?+KtrE#_Q2K2R(!+K$XZ:g7BD_Xo/b$3N&WXk4sJ5^;%K#)L)kc)fu4(r^4\=L#"_r[-G0FZG"&+h?5n.5?qR2?ZLs&S4]3eB<<j_2=rm>i/\7jM_iRgM<ZDZ7aQKn%TkINH5?)l2gB$.*EKI>PZSj8'Z7!`^fQJB,SjV2I)f]iB=YrE4gsXrF"cf<=EPVY1(&s<+['l.HZfO("h"T;!IAe3(Im`;G!,Hui@7=KC)gJ"e[@EGEcUS)<Z,h)e.=ZnXN7F61)EB-`k=a13CdFfd\S)E6pRU!1tGTZ8QSZ03RscO2Hul/&3c$HrW1l2:NJ]7<KCC0,P$5gp\8#Ej1$YPYq2&3B*a2&Q%ioL_JH-u=MBpH6=3^j:=t',K,4JiBF,0A`IAdXV8?+Y]HW\#dk[$lk"slG3_m"j#lW&Y/urfui5L*M[a.*dbuF9;L(]]TPiYZ_gh3<34Ff2204fMEAY1V61UWf$U+B*Y&.<nadMpZcpX[%bc3pZl;d+>Z('uH7Gm?G/=kJQ@@#$t+SK:[SC_<%&;,&>rOkipW8Z=5]$Mk]ZPe=+>#lM9gcf2?L*T6Y?]RGcM/q7^2qAN@--$0Ptb[;bseH58\%?/ULh2$.7\@uT0D-Z"Qrc5j6o_;(*lGh*3oR&&Q,NR?l<>3/B7,8sP]+9p.ZEZ+uCJrO&7L9iq$%@nm1l,)(U!/5;'c+Q&7Bl./*a_A*LgJ?.k=DMb*<]S43]?SG41(>eD,2R,;aOb>R8=.6Yl"7F43Eun7bCV+\\,;TY,l9,>pAhIh1&=K>f@%!4]86qY$XmrE?crdT")lScDWs#<U8h$VOE>Q3V4?<2Qm1>%&^RZGaWj.C)≤CWWRr*>B.PQ^C.5g6%H1feI)kR;1N&n;B-,Y'9[lWET=N(^NqjKR"mgI8sOK&qTV'a5iF9=6[NDq$T_'AbeR3AH+?'7#l\ak21CIe*6'_k7aO0CnT#/R?6"\?_LjAkBcs/RadPauHX)hJ@5M]lpruF%Ir<HsL2^kk+-%<*r$GC[*fb?+B4,aE=8s>&"b\B!fF$-<P*ule3+k5M$9X>;+3f%obq^.9Ij<V.;F5,'>&d>+??@Ag[-;KLV5_+a^I!A7N,9U#P^P-kaZ-LY<]Fc8J)h<rBJ%]0e?Ml9'g/X%(:4%i".Ek82SEW^P&gTE895*PiIX"#H2J=Tp6?^I;,"q[bY4>@?Y:?VosNllUg1C4K_VVU7dKnt]]4%.>jpHDEJ7[3pTK7\rmW:Z3feVJDBXJOqaL?s`OtF3n5oUDBiL+HiNJcPP$7%C<%:^ZdU:@WiT/LWYG%7Gb#IoH=qbI/<GjH#UADPf\>GdpG+?D&5U\%@J4rI&k1F[_H(OVO0h:p.4<^<$Zj4<M=u%jBC0h6F8pJ:&?S>iD#4")!&J'.4q-&0m8i+YrfrIRk-=```Oj-n8;r]V.g'80\nIgY-".e2"0ar9T"5@P*VM<.YY.)9M)3Hl0Yh?4fd_o(-ciJda%f!\_q+crL0mt_toR^icMqSF3@cJq%9s!'E5RO@r^'E489&TTG?!Tk1%ocVZ"ou2so2`RGDHACU2#3;QpSM2cTC$;1%a+?O`T6-&rf53*lKBcF([;'=d=u?,HIS'shM<_=&SF2HJsNX!H@=OShl(:1jLk,O)TKe<O&e.eVVXRR/W`,"55ln9NIm+-uHWJ=u^#AfA]lrO-kp*;p7X,s\m~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 13
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000122 00000 n
|
||||
0000000229 00000 n
|
||||
0000000341 00000 n
|
||||
0000000446 00000 n
|
||||
0000000651 00000 n
|
||||
0000000728 00000 n
|
||||
0000000933 00000 n
|
||||
0000001002 00000 n
|
||||
0000001290 00000 n
|
||||
0000001356 00000 n
|
||||
0000003514 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<3c3ecab7e0474d3949798c7db62413af><3c3ecab7e0474d3949798c7db62413af>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 9 0 R
|
||||
/Root 8 0 R
|
||||
/Size 13
|
||||
>>
|
||||
startxref
|
||||
5477
|
||||
%%EOF
|
||||
358
Les13-Agents/Les13-Lesstof.md
Normal file
358
Les13-Agents/Les13-Lesstof.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Les 13 — Lesstof
|
||||
## Agents — een Polderfest-app die zelf nadenkt
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Vorige les:** Les 12 — Tool Calling
|
||||
**Volgende les:** Les 14 — Externe APIs + Cursor + Vercel deploy
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Wat is een agent?](#1-wat-is-een-agent)
|
||||
2. [Tool calling vs Agent — een continuüm](#2-tool-calling-vs-agent--een-continuüm)
|
||||
3. [Wanneer voegt een agent écht waarde toe?](#3-wanneer-voegt-een-agent-écht-waarde-toe)
|
||||
4. [De drie AI SDK functies die we gebruiken](#4-de-drie-ai-sdk-functies-die-we-gebruiken)
|
||||
5. [Externe APIs als tool](#5-externe-apis-als-tool)
|
||||
6. [De agent-loop](#6-de-agent-loop)
|
||||
7. [Stappenplan: Polderfest → Polderfest-met-agent](#7-stappenplan-polderfest--polderfest-met-agent)
|
||||
8. [Veelvoorkomende fouten en debug-tips](#8-veelvoorkomende-fouten-en-debug-tips)
|
||||
|
||||
---
|
||||
|
||||
## 1. Wat is een agent?
|
||||
|
||||
Een **agent** is een Large Language Model dat in een loop draait, tools gebruikt, en zelfstandig kiest welke volgende stap nodig is om een doel te bereiken.
|
||||
|
||||
Een voorbeeld maakt het concreet. Stel een gebruiker stelt deze vraag aan onze Polderfest-app:
|
||||
|
||||
> "Plan een zaterdagavond met indie en techno, geen tijdoverlap, alleen onder een dak als het regent."
|
||||
|
||||
Hierin zitten meerdere vragen verstopt:
|
||||
- Welke indie-bands spelen er zaterdag?
|
||||
- Welke techno-bands spelen er zaterdag?
|
||||
- Wat is het schema (om overlap te checken)?
|
||||
- Hoe is het weer op zaterdag?
|
||||
- Kunnen we al die info combineren tot één avondprogramma?
|
||||
|
||||
Een gewone chatbot beantwoordt één vraag tegelijk. Een agent pakt alle vijf de sub-vragen autonoom op, in de juiste volgorde, en levert een eindantwoord.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tool calling vs Agent — een continuüm
|
||||
|
||||
Belangrijk om vooraf te zeggen: **er is geen harde lijn tussen tool calling en agents.** In de Vercel AI SDK v6 gebruik je voor beide dezelfde syntax: `streamText`, `tool()`, `stopWhen`.
|
||||
|
||||
Het verschil zit in **hoe je het ontwerpt en gebruikt**.
|
||||
|
||||
| Aspect | Tool calling (Les 12) | Agent (Les 13) |
|
||||
|--------|----------------------|----------------|
|
||||
| Vraag-type | "Voer deze functie uit" | "Bereik dit doel — kies zelf hoe" |
|
||||
| Aantal stappen | Meestal 1-3 (ook met `stopWhen`) | Vaak 4-10+, variabel |
|
||||
| Wie kiest de volgorde | Jouw prompt stuurt sterk | AI plant zelf, autonomer |
|
||||
| Bronnen combineren | Per tool één bron | DB + externe API + ... door elkaar |
|
||||
| Reflectie / bijsturen | Nee — direct antwoord | AI checkt resultaat, beslist vervolg |
|
||||
|
||||
Belangrijk: tool calling kón in Les 12 al externe APIs aanroepen. De `execute()` van een tool is gewoon JavaScript — een `fetch` is prima. Het is niet alsof tool calling daar beperkt in is.
|
||||
|
||||
Wat agents onderscheidt is dat ze **meerdere bronnen door elkaar combineren** in één antwoord, omdat ze meer stappen mogen zetten en zelf de volgorde bepalen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wanneer voegt een agent écht waarde toe?
|
||||
|
||||
**Tool calling is genoeg als:**
|
||||
- Je weet welke functie nodig is voor de vraag (een DB-zoekopdracht, een API-call)
|
||||
- Het antwoord ligt in één bron
|
||||
- Het pad is voorspelbaar — je kunt in je eigen prompt al beschrijven "doe eerst X, dan Y"
|
||||
|
||||
**Een agent is écht zinvol als:**
|
||||
- De vraag is open: "plan", "vergelijk", "vind het beste", "concludeer"
|
||||
- De AI moet meerdere bronnen combineren in één antwoord
|
||||
- Het pad is onvoorspelbaar — soms drie stappen, soms zeven
|
||||
- Het eerste resultaat triggert vervolgvragen ("die band is uitverkocht — zoek alternatief")
|
||||
|
||||
**Waarom niet altijd een agent?**
|
||||
|
||||
Agents lopen langer. Meer stappen = meer tokens = hogere kosten en hogere latency. Ook lastiger te debuggen door non-determinisme — twee dezelfde vragen kunnen verschillende paden bewandelen.
|
||||
|
||||
**Pragmatisch advies:** start altijd met tool calling. Schaal pas naar agent-niveau als je merkt dat je system prompt steeds langer wordt om "doe eerst dit, dan dat" voor te schrijven — dan kan de AI dat beter zelf bepalen.
|
||||
|
||||
---
|
||||
|
||||
## 4. De drie AI SDK functies die we gebruiken
|
||||
|
||||
### 4.1 `tool({ description, inputSchema, execute })`
|
||||
|
||||
Dezelfde functie als in Les 12. Drie velden:
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
searchBands: tool({
|
||||
description: "Zoek bands op genre en/of dag.",
|
||||
inputSchema: z.object({
|
||||
genre: z.string().optional(),
|
||||
day: z.enum(["vrijdag", "zaterdag", "zondag"]).optional(),
|
||||
}),
|
||||
execute: async ({ genre, day }) => {
|
||||
// Supabase query
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
- `description` — wat de AI hierover leest om te beslissen of-ie de tool wil gebruiken
|
||||
- `inputSchema` — Zod-schema, AI vult de parameters automatisch in
|
||||
- `execute` — wat draait als de tool wordt aangeroepen. Gewoon JavaScript: DB-query, fetch, computatie
|
||||
|
||||
### 4.2 `stopWhen` — de "knop" om de loop te bouwen
|
||||
|
||||
```typescript
|
||||
import { stepCountIs } from "ai";
|
||||
|
||||
streamText({
|
||||
model: openai("gpt-5.2"),
|
||||
messages,
|
||||
tools: { ... },
|
||||
stopWhen: stepCountIs(8),
|
||||
});
|
||||
```
|
||||
|
||||
`stopWhen` is een conditie waarop de loop stopt. `stepCountIs(N)` is de meest gebruikte: stop na maximaal N stappen.
|
||||
|
||||
In Les 12 hebben we dit ook gebruikt, vaak met `stepCountIs(3)`. Voor agents zetten we 'm typisch hoger — 8 of meer — omdat we de AI ruimte willen geven om zelf het juiste pad te kiezen.
|
||||
|
||||
Andere stop-condities (combineerbaar):
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(8),
|
||||
hasToolCall("finishPlan"), // stop als specifieke tool is aangeroepen
|
||||
],
|
||||
```
|
||||
|
||||
`hasToolCall` is handig als je de AI dwingt een "klaar"-tool aan te roepen voor-ie stopt.
|
||||
|
||||
### 4.3 `hasToolCall` — stop op specifieke tool (NIEUW)
|
||||
|
||||
Combineer `stepCountIs` met een **stop-zodra-tool-aangeroepen** conditie:
|
||||
|
||||
```typescript
|
||||
import { stepCountIs, hasToolCall } from "ai";
|
||||
|
||||
stopWhen: [stepCountIs(8), hasToolCall("finishPlan")],
|
||||
```
|
||||
|
||||
Effect: zodra de agent een tool genaamd `finishPlan` heeft aangeroepen,
|
||||
stopt de loop direct — ongeacht hoeveel stappen er nog over zijn. Dit
|
||||
forceert een nette afsluiting.
|
||||
|
||||
Combineer dit met een tool die **structured output** vraagt:
|
||||
|
||||
```typescript
|
||||
finishPlan: tool({
|
||||
description: "Roep dit ALS LAATSTE aan met een gestructureerd plan.",
|
||||
inputSchema: z.object({
|
||||
summary: z.string(),
|
||||
items: z.array(z.object({
|
||||
time: z.string(),
|
||||
band: z.string(),
|
||||
stage: z.string(),
|
||||
reason: z.string(),
|
||||
})),
|
||||
}),
|
||||
execute: async ({ summary, items }) => ({ summary, items, ok: true }),
|
||||
}),
|
||||
```
|
||||
|
||||
De UI kan dit object renderen als een mooie timeline of kaartenset.
|
||||
Geen risico op vrije-vorm-tekst die je moet parsen.
|
||||
|
||||
### 4.4 `prepareStep` — dynamisch model per stap (NIEUW)
|
||||
|
||||
Geef per stap ander gedrag:
|
||||
|
||||
```typescript
|
||||
streamText({
|
||||
model: openai("gpt-5.2"), // default
|
||||
|
||||
prepareStep: ({ stepNumber }) => {
|
||||
if (stepNumber === 0) {
|
||||
return { model: openai("gpt-5.2") }; // krachtig — planning
|
||||
}
|
||||
return { model: openai("gpt-5-mini") }; // goedkoper — uitvoering
|
||||
},
|
||||
|
||||
// ...tools, stopWhen, etc.
|
||||
});
|
||||
```
|
||||
|
||||
Eerste stap (begrip + planning) = duur, krachtig model. Latere stappen
|
||||
(simpele tool-calls uitvoeren) = goedkoop, snel model. Zelfde resultaat,
|
||||
significant lagere kosten en latency.
|
||||
|
||||
`prepareStep` kan ook andere dingen aanpassen per stap: `activeTools`
|
||||
(welke tools beschikbaar zijn), `system` prompt, etc. Voor vandaag
|
||||
gebruiken we het alleen voor model-switching.
|
||||
|
||||
---
|
||||
|
||||
## 5. Externe APIs als tool
|
||||
|
||||
Tools hoeven niet alleen je eigen Supabase-database te zijn. Externe APIs werken net zo. Een tool is gewoon een functie — wat erin gebeurt is aan jou.
|
||||
|
||||
Voor onze Polderfest-agent gebruiken we **Open-Meteo** — een gratis weer-API die geen account of API key nodig heeft. Perfect voor demos en onderwijs.
|
||||
|
||||
```typescript
|
||||
getWeather: tool({
|
||||
description: "Weer-forecast voor een festivaldag.",
|
||||
inputSchema: z.object({ date: z.string() }),
|
||||
execute: async ({ date }) => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", "52.0907");
|
||||
url.searchParams.set("longitude", "5.1214");
|
||||
url.searchParams.set("daily", "temperature_2m_max,precipitation_probability_max");
|
||||
url.searchParams.set("start_date", date);
|
||||
url.searchParams.set("end_date", date);
|
||||
url.searchParams.set("timezone", "Europe/Amsterdam");
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
return {
|
||||
temperatureMaxC: data.daily.temperature_2m_max[0],
|
||||
rainChancePercent: data.daily.precipitation_probability_max[0],
|
||||
};
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
**Vuistregel:** wat een fetch kan, kan een tool. Stripe-saldo opvragen. GitHub-issues lezen. Wikipedia. Spotify. Alles via HTTP — alles kan een tool zijn.
|
||||
|
||||
> Dit is een mogelijkheid die ook in tool calling van Les 12 al bestond. Het verschil bij agents: omdat de loop meerdere stappen mag duren, kan de agent het *resultaat* van een externe API gebruiken om vervolgens een tweede tool aan te roepen op basis daarvan. Dat is de échte meerwaarde.
|
||||
|
||||
### 5.1 Write-tools — de wereld veranderen, niet alleen lezen
|
||||
|
||||
Tot nu toe lazen al onze tools data uit Supabase of externe APIs. Tools
|
||||
kunnen óók schrijven. Dat opent een hele nieuwe categorie: agents die
|
||||
**iets doen** ipv. alleen iets opzoeken.
|
||||
|
||||
```typescript
|
||||
addToFavorites: tool({
|
||||
description: "Voeg een band toe aan de favorieten van de gebruiker.",
|
||||
inputSchema: z.object({
|
||||
bandName: z.string(),
|
||||
}),
|
||||
execute: async ({ bandName }) => {
|
||||
// 1. Zoek band op naam (we hebben band_id nodig — FK in user_favorites)
|
||||
const { data: band } = await supabase
|
||||
.from("bands").select("id, name")
|
||||
.ilike("name", `%${bandName}%`).limit(1).single();
|
||||
if (!band) return { error: `Band niet gevonden` };
|
||||
|
||||
// 2. Insert in bestaande user_favorites tabel
|
||||
const { data, error } = await supabase
|
||||
.from("user_favorites")
|
||||
.insert({ user_email: "demo@polderwave.app", band_id: band.id })
|
||||
.select().single();
|
||||
if (error) return { error: error.message };
|
||||
return { added: true, band: band.name, favoriteId: data.id };
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
De gebruiker zegt *"Zet Band 5 in mijn favorieten."* — de agent roept
|
||||
`addToFavorites` aan — er staat een rij in `user_favorites` met
|
||||
`band_id` als foreign-key naar `bands`. We gebruiken de bestaande
|
||||
tabel uit Les 11/12, geen nieuwe tabel nodig. Voor demos hardcoderen
|
||||
we `user_email` op `"demo@polderwave.app"`; in productie gebruik je
|
||||
echte auth + RLS (zie Les 10).
|
||||
|
||||
Belangrijke veiligheidsoverweging: write-tools zijn machtig én riskant.
|
||||
Een agent die kan schrijven kan ook fout schrijven. In productie:
|
||||
- Beperk wat een tool mag (alleen INSERT op specifieke tabellen)
|
||||
- Beperk per user (Supabase RLS — Les 10)
|
||||
- Log alle writes voor auditability
|
||||
|
||||
---
|
||||
|
||||
## 6. De agent-loop
|
||||
|
||||
Visueel:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────┐
|
||||
│ Gebruiker stelt een vraag │
|
||||
└─────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
┌──▶│ LLM │ → kies een tool of geef antwoord
|
||||
│ └──────┬───────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ Voer tool │ → bv. searchBands of getWeather
|
||||
│ │ uit │
|
||||
│ └──────┬───────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ Resultaat │ → terug naar LLM
|
||||
│ └──────┬───────┘
|
||||
└──────────┘
|
||||
|
||||
Stopt wanneer: stopWhen-conditie waar wordt
|
||||
```
|
||||
|
||||
Soms duurt het twee stappen. Soms zes. Soms acht — dan kicked `stepCountIs(8)` in. De agent beslist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Stappenplan: Polderfest → Polderfest-met-agent
|
||||
|
||||
Compleet stap-voor-stap met code-blokken: zie `lesbestanden/Les13-Stap-voor-stap.md`.
|
||||
|
||||
Samenvatting van de 11 stappen:
|
||||
|
||||
1. Polderfest-original uitpakken (Les 12 versie) of eigen versie gebruiken
|
||||
2. Zod check — al geïnstalleerd
|
||||
3. Maak nieuwe route `app/api/agent/route.ts`
|
||||
4. Voeg eerste tool toe: `searchBands` (Supabase)
|
||||
5. Voeg `stopWhen: stepCountIs(8)` toe
|
||||
6. Voeg tweede tool toe: `getStageSchedule`
|
||||
7. Voeg derde tool toe: `getWeather` (externe API, Open-Meteo)
|
||||
8. Schrijf system prompt
|
||||
9. Maak UI-pagina `app/agent/page.tsx`
|
||||
10. Testen in browser met drie voorbeeld-prompts
|
||||
11. Deploy naar Vercel
|
||||
|
||||
Twee referentie-zips staan in `lesbestanden/`:
|
||||
- `polderfest-original.zip` — startpunt
|
||||
- `polderfest-with-agent.zip` — referentie als je vastloopt
|
||||
|
||||
---
|
||||
|
||||
## 8. Veelvoorkomende fouten en debug-tips
|
||||
|
||||
| Probleem | Oplossing |
|
||||
|----------|-----------|
|
||||
| Agent stopt na 1 tool-call | Heb je `stopWhen: stepCountIs(8)` toegevoegd? |
|
||||
| Tool wordt nooit aangeroepen | Description is te vaag — schrijf in heldere taal wat de tool doet |
|
||||
| Tool wordt te vaak aangeroepen | System prompt is te open — wees specifieker |
|
||||
| Loop blijft hangen / oneindig | Check `stopWhen` — zonder maximum kan hij hard doorgaan |
|
||||
| Open-Meteo geeft niets terug | Check ISO datum-format: `YYYY-MM-DD` |
|
||||
| UI toont geen tool-calls | Loop door `m.parts` heen, herken `p.type.startsWith("tool-")` |
|
||||
| Supabase error in agent-route | Check `SUPABASE_SERVICE_ROLE_KEY` in `.env.local` |
|
||||
| Vercel deploy faalt | Env vars in Vercel toegevoegd? Build logs lezen |
|
||||
|
||||
**Debug-tip:** zet `console.log` in elke `execute` om te zien welke tools de agent aanroept en in welke volgorde. Heel leerzaam om dit één keer te doen.
|
||||
|
||||
---
|
||||
|
||||
## Verder lezen
|
||||
|
||||
- Vercel AI SDK docs over tools en agents: <https://ai-sdk.dev/docs/foundations/agents>
|
||||
- Anthropic over "Workflows vs Agents": <https://www.anthropic.com/engineering/building-effective-agents>
|
||||
- Open-Meteo API: <https://open-meteo.com/en/docs>
|
||||
231
Les13-Agents/Les13-Lesstof.pdf
Normal file
231
Les13-Agents/Les13-Lesstof.pdf
Normal file
@@ -0,0 +1,231 @@
|
||||
%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 6 0 R /F5 11 0 R /F6 13 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 /Symbol /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F6 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260609144945+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260609144945+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Count 8 /Kids [ 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 14 0 R 15 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2195
|
||||
>>
|
||||
stream
|
||||
Gatm<gN)%,&:N_Cm%fg_2%:E)i!%=ta3CIN`,2#JFWRPHcltk6U.BukjMkQa"i6ZR9;WHW)VG*,HT='t=HJ&m^SL^t/c^>>_lNM7#_hdWLgrG!^4:E;j-sISO8=kE(?7:\-s-LK5>]9f#-MTa!=]<RgUF]]JA[5_H,,!j3o;;K'EVl"$j)a`ffjVqil(N"\$MZ/*i3Q(Z!>T_[b'.1ETLM7H*qk[VZ*5DPTY*FX]FRd2K=-]`X<X^q<@_q53/ssVmgrC_V_X1dp!+l8prZ\LUU_EdX3L$T4.X4`\3R-Sl3Vh(kUph._QC).J&s.EZ)kg('<_Lpl#J:)E_38jY:6?J]eW#KCBd#Y$2a'mqPO589#2CeFqCU*nDBFZl=?G2;/'Sbd(N@W3tHOB/-"]ctXdNcr.!NG%PL]*iDJEXIZUXD0BQ.C=q`>MVkghrC>*Vj,@1E2q9+B;hhPU,R1:t_@_dV`D.;NA3TJ&:4:=J[`c;8Bi%I1`nD:FHTPnNDeJE3S)"tZiRjDNf5_2VU^PDOW(R39N$b7JYNGeapLqHCWI`!^F`-iMMtp%o7N)!bU2Bcn6X:g.1Uj7O-M+9i5cTFO%]Tu>J.grDC-@JD-;1I@L^Yp-1_Rc$PgTu,98F2]pEH.o(.KL;b=>J7NVKNX@&ii&g(\`Og'W3`m-8SC$G'nPmaJV52=#,/TU0%sqQj<lOCB[\"`:B;.Z'qdE;k1$l+U4"1.hR%<&`L[I<KM"Q"<ic&N/*PJS=I/B*70a"n=;VbK$@pWgrE*LGP<V(p$aAbeBF#Qqr/p&-]J&gBpfZo/)2Lq04GX9l.dHjTsCQ*ihZg9pheL(*<nG\6(ErO[T=L0jnVug3>"_7gJYR5:MI`^tIgp$,=9F<Dhl^re$S571,/ZVYTs,5<a#]e#'+&LEbh^cF20.'>T4N#jh6dJ,PCkG?*V^Ye["lcnu>L/mLQ@cj<gTnCqRIKWFTbclLIrY^0I-JM_sAkm+V(UM0:6R>N7V(@8'Rf4/Wt\B^!XmVR,Sa((Xo:p\E8*CW3"hn,h.g[_Ulft5Q?p2>r+_;90Aa1O;XWWnN!^7JuHq't+a<-nb@Oe0h67f]tWY5^=C+3n/$mIel3lX$i/l:F#5c^.Hp;H?;%W/fG9s.,*t,3E/Zc(m19g8q.^e5qTmSKprk,YiMnS/'MNRd%&r"%5T.[1UkMP=`3O0]-"]"K3bhJQXW[f;45<X:XV(*sRIi";j$"(ihG6Z:p&Uf"$TJ`rh\_Q`/>Q(PW)o!1Ab&)@WTB/MO4/*CJ%sJZ0G.Ic;J._+C;>#!`hAM9K0);1\ZkhVG?kIY?B(S&tkLT'A'!"N9;s==&$N.9IQ'6WVWIR$_GA2]HHEV9M)1[aGCHnP94D3rfup85M2OI@-=nnjFYZRR06U4rc'X5,qNE@(<,6O5>D+O"HF6!R:$hc^-+T:Mk24FYLJWPXU'eGa(?(T'fa)j1CJ!CD,;U%C=["]2[6UYYFWoFhOn85X.Qt7_P;D6:d)u6HhC3cA9$tV#(,#.h2Z-$9\+a4<K2s%pQ2j8Cr,nbk"9R2U1fX`.El=8DL:U;^T[c6,Ln>VPL1;:&K*r%m[D/6)H7maM,A)4tJsD/+B<n'<trCD0W_&,FnHi37(NdLH=^EBPG*%XXrqJV6sMD@=]Hg7@;%EP_23k8G/\!mAtW:Z5;hR(aefb<Q*%WU>m)_X1&@*gal',QmX@2mR0)P9q7u$di0f\6^B5_1n?9Lm88npUjsC]#Z^QlF0<F$F#J4*8^p].-pHH0Y9I)%iEX7%OK,LP(.(P"XsA=h>\d*/!^.@!6u(Qd=<eA3KOG(1L;V9F.f1lXD3%A(Irh7>L_sQdiCtd%D^ag>ROm"d=BpRS0AgPP\lGrU4I<$5s5bm)\fl7Rh#LTHp-p[kopeb`j.1AdIKA%UFj&5P.kC9*>A9Pc*u?$R[=J7b.mKqN$X[4*Aj"+NhMK2Ym"!i8`=+0O0a%?S1T>i<VYk+T1i]0qWXZ[(i3&7'0&b,/Z-$,EHlq?f*//CBcI%+mYfM4B]&4fiQc:S+M,[<(jD1!`7?N>JQ[#7&C(=;f5:Rk4oBM]EA1E7)3:#8eZ:+%$i:Mdm'Zd$G7&hHB^n)41Wi-gd[YplEq,.\FV_1)LnDjrs4iM\&+hk_C7n2e<YfH/)(KHXj^7$u_X%Fe,,#q-.dI.$PC"D#B`rFM.%8Q~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2377
|
||||
>>
|
||||
stream
|
||||
Gb!;egN)%<&q/A5FQnWW.%%LU(VachV.=EjRrr2CZs1M9+YeWm!]()^;NAJ?:CdF<,XkR2Wa$k+#u8h=p%7L29d>&>k3"iS\t%.%T5CkSROlPR/<;-mdSH(55L1n$G62\VQ5bk>a<<'Sq1HiN9[VM%r5a_2$:fX^"s/&`D1uC4Oor6;YQH@$/Uf[caU7rC&:.l@p=KoS"*rMh*=#)]f%B_lIJHpYOO]7?^XWg/2IG)F/*ticc*"3^NU1X=VFaO8hu8'&OZqts<qBOt1\smQb5#mK;o]4.rC=G^CROERQ>^GW8!>%iJDR/p+2S.Ff\Q1$=]Mk91n/k.8ZDnR]a]FG#7o>TK?stG,7gBdgEZ<5@tBKQaMbmI7%f_SYsTc,6qhnFO2tfOhu;taH^^lCVKNO;W7Y2"[B6]NLN`,;C;,^EDXVIl,I=5B)P0t*@O>&Z;,t\2.Zu)@Lg$+L8H*W$plO;#qq8JBle?4i25M&Db*nDb1)Dbfka_9KQC0./4Q5OP.Fn(8;jKJ>mq:nYXDbE:K9i()pC[MV`Ns%;Hq2(\ACiIbV3$l7P!hcZ$7"(fdp2(B<65dAe/H-\Nc"br,h?/<7B)JKjLI7Wr:`AAI&F#ecgOm1;'kW!;([9L,)NtaMA&V31[$ZZ6Ikg6QZ6BIFX]_1]Lue$hCU<g6'4]<;^%GaCs40,e%5e&P,d(!!p^M+ZDt=\S@QVCZY>LN??N_L/*-5(B*lkH6c/C&!*Rku\X$//GD]KmK=#b'0[o^m0tZDl"lh#d/9lX#pm\oNIDKX1k#aHKI%S=C<$eoSke6M?_._DKY#V$me^P&@r3Z$)r3b)BOgn\U/:6IS;*^?B2K[\Mp0pdL9:6*OP_]pflYC`@//Tu\A-*4tO\o^p#Qn$GW\7eOD["!C#VU4><E1A7HU`/ZYH2o.med*61]b:Uj]lEQkIt4"dMY2WN<a5@8'K+U\thE%ZpL4XOGW(ObSH^)J#QV!h<=$EHWOmS,+lr[p&:r.K=.fKF>/,aH6jJEEH<Y\rlSk!g=Z"W`QA1sPL91+PXC0-daO-r;II-uLr_.J&Pce#6k\>!ZDblEWU\ks)igu3jX?8k][U"6kOg7S"VSOZ=8^aR!S0K@`ep.,\[;CKQ&>L')[FXrp@Z:dq`7O-A8%+3:Q'P(p$pNGinutu`k6bj^Y(e5mR5H7nIA)`RE=Ei2Eq_J>plirT[`\6##Z?oG=QJAcp^_Y5&+cl2$<VBrDt.<.GkK&?Yb/o(FUk9C0mZhXj[(DPk(C;-(5gr4[tUT)R*VBELFZZY'<:!+5+daoNYlji#fY=W^TdlV0ZD4<p!ae<\.cg\BL?4P&dErD7Q9\ICm\1&52RHm*Uk]>)P-N1RT7^FrV?])n:[*Wu25USp!H`<qMS^h%NtA?Eg<-<QW!d\NHR$.h0!?!;X$Bl`b69q6:OL-WE-b?'a46P:[q<Tkl7R)*)-Z_Nl+aYgj%c>N)VUqN1d>Z39'0s7mmG=dFd>5X,7J(ZMCEOcfZ2/i%,rnL`#64#lQ5hMpT7;DoC0V\mOk='B3U6LUjoZ!lBEE2-TJ[1N!BrN"(+?l70LgqA[aJNr,jm`_uScuB)o%`VDu%&b>bQi?dCR5Ll=%@6-\FQ/W)I6#UIgh:5,CitaN*HrK@(-G29T#i)2mC*&VeFd+d%BG%7VR=*6<[<>J[7$ipQu`d!Y5Bd4X'.^<Sl&t>O=#%?@3^TQ-E_ts@P,=<bI$L'X8VE%ZC]qpClH&b1<kMAgj&?]V38B(KlMFZ\/;3#Lgi,HR\$$McX6gWj8TQYo7Y`b.CAp'Duq^;ULc!dRLb3?Nq!A_V("C,h-Sq(fC=k;<.E2,'7U:6LiYIL`d,4HLUNF_VXG&$Y*k3K#./a(`nh>q\Y%n0gU$7^4eApWNm30j9?gb#D?RTS;q>,!bq.6N.dR/LNO]j[kgioY+#(18JOT6SH[5nORkK!^^I`5FSN$Z-SRu_^eG%*E>iE>Il]"]'LOGchq'r;;(J;`InWOh%38)O&3?<&IY:_ILZs;l?/d7t/1k>i">d/^%)Z+<_"HhH)KF'3/GDgXJ_nN^o&2p^'E>MfmXd_%Y)*#[<ZWnqh1&dCdj%ddPcsgU7Q\osn`m\u?8VFcFfDY.L.&lE[k6EIYD4Gc/H?gfTY<-,Z#:'hZ!ZXD5S4DGZb4ue-aBj"c$KpW)Y8PWRPF`<L*;$2?1,:L\'q<#T;6;-/R;4\+<\]2P(s,j5BQ*uc@]j.p6(dRNqE<Pj?U&_A`N@[!Gum$3UF?64/[ZERNum@c^3WU3?mN3k!S(bM,^b=!NWnf-k]Cdh=2=a[$MG.qBY;+0S,Wq;B*4/JRhk4@Mt9FWg/25c)i!@d#hX#+.%PS#\5)MP/S!M47s+HS\6[^r~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2227
|
||||
>>
|
||||
stream
|
||||
Gb!#\D/\/e&H;*)EU]=*7Dal1)s(N2g3)5_QYo0s%bNn:Zp`Z+,uKpU]1LEM^8'O9-Z5/X5dA7a9j0YCpQ0UnO;'8DV=jjB"aG1s(G@15#+$j5%WUFtBDCn8nnpd]a*bo2@WT^&(m)Nk5>\F/)7UaX!Z"VjQ;>$ZJAWhp4Q+"oSF9Au$43cR'+2\P/>#!h3-@CPh&1,EO"m0Y+g0u3\&YuQ\:GG-Y=44#Rhm!\-YVgFDq0#e7:"f)b7nQlIXUf#ISaf_L)Zs%pd5iQgh9eDk/5Z7n6GBlU2jC!K$,[SHu(,H(j.s$/pRB#d@XuQC:.f8r$L!7-GUZ>p`17=/9n7MjYKB9CH9I`9e\E-@jgc,RjmW6Z'o5YDU4oWAYr*S:btX1[[%)J?_f6Ge06iB?UJRs>&@hO@V^Tk,E5o-CA\iSD+>eS*F9ES<Gf!<l?I0#aU\>%P07U"dm9d/!L_*U,H57:``ZQDZ>j(:V1_CraCH#WfG27c^TFdMhfc:jZK!3m)1Ip1#lBp.0<g.9jG9^t9R4?2@-WOgRi-Af:+fkWQD4JK0`)<qUdE+X!CX;QAN?rl4A\'pS.ur1U>r9[C"(-AmYt$g8Xumh]FHke,RD):9S`b/GPXF[G>_C"We;mZUPc1.88-I#YE@nQB.tu<RVSE^b,B,X$hgTV1oM+cOck5f<T<LMT:q7gfU9V%n?()A\1-F<*R[u0D8+nrB;F4bB#^ZM%l!)=;_DW6*ltMI&DQ_[_sZao)"/QV2\R15N7VlM<c?n2Y\)WN"=">L,-J2cIf:"^W[uZ0.-S9r]nEdu(0-<?p&W5dCC#kW5]4?o#3PQnS3!6d$]Mr,Zg*J%#@t7OM`B=nb0D-Tnk.pPOJt2Aq-Jdj0$2/QUUT7`?%)qG;^(S!N:?"91pmbB)_K>8P7;,;jO''FaU5q"4q>QOP\`.q%2;,\)L.X.qtF\O9er;Jp*S#&rW(B'j82Opn,&9)K21C@6W]oI4^pF;5O@@b1Z>.uUYI)::/?UmS-5@MOc=?!=Ns2rK"_Ia*?*%Q&ted3f@Id*FdO\L=9gMd2L;Ilpk(M\O5XV*WN2?\D&2NuK`aWS8/C0$r>D\J5QXEC#8C[jn6T1O36`1MQOcegq$=%<"MKVh:JTN/hMB>dS*q=9\`k^$W+a\6K.o<fESU."#'f'Qbs\BB7C[Vtft!V?)\=6X:nLk(eMmrSi0hLT0."*31tFqR(u-!0*1:)?Z&HVi9[?1%>:uV@la3MoTf@fHU6\k9e>)d?)](?hSIoO3(^$Te0Y]8QMap1cJ43oc,G7^U\IN5;c<_d!NA[,(l!jA\FGIsVSXZ50&n,W/]$Di^TDnZ!ah3-`%d0pam,R:0f-^bBD$RESCC[)Oc9a5<X`iTdR;6:B^A\=+#]s>2ffnm']srUE<A'T3Pjki=`V'2ID4C<ClKQ;);HH:mj2B%<AXFQu!pmQAn\'9.:Z#VrQkU(ROP5+\HoJ=qYIcd_ZSCc7E&XA@b<E*H*brtS__7tWIkl*"Pdlrug<Xon?mg;k;mL$IL3M]R/L0s;*#]-gM.NAWV*Q;:=\BC/E(a=\EET%":oj3?Lq>!`mGm7.24C:*rc/_+Vki927>#]&R\IPrFC)D@+*lR?8PN>?$BZ7K<J%0VlGR7]+!&l.Ecdi*0[2?3?96e>ER/MOKSXcQ6i,V8Mc`h,=s`^k6/[+#="8hE`D4(KGMjI45<H(?`#5YP%QAf@c9g%"Pt(c6*NsqlP`fJ1*<bbZ<IG;4imB@XF!J$#Xh9rRh.A./RR"YXU9JU)J5Feq+f^d<Y/tn]s$19qABMnjp/#I#;]j4GS^O5uo?]d;FZm.\l5Bo(eb*#LA(G(k0<DO@ML.">n>)>!X/\k./3cL@%1E%8.l&R[.FLlbIU%W_.a4m/j6mu3q-CpsflWeNF-j,qBRrCN`i1L^4H>^PK5T4I'>#[]4oca:[&Vt4_Hj[qLFb)jHkc@XlMT]VebKbIbjGSto<VR)o1h?)90@$'&>C/&c*;#E-[La@0%/!(9qWNXR@YGpqJ,QaPhF.5!gj>KejgssN&esITCGhL5#0@6_Q3+!joJ!EJ@;L;`WSHD`67Sm#TL<ZU?d'H7b9r*D3+;tG!^$2X,hTj\*nW<i/P:F&C#pYNRYP-i[^6*f=@l>^%:oV;j1cQb)5>5Wc"idggf\^X%3e#MCEaV?Q((YF8(5?C_"DIKSHNW7Tee*a^jONN(;tk4Mp/d_GDb!~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1804
|
||||
>>
|
||||
stream
|
||||
Gb!#\>Ar7S'Roe[3:N,0,Rd7M87gG(XA-/(6.[^VqCiF.,La(68TZ7=>?^[6mnic2DX:ar\&=(S>5Z\=qfieZ'Rs9Y2bF+"SA=B)#)pH1%a6.=9Wm;_1AWsS4Q+*JJ[glS+U8Xkp':0--j)[V6-)CtOpWCe#G,Hob"r/[I6oUg\VBp<&Ha3Y.\m;;1-^l:ep`qgS>0eZpd#I/mVi#ef"I.JNHaSurB`3NK/Gr;W3/W=o7[^='V%+sr-er:RtWJhV(i:h.ru^_9:0`Pi;=3ol$JtgQ-PFuEXS'If"_:8$RCH<LrZ/i\gU0,Y<h$F7@Ygr"ouDI#$Zb22f8p\+O[//B"gkke??nY;"1Q?W[bBYMN-=McXn):JlYr#Oi_3T)=%@$:1VeB37Bq';(A1ZSNV"&V],[!8P0b%2[H00%'W@[HX1JmV%K#YE$*\!84T6_FbRH8AE6<s2tfmM)[(h?<")i=HVZK`mh/$m_"T=WFtFs_(KF,Yi.<gFFWPWT#h<9-,Q&hKi+q&!dG@n\U:i>h??En>qtk('O3qW$WnQ/*ece-`m6OhhL_AAtk+Iac1?/C[4pbI;X0/_HHb\(`)>pbOg"K0'98(K6]aM(.HM,^fZgtDh3c]Ogr:FleWO+3kFtZiM_8^u(P%<j:kA_7RbI<>N`Wu]dH<TtUW2+rQV5&8VlT5QPV+P!C;V=D"7^$q-[*eU'gALPe*u+iYG!5V?8,Ln4Dc9o)?pGKH"p%_IHEK6YjeY:e/?45<29`ERq<Y:E>Eke'#33;uR5-t*,/iuDe5-AUpVu8AED<a%*5Eh(qCW,lC5(GjrsHMd'dU4`eNAj^>kg"/lpIm,<Gh65=dl>]k47NfqruLW0E$tRP\[]Xf.U?RJL8oa7@ZYBDH.k1Ffq2eGeXL6l][auW_3<5Ap4\=>*1m3d<s70G`i".7ItqLKMap41g^m<nY];B5AY6&4QohIU3.9CU3.un#G\u7DldW5`$nG_-84?L:ns&+Ooo'!SspG6.NX(`\8E<61]p&"d`A>.DneP_Q8D</D30Lo]r\c3cZN3Y6.P7$K%*,"2'jaGig!F+Cl;6*P?W-2C-=5*EdV-8PXS@V.`<mX\Y^E`g>Es'G:PcA/.G?73l*C_AX*c$S='PJHi3@^^<HqrMG'>+4@6("Nb5jjAQ87F=X+B^`;t;K$3%?.4\'4Jp76e7l'e:mDbE[9NllLIB40<PC0b^glonKc;'$i%>l$ib.fXkk@)=-D5dSRO9b8e-gW9[6ZG*=0TNe\ld9UkbdB1b:N:s]oZ$\6t,HRg^@aOUW<N2nAkGL1hH-KbucSdSLlLT7#`WcI`^EG>3mQ&%nL9'q;an=_h3o\n3_M<ZMD0.so("sto4jt,*P(Y%K)N/Ti]lkDJFfLP\`8',llh7s8Cu/ml2NDnF1fa@(Kg:ud?;kM*>fWd=WY)ZCM+aBT(..,nHC-_u]+_bXa&)J#!+h\cB6f..l1uIH2a,.i:0u8`_"ldWUuVUd<2de-*:X3;I]riDY#SmliYa/^3Sd#LR3(bOZSA>,H1#4Gs45T/`+W0@O@;.XI;\rA\?;rfK^XECn]`eU4/>Qaed%Q_*gmskmhS=Km0)s9#M&Yngj2kHV;^5Vg`c&9($gmuMH%p*=Z)LL.]14YWnX[L;"Xj]V=/@S06L=ZP/'leQ96eZaOobO5*'XTah>gFP>^9QAT]3PlL(08%2+&bnj6so*)iC)_smZef^?S2<Na`Z^;^b\);Ad7R7cK`>%4!X4EjkOXP'rS3HI9[rZP:orU0e]i4QP!D":I%E#Mkq*f)NH#>6"Ff`~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2259
|
||||
>>
|
||||
stream
|
||||
Gau`TD0+H"&H;*)Z'.s9fOme,CR?kUT[:su#fP(-#h(fWC\_H(:.Q`o$.Jq"Sn-#bS6G.nBUi0cSY"ZlIJ3BFg0i<h?2om?J8]$Rq;)>1J_:ZPQanes#Hqcm^B`!QS\XoK9g`\73P_Agq$0>DOPorYO.=:GcjEkl\DW*"$c=@qLY`C?4+IB@q8L,R(ALoA]"gYeo0`Q=r@.=YLJ5>(oDRnCq`FgEj'K\%STj.,o-#fV@IFu^U/#!+fj<UQ/;)nW5QC]uhP^S#Gbn@H^Zbg[Q%g6u$/j?<p^T6MWZ6p50Y<%l(WJ]FeFc14Jfbtb^;I8+C>",BnO,nX?3fC)MVoPlGBRkL!A.^uKG=4_ZRn90.6R!A'4qoL!QC6_*fW[O%GW(>6JINs"#mU2Mhduar][Yn/*N"Hq`42>*OEkcaLD'cWZC\u*S=n8#B'Ef1TC&?*?hB!9ffL]&;HarecEuUfSTpb_3pO*>$lLB+'*HmN!HCLbp2i/UjM[G$W5/MMhO5^F:o@oaKZbX-q01a40%mP;UThK_`N9&r9*<4\*l=#MZVe<hZLsL!n0dbJk.M3L)3Fa`u]:SDI/8%?o!9:/pW`I*`+q+<aaJ'S=*qce9l1Q3\N2M"^Yug07j%UP-086Z8i]in`C"fr#0n4&ksg8;7Hi6&4G2[#Nk-kQXCD)%h<SilFBmmHQ8^Te5[[NT/-M"O'gF9.7icV;pC/p.@<6Z;@8h^2sNoWFi2R?NIEuSh[>hAd;J->GB',Z.?;)8'J@sK.qm_>K\e+.H6%.AVQ:`K+%PL,:T#b23_jpk$4^(4fa2kqj;_Z'e,E@-L^#>Y^Hsp^e1p8t_!]sm&8/`Bh4rbuh4mXWJECpcdF)t#Ibd&dVKJ7m*59FEa@-U0S9Qs4U_#P6g#h%.()BnMN"1N>F2QoVf2skJ;O.__7@%G@@+IH#H((&rI%W;Uqh5HJUoBmD+u.p6rIO3RJOG'PC6N^5:IQJH;T)gT89n[GXVH+3JNbNNQgm4#$I@0%ZFs"`Sd7q%$F<;]U$9K,L7OL19cYE;KJf*he/Gi%)!;YK0_10DRId_[/&P!nT1dhM]!jBSYne:K)0VOCh@-l\Y+%D:0K396XQQ)in/uTtOtBm"Gi_*-,s-\#$^Khb@3,9K;ums/Q1G+kS/7GmPET4('l'Ch+l."ZUcKa\E[ru7QH=2W9WGn":*UG4-RZEQcqZPN<K?Y12k5$U6nQ5h39r;DI7N@^#d:DfQK3?p#HQ`VGA84FnThN?V[HO_jG*je%RI]q5[dQ??0-IZ9,oUpXtP<?b@L"&a'1)gkTrMW.!eA7AcX$s6]p;-.:P>$>#pgMK$>(?jVl>QB/59CU$3T834Mu=1\*A^U.t^6e:RWu/^t2,nc@]AV*t,*Q?l5Y8bIAIV]?;=4V*%>%:]5[C/h*A2et1,9F@qVM$t/0M@gR:&u[7W3*$bs+RMYYD1'oZ%oOum5?]3_21mF>:TPK>0/->$fd;q4c)nK-T+N'<5J3*Uq;9_oj8-uVaB[F>$^kX3DQEINcY=f7*jd.]mBnmTg1s3[".458ocL4t`uu#7C=IN\qE8l4_W%l(KO2nnJ(W9EG0P1rRj9jD/X3o$Mt_rn91=U??LISbQP$K&Cj<PL$+oD8q2T#@f8B8W9ZXlpfC92(IL-NYc#8GQ7c.(6UDY5_C:`NRLm-*or<^jg;XfN;4)hjM\2#bN]U(A>@@ntcmD*@)Jh^;h<a?FhF`*%?q!H[W,bS1AkN1f9_K3PPWZ#NI**KAKai9q=K6;182kDef]61Ngk/\)aOVdQQMAJB*l%UC'61%h=C4R0G\F:JM798mdE1.Z3_eC4&P0!`qfoGG`"2&ul3!6nQ,/#qNe53,qF&Jh/_9"lOr,+o3+;VN7gbA*tFk8XGMcAneoCNnQ*$8-DklPRR6@U[7+@"(O5B>qoEXiuKiq%,McSqK7eKVm9-0p5sXpSXjjAe[4Ufd,f5;"==Sqb-."c:-EbCuhQ,qX-n,kJ]_-V'd5c*ZOINhG'BWMHNG*k7D$c2NgqR:dIh%\\@4GMc`X0!Uj`m$)=9>(?53,KH^Lg,1MD[js(@Ui%#J?Ttc8QG*7Z]#UK+FOq2:7jk%FA(IE-cfh<95Llp=Y\i!)[>a^QQDoQ508>.?R4"`)el[(8+OV-m5H:PDmUR5gCYJ2t*1,/K'HL(1Kb6TZILu?M2'7a^XGFG:5+'1\@SLn[K!u(tGb$?:QM1eoOa\ii,<3?(c)qs>`9X\2]gb:OdP!<L(GFtuZN~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2529
|
||||
>>
|
||||
stream
|
||||
GauHLCN%rs(B(CCES&hCMV"88pnis%[QtPUfH>T1RfJ.mL*fD'D2P)7.CjHVs5%gP:K_k*oL&Qg`So$hT)56-$bt,;->T=l1OB%(P-er3+R'6"AoHn`D"@U_Y^JTP4VCM'3K[#[EqkF4jeO$F(OfmbP!UZ1@Gm:f8eHeT82e`sJIJJVZoFUI&qOs)Th?SoSfde6k8iO92H[T.oDRnPq,Y4k`J]E,8d@)?57k^BfdbNNF^/?H0CWY`VWlj-hI"WjHE!oTV0,Q5X%S_ZC7<n#5X/N#9=7^/hPfuoc)t`'ap^a->sEuLI]uW?NN0!^0,c$!a31Q?aWD,i"Vs33b7@kjHBnr^j7/=+Kk"*&Qaq^V8PMdJD:gg-[3dj%)iImais*YQ%c<3smJ+T$8',`190Uk_3RGP^M"ps7*T%O#`?fFOiiCTMZ'\F@Mhf>R]:o&-N1:3VkeehaP:*A$%s/DG'nt"96oLPWiS%\]@)+O<F>?%t:#g9;R'#n[_5:eJC%o^8lmc:I27=47Vto'PKLLpGmM_3B!2%!LC<A#(Wh;52O_7bI/!ZrqAr<t0-M@gW-&M4U/!dQ#$\HQi?_#TAK("Usd#Y3(SLOZA%'/%r`Cj4\eN)l:)V:4R%M@/plOBt].T6L&X#f*RR!63=FBbd)N39u?<=Jt4JaR6OWbl(_EIDTSZ`NGQ>*Xf9++CqsV7J4efelh'\n;+RFufkf7^r4`.1eA:`1TuF3Ee.M895YM0L6Vt---I?)&ncIqqU]e!^moODt#kh@Lq0$mU+[`B56>l5Rt:7+;_?*)Y^H0rT#kSZAjLpVU2ip'9YPD=fn:#$<7aVORNHDbYpEJ>d-M&^ES<Ti%7tk=1>1hOSDXMgE&qt-P#`T[pU)h1I;sRB=asL$em/RA+3u9ki0%U2VYX"Ba^<AgMUJf)Rrt0rDs;oR>ohQU;6$(j#d0g9gDXF?0tVW'4du7mN'O:XV??qqJL3c!*ZG"/i.Y,@a@<lCcBp%E_g*_4OC3-#)/q=D0DX0S(XVs[orf4LE8[aTAs+l%7:e\6<Z-2Xr+</Ued(uK^E22U@lG6JgTLS[=$tg(*=N^L=@M#f*]MJ@CIVP%&kqrH"krd<53#Qa-Ku!)WI]-c)+g!IKcO%RsZDK&;Ht#ej.FtGU:F1iLkf\aP*4-Zl-S)PL[U97K2[pS4etF]d:NRac4(QEUpK!FkokrNblj6S>J+@3o4Q\%ceSKgjJtoi<n.7E.#K'-9qj3.Oh\uSa#.(:>Tco1b%H?jua,Ma%\m((ac9&UKt4S-(\#)S/l$O6Z:`M>)lajg",VTQ!25kF=["F'ahp"W?k4k3uVW4HlK7(PGmaZfm,cP5<U?M"fPUegJYRDH9d4Yn:9HjJGMVZgR<brqG\^CTmJee(k)F&3hh?s_=aPLmIb`'*&sYL&8[?NOcbP]?@t/QHDN5I>A*koZ;g$E>;k1'omaM3D(AWc^?GJZIsQdWn!]@*Gpg2%CAMb0e9St2NkVFd-:YkPg4XU%VIp"m_4V=$=Hn+pT:RaMl=Bln<(L!TiKGf]JqgC&N_51n&r&.[;pB?5%7CVU1]$n-T:\4"h`^c@2L2aV^dU"K^`cbfM!p?XZ9o4DLXS!#d*#/`_1^dm;UsO-J:U[&lLLH/^0hVB'!kP!P^ZV[Uq:+fA0@th1:hjAQ\?XHH[E9,"^Mi3E6YQJAg?FnGL,O/CoR?U,")q3c5:Y5a.G6CIC&+!?ldQ^%6sL_^4]"im75#N"_?t:UGrLG+bj6ajiC5s>3l3)FImMCZ;Ui''<oj'7g!1N5e/trdL-3$0#oM)R!oEl>Yk,i&FRHVf^f.=R#>'VA3c,<MAlf&[q".pm@G<'^4NY5\Q+XWXl:<<B@PW5!mBq;@uhkIC_TO6\%94s,V+%>hL.&451h%2iQrb'l`aXoBSZ8$<,:\u/aKRCn[eGUj>TK&M+s$uAY!*AUOjde%bdJVJSa%VmY-"^DRX>p[N:Og\nK/M.#uOMh@,qo96-ZeIUF/B>cU4H>&5;O8'e]lV95+CZYT5jMamO#i<(ui5,cl]!&G%oAM9HF\Sj@cNdNJH1Ru;T[c'%^Rt;pL]gi"Zd&^o70d4rA)+T&QYQ7EO+"3pD*T,9D#6Xu)dI5`;Ro60=Us=;:kXpsmo1Q`u=7e0Bp!#Q$k:LioSLh_Tn`H.lXT?"ocBleKE%kY5:jH:s!JYBD.0UH&al?s/0sTO'eA",DS'bJ^bJ7E(&=Ud)`\.NoKorSToSQ-H"'=pWGtdQ0"ET?<[J/EBDsQrkE%s,oq4g"FmjDX&%bInU_aAI]c:j;51O<Tc)]NWHKA]TM*@X*"6TO0!LeHMn4L.iG2;MZ716XGR`&rLpVsX+tnb1e>?YA/K<?%36M[WN\KD&ObHY$bq0['jShl$Fgl`Xu_%oZN&F[)1\khpj>"ilrdr>QLM97">"9/FgNd8eSS'3)V=1H6"E=F^2DpD=H.TQP9WNe]Qd#O"'n]hSF]-K^krh8ntneo.V;1O`>G4?lElg#N'+ApU(L>G^of.=igq!l$1ZF8~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1914
|
||||
>>
|
||||
stream
|
||||
Gb!#]?$Dbt&:N_CbbNq!<fdk8o%&lhDU(h_l,ISdg#ElX`\n81$?FSgfC&u]N8rhU[Z_Bf_[`=Wh)hCZ*JK,??bH4uRK:&A^rZfk>ej_U>VZV<F80farNM[YKJ-V=Nj>3@!"0e<Jmr2ci-NTT7)2+A5#]/uWAmg+:`sGAmrKQXK^ii9"7n\1<t^n[Y,AplnIG/D+P2deSep0&^R1WKdgMPPQlSo-r[).I"CH[oX?)Nth"=qibb4^VVlsb&G;d9X1sN</^?>Vg;[l);I!B]:n*pUG/B?`b%)0X;[;jPK.Qp334Fc2N3L6Me`jLqeE"]&lpalT?M8?"l:Fr@h4e'!>#)m.0j#.E1U8N:?Bf>Tc`$Z3N>n:=T8`-_ND6>1N<43[S]%$6<0jE@\qheQ@1U:%Y;PF4kR;/me2,T@7N=4ju%lH.VY)oX5+,Rt)Y>jhr]eX3'I#T\(249-sB]clC^I#:'33oD^Pt0u;%en'M#sji[2H,NmBE]qpJ`+BIiR0*o2rQgni2r((V7XIAQu70(aHFQB<6TNqgYqgSCH[3_n>_krf2t;;1!tN=_G(Vs'55&pUNa*!4\*P&k5BR^&LJ[fHM%8#"Y;:YK)+4Bkf5`gD'nX8[QS`Wr@kOe;TttXYk5<I94pl5q>&)0../qpS3&Za_Y(L[>fTrIH)jZ)V:mmp*(-+YgFOkLqho4lf_:aP./=G4DQlnN#Z"L/HiJ@32oiHZW)WTun9!G3_'M7<p]::T;;?h6JaY`f5';k[RSiIT1B3c.gXQac'#S08I2f%\=YDc5%7<;GOtmJ.gh4'65=*f17a,@jr3]qEN-juNWu6IUg?"u2G$LV0h`7hbjo.S]L)2NmPnG`%DQ_:o:&Off`P5O#'-2i(\S?l=@Qo?"$k"9i[MK871=,L@98etn0]*K%_0I&o<2grp4fInTAX+q*lY0Vh/D"h[VC>to8fBn:^J-HMO0UU"Fc:F#356a[U`XCcC$Sq\M*K^cl)-kmWuet>g2-qJM8aZnJ_jrr.j:A-$Op%aTPkEV2IW1eb+Q?A)p?3uYj$b70I?NQ]#G!oZ>/D_C.Ii=ff[etjfkY;Y86l!c?g+diV*W:"OdQZmg@5`/i?i8HTtb1>p)]W"8CXW*9p"[%r=Co=JKWYh2$rp0stRm<qq?\-6l='oFrp`,f4#LV-^:$[/mDKFN7?rCk%2N^P9KBY2cMtR^FTf#D;n+S)jrS=A;3\2^GI$jqEi+(hF<QYEJ8K)@>&79"DQr9X(=sBG(]?St'<4Sc7`>*B&iD7X'Uf3c[AIc$\tG\$B9!ls5?%9Q*8"j)b>]P_B^43a$(]BrC^fUkcG-kD*VM\!NuqNA&[!C+[IVC?D&,AX%!dWXXK&1Lta0cAPl/*^rgS_m1IE7neaO;L)qb$q#(?A['uu;8tSCAJ?9,'6A+h>p241T.-S,;cK"18"i685XW`FUM0EUMdSB\d</`qLkH'E*S4(99+kD<CVAg6549opS6bV9>s34.Igj5187S1I+r&^8Dm!>K$n#I)T2[]1Uj@d[EZ8b='\%.>e8de'e1XOHJk,]2h)=L+:H*Ki<J.W_nI0c2o/sYm93ggU*F+DqE8g6%KfFU@c&K3`&4TAVYQY\_gB]c6V=SjR^cm8KUK+%*a[#5FD-.IaUhUIcPAQ\J;q:dd+KGSU;g!QqE4_`L:O`45C-G(2F)gmYI%oH&rt.,#>M$`3MP8aT(2prk:SBB"mpdbq;:<c501bVTX"":nWH5lcJG(O8<H8%Zf$EZ3&`$[EX/\o*R%jS$$eXJR1?s@4O=^9_Da7K[oLGFg<KZ+'=B*^*.S,L8Q$J(N7/]0FgU=<Gi7pbim0E5Gk"59Im\@O+0u@J=e)r?o*7XglmZgKKe'8[f`e%)8`r>Ed09''D/'bU2rjrp+Y-nQ#J%0"UZi~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1661
|
||||
>>
|
||||
stream
|
||||
Gau`SgN)"=&:N^l])4rob?-]Fn]R43P7=!lm0)EJWi/P3cj1M'7[Y-5iih5\8]V`FLc8=:0(MFoB282CT5N=idG$:Wdi@;b(f"Gk?op'6KC(B(`c#9>mX4CUa*.Tg&'PJ72faF;Y'GRl3ZRu[o-5Wg+g_:d"s/#_c,).A_4c!;?j@edk0H+IYe[`"ciY';I6[Auf"9.M*89+VIG^*/f@Tpe6SDntc&QI<*s*Yp]6:2?FZj?irLa\]*4l/T?4Z#&85N"s6=fR_9:RUk9H5F1o-%As)Db6`Hj8V8(dBquCk6n]81s\RA'h]`PSWitj=3o&*^dF@n=Ci67k:54LOI@@aO>",]NoeZSq0n5J^^.)Aj$=JWg<S@9XYdBi#.QQYFH&6H)f\W*<_EODNf0Zf!k%nDYDchXC+e1Gr7-olAnW3>DR*MEfNcdKM5*&13T9*jaI`sFfU$A*D9\>\8J-156"*=J(<Sk<%iQ;Jm>e^/9-]5/5h-\;fIZ!Ng7nC./'G*IYXokCbj*&RHsZs6hm=*+'=N=cNhd7Jl`_V;["gbE/g6eKoeri,G.h%MNpX@oU4[_.d?3):p'8)o%s!9T-M<hSR_O-C=4=V)^-s)g;^"$#AdLQ'ZPj_T:8i0GT>E.rX1Irbq\;CTNap:K7eV:_Y"C"FTg;Vk[fBN.[%-=86[P'P>#)T&7.qJP9K(D`RCNEO$#(1bV+lJ.$a@6bC3LuU\7umS4f5j;emX'<'3FgCP*sFmnMgn42PJ0n/?4=#o\Im$fksm)\t(18^^F0]&D]f+_$%=]$1.9N(Ij^D@&3ZD<4O6)3GLTeI]b`5q0$'IZ.&3EC&DPVM.bKLK&g_l4>>N>##kh]q%O5@=^mh&9Jo@Nj5H:/7"%!5i#&]M0<T6prfQHX[,G6.8i/?;WJPhMCLJZ?;\C'<1jSgVXkLgD(V#$=`kZ0q1G/q][O)=>WA7*i0a(<RN,g:A%Z()FP30p`T(9d>"QfqDeZr.@2W#-dTbElc6uXY-KKCj2eiUuVBYKB6]D-[7,l6ceAbe)3l\6jW/(mQQS=?j.dtW9b[Z&*TBXmQpTA^[d70g:-0`ID3F#0u,/>9u>TMM3?<tS%3"%_sW8B)I*oqgt<A"T\@/>5$XDGl@'/d!cpS?U(AFcg]QWkd%BN;u0YsL])$(g^nm!V$2]#,,#2+/1KdDr<Z([%R0QdOa/p$h)$bD49MK.h-IT+*,7^1NcUO5&kt)0q3^MGsUIddeF@<#I9Qcu>r7%JZBL5Us*ANNlLTn7%Yp1WVtY+N+(HojM%Zht4VL0KT[`Q[5&e,Hb5\]-2;Uq!_@?p#3&f<r%C'RQiSF[J+plZP\\;:2>pbXKXG3R9E;aIMc:Z6Y"<YVK&6qbpiCcU&O4eios2A=!C!mLh+i'!f,GApX\7]S8L)jgKrr1/acQ[^:SEZaZ!cfXNVc]7Ri<CR4m"BAeXn+>%@qR(G^506OB:ib540D%Hr`rGN/=5oB!3P4(]Rt7*\tkP9u`9j>/l:gV`FT<b(X",8K=.dA>ZPC71Q].,<PP$-rusq"og/LV9-%3s]^g#-TP;T"d`2Ds4:"$H1X<>aZ4,98Ye-d1aCsn5T$J0@.1885Z23b.3^31eQ(MEMm3HTH%FjaU_\Tm12KYFE"9RSmMs$?)^d53r]9IW<-B~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 27
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000144 00000 n
|
||||
0000000251 00000 n
|
||||
0000000363 00000 n
|
||||
0000000440 00000 n
|
||||
0000000645 00000 n
|
||||
0000000750 00000 n
|
||||
0000000955 00000 n
|
||||
0000001160 00000 n
|
||||
0000001365 00000 n
|
||||
0000001571 00000 n
|
||||
0000001687 00000 n
|
||||
0000001893 00000 n
|
||||
0000001977 00000 n
|
||||
0000002183 00000 n
|
||||
0000002389 00000 n
|
||||
0000002459 00000 n
|
||||
0000002744 00000 n
|
||||
0000002850 00000 n
|
||||
0000005137 00000 n
|
||||
0000007606 00000 n
|
||||
0000009925 00000 n
|
||||
0000011821 00000 n
|
||||
0000014172 00000 n
|
||||
0000016793 00000 n
|
||||
0000018799 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<e5505c20b0aa9b5d2861a5b760b291bb><e5505c20b0aa9b5d2861a5b760b291bb>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 17 0 R
|
||||
/Root 16 0 R
|
||||
/Size 27
|
||||
>>
|
||||
startxref
|
||||
20552
|
||||
%%EOF
|
||||
304
Les13-Agents/Les13-Slide-Overzicht.md
Normal file
304
Les13-Agents/Les13-Slide-Overzicht.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Les 13 — Agents (Klas A)
|
||||
|
||||
> **Deel 3: AI Development** (Les 11–16)
|
||||
> **Lesvorm:** fysiek, demo-driven
|
||||
> **Structuur:** ±1 uur presenteren → pauze → ±1,5 uur zelf bouwen
|
||||
|
||||
---
|
||||
|
||||
## Blok-structuur
|
||||
|
||||
| Blok | Onderwerp | Slides |
|
||||
|------|-----------|--------|
|
||||
| 1 | Welkom + Terugblik tool calling | 1–3 |
|
||||
| 2 | Wat is een agent? | 4–6 |
|
||||
| 3 | AI SDK basics: tool, stopWhen, externe API | 7–9 |
|
||||
| 4 | Drie agent-extra's: prepareStep + hasToolCall/finishPlan + write-tool | 10 |
|
||||
| 5 | Stappenplan Polderfest → Polderfest-met-agent | 11 |
|
||||
| 6 | LIVE DEMO — gedeployde Polderfest-agent | 12 |
|
||||
| – | ☕ Pauze | – |
|
||||
| 7 | Nu jullie — zelf bouwen | 13 |
|
||||
| 8 | Afsluiting | 14 |
|
||||
|
||||
---
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik tool calling
|
||||
|
||||
### Slide 1: Title
|
||||
**Les 13 — Agents**
|
||||
Subtitle: Polderfest krijgt brains — multi-step tools + externe API
|
||||
|
||||
---
|
||||
|
||||
### Slide 2: Wat hebben we vorige les gedaan?
|
||||
|
||||
**Tool calling (Les 12):**
|
||||
- AI kreeg toegang tot tools (functies)
|
||||
- Eén tool per call: bv. `searchBands(genre)`
|
||||
- Goed voor: "ik weet welke functie ik nodig heb, voer 'm uit"
|
||||
- We hebben dit gebouwd in de Polderfest-app — onze chat zocht in Supabase
|
||||
|
||||
**Maar:** wat als de vraag complex is en meerdere stappen nodig heeft?
|
||||
|
||||
Voorbeeld: *"Plan een zaterdagavond met indie + techno, geen overlap, alleen onder een dak als het regent."*
|
||||
|
||||
Eén tool-call is niet genoeg. Je hebt nodig: dag-schedule + bands per genre + weer. En ze moeten worden gecombineerd. **Dat is een agent.**
|
||||
|
||||
---
|
||||
|
||||
### Slide 3: Tool calling vs Agent — een continuüm
|
||||
|
||||
**Belangrijk:** er is geen harde lijn. In AI SDK v6 gebruik je voor beide dezelfde syntax (`streamText`, `tool()`, `stopWhen`). Het verschil zit in **hoe je het ontwerpt**.
|
||||
|
||||
| | Tool calling (Les 12) | Agent (Les 13) |
|
||||
|---|----------------------|----------------|
|
||||
| Vraag-type | "Voer deze functie uit" | "Bereik dit doel — kies zelf hoe" |
|
||||
| Aantal stappen | Meestal 1–3 (ook met `stopWhen`) | Vaak 4–10+, variabel |
|
||||
| Wie kiest de volgorde | Jouw prompt stuurt sterk | AI plant zelf, autonomer |
|
||||
| Combineren van bronnen | Per tool één bron | Meerdere bronnen door elkaar (DB + externe API + …) |
|
||||
| Reflectie / bijsturen | Nee — direct antwoord | AI checkt resultaat, beslist volgende stap |
|
||||
|
||||
**Beide kunnen externe APIs aanroepen** — `execute()` is gewoon JavaScript, een `fetch` is prima.
|
||||
|
||||
**Agent ≠ aparte API**, agent = mindset: "geef AI een doel + tools, laat 'm autonomer beslissen". In code uit zich dat in hogere `stopWhen` waardes (8+ ipv 2-3) en bredere, meer open system prompts.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Wat is een agent?
|
||||
|
||||
### Slide 4: Wanneer voegt een agent écht waarde toe?
|
||||
|
||||
**Tool calling is genoeg als:**
|
||||
- Je weet welke functie nodig is (DB-zoekopdracht, API-call)
|
||||
- Het antwoord ligt in één bron
|
||||
- Het pad is voorspelbaar — je kunt het zelf in 2-3 tool-calls beschrijven
|
||||
|
||||
**Een agent is écht zinvol als:**
|
||||
- De vraag is open ("plan", "vind het beste", "vergelijk en concludeer")
|
||||
- AI moet meerdere bronnen combineren in één antwoord
|
||||
- Het pad is onvoorspelbaar — soms 3 stappen, soms 7
|
||||
- Eerste resultaat triggert vervolgvragen ("die band is uitverkocht — zoek alternatief")
|
||||
|
||||
**Risico's van agents (waarom niet altijd):**
|
||||
- Lopen langer — meer tokens, hogere kosten
|
||||
- Moeilijker te debuggen door non-determinisme
|
||||
- Voor simpele vragen overkill — gewone tool calling is sneller én goedkoper
|
||||
|
||||
**Pragmatisch advies:** start met tool calling. Pas op naar agent-niveau als je merkt dat je system prompt steeds langer wordt om "stappen" voor te schrijven — dan kan AI dat beter zelf bepalen.
|
||||
|
||||
---
|
||||
|
||||
### Slide 5: Hoe ziet de agent-loop eruit?
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────┐
|
||||
│ Gebruiker stelt een vraag │
|
||||
└─────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
┌──▶│ LLM │ → kies een tool of geef antwoord
|
||||
│ └──────┬───────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ Voer tool │ → bv. searchBands of getWeather
|
||||
│ │ uit │
|
||||
│ └──────┬───────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ Resultaat │ → terug naar LLM
|
||||
│ └──────┬───────┘
|
||||
└──────────┘
|
||||
|
||||
Stopt wanneer: stopWhen-conditie waar wordt
|
||||
(bv. stepCountIs(8) of geen tool-call meer)
|
||||
```
|
||||
|
||||
Dit gaat door — soms 2 stappen, soms 6 — tot de agent klaar is.
|
||||
|
||||
---
|
||||
|
||||
### Slide 6: Stappen om bij een eindproduct te komen
|
||||
|
||||
Voor de Polderfest-agent:
|
||||
|
||||
1. **Bedenk de tools** — welke acties heeft de agent nodig?
|
||||
- `searchBands(genre, day)` — Supabase
|
||||
- `getStageSchedule(day)` — Supabase
|
||||
- `getWeather(date)` — externe API (Open-Meteo)
|
||||
|
||||
2. **Schrijf de system prompt** — vertel de agent wat hij is, welke tools, welke werkwijze
|
||||
|
||||
3. **Voeg `stopWhen` toe** — veiligheid (`stepCountIs(8)`)
|
||||
|
||||
4. **Bouw een UI** — laat de tool-calls zien, anders is het magic zonder zicht
|
||||
|
||||
5. **Test met echte vragen** — "Plan een avond met...", "Welke band op...?"
|
||||
|
||||
We gaan dit zo live zien.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Nieuwe AI SDK functies voor agents
|
||||
|
||||
### Slide 7: `tool({ description, inputSchema, execute })`
|
||||
|
||||
Bekend uit Les 12, maar essentieel:
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
searchBands: tool({
|
||||
description: "Zoek bands op genre en/of dag.",
|
||||
inputSchema: z.object({
|
||||
genre: z.string().optional(),
|
||||
day: z.enum(["vrijdag", "zaterdag", "zondag"]).optional(),
|
||||
}),
|
||||
execute: async ({ genre, day }) => {
|
||||
// Supabase query
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
- **description** — wat de AI hierover leest om te beslissen
|
||||
- **inputSchema** — Zod-schema, AI vult dit automatisch in
|
||||
- **execute** — wat er gebeurt als de tool wordt aangeroepen
|
||||
|
||||
---
|
||||
|
||||
### Slide 8: `stopWhen` — de agent-knop
|
||||
|
||||
Dit is wat een agent maakt:
|
||||
|
||||
```typescript
|
||||
import { stepCountIs } from "ai";
|
||||
|
||||
streamText({
|
||||
model: openai("gpt-5.2"),
|
||||
messages,
|
||||
tools: { ... },
|
||||
stopWhen: stepCountIs(8), // 👈 dit
|
||||
});
|
||||
```
|
||||
|
||||
Zonder `stopWhen`: AI doet één tool-call en stopt.
|
||||
Met `stopWhen`: AI loopt door tot conditie waar is — een echte agent-loop.
|
||||
|
||||
Andere stop-condities die je kunt combineren:
|
||||
- `hasToolCall("finishPlan")` — stop als specifieke tool is aangeroepen
|
||||
- Eigen functies die op output letten
|
||||
|
||||
---
|
||||
|
||||
### Slide 9: Externe API als tool
|
||||
|
||||
Tools hoeven niet alleen je eigen DB te zijn. Externe APIs werken net zo:
|
||||
|
||||
```typescript
|
||||
getWeather: tool({
|
||||
description: "Weer-forecast voor een festivaldag.",
|
||||
inputSchema: z.object({ date: z.string() }),
|
||||
execute: async ({ date }) => {
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?...&start_date=${date}`
|
||||
);
|
||||
const data = await res.json();
|
||||
return {
|
||||
temperatureMaxC: data.daily.temperature_2m_max[0],
|
||||
rainChancePercent: data.daily.precipitation_probability_max[0],
|
||||
};
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Open-Meteo: gratis, geen API key. Perfect voor demos.
|
||||
|
||||
**Vuistregel:** wat een fetch kan, kan een tool. Stripe-saldo opvragen? Tool. GitHub-issues lezen? Tool. Wikipedia? Tool.
|
||||
|
||||
Externe APIs konden ook met tool calling in Les 12 — dit is niet nieuw. Het verschil bij agents: ze gebruiken vaker **meerdere bronnen door elkaar** in één antwoord (DB + externe API combineren), omdat ze meer stappen mogen zetten.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Van Polderfest naar Polderfest-met-agent
|
||||
|
||||
### Slide 10: De stappen die we gaan zien
|
||||
|
||||
In de demo straks doen we (en jullie straks ook):
|
||||
|
||||
1. **Polderfest-original** uitpakken (Les 12 versie)
|
||||
2. Nieuwe route `app/api/agent/route.ts` aanmaken
|
||||
3. Eerste tool toevoegen: `searchBands` (Supabase)
|
||||
4. `stopWhen: stepCountIs(8)` toevoegen
|
||||
5. Tweede tool: `getStageSchedule`
|
||||
6. Derde tool: `getWeather` (externe API)
|
||||
7. System prompt schrijven
|
||||
8. UI-pagina `app/agent/page.tsx` met tool-call-weergave
|
||||
9. Testen in browser
|
||||
10. Pushen naar Vercel
|
||||
|
||||
Volledig stap-voor-stap zit in **`Les13-Stap-voor-stap.md`** (in lesbestanden).
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — LIVE DEMO
|
||||
|
||||
### Slide 11: LIVE DEMO — Polderfest-agent in actie
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
1. Open de **gedeployde versie** van `polderfest-with-agent` in de browser
|
||||
2. Plak prompt 1: *"Plan een avond met indie en techno voor zaterdag."*
|
||||
→ Studenten zien meerdere tool-calls (searchBands × 2, getStageSchedule)
|
||||
3. Plak prompt 2: *"Wat speelt er op zondag en hoe is het weer?"*
|
||||
→ Agent gebruikt nu ook `getWeather` (externe API)
|
||||
4. Plak prompt 3: *"Welke band moet ik NIET missen op vrijdag?"*
|
||||
→ Agent doet creatieve combinatie, geeft mening
|
||||
|
||||
**Daarna kijken we in de code:**
|
||||
|
||||
- `app/api/agent/route.ts` — de drie tools + `stopWhen`
|
||||
- `app/agent/page.tsx` — UI met tool-call-weergave
|
||||
- Vergelijking met `app/api/chat/route.ts` (Les 11): "kijk hoeveel meer kan met agents"
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Nu jullie
|
||||
|
||||
### Slide 12: Nu jullie
|
||||
|
||||
**Doel:** bouw zelf een Polderfest-agent vanaf jouw Polderfest-app uit Les 12.
|
||||
|
||||
**Materialen (in `lesbestanden/`):**
|
||||
- `polderfest-original.zip` — startpunt
|
||||
- `polderfest-with-agent.zip` — referentie als je vastloopt
|
||||
- `Les13-Stap-voor-stap.md` — 11 stappen, volg ze één voor één
|
||||
|
||||
**Stappen:**
|
||||
|
||||
1. Unzip de starter OF gebruik je eigen Polderfest-app uit Les 12
|
||||
2. Volg `Les13-Stap-voor-stap.md` — werk in jouw eigen tempo
|
||||
3. Test in de browser met de drie voorbeeld-prompts
|
||||
4. Deploy naar Vercel (env vars niet vergeten!)
|
||||
5. Deel je productie-URL in de chat
|
||||
|
||||
**Vragen?** Type "vraag" in de chat of steek je hand op — ik loop rond.
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Afsluiting
|
||||
|
||||
### Slide 13: Wat we vandaag hebben gedaan
|
||||
|
||||
- Verschil tool calling vs agent — `stopWhen` is de knop
|
||||
- Drie nieuwe AI SDK functies: `tool()`, `stepCountIs()`, multi-step loops
|
||||
- Externe APIs als tool — Open-Meteo, geen key
|
||||
- Polderfest-app uitgebreid: van chat-met-context naar echte agent
|
||||
- Live demo + zelf gebouwd
|
||||
|
||||
**Volgende les (Les 14):** Externe APIs + Cursor + Vercel deploy in diepte.
|
||||
|
||||
Vragen?
|
||||
BIN
Les13-Agents/Les13-Slides.pdf
Normal file
BIN
Les13-Agents/Les13-Slides.pdf
Normal file
Binary file not shown.
BIN
Les13-Agents/Les13-Slides.pptx
Normal file
BIN
Les13-Agents/Les13-Slides.pptx
Normal file
Binary file not shown.
349
Les13-Agents/lesbestanden/Les13-Stap-voor-stap.md
Normal file
349
Les13-Agents/lesbestanden/Les13-Stap-voor-stap.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Les 13 — Stap voor stap: van Polderfest naar Polderfest-met-agent
|
||||
|
||||
Dit document beschrijft welke stappen je doet om van de Polderfest-app
|
||||
(Les 11/12) toe te werken naar een echte agent met vijf tools, een eind-
|
||||
tool, model-switching en een write-tool.
|
||||
|
||||
> **Startpunt:** je eigen Polderfest-app uit Les 12, of `polderfest-original.zip`
|
||||
> uitpakken.
|
||||
> **Eindpunt:** vergelijkbaar met `polderfest-with-agent.zip` — open die als
|
||||
> spiekbriefje.
|
||||
|
||||
## Wat is AL bekend (uit Les 11/12)
|
||||
|
||||
Dit doen we vandaag **niet opnieuw** — het zit al in je Polderfest-app:
|
||||
|
||||
- `tool({ description, inputSchema, execute })`
|
||||
- `stopWhen: stepCountIs(N)` voor multi-step
|
||||
- Supabase-client opzetten
|
||||
- `searchBands` en `getDaySchedule` als read-tools
|
||||
- Bestaande tabellen `bands` en `user_favorites`
|
||||
|
||||
## Wat is NIEUW vandaag
|
||||
|
||||
Zes echt nieuwe features. Allemaal in één nieuwe route + bijbehorende UI.
|
||||
|
||||
| # | Stap | STEP-anchor (Cmd+F in code) |
|
||||
|---|------|------------------------------|
|
||||
| 1 | Project klaarzetten | — |
|
||||
| 2 | Nieuwe route `app/api/agent/route.ts` | — |
|
||||
| 3 | `addToFavorites` write-tool | `STEP 8 — addToFavorites` |
|
||||
| 4 | `getWeather` externe API tool | `STEP 7 — getWeather` |
|
||||
| 5 | `finishPlan` structured-output tool | `STEP 9 — finishPlan` |
|
||||
| 6 | `hasToolCall` extra stop-conditie | `STEP 10 — hasToolCall` |
|
||||
| 7 | `prepareStep` voor model-switching | `STEP 11 — prepareStep` |
|
||||
| 8 | UI-pagina `/agent` met tool-rendering | `STEP 13 — Tool-call rendering` |
|
||||
| 9 | Testen + deployen | — |
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Project klaarzetten
|
||||
|
||||
```bash
|
||||
unzip polderfest-original.zip
|
||||
cd polderfest-original
|
||||
cp .env.example .env.local # zelf invullen
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Browser: `http://localhost:3000` — de chat uit Les 11/12 werkt.
|
||||
|
||||
Verifieer in Supabase dat de tabel `user_favorites` bestaat met
|
||||
kolommen `id`, `user_email`, `band_id`, `created_at`. Die hebben we
|
||||
nodig voor de write-tool.
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Nieuwe route `app/api/agent/route.ts`
|
||||
|
||||
Kopieer `app/api/chat/route.ts` naar `app/api/agent/route.ts` als
|
||||
startpunt. Verwijder daar voor nu eventuele bands-context — we gaan
|
||||
de agent puur via tools laten werken.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
convertToModelMessages,
|
||||
streamText,
|
||||
stepCountIs,
|
||||
hasToolCall, // ← nieuw vergeleken met Les 12
|
||||
tool,
|
||||
type UIMessage,
|
||||
} 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.SUPABASE_SERVICE_ROLE_KEY ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
const result = streamText({
|
||||
model: openai("gpt-5.2"),
|
||||
system: "Je bent een Polderfest-agent.",
|
||||
messages: await convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(8),
|
||||
tools: {
|
||||
// searchBands en getDaySchedule overnemen uit chat/route.ts
|
||||
},
|
||||
});
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
Test eerst dat de route bereikbaar is, bouw daarna stap-voor-stap uit.
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — `addToFavorites` write-tool (NIEUW)
|
||||
|
||||
Eerste tool die de **wereld verandert** ipv. alleen leest.
|
||||
Anchor: `STEP 8 — addToFavorites`
|
||||
|
||||
```typescript
|
||||
addToFavorites: tool({
|
||||
description:
|
||||
"Voeg een band toe aan de favorieten van de gebruiker. " +
|
||||
"Gebruik dit als de gebruiker zegt: ik vind X mooi, voeg toe, etc.",
|
||||
inputSchema: z.object({
|
||||
bandName: z.string(),
|
||||
}),
|
||||
execute: async ({ bandName }) => {
|
||||
// 1. Zoek band op naam — we hebben band_id nodig (FK)
|
||||
const { data: band, error: findErr } = await supabase
|
||||
.from("bands")
|
||||
.select("id, name")
|
||||
.ilike("name", `%${bandName}%`)
|
||||
.limit(1)
|
||||
.single();
|
||||
if (findErr || !band) {
|
||||
return { error: `Band '${bandName}' niet gevonden` };
|
||||
}
|
||||
|
||||
// 2. Insert in user_favorites
|
||||
const demoUser = "demo@polderwave.app";
|
||||
const { data, error } = await supabase
|
||||
.from("user_favorites")
|
||||
.insert({ user_email: demoUser, band_id: band.id })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { error: error.message };
|
||||
return { added: true, band: band.name, favoriteId: data.id };
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Test: zeg tegen de agent *"Voeg Band 5 toe aan mijn favorieten."* en
|
||||
check in de Supabase Table Editor of er een rij in `user_favorites`
|
||||
verschijnt.
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — `getWeather` externe API (NIEUW)
|
||||
|
||||
Geen account, geen API-key — Open-Meteo is gratis. Toont dat tools
|
||||
ook gewoon `fetch()` kunnen doen naar externe APIs.
|
||||
Anchor: `STEP 7 — getWeather`
|
||||
|
||||
```typescript
|
||||
getWeather: tool({
|
||||
description: "Weer-forecast voor een festivaldag.",
|
||||
inputSchema: z.object({
|
||||
date: z.string().describe("ISO datum, bv. '2027-07-10'"),
|
||||
}),
|
||||
execute: async ({ date }) => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", "52.0907");
|
||||
url.searchParams.set("longitude", "5.1214");
|
||||
url.searchParams.set(
|
||||
"daily",
|
||||
"temperature_2m_max,precipitation_probability_max",
|
||||
);
|
||||
url.searchParams.set("start_date", date);
|
||||
url.searchParams.set("end_date", date);
|
||||
url.searchParams.set("timezone", "Europe/Amsterdam");
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return { error: `Weather API faalde: ${res.status}` };
|
||||
const data = await res.json();
|
||||
const d = data?.daily;
|
||||
return {
|
||||
date,
|
||||
temperatureMaxC: d?.temperature_2m_max?.[0],
|
||||
rainChancePercent: d?.precipitation_probability_max?.[0],
|
||||
};
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Festivaldata: vrijdag `2027-07-09`, zaterdag `2027-07-10`, zondag `2027-07-11`.
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — `finishPlan` structured-output tool (NIEUW)
|
||||
|
||||
Dwingt een gestructureerd eindplan af via Zod-schema. De UI kan dit
|
||||
later als nette plan-kaart renderen.
|
||||
Anchor: `STEP 9 — finishPlan`
|
||||
|
||||
```typescript
|
||||
finishPlan: tool({
|
||||
description:
|
||||
"Roep dit ALS LAATSTE aan. Geef het eindprogramma in gestructureerde vorm. " +
|
||||
"De agent stopt automatisch na deze aanroep.",
|
||||
inputSchema: z.object({
|
||||
summary: z.string().describe("Korte samenvatting"),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
time: z.string().describe("HH:MM"),
|
||||
band: z.string(),
|
||||
stage: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
)
|
||||
.describe("Items in chronologische volgorde"),
|
||||
}),
|
||||
execute: async ({ summary, items }) => {
|
||||
return { summary, items, ok: true };
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — `hasToolCall` extra stop-conditie (NIEUW)
|
||||
|
||||
Combineer `stepCountIs(8)` met `hasToolCall("finishPlan")`:
|
||||
Anchor: `STEP 10 — hasToolCall`
|
||||
|
||||
```typescript
|
||||
import { stepCountIs, hasToolCall } from "ai";
|
||||
|
||||
streamText({
|
||||
// ...
|
||||
stopWhen: [
|
||||
stepCountIs(8), // max 8 stappen (veiligheid)
|
||||
hasToolCall("finishPlan"), // klaar-signaal van de agent
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Effect: zodra de agent `finishPlan` aanroept stopt de loop direct.
|
||||
Gegarandeerde nette afsluiting, ongeacht resterende stappen.
|
||||
|
||||
---
|
||||
|
||||
## Stap 7 — `prepareStep` voor model-switching (NIEUW)
|
||||
|
||||
Per stap een ander model. Stap 0 (planning) krijgt het krachtige model.
|
||||
Stap 1+ (uitvoering van tool-calls) gebruikt een lichter model.
|
||||
Anchor: `STEP 11 — prepareStep`
|
||||
|
||||
```typescript
|
||||
streamText({
|
||||
model: openai("gpt-5.2"), // default
|
||||
|
||||
prepareStep: ({ stepNumber }) => {
|
||||
if (stepNumber === 0) {
|
||||
return { model: openai("gpt-5.2") }; // planning
|
||||
}
|
||||
return { model: openai("gpt-5-mini") }; // uitvoering
|
||||
},
|
||||
|
||||
// ...rest van je streamText config
|
||||
});
|
||||
```
|
||||
|
||||
Resultaat: significant lagere kosten en latency zonder kwaliteitsverlies
|
||||
op het denkwerk.
|
||||
|
||||
---
|
||||
|
||||
## Stap 8 — UI-pagina `/agent` met tool-rendering (NIEUW)
|
||||
|
||||
Maak `app/agent/page.tsx`. Verschil met `/`:
|
||||
Anchor: `STEP 13 — Tool-call rendering`
|
||||
|
||||
```typescript
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
api: "/api/agent", // ← belangrijk: agent-route
|
||||
});
|
||||
```
|
||||
|
||||
Speciaal renderen voor `finishPlan` en `addToFavorites`:
|
||||
|
||||
```tsx
|
||||
{m.parts.map((p, i) => {
|
||||
if (p.type === "text") return <div>{p.text}</div>;
|
||||
|
||||
if (p.type === "tool-finishPlan") {
|
||||
return <PlanCard result={p.result} />; // nette timeline
|
||||
}
|
||||
if (p.type === "tool-addToFavorites") {
|
||||
return <FavoriteBadge name={p.input.bandName} />; // ⭐ badge
|
||||
}
|
||||
if (p.type.startsWith("tool-")) {
|
||||
return <ToolDetails part={p} />; // expandable JSON
|
||||
}
|
||||
})}
|
||||
```
|
||||
|
||||
De gestructureerde output uit `finishPlan` kun je nu prachtig
|
||||
renderen — geen vrije-vorm-tekst meer die je moet parsen.
|
||||
|
||||
---
|
||||
|
||||
## Stap 9 — Testen in de browser
|
||||
|
||||
Open `localhost:3000/agent`. Probeer:
|
||||
|
||||
> "Plan een avond met indie en techno voor zaterdag."
|
||||
|
||||
→ meerdere tool-calls + `finishPlan` aan het eind. Plan-kaart in de UI.
|
||||
|
||||
> "Welke band moet ik NIET missen op vrijdag? Zet hem in mijn favorieten."
|
||||
|
||||
→ Tool `addToFavorites` aangeroepen, badge in UI, rij in `user_favorites`.
|
||||
|
||||
> "Wat speelt er op zondag en hoe is het weer?"
|
||||
|
||||
→ Externe weer-API call. Agent combineert weer + bands.
|
||||
|
||||
---
|
||||
|
||||
## Stap 10 — Deploy
|
||||
|
||||
Push naar GitHub, importeer in Vercel, env vars:
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Samenvatting — wat je nu hebt
|
||||
|
||||
Zes nieuwe agent-features bovenop Les 12:
|
||||
|
||||
| Feature | Voorbeeld in code |
|
||||
|---------|------------------|
|
||||
| `addToFavorites` — write naar `user_favorites` | STEP 8 |
|
||||
| `getWeather` — externe Open-Meteo API | STEP 7 |
|
||||
| `finishPlan` — Zod-schema structured output | STEP 9 |
|
||||
| `hasToolCall` extra stop-conditie | STEP 10 |
|
||||
| `prepareStep` model per stap | STEP 11 |
|
||||
| UI met tool-call rendering | STEP 13 |
|
||||
|
||||
Plus de bestaande tools (`searchBands`, `getDaySchedule`) en patterns
|
||||
(`tool()`, `stopWhen`, Supabase) die je al uit Les 11/12 kende.
|
||||
|
||||
## Vastgelopen?
|
||||
|
||||
`polderfest-with-agent.zip` is de complete referentie.
|
||||
Zoek op `// STEP N` om direct bij de juiste plek te komen.
|
||||
Reset `user_favorites` tussen demos met `supabase-reset-user-favorites.sql`.
|
||||
250
Les13-Agents/lesbestanden/Les13-Stap-voor-stap.pdf
Normal file
250
Les13-Agents/lesbestanden/Les13-Stap-voor-stap.pdf
Normal file
@@ -0,0 +1,250 @@
|
||||
%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 5 0 R /F5 9 0 R /F6 13 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 /Symbol /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F6 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 19 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260609144810+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260609144810+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Stap voor stap) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Count 9 /Kids [ 6 0 R 7 0 R 8 0 R 10 0 R 11 0 R 12 0 R 14 0 R 15 0 R 16 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2296
|
||||
>>
|
||||
stream
|
||||
GauHLD/\/e&H8h>EPPDdD.?&26qZaRZq[t;7@#iL!u(qT=f-$rZD:*5Z?=fLhiW6X]:63fitkS5j5'79k>%g\if!*J?ifbC!2gVI."f>=W(h(F]S#g,n\lVE0ZW<@/D%6k#/mjt+(S:p"tk`$Qq+tM*TB+<fIeJQlaafMT&WWP1's-\"pNuldrB4I0'0qb)tPX*(rN1e_M.L1k?%Afauk`@auVAcr't<H5qAiOXd2[TarLtE"n\tspiHhW,u^3:ZQtCcA!+l@<kseO#L4s\XaH4-P03,O%-B6gTbci?[;AAqa*(LKh@tDG%nVpMY't(jfD[UcI@qTDmkKp3,/GbpKLLaXE=4^5Dj1]^o+m)Jn1q?t7!RZ&AdW[Y'6<&8XM#A\(sN5\(,7hIC2Q*[LuhGD)Jt^bV,Xd'1D1OqgQ>>ber5uu2R?[?0MF;J_TtigqGO'o4C`!]h@f+bbX12)iks28EA8BhcZ`up9$;4d^,(P3Y0#MNGNMn'c!nqRo"DSIHuDKu<h%bpp:l#WauJ@E]!QV(-B(k6';Wo(od\pMH@]i;Pp$Da<i&=l_3ig'Z'Y\)RV8.MV\/i%k!6$]\Z9CMbl^ZN<7?K-=1^sb7[5!mr&T\S17Q@t!3PY\_HKmd)_s%GHg;(3TUjtIaJ/-!AR$3K/%!5l>_HhA'kg^#$aUH?@[R"8*C2Nue0_DS!:0G-=Dg,&XXjEQBa<UqS6"B@.FB^#qrHYL=u!U(Q&"R%d5k;e?.F@Hfg![O0JDLs-EtI8N8C`t_9Af;+gZqLRR3/Yp>spQepPk9A0hinMP8r&;&%%!IEjssQd=VF:-?XRZj#f+=NjCUP-8:o)DX=8)30AjA,Z.;Fu4I+*,D7+*3k7]<ku-^J^$7QiE%"He84-]!c6h\7"Rf22`h^Og*k_i!U=P3_:u2EE=sf;%Ul05D&1DE"['rk?><q@AINX:6F),P!=sVfA(f+L'S0\L#C9"qn2V2>KL2DnllIOp^>DoST#:Y0a<g%X!tkc"i/0KAM=M>,pVV72f)mD)8B*FMkI<[k%+,7D2SL9>Y`O8Z6?Zh5Tb*^b:5#3u,Z]:@r)"_ZMh;(_-qmWfUP]l='@@LI?#BeSgstE'/C@duedgS!C/T,L8rh^WiN1Ri^B:!a?W!Y^X(#"%0pSU9^Gt(..tR9?e92$MKA.1R)p`%WhXH5DVWXD(.cbc5eVAB\T2O2ghC6-d]I8]edF=&pTiJgD6!/uE2N'YIe.#M\Us$8meJ8`KePd$C+[TWY=\Am2aYbH-9]3(;ZUkq4hCVVCH`%!FruM22lKVB=coX`_D-L!bnj-kd)27\Z&`QdMnST^-gm,g>W14][LuBb;4:UEN-k#-I/iUVVP:16O&-j/sSO?mbZOtjEfWT+CBK/t8hlP&m>JG\BLU22liqEd7_d4#.11tfC;.G[K\fH,UfsT[]\o$J#RSenl'n_HnJ%DmXEMs^$`NSC`#!@"_B.36a2>Ii;RZpjXZF`de<5:W.j2B?,GG/n>NnoKX[;8ASB;^mE#F=ae]@QsrB!3[cCcLe[KC#*5=dTt5&t`R%dUdD3biHW2ErAW89;LEopZsh:PPBF+enaZ-PJ%E#H'+o31E!sE$74.[$0Lqm53uQTIW^4:)s"sn.`/o_h;`_6eGXX,T@cEc/5q&ZkH^.*YGu5uI[*>Fj#p_VmG3itWa>!qG>n?@0RJedODeqW;FWNE:[Lkfn^d_E@=#SFjMhVIadMeA\i\"-7HPs%k"2BE)L9TJW(9qHAJB!<`TYsI/T[?Y3Un1(eMqD8AS7E?Y/$sk1/54cE3g/O:1`_Uo).Nb>p^$t-H=gU$be'b5%=oK#>;Yp?m?rq@[Y?.^_m?(iKpBQBQ:'/E@mJ&MFV$j.%H@Z/A\4egng'u7l7!D%N"jL`_1GheF"=)oeB'Sk6P7GRG28.V]I6;=T>O9H/bK+LHRr,m2jd#pA7g[NNC[ic;k8leZEHPQgKQX<+)quk;@9=)s8G?L,+N<Y"m8WdD>X$hi@lp;f%WR/B0=r'EA2^$s>$Jl`GfKa',f3IADY"XlQXkA?K,3qZn.h%=[2LHo4h[8_ZZ;+:BV1G//o+m&CG7CopJ21d'<*n>RHR7=N%L9$ohd\>)Ko""%#WrcgOr/ntUV496KWUmeGE(.hm-OfPL*R>5LT-JA^KX2M4lh'.'eA;4r%+gY:fg%?X2@:5os)sELj[`Wb2<uqV;"1FImP;!L<fl_sDmWjpH"%@1h5&MURL;t.p[J"mBIBCN7<(1uFj*SN3SFj_",&L8(e=9*FOc4Q90j0`D/$B[LG~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1572
|
||||
>>
|
||||
stream
|
||||
Gatm;gN)%,&:N_ClqC`MUk1iC,XK1&[l/(MF_W3uSoGk[Q=s2B6\85$YO;P*Lt9Q;F=PD0"RR8%^#1I&!l_'Qs2Q(/2fk@7E5?*>^BKr;%eEKcId[,Q#RabUI3>,S?Gq@^Sp<T3E\d+d&k"9k6-t^LO96:O&L%k'cn>g`&5!,8Y8e?L(sl)u+N!dR&KLUn,[_%!c&!MFd@l'5:DeBi$!MLCZ[!]2>.AEG4fZO/FoB[Bq$&1<s8)F$Ia;)qMV!-F_H1$CGcfqnYn2B_#k-8DV0ScWQS?1,q($N=!@[,i6m3'_K3i7p^c'M3W6@>]FX^B`-@lVF^0i.GfEE<B_P\@]0U?AD@s"?u.H>h_c*Og7Jl=!A1AUY%nCfpWC_W4os+&;fPIS&o=DqpnNjT$2j?(0G\BXQ%dfj^n9I,OjhTjX1[&M)4_L*WWS'Knu:1]0>:r;ndm:Q1j"!ec&q,1M5*+2SNd*ZtZ3mUj:(-*3TgR>6U29.%k/(53^:L-TZT=gQ2H+$CgU.3"55tht>K=:(MLo7ogn3cLWN_jQ:MGb1p<**82*PTAVUsj(Fk]iY/WtC@D9#K%n3"@(WEQ!krDIS9/C\IG]ZtOX;9Wh?l@A>Ym9Z+5:fi"*q1g?W-VUs0OH(;mO1a=E"BaF?k7bBV"F&cq>_/!/Q,0g-bfa,"*b:jhO)BJVUs6^ld$Geh=%#2(Q=b.;9+n55E%e3uZ.Df]3%Im@(="$OF*XP`[3^I4=Jmtu]OA6b)(f6$GER)?9Zo<5-dK]5>9HsaVe\LH3Zbr]'f!]QNbPY,S)X^NQb6Om:UZ@sPLC6lH`\ngr;E"q:8c?&jAAlVVV8A2OH3>1BLLHCKIP#\%L4tZbV*:^VTq$kT("(^Q:;7Q6B"1^q6?RHrofVA(#;3[?W+XJ\+OpBXlF8)R@dl82K@0Y2@T5"cO+H`0[f6/;p2=GRm2X5)DflT2eZn[6,[rA^i^/%\H&XT0fgAY&Of,^9DR&T,H't%:RM&jih=_j!pIX:s_SX77;]&=-HQEJV``dloLs!.orKNcq<R$:Ua1Vr,irqQDoG4ia>Gp_r*SE_7bd'6o=Qlj=TGpZ)F*cSdFk=p<X><h+m?uG>^3`^>]'fOeGe_lKk1X*M%rHKE'%Xe>'hmJKq6!WW"k^GS$8tA;[FW=9Ch?VMc7=>%;+b&f%VaAX\YXn&/N)%7L/4g9&YJ%CgSsaMP@n\qO.W39MGaB1:ipUhV+NP/YNeG,A>N[jCljpJ$^Dc%<b&d$[rRC4U/c=MdK2L$b%<ook@qZ`TG/JW,g/Qi?V]KKD.JeNflgL9=>+3n]Qk!'>Ul`'nj_n3>6=7+"`(MJ+QK#E/70a)Ag1_?Fsc"D)0sOW$H$@r'V3io:B\AIs.\DPl(u^_Wf_Uqf36"7=VNYp,gFE%fgVX9n)O=/T+:(UBW<L-e7Utjo\It*p1I^ap,X`l8'Q.)cg7Tu17U[D>ouRZj=A^%r)P4g;.l<tG5<i%6]&_K7Sm%7/e?H"1!+1G+"!DY($_-"VokMB2'*[*e7)T_;Ar,Xjp4r92<#n0SqAL`V7(eNhQ-"I7DS^8ilb\O~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1672
|
||||
>>
|
||||
stream
|
||||
GasapD,]1K&H;*)Z,`chBbX4+A=8o^%qq?mNWM^EHT1+,C(`hf3d._J.)"TSho`,/AT78#(KpY!fba%Y.G4Elj`.^33:L?u7"I,_YCU`2_*<[ZDr"TM8`k$o`)3u'0d*&t5*4T&#ma+ua#j^OG):6"E(,U]ScXXeY"N'A1gIf$-kKJ6Y2V\\'iN-qj;fE^_kf@*;L2.#COXFQo8'L$+jK;-?oWei9!tT--T2^B!.P<-PLGR+Gb<jVI'uSp(bU\>cP?ZeN4H/WJTprca6?3/]AS/>s-6_p"-djr4I<\7T[P)i_-NLAOIN9`lR]]*Oq=,mS3PD_%?P*045gb=3#!B@&Sb6?U6XGRASkp'$dD;2^i9mN(3eTW_X1HAEFg**DQ%]1,W<-fbn8LMR9ApEUHCl>i$D>Yk#(-J;F1KRGYU,qA^HG[<-K![Pdj>&4LR=*]3B&_fc?ItDd'<OI&B\_3O9K%),B<uH$seQ_(3FS-E7Q7%DR2,A!BQ(Ue>NX'TCe@jXSL^q!WK0[j"/3+"/^0!+oqcVmF695GW?@J'`H^@PGL`_;Gf3Y)kM1N^4I6IJp;3PB2]%Ctd%reR?$aMFm8ge0A5hB#-p'q\\^U@3!%:mP>bI#r=V_[Z*3OR,IY4-[$Lq'']'PTFl'`XM=$'QF_TVSR^O?+bf/H#d;XQ5XmnO0M`6/*77;>X>F0UC8:OF.YkN=Q(j=YSY]!3$)=%uH_ao*I*TZ2J,gpL,`JCIq(7P[q>BQW)X`:nGicHiLbk!?66F40:5TN??a%>I0\<)^@e_8<L$+?G%Xl4hHOK[5SbGH1P*e$L]J,3$raU,Z%dnq]J;,A_g!V5boINKMBa=gBZ?1g2,s&R]-f_u>bHm+Zj2p9Ao\s]e@"15O]Jr.TV9dc%9t9T\B-:EGl1k2:jD]KlIX4,jr+#X`]RBZ4-rl-1beH%!:GCTh3B]+DI<;G=M==J52q[Nf%[F].'ed1S$AJl9XYBFW1(;%t+dp1>3gg^[R_[V@e.2bO^>4E_ICn'U&a]4&5K\!'/*_910/qjtXRdJqmVUbHFdqupIk^r\5^4A?"]k(rng.(qi$Xb#$4Err>O,i]?cN>eFbqmkoF\!gl!:pmhU:KN]m;16K:SK30bf<."M9*=Z:.co)Pm93'Cg)RG(/%^AbN=I,^>WpF.HU3;2;*!;NRFj=KIF^%>'aNCW4G2cW?QCd*1f%N^__8fNWI9MfeW+Tp*178eMP309X7g_NeR^P0)NN[)WCQo6o<H1h(%<a`Wuq('e8'cN+K!UPj?T_fW?6o6%,X7@b+*`!:'fK1/n$Z?0Ac;WKJa`j&K&Y%lR7m&dD.FVnls*d^8<@:_.[BJ#>1@\"ffCm#uZ6[r8Dl+,)@(qf\m@_OjhQ)_<#F@=k:R_BcAhfeI(6Q&p91N-@>Hs+(0$r;i5%$0ukru+Ma@MEH9qK`m2)+=:9\4X$Acg_Ja=OYY-9lE*EHWtS!fqS7(8VV)f\Nl>(:1-]&'4-'ufMY=B9&U.Eq$OneV985\WOhNDiI('k8)[aB7>9#O_nA&@9t3MD315^Y4pARsr#)BeNo#hOXj#IdGJakD(`WA;Ef7^qd`0FP/&gf<K6stL4?#D;mK22(p%&B*!7C(l!-W-)Zeuu[#j`r6`ff^TgQpB2VuHH"?\qDJ\$^^[-WpBN(oqP(~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1781
|
||||
>>
|
||||
stream
|
||||
Gat=+CN%rc'`G1AEG)A?gUR_a;%f.f_ceVUJo,@[\EtsQBOmNSPfTRlaj0b(J);RFYag"S[O.tbRQU?6gnu#dr*f$nF9V/M$:h!&(l.>WQllI-^VS%AHg_OfnjimKHqF:(",VI'&'WHP+:(OUo9b*)&Wr1U+W6CtQGE#q'X7q&K#Jsq!Js,]8HD5b+I(h$kC!t`Nhusd_D&]R^>UFk]pOH^4/?$Z+rK`Uc)!I#?P(A3."LOh`\1Z43J1]2;O-L3eiCCGN(U9'\DTK$Ve>M*g8\D]Q$EYaCR@;k#'uq/E"YYbg'9dEOu7'`2Gi*TL\3-"AjlcqL[t`Xf`Hl?AJYQhpJHfijUjsb*26']CTY$Fa[92;%ra[dPp`<)<k)AW4X$df\6J7R&/$I+e6e@*T%*)WI)P(H532[MR"u#&)f-T\E-jVIh$P@nhW,_&Od*P?Bno-$Q;We3W9!`NA(hp[h.?>JH<6[G-Mpa=r9.*Fm"1N9fn;f?CGCZ+j!"1`J<D\MS<hC^;gk*N[E0mu*^bY?E&%?6f^Bi4n-P/-cliD50oSf7Xtj9&%ik3ZnKZ2FK)hZ*=tN%>!bP"Y8*^MFUd.=M3)6KjO1+`(;U*E4]2=<u#ehB7`),PlKKDQIldIQ6.S0,N%hLq)AT\FVkr(mEJ&4hDCkApNglTqe<0`WoAABIfI1X?ah6LM'WQrsC[7ZJ<,LNi5Pu`O[ni.b#i!19@/'Ei]V(>)=RB0LsN%Z4^:uThMOcU$nh=1CL"'tEIWqoqrRgMk:jbG#`,i-kP@.<5I(qN1e:O^gLiogY8)?/(LVY7?&6]iXp\jkI)\5KY9'*I^/3>?e2/^a"NY87]S$jfM(cZXW2\gT*("S8EUh(9aqcPUMZ@=c-U0a@((#pctd`^..<-aTUJ"#g(=f[n[W!*L/P\epJZHoW,+a>%Bh%Jg($b$?uc1?;N0YeqlJPQ!u_,g.oe&]:03^u^2e5IGsOHZ1tN81>--(4F+`>@km5%@q'.OoUF2hN]cQF(M-<LOm??8?hIJ$tft?FmGUETA^`9$]MEC+Ch*ah4mKWW+ofLFFW?Ab@``e^?b%S*>DUT0_ModR&f3ZmY9?u*/edC@s^/=?XWDhbd%#e/hXD]L8(:`SA89).T9dCJ.4]T>I8Nu^a+gDNG*\m$0H!!?R#u6qoCu$[+>%XRG28Q8?uQ_L+Z6+9n\'WQs*3^rT@FCIFn&.JRmKLnR+Og8+ct8_cK&GS:%bO3A<"t=ao/j0PTiG>o*b^R_'^n+gqt+,sRX+6Y?Mf#=ssTY9X_4#Q.-\kD7S,'B=P/],.bNAXP+"?psnE*.6%UfGtqA!t*SXbXU,l2+lO'1P++9B=Mbf^^2u.Mdp8%_e\"a"r^A9C3SQN>DL6g]Lt=E^@)0`T=Z1d_n-lNWL\tRDgoqD``#lHfDfNR]NQdDA"\9>"jZJ^C![h<f2IR-,saJbXKq/d2l2U5F/h)T#)'qoTAJ4J7dT^n6XZA,EWmgMM-pPP7=.UJYFC2=G&:#3E2&%J:_EQD[`[M^O.7]*VqPS83#3rM38G'^i)4Y9^A>NU:NP^/UJpU!?XRr`in*&q7,@/^Q`>A4P3PZ-9:RdfAI8T:?_(VE^Gn<#rH9ilre8#Pq)`I!SGnlg8gS%>kP"3RnD*<"UoE2beHD?C^-aZ:Dq'Q2m`W!f*rh8geW?'#1eEst"K#@4OC/F*Bt4AKDt**FY(#?:"\t(Z?)d&blXj'02Wh@rg+-+FGDq0:kt?X5T,.2Z%J0K]qYBdE'gsq0SZqi&"RdFh"ftu~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1646
|
||||
>>
|
||||
stream
|
||||
Gat%#lYbH8'`P;63&:cg.:SaA.$;[!'j7#6!P10nS/'[=,`GFO8hJ/MBK'YQGMf*1ablQVSS_IA`UfYtKa]AY=nNl7!5FJ#GfkPV(eoPf,%1$I:.F)1O786(&HM>9ocjqlo%aP.R?p2NOtbl-"%=M5;LhT&-l"2h81,^I:`,Q`pD=GZ;Q0d1`I^p?hpD6Gj;_;1&qj]#:VW/j3Un:+DP]]T2a7G_*GhcUKB`h2=Wde`V9j_hma!]a24p9lT',nN/o>%V\V2d$&rt;r[98tf^,3e0N,E(rgbEJn$Gf:ZKmX/P@J(8aH!Wc#mOG;Z4\IO>5muk"1h7%0nle'h&LO4`*T.N!=VRTqRd7uIc:T.HQ1+e*,=js@ji50^g;4M\3l9]Nj+`Xl6"PC3eU(c/=n;`f#bb3$eZ8p"%&&FL"1d+5n8D>=B_Sb#(m.Fu?IKgMmeF^Ah$LC&LITQ@ohL_,D=#N\.[8_4j72*-J>`fX9%5aI>!@j8`#0*%pDNKIC>iDU1\SN0G&E0^YS[Z+rg1YghGCP7Lera>28B=sW(<44ag35U]f2f*9QLUg?.r!u(mDecPVu\1`C/$Cn#um9F!h5;[`)%JjiUoJF-Gl6pe:7J>8,ad+aJ`!%fB]BJmrJ1*.;OZ,b'ejircTLr5O]m]sEW@DT=4iKUXiMB:O!&TQ7j3ZekJM6X['E,)+aVqV&Vi@S@)7b@^09]Q1mRnihs4<NCdq5k*cGkYa7Q4'sml`d*;n?PNrn!L``dQ[cY6rT;Z`bG4#m0,&ptSIJJ,9hs\1*Nn)"p(sb*;[57T#kclrXN63^7MkUm>SfFQnH#30>A0MYe\q(iTbhZ7,:^G1g^f7RG%'ReC^5t79RI('a1UB<9CNE&`^sB[JDN[AMu0Z5k*-KE,LIjF-H4\r+]k$]SmJ8imVn"h8=#+_/si`,7=M"MP!rjKh:jP'9IUT^NGNi]RY8eE"Ha=&p;T^2fIIc$BQ/1[7Lhjee%:]92Yhj4(eW5aeIGiRUul`i<<7-G.PTe9a(]6^1!s]ZW%`eA;mAsk-['K`r@\k3M-$&W'2O,+M8c3%j8$3G6?_s2P#+_o(/(N,#_##qP(18.:`'V*%).6@MB!#sosSDKnbF%r2&95o+$Y3oqpA\m<IVW9Y?a7>-;PKFCOi#'N=$0b[`gO/00A)9*ue-?S@3BmjWIon.TJMpYhT#*^i?@YW>0oO/<$Smb$,27!idJVYTl59X@:[^b2;jIX!="E,i<5!9Cb%?VZU13s7)q#@2CuH?HmU.Rr_NH-F4H`P=bd;N-oG*53G"&?%1thIG?MZpT"f%qW`7mosVo]3:(PU)#(9E8>1nM<mB(CFA:Si6MU7Ubb0I>bR#&srOPO;EFOHUV42<['`GU)dqJWU`p+Y_B3jWpIhOR5#Z70V8](0ma9*qTDMLK>:^#5"iUIAI2]J>N=$3]qjbVkE*Y#..T3'V!bh^*IhS,R.=]3Pk(HT1J0t^W-/p&InHt@;\Wb+Ap/T8s10&M^#](Ou2Cl.sQ(8a,5qYCk#>IKt$nYB"iK,t)XA<S=^#^QjD7)NC-A5H<:*l5/f>b8b:Ho]"eZu@W$]dRlnYrER\Wkeq">m\\A2DP!YpnN>/iGf&PjM!9#7OR@Y)cmg/)/)fonGWRn(O%\~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1782
|
||||
>>
|
||||
stream
|
||||
Gb!#[D/\/e&H;*)EUY?R9c0"e6n-(ZA8U$)CV+\.f+7J'1,:G^d>LNB=kfYMI7HkpA35]aC`"K<Z-De+*o?3M$4ad`of("nI[J":Ese/JF9.U>%b"Ku0cg3V,0X\OHU-B%EC0pUFR9/k*p)>U`"-!A+`IPj":#uE&d2VC%-</8!jmQuUCdYRHq+;Z83f=&nFu!Li\Q=d3UqJ]gm)gI"VtE8r!"1YXP]rbGL5JKX]Cc%LO-giBa0b[5Q,U/08iqtdBY/uG\=C@Q3Lr[#ro\QBBi6g`CfA>R!s_Q,AWsMP]K;\_8Wk2CElo"<tlQFqlS:h-0$8;i:llV77V50q5cm&"X?,8(^=A9W'`oo^!qM^hR6`/Tj%ipE'8kj`.b-QgodIu*]R3)>AD(6'8JZD^CkAV7&)A:!p-E!.TPjS,;,`LUpY6eJpL@RTNsi`h2cYGM$S^i^R\,fe(KUJ:'W,b+"c&5^,o'`2&\&:<BAf7*VInE*5X1B*>12cd>M4"6:R@RN!I:@BcXWNOsfA^.[BfI(ef\;jNm^uhFbCDZ(G&6@#d7CPEe^sr+nWY+`ujkN?H%3k<c:YSesG:FUbP"c_oCD$]m+kn3X)X/<p7$jR\Wh@P+o[6BVH5XO6@TXL1rPXt\gV7UQXc#E2UR3pkULVEi-N2jg\cb(*`d,*O@iJcJKs\b`$O&rd1=pio'5it1X3]MY9]X0oKuQ7A)2n:_Q8+&uhU]h3p-GaRYG_K(V`et9&O4E6P)ZcVQieF(%JZcW!_65p*)bZ'$=NPQXqg!Ke!.X^60!H5(c7V*n"(_H:a<N*#:!0LI.ca1`DTZ8a;"n)c7!C#J0`W6OXU]F/PWP<Z#bElkF?[m\U=F`QYk<$7nA3So%4f!i?iWs05,IYBi*N$pJ#kbl:5[)8B9C!-`To1Ls+(Y7r*>HD/kB\,g=XQ*hH<A)r$*W'+0/%XF\?s@hM`+c(+tq@g[e43I2N8(qhVfl.JlkRN0edt\mLe=k6*jpH;UO3+*+\k*NrTi2;c^q]k$*i7h=iUbLosC(b?3,_;_)ZL3UEb-L>iu\W*V"LqIHNJOhd"Mc,k]Lg3hW14'oFHT_^:W'QEG)]mg+tm=kk69b.ZX,n&Z'3KfS`I^"\KeW\mo-iSmt/>kHsT#rngJ,q?-<\="j.WT@uM8C[R\\*1]IOhnah@J$DBb08?&m>Hk<WFV\k#am%2/aMQjCsb4,WUkkW%?W\=RV]C7]i6tleGa2ZUrLdl*O,,_pPe@e2nN0FuM>"qsnB=Sg1SiB7!6i_Hkj`elS@4pGEKYKR*S6W^JRq)!A8%PpjP9i-B35Q)I@A9eCs?`DWQ+7n]-$'rP!mFLM0YWjL<APAGq/f2d<d5KD-/*diZq/i"9eA3<]gr?hK&4W+KTP1^ObC2e4]2!37c+G:`aC2M50?RhR.^E1gC!Hr8E)"o#(PK?UFK<Z5HQ--?Xgo2Pnih]906SiAY(I(`_a.:'$<(_R:-?1YL$?n6qN`S$PKDP4YL\9mZ6[]jZEEg\CGIWRM!dPKCK4:upH_A'Le+mOXd5;nf6\A6)=qm9ej+Yi^*Zdt6Pc3`u\MD5cje>H#M<15/0.'+/.qrp`)X4f\$7.UH-/[(t*:`O&U4WBV_b)c'C8KHQC3>Lrqk:a9B#S#O-flG,M3`D)6`(T@]mh`<Y#KI^<`fpajUDt,'k/.#P8-P@[iFlCDKYM)2UrQ,<a3ltZ.do-+/8gjl)mjK2HC]6YBM?+ek+O8B+h6a]hWH>AN*=[<G2-d.7S)#p3aC1,8g@_hb,QI~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1917
|
||||
>>
|
||||
stream
|
||||
Gb!#\>B:]A'Ro4HSFI9<fL";]Zm<pjY#BN_a/qr(_F%jq@Dgs5/"ECVrd0_"%bRl3l(eL&m#%R5F8rs3",_"tr1-7V0./H"bCE_L\H1h(#:@4%S&sU7!O4bF4"_(K:trI0ppPH6[MZs@HGoD,T7NZ*#k!3f(_OV$@,X;p)2Co%c'&/1_EH%q"%4ip'AhSpRs:6Q7m"Z^4F_R)AY1[=q8B>86Slmr.ROMC3RqfN*C?1;?'n#6]QjJH*.CHp.\A;V,_Q[h;mW<TWF^'oE=T2&XU:7kA]HM#!g>"oJ<!OJZ&$^H^9.$"b7niX,_OlW'tQ$Z,)@Q7Nh5,_OL6:YWiU8%H=CrH#oF4CRI=RKRdW#c-;"@K/$W>apE:G7S<jh*f"ut_W7ffU2/2OcFXZ^]Lg"CKB\"$SjEoB,<&JGD@@Z::Q#c#4V,1YfZ\#42RrH.K(S^LWD3`NBXP&-4`e8TcfNRZ\]kK-4KB8aS-hAss5'lOF?^*,gU+2*shL+<ebjr!Dn7TT8,&K@&?:GV$9i$OgpZK0AB*leok7mdnU1CFD0jpQj17/Q4(9'K?M01X<4olL[@'4D.3n-;R)(1Zt_Nk:%'SGPYouUpF;OOL7XjSqsfiW5D]i[eV.YH"<(s\^?O"7r(@J!!j&0SJK73+6uB4jJ,<KA1P&_hGI#1Rr!]2Yu?)"%mP#<#@N=VIYhP]7\]Mo,dF5&hPcXQH2#@<n1Q@i&qA&"@\["`^b^ffIKhM9:-]F54'HbG-sCLS\jWE8.\:iJ_!nHY2VE9$nPD8>^D:[k`[k$JN(F=VBTp4Z:mK7NIf9e)#rBZ">2JaUdKGHb**'$E/I_>/H!X]&=_\a240@O!,#LEGuT8A?6r=$\^BaQ!g0%J=fZt`U#\aQ,IaF\^ab!#$M0k^K"DRW#V!(9;SnWimh+qXiQgYpY5*1K\<ePm(QQ:&Zri78DE^aVPU$n9*OfeV5)#R(ZhauE.47Aj8SWU*KB`)LsU=)!-=SXj'\dHL=>,#q3qneVT8Wf3?IWk]H@,.k\Uqnab&J5.9B?9@>88@0U-9TRQ*Wl(")id8to?@,k+ZBLlOc'33fMlZn"@q(GTB52Vo+\R_606AsY6@Qse-K(rBTI*qnLbrGk=*kMYR886He;]&;cZ67mRBi9:$&:h)et@_f7ZBu$uVTrg*(T(S;`YreP33*C4[i65/GNg5d"5`U,:=5KJbU8-=#Cu>`Q\U(ZJr><+%f45eYiFmmOAO.B.[,\Wq,^\;2iRDn=VT?2qJnp'JZH.VNX"b=b::"K5e\=0aD+o@`O$DE\=/hVl;Uu;-6a35B]3TK1E0N"/d@8re1NRW@TBGf;nlFTo)33$jY;c>_N$0;4?0hO+5Ij.7LW=T6-T<$%gF`HdU(hA<B>%44chaO#TCiP.Q-o<,T?TT+$e=g2I,.3o\X^r:,h"3Z6@&^LUF=n5Mld7i9(_jW($+aQ,;h06VBS4Mk/$HV3u#Klc*NPCHhquuq`"Y(Y8Ls5\)i"ecRF(ZDF<6ORJj="2TH!=0@%q6nnJ9%k8iCp/$0QM+AuVZpGMt/&aj(m4*:4GB=GMC&J"8LgY1Uia\+X$.#;H1N6ae[Xu3bUnb`GiC>[csl0V0`pq^ZYeG^#=mKq2t&ai&B+AuU,IgYf\)0ZZ=N(\Y2[_Sg\)+,ZU.kQB-#LbjRQYmBlV4bQ!(ajSL.er]%rP]:U;ie7HYd*GF%>KhQT!:A]_*fqo]N0RXX0_I-"/&UO=+JAHKj^KMIKVi8lf.FX?K4t[S1SlfBVDB_BFAB:#r,DM@eE0f>-3kg7!L'&K;Mn.W$=or)Ii?$'0Ll71T)eq#FRS^FsHL$B'qfUbl*E@WoA:KM"*J,Y-[2)^@dPX#L)jDeWg(:jkm!1TQHPDo3L@nce,+ZQ@*>;G@+t(29'qYZsiG_PXVLs~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1917
|
||||
>>
|
||||
stream
|
||||
Gb"/'D0+Dj&H9tYf]c(BBJ6<M/AL#-:=@RV]3IZ),^5g'0Z$l+M6VK;8Zr$b[uq2f[h.8]9Kt?FCtYk;IC-$i"Tptgs8#ZEP<QYT5_1]ShZ2bk6iRkSrPlS]_JJ@R$]+$cVT443VYYp]gk`ePM=!$YJnm?aFZ=?,'%ce]?oZJPVurH@S(A)V`3BcljZm'cfri"Ch]t*i.%1gm[_;A0Q%#l6cQdV0*NE7kOVq'Db,>JFP7rRPNWoA11saH#47F[:bM8^88HaYiPQ%A.CW7>Rm_F0p0;BeXfdl:D6N=G+:h.X.,G)fD<MmGrUi;qO)<C'XoBM3Q(^k:uHp4;M2mdWS,HbTs$in"j3Kc?2$8ID`)XS@!7q&EFKX7Pk*P6Ep!>,gg#F;=nFbcI[EbL$=*+6:Ng;q-5n0JbG0"BrTO)sh(<?/E2C5S]Eq0916[8@O#^P:=gLC%5!L:HbO`kdB]`%sLlm3Nf@%!["K+f1l?BKbK=[W#&4hU$-j"0<Y\BS`qt5j*Tm>WpWO,R@?I?,Ztn9*MH;7Dle6U)Sq+Ul7Up-`Hc=((eJO-ikD)#a..rA,K%81(2$f]NC#mMXKZm3gXEa.a#3?CJis<VUT%R;b_I`C9D;tbq*(WhQR$G7b&d\X?^a6HkAe-r!!ljYDAZ,@3"DCAa^P,-4'7[">,sR3WV[t\e?8dY(/dSTL1VrhLMtMrIX[f!U2*Ph+8Fi"m8;4q$t\2m644]4@`?uR'j6h(:!cQ^>S=6S>nPE_qQ#GE"uoW/7a,>cHYOgH!f\$F]aPEkU.IZhM'1_6$k/h7&D_'6D"BjWJhj>NW=L#DV+^/Eg[ATG4Bpr6En+0$fNI9#bli3Pqp5#Q7:U=_\=cD.^`%b`'LPih?.B9[g/S^Ep@=&9W(8)hYn$9:+Wf%XlF:(<+74o4I.]_5+.GJ(uLSSA%Gb-4l6^pV>>FLEnaLWD^SrdK&G$>i/5'gBeF;`oL=h*7d)uJR\_r5mq!C`?_=!b_,h=1.-TJi5(OQ/(OQXL:UAF\K[Vd"$KWO<LR0dfATWS:\;,EnjhL7kc^_O'Fe4Or>c[R,0'!BVXrrBYc;#Z[8h<S?>*JWh\+hHEZhE;T@<Z[2g3GXD%D'S-a1_1cE,iHf@$sWYbGqD?+1LP1m/1\pBD\;k9Ol*6RC8?&OD,)DHL6X6cF`f>ir$0sc`CcdlFW!9n$^Q&4%%53>:nNrM)k6bbLu;_B"I,Vi.k7!Qt"JT71mQ"17p^;H+B1Oc`i@eMZ%hF[-<H+#N`?H15@iRZNge'<IiY['ZDW@Vk`<Z``V"knXd$6,W+e4:\0IDhUJ]pDtM#]WE5]P%HlJZLo7h4'@sAY=j5OAfsAGGZ,?;+j]d_u_j>5JEktfn"L_VJC8&%d>"g"YHmiL6rNq,qRs:HM$gLCO5=`5A#ugrsPFt*X]c;<Y#\T`@\%6"NTWIS)ZFOQHqhU+EcX+BB>rhR[Q#)[4-=^1`U#cq=`EK:CFu\Zp3H+kM>t4s`"87SQQt_]./s2NZ'[a5L/L>j;Jl_;La!2][ZC.nj&3r!$dTNhFDT,J2olc2"*)/7%\a!2OiJ(&ER42iAg@B7\)&D2F\5uA2,\lZYKhGBBV3t"G-@:G?Z.fXs"K<qJV"R?Yk*K!0/h(@V#9A4HZ8c/Ukf87u0GEjd;F:[HCU>QArIP!X%74?eR@&Gjj)S(j'tTcoi8S7!=R`Q90@T%:;o8le]B]N@3MDJTG1Hh%=UThhL/m@5a#hja2k&jn=C`])c+*'lcWtK92:m2s>`-atS>`"SH6[^Bd<O[9GV?f)&=pWgP3_blY>j8>@f=+hZV?b6i>Ti3$W:*>R`LIuTVHd_R_;iR7O"0I<#p[$OV8>N_P4+doF3.74,&24Zl@N5G1?60h6Bes^D4SGf'#bh(LqlV)o#ZW%3#6KNDN[&~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 535
|
||||
>>
|
||||
stream
|
||||
GarW5;/_pX'SYH?($@<P'F,ReS&\6=[7XBLY&"WtUkpE765!aB4.3H_5Jp$W2T#`%_WO#DhTM&$`Q.GQ"$VrOgi.4`Z3HXhUATu913lW^:dW>F/2M!@#d>Kl*k3QM,?IPRU>R`)Ak#Ea_jN65JVZ@h\V;?Y<s],\.b."?)4i@R0(^#[6:>V2Ei"Z&qC/$SiY>-]l!hkd42Ttd4r*`Wbp5r-Y&fgJAp7Urhr9i:a=l/rmdMef>-7sV$DI"h(*Dd3455sGLN8%<C+qW"8h!l9V1&l%$rr@`[17XFNNE#WC%i@"1X8@(j9_X#PYMV&9V0BdJGB#c._]Mfo5P+q)[W)GKekH#f3m1ah25H350SMc@6Le4g'@>Lm75oNR%n>Lemh-Ng90=1T\.V[>eJf(_[fCEfH-7=A2ILAn5s#Sj6ct\407/g\:CK#1N5:.ed"7d3f>%`7$k%CB[?154G=1@_3=ar=%DT15lSVF*5&1tKQl"BqiBSUqWb'N1j9%N52bQV39HiWDisD:/05ELIf]28`/+~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 29
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000143 00000 n
|
||||
0000000250 00000 n
|
||||
0000000362 00000 n
|
||||
0000000439 00000 n
|
||||
0000000544 00000 n
|
||||
0000000749 00000 n
|
||||
0000000954 00000 n
|
||||
0000001159 00000 n
|
||||
0000001274 00000 n
|
||||
0000001480 00000 n
|
||||
0000001686 00000 n
|
||||
0000001892 00000 n
|
||||
0000001976 00000 n
|
||||
0000002182 00000 n
|
||||
0000002388 00000 n
|
||||
0000002594 00000 n
|
||||
0000002664 00000 n
|
||||
0000002956 00000 n
|
||||
0000003070 00000 n
|
||||
0000005458 00000 n
|
||||
0000007122 00000 n
|
||||
0000008886 00000 n
|
||||
0000010759 00000 n
|
||||
0000012497 00000 n
|
||||
0000014371 00000 n
|
||||
0000016380 00000 n
|
||||
0000018389 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<3079c5437762c8adeb291f8ff4fab280><3079c5437762c8adeb291f8ff4fab280>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 18 0 R
|
||||
/Root 17 0 R
|
||||
/Size 29
|
||||
>>
|
||||
startxref
|
||||
19015
|
||||
%%EOF
|
||||
BIN
Les13-Agents/lesbestanden/polderfest-original.zip
Normal file
BIN
Les13-Agents/lesbestanden/polderfest-original.zip
Normal file
Binary file not shown.
BIN
Les13-Agents/lesbestanden/polderfest-with-agent.zip
Normal file
BIN
Les13-Agents/lesbestanden/polderfest-with-agent.zip
Normal file
Binary file not shown.
@@ -1,785 +0,0 @@
|
||||
# Les 14 — Agents
|
||||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||||
|
||||
**Les:** 13 van 18
|
||||
**Onderwerp:** Agents — LLM in een loop met tools
|
||||
**Duur:** 180 minuten
|
||||
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Nieuwe research-agent (from scratch, los van Polderfest)
|
||||
|
||||
---
|
||||
|
||||
## Hoe deze tekst werkt
|
||||
|
||||
Dit document is een **lopend script**. Lees mee tijdens de les op je laptop.
|
||||
|
||||
- `[SLIDE X]` — Klik naar slide X
|
||||
- `[SCHERM: slides | terminal | editor | browser | supabase]` — Welk scherm op de beamer
|
||||
- **Vertel:** "..." — Letterlijk wat je zegt (mag in eigen woorden)
|
||||
- `*[stage direction]*` — Korte instructie voor jezelf, niet uitspreken
|
||||
- Code blocks = wat je typt
|
||||
- 💬 = verwachte studentenvraag
|
||||
|
||||
---
|
||||
|
||||
## VÓÓR DE LES — Setup (45 min)
|
||||
|
||||
### 1. Tavily account
|
||||
|
||||
- Maak account op https://tavily.com/
|
||||
- Kopieer API key (begint met `tvly-...`)
|
||||
- Gratis tier = 1000 calls/maand, ruim genoeg voor deze les
|
||||
|
||||
### 2. Nieuwe lege Next.js demo-folder
|
||||
|
||||
`*[Doe dit thuis vooraf, niet in de klas. Klas kijkt naar de setup-stap als je 'm doet.]*`
|
||||
|
||||
```bash
|
||||
cd ~/novi/novi-lessons/Les14-Agents
|
||||
pnpm create next-app@latest research-agent \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd research-agent
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||||
```
|
||||
|
||||
### 3. Environment vars
|
||||
|
||||
`.env.local`:
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=...
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
```
|
||||
|
||||
### 4. Supabase tabel
|
||||
|
||||
```sql
|
||||
create table research_reports (
|
||||
id bigserial primary key,
|
||||
query text not null,
|
||||
summary text not null,
|
||||
sources jsonb default '[]'::jsonb,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
alter table research_reports enable row level security;
|
||||
create policy "demo open" on research_reports
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
### 5. Backup eindstaat
|
||||
|
||||
- Werkende `lib/tools.ts`, `lib/agent.ts`, `app/api/research/route.ts` ergens als backup
|
||||
- Verwacht: 1-2 typos tijdens live coding — geen ramp
|
||||
|
||||
### 6. Browser tabs
|
||||
|
||||
- `localhost:3000` (dev server, maar gaan we voornamelijk via terminal testen)
|
||||
- Supabase dashboard
|
||||
- https://ai-sdk.dev/docs/agents/overview (referentie)
|
||||
- https://tavily.com/ (dashboard voor usage)
|
||||
|
||||
---
|
||||
|
||||
# HET SCRIPT — Lees mee tijdens de les
|
||||
|
||||
## BLOK 1 — Welkom + Terugblik (10 min)
|
||||
|
||||
`[SLIDE 1 — Title]` `[SCHERM: slides]`
|
||||
|
||||
**Vertel:** "Welkom bij les 13. Vandaag stappen we van tool-calling naar agents. Eén stap verder qua autonomie van AI."
|
||||
|
||||
`[SLIDE 2 — Terugblik]`
|
||||
|
||||
**Vertel:** "Vorige les bouwden we Tool Calling in onze Polderfest-app. Zes tools. `stopWhen: stepCountIs(5)`. AI roept zelf functies aan om aan data te komen. Goed werk.
|
||||
|
||||
Maar er waren dingen die een tool-call bot **nog niet kon**. Lange ketens — 30, 40, 50 stappen — daar is `stepCountIs(5)` te krap voor. Een eigen plan maken en bijstellen. Tools die andere tools triggeren. Per stap een ander model kiezen.
|
||||
|
||||
Daarvoor heb je een **agent** nodig. Een LLM die tools gebruikt in een loop, en zelf bepaalt hoe lang die loop duurt en welke kant het op gaat."
|
||||
|
||||
`[SLIDE 3 — Planning]`
|
||||
|
||||
**Vertel:** "Vandaag drie uur. Eerst een half uur theorie — wat is een agent precies, hoe ziet de loop eruit, wat is een `ToolLoopAgent`. Daarna vier live demos waarin we from scratch een research-agent bouwen. Geen polderfest vandaag — losse repo, omdat agents een nieuwe context verdienen.
|
||||
|
||||
Aan het eind: wanneer wel/niet een agent, en wat doe je voor huiswerk."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 2 — Theorie (30 min)
|
||||
|
||||
`[SLIDE 4 — Wat is een agent]`
|
||||
|
||||
**Vertel:** "De definitie uit de AI SDK docs is kort en bruikbaar:
|
||||
|
||||
> *Agents are LLMs that use tools in a loop to accomplish tasks.*
|
||||
|
||||
Drie ingrediënten. Eén — een LLM die besluit wat de volgende actie is. Twee — tools die het LLM uitbreiden. Drie — een loop die deze twee orchestreert.
|
||||
|
||||
In Les 12 hadden we deze drie ook al — `stopWhen: stepCountIs(5)` is in feite een mini-agent. Het verschil zit in **schaal**. Vandaag praten we over 20-50 stappen, en het model bepaalt zelf welke tools het wanneer gebruikt."
|
||||
|
||||
💬 *Mogelijke vraag: "Wat is dan het verschil met Les 12 echt?"*
|
||||
|
||||
**Antwoord:** "Schaal en autonomie. Les 12 was: vraag → 2-5 tool-calls → antwoord. Vandaag: vraag → agent maakt plan → voert plan uit (10-50 stappen) → schrijft rapport. Het model beslist veel meer zelf."
|
||||
|
||||
`[SLIDE 5 — Anatomie van de loop]`
|
||||
|
||||
**Vertel:** "Wat gebeurt er per loop-iteratie, één stap? Vijf dingen.
|
||||
|
||||
Eén: we sturen de huidige messages naar het LLM. Twee: LLM antwoordt — óf met text, óf met een tool-call. Als het text is, stopt de loop. Dat is een natural finish — model heeft niks meer te doen. Als het een tool-call is, gaan we door. Drie: we voeren de tool execute uit. Vier: we voegen het resultaat toe aan de berichten. Vijf: we checken stop-conditie. Niet voldaan? Volgende iteratie.
|
||||
|
||||
De loop stopt in vier gevallen: text-only antwoord, stopWhen geactiveerd, tool zonder execute aangeroepen — dat is het 'done-pattern', komen we straks op terug, en tool die approval nodig heeft. Die laatste skippen we vandaag.
|
||||
|
||||
Default in v6: `stepCountIs(20)`. Dat is een veiligheidsgrens — voorkomt dat een rare bug in je systeem prompt resulteert in 200 API-calls en een rekening van 30 euro."
|
||||
|
||||
`[SLIDE 6 — ToolLoopAgent]`
|
||||
|
||||
**Vertel:** "In oudere AI SDK versies moest je de loop zelf schrijven — een while-loop rond `streamText` en `generateText`. In v6 is er een dedicated class: `ToolLoopAgent`.
|
||||
|
||||
`*[Wijs naar code op slide]*`
|
||||
|
||||
Je geeft model, system prompt, tools, en een stop-conditie. Eén keer definiëren. Dan `agent.generate({ prompt })` aanroepen, of `agent.stream` voor streaming.
|
||||
|
||||
Wat je terugkrijgt: `result.text` is het eindantwoord, `result.steps` is een array met alle stappen die de agent zette — welke tool aangeroepen, met welke input, welk resultaat. Dat is je debug-goud."
|
||||
|
||||
💬 *Vraag: "Kan dat nog steeds met streamText?"*
|
||||
|
||||
**Antwoord:** "Ja, voor backwards compatibility. Maar voor nieuwe code is `ToolLoopAgent` de aanrader. Minder boilerplate, herbruikbaar."
|
||||
|
||||
`[SLIDE 7 — Stop-condities]`
|
||||
|
||||
**Vertel:** "Vier soorten stop-condities.
|
||||
|
||||
Eén: `stepCountIs(N)` — stop na N stappen. Standaard veiligheidsmaatregel.
|
||||
|
||||
Twee: `hasToolCall('saveReport')` — stop zodra een specifieke tool is aangeroepen. Logisch voor 'doe het werk en sla op, dan klaar'.
|
||||
|
||||
Drie: `isLoopFinished()` — onbeperkt. Agent stopt alleen natuurlijk wanneer model klaar is. Dit is gevaarlijk zonder andere safeguards — agent kan in theorie oneindig doorgaan. Alleen gebruiken met cost limit erbij.
|
||||
|
||||
Vier: combineer ze in een array. Loop stopt zodra één conditie waar is.
|
||||
|
||||
En vijf — niet built-in maar zelf te schrijven: `StopCondition` callback. Daarmee kun je stoppen op kosten, op aantal tools, op wat dan ook dat je uit de step-historie kunt halen."
|
||||
|
||||
`[SLIDE 8 — prepareStep]`
|
||||
|
||||
**Vertel:** "`prepareStep` is misschien de krachtigste feature van `ToolLoopAgent`. Het is een async callback die VOOR elke stap draait. Je kunt het model dynamisch wisselen, tools beperken, messages trimmen.
|
||||
|
||||
Voorbeeld: begin met `gpt-4o-mini` — snel en goedkoop. Maar als de stap-historie complex wordt, switch naar Claude Sonnet voor beter reasoning. Dat scheelt geld zonder kwaliteit te verliezen.
|
||||
|
||||
Of: dwing een research-fase af. Stap 0-3 mag alleen `webSearch`. Stap 4-6 alleen `readPage`. Stap 7+ alleen `saveReport`. Agent kan niet 'cheaten' door te vroeg op te slaan.
|
||||
|
||||
Of: trim oude tool-results uit de berichten. Een lange agent kan 30k tokens aan tool-output verzamelen. Niet meer relevant voor de huidige stap — eruit, scheelt budget."
|
||||
|
||||
💬 *Vraag: "Hoe weet ik wanneer ik prepareStep nodig heb?"*
|
||||
|
||||
**Antwoord:** "Niet bij elke agent. Begin zonder. Als je merkt dat je agent veel geld kost, of te vaak in een verkeerde fase blijft hangen, dan kom je terug om `prepareStep` toe te voegen. Premature optimization is ook in agents reëel."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 3 — Live Demo 1: Setup research-agent (25 min)
|
||||
|
||||
`[SLIDE 9 — Wat we bouwen]`
|
||||
|
||||
**Vertel:** "Genoeg theorie. We gaan een **research-agent** bouwen. Doel: gebruiker stelt een vraag — bijvoorbeeld 'wat zijn AI-trends in 2026?' — en de agent zoekt zelfstandig op het web, leest de relevante pagina's, schrijft een rapport, slaat op in Supabase.
|
||||
|
||||
Vier tools: `webSearch` met Tavily, `readPage` om URLs te lezen, `saveReport` om in DB te zetten, en eventueel een `done`-tool.
|
||||
|
||||
Stack: Next.js 16, AI SDK v6, Tavily, Supabase, gpt-4o-mini. Losse repo — niet de Polderfest-demo. Agents verdienen een schone context."
|
||||
|
||||
`[SLIDE 10 — LIVE DEMO 1]` `[SCHERM: terminal + editor]`
|
||||
|
||||
**Vertel:** "Klas, leun achterover. Vragen tussendoor mogen. Ik laat zien hoe je een agent from-scratch bouwt."
|
||||
|
||||
`*[Open terminal in /Les14-Agents]*`
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest research-agent \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd research-agent
|
||||
```
|
||||
|
||||
`*[Wacht op next-app, ondertussen praat door over Tavily]*`
|
||||
|
||||
**Vertel:** "Voor web search heb je een API nodig. OpenAI heeft sinds kort een eigen web-search tool, maar die werkt alleen met gpt-4o en is duur. Voor onafhankelijke search gebruiken we Tavily — gemaakt voor AI agents, gratis tier van 1000 calls per maand. Gewoon registreren met email, API key gepakt, klaar.
|
||||
|
||||
Andere opties: Brave Search API, Serper, Google Custom Search. Tavily is het simpelst voor onze case omdat het naast search ook content-extractie biedt."
|
||||
|
||||
```bash
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||||
```
|
||||
|
||||
`*[Open editor]*`
|
||||
|
||||
**Vertel:** "Eerst environment vars."
|
||||
|
||||
`*[Maak .env.local]*`
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=...
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
```
|
||||
|
||||
**Vertel:** "Belangrijk: `OPENAI_API_KEY` is GEEN `NEXT_PUBLIC_*`. Anders zit-ie in je client-bundle en kan iedereen 'm zien in DevTools. Server-only. Hetzelfde voor `TAVILY_API_KEY`."
|
||||
|
||||
`*[Check .gitignore — moet .env.local in staan]*`
|
||||
|
||||
**Vertel:** "Check je `.gitignore`. `.env.local` moet erin. Standaard create-next-app doet dit goed maar — altijd checken."
|
||||
|
||||
`*[Supabase Dashboard openen]*` `[SCHERM: supabase]`
|
||||
|
||||
```sql
|
||||
create table research_reports (
|
||||
id bigserial primary key,
|
||||
query text not null,
|
||||
summary text not null,
|
||||
sources jsonb default '[]'::jsonb,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
```
|
||||
|
||||
**Vertel:** "Tabel voor onze rapporten. `query` is de vraag, `summary` is het rapport zelf, `sources` is een JSON array van URLs die werden gebruikt."
|
||||
|
||||
```sql
|
||||
alter table research_reports enable row level security;
|
||||
create policy "demo open" on research_reports
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
**Vertel:** "RLS aan, en een open policy voor demo. Productie: koppelen aan auth.uid()."
|
||||
|
||||
`*[Terug naar editor]*` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nu de tools. Ik maak een `lib/tools.ts` met onze drie tools."
|
||||
|
||||
`*[Maak lib/tools.ts]*`
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
export const webSearch = tool({
|
||||
description: "Zoek op het web. Returnt top 5 resultaten met url + snippet.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Zoekterm"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const res = await fetch("https://api.tavily.com/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
query,
|
||||
max_results: 5,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.results?.map((r: any) => ({
|
||||
url: r.url,
|
||||
title: r.title,
|
||||
snippet: r.content,
|
||||
})) ?? [];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "Beschrijving van `webSearch` is kort en specifiek. AI leest description om te beslissen welke tool nodig is — vaag = verkeerde keuze. `max_results: 5` houdt het beheersbaar — Tavily kan ook 10 of 20, maar 5 is goed startpunt."
|
||||
|
||||
`*[readPage tool toevoegen]*`
|
||||
|
||||
```typescript
|
||||
export const readPage = tool({
|
||||
description: "Haal de volledige tekst van een URL op.",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const res = await fetch("https://api.tavily.com/extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
urls: [url],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const content = data.results?.[0]?.raw_content ?? "";
|
||||
return { url, content: content.slice(0, 5000) };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "`readPage` haalt de fulltext van een URL — Tavily heeft een extract-endpoint dat ruis verwijdert. Ik knip op 5000 chars om token-budget niet te exploderen. Voor productie: chunken + samenvatten."
|
||||
|
||||
`*[saveReport tool]*`
|
||||
|
||||
```typescript
|
||||
export const saveReport = tool({
|
||||
description:
|
||||
"Sla een onderzoeksrapport op in de database. " +
|
||||
"Alleen aanroepen als het rapport compleet is.",
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
summary: z.string().min(100),
|
||||
sources: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string(),
|
||||
})),
|
||||
}),
|
||||
execute: async ({ query, summary, sources }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("research_reports")
|
||||
.insert({ query, summary, sources })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { error: error.message };
|
||||
return { reportId: data.id, success: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "`saveReport` is onze enige write-tool. Description benadrukt: alleen aanroepen als compleet. `summary.min(100)` — zod schema dwingt minimum lengte af, AI mag geen 'TODO' insturen."
|
||||
|
||||
`*[lib/agent.ts maken]*`
|
||||
|
||||
**Vertel:** "Nu de agent zelf."
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { webSearch, readPage, saveReport } from "./tools";
|
||||
|
||||
export const researchAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je bent een research-assistent. Werk als volgt:
|
||||
1. Begin met een kort plan (3-5 bullets).
|
||||
2. Gebruik webSearch voor oriëntatie.
|
||||
3. Gebruik readPage voor de 2-3 meest relevante URLs.
|
||||
4. Schrijf een samenvatting van min 200 woorden.
|
||||
5. Sla op met saveReport. Inclusief alle gebruikte sources.
|
||||
|
||||
Verzin niets — gebruik alleen wat je via tools hebt opgehaald.`,
|
||||
tools: { webSearch, readPage, saveReport },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "System prompt is **het** kritieke onderdeel. Ik geef expliciete fasen — plan, search, read, samenvat, save. Hoe duidelijker, hoe minder de agent drifft. 'Verzin niets' is belangrijk — anders maakt AI bronnen op."
|
||||
|
||||
`*[app/api/research/route.ts]*`
|
||||
|
||||
```typescript
|
||||
import { researchAgent } from "@/lib/agent";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { query } = await req.json();
|
||||
const result = await researchAgent.generate({ prompt: query });
|
||||
|
||||
return Response.json({
|
||||
text: result.text,
|
||||
steps: result.steps.map((s, i) => ({
|
||||
step: i,
|
||||
toolCalls: s.toolCalls?.map((tc) => ({
|
||||
tool: tc.toolName,
|
||||
input: tc.input,
|
||||
})),
|
||||
hasText: !!s.text,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Endpoint stuurt query naar de agent, returnt text + step-samenvatting. Voor productie zou je dit streamen, voor demo houden we het simpel — <20><>én POST, JSON response."
|
||||
|
||||
`*[Run]*` `[SCHERM: terminal]`
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`*[In tweede terminal:]*`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/research \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Wat zijn de belangrijkste AI-trends in 2026?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
**Vertel:** "We zien... `*[wachten 20-40s]*` ...de agent draait. JSON komt terug. Kijk — `steps` array. webSearch, dan readPage, readPage, saveReport. Vier stappen. `text` veld is een samenvatting."
|
||||
|
||||
`*[Open Supabase, check tabel]*` `[SCHERM: supabase]`
|
||||
|
||||
**Vertel:** "Rapport staat in Supabase. Werkend agent. From scratch in 20 minuten."
|
||||
|
||||
💬 *Vraag: "Hoeveel tokens kost zo'n call?"*
|
||||
|
||||
**Antwoord:** "Goeie vraag. `result.usage` heeft `inputTokens` en `outputTokens`. Voor zo'n research van 4 stappen: ongeveer 8.000 input + 1.500 output tokens. Op gpt-4o-mini: ~0.001 dollar. Bijna gratis. Op gpt-4o: ~0.05 dollar. Houdbaar."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 4 — Live Demo 2: stopWhen variants (25 min)
|
||||
|
||||
`[SLIDE 11 — DEMO 2]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "We hebben nu een agent met `stepCountIs(10)`. Laten we de andere stop-condities uitproberen en zien wat het verschil maakt."
|
||||
|
||||
`*[Wijzig stopWhen]*`
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs, hasToolCall } from "ai";
|
||||
|
||||
// ...
|
||||
stopWhen: hasToolCall("saveReport"),
|
||||
```
|
||||
|
||||
**Vertel:** "Nu stopt agent **zodra** saveReport is aangeroepen. Geen veiligheidscap. Laten we draaien."
|
||||
|
||||
`*[Curl draaien]*`
|
||||
|
||||
**Vertel:** "Werkt. Agent doet zijn werk, roept saveReport, stopt. Voordeel: geen onnodige extra stappen. Nadeel: als saveReport faalt en agent retried, kan-ie alsnog lang doorgaan."
|
||||
|
||||
`*[Beter: combineren]*`
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(20),
|
||||
hasToolCall("saveReport"),
|
||||
],
|
||||
```
|
||||
|
||||
**Vertel:** "Best practice: array. Veiligheidscap én task-completion. Loop stopt op de eerste van de twee."
|
||||
|
||||
`*[Probeer isLoopFinished]*`
|
||||
|
||||
```typescript
|
||||
import { isLoopFinished } from "ai";
|
||||
// ...
|
||||
stopWhen: isLoopFinished(),
|
||||
```
|
||||
|
||||
**Vertel:** "`isLoopFinished` is letterlijk: geen limit. Alleen natuurlijke finish stopt het. Dit is **gevaarlijk** zonder andere safeguards. Een misverstand in je system prompt en agent draait 50 stappen door."
|
||||
|
||||
`*[Curl draaien — agent doet nu 8-10 stappen]*`
|
||||
|
||||
**Vertel:** "Zie je? Agent gaat door tot het zelf besluit te stoppen. Eindigt met text — geen tool-call. Werkt, maar — wat als prompt slecht is, of als API een rare fout gooit en de agent retried?"
|
||||
|
||||
`*[Custom StopCondition]*`
|
||||
|
||||
**Vertel:** "Eigen stop. Bijvoorbeeld stoppen als we genoeg pagina's hebben gelezen."
|
||||
|
||||
```typescript
|
||||
import type { StopCondition } from "ai";
|
||||
import type { researchAgent } from "./agent";
|
||||
|
||||
type Tools = typeof researchAgent extends ToolLoopAgent<infer T> ? T : never;
|
||||
|
||||
const enoughReading: StopCondition<any> = ({ steps }) => {
|
||||
const readCount = steps.flatMap((s) =>
|
||||
s.toolCalls?.filter((tc) => tc.toolName === "readPage") ?? []
|
||||
).length;
|
||||
return readCount >= 3;
|
||||
};
|
||||
|
||||
stopWhen: [
|
||||
stepCountIs(20),
|
||||
enoughReading,
|
||||
],
|
||||
```
|
||||
|
||||
**Vertel:** "Drie pagina's lezen is genoeg, stoppen. Custom logic die uit step-historie wordt gehaald."
|
||||
|
||||
`*[Demo run]*`
|
||||
|
||||
**Vertel:** "Zie: agent stopt op stap waar readPage voor de derde keer wordt aangeroepen. Conditie werkt. Voor productie gebruik je dit vaak voor kostenlimieten — daar komen we morgen op terug, eh, in het huiswerk."
|
||||
|
||||
💬 *Vraag: "Waarom `: StopCondition<any>` en niet typed?"*
|
||||
|
||||
**Antwoord:** "Excuus, dat is een shortcut. In productie zou je een `ToolSet` type extracten zodat je `StopCondition<typeof tools>` krijgt — dan kun je tool-names typesafe checken. Voor demo houd ik 't simpel."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 5 — Pauze (15 min)
|
||||
|
||||
`[SLIDE 12 — Pauze]`
|
||||
|
||||
**Vertel:** "Pauze. Vijftien minuten. Loop een ronde."
|
||||
|
||||
`*[Reset agent.ts naar stepCountIs(15) + hasToolCall("saveReport") combo voor volgende demo]*`
|
||||
|
||||
---
|
||||
|
||||
## BLOK 6 — Live Demo 3: prepareStep (25 min)
|
||||
|
||||
`[SLIDE 13 — DEMO 3]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nu het krachtigste stuk: `prepareStep`. Voor elke stap kunnen we model, tools, messages aanpassen. Drie use cases vandaag."
|
||||
|
||||
### Use case 1 — Dynamic model
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
console.log(`Stap ${stepNumber}, ${messages.length} messages`);
|
||||
if (stepNumber > 3) {
|
||||
console.log("→ switch naar gpt-4o");
|
||||
return { model: openai("gpt-4o") };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
```
|
||||
|
||||
**Vertel:** "Stap 0-3 doen we met gpt-4o-mini — goedkoop en snel. Vanaf stap 4 switchen we naar de grote gpt-4o. Reden: vroege stappen zijn 'zoek even', late stappen zijn 'vat samen en redeneer'. Dat laatste verdient een sterker model."
|
||||
|
||||
`*[Curl draaien — kijk in console naar logs]*`
|
||||
|
||||
**Vertel:** "Zien jullie: 'Stap 0 — mini'. 'Stap 1 — mini'. 'Stap 4 — switch naar gpt-4o'. Dynamic. Effect: lagere kosten, betere eindkwaliteit."
|
||||
|
||||
### Use case 2 — Fase-gebaseerde tools
|
||||
|
||||
`*[Pas aan]*`
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 2) {
|
||||
return { activeTools: ["webSearch"] };
|
||||
}
|
||||
if (stepNumber <= 5) {
|
||||
return { activeTools: ["readPage"] };
|
||||
}
|
||||
return {
|
||||
activeTools: ["saveReport"],
|
||||
toolChoice: "required",
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
**Vertel:** "Nu dwingen we een fase af. Stap 0-2 mag alleen searchen. Stap 3-5 alleen lezen. Stap 6+ alleen opslaan, en moet die ook gebruiken want `toolChoice: required`."
|
||||
|
||||
`*[Curl draaien]*`
|
||||
|
||||
**Vertel:** "Agent kan niet 'cheaten'. Geen `saveReport` op stap 2 met een vage samenvatting. Eerst werken, dan opslaan."
|
||||
|
||||
💬 *Vraag: "Is dat niet een beetje vechten tegen het model?"*
|
||||
|
||||
**Antwoord:** "Het is een trade-off. Meer controle = minder flexibel. Voor research is een gefaseerde aanpak vaak prima. Voor open-ended problem solving — bijv. een coding agent — zou ik 't niet doen. Daar moet het model vrij kunnen kiezen."
|
||||
|
||||
### Use case 3 — Context-trimming
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ messages, stepNumber }) => {
|
||||
if (messages.length > 12) {
|
||||
return {
|
||||
messages: [
|
||||
messages[0], // system
|
||||
...messages.slice(-8) // laatste 8
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
```
|
||||
|
||||
**Vertel:** "Bij lange agents groeit `messages` snel — 30+ items, elk met tool-results van duizenden tokens. Hier zeggen we: na 12 messages, gooi de oudste weg, hou alleen system + laatste 8.
|
||||
|
||||
Voor research specifiek niet zo nodig — meestal eindigt agent na 8-10 stappen. Maar voor coding agents of debugging-agents die 30+ stappen doen — essentieel."
|
||||
|
||||
`*[Test]*`
|
||||
|
||||
**Vertel:** "Voor onze research-agent niet zichtbaar effect. Maar nu je het weet: bij langere agents — gebruik je dit."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 7 — Live Demo 4: Done-tool + custom stop (20 min)
|
||||
|
||||
`[SLIDE 14 — DEMO 4]` `[SCHERM: editor]`
|
||||
|
||||
**Vertel:** "Nog twee patronen. Eerst de **done-tool**. Een tool **zonder execute**."
|
||||
|
||||
```typescript
|
||||
export const done = tool({
|
||||
description:
|
||||
"Roep aan wanneer onderzoek klaar is en rapport is opgeslagen. " +
|
||||
"Geef het reportId en een korte samenvatting.",
|
||||
inputSchema: z.object({
|
||||
reportId: z.number(),
|
||||
summary: z.string(),
|
||||
}),
|
||||
// GEEN execute — dat stopt de loop
|
||||
});
|
||||
```
|
||||
|
||||
**Vertel:** "Geen execute betekent: als agent deze tool aanroept, stopt de loop automatisch. Geen tool-result toegevoegd, geen volgende stap.
|
||||
|
||||
Combineren met `toolChoice: 'required'` — dwingen dat agent altijd via een tool stopt, nooit met losse text."
|
||||
|
||||
```typescript
|
||||
export const researchAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `... [zelfde] ...
|
||||
6. Wanneer alles opgeslagen is, roep dan 'done' aan met het reportId.`,
|
||||
tools: { webSearch, readPage, saveReport, done },
|
||||
toolChoice: "required",
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
```
|
||||
|
||||
`*[Run]*`
|
||||
|
||||
**Vertel:** "Agent doet zijn werk, eindigt met `done({ reportId: 42, summary: '...' })`. We pakken het eindresultaat uit `result.staticToolCalls`:"
|
||||
|
||||
```typescript
|
||||
const result = await researchAgent.generate({ prompt: query });
|
||||
const doneCall = result.staticToolCalls[0];
|
||||
if (doneCall?.toolName === "done") {
|
||||
console.log("Final:", doneCall.input.summary);
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:** "Voordeel: gestructureerd eindantwoord. Geen vage text. Voor pipelines waar je het resultaat ergens anders moet injecten — heel handig."
|
||||
|
||||
### Custom stop op kosten
|
||||
|
||||
`*[Voeg toe]*`
|
||||
|
||||
```typescript
|
||||
const costLimit: StopCondition<any> = ({ steps }) => {
|
||||
const tokens = steps.reduce(
|
||||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||||
0
|
||||
);
|
||||
const usd = (tokens * 0.00015) / 1000; // gpt-4o-mini rate
|
||||
console.log(`Total tokens: ${tokens}, ~$${usd.toFixed(4)}`);
|
||||
return usd > 0.05;
|
||||
};
|
||||
|
||||
stopWhen: [stepCountIs(30), costLimit],
|
||||
```
|
||||
|
||||
**Vertel:** "Stop als kosten boven 5 dollarcent uitkomen. Voor productie: kritiek. Eén bug in je prompt en je hebt 100 calls — zonder cost cap is dat 20 euro. Mét cap: maximaal 5 cent per run, klaar."
|
||||
|
||||
### Plan-Act-Reflect
|
||||
|
||||
`[SLIDE — terug naar slide 14 visual]`
|
||||
|
||||
**Vertel:** "Laatste pattern — alleen kort toelichten. ReAct: Reason + Act. Idee: in de system prompt vraag je expliciet om eerst een plan, dan acties, dan reflectie.
|
||||
|
||||
System prompt:
|
||||
```
|
||||
1. Begin met een plan in 3-5 bullets.
|
||||
2. Voer plan uit met tools.
|
||||
3. Eindig met korte reflectie — wat zou je anders doen?
|
||||
```
|
||||
|
||||
Geen extra code nodig — het model doet dit vanzelf binnen de loop. Effect: tracebaarder gedrag, en zelf-correctie in latere stappen.
|
||||
|
||||
Voor sub-agents — een agent die een andere agent als tool gebruikt — schuiven we naar Les 14, want we hebben tijdsdruk."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 8 — Wanneer wel, wanneer niet (10 min)
|
||||
|
||||
`[SLIDE 15 — Agent vs tool-call vs workflow]`
|
||||
|
||||
**Vertel:** "Drie niveaus van AI-autonomie. Plain tool-call: 1 tool, 1 antwoord. Multi-step uit Les 12: 2-5 tools, 1 doel. Agent uit Les 14: 10-50 stappen, open-ended. En het hoogste niveau van **controle** — een explicit workflow. Code die exact bepaalt welke tool wanneer.
|
||||
|
||||
`*[Wijs tabel op slide]*`
|
||||
|
||||
Wanneer GEEN agent. Determinisme nodig — finance, juridisch, medische data. Geen agent. Doe het in code. Latency belangrijk — agent kost al snel 30 seconden. Voor user-facing chat: te traag. Voorspelbare kosten — agent met 50 stappen kan duur zijn.
|
||||
|
||||
Wanneer WEL agent. Open-ended — research, planning, debugging. Volgorde onbekend — model moet zelf bepalen. Async gebruik — backend job, niet realtime UI.
|
||||
|
||||
Quote uit de docs: *Agents are flexible and powerful, but non-deterministic.*"
|
||||
|
||||
💬 *Vraag: "Wat is een workflow dan precies in code?"*
|
||||
|
||||
**Antwoord:** "Gewone JavaScript. If-statements, switches, expliciete functies. Bijvoorbeeld:
|
||||
|
||||
```typescript
|
||||
async function processOrder(order) {
|
||||
const validated = await validate(order);
|
||||
if (!validated.ok) return reject(validated.errors);
|
||||
const enriched = await enrichWithAI(validated.data);
|
||||
await save(enriched);
|
||||
await notify(enriched);
|
||||
}
|
||||
```
|
||||
|
||||
Dat is een workflow. Reproduceerbaar, debuggable, testbaar. Een agent zou dit ook kunnen, maar je weet niet exact wat-ie doet — non-deterministisch."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 9 — Lesopdracht + Huiswerk (15 min)
|
||||
|
||||
`[SLIDE 16 — Lesopdracht + Huiswerk]`
|
||||
|
||||
**Vertel:** "Lesopdracht. Vandaag in de les: setup van de research-agent zoals ik 'm vandaag liet zien. Tavily account, Supabase tabel, 3 tools, agent, één test. Half uur — kun je halen voor we klaar zijn.
|
||||
|
||||
Voor huiswerk: uitbreiden. Vier dingen.
|
||||
|
||||
Eén: 4e tool `listReports` — om eerder opgeslagen rapporten op te halen. System prompt aanpassen zodat agent eerst checkt of er al onderzoek is.
|
||||
|
||||
Twee: `prepareStep` toevoegen. Kies één use case — dynamic model, fase-tools, of context-trimming. Documenteer wat je koos en waarom.
|
||||
|
||||
Drie: custom `StopCondition`. Token-budget, of aantal pagina's gelezen, of iets eigens.
|
||||
|
||||
Vier: schrijf een `AGENT.md` in repo-root. Tool-lijst, stop-strategie, prepareStep-keuze, één voorbeeld-run met logs, één observatie.
|
||||
|
||||
Bonus voor wie wil: UI met live step-rendering, of een sub-agent voor samenvatting.
|
||||
|
||||
Beoordeling: 10 punten totaal, voldoende is 6+. Inleveren via Brightspace met repo URL voor Les 14."
|
||||
|
||||
---
|
||||
|
||||
## BLOK 10 — Afsluiting (5 min)
|
||||
|
||||
`[SLIDE 17 — Afsluiting]`
|
||||
|
||||
**Vertel:** "Wat hebben we vandaag gedaan. Een agent is: LLM in een loop met tools. We gebruikten `ToolLoopAgent` om de loop te managen. Vier stop-condities — stepCountIs, hasToolCall, isLoopFinished, custom. `prepareStep` voor dynamic model, tools, context. Done-tool pattern voor gestructureerde eindantwoorden. En we wisten wanneer een agent wel/niet de juiste tool is.
|
||||
|
||||
Volgende les: RAG. Embeddings. Vector search in pgvector. Combo met agents wordt heel sterk — een 'searchKnowledgeBase' tool die semantic search doet, in een research-agent.
|
||||
|
||||
Daarna: testing, deployment, performance, eindopdracht.
|
||||
|
||||
Vragen?"
|
||||
|
||||
`*[Vragenronde — minstens 5 minuten over laten]*`
|
||||
|
||||
---
|
||||
|
||||
## NA DE LES — Wrap-up
|
||||
|
||||
- Push working `research-agent` repo naar GitHub als referentie
|
||||
- Brightspace: link naar deze repo + huiswerk-instructie
|
||||
- Verzamel vragen die niet beantwoord — neem mee in Les 14 opening
|
||||
- Note voor Klas B (later): Klas B doet dit waarschijnlijk in week na RAG, niet ervoor — orde aanpassen indien nodig
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende fouten tijdens live coding
|
||||
|
||||
| Fout | Oplossing |
|
||||
|------|-----------|
|
||||
| `TAVILY_API_KEY undefined` | `pnpm dev` opnieuw starten — env wordt bij start ingelezen |
|
||||
| Tavily 401 | Verkeerde key — check dashboard tavily.com |
|
||||
| `result.steps` is undefined | Je gebruikt oude v5 API — check je `ai` versie is `^6` |
|
||||
| `ToolLoopAgent` import faalt | `pnpm update ai` — moet 6.0+ zijn |
|
||||
| Agent stopt na 1 stap | `stopWhen` te restrictief, of system prompt zegt niet duidelijk wat te doen |
|
||||
| Tool returnt `[Object]` | `JSON.stringify` voor logs — Tavily resultaat is genest |
|
||||
| Supabase RLS error | Open policy nog niet aangemaakt, of `enable rls` ontbreekt |
|
||||
| Cost-cap stop werkt niet | Check of `step.usage` echt populated is — sommige providers geven het niet altijd terug |
|
||||
|
||||
---
|
||||
|
||||
## Mentale model voor de klas
|
||||
|
||||
Als studenten verward zijn over "wanneer is iets een agent vs tool-call":
|
||||
|
||||
> **Tool-calling** (Les 12) = "AI mag tot 5 keer een tool aanroepen om je vraag te beantwoorden."
|
||||
>
|
||||
> **Agent** (Les 14) = "AI krijgt een DOEL, mag autonoom plannen en 20-50 stappen doen, en levert pas op als-ie zelf vindt dat het klaar is."
|
||||
|
||||
Het verschil is dus **doelgericht autonoom werken**, niet zomaar 'meer stappen'.
|
||||
@@ -1,322 +0,0 @@
|
||||
# Les 14 — Huiswerk
|
||||
## Agent uitbreiden + prepareStep + custom stop + AGENT.md
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Deadline:** Voor Les 15 — RAG + embeddings
|
||||
**Inleveren:** GitHub repo + `AGENT.md` in root
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Bouwt voort op de **lesopdracht** (research-agent met 3 tools + `stepCountIs(10)`). In dit huiswerk maak je de agent slimmer: extra tools, dynamic step-control, een eigen stop-conditie, en documenteer je het geheel.
|
||||
|
||||
> Niet klaar met de lesopdracht? Eerst die afmaken — het huiswerk heeft die als startpunt nodig.
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel A — Extra tool (verplicht)
|
||||
|
||||
Voeg een 4e tool toe: **`listReports`** om opgeslagen rapporten op te halen.
|
||||
|
||||
```typescript
|
||||
export const listReports = tool({
|
||||
description:
|
||||
"Toon eerder opgeslagen onderzoeksrapporten. " +
|
||||
"Gebruik dit als de gebruiker vraagt naar bestaand onderzoek " +
|
||||
"voordat je nieuw onderzoek doet.",
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(20).default(5),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("research_reports")
|
||||
.select("id, query, created_at")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(limit);
|
||||
if (error) return { error: error.message };
|
||||
return data;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Voeg toe aan `agent.tools` en test:
|
||||
|
||||
> "Heb ik eerder iets over AI in de bouw onderzocht? Zo nee, doe het nu."
|
||||
|
||||
Verwacht: agent roept eerst `listReports`, dan eventueel `webSearch` → `readPage` → `saveReport`.
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] `listReports` werkt
|
||||
- [ ] System prompt aangepast — agent weet wanneer te checken
|
||||
- [ ] Minstens 1 test-query gedraaid die `listReports` triggert
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel B — prepareStep toevoegen (verplicht)
|
||||
|
||||
Voeg een `prepareStep` callback toe aan je `ToolLoopAgent`. Kies **minimaal één** use case:
|
||||
|
||||
### Optie B1 — Dynamic model
|
||||
|
||||
Begin met `gpt-4o-mini`, switch naar betere model bij complexe stappen.
|
||||
|
||||
```typescript
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
tools: { webSearch, readPage, saveReport, listReports },
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
if (stepNumber > 4 || messages.length > 12) {
|
||||
return { model: openai("gpt-4o") };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Optie B2 — Fase-gebaseerde tools
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 2) {
|
||||
return { activeTools: ["listReports", "webSearch"] };
|
||||
}
|
||||
if (stepNumber <= 5) {
|
||||
return { activeTools: ["readPage"] };
|
||||
}
|
||||
return { activeTools: ["saveReport"], toolChoice: "required" };
|
||||
}
|
||||
```
|
||||
|
||||
### Optie B3 — Context-trimming
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ messages }) => {
|
||||
if (messages.length > 15) {
|
||||
return {
|
||||
messages: [messages[0], ...messages.slice(-10)],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Minimaal 1 `prepareStep` use-case geïmplementeerd
|
||||
- [ ] Effect zichtbaar in console-logs (welk model / welke tools per stap)
|
||||
- [ ] In `AGENT.md` uitleggen welke optie gekozen + waarom
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel C — Custom stop-conditie (verplicht)
|
||||
|
||||
Schrijf een eigen `StopCondition`. Twee voorbeelden — kies één of bedenk eigen:
|
||||
|
||||
### Optie C1 — Token-budget
|
||||
|
||||
```typescript
|
||||
import type { StopCondition } from "ai";
|
||||
|
||||
const tokenBudget: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const total = steps.reduce(
|
||||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||||
0
|
||||
);
|
||||
return total > 30_000;
|
||||
};
|
||||
```
|
||||
|
||||
### Optie C2 — Genoeg sources verzameld
|
||||
|
||||
```typescript
|
||||
const enoughSources: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const readPages = steps.flatMap((s) =>
|
||||
s.toolCalls?.filter((tc) => tc.toolName === "readPage") ?? []
|
||||
);
|
||||
return readPages.length >= 5; // 5 pagina's gelezen = genoeg
|
||||
};
|
||||
```
|
||||
|
||||
Combineren met andere condities:
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(30),
|
||||
tokenBudget,
|
||||
hasToolCall("saveReport"),
|
||||
]
|
||||
```
|
||||
|
||||
### Eisen
|
||||
|
||||
- [ ] Custom `StopCondition` werkt
|
||||
- [ ] Gecombineerd met `stepCountIs(N)` voor veiligheid
|
||||
- [ ] In `AGENT.md` uitleggen wat de conditie doet
|
||||
|
||||
---
|
||||
|
||||
## Onderdeel D — `AGENT.md` documentatie (verplicht)
|
||||
|
||||
Schrijf in repo-root.
|
||||
|
||||
### Sectie 1 — Tools
|
||||
|
||||
| Tool | Wat doet 't | Read / Write |
|
||||
|------|-------------|--------------|
|
||||
| webSearch | Tavily query → 5 resultaten | Read |
|
||||
| readPage | Tavily extract → fulltext | Read |
|
||||
| saveReport | Insert in research_reports | Write |
|
||||
| listReports | Bestaande rapporten ophalen | Read |
|
||||
| (eigen tool?) | ... | ... |
|
||||
|
||||
### Sectie 2 — Stop-strategie
|
||||
|
||||
- `stepCountIs(N)` — safety cap, N = ...
|
||||
- Custom: ... (uitleg)
|
||||
- Combinatie waarom: ...
|
||||
|
||||
### Sectie 3 — prepareStep keuze
|
||||
|
||||
- Welke optie gekozen (B1/B2/B3)
|
||||
- Waarom
|
||||
- Wat zie je in de console-logs verschillen tussen "met" en "zonder" prepareStep
|
||||
|
||||
### Sectie 4 — Voorbeeld-run
|
||||
|
||||
Pak een echte query, draai 'm, en log alle steps. Plak in `AGENT.md`:
|
||||
|
||||
```
|
||||
Query: "Wat zijn de belangrijkste AI-trends in 2026?"
|
||||
|
||||
Step 0: webSearch({ query: "AI trends 2026" })
|
||||
→ 5 resultaten
|
||||
Step 1: readPage({ url: "..." })
|
||||
→ 4500 char tekst
|
||||
Step 2: readPage({ url: "..." })
|
||||
Step 3: webSearch({ query: "AI regulation EU 2026" })
|
||||
Step 4: readPage({ url: "..." })
|
||||
Step 5: saveReport({ ... })
|
||||
→ reportId: 42
|
||||
|
||||
Finale rapport: (200-300 woorden)
|
||||
```
|
||||
|
||||
### Sectie 5 — Eén observatie
|
||||
|
||||
Iets wat opviel:
|
||||
- Onverwacht goed gedrag?
|
||||
- Loop die te lang duurde?
|
||||
- Tool die niet werd gekozen?
|
||||
- Effect van `prepareStep` zichtbaar?
|
||||
|
||||
### Vorm
|
||||
|
||||
- Max 700 woorden totaal
|
||||
- Concrete logs, geen abstracte tekst
|
||||
- Mag wat informeel
|
||||
|
||||
---
|
||||
|
||||
## Bonus (optioneel)
|
||||
|
||||
### Bonus 1 — UI met live steps
|
||||
|
||||
`app/page.tsx` — een form + lijst die elke step toont terwijl agent draait.
|
||||
|
||||
Gebruik `agent.stream()` ipv `agent.generate()`:
|
||||
|
||||
```typescript
|
||||
const result = researchAgent.stream({ prompt: query });
|
||||
return result.toUIMessageStreamResponse();
|
||||
```
|
||||
|
||||
Client: tonen welke tool aangeroepen wordt + resultaat collapsed.
|
||||
|
||||
### Bonus 2 — Sub-agent voor samenvatten
|
||||
|
||||
Maak een 2e `ToolLoopAgent` (kleiner model) die per URL samenvat. Hoofdagent gebruikt deze als tool:
|
||||
|
||||
```typescript
|
||||
const summarizer = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: "Vat één pagina samen in 5 bullets.",
|
||||
tools: { readPage },
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
const summarizePage = tool({
|
||||
description: "Vat een URL samen",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const r = await summarizer.generate({ prompt: `Samenvat: ${url}` });
|
||||
return { summary: r.text };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Bonus 3 — Done-tool pattern
|
||||
|
||||
Vervang `hasToolCall("saveReport")` door een expliciete `done`-tool zonder execute, met `toolChoice: "required"`. Documenteer in `AGENT.md` wat het verschil was.
|
||||
|
||||
---
|
||||
|
||||
## Inleveren
|
||||
|
||||
1. **GitHub repo URL** in Brightspace
|
||||
2. **`AGENT.md`** in repo-root
|
||||
3. **Updated `lib/tools.ts`** met `listReports` (+ optionele extra tool)
|
||||
4. **Updated `lib/agent.ts`** met `prepareStep` + custom stop
|
||||
5. **Schema-update** in `schema.sql` (mocht je nieuwe tabel hebben)
|
||||
|
||||
---
|
||||
|
||||
## Beoordeling
|
||||
|
||||
| Criterium | Punten |
|
||||
|-----------|--------|
|
||||
| A — listReports werkt + agent gebruikt 'm correct | 2 |
|
||||
| B — prepareStep geïmplementeerd + zichtbaar effect | 2 |
|
||||
| C — Custom StopCondition werkt | 2 |
|
||||
| D — AGENT.md compleet met 5 secties | 3 |
|
||||
| Agent draait end-to-end (geen broken runs) | 1 |
|
||||
| **Totaal** | **10** |
|
||||
|
||||
Voldoende = 6+. Bonus telt mee bij twijfelgevallen.
|
||||
|
||||
---
|
||||
|
||||
## Tijd-indicatie
|
||||
|
||||
| Onderdeel | Tijd |
|
||||
|-----------|------|
|
||||
| A — listReports + test | 20 min |
|
||||
| B — prepareStep | 30 min |
|
||||
| C — Custom StopCondition + tests | 30 min |
|
||||
| D — AGENT.md schrijven met logs | 30 min |
|
||||
| **Totaal** | **~2 uur** |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende valkuilen
|
||||
|
||||
- **`prepareStep` returnt niks** → je moet `{}` returnen voor "geen wijziging", niet `undefined`
|
||||
- **Custom stop returnt nooit true** → log `steps.length` en je conditie-waarde per stap
|
||||
- **Agent stopt te vroeg** → check of je `stopWhen` array niet te restrictief is
|
||||
- **Sub-agent recursie** → een sub-agent gebruikt zelf weer een sub-agent? Stack-overflow risico — limit op depth
|
||||
- **Token-cost out of control** → log per stap usage en zet kostencap
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Log per stap** — `console.log({ step, toolCalls, usage })` is de beste debug-tool
|
||||
- **Test isolated** — test je `StopCondition` met fake steps eerst
|
||||
- **Houd `AGENT.md` bij tijdens werken** — niet aan eind
|
||||
- **Eindrapport kwaliteit** — als agent rapport een 4 is van 10, ligt het meestal aan system prompt — niet aan tools
|
||||
|
||||
Volgende les: RAG + embeddings. Combo met agents: een RAG-tool in een agent. Tot dan!
|
||||
@@ -1,194 +0,0 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260607091327+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260607091327+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Huiswerk) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2436
|
||||
>>
|
||||
stream
|
||||
Gatm<CN%rs(B)NcEFGLJ3H_4ji+(uF$b4s@0i2u$M)P6"5XA=YM_RZC,S_[9dbhcOp_q:U/D%M"+d0a2F75[s3[6p1+45P8CSDp6o\IiFKV\DY$(q#Ms+Oe(]j$Q>:e#n!9PLY=8M!p9O/d!!THIcR/7Uu77ncE:q-#JS@ndU%U.@[O)7k.Q$"?cQ,OH/n+p`>d@(g$CPS!5g+:R<-l[/T;<A.rmY8u-ZrW.`kU,QgT<BfFeL]X$KC%gjWhd+Upo%MUXW[r5fG>joOe^-Y*Ls@VZT%.sa2eGWGR1CVt2eu>Ka#&9Ao+tHY"Q*8]o+d=0nUE8Ne>LpQXM-L@9o&VLj>ne(&egqqYc._&.!nZU-!1id$I3m<a1W&Met*reh)9>snIRLu6i1K$rLm9?4bo8Jj+@o#nK8BdXW%Y9MF:,_RLjHtCD8[eUu(0Tme+H:&an#U':*qaC?50XkP#cSMBMOl*rO68el'>-M-*Vle7e3gs3Gkf!3%/*[9Z7\CE@/%]8ZViUTeaVg-.]Z[Z;ZgTlDIHJ%"0b"Y:3O$YPjQA0>>,bE-6-e"sp*5=7nRZNYn+qfJ6Z0N).D8h,^k,3Mhi6<JAm42gZ,$FE5gZpd;EMArm($EF28c`WC&[*>&_b%+.Dd'pB]bU0=5gW"(!P]#fa>SFj<e(OPt?fu7=4I@o9(>5F7S=*+hdm*?`38rHKmH\2-TIOm)OCP++eoMsU9423&^KY%VCXt,Ma#ust/naX9S'rCSVcq%F/\<0%`>6)<LC;V73)3(1bD;caZHTYl*4BAhn.;sZE]-Tj^b1m`7=DFW,ONb/3P97\QY*ePMX1*NRj<KL9c$6Y/ZVQbfVk]?\EBITXEhLm6m?Nk8H_sm:fWgBGtC3CTR$6[q#jonA]gX.[bL'Jpi#n[N+j-%$VHDiFY=3["B;O4_F#C6jrB"tD2$HG-9mI$ieHGbDtiG&mlS<hn!ct&*_T4JR2\+.D(*Z8N?oCL?(ce)A%QC^Hl+9FgFf9/h'5bP"'U%8H&A9-mm&^RkdXEn<a.!nU:+8"9=RD_D.UP!hG<@LH)kRM/,IR"._K[nZ$]peMg;Bap'`f95eUfM`shgiTI8='`'`,dSm:aBG9Q^WE\PaLg"62+ZP1iKdc>9'=F'oEbdZQl'Ueo%DXSM?J83FBlMf4.DX)8KXHJm7bplY]Ue4]API>n@9gadV0Xhr<L,uj?8%C4?Mg@)kHuZeYAopns12CP\0X,\_gsCC(K^oNq3ikBgen\3TP53a08I'Q.b>1R4cI*L*AQfjD<TYZ>d'$,tASle"j,fB@]R[@Eh:a>p9EdG`11R3-Y0G+VS39#pHkipm'_&/;Ugi*I@ssF_.R]S'IcuPp9?=g7FrUTs!CPh[Sr?7sca)q"Pgbb&R$2jV(iDT,>3\$V+`.t$+0iXDg,Yo0_a"P4L\oj?GUqQB4p(7OMBu&Le"/WIqo,<J'?b_tFe9>(2CcO<ogA-[1GDo[/^J!#Be[&_]M)SO7(:G8Y7pi"F+FVciC73"*F7F9Il2s'rGB5AlIGWnUWpP>-`NOQH%<oBaYW"riqO'0;Q*&PM#?AGs/n31D7'2;;l;&RJX)K>*1:i:3)h1CWtk&aYRLXta\D:Z`ZOCji)Hqg[`C/=YmKUrj42[8cZbo1')h#8caB:6gP'OdZ$aQ>*_f/s0P?1?qb4sYg7ePf]TE#6AHsiQmO!o3N$Ejn%lS_YXXC<_"63.B%:JR>i(hmQ]sG`7mL<RnlIKHmJ,FJS)dc9qmSIX^>)*]i<o*,\eISdC(UMHbO16DMkKN+/)_j-8Ofm._gGE'D6`'K-`*%fuE,<:Nk\+:gW2-:oU0(0"$MDnJJ!B)m*i"Y@&Uf!+*g)IJpDs1B\ZE6m:"o[]oEZ0.lY["!5#b*oh,'4Z'j[9>8J6-u;=YC=Kmju`:7U]UbG;CS^W=XmDM#;J9uDud%NkuSfq_!ZMTrSTS$'sr3MerBH2/N+s.FRhO$M(/WJ/'P2&;k;JkfC*);8P#2!oFV'kim+-Mb0=8b>]b1fqdWa6R,QHq7UDj4I7t:=PWJ$NrLV`!>,B8!@g0F%T%&WCTn;5'P.Q,l6KgFN@?$0G+N7hd&X"-MS]L0<QcE._[%*PIgsjolcO(-FJ+=1feJ$FJ)%gW[cVYg!N&KI]p>13SGprHa$=?<8aMf9X&8q[pD2>Q7$dd2:\T<8h6+`%/5+s*/"G.p3j()'XG+1"2kMi$$IJk;t*6rSkeMW_/J\C4q8W%bc31ji2o?^ZgMl3rSmn9i9a;Z_tr^,:2Tq0LK:;*<Ws1Q#>Vl=>FW2ncI8*^cq:Q6fg\)3pMe^8#F8!3FSiu7&>f\aFS[N,QX,gL2_m#*7Ws-/%dZ9pFmPm*h6U0crRc[C4RJdH6)9>9iia3Q*WNjr'q]Hn>_jg)WfCJ(h+pqp4)%IeOX9?)ccI23-L\^sr]MA~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 535
|
||||
>>
|
||||
stream
|
||||
GauI192jS!'SZ;\'k`piK90WsP*^8o#%m'$l?:.dj'3Rjpjk!=nF(^64)*,.QaJ[(aS4KYS1=cb99!T;!:P!!>pq+\r#WudKDZW0oB7nA3n;e,&@%F=VBN6io>+L*M^0$OVDAB'X`2k,3$k*<*&Q8MX"6BlrZkAQ2O2i2#cEj8V+:3`8@@U27O2]:rEVYZ\IDjld[?+MVp/]KLRLs)<+@/K8o0*l_;b'Q+'5u=.N6H0f<+R+GUcbqG?S4@?_*c!6TkQUiWK.RFk8S%L3L^g,!XcQg=&!#,"sQTU&HnYN/u/[H?^e:0^4$<Wk'dTpOmEDe0@l7RCbj**X5upnTlrJN^\t<dCZ)glT&T<Q2G,cHfPfO_g6L"\!l?M(O+$U"2oW/=G]EpVfob6"Xf^&W'\hb='0isiE.&k^2*RqU1c(BMd[+0W&SBO'Cer,/WiOGPUe4*W-o>9IK)glgK$C3YA1+*m8%lJ[Te1oChO*PO&+hD:sm]-7D6XF-ER$Vh,</+0Hg/%WlTr?S==LAq@,A,h?3~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1697
|
||||
>>
|
||||
stream
|
||||
Gb!;dgN)%,&:N_Cm-%#9/Hc%S&p0VejC0L.VFf78I>RM[e$.&N(R_)cVN1\i$XQ(j[Of&83)dq/JkP%g!n_RM'a'>#<r\4YJEKjUI)(8PR%=9k7)8F(lqa0.LN&=O63;-AFTW+!E6SWF\W_U:L_oQ2JYG>*^&XiD;(>DXELcJR*srH)NuSABpSb7u&B1HC]PHP5ic1!]"d,AmXH.;[;^T/-G\qO>B(LdcfRpSqZDaoKPY#6!1kkJSGCLULl[5kYkVUU!pq8/!FBZM>F!$to:7d_^>ZU\<?KuU&jGQuMeIG:QK]s`ACOe'C8VBb8<W"ee`:2aY1+k5%n[ZL724V]i#jq]V4YCK)jRG-+T.8h=a=+`E`>oar7_-AL*i':4UhO":ic(Md.b)8R<SL.Xk'MJhO^4U#Yr6DP`L)4^V?$Js&hLK7RQ`W[c4FE7>s!$C5%X[[C4W>V&im=[,/G3;A38F=@kdMq3HaV_1>c^4+!6m2WO(2$Wm-U@ScsePJ9(l&EEskj]k>Xrqa+Dkbj3j]^/s>"KO;b9S4FE'ii_2m$G?]QrLqXIW,d<Xlab;K2Eoa3,)J&OAina&_[EEfN3f3J>UncdePrZm920XJ>k;%@@Jk2*8QgD=j0(P'dl<970MGm:8le.rpT:Oah8,(Z=_`c0;X^_UH?61?\i!O=`7f(Yjs>fQ]1!HMGg73K^&4%IBcKF-+7!Ha+;,tc8lRT6-Ur)c:k7o?TiFf7<%PnUNCt;Nqi,i'>N,oWc+r1H(!\%':k*.U?(]3C[X\t3IHf9YS]rIpX*c1'$QdBtPE!@]&?<K"N:N5P4Jk7Ym<ojMWI>>aO0p"cc_bX;81GF-3p5bPS-h79*i:=t67KX?np:q6?a9t/r=Vq0(N5Z)OY1f#eR]Vm%`?h5#G+&3bF<(OK9nca_KAuj%hP?U?k3K<E=uWn>#:CGCgN[,R;mo;Jh88<>[OncJ8^ol=_)L),ha=#$_<"ACcn.>WeneeLj8UQm?"L(&>M&kk1/k43Tc!?e$ZkNs4a&c=#9,VRtO<HgPua#LmG]IjT4Y`A?M!_U5NN@)ODi5G'[W3&rI9LIsNktV/[[2OIcm<;F4"-dZ`VtXR9UDYSKT%HXl;S/[PbX]rq[<-_lhY:Pud3o8]V7g#4\_@KS'/H3/:u+0N+hW-t4c@8t5/H>:lB%mH!d4IQ/F(O8lCUKn^BkbAYL/>CV@Vlb&G&Z;aQgnHYc/bpJVASkJ%%Zg.1W:)EuD^$[srlae92lf"9Zs.VG`G$A`H4M^:ZXmD*j3cH!m\N=S$nUq`OV53a'"uAm9)hmcWZk4.J2?(86JO9$5#$KM&Rs1Y)->)hN1!?rIQA(XF(a=.(ST,PMbnWHMbn&[4W*R74g53okf!`<]'$V39g*6ohJ`h3"(Be<U1_]``56m_s%'>rq2ll3#U)+5&l8LQ2P)T`g\J4'5:QF.*BlGoQ=ac6MQAZm(t<YMArZQF;J2Kb&6nlb1%\''a'H,sKam)9-]W)W_LkE!k2m;(6^S<p?&O\TbkF%#M+7ZiYk5?;mTJ!:dT(2oL=ISNBbOFA_-G`XXpJR-/eW#MbV2PoNF=9kW/$jhn&s#C7W>dC^410tUC-uYQ1Iii((b*ij]S!!HBR@\bpQ;Wh#gF??d7slK4hC]hbHah%*Q.Qo\B@DE1rKA)QOG@WqufLE6``T/V<`i(S#sT#6K=r~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1699
|
||||
>>
|
||||
stream
|
||||
Gau`SgN)%,&:N_Cm.a.I'*MAL9PSne2]'f9:=H0/6^6gh'I5Ep73O*mg._LZ$XQ*@[]I'(E@d/A/V155fKIC]",\2OI)d,?dj[D,c@Bo%/q?M;@JU>&9ta=DiB@dJ![9H9+U8VeqZl62b<bsB%sc5]dt$XIK%,`m*GXLl0*kY[V1HTKNr]Q7)JBFF1Z`EPb'ZL&cI'$Fb4ifso/hETdL1ohYsXuI5D%i"eh,%<$:uXEJV%S?POmD@T>A][BrZDX'Iir\QR6%h0]P9G-YEs]<)liHAe%%n$^2@lbiJF?9L$DdT(OtXQU295BX!Krb#CnR?>bqkZRPohEIWh28KNF&!PQ<Dm?)eQ0b('._Eu8qNB?P7g_h]>07Co(Y"3p*CoPGW6E/HZ)qWmo8QapBYsn=q@aE`sL2"XYPV\"Q/l^c5"`).<!kfaHK)L,_0mq,uLXZ?%k9]ic^`p`>Nat*%/Ru0YfNWVq;V=hbqhNL7nhf%c'A_cj-tXW2/EC%<6X6=MDIRcMM@3k[2IF1YnGNPU,lB.+j@$6a'6bCF;q4;@ppGP^(\p1m'*GS_KdkVfK#or5gm8k,86o\,0gbcPSaa.)em5%Yg+(`1S@jh_Ab'gA`oLNj-%<Tt-DrGX+t#)CC^aP_7JXceFDgY>>BI"qKQdNlX!CfRq*R,bD_"LfYO-e/Z;rL)i\-K[pop/>9td0Sij;:47PTk]hIDObf2ank@^%6mhSpo6A.Z-@ak9QfC2,++-<J-567!)\(>Y%.4_J7Q$SE`@TU[SZ;CMW03("97K,au+6)`at'ht@okV4*nkhsYL7Ls:-"4qGiYaXPE>29GiS!9Vub9l;/UDTsSbZ@Ml-V:a!ar9Q[f*8s*V\FN47>Vn3&1%RBYHON\A13O?W#Nh6UMrSSP*doR0hSB2^G\lZ2JM_qg\[%t;R+"L79U_L&PDEuMU/@Spc:KiULX(>1FF8'>&);$DZIJc3_C!c!@"A.DdkMlg^Kt[@Tg#I#`qWcamMOHd_L5)_:lu-MO*CX1d2fY<,cjZFEVp/Ad;3G?Nf*Ae!l+#5ltK<?TW^[\0j6Uo@Ud-]CD_s2eCk5`%_Po-`(olI(QFJq"f5BSWb4@ErJO>KstXTKuj_/KW<[h0tsBepAP_:=RGgK(npJJV\K%-,pm.jnNcp1&eiu\lG.Ti-</eDa1thoM/babM/a:lJC23b*LE-FmBt*SaDI6D[r(3?[FR!&[.ROiBT>4pk"2cN72j]@8*eh2e_8s<:6u^#"AbdLX\Qq]8S_7.W&efVeoC<Z*&A4fNfN%j-ZFU^Yn4QpIi#\>X!XgfQh:]GO$J+lGmfRK0;Vm^0rIJ6+j!H8rC^a;M"3R_6f1[>s.O/cW/.n<r8($U6@7"OC91rm'nRs6A1bAYNe+N=-+b6oaj*3X0KT!?@jQ%BbP&Z"XK]*cGILG0(#N`1am69J2A,iDLR3ZJl4N_`]VL?`<)#Hjc6`crFCBVU[bF)2hETWp23u2XI>Zr<F=I$"cf*oA5hoM?M8J=V;Vfj7]CR`*A"U,Rgdfq0Nk;'95@<H?YSZ`QUZi8W@i`lt&F:[!mB0nhDWL,,7A;q[D0McK+MR_(Tn1\)>M%g!<pP'?ijScQEo*%q/S1I4CVl\#YcULFA@XJ;m_/mlH\\[#?].(jiQ7!jk>RPg@?]3?=kZ7/kGX.2;".n">]W/XdsSt.(XYuJmf~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1837
|
||||
>>
|
||||
stream
|
||||
Gb!;d>>O9K'Roe[380^R$AOu=gl0acJ7Z>`/+u<P^?!8c6SLKCeom]Zec4J^G%*NE_F8-B#U3k9o:>>TkF=6!pgWpRBEGCsK^JLB(l.&f0L5gM5Jk7`qsG'!\/#^5Dl=as+G'!1TGW"n84?Ln!sXKp@^H,D(bm?k_"!rG9LE7IlrT5Kd>DZ`J/BVu;0U=_6ue6]"d)C\\'NCr)+Q>&C<d<'2STNP44mf;(=a4S18uQ.-SROVFE.X/o!'dAo/oh-c`44sGm,G<2=)B%m?V<]<]5\Vp<'9.FCMRk",7UGr7/pSi<s!*iD[FKs3[-'+$Ro3o%FkG%IWCuS7Eea*mb][l2jQI3E&W]nJu-s=_i$'8';`V.%t&.EClV>FtNBs-Bnkd>N9>09+[s8n#sgOT#b^!n:;Is*)sNai4_HCMs[i_:J5*#&0\m/F6YLXP]\(r(5TCs,*-mu;Z*F=3Bu<!dF15KcFl9;goKuDp3RT<Fn@.mk4-jj=[3f33&eVaf/M.-5'toQ$Xo$_Dnl&9<K/(3IMZ@1,$BGI]7drra2-/b9O`hLB]]@GX:bo9_3HULBnF_kg3>#$<']3ZE7kNAmrH@Afpo]9KkZafNoJtN_u,qb>=G,bRrFWtS3^j6jA>7qa^$J9T/t)<V6SauS`0#r+:O9GWHbksMBV+hq$7%R5@=(lG\U0EfF#J0/hBss-CXq7%HHho78hgd#'g=V/;>fo"Pt!S\B:^RAQ=DK*@LPe->bK$ZHn3T+"q*0eXa0lHZ@nmD\`Pi\#ug<-XX=FLhV8j8^).q0V;QP.h!^B6S581g^+(a]@6_+Q;r_%BQ,f^BIm,;B=@X;;)mBb,PV]R\_gh?j3XErk!IW4e_Hd^<Bb,]RjD'La6WBnM<[2R_tN]D.:-mEUp%\?a<u07ICV#3,ZcXi<ISH/.Fu,8Yf@SWE+rrDO%:gqeE]]--i3%r9eS*Ze)oB;A(u?XHdBHLlhpL&oMhqnLYF?%SJ,48)k*Q6j]S"LFt:hXV7Wk<Dt5Hf`T'lgpu\DO8MmDgP39f`[;!9Rbd*"B9#8aCDsPa.-!6Xflc:Z*T@r5E-H&=i-Lf2h'@8dbNMdQI7Icc%Kqj,Wj/VOF)9'l0-]n&8M2&U6`fYuEVK85O6L/60C"H6Y)!VTS9C7P1#Sg>5=3P,LjL&bs$Y#?02,q2BNO78#/ARZ3c)-dm]7Nl85H^j6`q2Hl*>n0S\ZXY.3m`oQr-YD:pUIN0p*$Bj6ru=t6#C-Z3Pt(8[=^-XGrtNX>?J^9EfiY(!F)f_)]-W8,s'B^><$t@E=#3k-%N&*DcRBP/\XL?L'8':bq>M8!j*@Vo`[U)i7abDgTZLZ7t\RG\3arBLG1+6Uq_J1Q3uV$B]a,)CB=TgXkuER@pRAXD!5a0^f`,n;mJoOmGQqW`nA^"<jNu=:>">aJk4gp[fRd8,!o0YU?>qcPst#A5E5d.97bdQEoG@-U&lNl0t[V[QgB\[%m``\*$*TrLd%G$+:(Q'Y>#6EGHc"uZ<qV<.4V5.Jip,mmh>*#nZ]E:;$.fX4#krr5Kbd^h3SlBUN>tn"MDd`$&_pu:7[=a]U:dV><Y+=)Ja5,Z\QRf:AqW:]hmCK:"_`Jq3#<NT9cpqEo#Qh*>IP>8*1*uPZ0m9U@)PTl"9dViNRbO\o3JG\W($6nQHjG?.426K!t/YSkgM!T49a%Po8T7%-)5r<j//JFC-QJ>%J##rR\JshR^pLCpmBLs*LqoE-?sB$<LZE1u6rsmuL5K'>CO&^!.arH@=<TD:Q-X]q4<(UhRM'M?B6!It@#;(K>YpL+.]P4GsR4iX7j&:#%YHg\qU)YbSio`e4"=dB*sg~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2009
|
||||
>>
|
||||
stream
|
||||
Gb"/'gMYb*&:N/3%"cI<BcBO;.#Dul"d66,!sIFsj)eZ+:g%CIV7K*iS6h;,hk@2j@Ri_R^24il1.<80+_T(/7DZnQXhKE)i+N/s-PRt]aX<aOE1!03X,1h-&EY[5gH[;u=rme*=o&["KopoQ&BA9%"_"u1\/`#O6jQQg_f;hP?-"=>H=B\nY%,%p!tj%+KQCe=73'<dC//TcnSXD+lkA)8O#W,ABP.4(@+^)iUcMJ5'9h.Q%?hVuDu]fU$?+@H/:qH%7W>&A)(ZLr)0=d+FT8Qn_Eu@)]TaoaV-,>_[0MmgQorc;Xl4?E-JUB4+7[ZfJ`ak>1+]S>rjg&Ia'XLV&'c*?4'^8Na^L1*Xso1'+bTRH'fR[K).@N?3tN@T-M;6-()b=BVTHe\)\Yq!:8mK_obP1,',Y!ohG?M;L?b3im-!'K@9^,,di$Lj"DD1i*uPcbdf,rn!L_apJRS7Q7/3N5Z5MHF=CWBh9;?MWrMfPji']Y&jh2-9NIS%sR7D(W1F@VrXsIm]mFePJI3Gt(k1eKFI&FjU#eQ#@)6p/rNr&qX;4<^Q9Lifo_DIB1RdLW1Jg8Lb'<]o!^noGGZm+,Nh/7Bt4fr#[iY5k$+:;0_d*K&X#>6u0iXFJVCKk`sbfU^Pr9MI3b?8/+T:q_9i5BC,N;Nh`(+Cs6r-18;(8Stf6'snQq7\=djX5SlAaV(J]+R)`66$Fa:%;FWT5,pLA:>P*7SmO24SSeNOU(B^fH[RWX4R3\SF+q:XO8q\_FH=`q+Ab>fI2[h@XFtN8HC]rWDG^hCFojX'VO.m<]aT'E7_4G[qgm1Nin$;qkd,Z*Fa'<?#L<lT>9YJS2%m7?klYm`mIOW.t%lPQrA4o/Eg7KWrJdC^_L>GZ.XbtrENM;7*i^9D/I;eD#2nGJma.2`N-OP%m3<!b)9sHDL0iVO+K2;'GP*uqg%s2k3rEbGmtE\(M@HN*U4Z9NkTGW$8!G:!k$&=.R\eHS]b[TBdOKXS;qb.RG-igIN0]nc?/O.!1)rD0*pEB'joACj"]nJn.kjMrLd-\H$dN/WKU'<D*!PjR$CMo<C(e_k&GY-IEakk?;WG3n!JM$3MnJ:2a[R'_qf;o6WKTR]9LFI$I@b>9[mc2*Eg:53UW3+[37KX^#oLR0o^F\;b_U708b%u28)_2>d8WQ0co1SdjESL>EX'/,kA0;p1?rCY5?38Z[)k2f%dUYHi(JcPH7aH_,A;P(qu,SFt!O\Cf'8VDEu#HP=;="fV5Qg1Urql[?f'7U$=Z!(TC\O%"@_($(sT93:Po\(gufWKNAah@fb(0WIr.(7_N)c)q'Z<!3#8tD'/.1!O=hI1GsoJkDa5:iK*G@k0:pIZJ6dejbaO_fR5</\<\`Sg*+"+AY,RTf3H%M+X]m2;/e=A`)%Lt_:g"@KhHdKi8pGLK&_#7iGfKq<@=f5=I&5gk=>'XoTVQc5N/VZZ%\(OWo/NR0rltq<!>q0cn]t78*J^>Ktif>AX;InG=-p8f/ZjORf&=;@1Z$WZ)o>C/PY@Uh2SU&dm[&*C2H/jolumZ!CjtE0!EDOQh0%f;\<9-T[Y=iM3'-bLTb'uSWJ(+f,3pK/>lN(a7D\9@70R8MM$aTMZ0gNIoi0o4QfJ1J+B)pr6lq.6Xu-/q%Si7\gQWf<9p6^7fDegiG-04>7nWY%b3'$-B,E[@<X?$[^A#s=ZpZe\%YX@pBmQ6O&_q)R(D*r\!U?/<m!+jgB.W.Isn'ZBBmEF,;f?2qK8anPV^hZ/?LZ^L?qbY/s`NYr'5.j(+\B7@E'A(bkX82SammL9lE6l;frTor[<H$#8#lZ8*D.dqtbG.'[@2gJs5,LY<JWqWYYX%7oVT?4:4=P!.O:dq:0-7L0o\ikNDD@+'bF):h$[L<u(<iC`eA4r;a,fa0i'SpWJH"p<]OK+N7?6<G%cdpM?0/P#.Ir&sPVE&SBAB9I@5TLhN:7D+gH7dnh]%^K:[4.ZG2fRB1ZI\4WNA.^=X,kNR*W[[;[:+%7.<^]~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1570
|
||||
>>
|
||||
stream
|
||||
Gb!ksbBDVu&DcY&As:e;/X-7mmE0EH-TsJ('!',D)i0.q=<t]_TRG7Yr-JYkorr74D)YbMH;GjkpC7B`(LmdQ^8V&a)M%!,q!f.-Peh/B1a3OUhS@=fjR\&/KV_NS,g:ioB0c%7rIM*u/]7reEgBYFJs9e["AqW[QK8u<S<p*0"YF-XCdN]K8W[NrOGq1NQ9:NVP(YQcN_#Zcq;M8!^Wa6TP@X:qh&P8WL)+l9e.sp#83l2lL04UkDh%ZZ)IBC8Qsumk%DQ+!MNrlU@jtZqSmE6YN>3bP&@\B7>pEPM'P99XHfhj3n=qZa;&H,'n_XV)m_c)3RQI4p"7A6Wad,<*#P9'iqS:Q>@jHtb#WLdCb]-sPm/fF^d5K#4[BYR_a)"<7JfhkgFYepZ-^"#]?N@"j,]<8<U/'XP7p!7?okB%](&5.JA(p9dAHF]#Ietb*ZpMsM3O7ha\3V)2GC=6q]4W+.b7,nSr%C$4mP-?P];FY$oeY*bH65PQZo-dIa2/A&/'L8W`'X22++2s2TetouZ)l`,323b"m>sLK1A:O0^=/h:>$P:Z+n6WelXI]@#-hG4%DEGB-soH!#]Jr[HXg%Xf2d$\W\('4WZ@CRJnd\7aqUb&r5Fudip,X[)/2\O?.7=j=D71;K>D+j@8>1/E8`N4/4mU9I\TN[\[3L5Eba,=0/,VkKP4efeJ^*PDpA4E$-!R>*b-15G,?hRoa.S5PQlJH:3_6,ccq-i/7ThQ$q!`!]fKf)3Ul!B6=RIZ8k?9=06^X<2<rfjHQ&3SNJ=9o`0=k/_]BQ'-jd?eT-mDM!jrUk?do-^>!l01c5d6O$[L33i!kc*8l>6@0SA<?#nu_u;%1F8Y<hAam;?E=@'kQ[6+/"W2[.KNf6W*m^Otpd^]P?sNR8peD2.MpqcgiC5!=OAo#,DCiL2I7H-5+pa4&@gIAaQj^?E,>6,3u<j?UM(Z)+VYLNr7K!:$]4,O1>0YCP+'&3?=+LK@ctfgd4N>G^sqiPAS&\E3CpQh;bHcABV0Yjm]o>ZU7)4.F\U]r*,ZfNr'!ZuU#<j7#N'UTaa5IN&M\H]H#=Udr_,CdmUQ*:eV=8d0\2i@0J7Ap#a2e65l!$&qEeEtlTd.:RUY)HM^t`1^?T+Ca7i[1_#?;liBlp*621=Kl%GrakTE]%o2D9;`]O"2O5O0cdV>GJ)NsDVUBa\qtm&U9cB>X5LsCWc!Msf':Pe$f/mnEfBQ!F64`X=t#"'4ar]7;-%2]no2R]6$AK0B0@hkGH]V;Km9t6UR2X^L?rTmL=hN'%/s<C9Ca4O:ddn#Y[,5Zc%f'\5H<J5r?.[8Z8;N^7,R>Yc]Iq%26%DpdqX@*'aI-N%MkPp94$64I*Ag5o;<]?Ahu!B('OV=o[VHZ*@<EL]/mTS^:T#Aak+DI)\>?2ebi)b1WQqN4;rDlY>elscL6J"!UD7IBTTG^^$EBp3H%4)p/!hWiZl]0f2]Z`Nn;qA@6rJMZM=I>'0jZf6#RZ@/Sksa!MWed3Iq%+lH7:[/^.#[iSk41:,E>UQEmV$JP4NEPTTCG!A#&=!dJu57g]nVIfcJoAB4~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000001943 00000 n
|
||||
0000002236 00000 n
|
||||
0000002334 00000 n
|
||||
0000004862 00000 n
|
||||
0000005488 00000 n
|
||||
0000007277 00000 n
|
||||
0000009068 00000 n
|
||||
0000010997 00000 n
|
||||
0000013098 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<6b9084b4bccf0b543324f6b07f7d5576><6b9084b4bccf0b543324f6b07f7d5576>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
14760
|
||||
%%EOF
|
||||
@@ -1,282 +0,0 @@
|
||||
# Les 14 — Lesopdracht
|
||||
## Setup research-agent + eerste run
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Duur:** 30 min in-class
|
||||
**Status:** Tijdens de les afronden — daarna huiswerk uitbreiden
|
||||
|
||||
---
|
||||
|
||||
## Doel
|
||||
|
||||
Aan het einde van deze opdracht heb je een **werkende research-agent** in een eigen Next.js repo, draait er één test-query, en kun je in de console alle stappen zien die de agent zette.
|
||||
|
||||
---
|
||||
|
||||
## Vereisten
|
||||
|
||||
- Node 20+, pnpm, git
|
||||
- Supabase account (mag dezelfde zijn als Les 11/12)
|
||||
- OpenAI API key (zit waarschijnlijk al in je `.env`)
|
||||
- **Tavily API key** — gratis tier op https://tavily.com/ (1000 calls/maand)
|
||||
- VS Code / Cursor
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Nieuwe Next.js app
|
||||
|
||||
```bash
|
||||
pnpm create next-app@latest research-agent \
|
||||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||||
cd research-agent
|
||||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||||
pnpm add -D @types/node
|
||||
```
|
||||
|
||||
Open in editor.
|
||||
|
||||
---
|
||||
|
||||
## Stap 2 — Environment variabelen
|
||||
|
||||
`.env.local` (in `.gitignore`!):
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
|
||||
```
|
||||
|
||||
> **Let op:** `OPENAI_API_KEY` is GEEN `NEXT_PUBLIC_*` — moet server-side blijven.
|
||||
|
||||
---
|
||||
|
||||
## Stap 3 — Supabase tabel
|
||||
|
||||
In Supabase SQL editor:
|
||||
|
||||
```sql
|
||||
create table research_reports (
|
||||
id bigserial primary key,
|
||||
query text not null,
|
||||
summary text not null,
|
||||
sources jsonb default '[]'::jsonb,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table research_reports enable row level security;
|
||||
|
||||
create policy "demo open" on research_reports
|
||||
for all to anon using (true) with check (true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 4 — Tools schrijven
|
||||
|
||||
`lib/tools.ts`:
|
||||
|
||||
```typescript
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
export const webSearch = tool({
|
||||
description: "Zoek op het web. Returnt top 5 resultaten met url + snippet.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Zoekterm"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const res = await fetch("https://api.tavily.com/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
query,
|
||||
max_results: 5,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.results?.map((r: any) => ({
|
||||
url: r.url,
|
||||
title: r.title,
|
||||
snippet: r.content,
|
||||
})) ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
export const readPage = tool({
|
||||
description: "Haal de volledige tekst van een URL op.",
|
||||
inputSchema: z.object({
|
||||
url: z.string().url(),
|
||||
}),
|
||||
execute: async ({ url }) => {
|
||||
const res = await fetch("https://api.tavily.com/extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.TAVILY_API_KEY,
|
||||
urls: [url],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const content = data.results?.[0]?.raw_content ?? "";
|
||||
return { url, content: content.slice(0, 5000) };
|
||||
},
|
||||
});
|
||||
|
||||
export const saveReport = tool({
|
||||
description:
|
||||
"Sla een onderzoeksrapport op in de database. " +
|
||||
"Alleen aanroepen als het rapport compleet is.",
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
summary: z.string().min(100),
|
||||
sources: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string(),
|
||||
})),
|
||||
}),
|
||||
execute: async ({ query, summary, sources }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("research_reports")
|
||||
.insert({ query, summary, sources })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return { error: error.message };
|
||||
return { reportId: data.id, success: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 5 — Agent definiëren
|
||||
|
||||
`lib/agent.ts`:
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { webSearch, readPage, saveReport } from "./tools";
|
||||
|
||||
export const researchAgent = new ToolLoopAgent({
|
||||
model: openai("gpt-4o-mini"),
|
||||
system: `Je bent een research-assistent. Werk als volgt:
|
||||
1. Begin met een kort plan (3-5 bullets).
|
||||
2. Gebruik webSearch voor oriëntatie.
|
||||
3. Gebruik readPage voor de 2-3 meest relevante URLs.
|
||||
4. Schrijf een samenvatting van min 200 woorden.
|
||||
5. Sla op met saveReport. Inclusief alle gebruikte sources.
|
||||
|
||||
Verzin niets — gebruik alleen wat je via tools hebt opgehaald.`,
|
||||
tools: { webSearch, readPage, saveReport },
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 6 — API route
|
||||
|
||||
`app/api/research/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { researchAgent } from "@/lib/agent";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { query } = await req.json();
|
||||
|
||||
const result = await researchAgent.generate({ prompt: query });
|
||||
|
||||
return Response.json({
|
||||
text: result.text,
|
||||
steps: result.steps.map((s, i) => ({
|
||||
step: i,
|
||||
toolCalls: s.toolCalls?.map((tc) => ({
|
||||
tool: tc.toolName,
|
||||
input: tc.input,
|
||||
})),
|
||||
hasText: !!s.text,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stap 7 — Test in terminal
|
||||
|
||||
In één terminal:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
In een tweede terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/research \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Wat zijn de belangrijkste AI-trends in 2026?"}' \
|
||||
| jq
|
||||
```
|
||||
|
||||
Verwacht: JSON met `text` (eindrapport) en `steps` (welke tools werden aangeroepen in welke volgorde).
|
||||
|
||||
---
|
||||
|
||||
## Eisen
|
||||
|
||||
- [ ] Repo werkt — `pnpm dev` zonder errors
|
||||
- [ ] `.env.local` met alle 4 keys + in `.gitignore`
|
||||
- [ ] Supabase tabel aangemaakt
|
||||
- [ ] Alle 3 tools werken
|
||||
- [ ] Agent draait 1 succesvolle query
|
||||
- [ ] Je kunt in steps zien welke tools werden aangeroepen
|
||||
- [ ] Eindrapport staat in Supabase (`research_reports` tabel)
|
||||
|
||||
---
|
||||
|
||||
## Tijdsindeling (30 min)
|
||||
|
||||
| Stap | Tijd |
|
||||
|------|------|
|
||||
| 1 — Next.js setup | 5 min |
|
||||
| 2-3 — Env + Supabase | 5 min |
|
||||
| 4 — Tools | 10 min |
|
||||
| 5-6 — Agent + route | 5 min |
|
||||
| 7 — Test + debug | 5 min |
|
||||
|
||||
---
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
| Symptoom | Oplossing |
|
||||
|----------|-----------|
|
||||
| `TAVILY_API_KEY undefined` | Restart `pnpm dev` na env-edit |
|
||||
| Tavily 401 | Key fout — kopieer opnieuw |
|
||||
| Supabase insert faalt | RLS policy check (`alter table ... enable rls; create policy "demo open" ...`) |
|
||||
| Agent stopt te vroeg | `stepCountIs(10)` → `stepCountIs(20)` |
|
||||
| Steps array leeg | Check je `result.steps`-mapping — niet `result.text.steps` |
|
||||
| `gpt-4o-mini` does not exist | Check OpenAI key heeft GPT-4 access — anders `gpt-4o` |
|
||||
|
||||
---
|
||||
|
||||
## Klaar? Verder met huiswerk
|
||||
|
||||
Het huiswerk bouwt voort op deze setup:
|
||||
|
||||
- Extra tools (listReports, eigen tool)
|
||||
- `prepareStep` toevoegen
|
||||
- Custom stop-conditie
|
||||
- `AGENT.md` documentatie
|
||||
|
||||
Zie `Les14-Huiswerk.md`.
|
||||
@@ -1,194 +0,0 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260607091327+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260607091327+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Lesopdracht) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Count 7 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2309
|
||||
>>
|
||||
stream
|
||||
Gb"/'D/\/u')p0`0it#uP=DV/jkI(+JLIMPo@7fM>4Gl!!sVJ:Ct]T&OHa&JfAEn[`MMYigr5mp)'\hBR@2m!B1=D-j!1#]A5;c79&ne3#t`Ul,Wt5:FI0G6c528X5_bk+bZ3GX,dGEJIoN"#3V`[`+rL\b4Cfr6aA[[>'e,;J(PX-0;Uag\NZ])A4B*@V-2^"3\(8/BO:n9Njp+2Z\\?Ng)>,0hFP9%*p)d\0+ZcBE<7bVbpn:L43Gn/338C3W(8n`9cAEg@:N`Y#Z^QoM$u*"'RT*9jeTs^"l:[Z_Mn..!=#<(<l"_MZJF?'jaiFkAnTT*Zoa-[g0e;9*H9XSfo9dGrlQc+G"W#0Sft\=RN-lH)n_g9+!S5L90D+"ik*2PO1!'MR1,shM\^Ing8t<U=KZgmo$a\FX"j@EE6p6tM;*1fpf(UH8?oW3g@=8ZqB\Wqf*"Fh]I!>1oLZsL73bJq_RL,gd$elu_`GHSb@5n18[S%EuDQ&;ubE-__I:[8^;)bNlf(U$9qaPLNhnq#a\&/,WgC9OrHm+`YL;=IM&KtI]@:h]?1lQp:N5,Q)*(tGe1[njA\HlL*;@"!t%jUDZDX4h8='cDjL+5SY(%-_$&W<G=8c\ZPf?4?iXE7\oB;>59nO5[)eg7&Nb%:QcJ,hJa,_H(7:?]UO%sU=tMo2%CeQtKihC'q%Am;D#jJmA0Ch`q$q#l^h/2/$sJMd#/FVnK`]Vb<tWsF/b9ReP#=N>l\OOhCe3VZYnn`t3u5&S><_)JH7&Zoc]XE@9P5"`,jWZJJ(Wsgtdj*Fik_1WU&2I_SSFW\]&X_E)Fds4A<9c68Br`g4!3naS%`Gl3!5?k#8PqF_'RFWa^:,.2jRu\Lloj7%_89G7o^Z!^dK'2h@DPtlHIn/[%_[_Rdj98CR$m6lX\3!Bs0^JhT3'X!Jj<ptfP71<ePF@Xc?'sD`W"tSLSn]3b%")-Y;lt-DgB?elG(j)K[gX!t('Go;UZ4MR>!0)#Yp&aW#rbB]*^5=(2kg34NLgHIdD'$emej$Tfe$E1`$<7+B-8E@>fi]uX*1Q_%+HAmd8K4mkl^>umd_sVpV==M8!s$`G(a=uZc7!D4E&[`NB:93[O,uhB=]F\_B8;k6K]7tRZm80D>H/h(5H^\Fc$t%l+_"W?=D-RGXLEa3!E8Q8$:_b.U2TUcCq:boOY#FlSg?7h?(d;1,hN#$u&:q<s?#qcL=ianR@%e2lY*Udpp3%'FRb?h&?`n%$9s&ok9?G_EF.L"^Z_"!GSlgmJE!POeTCa#(9BlZV9osjKQ[3bt4G>%kl#B:$pDR+nf/Shd[o$2KO0J<ZMJ\>eOd1^o4Ji]EdH0'N70i<dS_Llk]>-$nUk)MTqk\1p2Q'9\kiA3N,4dEspp;$71X&h8@s3M0>@IT*,"gkWgG=7qLUMf%)?epkKchM,\\+$"+-ef:N!u1;'>];=K+b4<`5pM(lS$cf"TPiloPk<j(4.&4Mc^oS+.W@fD*We,-I7GW'e,K<2.e)0E)f`$]O.8k>+S#mNHER`D>aMAr+7L93E!Gn.EaW2Ver=NI7=%p3WUWGc_F<u,Wp^j2+aRdBLiKt`;T2dk/d'FFQV$"L=6quR'A_?=^2SR4QoYcYIM]tl4%pK'lUcKZgl9XF&M_0tb:;H-jGSX+8d<`Gtf0?""7fdpQ'0us8!c64#RUDhJ=31i@H+"t,E?Zl:%#P?jUnX//dbGlt_T(liL6HA-WHX;'H]I(C5?GfR&"U:GMfLto.9)"#UlD)CKmn+1tjpK<lL#@JOq;4DR$0uj0)Sk-fKH!tpk*gplKso1UjG\FJ*;6>]SAC1V!(B<6Hc$ed_#1VORk)5h4;?VI?A#rs:psg1,#i;2bUj[@ZA`QICBM&WG3c7fp@V24E%,NsNo/q9X"+P'B?`T!3B*.P.4KCECW^X),Fm?>/2h)/AlX8$FS?g7$J%XR]2H)I7&*V-^!KbWVMj.bi\\86!KU<<^is+DW\PI:dLqO]^*lSZcdK=t+5W#bs7!6DM5)f7VN%Zf[\<!(CPOi+NVW*\7WD%=j)e8F0#e&m)k&_)N8@T@@NVd[7d&<3c7D:(BCqbQ?C'O)QQS9@'&7YZR?g9ZDQ2hT0%9S`BD5I!eh?r?jVQ7g\#+ad=b6DMV$)Z*L6=P[c&5L"j65%l(IGh%p)_-[^(]j+'C:MQmCgWe1$Zd=5IUUgV7IKu`h#Jle&gRomU,qhqD,[3*+=gJpU:)X4l652DKk:Bn\M.%ah,[2p@5%FGCMWRlUCL<GRV(s]qJPV3dKQg4PBV9f4_1fs0]#m7u*W7!XlsbMZ~>endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 626
|
||||
>>
|
||||
stream
|
||||
Gar>C?#SFN'Re<2\1a+.,[B*ok9Jg2%1Q/aV;3V$64dBm'bZg#r;Kt`J^BQ5k9dp`a6+EH'MfL9fcK'8Kt@QoN'mFO9Fq6h%$EID(D3g[((s/SBP][?)uBa&D,c*d6s4CF(oiG?!eb"#(i+Y3T@Q\'mGNC,0d'XsD$1EfUb_585:28/>Zq]<Pl9=no.uu0-)tU51qO.3E!GVeF[h>oThK2-koOa*O"XH,<Gc$*mAd1:kI,QeY(\g[2,'n-8^l`plSe7M*F2M_p9&5H62CWHaKK`;0=W.[$N1iljr1$+3)kZ-]fJM6<nk8l!PcEr6=$e9.Vjj&<1k?.qe#6"%GK@DE2Z(R6<<.P0,\s#6"ng59VK6'-'>Gc0(k:U:eJf`!gsi@PsoK,!$_5hNpdkZi_]9'pe"=6e0ANa8Gl3Z$/LdLSf-nQcNq\W`g*$o]jN1gk#,=*K]03&1IVCu'>#t7>m+gPXE-F5Q%lC2F&GekU'^TPW-D_hhV.kF6F?2&0Pel7&D#t=lWA)=V(GZ%@%:S$K_`9qE.YQ?TQ9q>8EQ-#EjD&bIj_F]2t/UpjtKU1VTZsMrG;=Y3U/B)Y7S6ap!M=M-Jpdb(\u,!Q/kk7Hq^-K$.45*R+\)~>endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2004
|
||||
>>
|
||||
stream
|
||||
Gb"/&=``=U&:W67f]jc+lG*B*n5(K,SMZbGof-ikAD0Kj%8FYS*F9n_3(bOt]aKpR.-G"0P.m/E8?ID\Qb2,r?pO7;g&FVRn8J_i:Dbbqdn=)##ah4!j6>e#LiAsk63?[Dl9u[`jI$D@X&/oG-[ruY"sFFH(^LZr,@MFC^i<]mYQ/DAQZkq.8Dqr>'L4u3rQtRX^IN[s;7.OppS6L?48V?S?:=_:3]+2A75.u_\sIH9Sfj9>5f,@5^!in*O5ZGr8i=_r/2MOJCR$G.bW$-kkkO"3e"ht($i!l<;-@&`WmLVs]nYcWUT9/'\0*mH_(P_DC=%@D>?NQCfKl.K=(r8!f>IG*rON%$H"UqY6fNAJ^j(7Skp$_0'7.M353*q@8c.^^fn]ld;A&V54R?)T_?-A(-$$==)*&QuY`LRAj[9]`M^18uO0rbn!?4=V#&^$=&]8P?p.O\$W7-ogkgkV"5<'n"c0dH8Dk;oR,KhV/dt[nlM;aLAYYW,r,/(388pJFe#7PUc:kglEU)+W_=uY?Qp[ETh*teK,^>MDb#e@R"J$.f=%C]:r?WHVbgaLP>L!#,U+[ndIpO#U>7M-QFaPC'0=@-EL&`0P57j85m%!8dk,XQ6(DFH0PI6n)HqN>g(-5ef]PDi<eR>#Z:%pA1EYQ8B7[YpXZ:4c<s%u(``)"t\@>;3Y=AS;ZnhgoZ^mIkJUB)D?p>'LV3W6nDG+sIV3*4^fGMumA=TI%"'[1-'3YQ?[3Y*7t[<`N;g#%i4SXpY_s@?LaMZ;:Pc!o.p\?8\`Xis_qKc/X*ZYLNDV!00[gHmq+df.2R0g.SKU)N#1-<`q<\';:=q'Q.<I<GqJZ/I]YVP6-rBK.0lEJsFYef1fCQ^d;>HX%^F%(GG5N-<<#)ksn-t0]X6blhn^aenA3TX%(-Tj^=#;B!sYQs"^;WLp<(F`7b,#?X><0n\)`8lfT(0*XOsSgSBicXm46[6PZ`9]Re;6&nk7MW`-SP0S<kIRASSK?r^fQZA.m9jX$.V.`P:#+/p4cI!9MBHWeo2B,TnGiW?Ac9j1dFV00ILZ[K>Tob*Fa8mXO2%tfRnqF"dQqS.*"e'98S7HY;#kF1DKe,W6=FaF!U'gmqqCj(b^h:I<41>'grQZhAa`[isHrN38202!r.[F%K<XYngNeA\sE4QK,P1k(X#lcG.rT<s4/I"kR"nD?0$,_I[>r<DNnX=+][AHV@/*O:Tn"\rMFW;GKkW0>R[(u=KR`XUEbDHVQ1m\IBB9^j)s+Q3E-en#?O3#rOZ5b*=\PS)XRH05aqQ&?dk*aq'qT%o$5lfk/q`Mq^frR>okg#;)PhK:W1+/5gT5e#YP7To63LnlR?%3$%$J&dqM@01d6f$L(jhGmij[r$pNhN64,Ino0>cQ8Yt5FS3/4$.b5FT-Oam#RA!#21XF+e]s@rtb1ho]bW<\&Wt-id*fAoU3OebKUo^;`Wc*`lZ4up""l'Flp+Vgp[uemCaUg7k#$,g.X^=49a;MV:2`;_"d@r]u'1E94a4lQFdL"BsTd=@&+-6G?/hjha:><<M_Mi%o$o1'%dYE7CCS,fN@`i!q/[*ENKk4rVLc.1SR@jfSRjh"!;.P6g+QZh!HenhVs?beHFlD>pS?I%2.Ir&I+cCHsoZ[LBa<5nbpkKnP,Ng\5YLlcM#7NXfH&SGSjL2dC'UMUs'A(H";:VW(D$24b7R@^pnPj/hdWF:X>qk?56Rp65K7f03,Y]FYgiZ+$V4_N0_1:>utd"OE":Zr"PVS+.>cC$m13<oT'%/b[)nU5eimT4mc^C?EOP/G3]R`?KeaVcMNfrS=:Q_5<emYIWTaLP3hM[!psPdI/nQjG2?+4SKYc91gmIpf%lenm?W;c^GXbaW3qG(m'qa=',-qiE7rk]lj[4%oVsMp*pI#1!Qj@/Z1_qVLLT(O^gOs(LOdYlR-t]e$/Q0]*256#51t^D9bf@FUugB^lZS:!Sa4Y.#7ob3bHEoB^`\:,j8OhgdlWNf!QPbWJc~>endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1601
|
||||
>>
|
||||
stream
|
||||
Gb!#ZD/\/e&H8h>EUY?RE#Fh5hE5mG[^BeWTZnCa5Z&<u.l4G6]p5>@Udb:[Nfk^ADE>bS0[)FKlca_DGgDK_),P^eqE)t&I[S%21BrfUlk5[.!bSA"FQRso#krTe4<SN4h1u/i>l+SNjlG@q`/f/]+J]#E!s'TV,RgFZ3;NdZ$U?5PI,lt(JPUnp!"#(VL]2Ms0(CsBd"6MV+78p.o*]D]&b4K+@@GFG_/KT_0<.B^"ej-J4IB\6Ie!!R#s9lD<#%'E,)+W<X*A>dc?TE1-]a=h.]JA1^i/LAV+$fOg`4,r,Kq4^=<%$e$c4,#Cu_.f$I]5"MK5D%#CRr&P$EUk_"n`1g4K-JSS[6\,kS$#53fF@o@[Fg"_!&6>g\9V>DoaeEe/cU(gPd:/(D8K4N[t?PhTT!;&8uK:cC:+>D=,nP].0#&S-n-Jqi+,Q9$4cf!./V/`79lBWu1WgGuOf2[\_9KH[Om3h*oI,MPs&39P`HWQUs@+B8B&3fF2M\FDNF_.Xo@G%dLAM@X!qC]Ai@rQj,!3cut-G=0R41DcdS#"laT;f8fSObbg]"/h@h)V*>seP01["*'H)#!1V4eBH1XhsPEn(5[_gnW9:[$"/>/XHV'X76ZFQF"($E_0(m;C6@.d:nKG_[ELq9*+$B@Kq]ifaZtg#-XILEFc7S!=6;VOW_?1H.k*Z0<3a8-@AQn<W/OLBX8.!h5gWt1\=4QK`UH=PM<c#D7`?mQVBDqo4GG`)`ITC$_ba&iNhchq^<i.R_Q(a31,3aI9Y?mV7n[@7C3k+QbCg^XSYW_?0![ie@rT>Sd:=qq5b:=YFeJdjAX:::X+0j6Ho>)1*g.r@`J4C*4$`%F$K#7`CQTPuFWSkH9/T%,=dEb792"a4a/[3NE&SJ]"b[)%1VjU@icY[DT>,cYVCK-h*UY`K&a"G0M#q)8/j:]e.s2H)WZiq--F;_k-`:l9NmCHQrcn9_+MGk8c.'4'o0T<oq<r9I5N%RbKBigXTAG"+ebk5ZHIK>ukjm3[]6F@]@G"=F/YnM794</ZbDC*2!BB$Mh[RFC[(%7u!A,oi]N&^H,P$l/59a9[\S*?1*PlDG&AiQ]i,[\pd)rG3%eW#S['?Lq%oQhCWVMoV:^JPX>BV+ha.q<;niFQ_+1@r8]i4BHogjb1e"aUqYEIN&Q)[X1#Ke-$GhqjomscI0*a1#L,rXl28%E#6eB8p\?;KkJYI2mNf08itf32hu7f-<f[J]".VJV&>p"LVWJbj#uS9*uOUOPZ0bDFETDL[Vj;d##fOlQ!/[6UG8Ghh"P:a4j8'bp\4Bn`Nu,sI\^h:tp(+7&&q!Y=dGV=a/`cL.Uf!7`RBj=U_[&j3dnh>I.a2<#?$NT\'qP?LUYPD:f0=0(<#f8---^=QhXQrNHSX'J$2_eRh<>@\gpgY#WVHYto&bEJ6k8R(0ZhLN>(eRj/VX-I5t9'\g]>piq.Wg"T9V=k[/EiFs*Y>M5u9'UYHU$Ks-NTLImC+s*CH)7kc=T"aONNe[?lUJfVWiu_+oa_0d\O9?\WaY]b-*7X*AfquJJ*ajs82l#LbY@J"nEO-AJ$Q9nM!_lj$:_4RU&RhY#3RY/]A6r~>endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1809
|
||||
>>
|
||||
stream
|
||||
Gb"/%bAu>q']&@2DS52S)%-L2&-S7igH:qU*D_j<9!Zhg0Ek+V.?Z#$4)$&AhTIk>GX?&,DIRrD&d$?ApPHe<i\.A3LKf;,$^eV8^c3mjJI*N.E<:V@S1BA6aaN+KE><Tu%O?#:+3<&/!hLYmhuNpKIA2)i68>36TRc&_he,.6#0n;O%#?R9Y65EU4K7CCk?T8l8(3'7;?jVV2uHWH0-IQ/LG\<]"N)7]EN2BK"ej034]lN/o((>CA,%Q[Rj-X`BaeosDShBe-0IHS3M&D`MQ!ak"jbH$M:69V&pjnB$WoMBD%5t<(PE%1ho@r9"k!YR8@)=!46Y:B*@2W:!S0IPf+q'NL9+/cEF%j>5/%nVa2Qsb=%FKClYqp7O54"+E1]rVe6n9:AsXXaQ[H=&[<k6<>Jg]C(b;;kV#^5n*d$$eX7p]=g4^(E[a24rn6=In8NS'K=U?,&Z\\.J,M=()g_b8W3ZFusX7E_ps6N0W5?._WU7)]4UlA6g(#po#`-%?CWjKl6?3mULI/@u80.ZsNn?;?c-QWZ"G5(K%Xj4^tWbdYP"Y4X8-.+AX[uDKk-5iil):5N=b'&I7Dnk?fBVi,:LM>S^-,E.mS]u7jQ.TQ[BB`/q_XiR":g)<kUZ`.FDYI;c@TA'5l%I[FDB7^5FjkPMYp0r7&,&ZPA<;HOl46rq]-h^sq8#''9(<7g>8,(6.&T`JcFL#8FUYhrK^)pe1A$hMHqerAg=n)-;>!-"SniD]pSNGQZP$OLW[uN/$8EY(B,p6+;BfdNASr,tZS]/\)&>3%:=:u"MWG+_,\><M?"/j&p]BW(\qL5,*B^o[1pt\(.H4dIp7]sZ,Wb3uIb2uS=C'5;j\\QU@Vr&JCi@PNWR6"$3Pn@i=#fOTEn8k)NN%*.[`H[e?cArU.B68sNI56&a"\Zb9uVL]%#&Cn=QE:XD!FcB%#MOJ&*S')H%^aaS1.K/%Ubd/9K[5;\""G>1HQ?9FGh/eYWP#d_86d#peiqN`@"@AFq,Vn2K2^f1FD?':N_*c@&q.kP@&p%$b/sl[@N?q@5sEu=qbKNSf57[?u'NPb;(tYX:n]2go1Nk]/V^W=<<V;<9b&B]p^u0n>A:iGL9uoDrd5HOQI-1ei#9.G_(eOdT2F`j0_<S_lX@:WJfh3Q-7fblbj]dqrX[k_8dGPH3.`%M(].)L7:<:33QdfBY;Nl[C&l;#"/U<?J0^FK/QdX)1e(S&_2+STfn'h2:b,0iP*YF*%,1-.jB%pR>VNUCBouMOm_eeN&fhlUu]k9?2;>^de2pqjdjNn77WLHo*Nms?tC@L^<m]K0W^CkWq0V.c7JNmRuQ"hML68??.UC:bIMA&\&+NiVr:-b3=bXYg9j(Pe*R%Q,kKuf>>?"UD6ET=`;-]$Ho-#-$Kfofnl&oo8:BV"#^n!<IJ*FRnoSNh==mp2\m"lH9r514VR'se:@;7rnpUXua^33i'NkBr^tRBGSJ[f<>5<N`eNNS',>rXR.1+s\Q\77h'6sK@FKq@sk%YBEbgd1V^#^Drl'$Zb_YI?XT$Mqbo2"SoA'd.q.PK*uE`&XSEj@nJa4(gq6>@=k!Sef0"TOlp>D$Al15oOOk:M7)CBK!-Drm3&]?:ir;NW+ads*bdWM)!Ir3>B/'!4;Ajia$=)eu#YTY."V%\\1n>Ff2]-cHZcR(UJS_C2TN.0S!!cQO4cZ_qg.KB9N=$u^N(<KQY1\!E9gn&]9>irJ`0F0K'Vr";F4qNteT>HLn$,D:i@VEr0&)84^Y,,[W4dD_hPhLc'PI56">]\Lj"/S-\Db'OJ+Dt\hT+((G$63~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2065
|
||||
>>
|
||||
stream
|
||||
Gb".=gJZcqoNbgDL51MZj(3GBP#c\%*Lc+W_?9omD.]0W..Ui#F[M<dS@8ISZaq)l<&,-]-.j@)B[?Xg$Nl8.<rVb:J7$@$pQ>R;0YRZ:Lk,"-]GMSH#2qIaKE];Ye9!\^f@3$H9oTu@S(K?;)+a$(km.i`&Lgul*:Y#(3KP]"HtG&TN_LNCJ4O;*d_:KT&t<rQ/VmummTo`o5,lMBM-Wc0@THh]L+?B.<(kaUKJL`U?l[#N]ArKMGb%N.mU%83#eq1DnuiD1i7rm9_pLcA#C3!in=*U[0M*K((JeIe%HD)i=-()RJoFdNX7VHPNoC.IP&uk4/a3pe)T3]L4^.=%Xpm+Tf>9RJ'%<WdqVsl:)^ZInR_fgu6jC+kFkac"K0+`a+aYmqlW&Nja6%f+eZA;#(5=bA18[XN+;`Y?(::GC<ip'>BE14'f\D5d_mc^o8R"Ngjl.5dP9Rr#<lc2i<MQJ&Dd4/k>j5&Grk4XZI`_Z!M6'(?k;Gd9Q].ELOKL#a=h7?$r'FLsk.B4dICl<D&q6Iei5B(W`pO34ZbfAOWBt$pncFC[$C+$s'B)bYfIh#.6>%KAhplC1HYGr\qU-jA[;<J8RL-4s'gCqD$(G=K<l)g)Wdu&B?E52"K+CnNn4LsH&<9\#HOC2j+fRrt5]'(X%Y?`iLhtZ;7LM"t8YgVu1(:3]6e0*dDO?kR$A?.W0QX$@6+"V)R[+F9fh"[q.;G)BcL,jmE1,XMetq\Ab9\cRghs=Lo4a^a0N!`o04Q1_[f:Y,oDd`[945msb5ZrEgCA4jD2H!1D/DD5Ca'U6LXh;d$!N..I/D:%^P1$p)!uQb3>R9:Ct#A`J3V@H)#Xaga(Jnl.Z/=tD_Fe30>'pJ@c*uGmWiNNQ`458R=>AV([G:$6eaGS@rblf#8k./S``RL@*9IJQ`8[D029@"pPL)MpTF*5hAIT\d1*\M2C'[>jKG,D\]U4"qlo[I=2:0\3lZ]):Fc"rBbjt"Y[?\HM4HJpjLVX:b3`PZrcp9?Xe.!AJ&Cs(:mtK5D1hRp9kmW6d3V=:O^RogA[f5sn&(X$j*)'6!_RpqQ.>H]6@CJ$=@6O6/p:5+@lI34,V)\;(#Qnr)l39J`*3u%'GNNAjD_0*$)2`Z2O,R<`HsFB[:58Odfa#b4=.l_/>PigQeL>PKWP^jmPc1"?3Ifr2^CXIc!!m.q\t0RY8a?U=5`K(hV_%;7@W/>0k/_A)SXp?/st[8N)9/;`R%O(ZU\WkWCdhp-aS,8^7/^bh6`eg?]oZ.1/M,>AGJ^j,e/5K7d,([WgOM801ab3apR6aTu*'N[ERT">qj>V."Z0'AEj1YZ0,suc]6VPoK8V@hBstslKDY0WQJ]2H*cK2[4HZOC3@01V:L4kQe@Z%fq"X><QLEZ;lJaL3/+4h:]Rpg0.:dB550JjA%t>fbJE*;mJ%Gi:<2Ztg:^?;S&aWFXR+n*hl*D$Dee#;BtQlg^ImB"h'@U[HX<[sPD]+%^>Cc!R*<G7$?a+E+Q$i8Z-f#CT']TK"@g/r%!&LM\4Dd!Q:*^W.nI#-;lHGL\V(9e%>]T,6H$3A[kf"Dd@Lm"p*nT!Z#1"jot_e$Hfs*j@2ToVm+Pf4f\qI)`EK)Bkjl,bhM[WK\ZR#%^arf_0eK.7.os$+bCr5cSM)hCO4,f)o,sXuf&%Jqh$:H1[j3an3'TP%nF>GtnDi7>+(1*pEuDo21*f;JI]BUX@4>pq4,?&T?7eW]&o9OO7Fnh,CE7b[+C'MkCku5Rn5_."178sJ6T"?(X.NdcUk-Cf0hLCjc#5Nq`*uph\/AD#3*.O"V<<OOKPXfCW2E-uO=%IR?\3d(r7NDl<jhJ#>&IXlB`Y`S?=;NU%PXn;FUrhj?pd$"0<eX<Ap*`%X8k6^2mLC3X\LBh,P8:c>=,7m=k?d%[:4*W0SK%C=HKhZN[!gIX$O22Z&4-I>)Yj)MLmg<8YU6]P607s+<NABgZ*[?Sb_2?&'FD,A3:L_ks;/\):2s\%K9[Dl'.@#=J*)JKj"C^6=RocdoQaYi3k4&;4!gL0ncmX/WlHe5)q@?"ak6=nLpB!Y\*~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 722
|
||||
>>
|
||||
stream
|
||||
GarnSgJ6NX'R\fAQkuP%Z,TNYa6?5c<@Ff%Q8YR;`Ci`3]4l>%iW[i8n&],o'K(a6SCfV8cPZo.[i38n"Q%*W3EK#uiWgk`1u^$U`O"GXi>F;u!JGBf+*eE[hd:&`ZO26q-Lr>a.mm`4%;hB72R;fh(PXD-96`Fb.>Z?lctWie!b9J*]J#6,nZT>+N/@PQ:\nl\0+qkpa"k\ja`rT0%^fm@[?)jD"2lMJf:Qq6ED\Bg9367Ai1"L2>rg\c?AR(R`G*rcSYHV^l&gk7.B.?CLjpU4lMn94Y<=JM8;AkoYGbt,i2LV"2VCuX:6p0j+Pc"g@:*Au2-()(8Bq+kjb%nh(gM)g&:9L"Uda/4Q8,Cm%aF4D7)9%2')6m[n%WdaAUnl((tF8IQ-ZS[3tc%Hm/3G1QD\2$jB&mCaIXjB.H"btD&>c'kY/7upENYD"0/Y.B+^8b/>$!3*_.F6GtckP$p!P(BO#L2#!ia-NVsn2.u2&hgUqEOj]:m&_99OC8bf=SQ5TgREmBM?4i..sNqp^>8_UY`V)G(a,rr'XH1s_oYG%f+$9`E'%tj^Jn)V_Hp-?-kf1um;Fko+.%5RcIkfBlq2kXg8-?'sN%X7MroD""XhUgR(*i!GK8QtuUW8T?#=OWG5A#/G9e`A,`F"WnM>$"kpE]OD)3;mf>*K)[?<i>7IeHrDbr5biJX7@&CP;:Nr&)p#Oqk2dS2eIO?;H+!-~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 22
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000436 00000 n
|
||||
0000000641 00000 n
|
||||
0000000846 00000 n
|
||||
0000001051 00000 n
|
||||
0000001256 00000 n
|
||||
0000001461 00000 n
|
||||
0000001667 00000 n
|
||||
0000001873 00000 n
|
||||
0000001943 00000 n
|
||||
0000002239 00000 n
|
||||
0000002337 00000 n
|
||||
0000004738 00000 n
|
||||
0000005455 00000 n
|
||||
0000007551 00000 n
|
||||
0000009244 00000 n
|
||||
0000011145 00000 n
|
||||
0000013302 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<03e0fbda8ddfaf289b6c540c3b19f7f6><03e0fbda8ddfaf289b6c540c3b19f7f6>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 13 0 R
|
||||
/Root 12 0 R
|
||||
/Size 22
|
||||
>>
|
||||
startxref
|
||||
14115
|
||||
%%EOF
|
||||
@@ -1,474 +0,0 @@
|
||||
# Les 14 — Lesstof
|
||||
## Agents — LLMs die tools gebruiken in een loop
|
||||
|
||||
**Vak:** AI-Assisted Development
|
||||
**Opleiding:** NOVI Hogeschool Utrecht
|
||||
**Vorige les:** Les 12 — Tool Calling
|
||||
**Volgende les:** Les 15 — RAG + Embeddings
|
||||
|
||||
---
|
||||
|
||||
## Inhoud
|
||||
|
||||
1. [Wat is een agent](#1-wat-is-een-agent)
|
||||
2. [De agent-loop in detail](#2-de-agent-loop-in-detail)
|
||||
3. [ToolLoopAgent — de v6 abstractie](#3-toolloopagent--de-v6-abstractie)
|
||||
4. [Stop-condities](#4-stop-condities)
|
||||
5. [prepareStep — control per stap](#5-preparestep--control-per-stap)
|
||||
6. [Done-tool pattern](#6-done-tool-pattern)
|
||||
7. [Planning + reflectie patronen](#7-planning--reflectie-patronen)
|
||||
8. [Agent vs tool-call vs workflow](#8-agent-vs-tool-call-vs-workflow)
|
||||
9. [Productie-overwegingen](#9-productie-overwegingen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Wat is een agent
|
||||
|
||||
De definitie van Vercel AI SDK is kort en goed bruikbaar:
|
||||
|
||||
> Agents are LLMs that use tools in a loop to accomplish tasks.
|
||||
|
||||
Drie ingrediënten:
|
||||
|
||||
1. **LLM** — beslist iedere stap wat de volgende actie is. Tool aanroepen of antwoorden in text.
|
||||
2. **Tools** — uitbreidingen van wat het model kan: zoeken, lezen, schrijven, rekenen, anything.
|
||||
3. **Loop** — de runtime die LLM en tools afwisselt. Beheert berichten (context) en stop-condities.
|
||||
|
||||
Het verschil met Les 12 zit niet in *of* je een loop hebt, maar in **hoe lang die loop draait** en **hoeveel autonomie** het model krijgt. In Les 12 deden we maximaal 5 stappen — kort en gecontroleerd. In Les 14 gaan we naar 20-50 stappen, en het model bepaalt zelf welke tools het in welke volgorde gebruikt.
|
||||
|
||||
### Voorbeelden van agent-taken
|
||||
|
||||
- **Research agent** — gegeven een vraag, zoek op het web, lees relevante pagina's, schrijf een rapport
|
||||
- **Coding agent** — gegeven een bugreport, lees code, draai tests, voer fix door
|
||||
- **Planner** — gegeven beschikbaarheid + voorkeuren, plan een vakantie
|
||||
- **Customer support triage** — gegeven een ticket, classify, gather context, draft response
|
||||
|
||||
In al deze gevallen is de *volgorde* van stappen niet vooraf bekend. Daarom heeft een agent meer ruimte nodig dan een simpele tool-call.
|
||||
|
||||
---
|
||||
|
||||
## 2. De agent-loop in detail
|
||||
|
||||
Wat gebeurt er per iteratie?
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ STAP N: │
|
||||
│ │
|
||||
│ 1. Stuur huidige messages naar LLM │
|
||||
│ 2. LLM antwoordt met: │
|
||||
│ a) text-only? → loop stopt │
|
||||
│ b) tool-call(s)? → ga door │
|
||||
│ 3. Execute(s) de tools (parallel) │
|
||||
│ 4. Voeg tool-result(s) toe aan msgs │
|
||||
│ 5. Check stopWhen-conditie │
|
||||
│ 6. Indien niet gestopt → STAP N+1 │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
De loop stopt in vier gevallen:
|
||||
|
||||
- **Natural finish** — model genereert text in plaats van tool-call
|
||||
- **stopWhen voldaan** — bijv. `stepCountIs(20)`
|
||||
- **Tool zonder execute aangeroepen** — done-pattern
|
||||
- **Tool needs approval** — agent vraagt user-confirmation (advanced)
|
||||
|
||||
> **Default:** `stepCountIs(20)`. Dit is een veiligheidsgrens om runaway loops te voorkomen.
|
||||
|
||||
---
|
||||
|
||||
## 3. ToolLoopAgent — de v6 abstractie
|
||||
|
||||
In AI SDK v3-v5 schreef je de loop vaak zelf met `streamText` + `stopWhen`. In v6 is er een dedicated class: `ToolLoopAgent`.
|
||||
|
||||
### Het basis-pattern
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const researchAgent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
system: "Je bent een research-assistent. Plan je werk, zoek, lees, vat samen.",
|
||||
tools: {
|
||||
webSearch,
|
||||
readPage,
|
||||
saveReport,
|
||||
},
|
||||
stopWhen: stepCountIs(30),
|
||||
});
|
||||
|
||||
const result = await researchAgent.generate({
|
||||
prompt: "Schrijf een rapport over AI in de Nederlandse bouwsector",
|
||||
});
|
||||
|
||||
console.log(result.text); // finale antwoord
|
||||
console.log(result.steps); // array van alle stappen
|
||||
console.log(result.steps[0].toolCalls); // welke tools werden aangeroepen
|
||||
console.log(result.steps[0].toolResults); // resultaten daarvan
|
||||
```
|
||||
|
||||
### Waarom ToolLoopAgent?
|
||||
|
||||
- **Minder boilerplate** — geen handmatige while-loop, geen message-array management
|
||||
- **Herbruikbaar** — definieer agent 1x, gebruik in API-routes / scripts / queues
|
||||
- **Single source of config** — system prompt, tools, model, stop-conditie op één plek
|
||||
|
||||
### streaming vs generate
|
||||
|
||||
```typescript
|
||||
// Eenmalig antwoord
|
||||
const result = await agent.generate({ prompt });
|
||||
|
||||
// Voor chat-UI: streaming
|
||||
const result = agent.stream({ prompt });
|
||||
return result.toUIMessageStreamResponse(); // streamt steps naar client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Stop-condities
|
||||
|
||||
De `stopWhen` parameter bepaalt wanneer de agent klaar is. Vier opties:
|
||||
|
||||
### 4.1 `stepCountIs(N)` — maximum aantal stappen
|
||||
|
||||
```typescript
|
||||
stopWhen: stepCountIs(50) // stop na 50 stappen
|
||||
```
|
||||
|
||||
Bruikbaar als veiligheidsgrens. Default is `stepCountIs(20)`.
|
||||
|
||||
### 4.2 `hasToolCall(toolName)` — stop bij specifieke tool
|
||||
|
||||
```typescript
|
||||
stopWhen: hasToolCall("saveReport")
|
||||
```
|
||||
|
||||
Stop zodra een bepaalde tool is aangeroepen. Handig voor "doe het werk en sla op, dan klaar".
|
||||
|
||||
### 4.3 `isLoopFinished()` — onbeperkt
|
||||
|
||||
```typescript
|
||||
stopWhen: isLoopFinished() // GEEN limit — agent stopt alleen natuurlijk
|
||||
```
|
||||
|
||||
> **Waarschuwing:** zonder limiet kan agent oneindig doorgaan. Alleen gebruiken met andere safeguards (cost limit, timeout).
|
||||
|
||||
### 4.4 Combineren — array van condities
|
||||
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(50), // max 50 stappen, of
|
||||
hasToolCall("submit"), // submit aangeroepen, of
|
||||
]
|
||||
```
|
||||
|
||||
Stop zodra één conditie waar is.
|
||||
|
||||
### 4.5 Custom stop-condition
|
||||
|
||||
```typescript
|
||||
import { StopCondition, ToolSet } from "ai";
|
||||
|
||||
const tools = { webSearch, readPage, saveReport } satisfies ToolSet;
|
||||
|
||||
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const tokens = steps.reduce(
|
||||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||||
0
|
||||
);
|
||||
return tokens > 50_000; // stop bij 50k tokens
|
||||
};
|
||||
|
||||
new ToolLoopAgent({
|
||||
tools,
|
||||
stopWhen: [stepCountIs(30), budgetExceeded],
|
||||
});
|
||||
```
|
||||
|
||||
De callback krijgt alle `steps` tot nu toe. Je kunt op alles stoppen wat je kunt afleiden uit de step-historie.
|
||||
|
||||
---
|
||||
|
||||
## 5. prepareStep — control per stap
|
||||
|
||||
`prepareStep` is een async callback die VOOR elke stap draait. Je kunt dynamisch het model, de tools, de messages of de toolChoice aanpassen.
|
||||
|
||||
### 5.1 Signature
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({
|
||||
model, // huidig model
|
||||
stepNumber, // 0-indexed
|
||||
steps, // alle vorige steps
|
||||
messages, // berichten die naar model gaan
|
||||
}) => {
|
||||
// return iets — of {} voor "geen wijziging"
|
||||
return {
|
||||
model?: ...,
|
||||
messages?: ...,
|
||||
activeTools?: [...],
|
||||
toolChoice?: ...,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Dynamic model selection
|
||||
|
||||
Begin goedkoop, switch naar duur model voor complex reasoning:
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
if (stepNumber > 2 && messages.length > 10) {
|
||||
return { model: "anthropic/claude-sonnet-4.5" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Tool-filtering per fase
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 3) {
|
||||
return { activeTools: ["webSearch"], toolChoice: "required" };
|
||||
}
|
||||
if (stepNumber <= 6) {
|
||||
return { activeTools: ["readPage"] };
|
||||
}
|
||||
return { activeTools: ["saveReport", "done"], toolChoice: "required" };
|
||||
}
|
||||
```
|
||||
|
||||
Forceer een research → read → save flow zonder dat agent kan 'cheaten'.
|
||||
|
||||
### 5.4 Context-trimming
|
||||
|
||||
Lange agents genereren veel berichten. Token-budget bewaken:
|
||||
|
||||
```typescript
|
||||
prepareStep: async ({ messages }) => {
|
||||
if (messages.length > 20) {
|
||||
return {
|
||||
messages: [
|
||||
messages[0], // system
|
||||
...messages.slice(-10),// laatste 10
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Done-tool pattern
|
||||
|
||||
Soms wil je dat agent ALTIJD via een tool stopt — bijvoorbeeld omdat je het uiteindelijke antwoord gestructureerd wilt.
|
||||
|
||||
```typescript
|
||||
const tools = {
|
||||
webSearch,
|
||||
readPage,
|
||||
saveReport,
|
||||
done: tool({
|
||||
description: "Roep aan wanneer onderzoek klaar is en rapport is opgeslagen.",
|
||||
inputSchema: z.object({
|
||||
finalReportId: z.number(),
|
||||
summary: z.string(),
|
||||
}),
|
||||
// GEEN execute — dit signaleert: stop de loop
|
||||
}),
|
||||
};
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools,
|
||||
toolChoice: "required", // model MOET altijd een tool aanroepen
|
||||
});
|
||||
|
||||
const result = await agent.generate({ prompt });
|
||||
|
||||
// Het finale antwoord zit in de done-tool call:
|
||||
const doneCall = result.staticToolCalls[0];
|
||||
if (doneCall?.toolName === "done") {
|
||||
console.log(doneCall.input.summary);
|
||||
}
|
||||
```
|
||||
|
||||
**Wanneer dit pattern gebruiken:**
|
||||
- Je wilt een gestructureerd eindantwoord (geen vrije text)
|
||||
- Je wilt voorkomen dat agent tussendoor stopt zonder iets op te slaan
|
||||
- Combineren met `toolChoice: "required"` om text-generatie uit te sluiten
|
||||
|
||||
---
|
||||
|
||||
## 7. Planning + reflectie patronen
|
||||
|
||||
### 7.1 Plan-Act-Reflect (ReAct)
|
||||
|
||||
Een populair pattern uit de literatuur:
|
||||
|
||||
1. **Plan** — agent schrijft eerst een plan in text (stap 0)
|
||||
2. **Act** — agent voert plan uit via tools (stap 1-N)
|
||||
3. **Reflect** — agent kijkt terug, vat samen, evalueert (laatste stap)
|
||||
|
||||
In de praktijk komt dit door simpelweg in de system prompt te zetten:
|
||||
|
||||
```
|
||||
1. Begin met een plan in 3-5 bullets.
|
||||
2. Voer het plan uit met tools.
|
||||
3. Aan het eind: schrijf een korte reflectie — wat ging goed, wat zou anders?
|
||||
```
|
||||
|
||||
Het model doet dit dan vanzelf binnen de loop — geen aparte code nodig.
|
||||
|
||||
### 7.2 Sub-agents
|
||||
|
||||
Een agent kan een andere agent aanroepen als tool:
|
||||
|
||||
```typescript
|
||||
const summarizer = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o-mini",
|
||||
system: "Vat één webpagina samen in 5 bullets.",
|
||||
tools: { readPage },
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
const summarizePageTool = tool({
|
||||
description: "Vat een URL samen in bullets",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const result = await summarizer.generate({
|
||||
prompt: `Vat samen: ${url}`,
|
||||
});
|
||||
return { summary: result.text };
|
||||
},
|
||||
});
|
||||
|
||||
const researchAgent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
tools: { webSearch, summarizePage: summarizePageTool, saveReport },
|
||||
});
|
||||
```
|
||||
|
||||
Voordelen:
|
||||
- Token-budget: sub-agent ziet kleinere context
|
||||
- Specialisatie: sub-agent kan kleiner/goedkoper model gebruiken
|
||||
- Modulair: sub-agent te testen los van hoofdagent
|
||||
|
||||
### 7.3 Self-correction
|
||||
|
||||
Geef de agent een tool om eigen tussenresultaat te valideren:
|
||||
|
||||
```typescript
|
||||
const validate = tool({
|
||||
description: "Check of een rapport feitelijk juist is. Returnt issues.",
|
||||
inputSchema: z.object({ reportText: z.string() }),
|
||||
execute: async ({ reportText }) => {
|
||||
// bv. tweede LLM-call als fact-checker
|
||||
return { issues: [...] };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Combineer met system prompt: "Als validate issues teruggeeft, herschrijf en check opnieuw."
|
||||
|
||||
---
|
||||
|
||||
## 8. Agent vs tool-call vs workflow
|
||||
|
||||
Niet elke taak hoort bij een agent. Drie niveaus:
|
||||
|
||||
| Niveau | Wanneer | Voorbeeld |
|
||||
|--------|---------|-----------|
|
||||
| **Plain tool-call** | 1 tool nodig, 1 antwoord | "Wat is het weer in Utrecht?" |
|
||||
| **Multi-step (Les 12)** | 2-5 tools, vraag heeft 1 doel | "Hoeveel jazz acts vs rock?" |
|
||||
| **Agent (Les 14)** | 10-50 stappen, open-ended | "Onderzoek X en schrijf rapport" |
|
||||
| **Workflow (code)** | Reproduceerbaar, audit-trail | Order intake pipeline |
|
||||
|
||||
### Wanneer GEEN agent
|
||||
|
||||
- **Determinisme nodig** — finance, juridisch, medische data
|
||||
- **Latency** — agent gebruikt al snel 30s+
|
||||
- **Voorspelbare kosten** — agent met 50 stappen kan duur uitlopen
|
||||
- **Eenvoudige flow** — een if-statement is genoeg
|
||||
|
||||
### Wanneer WEL agent
|
||||
|
||||
- **Open-ended** — research, planning, debugging
|
||||
- **Volgorde onbekend** — model bepaalt zelf
|
||||
- **Lange ketens** — meerdere bronnen, meerdere stappen
|
||||
- **Latency + kosten acceptabel** — async use cases vooral
|
||||
|
||||
Quote uit AI SDK docs:
|
||||
|
||||
> Agents are flexible and powerful, but non-deterministic. When you need reliable, repeatable outcomes with explicit control flow, use core functions with structured workflow patterns.
|
||||
|
||||
---
|
||||
|
||||
## 9. Productie-overwegingen
|
||||
|
||||
### 9.1 Kosten bewaken
|
||||
|
||||
Eén onbedoelde loop kan tientallen euro's kosten. Altijd:
|
||||
|
||||
- `stepCountIs(N)` als safety cap
|
||||
- Custom `StopCondition` op tokens of dollar-amount
|
||||
- Logging per step (welke tool, hoeveel tokens)
|
||||
|
||||
```typescript
|
||||
const costCap: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const usd = steps.reduce((s, step) =>
|
||||
s + estimateCost(step.usage), 0);
|
||||
return usd > 0.50;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.2 Latency
|
||||
|
||||
Een 20-step agent op gpt-4o duurt 30-60 seconden. Voor user-facing:
|
||||
|
||||
- Stream steps naar client (`agent.stream()` + `toUIMessageStreamResponse()`)
|
||||
- Toon progress (welke stap, welke tool)
|
||||
- Loading state met geschatte tijd
|
||||
|
||||
### 9.3 Observability
|
||||
|
||||
Log per step:
|
||||
- Step number
|
||||
- Tool calls + input
|
||||
- Tool result (truncated)
|
||||
- Token usage
|
||||
- Latency
|
||||
|
||||
Dit is essentieel voor debugging — agents zijn niet-deterministisch, dus een specifieke run is vaak niet exact reproduceerbaar.
|
||||
|
||||
### 9.4 Veiligheid
|
||||
|
||||
- Write-tools (DB inserts, mails, API calls) altijd met explicit user-intent
|
||||
- Confirmation UI voor irreversible acties
|
||||
- Read-only sandbox voor development (Supabase RLS policies open)
|
||||
- Production: tighter RLS + write-tools authenticated
|
||||
|
||||
### 9.5 Testing
|
||||
|
||||
Agents zijn lastig te testen — non-determinisme. Strategieën:
|
||||
|
||||
- **Mock LLM** — test je tools los met fake LLM
|
||||
- **Snapshot tests** — log volledige step-trace, vergelijk met goedgekeurde versie
|
||||
- **Eval suites** — set van vragen + verwachte tool-sequences
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- **AI SDK Agents — Overview:** https://ai-sdk.dev/docs/agents/overview
|
||||
- **AI SDK Agents — Loop Control:** https://ai-sdk.dev/docs/agents/loop-control
|
||||
- **AI SDK Agents — Building Agents:** https://ai-sdk.dev/docs/agents/building-agents
|
||||
- **AI SDK Agents — Subagents:** https://ai-sdk.dev/docs/agents/subagents
|
||||
- **AI SDK Workflow patterns:** https://ai-sdk.dev/docs/agents/workflows
|
||||
- **Tavily Web Search API:** https://tavily.com/
|
||||
- **Anthropic — Building effective agents:** https://www.anthropic.com/research/building-effective-agents
|
||||
- **ReAct paper:** https://arxiv.org/abs/2210.03629
|
||||
@@ -1,315 +0,0 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 6 0 R /F4 15 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 21 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 21 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260607091327+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260607091327+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Les 14 Lesstof) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Count 13 /Kids [ 4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R
|
||||
16 0 R 17 0 R 18 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1880
|
||||
>>
|
||||
stream
|
||||
Gatm;>BcPr&:WeDbY-8RgR.7-!rA3h2eTJOS%!gf$N\pm",I#%2Z)fa,VWUf+k5g;a0pZnc$V=BDe'1X?du_Y)8M_%]_XmE170_F3jo.^.sO:fITeWCb]@$n\PIIc^^dmRK5t\fi!NZk/>1o7ch9L,*1ts!+8EWjTBB&^8dUCl('WG+`:G"RIHh/H*:2js?joak[OXbM^VBHffBrgNBY?blRstRY'so:;TlS1#eFd(6B5mS-VsfuARp*ra=t,`R?Zdfm<=RQ=H#Ra`];Va*WauV-j@6#HaCJUAL\]g]J"^]gn/[;;0"SNMIjHUe+7Q&*eY7Wn*%mF;"?O?oJhc'_$._-e346H8jd:tRJ6N*LAX;Ae9p>s[3!a=iKRP\4^@4/K`r[Hc)c4[oijr+XmH9/I4W6S@7VU5NlATjAOYqBR>-5&IL@f/T2\oO!h+7(L?/L+p=fao)ARV$ElMHMbGD;s2:!r_QNRMl[*$MlU&%OLB-9cGJSi[>6<LpC47W\#&*VXH+oOt$Zp:n.R0\:Isok@4GZ'e5e4=f/2VI.S>GkD)S#^ogZYZQ6U"r3(ES9g]FnBaTj]O4jXc%/u+q,b3$D\RdAL#A`)JK6GEm3ChARD[S*+->P+nS>.(!L^$_`\U^S[_d0hEhtO]X'p_.DUU9j+fV%q2JCLeZ@g20\=aK8Qh:`8>/WP[7L8r=L(!=SDS)OVFpl`7rc](]b>"2L@;\Ai@%McWDdVrm4nL5>lI&OtMG8<"T9cE]Es0C"'T#US.[)j6QVUPJC131G,>pWjL&4A;q)o1fVcc[sVha2L!S:s(f'<qapJiDUcQSUBR`/jDOnn\A&PA!%=(8JjW,r?GGad8a'77ZV%e/Us^0R(j.cD\Hh+4EaY"7+A(p2h#+XNPs&lL_5@2!K?7&uT^Y88:CPK[oTX=LtF0_o;+FJh,Op,;'.:@Dml^AtH(gBEAaT!'5Uo$/Yp%iI>NBZ(m[$%.)%`3>GKJ3U/K2\PQn:kq!/7-0JELsi*GG:D>hZ"&Wn4dq*P>jh'h8&dt^7+?ZNZ0e@)Tl+75%n$$8XT:S@E52BG>?LipFSgjpN3j5V$IZ(khMLb]Jee3.!P.k3PCLBi%S5LkN#d'T(]0^C1cM/;,LR`rR3"5FE3d\t1!m@nG05RQWXGaS)cNc6krjJMe$ULF=NOP)VNS;hVqKOa.-UNmrAhKeSqfYH8FiQO,[bPL<*#q;2''GM_PU[p\du]\b]+]#7X*:ID=;jdRVAfW)SCWkj23Bp?og7O19;s-b+!FB+Gt'S"a8M/1)<o!*bSoP/>^2:6i*Cq'(@Y\bT`=mg<6Mhj>B]UiiQDIojr9+0U9*C2GDS@Ctm\?#GbA%A:+m7)BlCLkb=;1=NYQ<JhI!PCVs;Kd_tm*QmE>T.[GnA:c6)*\GT:oiOm'Qc1Do&6Gt;B?mnFGW\#4aR(?Nmot&Wj_D!?_*9Qo75)\0+ok9uH0[ET+0XRZ]Uf`G?(^)I6<q'^!G'Wj40;A:_WqFW\A>hO[_[hDePAL@[Q!lG+0LU5+Voma_WpVq=dfkQf2\1Bh4=WUdM/L/nn$ej^(S*1J+'G\mbfbC1f(#Megh@JZ9F!I'T0Q?fTDt@P&,-TL5O,"2g)k#2;npg@IY9QoFak;O[=iA78_C6rCrC=0hT:MkbhU-6%UrRr8NK>`3=.,uL"+]'+l67Z[rdAIHFBl"VShFshZsL+nG%e8*01\B-R%\I+io\eg:&(;*ofR^_Sr(_KcHG\U%XsTBk]6.KU[,u(*iHHUD@;?]-G;STHT;V3K7-c'?D/m'r1_HK\0uA_k03<3KDb2BN#s\FrI1_-87MZ-pEtI?L'_ue4,aJqR==^1fpSh^LRD@is<?7@XG'-Rem\!rrK#"5'm~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1564
|
||||
>>
|
||||
stream
|
||||
Gb!;cfkq`f'RcUW\-NXF0l2U*m!Ws@"uYDK8Y[UcI\C@%M:$!++mGj!qHe1XNNFK7$3RPD%7u\;G<+iL@R0J&r+E8=pgjdc?SPq.VK6,WM-tW5^J0>V!T=>\UDQOhd4j`Ko$I>d?3E.(#t]0/1WX6%Z8@.]@jK7g$okC_;jN$$0L#j\O>NYF('2NZS7KaeqN)tmj44g!P9bXj^9]J6k2dlQX@"eu2hKusei>U\L(U+1:1KZo@IiSgmk<YoAg'mRCmk_1Z!%o9$eLoHE.3(UDN$>OQ>e!cA^tVJZF2Doq@TSPDQaY$'QFb\Gn9GKUd=SQ+3$51(A<_iW*00CiOm-sp-8rQ$r(G*_N:X^Mk^:`64?O'7S/i[3oD`,S&8>Cn<?`fAYUuOFgE:/dY*ZOidOW7$tIpt).O.k(s6i)*lELKZ?FN$5X/)<JUP-;(k0Z:?d\t2%_EDUh-*ZaB/$Vq4Ld-:]EjgSkEL>$WIO`dEQ=8%+^%<r;9B_%4C^JVL:CY-B0KmCPS_3j0L(#Hn.UkT'>BCn(r:7Rm0!7s_LaNcAIW,=OA9:(%"(dRe^NGTn9cRF4^c71Oskt"6;a_A;[om56^/q"e0n;K5ukjsl\m,,-JNDH`O6GVDUHhS;4Z)M,j&<QC_sSs+'")P:"h7o%]R66N*k%O6Mj?fSHUS'_[4i/F'4hk^6"lI)+=kp*32Ddc^+1:3)FN`DrV^Yg0-aif_W7jh"n\QTk"U(O5-[pjX=.(T=XAC?fQNCR0,FM4.'9o\Z1op'!<VFQgjG&23^gDY$^s_3[:@7U3ko=QfkJnP@s'=!!WT&E-:i/!P:a?g_^>5Mie$6[",/j2V873'[:r&.C6sKR?\rA*\bWSkABtK.4%Q=Q'`qeo6^K?Vo!>gUVK:X\FcYZSXNesN.IuF>*d=c1q=+VY\hokej&U?\jQ$JAY]4=3ggk=h3&udEn;8&h\O]<27[=5HI,26<ADD.2!f?E4FCL\0XCj+]#;&__+Yb@-WU=P8n<IW@KUgFJ5X7$;^"$&+UU^nAI_n!P,;"kJpsnA%)LH@H%-BCPauNf9&pL``*6+9&1jZ3F]6Ei<h_e?q\-R?IE>"PmWVpne5i[tPfen+d/-l:@Xb56Od5lkIE,2EP0`YsCL8pP6dd'=PP7u8AOoh*`Z4dN$U5JuXTA'%rF":p$clo8D1pCl0b/C?f&C#<KrFLRE4X][W/oZ4dFO"d+cE5+\uDLT-%R*\KS9sYTGBj=?-d!6".](&9Q9rr?o,@0I4Tc3eJoB+X1nGA.B;fjS0/c1Jm?Bb^/6CR^)Ri:9`8=$Bc9-uZ77qupoD<#'e=Q3*^b(0B_[#+2,9`<?i=<g.'UI_O[9l-d:I&_C7cHU_E?+=!U4'$j"K=TR8ZAFC\;ZW)@;#63`9X?J90l)`YHm2EU_g$aSJg`[1E\H1`!uJgIMcpFcZ9`7fV*gc'q>T[FG*Phr'k#idE?H=E+Qr?EIf@J)Y`Jh4jWoH)$Ekl3B:ghVEE/K@L=.YWi[f-1H]Wr*Q\.=@mjs<RWR_"0SNDHgBoN[s5;=S#/+7^d/+>ItDp_%f~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1637
|
||||
>>
|
||||
stream
|
||||
GauHK997gc&AII3bU%cuKc*'C.Ye4B*b^(@RNQKgnO4sJD'cLK9!jfSf6=.uV;8P'RiZ#X(.iM[H17:HaaiB;FoU,)i3sF`H-LkZ=Bu#m7)J4%?EKs:,JmrnV#hhL'aY,.#lfHf'31]7&goeM@&PDA99=l?@hJk`jg8V<)@P/X,6gQV<#Z.^0GU)PNk?JhkGVPs(!"F5q8#8TbtCSi=GLM^56SkACiCks$:uXE>/=qYMmn1bHiq.8)r"uZ\h;qpf@Xp*@*Q6:kAQ[4gU:ddc/GK^g9TT(Cum9Wobgek]ri6ePaj-YmjA%1D:*aH8*fI+*qimN.B4*#%-KdnF9IKs@&TrW#XL>N`Ou;5"9ujO)9Wk?&QbcHL9WMnRSYKYClFilQ6]/EHp4:E3E['9Ye^`X*\%RE3%n,?YQa@U"9ZYp$k432A]4'iRpOMC7"^dVU6?<<G(Pj/E,]=LGnt7lN`5r30E%*dSi[s<@q#Xgba+2b_@>N,(ubmt9XeP[[P!7+@D;]1iPusf6MOi6cNogZDf=RK[#f0e7a&*?pJ=hN>F-,e6YqPl)VEs]&Gk9YP%1H'N\5*]T=*OW`B2g>iG=^h_EPZ,l(fr.BG]6[H&SI.R&],u1U(@1ju223O1Rqth5>N8.jJc,(S$4H/Vj^u<6n(Y3%'X$(diUh.9Qlj`Y'J\&hnnn,Z?WN=:dq5C?g'7Bk(bYklaI'?(QOXnpsQE%e>fB;tD:7"=2eQcr9R08AZ67;H$"dXLa[$HrrO]#B-6+Xtc\>,#E&aH3lj;i.F*2)V?*g]\$?=@C\[a[8<]6".,@(g^f0k'hT[PHT4d5#c*a_>1ZL:&C*FH:lI])`pa#&qkWNcE;#c*elDPER3,T2$6A<7'^j:Hd1K2hi[nV\^afqO7=(AuA[u-Sl\fBsEg&hMG2A9-'o@W&".UM'lC6$.c?:n/JK8\;lbu&)&D`YV_bHgU5(?^BglEuhd=Us%=q^U4gJ)U"A(sB8H6'0.]MGT42YWtejLX77##n%L`R\X.]$PlZg1m&+<F0&k5<tIlfqZIghA4ZCCA07:K@eT>(\U\u;1aR\nAEc]/)IZ$^>^.@X@F4VoT?<%f;#Q]95I>VC%lpIZ(R#1e&a"V&7IEjm.P_IIX]cF2:ME'O]Y3i_egpf\dR0.9X(kmi<^cP#G`>E(KJk#4#N4#MuLZ!X)hesDAosg[W,SVq)Ifc1j(AiO]EV[c5LF_R%G1S;:c=JA)oQglS?M&3?=0]iq&9TSa?>EIAkqjG.li+7\\a_S^X4oqP`]b?!mF!k<G?bQlEK;:;1+PCfB*%4VN[bY3^2$3o&JjqqV4.hZ&)g.r4_ldf$nZYr%to\2uafYt<E(K0sDn?O<ooAqrmG]#"N?k*QXHbtl^FrRq$6jfH0Y]!?QG#FAb^(2k+ZK<-WtraLIW4$7&lr?6e[M&j6R.,d8Z>E4N;Y(CKT8F;*t0>S$XS3Rd\d'9.8/3_2W&Y!kVTcPZ#LVhYbR*bl^cHZj,nEDZ,d3DlWGC75:>b\cl$/W<'(M^IjdE[?k\g;OrMq3B\\@BQF'EOT8`;nIDi9]^^mhs_+m[\n3nht(Wb0JXZV\n>UCl>UOCi,<DS&SJ\Q;&V81l!RZRXX_6Um@Mo#t4dp(J&U$~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1844
|
||||
>>
|
||||
stream
|
||||
Gau`S968iG&AIa;m.eq)1,]Pk+94IOKA.Xb[7Qk+8p\m=VBC^%7EQleVoe#7G6'm=b@]2eUlm9k+RncF^GQ+NDs"do%DXVQ3.9)&EL8>/#pNdN&t[@\/!"YkPmDp8Ree04')M;*`QH@HO@4gcA>#nG=pYb/&ZnD0*7$(Q'GLb;O$*\88ZK,tU0/VH]Q;pWP&h<h80DJgqg>02[d&Q72VOjno`r<P#A]:`:jkVX$WI,4%8?croDKA0j==0KlT@u%>ud8:?,<%P"m=?uo<M'4at2JM\Me09h3b9i"LAUmHYd7&n:fdG5kXj@?^_VX#Q-$OHs+A$N`J^eJJd=X6(bSNOuJ7JWep1CKs>mLiLZ*jdCt]/_T(U:W@tlkeDlE_.eI,.lXlJ+9M%EQ$/.PIf<+!8du7G.DCNMP@/q=Vcg!Za!pr=5$F\Qca7(PD+4:XTG#De1p0+8:dj`rim`ZMM*d_\Kjck2FTg*sCF2oT1,e?R.6-'lh>VkdM6X3t'g4d`m`.bGLc*r0-psS,VK\sbX)8SY`gHGHPk1Rp?hk%#*Qf+dd+tD2??t%ts6?H@272/g[)+tI=A="9;6_X;<j=jZ;Y(!?M&J_B3`<ms@%.`5k?l>M2iSs+jE,CIf_-Fr7.#_<G#@fV`;,-h%dNr^'"fRlH2Das(,(KZlDGl'8^mGNh09S<#KbC:\=e<%d3u9pQqf]U7]lFdlBCA-E7Gngk9'49I;._KYhmt@D3FK`J]QX4m'2$fgVcueWTrC3-06I^r&h9guY3:%)m.G3('.;./am!c(',CB;A.?hh@Cf1(#2KVQof'<'Ph(K-Zj3A>/6`h"[t47a2WoF&qTFL[iBugLQ6*&_OG##&.nsdlTMJk9-C\jY<:C(b@X&:rhB$05R-h*Q/utiqI"c-oDr<$9Nh.6YPFV\Ii#otp^F.r\L[[<2N'^(oEl14+YU_tO=rO\ui]J_AUlWql7D,crn7Thdc+[o`ad3Ue!kkL7m2gdP^+edh*9CCg7EOe/>FgTJk]d44qd$.rBJ@TDd?,@Nq9?^Oe]ZNDX6E2T'2_em;-8Pn=j^8-)l)s4?$g'60-&OtU.,N$+bR%XY0C22SH!^?QNi,bTMr>B[stMSFuG<ZI,`WNUCj0Ga7O0ms)n<S,g'4tINYlZ4r@iK&g=XEqbc4H2S@F3PhZU,_(S_p_]"MVTSSlY)RHo?SD+CGFFfu<q66rq'Q'BKF)7V[UF*Dm_I^Hn.8=(/`E:2L5btUU2`enOM=N[Y%m,GSA$XQ]kE%e`,gBE^8aR<l@3\9FJ?RF#=Q2l7)Mk]a-I7Uh8-GF>qk.dqPVk?c7k"M+(&H!^A$dS%H>&fGeHl-IN9=)Q_"Oq7:#9)A9R9@q:i&?0b+Vr&*k1OBjXA-\n[CZb_:O[DpWmLHBNen,P+Kmc;YMYtP>]280'[!'"`1`@+6*,u8VTp"fpJS4fVY&-\&l*#934$8f29!/6rgNJE3mOd-[#X&=#D6TqW$p\AUUKT02-@gm#mEr;,eODHsn`+'8'%hT8q;0DS%L[o@MHT,2ZB[(nKZ"<$^5@?<usBcBJibjTdj.?hrb,a]>huo3DqI^jf%^MO2HMY^:+oWnG*KY.<uQomad'g^fc62sIM$fV[fk*Tc262qn)AqTl2@_G."C+'b/ZG5:[mP+tM^dbFdd?%s2"Xo^VR/jnCa[?B?f)$\?_:4mH':7'd22dLSWZj3A>X<0Q1m]eWI`&!ToU,6#$,QI+Mnj0RBDNfH8gb;L,"#Bj6%^lRDY(:g<<@@74B?bouR"GoB\jOk41RnJ'd!)HRjQ=<Y]-;lnnFLkQ=/IM\\h9/TVVInFFO*/9)\9T2!(mShJc~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1640
|
||||
>>
|
||||
stream
|
||||
Gb"/&gQ(#H&:N/3$j$QOJqV=:8?+osdV[D8#e!In2F-Rlcna3R<u4Ai2f6=D6X;*cf?'lqd_<XX/;YR3%QS0^L(#JZ=neO(!8f?dlo>0f@<i/Q&dc`_n8&uUN<iTjb8?$h<$WGS,5_]2D,`j'Le;p/^edaaQ66V]`ZtaJNmjG2W#],,MAQo,W&>9E@BhTQep'IpkP/^^@\o6Wis1%Qdn>K8fHJN=LLAbe%?h-t65%FM*Q,15`PqSd]S%*Oehu]pWcImFX^/MR@E#d3V_=Z)D47P?h$1T9f.O9#Ke(o5Yl"ThO8Q=rpel,F,J@Ub=0=jcJ;:#(/pl4mlo$@G`&!q8\>CL4iWE%P%2"2,N5Z#<4;,.X7S"g?'G6BtIi@&3Lhf>V1p])XL0g#sXn]OT*rDa1BYtKq)"URt.D^I)$isS:^/#k*2P`l_4O57,GM.)%"s=hUP"'P&OlJkQ3=HDHR*@aQG#>.ds6W1'p=sG]'<`TlON/n_N"U"F(VgS`R+QL(PI%b1eh?X>is]:;p^,ogn\$,KK"=+^s/E-o:XZ8uA8UIt]0r;#%3g$Y#KKj7E3,P\1t21TL'7p0IpO/<p1:R6>c]"?E\-RL=`Gk*g9S+H1p,U&?$XBS1c$e,D0gI_MA*sV%`_nQSNP:"#k&S(LnrV8(PK4ZOuPrO3&LlW:kVH@k:L<4P=D6?n.'!A65YHK251Elq=meFFX$e#$W.kl2:4+4H7U^61U3\gMda]FF#lH5564?R%XL`q#t[,:.NH,[cm8='G-:`MNa``%$l5A(J\M`P46XdGSVe%PIuKQbD<QJihDf2J1OIiF^$!$&VESNlCMR^iGluYNbHpT]AXd:9]<WSnMO4$u\+'&F?&j$Kh#`InR8`ujWnh,&1:pcD`QgA7o^?jjkGq%0B,A!qX+qc_6DA;+11+#Y3E1A?Q@"V)G!EQd<O_^?K]Z6A\-RZ]f>@2%$\)6Ig4FAlXPMaeI,i!m"1#pnh:C,A_ZV1Q_P.ibiOZ%(Gd+.els'qe>^Zc8[)jn!qo>qG-!]Q!$4f<efeSCsgcs&jZfY7fm@S$&f'M1([2<bm.WJK[ZMb%i1*Nog?Sqab1k$W6hQ">;p?VkOXN9,i."*R9N<nKg)r4j_/.\G^leGp>S$nF@g"r(733'at;Z-YoaRK>ThH9Q2H&_4CEP.5RSHXlG6/bFDq(3s*V'^:H:N7p,nc;V`eK9NBO-JWI`nPV!d/VRTB*p_Fo*EM&^+2.Ok(9U+1ll4"FhclZ*qg&M@kb38Jerj':V-cL,8Du)LYYM"kG3%0STBC*8LHmRZ+H1e%44.(#S-=/Enc;o5IXG=ab[_;FX"3f'l,j3G;2Si\O+3<>MLi=VRijMQjl+#(9NA-dldB=Zup0aL;gJ#7p&"26";S(p0d2\"04jo2/TOka.nS/D-"13d%5R(^4)[XLcCu?m*jh!k>ee48u+MpY&!pdh'U&tDW[l1s1\jh9f,eRS6M-H\ZNKWC(IK#cm^XS/`)i27<#0O674HJ4mP>@.N&mG[(0UF^Ra5mNe7IH\)=n@"h..Oa"Vthhf.=h\dU_&be"cdlN-cM<H)'8]^PO_#`RO!^1mS4r\4?,R")lOYi'tI"sJG9.NEDI$h38tH,$,TDXO#;q?Luim6C~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 944
|
||||
>>
|
||||
stream
|
||||
GasIe?#S1G'Re<2\CskCMJ##256fk!`[3$,^fZ*lMGTYND4jTqibkEgs.,X?-7tmfXd\=npY=eM:<\7JQ^.Tu!oR&a7op*9LN!;g&-:`!>__*2O)W4+#_;]=qM#),Idqu*l6R!4+q6I>1fZpo!CAT:[F)dp:FW.`ZS4E3(_A!mdK55:J^j7_I\00VEO6j>PihH6R\)4r)'Wj>8@/.9(p79>VA`E@&BY%h5e)@$gNT9miCuA@Gr/1C3IGq:NFiZTS1?S6IX[mOEUN01?J>]H:R;HY\3>T'+Zq;fD;bl=8V$GVnS`6(oDpVU9.(J@qp@5q7Nf0ZDGejlOQ8:H5kr9:k+`_6@?_XdJi2.Ppd-YF'dc&Y\q,P_PcDZ>C3*0a=u<[a`Xte@TPD*52esV@WA1.S$I;ADNL,mhN@s"g^cCN,U4ts46)aKcmP1H?Z`VRiN4-&:O1&T]ps<u^qqKr6[uiW@b%)7E.KDt?5+"+A32=d?Q>`YIQqLgZP/&2o_R=]o9JZoLE9G[27@K0F(T5@T6HmNAFg-TlQ7h*''Hb!_IiKA/.7uZ0fKH8:NdF[Sn0h"O#7a8cE.T&NmJWRrA^P=E\K?ec5'?r%keV_P--J/0\2^,!R'iAj(F`5&c+6Gc9oP7iV!_=EWHFQB,&>5BI(UuU9D<dI1#sYP;kuVD4TW8Zb8f*N5+J.[;[4nO#Z\7-eH=IfnA?T,&1%t2m!'8u@BTci#)()`e4>*ho$meAX*EPc4=b/WQ(sd\`2.?.&$&97B0_7N!RUciN/sC]j95e\4#G"P`lC$lYLdF<Xg2KD]2K"!2bIchL,;:(Xa,c.mT:GW;TrYJ9bmbl^="<:@NWbnh5'<+I#99l)Rt9Omh=f(Y3SfIc4BWXE'-@B;nR"a@r_<iAs6a";AaeDXqGrL%srci_/A,DG!KuKV)l5Cgj7oW!Vc8\Nr~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1615
|
||||
>>
|
||||
stream
|
||||
Gau`SD0+/q&H8h>Z/Z?g@%>Erl.uPs%cq7oLES7ZHti@p8/GK!QAhW80HL.>=)TJ>A>1dBJF1;+lLqdEgO5YT0qe;RmJhD=nB2/6Shjao<p.7$&[6QfcW7m&J8iK8O?c-"_h\DO_WD$`3ljT:U*:X50N8^\"UN#%0p)KsITeN:V1\Ad!<]&2eOBNn9IcgOndgT:*2aQ?.MX!#p[OXu]SNLM3Xi@l+;g,HI8V+D'SJ6@-*4Ooo]l)9$i%,3].2YO70\7mV2I8iS8k^jnVcGJ<L$Um9Ale]FBPm']"G`bX1r(cYF00X0*EX8_qW4]kpd)`n>iRIo+(J<]2!DTH@k_nY3>q^GTWl]L4g`#=Gpr24:#q89IQ-%YRS)fh+qt4!j2j+_G"e$oV2Ot>4JR\%V+M6g[GG(N&=Gg']HPmK#\@(2oZ(V(h*nmpmhQsn$;I1og%$Q&dNN;,/Xd1AFJm6Qt.6CS"T/Jk1u=%jE]39D=]5dqY$;@N)@G$iup+p%?32]alK6MKQYIcpcZGp/Ug+_fD&'1YR(,L%t@ZlSLrZeg?mhD2R1fp!Q7E5Qe6aK=$fpAh-=_DXaFn@CkV5_hspKncId0)FrYCO4F17\c=;ZIQG'ct#=q_tDbj/9K?HHgG'Z\OCX\`fD[ns9j>A3TF1Y//,Hra4QS"n#Z;aKL@UE=b%q'<7VC.iWNSP0EOgY#1<gCkM?:%_k,g8*ACDVHD:im$?3k&*QHBYLQ>@3V4ITdo"FH!8dA;k!.:mLs&8%+^o-t@pSjkL&,lV>j?/IR6*NT4^CZDRsC<)5)X(_[/2`j?jX.Fph2)mW-&&-d*N9fL-`i%53l?8Odn`_::!&WD`k2,lAA%WmZ;pE-^ZIpP^S&>HT7oI?_,,\e-`8WL#Y*"q3ofX<0RNrH^q>s3^'I)DZo!/>CX?RI5*\O_p+h:ZG&:i.DY7$!!F^O(+.IIG<iLI@3P4E6uu1'>F_7A\B7VO@^B,]dp-Pj-nE2eV'I,YZ<'We"O#!=n-_EIW"SbM(b*ZHF!6aL1?-UCc`-R1B):fj/jt#\I*VgG"inC"GdP3oj7(kG0:GKJC,`lAP<+I3=1oYBQ8#1t/_W[EnkBhEhhiC?(k%>c3p+eL21/s+LIAAe;h@k`GBJ@aqb(7DP%obmC6@s70Qp,5l^iO?(E!\76"8F^'9Ag<+]r[nc$?LJ(Y.o4f0&k2$m;LT>")n?f$gR]9rKPg=%)eM<<d+<Q0D1hu8.]NTUh*Ppr!f6:9=4XOI:Q@$4)GN50tqg`N4*`%hd3-YQGmbI?*,]idmhOp96KV-(EjPh:&?-D&ZL!ApX=mXT3,gPaFif:&`cWtsX-iM"8E=R6ijdT@4K^um`P=O=?j0n;j'^PNVc"bc)I,1A#cY[fWZOE$1T*t:P-RZfe!\VFj;$PAB6H[DIV@g1Id!1cm/F?;H\/:8^K8#=nFI_ZD+(_<2odi+A8BJP.qJ/_&oFSlbmN(ZJ'Cnk@7,/=is1%fAS1WdGin\X\eKI"Ip[KFZ>r#3pKAD!2MDLEJq5IS3b<^HNZO&g2]>_DN,:J?==8((UG.XKM(J@jU7IG&ucJPEe%4?HF\?-T2_'`1UWBRK.FLst%3`$t*rWA4b/,f~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 742
|
||||
>>
|
||||
stream
|
||||
GasJPgMYJ*&;KZJ.HWCfd`J'k78tagMFnTg@CU#t@@:sFA1&NY86(q!BC,Cm5`$Lmj2q;XcMfV8!M"&THtNVuRfm=^!ec6f&VNU_aW8#efM@7M8uE,knlu>)(fYq"DSan3/D;5!!7)[3Va5Z\h?ORSFNt0t]*OJ\9E7,sS_&Bu)!gQRHA,Bl>$@]+2Z0[qoCI*N!H";HV0VhIN+Rlh;a",pjMEkY8HG"c+2?XKqop=NflFF@iG5iZfs64?PRaj70<WK=<EAWO]T'[9^=a9A?(F:P"-W8%XV"oUM2'H*ahN`[V%5DIe`QCPrKJp8lP08Qo9h`.5ZY=MRNTsjE]#?a$8-oV_trn2Hu0]FLJ\tI#:Ps_mU12[bF/(:((R%P+r7`ODB/3&S;d+q@0CU\7"ac7MQ1)"FDt[4@8`^`+!K/'O>DI5qpF;KJFR'MN:A'ZS0%N3>Q(dqiFg/rXQch_4uC(A7R:]KR%nSHA5p$g5mh>*b<6&BF]5a9/oK-Dr)1N$-)BYO3eR7h)%%j'9"b<'q&-iZK'30C6B;fj\`9AL!j&@"P7E.h>VE*'>_1r=4%8-5Kf-Ff!?H^Uf5?"/(<BJhr#gW@$^Yg^>YB3?:%o+N6%.'$VD,Sn1FSmZS.s#32PAXXb83i?lqJr#<>Ob]$UL@XCMKH<'lh?Gj(.2=UEW1gLZrf82JABI]jh$,pjApb.4I?prqL;$a/"/e[BmJkSQ_g.CXrF'$Nes&\d:Sm~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1781
|
||||
>>
|
||||
stream
|
||||
GauHK9lo&I&A@[:m._i=C`!.<!d)K3E>,6m`_Dmi9b157'Eu[eq&tr`SYhVNh?E]=`%od_,0;#4@NXH9h_*Ig.)),H:3(lWk&BsU!TPlK"=AqTq$!;5]j$QeO@p/cnJ1^A%OCW4$`^U95S0)4iXl8],uIQj:lHe;.H"ke-'M/>\:!En+>T`j#p>*/TaJQ?pIWFd/hKU*L=)@]/O*h273l^&+jK/%?oWeH:A(R"+I!$bJ9_X2f5EjC__:+Tf?%#qB(3F3FE1r#_7>6Jo>B%g:2?V+n3Z$sXPDmJ0`*Q=LA<X;ed,Ug:'.6c&H>)K80@>!S+<k9r@M?R71@QLk:Q+9#8H`pW2,=G5*ZtC-TBiPEFY4d64lQPnUNI*rE/3%8LP<g1r`NW9+WF;4rt$A3Jl@Ki=/_E=f?ct_KeuY4gr\*3B_q2+@1V8IHE83b(b\Y#GV/HM+7dd)?/hk[`6!",L7%lb&a#Wmj@Zs0<KDfGXJ3/H&P)0.,g&'*uO#6.Sqa.i@$=32;A&jFbChtr.sjGnZ=!;KXuS^s.V(:Ts8(]`5_#@R*n8K_d3SW@sn2qON]#J93#`[+ID4hX^Uq;b\O^a1Uu#?M@IL>PVAV6bg0Y0J@dWBXUB)i:na@Zq9/F,Xt6WMf%CDQgtJItPr>j@ND>CscI2"t%<5hEESd^:@>9VLWbl4RQ.FL.Y%u)\ruUj[Q^#"EYtAXCpC'\^Ir,ngP`E/)n7GI;:Is0'Q$t:UKVI%:)!Ym48>r2%4JKDIM)SD4a<qR4^4Y^k6B!u:TEFA>CaSf`q@SB!ITWjJV+-O1!(MaOXPVLc"!+n[p4G<AmA#"%+`_V!O3'PjX)Z?)M2ho!'W@fYC?+;\_[684G\uau"2+6%1Bb5>bP+'#P,e\da70M[RQc6_^=2QCVQA"X"I;l;qCH<r<gXWBNlPTe`PTYI`+rk=VHCL3FCWb)3/5+jV;=HW(N:H4"c<j`<,;0+e2M?1`Gl&q%B0%OhH5s7=1LWIAi;<`h@s[aqp0!'YFQGuXa-ODkr_.a:S_6%lblJD^c33:\$&Fe&[H$+c;td\f]Gh4/S9q>.8#g;5pJ)cX1*?q=+=Ekbf7"lIfg*XM?K;[4j<)''<FhmUkY.qNo9*,Ze(j7l==Ze*($4!d4^H13HjKf@g-"<DC0m$i<hp[r!c=$Nenr!-D,dTQn0pQ'Q`J6beh7GP+`^=8Q.WFT[<Ee8"j7^,H#^$1>[uK+Xq-g:gRW07&cIk?,Z!TH=IFR3@-gX"2_pJgMN:^'.-ggpaADYjS;fD-CAInQ+l(OQ!Whu\@=OknEZRM3Q.=WY?/u2J9b+&53_(kKs4JOJ\Z?SmJgXF+p)1@KM[Oj*J$HgMoeF#W-#_tC^B%t3,Jaan4Qifd23&><+V)&fJEaDOO0>/KP_+Do/Oo)]ibP\-T5[J,AkKe5NuMgTY=3tcZt/3@^e;^5^t&&4($%=L6If7fX'UbMuCbDMhTl.]PRX#d5'/r/(Xk[E@$FJ3BubQGBK%:l9'I\@(LFTG+oimRM)O-;=EV,U=gW*3F6.Pg;XBKH<G+6)dVfH&c/Z$X"b@S*d=MgfCpuUs+AbQ/+?Q^IWl%1K7/C:HE5_N8\($1p9F_:c?6Kphgdisc5qSPd]QGfS_+_fPjPli-4BLR[SuUo'UphP5FTRj:/9G`d-p/mYh";EquaNQ;s'jhe84GS(FpJDgX/gbpkD`eHVgKhJ4Fap>;5:8GBE_3H2YD"QRUK:$tZTdNj=UL[gV_7>M`7p>OTR]gOk!h")m!7]RKs(B#]B~>endstream
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1937
|
||||
>>
|
||||
stream
|
||||
GauHK>Ar7S(k(#>peVBOdkqP*8JL9\"1#$t"(YUhDYE(Fati?4O[W9:UeQsN^5oiQZ(kWW`doF7jPfp;4CUSG_lE0E"7X>>%ULkt*+g^/%WPm"$nl8E-];r[4i[_895=>DLi8bIaOXAnXrtUE4p+A>,!?/NabZ1q"HMe1O*Qg9=?X&/&5Q+u3^=s3KbX:4gI-"@><Bb<_S<^si?o'tT884Bd]EH\$tlfaW".@$#\C1%_JB=<J,VSkK%G=No'fWE(d8Y%$;V%sF7-m'dJdRG7sUeon<^^_Ytd5-.J@.m#.(4>Y5u@I97jTs\+,MZNq<?*H(e:sm$hB',lo\\!.'PuD34s>R!M^EgSQ"rj$A`12L7jHNc5,;/FJ\t[Mt,VmL`&33>ge^RH5"B#sK>KgGttXNSt>1N#AR3aN&S?$QZ\#l8]l9Fs'2)m<re*I*2;@Zid&Y/tk^\(,>gP82cAf1Bs/#eu;XqQ2/q75Bpb&Voi=3A<S*fQn_4CAD@BD\@-Kj'f0b>(W'oGhn9Nh7>:1DINRJ+`+;+ZmOe>qEKDGCZA.6jO&rRnMb$,P'U_]q16rS?dOln2NR20@4Zd99JN#&c<(-Ej_d`XO9`sOAG7&iGpMR7gc9pCHJI,KXn(?:&\o&_B`\1<nWD:VrLb`IcTs=gDW.:^r35T#g;3omW-]>-n?!e"Bd->TiRVkpX[B4Vc`+j.GmjtL[%NO3=aA.U&D7W!Eds\_EU^%o"Z&VN^hA\5LgIJKf*X(-!ZKQ+)J5TNf^<r^c"JQ#+osr_s$_-X\R*NI`FT-]c@g5,-q1VjP;&a:Kjrt/*^t,&@@.L'pCk%/Xhe-n,ILd<E*hAi<(tkuq5D+joiud2VoF05<&=8acpB1D<=67$1):Zk^W_<o5Z>Ej-2JK8:.2ZIER[S?9Ra&V*)7b;Te!h%mnT,U=@AF7W)glscB6B;GIU6R)(':]0a[Rp`$qlSh92kR%b_mUS;XpbEA?q"(m"\S6coS;"Yr6kKUd8$0#S@+N]o:OS"4J3+MaqQPn&r\!'p[T(fC-o$O1!(Vr90cqoOOptqp^6aOCVOkAiS):)VEk#4$n2K&_EqTdM.Z"l%COf]@M+%BaU)AK\*-R`c=2F<cHksCcQ_QiPL&BPBhaVLO-iMg!0o+?4:jL*%?.:eVuT1dl3Gp#3T7@P<&p(#X\2KN3)NE*'C)])"%&Qb!Pe[d%5$Z^:0uSh5W#Bn>fC5D8O.;5%bjX$Dk\7IML^2W"\OIH+DT2=`gJqGQEZ4KGb!^(c2*:"Eco=hC^Hc6ma4"0hN0)e9*U%-Q:GVL^r!MZ1$oT9pA%ASrE_q!Y[j\%8^?ILie6nj]<1MAYG^)f*!_V,->EleTadD]<E^D=;<h6h+BY,p,-&Sh0KF[W.'=EMI?ZI@fS.%k]e-"6DsjSWaq!2.fE;48TC!@-h_q+$jR`ZPcQ3P1Z*OC1*-%dmtkf6#A,\)^`N0X`Rn:*iesKg<f]b;\qCC!20=\Y-!?HJO\V@dQmV=]h/ONj-3ds'n81k]52&ksQGraj<,I49>S4Y/_t+,d6(,](+>5JaW:S,YO5Pi)_Tf$'Z&!8K_)>KcTfsuj4AuOV01cEY]"rq6^VbjPKMS,<?u%<Dg9IMeK$,8TQ4^sE0oAhfHIqhGiQ!uUBTB/CHou,he1(<9NBD,CN`bVW7+e*tYf>/6[Y\X<hCluM;53QnDN+BHKo?l="udbt*8@<8'5ioOb,;pjankC;KPPdE`/bt*-X(08U+faAnRJoSounL5p]b#BbK#]jF67a08`GZ/bO@BsgZog/hFsQBA:IH/f4p*%lT>>P7R`lg.S@piO#8VgmHMqQ^YS2pc$u@c0FHef2Ze$DI\V6.o+0DU/^NsWb8To\<f=&<cL8D$hsn`_FN8TNj);W4hL`cfJU4e%GhOGlL3"T)iDf-nZ?j#JqLeH*fG1N)~>endstream
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1859
|
||||
>>
|
||||
stream
|
||||
Gb!Sl=`Y6:&:U6Yd4\H&eeTLTV0U9#JVD>M!AA`?Ml`'.M8tdI,6<8l[cWU2GbMRE@WU0Xj6kLMf;Fo`\G/__=1JA,I0S#);P4G1$_T]R@<Mqn1Zu)3=ND[N!9/"MM]%W/ZTjO)*=Y;*_O/:V\/Q#$q;?<d30-'Re;GLh6*=Vcbb7:rd#GOGK[V()iL[bR6H3tYT*/R>n*7CW\:k`_YXO=$Rm/`a'!ue]\*Ee-V*Pg2C]e<ghgKe[J!d&^jN<hur[r7<=!cWU(;]h4l$6.%7B$oEU$bll[Mu'dV_G`:fe^7kaM.+_K"aV`m9cjpi<-e/V-,Wehhu'G"].68FG3$J/;:/o)S)A_Sf;)n*f[\i@:Zn2LOKLJLBXKam%7i!Mhp]qI.WBf<ludH$JG2Gd)ehVItec(;ef&"O(+?3"6aTK[#sSRF:TVum&i."ihpnnd-V:Qr@668#8A'4oH7B"hMmZ\dTJSQ;;'hRpWm4#ii/t;,eU7?;9l+5(oPTq>[X4M_!@N\;-jNF_S#b0Z6.ZkA&@s,qhtY7`km.3s*TYl"Et$6=E.7gZHV1\Rs_ojbl*ZukfYpC6W'@e7+>`7d!\jTpPmeEaNDH[+V>@AL.$f@hHQh4S[@_S+0`rd8*tISGO(V<LcAFklSkB*hC2;BmT&NVKMaW5?OO!$aq%.O&5*c(ZBtQ$9KlN-91JJi/_iXf&0FDuQE6AGR7R!I""02=9!hWdi$UjXpP:%SJ[s=C7dg>DC\dA%'"36g9E/"'ANE9:gVQZP@I3.@$%T--Rg(ag_I?2t3oX5%Y>ooo^4!=nO;k_7Be!C(a"UtF%pd]%o1m/<[AZ.r>UW4Lh'tPgK<J4M'6Pd`Xni9&.$;[fjCl?i8)kFF6#TsG;+d%NP8N>'NGWQr8n,g&6KAdk<K\D+WQ3Vh^<;^$c)"OCOb./,2u%l\FN-?4O=hC<\mE)ME[24knB)mTgin.M)4D-*1AHZ)FEMhh$6j#!(33&<LBRb5$[b.SVk#T+L\5:Jh_f\'D$mI!0&D1^52;("%eHM=H)(%VgN83B#(;LI:/^EjaGnGj=us9h;03KtmX[[Q"moLj"rf@N=kmVX^O-?#03"qA9rcZOUHk:o?.MXXNh&$@`rbE5/_'uAX#s*SS960_;>#Wp7$Q[Z64P(:H"#pfqT^d"MGe0aos!n-RD;_Fo."%4+>P@Pb.r(Vh]D<V\NeF+DQWC"g?q;H<b6?'S)i9G>O0<RYYc\Te-Bc_+fVI]WjKu$%6bPdT,<J.$H7_IG6V2jM:4dM6LBImU"AN1G;ZH;42sWk?s.0<Q@#irZCUFhs#HKQ?X0170e\Lb[k.7@i?l4(\@gX>-a:iLAuT*3+Aq`FM8uRnh#1-pouU`;SDb/!4]cEX_;PZ9E-fT5Y-pZ--%&YEin('19;/Ujf=61lieYY-)*#efR-+\.6r/Ob/VF#EI!&bCeInlKJJ<f!&q[ZU[NaY\b]0$$.Iq\c0`UB,SKt:f2r*rqojk4kp<4]BR6kDh"IZ)jqiqP(iQrBpc-YY5bie"o(C$JE)1R.rND>B4aPeJA<iNk*E4#6B,@NQ6a(pOCTq*_RMg4K0.^$0)nL_P;h,^1oF_dK[<cJ!G[.D4*B@dq4].q?I)Mo=@nDm='-Md')1)qBZnYS<RrbF^:XO9HbpZPAtBp8-u"UJaBPpmH<^P7e&rkrl@*/_Ph/Jjo@k:ce:3pM>n!I!(%AElg&r>-<-XgqO/U;Fh,]IIn>eC138&'Iob\6e,(psi>ogJ3?lB;pmH"L#7+1iY?P.Et?TB<SZ=Zs81YZHZ:8@YB^nF-G.Dm%gUf@pr>?QDr78eD`ob=0eoJ>bq6p"1Fpl/@lro8f+"I!-T0qo)~>endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1724
|
||||
>>
|
||||
stream
|
||||
Gb!TW>Bed\&:i[:/*:\_)dLR1pT9eSCsLIEB_R2s@12*</D$%VZbLrZ1)QoSDVL@,%@ggb2)id-)Mrus04OpY97U)RGlF!/EFCs3inHYU7rUMt^0.l`\re(bmDY*P\6p\Ure!8@8dIaF9\b:dlpQ":q;B<3NoMAh(WSrVM4eH7HYO.sUX4j,+7,?t@3"]op[$7L;71<I@@6sPIViGnbtIDBD&o(u^/5_26.SeR;E`o\8.YqY?GbY!LTb@[VoY9"k<a._U2$">'hhjkJsU,fQVnSsYmfKn(#u#lZ;Ni:nHFlDC0a!u^),Pf96=J=Le0EFn3\Z[CW1auGu7HJCJW;)%-g;[kcQLsSCmNt-c4!81#eLSObl4coKu_aDIBr?h*#YdOmP,Wgq2ahl_f-"7%rAM'5X73XmR6:OI-S2Wk4PSF%HA&ClbsLF9ZQ!UhJ]KRTfoE-/0N4:6<q=8M+Rj"ii9eOmBiS)a2MGCMoQ9d][i^T>/puA$S&#j)ohCk);:.)\^g*Jo\OaLs;G9]m6kS)s/5,Hh'NK5I'$Mq7^VL,f.F4-N@R-Z6iQT)i>B20,\@hcH=n)+cMsE)8=p9([2:[f,B!E0L6QR,qu5iR'KArpd+M#h-j[bXo`(R?s)B$2&M8F?0<o/G_?nKGSR<`8eC*KAV0H2!>hJ^"YBaB;C]gLR`:e"r#oKW%(5%?=2dW%ih3,REJKS/Zd^]oDrteE*H=0$D8sWS`Tt;\Y-S4]D.T.A$mm6W%N:0A*tZZb:=nGiHM[c#Tr;:UVGq2K13c$/@ME\8)?Ak3,b6Z4"Y$P`g4:5e*m`NAY0N<G_T?rZY8[W,'$cb-T?F<4KtsL`5RGY:$-:OgE),^B?0[O,jQn,fqB/j0,1d*NMUeB_(aal.`YT&3(&S3s!gatJf:K1D]l1og6Y.3)r_Go$LghO)=1>L[#d,Vi?RS[SoNc*CU-K<J8l'_=WM(sfCRSFXk6s]_,YKj@1`="7;*uR24e0n6UNd#5pdt6B0Oc\Oc`qtn99VgX8kNki\Y^k=e/B,BZj]WUD.s2J#.lkJPA*NUOKh&[<40e_CqdKLP)<9fYcY*u^&7QN5r4K1oL^i?1'OP=V$b!%(%^jpnAG%!Z/WY8lZ#@k"Y\@GLuJ7$_his6m(dn%g@qbtkoR6ngR(LSKrqM)L#Q?9ZYYq+<o9"f625\bCj"RCE]J:s*8?lo:m=t$e+3Kf5kuT&+GC`:3NVMC9X.j/is.+n,e3R-6?:Qa4j_k%7&B(#&)Rhe8r:`/Tc:S.DI#"UD;+^0S.1iS=i*DB71!PAe1bBie]X[<UahJmp?aj4S=`=BY7);?cWRNbP3ejlopgf2oa$BGG?lUTeGY!e,i61#.cD#<j9[AO_6GH0]pE&DbnA77=1h'3e[D;CWS!4-P9U&Em_EF']qf6Rp>rC#gnLrk@oCKZar+$(Sfs/aB:g'dL8*a1\;;iEWVDL[N:j`u)&F$NmdoW^.\Wc+mG!@9&b7h-:ni>gD.5\!`#.s7_.>:IBqsgZ<G&MI4!*3n7kjt&5@]H#/+_NS6i>j$grJ6[A@rT?!_/r/46Jb=`D@Bq\7dfW'"F$X5r4DqS'@=-V]tToq0>O^3>]gt.JN_cE<EEXS?o<<RFqcQ<D?D8!XP]on`"$*DN"_AEjf@MSM;hRqR*o#H\U[)76/EnX7\__,l50J-*1k;;da>*ZY#ZO<LYI*ahBXbp[!HS>M3E_$S3OjDZ~>endstream
|
||||
endobj
|
||||
34 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 932
|
||||
>>
|
||||
stream
|
||||
GauHI966RV&AI`dp6]I364]0rd:o&KVjXDrBQM9!A(d`qa8t;-s1Z\*,bC)8+)F%DUt,4l?-*L'?EF-c2ZOX;\5s(ULN!RD,9DNin0N1J*5X"'Se"5&.KU)*&Ch$VQ'6bPM%"XE5Z:+Yo"`_5BH:dE\5"KM+?-MeG8Ll;WR;h!c$ZMP_0SBmk=FtP?cq<oqGs5kj97r[l;2*a750[.Xf3@AG_@hZq%.H%m/,&r)18hB;oEHe;tsAT-=3&.^q#']CbdT"UaXIh0$V\;G.TJ3q$Fo&&=us."+3mXJmQrBr+f6(I)&r`g1:B`,?cBr"AB5SDDA+2P2r`C75Y[/.P/SQ`Z9VudDD*=KT3ltdQI55WP]IEeC0%)Y1k@c0I?O6`:%/EV]We]VAZG-qOmoL!Zu`)_4$`t5W97L_!X4*(s]Gc,jg.rcOr<]Y9LrP?=^[X[kVhC4):a3Wp4>6mp9#=;g5Dj]'YPEbp6mWi*6@O>bQROf8S*C@0gZ>r:W;sO>T<hp`Y4/M9c7igfFUp\]lmn]i4pE;17^%jaV;a4l+M[7p=C4ISY.Qd1&.:f8MQ25U<59P#ZjC5jODYp:uo=V/`FHbNCZ4et24>Z,,+O`=+W]Hj+03);JMHKBW_e;unp>POB.Udd3&DR1tQLZ7n@a@H54al6+]e619]2$;"$,]d#VaqR*_8ph[8la+J$KGD^s8\-X/FN\4+>^#M0!q8IE</s:&$<I`6j]T&I=mj%i%TB9\`40IWAh`];\O.@(me;.aET$DDtFe%WARm/N(qZ>NN/5;^qH[fb)a1R<DSoWi#6S==@8brtcboH.FPfIR,rg5R_bBf&aFu*TiTC=\t7il8F\^'S#M&T[tei(^JM9BFBi56m*iOO*dZ1ZR>it<;TC\"oLo=rD-fR2dMo>9UZ;*out_1@-kq?#hE(&3rb~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 35
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000123 00000 n
|
||||
0000000230 00000 n
|
||||
0000000342 00000 n
|
||||
0000000547 00000 n
|
||||
0000000752 00000 n
|
||||
0000000857 00000 n
|
||||
0000001062 00000 n
|
||||
0000001267 00000 n
|
||||
0000001472 00000 n
|
||||
0000001678 00000 n
|
||||
0000001884 00000 n
|
||||
0000002090 00000 n
|
||||
0000002296 00000 n
|
||||
0000002502 00000 n
|
||||
0000002618 00000 n
|
||||
0000002824 00000 n
|
||||
0000003030 00000 n
|
||||
0000003236 00000 n
|
||||
0000003306 00000 n
|
||||
0000003598 00000 n
|
||||
0000003742 00000 n
|
||||
0000005714 00000 n
|
||||
0000007370 00000 n
|
||||
0000009099 00000 n
|
||||
0000011035 00000 n
|
||||
0000012767 00000 n
|
||||
0000013802 00000 n
|
||||
0000015509 00000 n
|
||||
0000016342 00000 n
|
||||
0000018215 00000 n
|
||||
0000020244 00000 n
|
||||
0000022195 00000 n
|
||||
0000024011 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<08c2f2200c35dce27e1015bb532ed2da><08c2f2200c35dce27e1015bb532ed2da>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 20 0 R
|
||||
/Root 19 0 R
|
||||
/Size 35
|
||||
>>
|
||||
startxref
|
||||
25034
|
||||
%%EOF
|
||||
@@ -1,442 +0,0 @@
|
||||
# Les 14 — Agents
|
||||
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
|
||||
|
||||
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
|
||||
**Demo-app:** Nieuwe research-agent (from scratch, los van Polderfest)
|
||||
**Vervolg op:** Les 13 — Cursor + Vercel deploy
|
||||
**Aansluit op:** Les 15 — RAG + Embeddings
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 14 — Agents
|
||||
|
||||
**Visual:**
|
||||
- Background: CREAM
|
||||
- "Les 14" in BLUE
|
||||
- "Agents" in BLACK
|
||||
- Subtitle: "Een LLM in een loop met tools — autonoom tot het klaar is"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik
|
||||
### Waar staan we?
|
||||
|
||||
**Vorige les (Les 12):**
|
||||
- Tool Calling — AI roept zelf functies aan
|
||||
- `tool({ description, inputSchema, execute })`
|
||||
- `stopWhen: stepCountIs(5)` — multi-step
|
||||
- Polderfest chat met 6 tools (search, stats, schedule, favorites)
|
||||
|
||||
**Wat een tool-call bot nog NIET kan:**
|
||||
- Lange ketens (30+ stappen)
|
||||
- Eigen plan opstellen + bijstellen
|
||||
- Tools die andere tools triggeren
|
||||
- Dynamisch het model wisselen per stap
|
||||
|
||||
**Vandaag: agents. Eén stap verder.**
|
||||
|
||||
**Visual:** Pijltje van "5-step tool calling" naar "20-50 step agent loop"
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 180 minuten
|
||||
|
||||
| Onderwerp | Duur |
|
||||
|-----------|------|
|
||||
| Welkom + Terugblik | 10 min |
|
||||
| Theorie: wat is een agent? | 30 min |
|
||||
| **Live Demo 1** — Setup nieuwe research-agent | 25 min |
|
||||
| **Live Demo 2** — ToolLoopAgent + stopWhen | 25 min |
|
||||
| **Pauze** | 15 min |
|
||||
| **Live Demo 3** — prepareStep + dynamic control | 25 min |
|
||||
| **Live Demo 4** — Planning patterns + done-tool | 20 min |
|
||||
| Wanneer agent vs tool-call vs workflow? | 10 min |
|
||||
| Lesopdracht + Huiswerk uitleg | 15 min |
|
||||
| Vragen + Afsluiting | 5 min |
|
||||
|
||||
**Format:** Demo-driven. Code-fragmenten in slides + live in editor.
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is een agent?
|
||||
### LLM + Tools + Loop
|
||||
|
||||
**De definitie van Vercel AI SDK:**
|
||||
> Agents are LLMs that use tools in a loop to accomplish tasks.
|
||||
|
||||
**Drie componenten:**
|
||||
|
||||
1. **LLM** — beslist wat de volgende actie is
|
||||
2. **Tools** — uitbreiding (search, read, write, anything)
|
||||
3. **Loop** — runtime die deze twee orchestreert
|
||||
- *Context management:* wat ziet model in elke stap
|
||||
- *Stopping condition:* wanneer is de taak klaar
|
||||
|
||||
**Het verschil met Les 12:**
|
||||
- Les 12: tot 5 stappen, korte taken
|
||||
- Les 14: 20-50+ stappen, langere autonome workflows
|
||||
|
||||
**Visual:** Loop-diagram — LLM → kies tool → execute → resultaat → LLM → ... → klaar
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: De anatomie van de loop
|
||||
### Wat gebeurt er per stap?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Loop iteratie (1 step): │
|
||||
│ │
|
||||
│ 1. Stuur messages naar model │
|
||||
│ 2. Model genereert response: │
|
||||
│ - text? → loop stopt │
|
||||
│ - tool-call? → ga door │
|
||||
│ 3. Voer tool execute() uit │
|
||||
│ 4. Voeg tool-result toe aan msgs │
|
||||
│ 5. Check stop-conditie │
|
||||
│ 6. Volgende iteratie │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**De loop stopt wanneer:**
|
||||
- Model geeft text in plaats van tool-call (natural finish), OF
|
||||
- `stopWhen`-conditie is voldaan, OF
|
||||
- Tool zonder execute wordt aangeroepen (done-pattern), OF
|
||||
- Tool-call needs approval
|
||||
|
||||
**Default in v6:** `stepCountIs(20)` — veiligheidsgrens.
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: ToolLoopAgent
|
||||
### De nieuwe agent-abstractie in v6
|
||||
|
||||
**In plaats van zelf streamText loopen, gebruik je ToolLoopAgent:**
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, tool, stepCountIs } from "ai";
|
||||
|
||||
const researchAgent = new ToolLoopAgent({
|
||||
model: "openai/gpt-4o",
|
||||
system: "You are a research assistant...",
|
||||
tools: {
|
||||
webSearch,
|
||||
readPage,
|
||||
saveReport,
|
||||
},
|
||||
stopWhen: stepCountIs(30),
|
||||
});
|
||||
|
||||
const result = await researchAgent.generate({
|
||||
prompt: "Onderzoek de impact van AI op de Nederlandse bouwsector",
|
||||
});
|
||||
|
||||
console.log(result.text); // antwoord
|
||||
console.log(result.steps); // alle stappen
|
||||
```
|
||||
|
||||
**Wat krijg je:**
|
||||
- Minder boilerplate (geen eigen loop schrijven)
|
||||
- Herbruikbaar (definieer 1x, gebruik overal)
|
||||
- Built-in loop, context, stopping
|
||||
|
||||
**Visual:** Side-by-side: handmatige while-loop vs ToolLoopAgent.
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Stop-condities
|
||||
### Wanneer is een agent klaar?
|
||||
|
||||
**Built-in condities:**
|
||||
|
||||
| Conditie | Wanneer | Wanneer gebruiken |
|
||||
|----------|---------|-------------------|
|
||||
| `stepCountIs(N)` | Na N stappen | Default veiligheid |
|
||||
| `hasToolCall("done")` | Specifieke tool gebruikt | Done-pattern |
|
||||
| `isLoopFinished()` | Nooit — model bepaalt | Lange autonomie |
|
||||
|
||||
**Combineren met array:**
|
||||
```typescript
|
||||
stopWhen: [
|
||||
stepCountIs(50), // max budget
|
||||
hasToolCall("submit"), // OF submit-tool aangeroepen
|
||||
]
|
||||
```
|
||||
|
||||
**Custom conditie:**
|
||||
```typescript
|
||||
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const cost = computeCost(steps);
|
||||
return cost > 0.50; // stop bij $0.50
|
||||
};
|
||||
```
|
||||
|
||||
**Visual:** Drie traffic lights — rood (step limit), oranje (tool reached), groen (kostgrens).
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: prepareStep
|
||||
### Per stap iets anders doen
|
||||
|
||||
**`prepareStep` runt VOOR elke stap. Je kunt aanpassen:**
|
||||
- Model wisselen (mini → sonnet voor complex)
|
||||
- Tools beperken (alleen search in fase 1, alleen write in fase 3)
|
||||
- Messages trimmen (context-budget bewaken)
|
||||
- `toolChoice` forceren
|
||||
|
||||
**Voorbeeld — dynamic model:**
|
||||
```typescript
|
||||
new ToolLoopAgent({
|
||||
model: "openai/gpt-4o-mini", // default
|
||||
prepareStep: async ({ stepNumber, messages }) => {
|
||||
if (stepNumber > 2 && messages.length > 10) {
|
||||
return { model: "anthropic/claude-sonnet-4.5" };
|
||||
}
|
||||
return {}; // niks aanpassen
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Voorbeeld — fase-gebaseerde tools:**
|
||||
```typescript
|
||||
prepareStep: async ({ stepNumber }) => {
|
||||
if (stepNumber <= 3) return { activeTools: ["webSearch"] };
|
||||
if (stepNumber <= 6) return { activeTools: ["readPage"] };
|
||||
return { activeTools: ["saveReport"], toolChoice: "required" };
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Wat we vandaag bouwen
|
||||
### Research-agent from scratch
|
||||
|
||||
**Doel:** Een agent die zelfstandig een onderzoeksvraag beantwoordt door web te zoeken, pagina's te lezen, en een rapport in Supabase op te slaan.
|
||||
|
||||
**Stack:**
|
||||
- Next.js 16 + TypeScript + Tailwind + Shadcn
|
||||
- AI SDK v6 (ToolLoopAgent)
|
||||
- Tavily API (web search)
|
||||
- Supabase (rapport opslag)
|
||||
- OpenAI gpt-4o / Anthropic Sonnet
|
||||
|
||||
**Tools die we bouwen:**
|
||||
|
||||
| Tool | Wat | Gebruik in flow |
|
||||
|------|-----|-----------------|
|
||||
| `webSearch` | Tavily query → 5 resultaten | Fase 1: oriëntatie |
|
||||
| `readPage` | URL → fulltext extractie | Fase 2: diepgang |
|
||||
| `saveReport` | Insert in `research_reports` | Fase 3: opslaan |
|
||||
| `done` | No-execute, signaal klaar | Eind: stoppen |
|
||||
|
||||
**Visual:** Diagram van research-flow: vraag → search → read → save → done.
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: LIVE DEMO 1 — Setup research-agent
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. `pnpm create next-app research-agent --typescript --tailwind --app`
|
||||
2. `pnpm add ai @ai-sdk/openai @ai-sdk/anthropic zod @supabase/supabase-js`
|
||||
3. `.env.local` — TAVILY_API_KEY, OPENAI_API_KEY, SUPABASE_*
|
||||
4. Supabase schema: `research_reports` (id, query, summary, sources jsonb, created_at)
|
||||
5. `lib/tools.ts` — eerste tool `webSearch` met Tavily fetch
|
||||
6. `lib/agent.ts` — `ToolLoopAgent` initialiseren
|
||||
7. `app/api/research/route.ts` — POST endpoint die agent.generate() doet
|
||||
8. Eerste test in terminal: `curl /api/research -d '{"query": "..."}'`
|
||||
|
||||
**Belangrijk:** Tavily API key is gratis tier (1000 calls/maand). Studenten krijgen instructie om zelf account te maken.
|
||||
|
||||
**Visual:** Folder-tree van de research-agent repo.
|
||||
|
||||
---
|
||||
|
||||
## Slide 11: LIVE DEMO 2 — ToolLoopAgent + stopWhen
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
1. Tweede en derde tool toevoegen: `readPage`, `saveReport`
|
||||
2. `ToolLoopAgent` configuren met alle drie + `stopWhen: stepCountIs(30)`
|
||||
3. System prompt schrijven: "plan eerst, search dan, lees relevante pagina's, vat samen, sla op"
|
||||
4. Test met `"Schrijf een rapport over de Polderfest 2027 lineup"`
|
||||
5. `result.steps` loggen — zien dat agent 8-15 stappen doet
|
||||
6. Wisselen naar `isLoopFinished()` — zien dat agent zelf stopt
|
||||
7. `hasToolCall("saveReport")` toevoegen — stoppen zodra opgeslagen
|
||||
|
||||
**Visual:** Console-log met alle stappen (toolName, input, output) — laat zien hoe agent zelf plant.
|
||||
|
||||
---
|
||||
|
||||
## Slide 12: Pauze
|
||||
### 15 minuten
|
||||
|
||||
---
|
||||
|
||||
## Slide 13: LIVE DEMO 3 — prepareStep
|
||||
### ~25 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
**Use case 1 — Dynamic model:**
|
||||
- Default: `gpt-4o-mini` (snel + goedkoop)
|
||||
- Vanaf stap 5: `claude-sonnet-4.5` (beter reasoning)
|
||||
- Console-log per stap welk model
|
||||
|
||||
**Use case 2 — Fase-gebaseerde tools:**
|
||||
- Stap 0-3: alleen `webSearch` (oriëntatie)
|
||||
- Stap 4-7: alleen `readPage` (diepgang)
|
||||
- Stap 8+: alleen `saveReport` + `done` (afronden)
|
||||
- Zien: agent kan niet 'cheaten' door te vroeg op te slaan
|
||||
|
||||
**Use case 3 — Context-trimming:**
|
||||
- Na 15 messages: trim oude tool-results
|
||||
- System prompt + laatste 10 berichten
|
||||
- Voorkomt token-explosie bij lange agents
|
||||
|
||||
**Visual:** Timeline-strip met de drie fasen + model-switch.
|
||||
|
||||
---
|
||||
|
||||
## Slide 14: LIVE DEMO 4 — Done-tool + custom stop
|
||||
### ~20 min
|
||||
|
||||
**Wat ik laat zien:**
|
||||
|
||||
**Pattern 1 — Done-tool (no-execute):**
|
||||
```typescript
|
||||
done: tool({
|
||||
description: "Signaleer dat onderzoek klaar is.",
|
||||
inputSchema: z.object({
|
||||
finalReportId: z.number(),
|
||||
summary: z.string(),
|
||||
}),
|
||||
// GEEN execute — stopt de loop
|
||||
})
|
||||
```
|
||||
Met `toolChoice: "required"` forceer je dat agent ALTIJD een tool aanroept — en `done` is hoe-ie stopt.
|
||||
|
||||
**Pattern 2 — Custom stop op kosten:**
|
||||
```typescript
|
||||
const costLimit: StopCondition<typeof tools> = ({ steps }) => {
|
||||
const tokens = steps.reduce((sum, s) =>
|
||||
sum + (s.usage?.totalTokens ?? 0), 0);
|
||||
return tokens > 50_000;
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern 3 — Plan-act-reflect loop:**
|
||||
- Stap 0: agent maakt plan (text)
|
||||
- Stap 1-N: voert plan uit (tools)
|
||||
- Eind: agent schrijft reflectie (text)
|
||||
|
||||
**Visual:** Plan-Act-Reflect ReAct-diagram.
|
||||
|
||||
---
|
||||
|
||||
## Slide 15: Wanneer agent vs tool-call vs workflow?
|
||||
### Niet alles hoeft autonoom
|
||||
|
||||
| Scenario | Aanpak |
|
||||
|----------|--------|
|
||||
| Vraag → 1 tool → antwoord | Plain tool-call |
|
||||
| Vraag → 2-5 tools → antwoord | `stopWhen: stepCountIs(5)` (Les 12) |
|
||||
| Onderzoek → 10-50 tools → rapport | **Agent** (Les 14) |
|
||||
| Productie pipeline met validatie | **Workflow** (explicit code) |
|
||||
| Wettelijk: must be reproducible | **Workflow** — geen agent |
|
||||
|
||||
**Wanneer GEEN agent gebruiken:**
|
||||
- Deterministisch nodig (audit, finance, juridisch)
|
||||
- Voorspelbare kosten
|
||||
- Latency-sensitive (< 1s antwoord)
|
||||
|
||||
**Wanneer WEL agent:**
|
||||
- Open-ended taken (research, planning, debugging)
|
||||
- Tot stand komen via meerdere stappen
|
||||
- Cost en latency mag wat hoger
|
||||
|
||||
**Quote uit AI SDK docs:** *"Agents are flexible and powerful, but non-deterministic. When you need reliable, repeatable outcomes, use workflow patterns."*
|
||||
|
||||
---
|
||||
|
||||
## Slide 16: Lesopdracht + Huiswerk
|
||||
### Bouw je eigen agent
|
||||
|
||||
**Lesopdracht (in-class, 30 min):**
|
||||
- Setup research-agent in eigen repo
|
||||
- Tavily account aanmaken + key in .env
|
||||
- Agent draaien met `stepCountIs(10)` en eerste eigen query stellen
|
||||
- Logs bekijken: hoeveel stappen, welke tools
|
||||
|
||||
**Huiswerk (voor Les 14):**
|
||||
- 4e tool toevoegen — `listReports` om opgeslagen rapporten op te halen
|
||||
- `prepareStep` toevoegen — minimaal 1 use case (model OF tools OF context)
|
||||
- Custom `StopCondition` schrijven (kosten OF specifieke conditie)
|
||||
- `AGENT.md` schrijven: tool-lijst + stop-strategie + 1 voorbeeld-run met logs
|
||||
|
||||
**Bonus:**
|
||||
- UI met live-step-rendering (elke stap als card)
|
||||
- Sub-agent: 2e agent die per pagina samenvat, hoofdagent gebruikt sub-agent als tool
|
||||
|
||||
---
|
||||
|
||||
## Slide 17: Volgende les + Afsluiting
|
||||
### Vragen?
|
||||
|
||||
**Vandaag gezien:**
|
||||
- Agent = LLM + tools + loop
|
||||
- `ToolLoopAgent` met `stopWhen` en `prepareStep`
|
||||
- 4 stop-condities: stepCountIs, hasToolCall, isLoopFinished, custom
|
||||
- prepareStep voor dynamic model / tools / context
|
||||
- Done-tool pattern voor forced tool calling
|
||||
- Wanneer wel/niet een agent
|
||||
|
||||
**Volgende les (Les 15): RAG + Embeddings**
|
||||
- Semantic search op grote datasets
|
||||
- pgvector in Supabase
|
||||
- Embedding models — wat zijn vectors
|
||||
- RAG-pipeline: chunk → embed → retrieve → generate
|
||||
- Combo: RAG-tool in een agent — best of both worlds
|
||||
|
||||
**Daarna in deze leerlijn:**
|
||||
- Les 14-16: Testing + Deployment + Performance
|
||||
- Les 17-18: Eindopdracht-werkdagen + Pitch
|
||||
|
||||
**Vragen? Feedback?**
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Type |
|
||||
|---|-------|------|
|
||||
| 1 | Title | Opening |
|
||||
| 2 | Terugblik | Recap |
|
||||
| 3 | Planning | 180-min |
|
||||
| 4 | Wat is een agent | Theorie |
|
||||
| 5 | Anatomie van de loop | Theorie |
|
||||
| 6 | ToolLoopAgent | Theorie |
|
||||
| 7 | Stop-condities | Theorie |
|
||||
| 8 | prepareStep | Theorie |
|
||||
| 9 | Wat we vandaag bouwen | Intro demo |
|
||||
| 10 | **LIVE DEMO 1** — Setup | Demo |
|
||||
| 11 | **LIVE DEMO 2** — stopWhen | Demo |
|
||||
| 12 | Pauze | Break |
|
||||
| 13 | **LIVE DEMO 3** — prepareStep | Demo |
|
||||
| 14 | **LIVE DEMO 4** — done + custom stop | Demo |
|
||||
| 15 | Wanneer wel/niet | Reflectie |
|
||||
| 16 | Lesopdracht + Huiswerk | Praktijk |
|
||||
| 17 | Afsluiting + Les 14 preview | Closing |
|
||||
|
||||
---
|
||||
|
||||
## Bronnen
|
||||
|
||||
- AI SDK Agents — Overview: https://ai-sdk.dev/docs/agents/overview
|
||||
- AI SDK Agents — Loop Control: https://ai-sdk.dev/docs/agents/loop-control
|
||||
- AI SDK Agents — Building Agents: https://ai-sdk.dev/docs/agents/building-agents
|
||||
- AI SDK Agents — Workflows: https://ai-sdk.dev/docs/agents/workflows
|
||||
- Tavily API: https://tavily.com/
|
||||
- Anthropic — Building effective agents: https://www.anthropic.com/research/building-effective-agents
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user