Ik heb een kleine interactieve AI gedreven verhalen verteller gebouwd:
Recent stuitte ik op deze Tweet/X post van Jelle Prins.
Het idee van een interactieve AI gegenereerde gepersonaliseerde verhaalervaring sprak me enorm aan; het leek me daarom leuk om een klein hobby projectje te bouwen rondom dit idee.
Je kunt je afvragen: als het al met een prompt kan, waarom dan een losse applicatie bouwen? Deels is dat eigenbelang: zoals ik al zei, het leek me vooral leuk, je moet nadenken over de prompts, over de flow/logica van verschillende calls, over UI, token gebruik, consistentie van de verhalen etcetera. Bovendien probeer ik wat beter te leren programmeren, en ik vind zelf iets maken dan vaak beter werken dan een YouTube turtorial nadoen.
Sidenode en enigszins offtopic, maar aangaande het bouwen van applicaties bovenop LLM foundation models kwam ik dit artikel tegen een paar dagen terug, hoewel enigszins fatalistisch wel een super heldere uiteenzetting en absolute leestip!: The Zero-Day Flaw in AI Companies
Er zitten echter ook een aantal functionele voordelen aan een applicatie ten opzichte van een prompt. Met toolcalling en structured outputs ben je in theorie in staat om de LLM een breed scala aan tools te bieden waarmee de interactieve ervaring kan worden verrijkt. Ook kun je input van meerdere modellen gebruiken om bijvoorbeeld zowel tekst als afbeeldingen te genereren. Daarnaast kun je de opdracht opknippen. Deze “agentic” aanpak stelt je in staat om de keuze voor bepaalde modellen te optimaliseren per taak.
Voor ik starte met bouwen had ik een paar technische voornemens:
- De applicatie maakt geen gebruik van een abstractie framework als LangGraph
Dit omdat veel andere projecten waar ik recent aan werkte hier wel gebruik van maken. Deze en deze bijvoorbeeld. Om iets meer controle te hebben over de exacte input bij elke call en ten behoeve van mijn eigen begrip van de flow besloot ik om dit nu niet te doen.
- De applicatie is geschreven in Svelte en Typescript
Ik had net deze turtorial afgerond en wou het graag een keer toepassen
Code uitleg
de hele code vind je hier. Hieronder een toelichting op bepaalde belangrijke delen.
Deel 1: het maken van het prompt
Waneer we het prompt uit de Tweet nog eens goed bekijken..
You are an excellent storyteller. Create an interactive adventure story, in the style of Harry Potter, for 12-year-old children. Start with an introduction of the setting, the magical world where the game takes place, and the main characters, two young wizard brothers named NAME-1 and NAME-2, both with blond hair. NAME-1 is X years older than NAME-2. The two boys go on an adventure. The story starts with them receiving a quest. After each scene, ask a question or offer 2-3 choices that allow the children to determine the direction of the story. Adapt the story based on their choices. When the characters need to make an effort, they may roll a dice. The higher they roll, the better the effort, for example an attack in a fight, succeeds. For instance, if they roll a 6, it's a critical hit, but if they roll a 1, it fails miserably. Keep the content suitable for children, but do make it exciting. Use vivid descriptions. Encourage the children's imagination. If the children introduce something unexpected, try to incorporate it creatively into the story. End the story after about 15 interactions with a satisfying conclusion.
..zou je kunnen zeggen dat het is opgebouwd uit een aantal elementen, ik vond het fijn onderscheid te maken tussen 4 soorten prompt snippets:
Generieke instructies - Het basis framework van het prompt, met de algemene thema overstijgende instructies
Setting/Thema instructies - Ik kan natuurlijk geen applicatie bouwen met alleen het prompt voor de Harry Potter setting, dus ik zal meerdere settings willen defineren.
Personalisatie instructies - Spreekt voor zich, alle elementen in het prompt die zorgen dat het verhaal gepersonaliseerd wordt voor de lezer/luisteraar
Verhaal progressie instructies - Aangezien ik een stapsgewijze chain van llm calls zal gebruiken zal ik de voortgang van het verhaal willen meegeven en de daarbij passende instructies (”start met de introductie van de setting” of “schrijf een bevredigend einde”)
You are an excellent storyteller. Create an interactive adventure story, in the style of Harry Potter, for 12-year-old children. Start with an introduction of the setting, the magical world where the game takes place, and the main characters, two young wizard brothers named NAME-1 and NAME-2, both with blond hair. NAME-1 is X years older than NAME-2. The two boys go on an adventure. The story starts with them receiving a quest. After each scene, ask a question or offer 2-3 choices that allow the children to determine the direction of the story. Adapt the story based on their choices. When the characters need to make an effort, they may roll a dice. The higher they roll, the better the effort, for example an attack in a fight, succeeds. For instance, if they roll a 6, it's a critical hit, but if they roll a 1, it fails miserably. Keep the content suitable for children, but do make it exciting. Use vivid descriptions. Encourage the children's imagination. If the children introduce something unexpected, try to incorporate it creatively into the story. End the story after about 15 interactions with a satisfying conclusion.
In de code heb ik voor de setting/thema definities een bestand te maken met wat simpele constants:
import type { StoryPromptSnippet } from '$lib/types/story';
export const storyPromptSnippets: StoryPromptSnippet[] = [
{
storyId: 'avontuur-in-de-ruimte',
storyStyle: 'een spannend ruimteavontuur',
storyCharacter: 'een jonge astronaut',
storyIntroEnd:
'de hoofdpersoon een geheimzinnige boodschap ontvangt van een onbekende planeet, vol vreemde symbolen en geluiden',
storySetting:
'een eindeloze sterrenhemel vol fonkelende sterren, kleurrijke nevels en vreemde, mysterieuze planeten. Het futuristische ruimteschip glijdt stil door het heelal, met ramen waardoor je naar de verre melkweg kunt kijken en apparaten die allerlei piepjes en fluitjes maken'
},
{
storyId: 'magisch-bos',
storyStyle: 'een magisch sprookjesverhaal',
storyCharacter: 'een jonge avonturier',
storyIntroEnd:
'de hoofdpersoon een geheime kaart vindt die leidt naar een verborgen schat diep in het hart van het bos',
storySetting:
'een prachtig bos vol torenhoge bomen, sprankelende beekjes en kleurrijke bloemen. Het bos zit vol met wonderlijke wezens zoals pratende dieren, elfjes die in lichtflitsen verdwijnen, en oude eiken die verhalen fluisteren van lang geleden'
} // etc.
];
De logica is basic: op het moment dat iemand een thema selecteert zetten we een storyId in de store, die we meesturen met de api call naar de server voor prompt generatie.
De voorkeuren van de user verkrijgen we in een simpel formuliertje, naast die voorkeuren, stellen we bij het verzenden van het formulier ook een unieke ID in, mochten we meerdere verhalen willen ondersteunen en de verhalen goed uit elkaar blijven houden.
Uiteindelijk wordt het prompt gegenereerd in deze file, hieronder een voorbeeldfunctie
const generateChildDataPrompt = (childData: ChildData): string => {
const { childName, childAge, childInterests, childTensionLevel } = childData;
const agePrompt = generateAgePrompt(childAge);
const tensionLevelPrompt = generateTensionLevelPrompt(childTensionLevel);
return `
Schrijf een uniek kinderverhaal voor ${childName}, een ${childAge}-jarige met interesse in ${childInterests}.
${agePrompt}
${tensionLevelPrompt}
`;
};
Voor spanning en leeftijd hebben we nog wat specifieke instructies ingesteld hier.
Deel 2: het aanroepen van de LLM
Hier kan ik nog veel van de logica optimaliseren en centraliseren
Met het prompt doen we de API call. Dit is vrij standaard, een paar dingen om er uit te lichten:
- We willen dat de output gestructureerd is zodat we deze weer goed kunnen oppakken in de code (hieronder een voorbeeld van OpenAI)
import OpenAI from 'openai';
import { z } from 'zod';
import { zodResponseFormat } from 'openai/helpers/zod';
import { OPENAI_API_KEY } from '$env/static/private';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY
});
const ChapterSchema = z.object({
content: z
.string()
.describe('De inhoud van het hoofdstuk; geef geen titel, alleen de inhoud zelf')
});
export async function generateChapterWithOpenAI(prompt: string): Promise<string> {
const completion = await openai.beta.chat.completions.parse({
model: 'gpt-4o-2024-08-06', // TODO: centraliseren
messages: [
{ role: 'system', content: 'Je bent een AI die gestructureerde boekhoofdstukken genereert.' },
{ role: 'user', content: prompt }
],
response_format: zodResponseFormat(ChapterSchema, 'chapter')
});
const chapter = completion.choices[0].message;
if (chapter.parsed) {
return chapter.parsed.content;
} else if (chapter.refusal) {
throw new Error('AI weigerde inhoud te genereren: ' + chapter.refusal);
} else {
throw new Error('Onverwacht response-formaat');
}
}
- We kunnen kiezen uit Anthropic en OpenAI die elk een eigen methode hebben om dit te realiseren (hieronder de api handler en hoe we de keuze verwerken)
// src/routes/api/generate-chapter/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateChapterWithAnthropic } from '$lib/llm-providers/anthropicChapter';
import { generateChapterWithOpenAI } from '$lib/llm-providers/openaiChapter';
export const POST: RequestHandler = async ({ request }) => {
try {
const { prompt, provider }: { prompt: string; provider?: string } = await request.json();
if (!prompt) {
return json({ error: 'Prompt is vereist' }, { status: 400 });
}
let content: string;
switch (provider) {
case 'anthropic':
content = await generateChapterWithAnthropic(prompt);
break;
case 'openai':
content = await generateChapterWithOpenAI(prompt);
break;
default:
content = await generateChapterWithAnthropic(prompt);
break;
} // centraliseren
console.log('Output:', content);
return json({ content }, { status: 200 });
} catch (error) {
console.error('Fout bij het genereren van het hoofdstuk:', error);
return json({ error: 'Kan het verhaal niet genereren' }, { status: 500 });
}
};
Het genereren van keuzes gaat op dezelfde manier, maar dan met een return van 3 strings, in plaats van 1.
Een belangrijke overweging hier was of ik de keuzes + content van een hoofdstuk niet in 1 call wou ophalen. Dit scheelt een call en de taalmodellen zouden dat prima kunnen verwerken. Uiteindelijk gekozen om dit niet te doen om 3 redenen:
- Flexibiliteit qua interactie type - Op dit moment heb ik alleen de drie vervolgrichtingen als eindinteractie na een hoofdstuk, maar ik zou dit graag uitbreiden met andere type interacties. Door de calls los te trekken van elkaar maken we dat wat makkelijker
- Genereren van afbeelding kan eerder starten - Mijn inschatting vooraf was dat het genereren van afbeeldingen, wat ik wil doen op basis van de content van een hoofdstuk, eventjes kan duren, door de output van de eerste call iets te verkleinen (alleen een hoofdstuk ipv een hoofdstuk + vragen) kan ik de call naar het diffusion model eerder doen.
- Weinig meerwaarde voor bezoeker - Iemand moet immers eerst het hoofdstuk lezen voor een keuze gemaakt kan worden
Deel 3: het genereren van de afbeelding
Hier moet ik nog werken aan sterkere consistentie van gegenereerde afbeeldingen tussen hoofdstukken qua personages
Het genereren van afbeeldingen op zich was vrij simpel, maar we willen natuurlijk afbeeldingen die consistent zijn qua thematiek, personages en die passen bij het verhaal. Het lastige daarbij is dat ik op dit moment niet weet hoe het verhaal zich ontwikkeld bij een gebruiker, dat kan natuurlijk vrij veel kanten op gaan. De sfeer/thematiek/beeldstijl van een verhaal kan ik wel enigszins sturen, aangezien ik het gekozen thema van de gebruiker ken. Dat scheelt al behoorlijk qua beleving. Om dit te realiseren heb ik opnieuw een soortgelijke aanpak als voor de prompt snippets gekozen, ik heb een aantal constants gedefinieerd waarin ik per scenario alvast wat prompt instructies met betrekking tot stijl heb toegevoegd:
import type { StoryImageStyling } from '$lib/types/story';
export const storyImageStyling: StoryImageStyling[] = [
{
storyId: 'avontuur-in-de-ruimte',
storyImageStyling:
'Futuristic, vibrant, sci-fi illustration with neon highlights and cosmic themes, emphasizing sleek designs and deep space tones.'
},
{
storyId: 'magisch-bos',
storyImageStyling:
'Whimsical fairy tale illustrations with glowing light and earthy tones, featuring magical, enchanted environments.'
},
{
storyId: 'onderwateravontuur',
storyImageStyling:
'Colorful, underwater illustration style with soft lighting and fluid, dynamic underwater scenes in vibrant tones.'
},
{
storyId: 'draken-en-ridders',
storyImageStyling:
'Medieval fantasy illustration with a painterly style, emphasizing grand castles, mythical creatures, and muted, earthy tones.'
} //etc
]
Nu moeten we zorgen dat de LLM die de prompt voor de afbeelding generatie gaat schrijven niet ook al deze termen meegeeft, of andere termen aangaande sfeer of stijl. Dit doen we (niet waterdicht) via dit script/prompt (nu een voorbeeld van Anthropic):
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC_API_KEY } from '$env/static/private';
interface MessageBlock {
type: 'message';
role: 'user' | 'assistant';
content: string;
}
interface ToolUseBlock {
type: 'tool_use';
name: string;
input: {
prompt: string;
};
}
type ContentBlock = MessageBlock | ToolUseBlock;
const anthropic = new Anthropic({
apiKey: ANTHROPIC_API_KEY
});
export async function generateImagePromptWithAnthropic(chapter: string): Promise<string> {
const response = await anthropic.messages.create({
model: 'claude-3-sonnet-20240229', //TODO centraliseren
max_tokens: 1024,
tools: [
{
name: 'generate_image_prompt',
description:
'Generate a concise description of a scene from the chapter, suitable as a prompt for an image generation model. Focus on the setting and actions without detailed descriptions of characters.',
input_schema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'Concise scene description for image generation'
}
},
required: ['prompt']
}
}
],
messages: [
{
role: 'user',
content: `Summarize a key scene from the chapter in 1 sentence. Focus on clear descriptions of the setting and actions. Do not describe character appearances or add any atmosphere. Provide the output in English, even if the input is in Dutch.\n\n${chapter}`
}
]
});
// Process the API response to obtain the prompt
let prompt: string = 'No prompt found.';
const contentBlocks = response.content as ContentBlock[];
const toolUseMessage = contentBlocks.find(
(item): item is ToolUseBlock =>
item.type === 'tool_use' && item.name === 'generate_image_prompt'
);
if (toolUseMessage && typeof toolUseMessage.input === 'object') {
prompt = toolUseMessage.input.prompt;
}
return prompt;
}
De uitkomst van deze call schieten we als laatste de API van replicate in voor het genereren van de afbeelding. Exacte uitleg daarvan sla ik voor nu even over, maar de code spreekt redelijk voor zich en anders staat hier een heel goed voorbeeld* dat ik ook heb gebruikt
*met wat kleine aanpassingen omdat we Svelte ipv NextJS gebruiken en TypeScript ipv JavaScript
Volledige chapter generatie flow
kort samengevat doen we dus het volgende:
- We verzamelen de gegevens van de gebruiker
- Compileren daarmee een prompt
- Roepen een LLM aan met dat prompt
- Gebruiken de uitkomst van die call (een hoofdstuk van ons verhaal) om tegelijk 2 acties uit te voeren:
5a. We genereren keuzes voor vervolgrichtingen
5b. We genereren een prompt voor de afbeeldingsgeneratie
6b. We genereren de afbeelding
Voor elk van de llm en Diffusion calls geldt: zodra we output binnen hebben updaten we ook de verhaal store om de UI daarmee bij te werken.
Visual
Next steps
Om echt tevreden te zijn wil ik nog een aantal dingen veranderen of toevoegen: -Meer interactie tussen het kind en de LLM (Quizjes, open vragen, feedback, challenges bijvoorbeeld)
-Verbeterde structuur, minder duplicate code
-Optimaliseren token gebruik
-Leerdoelen/themas voor verhalen introduceren