From 3bf88f8d935e80113dba6864233e6fb4f8c943ca Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 27 May 2026 13:02:32 -0700 Subject: [PATCH 01/12] Add Node.js agent templates and unlock JavaScript in agent builder Add conversational and recommendation agent templates using node-redis v4 with Redis Search for vector-based message history and movie indexing. Conversational agent uses hybrid recent+semantic context retrieval, runtime embedding dimension validation, and a clear note on Redis version requirements. Recommendation agent parses genres from MovieLens CSV format into Redis TAGs, validates LLM query params against an explicit allowlist, skips dataset reload if the index is already warm, and filters genres in Redis rather than JS. Fix template URL to use root-relative path so local templates load in dev. Update agent builder to support JavaScript alongside Python. Co-Authored-By: Claude Sonnet 4.6 --- content/develop/ai/agent-builder/_index.md | 2 +- .../javascript/conversational_agent.js | 251 ++++++++++++ .../javascript/recommendation_agent.js | 363 ++++++++++++++++++ static/js/agent-builder.js | 14 +- 4 files changed, 622 insertions(+), 8 deletions(-) create mode 100644 static/code/agent-templates/javascript/conversational_agent.js create mode 100644 static/code/agent-templates/javascript/recommendation_agent.js diff --git a/content/develop/ai/agent-builder/_index.md b/content/develop/ai/agent-builder/_index.md index 8b27ce90fa..87bfea9d7e 100644 --- a/content/develop/ai/agent-builder/_index.md +++ b/content/develop/ai/agent-builder/_index.md @@ -39,7 +39,7 @@ The agent builder will generate complete, working code examples for your chosen ## Features -- **Multiple programming languages**: Generate code in Python, with JavaScript (Node.js), Java, and C# coming soon +- **Multiple programming languages**: Generate code in Python and JavaScript (Node.js), with Java and C# coming soon - **LLM integration**: Support for OpenAI, Anthropic Claude, and Llama 2 - **Redis optimized**: Uses Redis data structures for optimal performance diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js new file mode 100644 index 0000000000..daaf314426 --- /dev/null +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -0,0 +1,251 @@ +/* + * Redis Conversational Agent (Node.js) + * Uses node-redis with Redis Search for semantic message history + * + * Requires Redis Stack 6.2+ or Redis 8 with the Search module for JSON + * vector indexing. The vector field is stored as a JSON array of floats, + * which is the correct on-disk format for JSON-backed vector indexes. + * + * To run this code: + * Install dependencies: + * npm install redis openai dotenv + * + * Set environment variables: + * LLM_API_KEY=your_${formData.llmModel.toLowerCase()}_api_key + * LLM_API_BASE_URL=your_base_url (optional, default: ${CONFIG.models[formData.llmModel].baseUrl}) + * LLM_MODEL=your_model_name (optional, default: ${CONFIG.models[formData.llmModel].defaultModel}) + * EMBEDDING_MODEL=your_embed_model (optional, default: text-embedding-3-small) + * VECTOR_DIM=1536 (optional, must match your embedding model's output dimension) + * REDIS_URL=redis://localhost:6379 + * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately) + */ + +require('dotenv').config(); +const { createClient } = require('redis'); +const OpenAI = require('openai'); + +const INDEX_NAME = 'message_history_idx'; +const MESSAGE_PREFIX = 'message:'; +const RECENT_KEY = (session) => `recent:${session}`; +const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'text-embedding-3-small'; +const VECTOR_DIM = parseInt(process.env.VECTOR_DIM) || 1536; +const RECENT_WINDOW = 6; // always include this many recent turns in context +const SEMANTIC_TOP_K = 4; // additional turns retrieved by semantic similarity +const MAX_CONTENT_CHARS = 2000; + +class ConversationalAgent { + constructor(sessionName = 'chat') { + this.sessionName = sessionName; + this.messageCount = 0; + this._dimValidated = false; + + this.llmApiKey = process.env.LLM_API_KEY; + if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + + this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}'; + this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; + + this.openai = new OpenAI({ apiKey: this.llmApiKey, baseURL: this.llmBaseUrl }); + this.redisClient = null; + } + + async connect() { + const clientOptions = process.env.REDIS_URL + ? { url: process.env.REDIS_URL } + : { + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + }, + password: process.env.REDIS_PASSWORD || undefined, + username: process.env.REDIS_USERNAME || 'default', + }; + + this.redisClient = createClient(clientOptions); + this.redisClient.on('error', (err) => console.error('Redis error:', err)); + await this.redisClient.connect(); + console.log('Connected to Redis successfully'); + + await this._ensureIndex(); + console.log('LLM configured:', this.llmModel); + console.log('Embedding model:', EMBEDDING_MODEL, `(VECTOR_DIM=${VECTOR_DIM})`); + } + + async _ensureIndex() { + try { + await this.redisClient.ft.info(INDEX_NAME); + } catch { + await this.redisClient.ft.create( + INDEX_NAME, + { + '$.role': { type: 'TAG', AS: 'role' }, + '$.content': { type: 'TEXT', AS: 'content' }, + '$.session': { type: 'TAG', AS: 'session' }, + '$.embedding': { + type: 'VECTOR', + AS: 'embedding', + ALGORITHM: 'FLAT', + TYPE: 'FLOAT32', + DIM: VECTOR_DIM, + DISTANCE_METRIC: 'COSINE', + }, + }, + { ON: 'JSON', PREFIX: MESSAGE_PREFIX } + ); + console.log('Created search index:', INDEX_NAME); + } + } + + async _embed(text) { + const response = await this.openai.embeddings.create({ + model: EMBEDDING_MODEL, + input: text, + }); + const embedding = response.data[0].embedding; + + // Validate dimension on first call. If this throws, either set VECTOR_DIM + // to the correct value in your environment, or recreate the index. + if (!this._dimValidated) { + if (embedding.length !== VECTOR_DIM) { + throw new Error( + `Embedding model '${EMBEDDING_MODEL}' returned ${embedding.length} dimensions ` + + `but VECTOR_DIM is ${VECTOR_DIM}. ` + + `Set VECTOR_DIM=${embedding.length} and recreate the index.` + ); + } + this._dimValidated = true; + } + + return embedding; // plain JS number array + } + + _toQueryBuffer(embedding) { + return Buffer.from(new Float32Array(embedding).buffer); + } + + async _storeMessage(role, content) { + const truncated = content.slice(0, MAX_CONTENT_CHARS); + const embedding = await this._embed(truncated); + const key = `${MESSAGE_PREFIX}${this.sessionName}:${Date.now()}_${this.messageCount++}`; + + await this.redisClient.json.set(key, '$', { + role, + content: truncated, + session: this.sessionName, + embedding, // stored as JSON array of floats, required for JSON vector index + }); + + // Track insertion order for recent-turn retrieval + await this.redisClient.rPush(RECENT_KEY(this.sessionName), key); + await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 4, -1); + } + + async _getRecentMessages() { + const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, -1); + if (!keys.length) return []; + const docs = await this.redisClient.json.mGet(keys, '$'); + return docs + .filter(Boolean) + .flatMap((d) => d) + .filter(Boolean) + .map((m) => ({ role: m.role, content: m.content, _key: m._key })); + } + + async _getSemanticMessages(query) { + const queryBuffer = this._toQueryBuffer(await this._embed(query)); + const results = await this.redisClient.ft.search( + INDEX_NAME, + `(@session:{${this.sessionName}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, + { + PARAMS: { vec: queryBuffer }, + RETURN: ['role', 'content', '__key'], + SORTBY: { BY: 'score', DIRECTION: 'ASC' }, + DIALECT: 2, + } + ); + return results.documents.map((doc) => ({ + role: doc.value.role, + content: doc.value.content, + _key: doc.id, + })); + } + + async _buildContext(userInput) { + // Hybrid: recent turns for conversational coherence + semantic search for deeper context. + const [recent, semantic] = await Promise.all([ + this._getRecentMessages().catch(() => []), + this._getSemanticMessages(userInput).catch(() => []), + ]); + + // Deduplicate by key, preserving recent turns first + const seen = new Set(recent.map((m) => m._key)); + const extra = semantic.filter((m) => !seen.has(m._key)); + + return [...recent, ...extra].map(({ role, content }) => ({ role, content })); + } + + async chat(userInput) { + const context = await this._buildContext(userInput); + + const messages = [ + { + role: 'system', + content: 'You are a helpful assistant that answers questions based on the conversation history.', + }, + ...context, + { role: 'user', content: userInput }, + ]; + + const response = await this.openai.chat.completions.create({ + model: this.llmModel, + messages, + }); + + const assistantResponse = response.choices[0]?.message?.content; + if (!assistantResponse) throw new Error('Empty response from LLM'); + + await this._storeMessage('user', userInput); + await this._storeMessage('assistant', assistantResponse); + + return assistantResponse; + } + + async disconnect() { + if (this.redisClient) await this.redisClient.disconnect(); + } +} + +async function main() { + const agent = new ConversationalAgent(); + try { + await agent.connect(); + console.log(await agent.chat('Tell me about yourself.')); + } catch (err) { + console.error('Failed to initialize agent:', err.message); + await agent.disconnect(); + process.exit(1); + } + + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + const askQuestion = () => { + rl.question('Enter a prompt: ', async (input) => { + if (['quit', 'exit', 'bye'].includes(input.toLowerCase())) { + console.log('Goodbye!'); + rl.close(); + await agent.disconnect(); + return; + } + try { + console.log(await agent.chat(input)); + } catch (err) { + console.error('Error:', err.message); + } + askQuestion(); + }); + }; + askQuestion(); +} + +main(); diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js new file mode 100644 index 0000000000..fdc6eb5824 --- /dev/null +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -0,0 +1,363 @@ +/* + * Redis Recommendation Engine (Node.js) + * Uses node-redis with Redis Search for movie recommendations + * + * To run this code: + * Install dependencies: + * npm install redis openai dotenv csv-parse + * + * Set environment variables: + * LLM_API_KEY=your_${formData.llmModel.toLowerCase()}_api_key + * LLM_API_BASE_URL=your_base_url (optional, default: ${CONFIG.models[formData.llmModel].baseUrl}) + * LLM_MODEL=your_model_name (optional, default: ${CONFIG.models[formData.llmModel].defaultModel}) + * REDIS_URL=redis://localhost:6379 + * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately) + * + * Download datasets: + * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/ratings_small.csv + * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/movies_metadata.csv + * Place them in datasets/collaborative_filtering/ relative to this file. + */ + +require('dotenv').config(); +const { createClient } = require('redis'); +const OpenAI = require('openai'); +const { parse } = require('csv-parse/sync'); +const fs = require('fs'); +const path = require('path'); + +const INDEX_NAME = 'movies_idx'; +const MOVIE_PREFIX = 'movie:'; + +const CONFIG = { + maxResults: 10, + defaultResults: 5, + minRevenueFilter: 30_000_000, + validSortFields: new Set(['popularityScore', 'avgRating', 'ratingCount', 'revenue']), + validSortOrders: new Set(['DESC', 'ASC']), +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse genres from movies_metadata.csv. + * The field is stored as a Python-style list of dicts, e.g.: + * "[{'id': 28, 'name': 'Action'}, {'id': 12, 'name': 'Adventure'}]" + * Returns a comma-separated string for use as a Redis TAG field. + */ +function parseGenres(raw) { + if (!raw || raw === '[]') return ''; + try { + const json = raw.replace(/'/g, '"').replace(/None/g, 'null').replace(/True/g, 'true').replace(/False/g, 'false'); + const parsed = JSON.parse(json); + return parsed.map((g) => g?.name).filter(Boolean).join(','); + } catch { + console.warn('parseGenres: could not parse genres field, storing empty string. Raw value:', raw?.slice(0, 80)); + return ''; + } +} + +function safeNumber(value, fallback = 0) { + const n = Number(value); + return isFinite(n) ? n : fallback; +} + +/** + * Validate and sanitize LLM-returned query params. + * Rejects any field that doesn't match expected types or allowed values. + */ +function validateQueryParams(raw) { + return { + genres: Array.isArray(raw?.genres) + ? raw.genres.filter((g) => typeof g === 'string' && g.trim()) + : null, + minRating: typeof raw?.minRating === 'number' && raw.minRating >= 0 && raw.minRating <= 10 + ? raw.minRating + : null, + minReviews: typeof raw?.minReviews === 'number' && raw.minReviews > 0 + ? Math.floor(raw.minReviews) + : null, + maxResults: typeof raw?.maxResults === 'number' + ? Math.min(Math.max(1, Math.floor(raw.maxResults)), CONFIG.maxResults) + : CONFIG.defaultResults, + sortBy: CONFIG.validSortFields.has(raw?.sortBy) + ? raw.sortBy + : 'popularityScore', + sortOrder: CONFIG.validSortOrders.has(raw?.sortOrder?.toUpperCase?.()) + ? raw.sortOrder.toUpperCase() + : 'DESC', + revenueFilter: raw?.revenueFilter === true, + }; +} + +// --------------------------------------------------------------------------- +// Agent class +// --------------------------------------------------------------------------- + +class RecommendationAgent { + constructor() { + this.llmApiKey = process.env.LLM_API_KEY; + if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + + this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}'; + this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; + + this.openai = new OpenAI({ apiKey: this.llmApiKey, baseURL: this.llmBaseUrl }); + this.redisClient = null; + this.indexReady = false; + } + + async connect() { + const clientOptions = process.env.REDIS_URL + ? { url: process.env.REDIS_URL } + : { + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + }, + password: process.env.REDIS_PASSWORD || undefined, + username: process.env.REDIS_USERNAME || 'default', + }; + + this.redisClient = createClient(clientOptions); + this.redisClient.on('error', (err) => console.error('Redis error:', err)); + await this.redisClient.connect(); + console.log('Connected to Redis successfully'); + console.log('LLM configured:', this.llmModel); + + await this._setupMovieIndex(); + } + + async _indexExists() { + try { + const info = await this.redisClient.ft.info(INDEX_NAME); + return parseInt(info.numDocs) > 0; + } catch { + return false; + } + } + + async _setupMovieIndex() { + // Skip loading if the index already exists and has documents. + if (await this._indexExists()) { + console.log('Movie index already loaded, skipping dataset import.'); + this.indexReady = true; + return; + } + + const ratingsFile = path.join('datasets', 'collaborative_filtering', 'ratings_small.csv'); + const moviesFile = path.join('datasets', 'collaborative_filtering', 'movies_metadata.csv'); + + if (!fs.existsSync(ratingsFile) || !fs.existsSync(moviesFile)) { + console.warn('Movie datasets not found. Skipping index setup.'); + console.warn(` Expected: ${ratingsFile}`); + console.warn(` Expected: ${moviesFile}`); + return; + } + + console.log('Loading movie datasets...'); + + let ratings, movies; + try { + ratings = parse(fs.readFileSync(ratingsFile), { columns: true, cast: true }); + movies = parse(fs.readFileSync(moviesFile), { columns: true, cast: true }); + } catch (err) { + console.error('Failed to parse dataset files:', err.message); + return; + } + + if (!ratings.length || !movies.length) { + console.error('One or more dataset files are empty.'); + return; + } + + // Aggregate ratings per movie + const stats = {}; + for (const r of ratings) { + if (!r.movieId || !isFinite(r.rating)) continue; + if (!stats[r.movieId]) stats[r.movieId] = { count: 0, total: 0 }; + stats[r.movieId].count++; + stats[r.movieId].total += r.rating; + } + + // Merge metadata with aggregated stats + const merged = movies + .filter((m) => m.id && stats[String(m.id)]) + .map((m) => { + const s = stats[String(m.id)]; + const avgRating = s.total / s.count; + return { + movieId: String(m.id), + title: String(m.title || '').trim(), + genres: parseGenres(m.genres), // comma-separated TAG string + revenue: safeNumber(m.revenue), + ratingCount: s.count, + avgRating: Math.round(avgRating * 100) / 100, + popularityScore: Math.round(s.count * avgRating * 100) / 100, + }; + }) + .filter((m) => m.title); + + if (!merged.length) { + console.error('No valid movies found after merging datasets.'); + return; + } + + console.log(`Processed ${merged.length} movies`); + + // Drop existing index if present, ignoring "index not found" errors only. + try { + await this.redisClient.ft.dropIndex(INDEX_NAME); + } catch (err) { + if (!err.message?.includes('Unknown Index name')) throw err; + } + + await this.redisClient.ft.create( + INDEX_NAME, + { + '$.movieId': { type: 'TAG', AS: 'movieId' }, + '$.title': { type: 'TEXT', AS: 'title' }, + '$.genres': { type: 'TAG', AS: 'genres', SEPARATOR: ',' }, + '$.revenue': { type: 'NUMERIC', AS: 'revenue' }, + '$.ratingCount': { type: 'NUMERIC', AS: 'ratingCount' }, + '$.avgRating': { type: 'NUMERIC', AS: 'avgRating' }, + '$.popularityScore': { type: 'NUMERIC', AS: 'popularityScore' }, + }, + { ON: 'JSON', PREFIX: MOVIE_PREFIX } + ); + + // Load using a pipeline for efficiency + const pipeline = this.redisClient.multi(); + for (const movie of merged) { + pipeline.json.set(`${MOVIE_PREFIX}${movie.movieId}`, '$', movie); + } + await pipeline.exec(); + + this.indexReady = true; + console.log('Movie recommendation system initialized successfully!'); + } + + async _parseUserQuery(userQuery) { + const systemPrompt = `You are a movie recommendation assistant. Parse the user's query and return a JSON object with: +- "genres": array of genre name strings or null +- "minRating": minimum average rating (0-10) or null +- "minReviews": minimum review count or null +- "maxResults": number of results (default 5, max 10) +- "sortBy": one of "popularityScore", "avgRating", "ratingCount", "revenue" +- "sortOrder": "DESC" or "ASC" +- "revenueFilter": true for blockbusters, null otherwise + +Return only valid JSON with no explanation or markdown.`; + + try { + const response = await this.openai.chat.completions.create({ + model: this.llmModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userQuery }, + ], + temperature: 0.1, + }); + + const raw = JSON.parse(response.choices[0]?.message?.content || '{}'); + return validateQueryParams(raw); // always returns a safe, validated object + } catch { + return validateQueryParams({}); // safe defaults on any failure + } + } + + async recommendMovies(userQuery) { + if (!this.indexReady) { + return 'The movie database is not available. Please check that the dataset files are present.'; + } + + const params = await this._parseUserQuery(userQuery); + + // Build filter parts — genres now filtered in Redis via TAG, not in JavaScript + const filterParts = []; + if (params.minRating) filterParts.push(`@avgRating:[${params.minRating} +inf]`); + if (params.minReviews) filterParts.push(`@ratingCount:[${params.minReviews} +inf]`); + if (params.revenueFilter) filterParts.push(`@revenue:[${CONFIG.minRevenueFilter} +inf]`); + if (params.genres?.length) { + // Redis TAG filter: match any of the requested genres + const tagList = params.genres.map((g) => g.replace(/[^a-zA-Z0-9 ]/g, '')).join('|'); + if (tagList) filterParts.push(`@genres:{${tagList}}`); + } + + const filterQuery = filterParts.length > 0 ? filterParts.join(' ') : '*'; + + let results; + try { + results = await this.redisClient.ft.search(INDEX_NAME, filterQuery, { + RETURN: ['title', 'genres', 'ratingCount', 'avgRating', 'popularityScore'], + SORTBY: { BY: params.sortBy, DIRECTION: params.sortOrder }, + LIMIT: { from: 0, size: params.maxResults }, + }); + } catch (err) { + console.error('Search error:', err.message); + return 'Sorry, there was an error searching the movie database.'; + } + + const movies = results?.documents?.map((d) => d.value).filter(Boolean) ?? []; + + if (!movies.length) { + return "Sorry, no movies found matching your criteria. Try adjusting your preferences."; + } + + let response = `Based on your request '${userQuery}', here are my recommendations:\n\n`; + movies.forEach((m, i) => { + response += `${i + 1}. ${m.title}\n`; + response += ` Genres: ${m.genres || 'N/A'}\n`; + response += ` Average Rating: ${parseFloat(m.avgRating || 0).toFixed(1)}/10 (${m.ratingCount || 0} reviews)\n`; + response += ` Popularity Score: ${parseFloat(m.popularityScore || 0).toFixed(1)}\n\n`; + }); + return response; + } + + async disconnect() { + if (this.redisClient) await this.redisClient.disconnect(); + } +} + +async function main() { + const agent = new RecommendationAgent(); + try { + await agent.connect(); + } catch (err) { + console.error('Failed to initialize agent:', err.message); + await agent.disconnect(); + process.exit(1); + } + + console.log('\nWelcome to the Redis Movie Recommendation Agent!'); + console.log("Ask for movie recommendations. Type 'quit' to exit.\n"); + console.log("Here's a quick demo:"); + console.log(await agent.recommendMovies('Show me some popular movies')); + + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + const askQuestion = () => { + rl.question('\nWhat kind of movies are you looking for? ', async (input) => { + if (['quit', 'exit', 'bye'].includes(input.toLowerCase())) { + console.log('Goodbye!'); + rl.close(); + await agent.disconnect(); + return; + } + if (input.trim()) { + try { + console.log('\n' + await agent.recommendMovies(input)); + } catch (err) { + console.error('Error:', err.message); + } + } + askQuestion(); + }); + }; + askQuestion(); +} + +main(); diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index 91f37ebee3..098a404687 100644 --- a/static/js/agent-builder.js +++ b/static/js/agent-builder.js @@ -426,8 +426,8 @@ } if (selectedLang) { - // Check if it's Python (fully supported) - if (selectedLang === 'python') { + // Check if it's a fully supported language + if (selectedLang === 'python' || selectedLang === 'javascript') { conversationState.selections.programmingLanguage = selectedLang; const config = CONFIG.languages[selectedLang]; @@ -445,9 +445,10 @@ const config = CONFIG.languages[selectedLang]; const languageName = config.name; - addMessage(`${languageName} support is coming soon. Currently, only Python is fully supported.`, 'bot'); - addMessage(`Would you like to build a Python agent instead?`, 'bot', [ - { value: 'python', label: 'Yes, use Python' }, + addMessage(`${languageName} support is coming soon. Currently, Python and JavaScript (Node.js) are fully supported.`, 'bot'); + addMessage(`Would you like to build an agent in a supported language instead?`, 'bot', [ + { value: 'python', label: 'Use Python' }, + { value: 'javascript', label: 'Use JavaScript (Node.js)' }, { value: 'wait', label: 'I\'ll wait for ' + languageName } ]); } @@ -520,8 +521,7 @@ java: '.java', csharp: '.cs' }; - const base = window.HUGO_BASEURL || ''; - const filename = `${base}code/agent-templates/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`; + const filename = `/code/agent-templates/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`; return loadTemplateFile(filename, formData) || genericTemplates[formData.programmingLanguage](formData); } From 0d0e1e2039bbe92fa470059ef71c1b761d914ba7 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Tue, 9 Jun 2026 12:56:33 -0700 Subject: [PATCH 02/12] Fix PR review feedback on Node.js agent templates and builder - Fix deduplication bug: _getRecentMessages now zips Redis keys with json.mGet results so m._key is the actual key, not undefined - Fix recent-window arithmetic: lTrim and lRange now use RECENT_WINDOW * 2 (user + assistant per turn) instead of * 4 - Fix ft.dropIndex error-string match: replaced brittle catch with ft.info existence check to handle Redis 8 error wording - Fix num_docs field name: ft.info returns num_docs not numDocs, so _indexExists was always returning false and reloading data on every start - Fix Llama3 requiring LLM_API_KEY: default to 'no-key-needed' instead of throwing so local Ollama users don't need a dummy value - Hide Anthropic from JS model selector: JS templates use the OpenAI SDK which is not compatible with api.anthropic.com - Fix Jupyter button: always disabled (feature not yet available) - Fix generic JS fallback: use LLM_API_KEY and node-redis v4 socket shape Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 17 ++-- .../javascript/recommendation_agent.js | 15 ++-- static/js/agent-builder.js | 86 ++++++++++--------- 3 files changed, 58 insertions(+), 60 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index daaf314426..853f50bd7d 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -39,8 +39,8 @@ class ConversationalAgent { this.messageCount = 0; this._dimValidated = false; - this.llmApiKey = process.env.LLM_API_KEY; - if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + // For local providers (e.g. Ollama), any non-empty string works. For hosted providers, use your real key. + this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed'; this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}'; this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; @@ -137,18 +137,17 @@ class ConversationalAgent { // Track insertion order for recent-turn retrieval await this.redisClient.rPush(RECENT_KEY(this.sessionName), key); - await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 4, -1); + await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 2, -1); } async _getRecentMessages() { - const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, -1); + const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), -(RECENT_WINDOW * 2), -1); if (!keys.length) return []; const docs = await this.redisClient.json.mGet(keys, '$'); - return docs - .filter(Boolean) - .flatMap((d) => d) - .filter(Boolean) - .map((m) => ({ role: m.role, content: m.content, _key: m._key })); + return keys + .map((key, i) => ({ key, doc: docs[i]?.[0] })) + .filter(({ doc }) => doc != null) + .map(({ key, doc }) => ({ role: doc.role, content: doc.content, _key: key })); } async _getSemanticMessages(query) { diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index fdc6eb5824..3771399b50 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -98,8 +98,8 @@ function validateQueryParams(raw) { class RecommendationAgent { constructor() { - this.llmApiKey = process.env.LLM_API_KEY; - if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + // For local providers (e.g. Ollama), any non-empty string works. For hosted providers, use your real key. + this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed'; this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}'; this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; @@ -133,7 +133,7 @@ class RecommendationAgent { async _indexExists() { try { const info = await this.redisClient.ft.info(INDEX_NAME); - return parseInt(info.numDocs) > 0; + return parseInt(info.num_docs) > 0; } catch { return false; } @@ -207,12 +207,9 @@ class RecommendationAgent { console.log(`Processed ${merged.length} movies`); - // Drop existing index if present, ignoring "index not found" errors only. - try { - await this.redisClient.ft.dropIndex(INDEX_NAME); - } catch (err) { - if (!err.message?.includes('Unknown Index name')) throw err; - } + // Drop existing index if present. + const indexExists = await this.redisClient.ft.info(INDEX_NAME).then(() => true).catch(() => false); + if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME); await this.redisClient.ft.create( INDEX_NAME, diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index 098a404687..962382c7e0 100644 --- a/static/js/agent-builder.js +++ b/static/js/agent-builder.js @@ -308,16 +308,21 @@ ); break; - case 'model': - suggestions = Object.entries(CONFIG.models).map(([key, config]) => ({ - value: key, - label: config.name, - icon: '🤖' - })).filter(s => - s.label.toLowerCase().includes(lowerInput) || - CONFIG.models[s.value].keywords.some(k => k.includes(lowerInput)) - ); + case 'model': { + const allowedModels = getModelChips(conversationState.selections.programmingLanguage).map(m => m.value); + suggestions = Object.entries(CONFIG.models) + .filter(([key]) => allowedModels.includes(key)) + .map(([key, config]) => ({ + value: key, + label: config.name, + icon: '🤖' + })) + .filter(s => + s.label.toLowerCase().includes(lowerInput) || + CONFIG.models[s.value].keywords.some(k => k.includes(lowerInput)) + ); break; + } } return suggestions.slice(0, 5); // Limit to 5 suggestions @@ -405,6 +410,19 @@ + function getModelChips(language) { + const all = [ + { value: 'openai', label: '🤖 OpenAI (GPT-4)' }, + { value: 'anthropic', label: '🧠 Anthropic (Claude)' }, + { value: 'llama3', label: '🦙 Llama 3' } + ]; + // Anthropic's API is not OpenAI-compatible; JS templates use the OpenAI SDK + if (language === 'javascript') { + return all.filter(m => m.value !== 'anthropic'); + } + return all; + } + function processLanguageSelection(input) { let selectedLang = null; @@ -435,11 +453,7 @@ // Move to next step conversationState.step = 'model'; - addMessage('Finally, which AI model would you like to use?', 'bot', [ - { value: 'openai', label: '🤖 OpenAI (GPT-4)' }, - { value: 'anthropic', label: '🧠 Anthropic (Claude)' }, - { value: 'llama3', label: '🦙 Llama 3' } - ]); + addMessage('Finally, which AI model would you like to use?', 'bot', getModelChips(selectedLang)); } else { // Handle other languages with coming soon message const config = CONFIG.languages[selectedLang]; @@ -488,11 +502,8 @@ generateAndDisplayCode(); }, 1500); } else { - addMessage("I didn't recognize that model. Please choose from:", 'bot', [ - { value: 'openai', label: '🤖 OpenAI (GPT-4)' }, - { value: 'anthropic', label: '🧠 Anthropic (Claude)' }, - { value: 'llama3', label: '🦙 Llama 3' } - ]); + addMessage("I didn't recognize that model. Please choose from:", 'bot', + getModelChips(conversationState.selections.programmingLanguage)); } } @@ -606,10 +617,13 @@ require('dotenv').config(); class ${formData.agentName.replace(/\s+/g, '')} { constructor() { this.redisClient = redis.createClient({ - host: process.env.REDIS_HOST || 'localhost', - port: process.env.REDIS_PORT || 6379 + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + }, + password: process.env.REDIS_PASSWORD, }); - this.llmApiKey = process.env.${formData.llmModel.toUpperCase()}_API_KEY; + this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed'; } async processQuery(query) { @@ -743,28 +757,16 @@ public class ${formData.agentName.replace(/\s+/g, '')} elements.codeSection.dataset.code = code; elements.codeSection.dataset.filename = getFilename(formData); - // Handle Jupyter button state based on selected model + // Jupyter notebook support is not yet available; keep the button disabled const tryJupyterBtn = document.getElementById('try-jupyter-btn'); if (tryJupyterBtn) { - if (formData.llmModel !== 'openai') { - // Disable and grey out the button for non-OpenAI models - tryJupyterBtn.disabled = true; - tryJupyterBtn.style.backgroundColor = '#B8B8B8'; - tryJupyterBtn.style.color = '#4B4F58'; - tryJupyterBtn.style.borderColor = '#B8B8B8'; - tryJupyterBtn.style.cursor = 'not-allowed'; - tryJupyterBtn.style.opacity = '1'; - tryJupyterBtn.title = 'Coming soon'; - } else { - // Enable the button for OpenAI models - tryJupyterBtn.disabled = false; - tryJupyterBtn.style.backgroundColor = ''; - tryJupyterBtn.style.color = ''; - tryJupyterBtn.style.borderColor = ''; - tryJupyterBtn.style.cursor = 'pointer'; - tryJupyterBtn.style.opacity = '1'; - tryJupyterBtn.title = 'Try your agent in a Jupyter notebook'; - } + tryJupyterBtn.disabled = true; + tryJupyterBtn.style.backgroundColor = '#B8B8B8'; + tryJupyterBtn.style.color = '#4B4F58'; + tryJupyterBtn.style.borderColor = '#B8B8B8'; + tryJupyterBtn.style.cursor = 'not-allowed'; + tryJupyterBtn.style.opacity = '1'; + tryJupyterBtn.title = 'Coming soon'; } // Attach event listeners to code action buttons now that they're visible From 92ed78b0435f58ebbc46560ff999ff15ff4f9e98 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Tue, 9 Jun 2026 14:13:12 -0700 Subject: [PATCH 03/12] Fix additional PR review feedback on Node.js agent templates - Fix multi-word genre TAG queries: escape spaces as \\ so 'Science Fiction' matches as a single token rather than two separate terms in RediSearch - Fix reindex leaving stale data: use DD flag on ft.dropIndex to delete movie documents along with the index on reload - Fix Anthropic bypass: processModelSelection now checks allowedModels so typing 'anthropic' or 'claude' while on JavaScript is rejected with a clear message rather than generating broken code - Fix context messages out of order: sort combined recent + semantic results by key (which encodes timestamp) before passing to the LLM - Fix trimmed messages never deleted: evict and delete JSON documents for keys that will fall off the recent window before each lTrim call Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 17 ++++++++++++++--- .../javascript/recommendation_agent.js | 13 +++++++++---- static/js/agent-builder.js | 6 +++++- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index 853f50bd7d..8fd9054cbb 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -135,7 +135,15 @@ class ConversationalAgent { embedding, // stored as JSON array of floats, required for JSON vector index }); - // Track insertion order for recent-turn retrieval + // Track insertion order for recent-turn retrieval. + // Before trimming, collect any keys that will be evicted and delete their documents + // so message JSON and embeddings don't accumulate in Redis indefinitely. + const listLen = await this.redisClient.lLen(RECENT_KEY(this.sessionName)); + const evictCount = listLen - (RECENT_WINDOW * 2 - 1); // -1 because we haven't pushed yet + if (evictCount > 0) { + const toEvict = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, evictCount - 1); + if (toEvict.length) await this.redisClient.del(toEvict); + } await this.redisClient.rPush(RECENT_KEY(this.sessionName), key); await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 2, -1); } @@ -176,11 +184,14 @@ class ConversationalAgent { this._getSemanticMessages(userInput).catch(() => []), ]); - // Deduplicate by key, preserving recent turns first + // Deduplicate by key, then sort chronologically — keys encode timestamp so + // lexicographic order preserves insertion time across both result sets. const seen = new Set(recent.map((m) => m._key)); const extra = semantic.filter((m) => !seen.has(m._key)); - return [...recent, ...extra].map(({ role, content }) => ({ role, content })); + return [...recent, ...extra] + .sort((a, b) => (a._key < b._key ? -1 : a._key > b._key ? 1 : 0)) + .map(({ role, content }) => ({ role, content })); } async chat(userInput) { diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index 3771399b50..6fa09499e9 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -207,9 +207,9 @@ class RecommendationAgent { console.log(`Processed ${merged.length} movies`); - // Drop existing index if present. + // Drop existing index and its documents so stale movie keys don't survive the reload. const indexExists = await this.redisClient.ft.info(INDEX_NAME).then(() => true).catch(() => false); - if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME); + if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME, { DD: true }); await this.redisClient.ft.create( INDEX_NAME, @@ -278,8 +278,13 @@ Return only valid JSON with no explanation or markdown.`; if (params.minReviews) filterParts.push(`@ratingCount:[${params.minReviews} +inf]`); if (params.revenueFilter) filterParts.push(`@revenue:[${CONFIG.minRevenueFilter} +inf]`); if (params.genres?.length) { - // Redis TAG filter: match any of the requested genres - const tagList = params.genres.map((g) => g.replace(/[^a-zA-Z0-9 ]/g, '')).join('|'); + // Redis TAG filter: match any of the requested genres. + // Spaces inside multi-word tags (e.g. "Science Fiction") must be backslash-escaped + // so RediSearch treats them as a single token rather than two separate terms. + const tagList = params.genres + .map((g) => g.replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/ +/g, '\\ ')) + .filter(Boolean) + .join('|'); if (tagList) filterParts.push(`@genres:{${tagList}}`); } diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index 962382c7e0..fcc7950b2c 100644 --- a/static/js/agent-builder.js +++ b/static/js/agent-builder.js @@ -490,7 +490,11 @@ } } - if (selectedModel) { + const allowedModels = getModelChips(conversationState.selections.programmingLanguage).map(m => m.value); + if (selectedModel && !allowedModels.includes(selectedModel)) { + addMessage("Anthropic isn't supported for JavaScript — its API isn't OpenAI-compatible. Please choose from:", 'bot', + getModelChips(conversationState.selections.programmingLanguage)); + } else if (selectedModel) { conversationState.selections.llmModel = selectedModel; const config = CONFIG.models[selectedModel]; From ec2044f27ed366e4ebc14611f023429a230dc12b Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Tue, 9 Jun 2026 14:33:41 -0700 Subject: [PATCH 04/12] Fix genre filter, zero minRating, and Llama embedding provider - Fix hyphenated genres stripped from TAG query: allow hyphens through the sanitizer and backslash-escape them alongside spaces so Film-Noir matches the stored value instead of becoming FilmNoir - Fix zero minRating silently dropped: use != null instead of truthy check so a minimum rating of 0 is included in the filter query - Fix Llama/Ollama breaking semantic history: add separate embedder client (EMBEDDING_API_KEY / EMBEDDING_API_BASE_URL) that defaults to the LLM values so Ollama users just set EMBEDDING_MODEL=nomic-embed-text with no extra config, matching the pattern used in the RAG templates Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 21 ++++++++++++++++--- .../javascript/recommendation_agent.js | 13 +++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index 8fd9054cbb..a4283b7905 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -14,10 +14,15 @@ * LLM_API_KEY=your_${formData.llmModel.toLowerCase()}_api_key * LLM_API_BASE_URL=your_base_url (optional, default: ${CONFIG.models[formData.llmModel].baseUrl}) * LLM_MODEL=your_model_name (optional, default: ${CONFIG.models[formData.llmModel].defaultModel}) - * EMBEDDING_MODEL=your_embed_model (optional, default: text-embedding-3-small) - * VECTOR_DIM=1536 (optional, must match your embedding model's output dimension) * REDIS_URL=redis://localhost:6379 * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately) + * + * Embeddings use a separate client so you can mix providers: + * EMBEDDING_API_KEY=your_key (optional - defaults to LLM_API_KEY) + * EMBEDDING_API_BASE_URL=your_url (optional - defaults to LLM_API_BASE_URL) + * EMBEDDING_MODEL=your_embed_model (optional, default: text-embedding-3-small; + * for Ollama use nomic-embed-text) + * VECTOR_DIM=1536 (optional, must match your embedding model's output dimension) */ require('dotenv').config(); @@ -46,6 +51,16 @@ class ConversationalAgent { this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; this.openai = new OpenAI({ apiKey: this.llmApiKey, baseURL: this.llmBaseUrl }); + + // Embeddings can use a different provider than chat completions. + // For Ollama users: set EMBEDDING_MODEL=nomic-embed-text (no extra keys needed). + // For Anthropic users: set EMBEDDING_API_KEY and EMBEDDING_API_BASE_URL to an + // OpenAI-compatible embedding endpoint (e.g. OpenAI or Ollama). + this.embedder = new OpenAI({ + apiKey: process.env.EMBEDDING_API_KEY || this.llmApiKey, + baseURL: process.env.EMBEDDING_API_BASE_URL || this.llmBaseUrl, + }); + this.redisClient = null; } @@ -97,7 +112,7 @@ class ConversationalAgent { } async _embed(text) { - const response = await this.openai.embeddings.create({ + const response = await this.embedder.embeddings.create({ model: EMBEDDING_MODEL, input: text, }); diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index 6fa09499e9..f96f905231 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -274,15 +274,18 @@ Return only valid JSON with no explanation or markdown.`; // Build filter parts — genres now filtered in Redis via TAG, not in JavaScript const filterParts = []; - if (params.minRating) filterParts.push(`@avgRating:[${params.minRating} +inf]`); - if (params.minReviews) filterParts.push(`@ratingCount:[${params.minReviews} +inf]`); + if (params.minRating != null) filterParts.push(`@avgRating:[${params.minRating} +inf]`); + if (params.minReviews != null) filterParts.push(`@ratingCount:[${params.minReviews} +inf]`); if (params.revenueFilter) filterParts.push(`@revenue:[${CONFIG.minRevenueFilter} +inf]`); if (params.genres?.length) { // Redis TAG filter: match any of the requested genres. - // Spaces inside multi-word tags (e.g. "Science Fiction") must be backslash-escaped - // so RediSearch treats them as a single token rather than two separate terms. + // Hyphens (e.g. "Film-Noir") and spaces (e.g. "Science Fiction") are stored intact + // at ingest, so they must be preserved and backslash-escaped in the query rather + // than stripped, otherwise hyphenated and multi-word genres never match. const tagList = params.genres - .map((g) => g.replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/ +/g, '\\ ')) + .map((g) => g.replace(/[^a-zA-Z0-9 \-]/g, '').trim() + .replace(/-/g, '\\-') + .replace(/ +/g, '\\ ')) .filter(Boolean) .join('|'); if (tagList) filterParts.push(`@genres:{${tagList}}`); From 35759794e956dbfa7d21277009ef15d4ba6bfa9d Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 17 Jun 2026 13:28:06 -0700 Subject: [PATCH 05/12] Fix rating scale mismatch: MovieLens uses 0-5 stars not 0-10 LLM prompt, validation range, and output label all said 0-10 but MovieLens avgRating is a 0-5 star scale. minRating values above 5 produced filters that matched nothing. Co-Authored-By: Claude Sonnet 4.6 --- .../code/agent-templates/javascript/recommendation_agent.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index f96f905231..cb026d0390 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -73,7 +73,7 @@ function validateQueryParams(raw) { genres: Array.isArray(raw?.genres) ? raw.genres.filter((g) => typeof g === 'string' && g.trim()) : null, - minRating: typeof raw?.minRating === 'number' && raw.minRating >= 0 && raw.minRating <= 10 + minRating: typeof raw?.minRating === 'number' && raw.minRating >= 0 && raw.minRating <= 5 ? raw.minRating : null, minReviews: typeof raw?.minReviews === 'number' && raw.minReviews > 0 @@ -239,7 +239,7 @@ class RecommendationAgent { async _parseUserQuery(userQuery) { const systemPrompt = `You are a movie recommendation assistant. Parse the user's query and return a JSON object with: - "genres": array of genre name strings or null -- "minRating": minimum average rating (0-10) or null +- "minRating": minimum average rating (0-5 star scale) or null - "minReviews": minimum review count or null - "maxResults": number of results (default 5, max 10) - "sortBy": one of "popularityScore", "avgRating", "ratingCount", "revenue" @@ -315,7 +315,7 @@ Return only valid JSON with no explanation or markdown.`; movies.forEach((m, i) => { response += `${i + 1}. ${m.title}\n`; response += ` Genres: ${m.genres || 'N/A'}\n`; - response += ` Average Rating: ${parseFloat(m.avgRating || 0).toFixed(1)}/10 (${m.ratingCount || 0} reviews)\n`; + response += ` Average Rating: ${parseFloat(m.avgRating || 0).toFixed(1)}/5 (${m.ratingCount || 0} reviews)\n`; response += ` Popularity Score: ${parseFloat(m.popularityScore || 0).toFixed(1)}\n\n`; }); return response; From f3e85cd11a765f9ac433f32e89d12b6cac26d43f Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 17 Jun 2026 13:44:09 -0700 Subject: [PATCH 06/12] Fix template fetch ignoring site base path Hardcoded /code/agent-templates/ breaks deployments under a subdirectory (e.g. staging). Inject window.AGENT_TEMPLATE_BASE via Hugo relURL in the shortcode and use it in agent-builder.js, with /code/agent-templates as the fallback for local/root deploys. Co-Authored-By: Claude Sonnet 4.6 --- layouts/shortcodes/agent-builder.html | 2 ++ static/js/agent-builder.js | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/layouts/shortcodes/agent-builder.html b/layouts/shortcodes/agent-builder.html index 8adeffb96b..c5a24cc09c 100644 --- a/layouts/shortcodes/agent-builder.html +++ b/layouts/shortcodes/agent-builder.html @@ -90,5 +90,7 @@

Generated Agent Code

+ + diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index fcc7950b2c..5ba73f373b 100644 --- a/static/js/agent-builder.js +++ b/static/js/agent-builder.js @@ -536,7 +536,8 @@ java: '.java', csharp: '.cs' }; - const filename = `/code/agent-templates/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`; + const templateBase = (window.AGENT_TEMPLATE_BASE || '/code/agent-templates').replace(/\/$/, ''); + const filename = `${templateBase}/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`; return loadTemplateFile(filename, formData) || genericTemplates[formData.programmingLanguage](formData); } From c0a3f07502b82d51764fe35afff170f4abef337d Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 17 Jun 2026 13:46:04 -0700 Subject: [PATCH 07/12] Robust num_docs check: handle both snake_case and camelCase node-redis field name varies across versions; fall back through num_docs -> numDocs -> '0' to avoid NaN causing every startup to reload the full dataset. Co-Authored-By: Claude Sonnet 4.6 --- static/code/agent-templates/javascript/recommendation_agent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index cb026d0390..0c00799cfc 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -133,7 +133,7 @@ class RecommendationAgent { async _indexExists() { try { const info = await this.redisClient.ft.info(INDEX_NAME); - return parseInt(info.num_docs) > 0; + return parseInt(info.num_docs ?? info.numDocs ?? '0') > 0; } catch { return false; } From 8094c825344a3e51864418667e67ceec8d8e91f5 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 24 Jun 2026 12:15:52 -0700 Subject: [PATCH 08/12] Fix semantic recall being a no-op due to document eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting message documents on eviction from the recent list meant the search index only ever contained the same messages already in the recent window, so _getSemanticMessages could never surface anything new. Only lTrim the recent list now; older documents stay in the index for semantic retrieval. Also drop __key from the RETURN fields in _getSemanticMessages — it is not an indexed field and the code uses doc.id instead. Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index a4283b7905..843fb20720 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -151,14 +151,11 @@ class ConversationalAgent { }); // Track insertion order for recent-turn retrieval. - // Before trimming, collect any keys that will be evicted and delete their documents - // so message JSON and embeddings don't accumulate in Redis indefinitely. - const listLen = await this.redisClient.lLen(RECENT_KEY(this.sessionName)); - const evictCount = listLen - (RECENT_WINDOW * 2 - 1); // -1 because we haven't pushed yet - if (evictCount > 0) { - const toEvict = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, evictCount - 1); - if (toEvict.length) await this.redisClient.del(toEvict); - } + // Only trim the recent list — do NOT delete the underlying JSON documents. + // Older messages must stay in the search index so _getSemanticMessages can + // retrieve them; deleting docs here would make semantic recall a no-op since + // every KNN hit would already be in the recent window and deduped out. + // For long-running sessions, set a TTL on message keys if you need a memory bound. await this.redisClient.rPush(RECENT_KEY(this.sessionName), key); await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 2, -1); } @@ -180,7 +177,7 @@ class ConversationalAgent { `(@session:{${this.sessionName}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, { PARAMS: { vec: queryBuffer }, - RETURN: ['role', 'content', '__key'], + RETURN: ['role', 'content'], SORTBY: { BY: 'score', DIRECTION: 'ASC' }, DIALECT: 2, } From 174b7160830a1094b399c24c1eaa2f7d9be2df7c Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 24 Jun 2026 12:19:15 -0700 Subject: [PATCH 09/12] Fix wrong movie join, add revenue output, escape session TAG query - Fix ratings joined to wrong movies: ratings_small uses MovieLens ids but movies_metadata uses TMDB ids; bridge via links.csv before merging. Without the bridge ~69% of ratings were silently dropped or misattributed and popular films like Toy Story and Forrest Gump never appeared. links.csv is now a required dataset (added to download instructions). - Add revenue to RETURN fields and formatted output so revenue-based sort requests can be verified from results. - Escape sessionName before embedding in TAG query to prevent metacharacters (spaces, {}, |) from breaking the query or matching unintended sessions. Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 2 +- .../javascript/recommendation_agent.js | 81 ++++++++++++------- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index 843fb20720..2210fa85f4 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -174,7 +174,7 @@ class ConversationalAgent { const queryBuffer = this._toQueryBuffer(await this._embed(query)); const results = await this.redisClient.ft.search( INDEX_NAME, - `(@session:{${this.sessionName}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, + `(@session:{${this.sessionName.replace(/[^a-zA-Z0-9_\-]/g, '_')}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, { PARAMS: { vec: queryBuffer }, RETURN: ['role', 'content'], diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index 0c00799cfc..8e85925c52 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -13,10 +13,12 @@ * REDIS_URL=redis://localhost:6379 * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately) * - * Download datasets: + * Download datasets (all three are required): * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/ratings_small.csv * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/movies_metadata.csv + * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/links.csv * Place them in datasets/collaborative_filtering/ relative to this file. + * links.csv bridges MovieLens movieIds (ratings) to TMDB ids (movies_metadata). */ require('dotenv').config(); @@ -149,31 +151,33 @@ class RecommendationAgent { const ratingsFile = path.join('datasets', 'collaborative_filtering', 'ratings_small.csv'); const moviesFile = path.join('datasets', 'collaborative_filtering', 'movies_metadata.csv'); + const linksFile = path.join('datasets', 'collaborative_filtering', 'links.csv'); - if (!fs.existsSync(ratingsFile) || !fs.existsSync(moviesFile)) { - console.warn('Movie datasets not found. Skipping index setup.'); - console.warn(` Expected: ${ratingsFile}`); - console.warn(` Expected: ${moviesFile}`); - return; + for (const f of [ratingsFile, moviesFile, linksFile]) { + if (!fs.existsSync(f)) { + console.warn(`Movie dataset not found: ${f}. Skipping index setup.`); + return; + } } console.log('Loading movie datasets...'); - let ratings, movies; + let ratings, movies, links; try { ratings = parse(fs.readFileSync(ratingsFile), { columns: true, cast: true }); movies = parse(fs.readFileSync(moviesFile), { columns: true, cast: true }); + links = parse(fs.readFileSync(linksFile), { columns: true, cast: true }); } catch (err) { console.error('Failed to parse dataset files:', err.message); return; } - if (!ratings.length || !movies.length) { + if (!ratings.length || !movies.length || !links.length) { console.error('One or more dataset files are empty.'); return; } - // Aggregate ratings per movie + // Aggregate ratings per MovieLens movieId const stats = {}; for (const r of ratings) { if (!r.movieId || !isFinite(r.rating)) continue; @@ -182,23 +186,40 @@ class RecommendationAgent { stats[r.movieId].total += r.rating; } - // Merge metadata with aggregated stats - const merged = movies - .filter((m) => m.id && stats[String(m.id)]) - .map((m) => { - const s = stats[String(m.id)]; - const avgRating = s.total / s.count; - return { - movieId: String(m.id), - title: String(m.title || '').trim(), - genres: parseGenres(m.genres), // comma-separated TAG string - revenue: safeNumber(m.revenue), - ratingCount: s.count, - avgRating: Math.round(avgRating * 100) / 100, - popularityScore: Math.round(s.count * avgRating * 100) / 100, - }; - }) - .filter((m) => m.title); + // Build MovieLens movieId → TMDB id bridge via links.csv. + // ratings_small uses MovieLens ids; movies_metadata uses TMDB ids — they are + // different id spaces and cannot be joined directly without this mapping. + const movieLensToTmdb = new Map(); + for (const row of links) { + if (row.movieId && row.tmdbId) { + movieLensToTmdb.set(String(row.movieId), String(row.tmdbId)); + } + } + + // Index movies_metadata by TMDB id for O(1) lookup + const moviesByTmdb = new Map(); + for (const m of movies) { + if (m.id) moviesByTmdb.set(String(m.id), m); + } + + // Join ratings → links → metadata + const merged = []; + for (const [movieLensId, s] of Object.entries(stats)) { + const tmdbId = movieLensToTmdb.get(movieLensId); + if (!tmdbId) continue; + const m = moviesByTmdb.get(tmdbId); + if (!m || !m.title) continue; + const avgRating = s.total / s.count; + merged.push({ + movieId: movieLensId, + title: String(m.title).trim(), + genres: parseGenres(m.genres), + revenue: safeNumber(m.revenue), + ratingCount: s.count, + avgRating: Math.round(avgRating * 100) / 100, + popularityScore: Math.round(s.count * avgRating * 100) / 100, + }); + } if (!merged.length) { console.error('No valid movies found after merging datasets.'); @@ -296,7 +317,7 @@ Return only valid JSON with no explanation or markdown.`; let results; try { results = await this.redisClient.ft.search(INDEX_NAME, filterQuery, { - RETURN: ['title', 'genres', 'ratingCount', 'avgRating', 'popularityScore'], + RETURN: ['title', 'genres', 'ratingCount', 'avgRating', 'popularityScore', 'revenue'], SORTBY: { BY: params.sortBy, DIRECTION: params.sortOrder }, LIMIT: { from: 0, size: params.maxResults }, }); @@ -316,7 +337,11 @@ Return only valid JSON with no explanation or markdown.`; response += `${i + 1}. ${m.title}\n`; response += ` Genres: ${m.genres || 'N/A'}\n`; response += ` Average Rating: ${parseFloat(m.avgRating || 0).toFixed(1)}/5 (${m.ratingCount || 0} reviews)\n`; - response += ` Popularity Score: ${parseFloat(m.popularityScore || 0).toFixed(1)}\n\n`; + response += ` Popularity Score: ${parseFloat(m.popularityScore || 0).toFixed(1)}\n`; + if (m.revenue && parseFloat(m.revenue) > 0) { + response += ` Box Office: $${(parseFloat(m.revenue) / 1_000_000).toFixed(0)}M\n`; + } + response += '\n'; }); return response; } From 0e74aa24c052a729c716d0a4ebf804cb678a1c72 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 24 Jun 2026 14:57:33 -0700 Subject: [PATCH 10/12] Fix session TAG query mismatch by sanitizing sessionName at construction Sanitizing inline in _getSemanticMessages caused the query to use a different value than what _storeMessage indexed, so KNN recall returned nothing for sessions with special characters. Sanitize once in the constructor so both storage and retrieval always use the same value. Co-Authored-By: Claude Sonnet 4.6 --- .../code/agent-templates/javascript/conversational_agent.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index 2210fa85f4..f4a79b37a7 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -40,7 +40,9 @@ const MAX_CONTENT_CHARS = 2000; class ConversationalAgent { constructor(sessionName = 'chat') { - this.sessionName = sessionName; + // Sanitize at construction so _storeMessage and _getSemanticMessages + // always index and query the same value. + this.sessionName = sessionName.replace(/[^a-zA-Z0-9_\-]/g, '_'); this.messageCount = 0; this._dimValidated = false; @@ -174,7 +176,7 @@ class ConversationalAgent { const queryBuffer = this._toQueryBuffer(await this._embed(query)); const results = await this.redisClient.ft.search( INDEX_NAME, - `(@session:{${this.sessionName.replace(/[^a-zA-Z0-9_\-]/g, '_')}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, + `(@session:{${this.sessionName}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, { PARAMS: { vec: queryBuffer }, RETURN: ['role', 'content'], From a09d5ad63015bb4739cb1113782efbc9260f9c17 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 24 Jun 2026 15:05:43 -0700 Subject: [PATCH 11/12] Fix partial import being treated as complete on restart _indexExists checked num_docs > 0 which passes for a partial load, leaving the index permanently incomplete. Replace with a sentinel key (movies:load_complete) written only after the full pipeline succeeds, so a crashed import triggers a clean reload on next startup. Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/recommendation_agent.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index 8e85925c52..e08ed7a66e 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -29,7 +29,8 @@ const fs = require('fs'); const path = require('path'); const INDEX_NAME = 'movies_idx'; -const MOVIE_PREFIX = 'movie:'; +const MOVIE_PREFIX = 'movie:'; +const LOAD_SENTINEL = 'movies:load_complete'; const CONFIG = { maxResults: 10, @@ -134,8 +135,10 @@ class RecommendationAgent { async _indexExists() { try { - const info = await this.redisClient.ft.info(INDEX_NAME); - return parseInt(info.num_docs ?? info.numDocs ?? '0') > 0; + // Check the sentinel written only after a full successful load. + // num_docs > 0 alone is not sufficient — a partial import looks non-empty + // but leaves the index permanently incomplete on subsequent startups. + return (await this.redisClient.exists(LOAD_SENTINEL)) === 1; } catch { return false; } @@ -253,6 +256,10 @@ class RecommendationAgent { } await pipeline.exec(); + // Write the sentinel only after all documents are loaded. _indexExists checks + // this key so a partial import (pipeline fails midway) never looks complete. + await this.redisClient.set(LOAD_SENTINEL, '1'); + this.indexReady = true; console.log('Movie recommendation system initialized successfully!'); } From 63df428297698b4d79f95b919b98fa5500c5a210 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 24 Jun 2026 15:13:58 -0700 Subject: [PATCH 12/12] Fix sentinel not cleared on index drop; fix dataset paths relative to cwd - Delete movies:load_complete sentinel before dropping and recreating the index so a dropped or partially-loaded index always triggers a clean reload rather than leaving indexReady true with no index - Resolve dataset paths with __dirname so the agent finds its CSVs regardless of which directory it is run from Co-Authored-By: Claude Sonnet 4.6 --- .../agent-templates/javascript/recommendation_agent.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index e08ed7a66e..c69831d13d 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -152,9 +152,10 @@ class RecommendationAgent { return; } - const ratingsFile = path.join('datasets', 'collaborative_filtering', 'ratings_small.csv'); - const moviesFile = path.join('datasets', 'collaborative_filtering', 'movies_metadata.csv'); - const linksFile = path.join('datasets', 'collaborative_filtering', 'links.csv'); + const dataDir = path.join(__dirname, 'datasets', 'collaborative_filtering'); + const ratingsFile = path.join(dataDir, 'ratings_small.csv'); + const moviesFile = path.join(dataDir, 'movies_metadata.csv'); + const linksFile = path.join(dataDir, 'links.csv'); for (const f of [ratingsFile, moviesFile, linksFile]) { if (!fs.existsSync(f)) { @@ -232,8 +233,10 @@ class RecommendationAgent { console.log(`Processed ${merged.length} movies`); // Drop existing index and its documents so stale movie keys don't survive the reload. + // Also delete the sentinel so a partial previous run can't block re-import. const indexExists = await this.redisClient.ft.info(INDEX_NAME).then(() => true).catch(() => false); if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME, { DD: true }); + await this.redisClient.del(LOAD_SENTINEL); await this.redisClient.ft.create( INDEX_NAME,