Files
novi-lessons/Les12-Tool-Calling/tools-demo.ts
2026-05-21 08:52:47 +02:00

247 lines
9.7 KiB
TypeScript

/**
* Polderfest Tool-Calling demo — reference snippets (AI SDK v6)
* --------------------------------------------------
* Dit zijn de tools die we live opbouwen tijdens Les 12.
* Matcht met de actuele stack van polderfest-demo:
* - ai@^6
* - @ai-sdk/openai@^3
* - @ai-sdk/react@^3
*
* Gebruik dit als naslag — niet 1-op-1 kopiëren tijdens demo.
* Plek in project: app/api/chat/route.ts
*/
import {
convertToModelMessages,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai";
import { openai } from "@ai-sdk/openai";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey =
process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);
// ────────────────────────────────────────────────────────────
// Tool 1: searchBands — filter op velden
// ────────────────────────────────────────────────────────────
const searchBands = tool({
description:
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre of tier. " +
"Gebruik dit als de gebruiker iets zoekt op één of meerdere criteria.",
inputSchema: z.object({
day: z
.enum(["Vrijdag", "Zaterdag", "Zondag"])
.optional()
.describe("Festival-dag"),
stage: z
.string()
.optional()
.describe("Bv. Main Stage, Tent Stage, Beach Stage, Acoustic Bar, Late Night Tent"),
genre: z
.string()
.optional()
.describe("Bv. Indie Rock, Electronic, Hip-Hop, Jazz Fusion"),
tier: z.enum(["headliner", "mid", "opener"]).optional(),
minPopularity: z
.number()
.min(1)
.max(100)
.optional()
.describe("Minimale populariteit (1-100)"),
}),
execute: async ({ day, stage, genre, tier, minPopularity }) => {
let q = supabase
.from("bands")
.select(
"id, name, genre, sub_genre, stage, day, start_time, " +
"origin_city, tier, popularity, bio",
);
if (day) q = q.eq("day", day);
if (stage) q = q.eq("stage", stage);
if (genre) q = q.eq("genre", genre);
if (tier) q = q.eq("tier", tier);
if (minPopularity) q = q.gte("popularity", minPopularity);
const { data, error } = await q.limit(20);
if (error) return { error: error.message };
return { count: data.length, bands: data };
},
});
// ────────────────────────────────────────────────────────────
// Tool 2: getBandByName — exacte lookup
// ────────────────────────────────────────────────────────────
const getBandByName = tool({
description:
"Haal alle details op van één specifieke band, inclusief members en bio. " +
"Gebruik dit als de gebruiker naar een specifieke band vraagt.",
inputSchema: z.object({
name: z.string().describe("Exacte band-naam"),
}),
execute: async ({ name }) => {
const { data, error } = await supabase
.from("bands")
.select("*")
.ilike("name", name)
.single();
if (error) return { error: `Band '${name}' niet gevonden.` };
return data;
},
});
// ────────────────────────────────────────────────────────────
// Tool 3: getStats — aggregate over alle bands
// ────────────────────────────────────────────────────────────
const getStats = tool({
description:
"Geef statistieken over de festival-line-up — totaal aantal bands, " +
"verdeling per genre, per dag, of per stage. Geen filters — overzicht.",
inputSchema: z.object({
groupBy: z
.enum(["genre", "day", "stage", "tier"])
.describe("Hoe te groeperen"),
}),
execute: async ({ groupBy }) => {
const { data, error } = await supabase.from("bands").select(groupBy);
if (error) return { error: error.message };
const counts: Record<string, number> = {};
for (const row of data) {
const key = row[groupBy as keyof typeof row] as string;
counts[key] = (counts[key] ?? 0) + 1;
}
return { total: data.length, counts };
},
});
// ────────────────────────────────────────────────────────────
// Tool 4: getScheduleByDay — slot-overzicht
// ────────────────────────────────────────────────────────────
const getScheduleByDay = tool({
description:
"Geef het volledige tijdschema voor één festival-dag. " +
"Bands gesorteerd op tijdslot. Handig voor 'wat speelt er om 22:00?'",
inputSchema: z.object({
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]),
stage: z
.string()
.optional()
.describe("Optioneel filteren op specifieke stage"),
}),
execute: async ({ day, stage }) => {
let q = supabase
.from("bands")
.select("name, stage, start_time, duration_min, tier")
.eq("day", day);
if (stage) q = q.eq("stage", stage);
const { data, error } = await q.order("start_time", { ascending: true });
if (error) return { error: error.message };
return data;
},
});
// ────────────────────────────────────────────────────────────
// Tool 5 (write): addFavorite — user_favorites tabel
// ────────────────────────────────────────────────────────────
const addFavorite = tool({
description:
"Voeg een band toe aan de favorieten van de gebruiker. " +
"Gebruik alleen als de gebruiker expliciet vraagt 'voeg X toe aan mijn favorieten'.",
inputSchema: z.object({
userEmail: z.string().email().describe("Email van de gebruiker"),
bandName: z.string().describe("Exacte band-naam"),
}),
execute: async ({ userEmail, bandName }) => {
const { data: band } = await supabase
.from("bands")
.select("id")
.ilike("name", bandName)
.single();
if (!band) return { error: `Band '${bandName}' niet gevonden.` };
const { error } = await supabase
.from("user_favorites")
.insert({ user_email: userEmail, band_id: band.id });
if (error) return { error: error.message };
return { success: true, bandName, bandId: band.id };
},
});
// ────────────────────────────────────────────────────────────
// Tool 6 (read): listFavorites
// ────────────────────────────────────────────────────────────
const listFavorites = tool({
description: "Geef de favoriete bands van de gebruiker.",
inputSchema: z.object({
userEmail: z.string().email(),
}),
execute: async ({ userEmail }) => {
const { data, error } = await supabase
.from("user_favorites")
.select("bands(name, genre, day, start_time, stage)")
.eq("user_email", userEmail);
if (error) return { error: error.message };
return data?.map((r) => r.bands) ?? [];
},
});
// ────────────────────────────────────────────────────────────
// De chat-route met alle tools
// ────────────────────────────────────────────────────────────
export async function POST(req: Request) {
try {
const { messages }: { messages: UIMessage[] } = await req.json();
const system = `Je bent een festival-assistent voor Polderfest 2027.
Je hebt toegang tot tools om de bands-database te bevragen. Gebruik altijd
tools voor concrete vragen — verzin nooit data. Antwoord beknopt en in
het Nederlands.
Tips:
- Voor "welke bands op X?" → searchBands
- Voor specifieke band → getBandByName
- Voor "hoeveel" / "verdeling" → getStats
- Voor tijdschema → getScheduleByDay
- Voor favorieten → addFavorite / listFavorites
Als een tool een error returnt, leg dat netjes uit aan de gebruiker.`;
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages: await convertToModelMessages(messages),
tools: {
searchBands,
getBandByName,
getStats,
getScheduleByDay,
addFavorite,
listFavorites,
},
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse({
onError(error: unknown) {
if (error == null) return "unknown error";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return JSON.stringify(error);
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return Response.json(
{ error: "Chat route failed", details: message },
{ status: 500 },
);
}
}