DEV Community

Evie Wang
Evie Wang

Posted on

Building an AI Conversation Practice App: Part 3- From Simple Prompts to Database-Driven Dynamic AI Characters

This is the third post in a series documenting how we built a browser-based English learning app with a cost-friendly conversation system.

Overview: From Static Prompts to Dynamic Character Generation

After transcribing user speech, our system needs to generate contextually appropriate responses that feel natural and authentic. I started with hardcoded prompts for our MVP, which worked well initially. As we will add more scenarios and want greater personality variation in the futhure, then I upgraded it to a database-driven system that could scale.

The complete character generation workflow involves:

  1. Scenario Analysis → Intelligent mapping from scenario titles to character types
  2. Character Selection → Pull the right character profile from the database
  3. Dynamic Prompt Building → Build prompts that include personality details
  4. Context Adaptation → Adjust responses based on timing (e.g., busy vs. relaxed moments)
  5. GPT-4 Integration → Generate replies that fit the chosen character

Total processing time: ~1-2s (varies by network conditions)

Technical Stack:

  • Database: Supabase (PostgreSQL)
  • Prompt Engineering: Dynamic multi-layer construction
  • AI Model: GPT-4o with character-specific prompts
  • Language: JavaScript with Next.js API routes
  • Error Handling: JSON parsing safety and database fallbacks

From Prototype to Scale

The Starting Point

I began with a straightforward approach for our MVP:

// Simple but effective for initial scenarios if (scenarioTitle.includes('tim hortons')) { const prompt = "You are a friendly Tim Hortons employee. Be helpful and use Canadian expressions..."; } else if (scenarioTitle.includes('restaurant')) { const prompt = "You are a restaurant server. Be professional and friendly..."; } 
Enter fullscreen mode Exit fullscreen mode

This worked well for getting started, but as our content expanded, we needed more flexibility and personality variation.

the Database-driven Solution---Core Implementation Details

Intelligent Scenario Mapping

We built a mapping system that automatically determines which character to use:

const mapScenarioToCharacterKey = (scenarioTitle) => { const titleLower = scenarioTitle.toLowerCase(); // Tim Hortons scenarios if (titleLower.includes('tim hortons') || titleLower.includes('coffee shop')) { return 'tim_hortons'; } // Restaurant scenarios with sub-categories if (titleLower.includes('restaurant') || titleLower.includes('dining')) { if (titleLower.includes('fast') || titleLower.includes('mcdonald')) { return 'restaurant_fast'; } else if (titleLower.includes('fine') || titleLower.includes('upscale')) { return 'restaurant_fine'; } else { return 'restaurant_casual'; } } return 'directions'; // Default fallback }; 
Enter fullscreen mode Exit fullscreen mode

Database Schema Design

Our character system uses four tables that work together:

erDiagram character_profiles { int id PK string scenario_key string character_name text base_prompt_template string correction_style } personality_traits { int id PK int character_id FK string trait_type string trait_value } canadian_expressions { int id PK int character_id FK string expression_category json phrases } response_patterns { int id PK int character_id FK string time_context string situation_type json response_templates int avg_sentence_count boolean includes_questions } character_profiles ||--o{ personality_traits : "defines" character_profiles ||--o{ canadian_expressions : "uses" character_profiles ||--o{ response_patterns : "follows" 
Enter fullscreen mode Exit fullscreen mode

Table Relationships:

  • One character profile links to many personality traits
  • Each character has their own Canadian expressions as examples, and they are organized by category
  • Response patterns vary based on context (busy vs slow)

Character Generation Service

Here's our main character generation function (lib/characterService.js):

export async function generateCharacterPrompt(scenarioKey) { try { // Get the basic character information const { data: character, error: characterError } = await supabase .from('character_profiles') .select('*') .eq('scenario_key', scenarioKey) .single(); if (characterError) { console.error('Character not found:', characterError); return null; } // Get personality traits const { data: traits, error: traitsError } = await supabase .from('personality_traits') .select('*') .eq('character_id', character.id); if (traitsError) { console.error('Error fetching traits:', traitsError); return null; } // Get Canadian expressions const { data: expressions, error: expressionsError } = await supabase .from('canadian_expressions') .select('*') .eq('character_id', character.id); if (expressionsError) { console.error('Error fetching expressions:', expressionsError); return null; } // Get response patterns based on random time context const timeContext = getRandomTimeContext(); const { data: patterns, error: patternsError } = await supabase .from('response_patterns') .select('*') .eq('character_id', character.id) .eq('time_context', timeContext); if (patternsError) { console.error('Error fetching patterns:', patternsError); return null; } // Convert arrays to objects for easier access const personalityMap = {}; traits.forEach(trait => { personalityMap[trait.trait_type] = trait.trait_value; }); const expressionsMap = {}; expressions.forEach(expr => { try { expressionsMap[expr.expression_category] = JSON.parse(expr.phrases); } catch (error) { console.error('JSON parse error for expression:', expr.expression_category); expressionsMap[expr.expression_category] = []; } }); const patternsMap = {}; patterns.forEach(pattern => { try { patternsMap[pattern.situation_type] = { templates: JSON.parse(pattern.response_templates), avg_sentence_count: pattern.avg_sentence_count, includes_questions: pattern.includes_questions }; } catch (error) { console.error('JSON parse error for pattern:', pattern.situation_type); patternsMap[pattern.situation_type] = { templates: [], avg_sentence_count: 2, includes_questions: false }; } }); // Build the complete system prompt const systemPrompt = buildSystemPrompt(character, personalityMap, expressionsMap, patternsMap, timeContext); return { role: character.character_name, systemPrompt, timeContext, characterData: { personality: personalityMap, expressions: expressionsMap, patterns: patternsMap, correctionStyle: character.correction_style } }; } catch (error) { console.error('Error generating character prompt:', error); return null; } } 
Enter fullscreen mode Exit fullscreen mode

Dynamic Prompt Construction

Our prompt-building function combines all character data:

function buildSystemPrompt(character, personality, expressions, patterns, timeContext) { const contextDescription = timeContext === 'busy' ? 'It is currently a busy time, so you respond more quickly and concisely.' : 'It is currently a slower time, so you can be more conversational and detailed.'; // Extract expression examples for the prompt const greetingExamples = expressions.greetings ? expressions.greetings.join(', ') : ''; const transitionExamples = expressions.transitions ? expressions.transitions.join(', ') : ''; const confirmationExamples = expressions.confirmations ? expressions.confirmations.join(', ') : ''; return `${character.base_prompt_template} CURRENT CONTEXT: ${contextDescription} PERSONALITY TRAITS: - Formality: ${personality.formality_level || 'casual'} - Energy: ${personality.energy_level || 'medium'} - Chattiness: ${personality.chattiness || 'moderate'} - Service Pace: ${personality.service_pace || 'normal'} CANADIAN EXPRESSIONS TO USE: - Greetings: ${greetingExamples} - Transitions: ${transitionExamples} - Confirmations: ${confirmationExamples} RESPONSE GUIDELINES: - Keep responses ${timeContext === 'busy' ? '1-2 sentences' : '2-4 sentences'} maximum - Use natural Canadian expressions from the examples above - Stay completely in character as a real ${character.character_name} - If you need to correct English mistakes, use the "${character.correction_style}" style - For corrections, respond naturally first, then gently clarify. Example: "Oh, you mean a large coffee? Sure thing!" - NEVER comment on someone's English skills or act like a language tutor - Just have a natural conversation as if you're really in this workplace CORRECTION EXAMPLES: - If they say "I want big coffee" → "Oh, you mean a large coffee? Coming right up!" - If they say "Where is bathroom?" → "Oh, the washroom? It's just down the hall there." - If they say unclear something → "Sorry, what was that?" or "Can you repeat that?" Remember: You are a real ${character.character_name} during ${timeContext === 'busy' ? 'busy' : 'slow'} time. Act natural and authentic!`; } 
Enter fullscreen mode Exit fullscreen mode

A Real Example to help understand deeper: Tim Hortons Employee

User Scenario: "Ordering at Tim Hortons"

Step 1: Scenario Mapping

mapScenarioToCharacterKey("Ordering at Tim Hortons") // Returns: "tim_hortons" 
Enter fullscreen mode Exit fullscreen mode

Step 2: Database Queries Return

character = { id: 1, character_name: "Tim Hortons Employee", base_prompt_template: "You are a friendly Tim Hortons employee working the counter..." } personalityMap = { formality_level: "casual", energy_level: "high", chattiness: "moderate", service_pace: "fast" } expressionsMap = { greetings: ["Hey there!", "How's it going?", "Morning!"], confirmations: ["You bet!", "For sure!", "Absolutely!"], transitions: ["Alrighty", "Perfect", "Awesome"] } // Random timeContext = "busy" patternsMap = { ordering: { templates: ["What can I get started for you?", "Next!"], avg_sentence_count: 1, includes_questions: true } } 
Enter fullscreen mode Exit fullscreen mode

Step 3: Generated Prompt

const systemPrompt = `You are a friendly Tim Hortons employee working the counter... CURRENT CONTEXT: It is currently a busy time, so you respond more quickly and concisely. PERSONALITY TRAITS: - Formality: casual - Energy: high - Chattiness: moderate - Service Pace: fast CANADIAN EXPRESSIONS TO USE: - Greetings: Hey there!, How's it going?, Morning! - Confirmations: You bet!, For sure!, Absolutely! - Transitions: Alrighty, Perfect, Awesome RESPONSE GUIDELINES: - Keep responses 1-2 sentences maximum - Use natural Canadian expressions from the examples above - Stay completely in character as a real Tim Hortons Employee `; 
Enter fullscreen mode Exit fullscreen mode

Step 4: GPT-4 Response

"Hey there! What can I get started for you today?" 
Enter fullscreen mode Exit fullscreen mode

Notice how the response incorporates:

  • Casual greeting from the expressions database
  • Concise format due to "busy" context
  • High energy personality trait
  • Natural Canadian friendliness

If the same scenario ran with timeContext = "slow", we might get:

"Morning! How's your day going so far? What can I get you today - maybe a nice double-double to start?" 
Enter fullscreen mode Exit fullscreen mode

Same character, different context, completely different response style.
Now it’s super easy to add or change characters since everything lives in the database, no need to mess with code. Each character has their own vibe and can react differently depending on the situation, and even if something goes wrong, the convo keeps going without breaking.

What's Next

In our next post, we'll explore how these generated text responses are converted back to speech using totally free browser-based text-to-speech capabilities, completing the full audio conversation loop.

Top comments (0)