Construire un assistant agentique RAG avec JavaScript, Mastra et Elasticsearch
Apprendre à construire des agents d'intelligence artificielle dans l'écosystème JavaScript
Cette idée m'est venue alors que je me trouvais au beau milieu d'une ligue de basket-ball fantastique passionnante et aux enjeux considérables. Je me suis posé la question : Pourrais-je construire un agent IA qui m'aiderait à dominer mes matchs hebdomadaires ? Absolument !
Dans ce billet, nous allons voir comment construire un assistant RAG agentique en utilisant Mastra et une application web JavaScript légère pour interagir avec lui. En connectant cet agent à Elasticsearch, nous lui donnons accès aux données structurées des joueurs et la possibilité d'exécuter des agrégations statistiques en temps réel, afin de vous donner des recommandations fondées sur les statistiques des joueurs. Rendez-vous sur le repo GitHub pour suivre le processus ; le README fournit des instructions sur la manière de cloner et d'exécuter l'application par vos propres moyens.
Voici à quoi il devrait ressembler une fois assemblé :

Remarque : cet article de blog s'appuie sur "Building AI Agents with AI SDK and Elastic" ( Créer des agents d'intelligence artificielle avec AI SDK et Elastic). Si vous ne connaissez pas encore les agents d'intelligence artificielle en général et leur utilité, commencez par là.
Aperçu de l'architecture
Au cœur du système se trouve un grand modèle de langage (LLM), qui agit comme le moteur de raisonnement de l'agent (le cerveau). Il interprète les données de l'utilisateur, décide des outils à appeler et orchestre les étapes nécessaires pour générer une réponse pertinente.
L'agent lui-même est soutenu par Mastra, un cadre d'agent dans l'écosystème JavaScript. Mastra intègre le LLM à une infrastructure dorsale, l'expose en tant que point d'extrémité de l'API et fournit une interface pour définir les outils, les invites du système et le comportement de l'agent.
Sur le frontend, nous utilisons Vite pour mettre en place rapidement une application web React qui fournit une interface de chat pour envoyer des requêtes à l'agent et recevoir ses réponses.
Enfin, nous avons Elasticsearch, qui stocke les statistiques des joueurs et les données de correspondance que l'agent peut interroger et agréger.

Arrière-plan
Passons en revue quelques concepts fondamentaux :
Qu'est-ce que le RAG agentique ?
Les agents d'intelligence artificielle peuvent interagir avec d'autres systèmes, fonctionner de manière indépendante et effectuer des actions en fonction de paramètres définis. Le RAG agentique combine l'autonomie d'un agent d'intelligence artificielle avec les principes de la génération augmentée par récupération, ce qui permet à un LLM de choisir les outils à utiliser et les données à utiliser comme contexte pour générer une réponse. Pour en savoir plus sur le RAG , cliquez ici.
Pourquoi aller plus loin que AI-SDK dans le choix d'un framework ?
Il existe de nombreuses structures d'agents d'IA et vous avez probablement entendu parler des plus populaires comme CrewAI, AutoGen et LangGraph. La plupart de ces cadres partagent un ensemble commun de fonctionnalités, notamment la prise en charge de différents modèles, l'utilisation d'outils et la gestion de la mémoire.
Voici une fiche comparative de Harrison Chase (PDG de LangChain).
Ce qui a suscité mon intérêt pour Mastra, c'est qu'il s'agit d'un framework JavaScript conçu pour les développeurs full-stack afin d'intégrer facilement des agents dans leur écosystème. L'AI-SDK de Vercel permet également de réaliser la plupart de ces tâches, mais c'est lorsque vos projets incluent des flux de travail d'agents plus complexes que Mastra brille. Mastra améliore les modèles de base définis par l'AI-SDK et, dans ce projet, nous les utiliserons en tandem.
Cadres et considérations sur le choix du modèle
Si ces frameworks peuvent vous aider à créer rapidement des agents d'intelligence artificielle, ils présentent néanmoins certains inconvénients. Par exemple, l'utilisation d'autres cadres en dehors des agents d'IA ou de toute couche d'abstraction en général vous fait perdre un peu de contrôle. Si le LLM n'utilise pas les outils correctement ou fait quelque chose que vous ne voulez pas qu'il fasse, l'abstraction rend le débogage plus difficile. Cependant, à mon avis, ce compromis vaut la facilité et la rapidité que vous obtenez lors de la construction, en particulier parce que ces cadres gagnent du terrain et font l'objet d'itérations constantes.
Encore une fois, ces cadres sont agnostiques, ce qui signifie que vous pouvez brancher et utiliser différents modèles. N'oubliez pas que les modèles varient en fonction des ensembles de données sur lesquels ils ont été formés et qu'à leur tour, ils varient en fonction des réponses qu'ils donnent. Certains modèles ne prennent même pas en charge l'appel d'outils. Il est donc possible de changer et de tester différents modèles pour voir lequel vous donne les meilleures réponses, mais gardez à l'esprit que vous devrez probablement réécrire l'invite du système pour chacun d'entre eux. Par exemple, en utilisant Llama3.3 par rapport au GPT-4o, implique beaucoup plus d'invites et d'instructions spécifiques pour obtenir la réponse souhaitée.
Basket-ball fantaisie NBA
Le basket-ball fantaisie consiste à créer une ligue avec un groupe d'amis (attention, selon le degré de compétition de votre groupe, cela peut affecter le statut de vos amitiés), généralement avec de l'argent en jeu. Chacun d'entre vous constitue ensuite une équipe de 10 joueurs pour affronter les 10 joueurs d'un autre ami, en alternance chaque semaine. Les points qui contribuent à votre score global sont les résultats obtenus par chacun de vos joueurs contre leurs adversaires au cours d'une semaine donnée.
Si un joueur de votre équipe se blesse, est suspendu, etc., il y a une liste d'agents libres disponibles pour compléter votre équipe. C'est là qu'intervient une grande partie de la réflexion dans les sports fantastiques, car vous ne disposez que d'un nombre limité de choix et tout le monde est constamment à la recherche du meilleur joueur.
C'est là que notre assistant NBA AI va briller, en particulier dans les situations où vous devez rapidement décider quel joueur choisir. Au lieu de devoir rechercher manuellement les performances d'un joueur contre un adversaire spécifique, l'assistant peut trouver ces données rapidement et comparer les moyennes pour vous donner une recommandation éclairée.
Maintenant que vous connaissez les bases du RAG agentique et du basket-ball fantastique NBA, voyons ce qu'il en est dans la pratique.
Construire le projet
Si vous êtes bloqué à un moment ou à un autre ou si vous ne voulez pas le construire à partir de zéro, veuillez vous référer au repo.
Ce que nous allons couvrir
L'échafaudage du projet :
Backend (Mastra) : Utilisez npx create mastra@latest pour échafauder le backend et définir la logique de l'agent.
Frontend (Vite + React) : Utilisez npm create vite@latest pour construire l'interface de chat frontale pour interagir avec l'agent.
Mise en place de variables d'environnement
Installer dotenv pour gérer les variables d'environnement.
Créer un fichier .env et fournir les variables nécessaires.
Configuration d'Elasticsearch
Mettre en place un cluster Elasticsearch (localement ou sur le cloud).
Installer le client Elasticsearch officiel.
S'assurer que les variables d'environnement sont accessibles.
Établir la connexion avec le client.
Acquisition en masse de données NBA dans Elasticsearch
Créez un index avec les mappings appropriés pour permettre les agrégations.
Intégrez en masse les statistiques de jeu des joueurs à partir d'un fichier CSV dans un index Elasticsearch.
Définir les agrégations Elasticsearch
Requête pour calculer les moyennes historiques contre un adversaire spécifique.
Requête pour calculer les moyennes de la saison contre un adversaire spécifique.
Fichier utilitaire de comparaison des joueurs
Consolidation des fonctions d'aide et des agrégations Elasticsearch.
Construction de l'agent
Ajouter la définition de l'agent et l'invite du système.
Installer les outils zod et define.
Ajout d'une configuration intermédiaire pour gérer CORS.
Intégration de l'interface utilisateur
Utilisation de la fonction useChat de l'AI-SDK pour interagir avec l'agent.
Créer l'interface utilisateur pour tenir des conversations correctement formatées.
Exécution de l'application
Démarrez le backend (serveur Mastra) et le frontend (application React).
Exemples de requêtes et d'utilisation.
Et maintenant ? Rendre l'agent plus intelligent
Ajout de capacités de recherche sémantique pour permettre des recommandations plus pertinentes.
Activer l'interrogation dynamique en déplaçant la logique de recherche vers le serveur Elasticsearch MCP (Model Context Protocol).
Produits requis
Node.js et npm: Le backend et le frontend fonctionnent tous deux sur Node. Assurez-vous d'avoir installé Node 18+ et npm v9+ (qui est fourni avec Node 18+).
Cluster Elasticsearch : Un cluster Elasticsearch actif, soit localement, soit sur le cloud.
Clé API OpenAI: Générez-en une sur la page des clés API du portail des développeurs d'OpenAI.
Structure du projet

Étape 1 : Échafaudage du projet
Tout d'abord, créez le répertoire nba-ai-assistant-js et naviguez à l'intérieur en utilisant :
mkdir nba-ai-assistant-js && cd nba-ai-assistant-jsBackend :
Utilisez l'outil de création Mastra avec la commande :
npx create-mastra@latest2. Vous devriez obtenir quelques invites dans votre terminal, pour la première, nous nommerons le projet backend :

3. Ensuite, nous conserverons la structure par défaut pour le stockage des fichiers Mastra, en saisissant src/.

4. Ensuite, nous choisirons OpenAI comme fournisseur LLM par défaut.

5. Enfin, il vous demandera votre clé API OpenAI. Pour l'instant, nous choisirons d'ignorer l'option et nous la fournirons plus tard dans un fichier .env.

Frontend :
Naviguez à nouveau vers le répertoire racine et exécutez l'outil de création Vite à l'aide de cette commande :
npm create vite@latest frontend -- --template react
Cela devrait créer une application React légère nommée frontend avec un modèle spécifique pour React.
Si tout se passe bien, à l'intérieur de votre répertoire de projet, vous devriez trouver un répertoire backend qui contient le code Mastra et un répertoire frontend avec votre application React.
Étape 2 : Configuration des variables d'environnement
Pour gérer les clés sensibles, nous utiliserons le paquetage
dotenvpour charger nos variables d'environnement à partir du fichier .env. fichier. Naviguez vers le répertoire backend et installezdotenv:
cd backend
npm install dotenv --save2. Dans le répertoire du backend, un fichier example.env est fourni avec les variables appropriées à remplir. Si vous créez le vôtre, veillez à inclure les variables suivantes :
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key_here
# Elasticsearch Configuration
ELASTIC_ENDPOINT=your_elasticsearch_endpoint_here
ELASTIC_API_KEY=your_elasticsearch_api_key_hereNote : Assurez-vous que ce fichier est exclu de votre contrôle de version en ajoutant .env à .gitignore.
Étape 3 : Configuration d'Elasticsearch
Tout d'abord, vous devez disposer d'un cluster Elasticsearch actif. Deux options sont possibles :
Option A : utiliser Elasticsearch Cloud
S'inscrire à Elastic Cloud
Créer un nouveau déploiement
Obtenez l'URL de votre point de terminaison et la clé API (encodée)
Option B : Exécuter Elasticsearch localement
Installer et exécuter Elasticsearch localement
Utilisez http://localhost:9200 comme point d'arrivée
Générer une clé API
Installation du client Elasticsearch sur le backend :
Tout d'abord, installez le client Elasticsearch officiel dans votre répertoire backend :
npm install @elastic/elasticsearch2. Créez ensuite un répertoire lib pour contenir les fonctions réutilisables et naviguez-y :
mkdir lib && cd lib3. À l'intérieur, créez un nouveau fichier appelé elasticClient.js. Ce fichier initialise le client Elasticsearch et l'expose pour qu'il soit utilisé dans votre projet.
4. Comme nous utilisons des modules ECMAScript (ESM), le nom de fichier __dirname and __n'est pas disponible. Pour vous assurer que vos variables d'environnement sont correctement chargées à partir du fichier .env dans le dossier backend, ajoutez cette configuration au début de votre fichier :
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { Client } from '@elastic/elasticsearch';
// Grab current directory and load .env from backend folder
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '../.env');
// Load environment variables from the correct path
config({ path: envPath });5. Maintenant, initialisez le client Elasticsearch en utilisant vos variables d'environnement et vérifiez la connexion :
//Elastic client Initialization, make sure environment variables are being loaded in correctly
const config= {
node: `${process.env.ELASTIC_ENDPOINT}`,
auth: {
apiKey: `${process.env.ELASTIC_API_KEY}`,
},
};
export const elasticClient = new Client(config);
//Check if the client is connected
async function checkConnection() {
try {
const info = await elasticClient.info();
console.log('Elasticsearch is connected:', info);
} catch (error) {
console.error('Elasticsearch connection error:', error);
}
}
checkConnection();Maintenant, nous pouvons importer cette instance client dans n'importe quel fichier qui doit interagir avec votre cluster Elasticsearch.
Étape 4 : Intégration en masse des données NBA dans Elasticsearch
Ensemble de données :
Pour ce projet, nous ferons référence aux ensembles de données disponibles dans le répertoire backend/data de la base de données. Notre assistant NBA utilisera ces données comme base de connaissances pour effectuer des comparaisons statistiques et générer des recommandations.
sample_player_game_stats.csv - Exemple de statistiques de jeu d'un joueur (par exemple, points, rebonds, interceptions, etc.) par match et par joueur sur l'ensemble de sa carrière en NBA. Nous utiliserons cet ensemble de données pour effectuer des agrégations. (Remarque : il s'agit de données fictives, générées à des fins de démonstration et ne provenant pas de sources officielles de la NBA).
playerAndTeamInfo.js - Remplace les métadonnées sur les joueurs et les équipes qui seraient normalement fournies par un appel à l'API afin que l'agent puisse faire correspondre les noms des joueurs et des équipes aux identifiants. Comme nous utilisons des données d'échantillon, nous ne voulons pas nous encombrer d'une API externe, c'est pourquoi nous avons codé en dur certaines valeurs auxquelles l'agent peut se référer.
Mise en œuvre :
Dans le répertoire
backend/lib, créez un fichier nommé playerDataIngestion.js.Configurer les importations, résoudre le chemin du fichier CSV et configurer l'analyse. Là encore, puisque nous utilisons ESM, nous devons reconstruire
__dirnamepour résoudre le chemin d'accès à l'échantillon CSV. Nous importerons également le module Node.js les modules intégrés,fsetreadline, pour analyser le fichier CSV donné ligne par ligne.
import fs from 'fs';
import readline from 'readline';
import path from 'path';
import { fileURLToPath } from 'url';
import { elasticClient } from './elasticClient.js';
const indexName = 'sample-nba-player-data'; //Replace with your preferred index name
//Since we are using ES modules __dirname and __filename don't exist, so this is a workaround that allows us to use the absolute file path for our sample data.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const filePath = path.resolve(__dirname, '../data/sample_nba_data.csv');Cela vous permet de lire et d'analyser efficacement le fichier CSV lorsque nous passons à l'étape de l'ingestion en masse.
3. Créez un index avec la correspondance appropriée. Bien qu'Elasticsearch puisse déduire automatiquement les types de champs avec le mappage dynamique, nous voulons être explicites ici pour que chaque statut soit traité comme un champ numérique. Ceci est important car nous utiliserons ces champs pour les agrégations par la suite. Nous voulons également utiliser le type float pour les statistiques telles que les points, les rebonds, etc., afin de nous assurer que nous incluons des valeurs décimales. Enfin, nous voulons ajouter la propriété de mappage dynamic: 'strict' afin qu'Elasticsearch ne mappe pas dynamiquement les champs non reconnus.
// Function to create an index with mappings
async function createIndex() {
try {
// Check if the index already exists
const exists = await elasticClient.indices.exists({ index: indexName });
if (exists) {
console.log(`Index "${indexName}" already exists, deleting it now.`);
await elasticClient.indices.delete({ index: indexName });
console.log(`Deleted index "${indexName}".`);
}
// Create the index with mappings
const response = await elasticClient.indices.create({
index: indexName,
body: {
mappings: {
dynamic: 'strict', // Prevent dynamic mapping
properties: {
game_id: { type: 'integer' },
game_date: { type: 'date' },
player_id: { type: 'integer' },
player_full_name: { type: 'text' },
player_team_id: { type: 'integer' },
player_team_name: { type: 'text' },
home_team: { type: 'boolean' },
opponent_team_id: { type: 'integer' },
opponent_team_name: { type: 'text' },
points: { type: 'float' },
rebounds: { type: 'float' },
assists: { type: 'float' },
steals: { type: 'float' },
blocks: { type: 'float' },
fg_percentage: { type: 'float' },
minutes_played: { type: 'float' },
},
},
},
});
console.log('Index created:', response);
return true;
} catch (error) {
console.error('Error creating index:', error);
return false;
}
}4. Ajoutez la fonction permettant d'intégrer en masse les données CSV dans votre index Elasticsearch. À l'intérieur du bloc de code, nous sautons la ligne d'en-tête. Ensuite, divisez chaque ligne par une virgule et insérez-les dans l'objet document. Cette étape permet également de les nettoyer et de s'assurer qu'ils sont du bon type. Ensuite, nous plaçons les documents dans le tableau bulkBody avec les informations d'index, qui serviront de charge utile pour l'ingestion en masse dans Elasticsearch.
async function bulkIngestCsv(filePath) {
const readStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: readStream,
crlfDelay: Infinity,
});
const bulkBody = [];
let lineNum = 0;
//Skip the header line
let headerLine = true;
for await (const line of rl) {
if (headerLine) {
headerLine = false;
continue;
}
lineNum++;
// Split the line by comma and remove whitespace
const [
game_id,
game_date,
player_id,
player_full_name,
player_team_id,
player_team_name,
home_team,
opponent_team_id,
opponent_team_name,
points,
rebounds,
assists,
steals,
blocks,
fg_percentage,
minutes_played,
] = line.split(',');
// Create a document object
const document = {
game_id: parseInt(game_id),
game_date: game_date.trim(),
player_id: parseInt(player_id),
player_full_name: player_full_name.trim(),
player_team_id: parseInt(player_team_id),
player_team_name: player_team_name.trim(),
home_team: home_team.trim() === 'True', // Converts True/False into a boolean
opponent_team_id: parseInt(opponent_team_id),
opponent_team_name: opponent_team_name.trim(),
points: parseFloat(points),
rebounds: parseFloat(rebounds),
assists: parseFloat(assists),
steals: parseFloat(steals),
blocks: parseFloat(blocks),
fg_percentage: parseFloat(fg_percentage),
minutes_played: parseFloat(minutes_played),
};
// Prepare the bulk operation format
bulkBody.push({ index: { _index: indexName } });
bulkBody.push(document);
}
console.log(`Parsed ${lineNum} lines from CSV`);5. Ensuite, nous pouvons utiliser l'API Bulk d'Elasticsearch avec elasticClient.bulk() pour ingérer plusieurs documents en une seule demande. La gestion des erreurs ci-dessous est structurée de manière à vous indiquer le nombre de documents qui n'ont pas été ingérés et le nombre de documents qui ont été ingérés avec succès.
try {
// Perform the bulk request
const response = await elasticClient.bulk({ body: bulkBody });
if (response.errors) {
console.log('Bulk Ingestion had some hiccups:');
// Count successful vs failed operations
let successCount = 0;
let errorCount = 0;
const errorDetails = [];
response.items.forEach((item, index) => {
const operation = item.index || item.create || item.update || item.delete;
if (operation.error) {
errorCount++;
errorDetails.push({
document: index + 1,
error: operation.error,
});
} else {
successCount++;
}
});
console.log(`Successfully indexed: ${successCount} documents`);
console.log(`Failed to index: ${errorCount} documents, here are the details`, errorDetails);
} else {
console.log(`Bulk Ingestion fully successful!`);
}
} catch (error) {
console.error('Error performing bulk ingestion:', error);
}
}6. Exécutez la fonction main() ci-dessous pour exécuter séquentiellement les fonctions createIndex() et bulkIngestCsv().
// Run this function
async function main() {
const result = await createIndex();
if (!result) {
console.error('Index setup failed. Aborting.');
return;
}
await bulkIngestCsv(filePath);
console.log('Bulk ingestion completed!');
}
main();Si vous voyez un journal de console indiquant que l'ingestion en masse a réussi, effectuez une vérification rapide de votre index Elasticsearch pour voir si les documents ont effectivement été ingérés avec succès.
Étape 5 : Définition des agrégations Elasticsearch et consolidation
Ce sont les principales fonctions qui seront utilisées lorsque nous définirons les outils de l'agent IA afin de comparer les statistiques des joueurs entre eux.
1. Naviguez jusqu'au répertoire backend/lib et créez un fichier appelé elasticAggs.js.
2. Ajoutez la requête ci-dessous pour calculer les moyennes historiques d'un joueur contre un adversaire spécifique. Cette requête utilise un filtre bool avec 2 conditions : l'une correspondant à player_id et l'autre à opponent_team_id, afin de récupérer uniquement les jeux pertinents. Nous n'avons pas besoin de renvoyer de documents, nous ne nous intéressons qu'aux agrégations, c'est pourquoi nous définissons size:0. Sous le bloc aggs, nous exécutons plusieurs agrégations métriques en parallèle sur des champs tels que points, rebounds, assists, steals, blocks et fg_percentage pour calculer leurs valeurs moyennes. Les LLM peuvent être aléatoires dans leurs calculs et ce processus est déchargé sur Elasticsearch, ce qui garantit à notre assistant NBA AI l'accès à des données exactes.
export async function getHistoricalAveragesAgainstOpponent(player_id, opponent_team_id) {
try {
//Query for Historical Averages
const historicalQuery = await elasticClient.search({
index: 'sample-nba-player-data',
size: 0,
query: {
bool: {
must: [
{
term: {
player_id: {
value: player_id,
},
},
},
{
term: {
opponent_team_id: {
value: opponent_team_id,
},
},
},
],
},
},
aggs: {
avg_points: { avg: { field: 'points' } },
avg_rebounds: { avg: { field: 'rebounds' } },
avg_assists: { avg: { field: 'assists' } },
avg_steals: { avg: { field: 'steals' } },
avg_blocks: { avg: { field: 'blocks' } },
avg_fg_percentage: { avg: { field: 'fg_percentage' } },
},
});
return {
points: historicalQuery.aggregations.avg_points.value || 0,
rebounds: historicalQuery.aggregations.avg_rebounds.value || 0,
assists: historicalQuery.aggregations.avg_assists.value || 0,
steals: historicalQuery.aggregations.avg_steals.value || 0,
blocks: historicalQuery.aggregations.avg_blocks.value || 0,
fgPercentage: historicalQuery.aggregations.avg_fg_percentage.value || 0,
};
} catch (error) {
console.error('Query error from getHistoricalAveragesAgainstOpponent function:', error);
return { error: 'Queries failed in getting historical averages against opponent.' };
}
}3. Pour calculer les moyennes saisonnières d'un joueur contre un adversaire spécifique, nous utiliserons pratiquement la même requête que la requête historique. La seule différence dans cette requête est que le filtre bool est assorti d'une condition supplémentaire pour game_date. Le champ game_date doit se situer dans la fourchette de la saison NBA en cours. Dans ce cas, la fourchette est comprise entre 2024-10-01 et 2025-06-30. Cette condition supplémentaire ci-dessous garantit que les agrégations qui suivent n'isoleront que les matchs de cette saison.
{
range: {
//Range for this season, change to match current season
game_date: {
gte: '2024-10-01',
lte: '2025-06-30',
},
},Étape 6 : Utilitaire de comparaison des joueurs
Pour que notre code reste modulaire et facile à maintenir, nous allons créer un fichier utilitaire qui consolide les fonctions d'aide aux métadonnées et les agrégations Elasticsearch. Il s'agit de l'outil principal utilisé par l'agent. Nous y reviendrons plus tard :
1. Créez un nouveau fichier comparePlayers.js dans le répertoire backend/lib.
2. Ajoutez la fonction ci-dessous pour consolider les aides aux métadonnées et la logique d'agrégation Elasticsearch en une seule fonction qui alimente l'outil principal utilisé par l'agent.
import { playersByName } from '../data/playerAndTeamInfo.js';
import { teamsByName } from '../data/playerAndTeamInfo.js';
import { upcomingMatchups } from '../data/playerAndTeamInfo.js';
import { getHistoricalAveragesAgainstOpponent } from './elasticAggs.js';
import { getSeasonAveragesAgainstOpponent } from './elasticAggs.js';
//Simple helper functions to simulate API calls for player and team metadata. These reference the hardcoded values from playerAndTeamInfo.js in the data directory
export function getPlayerInfo(playerFullName) {
return playersByName[playerFullName];
}
export function getTeamID(teamFullName) {
return teamsByName[teamFullName];
}
export function getUpcomingMatchups(teamId) {
return upcomingMatchups[teamId];
}
//Main function used by the 'playerComparisonTool' agent tool
export async function comparePlayersForNextMatchup(player1Name, player2Name) {
//Get Player Info
const player1Info = getPlayerInfo(player1Name);
const player2Info = getPlayerInfo(player2Name);
//Get upcoming matchups
const player1NextGame = getUpcomingMatchups(player1Info.team_id)[0];
const player2NextGame = getUpcomingMatchups(player2Info.team_id)[0];
//Get season and historical averages against next opponent for player 1
const player1SeasonAverages = await getSeasonAveragesAgainstOpponent(
player1Info.player_id,
player1NextGame.opponent_team_id
);
const player1HistoricalAverages = await getHistoricalAveragesAgainstOpponent(
player1Info.player_id,
player1NextGame.opponent_team_id
);
//Get season and historical averages against next opponent for player 2
const player2SeasonAverages = await getSeasonAveragesAgainstOpponent(
player2Info.player_id,
player2NextGame.opponent_team_id
);
const player2HistoricalAverages = await getHistoricalAveragesAgainstOpponent(
player2Info.player_id,
player2NextGame.opponent_team_id
);
const player1 = {
name: player1Name,
playerId: player1Info.player_id,
teamId: player1Info.team_id,
nextOpponent: {
teamId: player1NextGame.opponent_team_id,
teamName: player1NextGame.opponent_team_name,
home: player1NextGame.home,
},
stats: {
seasonAverages: player1SeasonAverages,
historicalAverages: player1HistoricalAverages,
},
};
const player2 = {
name: player2Name,
playerId: player2Info.player_id,
teamId: player2Info.team_id,
nextOpponent: {
teamId: player2NextGame.opponent_team_id,
teamName: player2NextGame.opponent_team_name,
home: player2NextGame.home,
},
stats: {
seasonAverages: player2SeasonAverages,
historicalAverages: player2HistoricalAverages,
},
};
return [player1, player2];
}Étape 7 : Création de l'agent
Maintenant que vous avez créé les échafaudages frontend et backend, ingéré les données du jeu NBA et établi une connexion à Elasticsearch, nous pouvons commencer à assembler toutes les pièces pour construire l'agent.
Définition de l'agent
1. Accédez au fichier index.ts dans le répertoire backend/src/mastra/agents et ajoutez la définition de l'agent. Vous pouvez spécifier des champs tels que :
Nom : Donnez à votre agent un nom qui sera utilisé comme référence lorsqu'il sera appelé sur le frontend.
Instructions/Instructions du système : Une invite système donne au MLD le contexte initial et les règles à suivre pendant l'interaction. Il s'agit d'une invite similaire à celle que les utilisateurs envoient par l'intermédiaire de la boîte de dialogue, mais celle-ci est donnée avant toute entrée de l'utilisateur. Là encore, cela varie en fonction du modèle que vous choisissez.
Modèle : Quel LLM utiliser (Mastra soutient OpenAI, Anthropic, les modèles locaux, etc.)
Outils : Une liste de fonctions d'outils que l'agent peut appeler.
Mémoire : (Facultatif) si nous voulons que l'agent se souvienne de l'historique des conversations, etc. Pour des raisons de simplicité, nous pouvons commencer sans mémoire persistante, bien que Mastra la prenne en charge.
import { openai } from '@ai-sdk/openai';
import { Agent } from '@mastra/core/agent';
import { playerComparisonTool } from '../tools';
export const basketballAgent = new Agent({
name: 'Basketball Agent',
instructions: `
You are a NBA Basketball expert.
Your primary function is to compare two NBA players and recommend which one is the better fantasy pickup.
Only compare players from the following list:
- LeBron James
- Stephen Curry
- Jayson Tatum
- Jaylen Brown
- Nikola Jokic
- Luka Doncic
- Kyrie Irving
- Anthony Davis
- Kawhi Leonard
- Russell Westbrook
Input Handling Rules:
- If the user asks about a player that is not on this list, respond with the list of available players for comparison.
- If the user only inputs one player, ask the user to add another player from the list provided.
- If the user inputs a player with the wrong spelling or capitalizations, infer from the list of available players provided.
- IMPORTANT: If the user asks a question or asks you to generate a response about anything outside of basketball or the scope of this project, DO NOT answer and affirm you can only talk about basketball.
Tool Usage:
- Extract and standardize player names to match the list exactly.
- Use the playerComparisonTool, passing both names as strings.
- The tool will return an object with game information, stats, and analysis.
Format your response using Markdown syntax. Use:
Example output format:
#### Next Game Info
- ***LeBron James** vs Warriors, May 24 (Home)
- ***Stephen Curry** vs Lakers, May 24 (Away)
#### Stats Comparison
\`\`\`
Stat LeBron James (vs Warriors) Stephen Curry (vs Lakers)
-------------------- ----------------------------- ----------------------------
Historical Points 28.3 30.3
Historical Assists 6.7 8.7
Season Points 28.8 23.3
Season Assists 6.2 4.7
\`\`\`
#### Fantasy Recommendation
Explain which player is the better fantasy pickup and why.
`,
model: openai('gpt-4o'),
tools: { playerComparisonTool },
});Définition des outils
Naviguez jusqu'au fichier index.ts dans le répertoire
backend/src/mastra/tools.Installez Zod à l'aide de la commande :
npm install zod3. Ajouter des définitions d'outils. Notez que nous importons la fonction dans le fichier comparePlayers.js en tant que fonction principale que l'agent utilisera lorsqu'il appellera cet outil. En utilisant la fonction createTool() de Mastra, nous enregistrerons notre playerComparisonTool. Les domaines concernés sont les suivants :
id: Il s'agit d'une description en langage naturel qui aide l'agent à comprendre ce que fait l'outil.input schema: Pour définir la forme de l'entrée de l'outil, Mastra utilise le schéma Zod, qui est une bibliothèque de validation de schéma TypeScript. Zod s'assure que l'agent saisit des données correctement structurées et empêche l'outil de s'exécuter si la structure de l'entrée ne correspond pas.description: Il s'agit d'une description en langage naturel qui aide l'agent à comprendre quand il doit appeler et utiliser l'outil.execute: La logique qui s'exécute lorsque l'outil est appelé. Dans notre cas, nous utilisons une fonction d'aide importée pour renvoyer des statistiques de performance.
import { comparePlayersForNextMatchup } from '../../../lib/comparePlayers.js'
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const playerComparisonTool = createTool({
id: "Compare two NBA players",
inputSchema: z.object({
player1:z.string(),
player2:z.string()
}),
description: "Use this tool to compare two players given in the user prompt.",
execute: async ({ context: { player1, player2 } }) => {
return await comparePlayersForNextMatchup(player1, player2);
},
})Ajout d'un logiciel intermédiaire pour gérer CORS
Ajouter un middleware dans le serveur Mastra pour gérer CORS. On dit qu'il y a trois choses dans la vie qu'on ne peut pas éviter : la mort, les impôts, et pour les développeurs web, c'est CORS. En bref, le partage des ressources inter-origines est une fonction de sécurité du navigateur qui empêche le front-end d'envoyer des requêtes à un back-end fonctionnant sur un domaine ou un port différent. Même si nous exécutons le backend et le frontend sur localhost, ils utilisent des ports différents, ce qui déclenche la politique CORS. Nous devons ajouter l'intergiciel spécifié dans la documentation de Mastra afin que notre backend autorise ces requêtes depuis le frontend.
1. Naviguez jusqu'au fichier index.ts dans le répertoire backend/src/mastra et ajoutez la configuration pour CORS :
origin: ['http://localhost:5173']Autorise les demandes provenant uniquement de cette adresse (adresse par défaut de Vite)
allowMethods: ["GET", "POST"]Méthodes HTTP autorisées. La plupart du temps, il utilisera POST.
allowHeaders: ["Content-Type", "Authorization", "x-mastra-client-type, "x-highlight-request", "traceparent"],Ils déterminent quels en-têtes personnalisés peuvent être utilisés dans les requêtes
import { Mastra } from '@mastra/core/mastra';
import { basketballAgent } from './agents';
console.log('Starting Mastra server...');
export const mastra = new Mastra({
agents: { basketballAgent },
server:{
timeout: 10 * 60 * 1000, // 10 minutes
cors: {
origin: ['http://localhost:5173'],
allowMethods: ["GET", "POST"],
allowHeaders: [
"Content-Type",
"Authorization",
"x-mastra-client-type",
"x-highlight-request",
"traceparent",
],
exposeHeaders: ["Content-Length", "X-Requested-With"],
credentials: false,
},
},
});
console.log('Mastra server configured.'); // Log after server configurationÉtape 8 : Intégration de l'interface utilisateur
Ce composant React fournit une interface de chat simple qui se connecte à l'agent IA Mastra en utilisant le hook useChat() de @ai-sdk/react. Nous allons également utiliser ce crochet pour afficher l'utilisation des jetons, les appels d'outils et pour rendre la conversation. Dans l'invite système ci-dessus, nous demandons également à l'agent de produire la réponse en format markdown, nous utiliserons donc react-markdown pour formater correctement la réponse.
1. Dans le répertoire frontend, installez le paquetage @ai-sdk/react pour utiliser le hook useChat().
npm install @ai-sdk/react2. Dans le même répertoire, installez React Markdown pour que nous puissions formater correctement la réponse générée par l'agent.
npm install react-markdown3. Mettre en œuvre useChat(). Ce hook va gérer l'interaction entre votre frontend et votre agent IA backend. Il gère l'état des messages, les entrées de l'utilisateur, l'état et vous donne des crochets de cycle de vie à des fins d'observabilité. Les options que nous transmettons sont les suivantes :
api:Ceci définit le point final de votre agent Mastra AI. Le port par défaut est le port 4111 et nous voulons également ajouter la route qui prend en charge les réponses en continu.onToolCall: Cette fonction s'exécute chaque fois que l'agent appelle un outil ; nous l'utilisons pour savoir quels outils notre agent appelle.onFinish: Cette opération s'exécute après que l'agent a fourni une réponse complète. Même si nous avons activé le streaming,onFinishsera toujours exécuté après la réception du message complet et non après chaque morceau. Ici, nous l'utilisons pour suivre l'utilisation de nos jetons. Cela peut s'avérer utile pour contrôler et optimiser les coûts de la gestion du cycle d'apprentissage tout au long de la vie.
4. Enfin, nous nous rendons au composant ChatUI.jsx dans le répertoire frontend/components pour créer l'interface utilisateur de notre conversation. Ensuite, la réponse est enveloppée dans un composant ReactMarkdown afin de formater correctement la réponse de l'agent.
import React, { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import ReactMarkdown from 'react-markdown';
export default function ChatUI() {
const [totalTokenUsage, setTotalTokenUsage] = useState(0);
const [promptTokenUsage, setPromptTokenUsage] = useState(0);
const [completionTokenUsage, setCompletionTokenUsage] = useState(0);
const [toolsCalled, setToolsCalled] = useState([]);
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
api: 'http://localhost:4111/api/agents/basketballAgent/stream', //Replace with your own endpoint for your agent
id: 'my-chat-session',
//Optional parameter to check agent tool calls
onToolCall: ({ toolCall }) => {
setToolsCalled((prev) => [...prev, toolCall.toolName]);
},
//Optional parameter to check token usages
onFinish: (message, { usage }) => {
setTotalTokenUsage((prev) => prev + usage.totalTokens);
setPromptTokenUsage((prev) => prev + usage.promptTokens);
setCompletionTokenUsage((prev) => prev + usage.completionTokens);
},
//Optional parameter for error handling
onError: (error) => {
console.error('Agent error:', error);
},
});
return (
<div>
<div className="agent-info">
<h4 className="stats-title">What's My Agent Doing?</h4>
<div className="stats-box">
<strong className="stats-sub-title">Tools Called:</strong>
<ul className="tool-list">
{toolsCalled.map((tool, idx) => (
<li key={idx}>{tool}</li>
))}
{toolsCalled.length === 0 && <li>No tools called yet.</li>}
</ul>
<div className="usage-stats">
<p>Prompt Token Usage: {promptTokenUsage}</p>
<p>Completion Token Usage: {completionTokenUsage}</p>
<p>Total Token Usage: {totalTokenUsage}</p>
</div>
</div>
</div>
<strong>Conversation:</strong>
<div className="convo-box">
{messages.map((msg) => (
<div key={msg.id} className="message-item">
<strong className="message-role">{msg.role === 'assistant' ? 'Basketbot' : 'You'}:</strong>
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
))}
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={handleInputChange}
placeholder="Input two players you want to compare."
className="input-box"
/>
<button type="submit" disabled={status === 'streaming'}>
{status === 'streaming' ? 'Thinking...' : 'Send'}
</button>
</form>
</div>
);
}Étape 9 : Exécution de l'application
Félicitations ! Vous êtes maintenant prêt à exécuter l'application. Suivez ces étapes pour démarrer le backend et le frontend.
Dans une fenêtre de terminal, à partir du répertoire racine, naviguez jusqu'au répertoire backend et démarrez le serveur Mastra :
cd backend
npm run dev2. Dans une autre fenêtre de terminal, à partir du répertoire racine, naviguez jusqu'au répertoire frontend et démarrez l'application React :
cd frontend
npm run dev3. Allez dans votre navigateur et naviguez jusqu'à :
Vous devriez voir l'interface de chat. Essayez les exemples suivants :
"Comparer LeBron James et Stephen Curry"
"Qui choisir entre Jayson Tatum et Luka Doncic ?"
Et maintenant ? Rendre l'agent plus intelligent
Pour rendre l'assistant plus agentive et les recommandations plus perspicaces, j'ajouterai quelques améliorations clés dans la prochaine itération.
Recherche sémantique pour les nouvelles de la NBA
Il y a une tonne de facteurs qui peuvent affecter les performances des joueurs, dont beaucoup n'apparaissent pas dans les statistiques brutes. Des choses comme les rapports sur les blessures, les changements de composition, ou même une analyse d'après-match, vous ne pouvez les trouver que dans des articles de presse. Pour saisir ce contexte supplémentaire, j'ajouterai des capacités de recherche sémantique afin que l'agent puisse retrouver des articles pertinents de la NBA et tenir compte de ce récit dans ses recommandations.
Recherche dynamique avec le serveur Elasticsearch MCP
Le protocole MCP (Model Context Protocol) devient rapidement la norme pour la connexion des agents aux sources de données. Je vais migrer la logique de recherche dans le serveur Elasticsearch MCP, qui permet à l'agent de construire dynamiquement des requêtes plutôt que de s'appuyer sur les fonctions de recherche prédéfinies que nous fournissons. Cela nous permet d'utiliser davantage de flux de travail en langage naturel et de réduire la nécessité de rédiger manuellement chaque requête de recherche. Pour en savoir plus sur le serveur Elasticsearch MCP et l'état actuel de l'écosystème , cliquez ici.
Ces changements sont déjà en cours, restez à l'écoute !
Conclusion
Dans ce blog, nous avons construit un assistant RAG agentique qui fournit des recommandations personnalisées pour votre équipe de basket-ball fantasy en utilisant JavaScript, Mastra et Elasticsearch. Nous avons couvert :
Les principes fondamentaux de la RAG agentique et la manière dont la combinaison de l'autonomie d'un agent d'intelligence artificielle avec les outils permettant d'utiliser efficacement la RAG peut déboucher sur des agents plus nuancés et plus dynamiques.
Elasticsearch et comment ses capacités de stockage de données et ses puissantes agrégations natives en font un partenaire idéal en tant que base de connaissances pour un LLM.
Le cadre Mastra et la manière dont il simplifie la construction de ces agents pour les développeurs de l'écosystème JavaScript.
Que vous soyez fanatique de basket-ball, que vous cherchiez à construire des agents d'intelligence artificielle, ou les deux comme moi, j'espère que ce blog vous a donné quelques éléments de base pour commencer. Le repo complet est disponible sur GitHub, n'hésitez pas à le cloner et à le modifier. Maintenant, allez gagner cette ligue de fantasy !




