diff --git a/docs/agents/using-tools-2.md b/docs/agents/using-tools-2.md new file mode 100644 index 0000000..cdfd056 --- /dev/null +++ b/docs/agents/using-tools-2.md @@ -0,0 +1,201 @@ +# Handling User Selection + +In this chapter, we’ll focus on how the chatbot processes user selections of time slots and finalizes their booking by reserving the appointment in the database. We’ll examine how the user’s button action triggers the confirmation tool and how the appointment is stored and managed. + +## Processing User Selections and Confirming the Appointment + +Once the user has chosen a time slot using the button interface, the chatbot must handle this selection and confirm the booking. This process involves handling the button action, reserving the time slot, and providing feedback to the user. + +### Managing Appointment Data + +The AppointmentRepository is responsible for handling appointment data within our chatbot. It ensures that available time slots are fetched, new ones are created if necessary, and appointments are reserved when a user confirms a time slot. + +The `reserve` is a new function than attempts to reserve an available slot. It updates the database, associating the selected time slot with the user’s chat ID. If successful, the slot is marked as reserved, and no other users can select it. + + +```ts title="src/lib/repositories/appointments.ts" +import { and, eq, isNull } from "drizzle-orm/expressions"; + +import { db as database } from "../db/index"; +import { appointments } from "../db/schema/appointments"; + +export class AppointmentRepository { + async getFreeAppointmentsForDay(date: Date): Promise> { + const allAppointments = await database + .select({ + chatId: appointments.chatId, + timeSlot: appointments.timeSlot, + }) + .from(appointments) + .where(eq(appointments.date, date)); + + // If no appointments exist for this day, create them + if (allAppointments.length === 0) { + await this.createEmptyAppointmentsForDay(date); + return this.getFreeAppointmentsForDay(date); + } + + // Filter free appointments where chatId is null (not reserved) + const freeAppointments = allAppointments.filter((appointment) => appointment.chatId === null); + + return freeAppointments.map((appointment) => ({ + timeSlot: appointment.timeSlot, + })); + } + + private async createEmptyAppointmentsForDay(date: Date): Promise { + const openingTime = 9; // 9 AM + const closingTime = 19; // 7 PM + + const timeSlots = Array.from({ length: closingTime - openingTime }, (_, index) => { + const time = `${(openingTime + index).toString().padStart(2, "0")}:00`; + return { date, timeSlot: time }; + }); + + // Insert empty appointments for each time slot + await database.insert(appointments).values(timeSlots); + } + + async reserve(chatId: number, date: Date, timeSlot: string): Promise { + const updated = await database + .update(appointments) + .set({ chatId }) + .where(and(eq(appointments.date, date), eq(appointments.timeSlot, timeSlot), isNull(appointments.chatId))) + .returning() + .execute(); + + return updated.length > 0; + } +} + +export const appointmentsRepository = new AppointmentRepository(); +``` + +### Confirming the Appointment + +The `confirmAppointment` tool is used to handle the reservation once the user selects a time slot. The tool checks if the selected time slot is still available in the database. If the slot is free, it reserves it for the user by associating it with their chat ID. If the slot is already taken, it notifies the user that they need to pick a different time. This tool ensures that only available slots can be reserved, and it provides feedback based on the reservation’s success or failure. + +```ts title="src/lib/tools/confirm-appointment.ts" +import { type CoreTool, tool } from "ai"; +import type { Context } from "grammy"; +import { z } from "zod"; + +import { appointmentsRepository } from "../repositories/appointments"; +import { tomorrow } from "../utils"; + +export const buildConfirmAppointment = (context: Context): CoreTool => + tool({ + description: "Confirm the selected appointment.", + execute: async ({ slot }) => { + console.log(`Called confirmAppointment tool with ${JSON.stringify({ slot }, null, 2)}`); + + const chatId = context.chatId; + if (!chatId) { + return "Sorry, I cannot do the reserve."; + } + + const isReserved = appointmentsRepository.reserve(chatId, tomorrow(), slot); + + if (!isReserved) { + return "Sorry, that time slot is no longer available. Please choose another one."; + } + + return `Your appointment at ${slot} is confirmed!`; + }, + parameters: z.object({ + slot: z.string().describe("The selected time slot to confirm. Format HH:MM"), + }), + }); +``` + +## Telegram Callback Listener for Button Actions + +The onAppointment listener is responsible for capturing the user’s button presses and processing them within the ongoing conversation. When a user clicks a time slot button, the listener extracts the selected slot from the button’s callback data and integrates it into the conversation. + + +```ts title="src/lib/handlers/on-appointment.ts" +import { generateText } from "ai"; +import { Composer } from "grammy"; + +import { registry } from "../ai/setup-registry"; +import { environment } from "../environment.mjs"; +import { conversationRepository } from "../repositories/conversation"; +import { buildConfirmAppointment } from "../tools/confirm-appointment"; + +export const onAppointment = new Composer(); + +const PROMPT = ` +You are a chatbot designed to help users book hair salon appointments for tomorrow. +You have access to several tools to help with this task and are restricted to handling appointment bookings only. You do not handle any other inquiries. +Your primary goal is to guide users through the process of booking their appointments efficiently. + +You will follow these rules: + +1. Appointment Search: Use the "getFreeAppointments" tool to search for available appointment times for tomorrow. +2. Display Options: After "getFreeAppointments" use always the "displaySelectionButtons" tool to ask the user the available time slots as buttons they can select from. +3. Confirm Appointment: After the user selects a time, use the confirmAppointment function to finalize their appointment request. +4. Single-purpose chatbot: You only help users book appointments for tomorrow and do not answer unrelated questions, you need to use tools to ask options. + +If a user asks for information outside of these details, please respond with: "I'm sorry, but I cannot assist with that. For more information, please call us at (555) 456-7890 or email us at info@hairsalon.com." +`; + +onAppointment.callbackQuery(/slot-.+/, async (context) => { + const chatId = context.chatId as number; + const slot = context.callbackQuery.data.slice(5); + + await conversationRepository.addMessage(chatId, "user", slot); + const messages = await conversationRepository.get(chatId); + + await context.api.sendChatAction(chatId, "typing"); + + const { text } = await generateText({ + maxSteps: 2, + messages, + model: registry.languageModel(environment.MODEL), + system: PROMPT, + tools: { + confirmAppointment: buildConfirmAppointment(context), + }, + }); + + if (text) { + await context.reply(text); + await conversationRepository.addMessage(chatId, "assistant", text); + } +}); +``` + +And we need include this listener into the main bot file: + +```ts title="src/main.ts" +import process from 'node:process' + +import { Bot } from 'grammy' + +import { ask } from './lib/commands/ask' +import { learn } from './lib/commands/learn' +import { start } from './lib/commands/start' +import { environment } from './lib/environment.mjs' +import { onAppointment } from './lib/handlers/on-appointment' +import { onMessage } from './lib/handlers/on-message' + +async function main(): Promise { + const bot = new Bot(environment.BOT_TOKEN) + + bot.command('start', start) + bot.command('learn', learn) + bot.command('ask', ask) + + bot.use(onMessage) + bot.use(onAppointment) + + // Enable graceful stop + process.once('SIGINT', () => bot.stop()) + process.once('SIGTERM', () => bot.stop()) + process.once('SIGUSR2', () => bot.stop()) + + await bot.start() +} + +main().catch((error) => console.error(error)) +``` \ No newline at end of file diff --git a/docs/agents/using-tools.md b/docs/agents/using-tools.md new file mode 100644 index 0000000..2dc51b8 --- /dev/null +++ b/docs/agents/using-tools.md @@ -0,0 +1,126 @@ +# Displaying Available Time Slots + +In this chapter, we’ll explore how our chatbot guides users to book a hair salon appointment for the next day by displaying the available time slots using Telegram buttons. We will focus on retrieving the available appointments and dynamically creating button options for the user. + +## Showing Time Slot Buttons to Users + +Our chatbot uses several components to display time slot buttons, allowing users to easily select their preferred appointment time. We’ll break down how this is achieved by combining the Telegram bot API, the Vercel AI SDK, and custom tools. + +The chatbot leverages tools from the Vercel AI SDK to perform specific actions—like displaying buttons. In this case, the displaySelectionButtons tool is responsible for rendering Telegram buttons for available time slots. This tool works with the context provided by Telegram (via grammy), dynamically creating and sending button options to the user. + +The chatbot uses the getFreeAppointments tool to retrieve available time slots for the next day. This tool queries the backend for any unreserved time slots. The slots are passed into the displaySelectionButtons tool, which then turns them into clickable options for the user to choose from. + +The Telegram API, through grammy, allows the chatbot to interact with users in a conversational manner. Once the chatbot receives a message from the user, it generates a response using the AI model from Vercel, following the set rules in the prompt. If time slots are available, the bot uses InlineKeyboard to display them as buttons, providing a seamless way for users to select their appointment time. + +To maintain a coherent flow, the chatbot uses the conversationRepository to log user messages and the bot’s responses. This ensures the chatbot can maintain context, such as remembering which time slots were offered, and guiding the user step-by-step toward confirming their appointment. + +By combining these components, the chatbot provides a smooth and user-friendly booking experience. In the next chapter, we will cover how to handle the button clicks and confirm the reservation. + +### Create the tool to display buttons + +```ts title="src/lib/tools/display-selection-buttons.ts" +import { type CoreTool, tool } from "ai"; +import { type Context, InlineKeyboard } from "grammy"; +import { z } from "zod"; + +import { conversationRepository } from "../repositories/conversation"; + +export const buildDisplaySelectionButtons = (context: Context): CoreTool => + tool({ + description: "Use this tool to ask to the user with the available time slots as buttons they can select from.", + execute: async ({ options, question }) => { + console.log(`Called displaySelectionButtons tool with ${JSON.stringify({ options, question }, null, 2)}`); + + const buttonsRows = []; + for (let index = 0; index < options.length; index += 2) { + buttonsRows.push( + options + .slice(index, index + 2) + .map((option: string) => ({ + data: `slot-${option}`, + label: option, + })) + .map(({ data, label }) => InlineKeyboard.text(label, data)) + ); + } + + await context.reply(question, { + reply_markup: InlineKeyboard.from(buttonsRows), + }); + + const content = `${question}: ${options.join(", ")}`; + + const chatId = context?.chat?.id as number; + await conversationRepository.addMessage(chatId, "assistant", content); + + return null; + }, + parameters: z.object({ + options: z.array(z.string()).describe("Array of time slots for the user to choose from"), + question: z.string().describe("The question to ask the user"), + }), + }); +``` + +### Add the tool and update the prompt + +```ts title="src/lib/handlers/on-message.ts" +import { generateText } from 'ai' +import { Composer } from 'grammy' + +import { registry } from '../ai/setup-registry' +import { environment } from '../environment.mjs' +import { conversationRepository } from '../repositories/conversation' +import { buildDisplaySelectionButtons } from '../tools/display-selection-buttons' +import { getFreeAppointments } from '../tools/get-free-appointments' + +export const onMessage = new Composer() + +const PROMPT = ` +You are a chatbot designed to help users book hair salon appointments for tomorrow. +You have access to several tools to help with this task and are restricted to handling appointment bookings only. You do not handle any other inquiries. +Your primary goal is to guide users through the process of booking their appointments efficiently. + +You will follow these rules: + +1. Appointment Search: Use the "getFreeAppointments" tool to search for available appointment times for tomorrow. +2. Display Options: After "getFreeAppointments" use always the "displaySelectionButtons" tool to ask the user the available time slots as buttons they can select from. +3. Confirm Appointment: After the user selects a time, use the confirmAppointment function to finalize their appointment request. +4. Single-purpose chatbot: You only help users book appointments for tomorrow and do not answer unrelated questions, you need to use tools to ask options. + +If a user asks for information outside of these details, please respond with: "I'm sorry, but I cannot assist with that. For more information, please call us at (555) 456-7890 or email us at info@hairsalon.com." +` + +onMessage.on('message:text', async (context) => { + const userMessage = context.message.text + const chatId = context.chat.id + + // Store the user's message + await conversationRepository.addMessage(chatId, 'user', userMessage) + + // Retrieve past conversation history + const messages = await conversationRepository.get(chatId) + + // Generate the assistant's response using the conversation history + const { text } = await generateText({ + maxSteps: 2, + messages, + model: registry.languageModel(environment.MODEL), + system: PROMPT, + tools: { + displaySelectionButtons: buildDisplaySelectionButtons(context), + getFreeAppointments, + }, + }) + + if (!text) { + return + } + + // Store the assistant's response + await conversationRepository.addMessage(chatId, 'assistant', text) + + // Reply with the generated text + await context.reply(text) +}) +``` diff --git a/docs/full-code.md b/docs/full-code.md deleted file mode 100644 index a0055a0..0000000 --- a/docs/full-code.md +++ /dev/null @@ -1,349 +0,0 @@ -# Full code - -!!! failure "Example outdated" - - This code still works but it is not updated to the rest of the tutorial. - -## Registry - -```ts title="src/setup-registry.ts" -import { openai as originalOpenAI } from '@ai-sdk/openai' -import { - experimental_createProviderRegistry as createProviderRegistry, - experimental_customProvider as customProvider, -} from 'ai' -import { ollama as originalOllama } from 'ollama-ai-provider' - -const ollama = customProvider({ - fallbackProvider: originalOllama, - languageModels: { - 'qwen-2_5': originalOllama('qwen2.5'), - }, -}) - -export const openai = customProvider({ - fallbackProvider: originalOpenAI, - languageModels: { - 'gpt-4o-mini': originalOpenAI('gpt-4o-mini', { - structuredOutputs: true, - }), - }, -}) - -export const registry = createProviderRegistry({ - ollama, - openai, -}) -``` - -## Tools - -```ts title="lib/tools.ts" -import type { CoreMessage } from 'ai' -import { Context, InlineKeyboard } from 'grammy' - -import { generateEmbeddings } from './ai/embeddings' -import { db as database } from './db' -import { embeddings as embeddingsTable } from './db/schema/embeddings' -import { - insertResourceSchema, - type NewResourceParameters, - resources, -} from './db/schema/resources' - -export const createResource = async ( - input: NewResourceParameters, -): Promise => { - try { - const { content } = insertResourceSchema.parse(input) - - const [resource] = await database - .insert(resources) - .values({ content }) - .returning() - - if (!resource) { - return 'Resource not found' - } - - const embeddings = await generateEmbeddings(content) - await database.insert(embeddingsTable).values( - embeddings.map((embedding) => ({ - resourceId: resource.id, - ...embedding, - })), - ) - - return 'Resource successfully created and embedded.' - } catch (error) { - return error instanceof Error && error.message.length > 0 - ? error.message - : 'Error, please try again.' - } -} - -export const createConfirmAppointment = (availableTimeSlots: string[]) => { - return async ({ slot }: { slot: string }): Promise => { - console.log( - `Called createConfirmAppointment tool with ${JSON.stringify({ slot }, null, 2)}`, - ) - - const slotIndex = availableTimeSlots.indexOf(slot) - if (slotIndex === -1) { - return 'Sorry, that time slot is no longer available. Please choose another one.' - } - - availableTimeSlots.splice(slotIndex, 1) - - return `Your appointment at ${slot} is confirmed!` - } -} - -export const createDisplaySelectionButtons = ( - context: Context, - conversations: Map, -) => { - return async ({ - options, - question, - }: { - options: string[] - question: string - }): Promise => { - console.log( - `Called createDisplaySelectionButtons tool with ${JSON.stringify({ options, question }, null, 2)}`, - ) - - const buttonsRows = [] - for (let index = 0; index < options.length; index += 2) { - buttonsRows.push( - options - .slice(index, index + 2) - .map((option: string) => ({ data: `slot-${option}`, label: option })) - .map(({ data, label }) => InlineKeyboard.text(label, data)), - ) - } - - await context.reply(question, { - reply_markup: InlineKeyboard.from(buttonsRows), - }) - - const content = `${question}: ${options.join(', ')}` - - const chatId = context?.chat?.id as number - const messages = conversations.get(chatId) ?? [] - messages.push({ content, role: 'assistant' }) - conversations.set(chatId, messages) - - return null - } -} - -export const createFindAvailableSlots = (availableTimeSlots: string[]) => { - return async (): Promise => { - console.log(`Called createFindAvailableSlots tool`) - return availableTimeSlots - } -} -``` - -## Main bot - -```ts title="src/main.ts" -import process from 'node:process' - -import { type CoreMessage, generateText, tool } from 'ai' -import { Bot } from 'grammy' -import { z } from 'zod' - -import { findRelevantContent } from './lib/ai/embeddings' -import { environment } from './lib/environment.mjs' -import { - createConfirmAppointment, - createDisplaySelectionButtons, - createFindAvailableSlots, - createResource, -} from './lib/tools' -import { registry } from './setup-registry' - -const model = environment.MODEL - -const PROMPT = ` -You are a chatbot designed to help users book hair salon appointments for tomorrow. You have access to several tools to -help with this task and are restricted to handling appointment bookings only. You do not handle any other inquiries. -Your primary goal is to guide users through the process of booking their appointments efficiently. - -You will follow these rules: - -1. Appointment Search: Use the findAvailableSlots tool to search for available appointment times for tomorrow. -2. Display Options: Use the displaySelectionButtons tool to ask the user the available time slots as buttons they can select from. -3. Confirm Appointment: After the user selects a time, use the confirmAppointment function to finalize their appointment request. -4. Single-purpose chatbot: You only help users book appointments for tomorrow and do not answer unrelated questions, you need to use tools to ask options. - -Proceed with these steps and maintain a helpful, friendly tone throughout the interaction. Make sure to validate user choices and guide them towards successfully booking an appointment. -` - -const availableTimeSlots: string[] = ['09:00', '10:00', '11:00', '12:00'] -const conversations: Map = new Map() - -async function main(): Promise { - const bot = new Bot(environment.BOT_TOKEN) - - bot.command('start', async (context) => { - const chatId = context.chatId - const content = 'Welcome, how can I help you?' - - const messages: CoreMessage[] = [] - messages.push({ content, role: 'assistant' }) - conversations.set(chatId, messages) - - await context.reply(content) - }) - - bot.command('save', async (context) => { - const content = context.match - - if (!content) { - await context.reply('Not data found') - return - } - - const response = await createResource({ content }) - await context.reply(response) - }) - - bot.command('ask', async (context) => { - const content = context.match - - if (!content) { - await context.reply('No question found') - return - } - - const response = await generateText({ - maxSteps: 3, - messages: [{ content, role: 'user' }], - model: registry.languageModel(model), - system: `You are a helpful assistant acting as the users' second brain. - Use tools on every request. - Be sure to getInformation from your knowledge base before answering any questions. - if no relevant information is found in the tool calls, respond, "Sorry, I don't know." - Be sure to adhere to any instructions in tool calls ie. if they say to responsd like "...", do exactly that. - If the relevant information is not a direct match to the users prompt, you can be creative in deducing the answer. - Keep responses short and concise. Answer in a single sentence where possible. - If you are unsure, use the getInformation tool and you can use common sense to reason based on the information you do have. - Use your abilities as a reasoning machine to answer questions based on the information you do have. - `, - tools: { - getInformation: tool({ - description: `get information from your knowledge base to answer questions.`, - execute: async ({ userQuestion }) => - findRelevantContent(userQuestion), - parameters: z.object({ - similarQuestions: z - .array(z.string()) - .describe('keywords to search'), - userQuestion: z.string().describe('the users question'), - }), - }), - }, - }) - - if (!response.text) { - await context.reply("Sorry, I don't know!") - return - } - - await context.reply(response.text) - }) - - bot.on('message:text', async (context) => { - const chatId = context.chatId - const userMessage = context.message.text - - const messages: CoreMessage[] = conversations.get(chatId) ?? [] - messages.push({ content: userMessage, role: 'user' }) - - await bot.api.sendChatAction(chatId, 'typing') - - const { text } = await generateText({ - maxSteps: 2, - messages, - model: registry.languageModel(model), - system: PROMPT, - tools: { - displaySelectionButtons: { - description: - 'Use this tool to ask to the user with the available time slots as buttons they can select from.', - execute: createDisplaySelectionButtons(context, conversations), - parameters: z.object({ - options: z - .array(z.string()) - .describe('Array of time slots for the user to choose from'), - question: z.string().describe('The question to ask the user'), - }), - }, - findAvailableSlots: { - description: - 'Use this tool to search for available appointment times for tomorrow. Returns an array of time slots.', - execute: createFindAvailableSlots(availableTimeSlots), - parameters: z.object({}), - }, - }, - }) - - if (text) { - messages.push({ content: text, role: 'assistant' }) - await bot.api.sendMessage(chatId, text) - } - - conversations.set(chatId, messages) - }) - - bot.callbackQuery(/slot-.+/, async (context) => { - const chatId = context.chatId as number - const slot = context.callbackQuery.data.slice(5) - - const messages = conversations.get(chatId) ?? [] - messages.push({ content: slot, role: 'user' }) - - await bot.api.sendChatAction(chatId, 'typing') - - const { text } = await generateText({ - maxSteps: 2, - messages, - model: registry.languageModel(model), - system: PROMPT, - tools: { - confirmAppointment: { - description: 'Confirm the selected appointment.', - execute: createConfirmAppointment(availableTimeSlots), - parameters: z.object({ - slot: z - .string() - .describe('The selected time slot to confirm. Format HH:MM'), - }), - }, - }, - }) - - if (text) { - await bot.api.sendMessage(chatId, text) - messages.push({ content: text, role: 'assistant' }) - } - - conversations.set(chatId, messages) - }) - - // Enable graceful stop - process.once('SIGINT', () => bot.stop()) - process.once('SIGTERM', () => bot.stop()) - process.once('SIGUSR2', () => bot.stop()) - - console.log('Bot started.') - await bot.start() -} - -main().catch((error) => console.error(error)) -``` - diff --git a/docs/index.md b/docs/index.md index 2ba4f49..8613490 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,5 @@ # How to create your Chatbots with Telegram and AI Models -!!! warning "Work in progress" - - The next sections are not finished yet: - - - Data Augmentation - - Agents - -
![Aula Software Libre de la UCO](images/logo-cuadrado.svg#only-light) ![Aula Software Libre de la UCO](images/logo-cuadrado-invertido.svg#only-dark) diff --git a/mkdocs.yml b/mkdocs.yml index 407b5a9..71f43a1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -134,7 +134,6 @@ nav: - Retrieval-Augmented Generation: rag/rag.md - Dynamic Data Augmentation with Tools: rag/tools.md - Agents: - - Agents: agents/basic.md - - Full code: full-code.md - - + - Understanding Agents: agents/basic.md + - Displaying Available Time Slots: agents/using-tools.md + - Handling User Selection: agents/using-tools-2.md