DEV Community

Cover image for Teaching an AI Dog New Tricks (The Gemini Integration)
Joshua Tuddenham
Joshua Tuddenham

Posted on • Originally published at joshtuddenham.tech

Teaching an AI Dog New Tricks (The Gemini Integration)

The Art of Prompt Engineering (Or: How I Learned to Stop Worrying and Trust the AI)

First attempt at prompting Gemini:

const prompt = "Plan a nice trip";
Enter fullscreen mode Exit fullscreen mode

Result: "Have you considered going somewhere? Perhaps doing things when you get there? Maybe eating food?"

Right. Maybe I need to be a bit more specific. Turns out "plan a nice trip" is to Gemini what "fetch" is to a golden retriever - technically understood, but interpreted rather loosely.

Take Two: The First Real Attempt

My first serious attempt at structuring the prompts was... well, let's say Wooster had some creative interpretations:

type BasicTripRequest = {
  destination: string;
  duration: number;
  startDate: string;
};

const firstPrompt = `
Plan a ${tripRequest.duration} day trip to ${tripRequest.destination}
starting on ${tripRequest.startDate}.

Please include:

- Daily activities
- Locations
- Approximate costs
- Duration of activities
`;
Enter fullscreen mode Exit fullscreen mode

Result: "Day 1 you could maybe explore downtown? Stay as long as you're having fun! Costs depend on how many treats you buy along the way. Some people spend a little, some spend a lot. Day 2 there's this AMAZING park the locals love..."

Not quite the structured response I was hoping for. Time to try JSON.

Adding More Structure (But Not Enough)

Here was my first JSON attempt:

const secondPrompt = `
  Generate a JSON itinerary for a ${tripRequest.duration}-day trip to ${tripRequest.destination}.
  Each day should have 2-3 activities.

  Format:
  {
    "days": [
      {
        "dayNumber": number,
        "activities": [
          {
            "name": string,
            "location": string,
            "description": string,
            "duration": string,
            "category": string,  // This caused problems later...
            "cost": string
          }
        ]
      }
    ]
  }
`;
Enter fullscreen mode Exit fullscreen mode

This worked better, but the categorization was a mess. I got everything from "Fun stuff" to "Walking around looking at things" as categories. It was like asking Wooster to sort his toys - everything ended up in the "things I can put in my mouth" category.

Take this response for example:

{
  "days": [
    {
      "dayNumber": 1,
      "activities": [
        {
          "name": "Explore the city vibes",
          "location": "wherever the wind takes us",
          "description": "Just soak in the atmosphere, you know?",
          "duration": "as long as we're having fun",
          "category": "Places with good smells",
          "cost": "whatever's in the wallet"
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Time to get serious about types.

The Final Evolution

After much trial and error (and Wooster suggesting "belly rubs" as a valid activity category), I landed on this much more structured approach:

export const createPrompt = (
  days: number,
  location: string,
  startDate: string,
): string => `
  Generate **ONLY** JSON data for a ${days}-day trip to ${location}, starting on ${startDate}.
  Have no more than three activities per day. Exclude arrival and departure logistics.

  Duration must be in format "X hours" or "X.5 hours".
  Price must be in format "$X" where X is a number.

  Activities MUST include:
  - "activityName": string
  - "description": string (20-50 words)
  - "location": string
  - "price": string
  - "duration": string
  - "difficulty": "Easy" | "Moderate" | "Challenging"
  - "category": "Adventure" | "Cultural" | "Nature" | "Food & Drink" | "Shopping" | "Entertainment"
  - "bestTime": "Early Morning" | "Morning" | "Afternoon" | "Evening" | "Night"
  - "bookingRequired": boolean
`;
Enter fullscreen mode Exit fullscreen mode

And later, after realizing I needed geolocation for mapping of activities:

export const destinationPromptTemplate = (destination: string) => `
  Generate **ONLY** a valid JSON object for ${destination}.
  Include:
  - "latitude": number (decimal degrees)
  - "longitude": number (decimal degrees)
  - "destinationName": string
  - "country": string
  - "description": string (50-200 words)
  // ... other structured fields
`;
Enter fullscreen mode Exit fullscreen mode

Finally, I was getting responses that looked like actual travel plans rather than Wooster's diary entries:

{
  "days": [
    {
      "dayNumber": 1,
      "activities": [
        {
          "activityName": "Morning Market Tour",
          "description": "Explore the historic Camden Market, featuring local artisans and food vendors. Perfect for gathering travel supplies (and snacks).",
          "location": "Camden Lock Place, London",
          "price": "$15",
          "duration": "2.5 hours",
          "difficulty": "Easy",
          "category": "Food & Drink",
          "bestTime": "Morning",
          "bookingRequired": false
        },
        {
          "activityName": "Regent's Park Walk",
          "description": "A scenic stroll through one of London's most beautiful parks. Watch for squirrels (Wooster insisted this was important).",
          "location": "Regent's Park, London",
          "price": "$0",
          "duration": "1.5 hours",
          "difficulty": "Easy",
          "category": "Nature",
          "bestTime": "Afternoon",
          "bookingRequired": false
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Still with a hint of Wooster's personality, but now in a format that wouldn't make TypeScript cry.

Handling the Responses

Of course, getting the prompt right was only half the battle. Gemini's responses weren't always perfectly formatted JSON, so I needed some utility functions to clean things up:

export const cleanLLMJsonResponse = (text: string): string => {
  // Step 1: Remove markdown code blocks with any language specification
  const withoutCodeBlocks = text.replace(
    new RegExp('```

(?:json)?\\s*([\\s\\S]*?)\\s*

```', 'g'),
    "$1"
  );

  // Step 2: Remove potential comments
  const withoutComments = withoutCodeBlocks.replace(
    new RegExp('\\/\\*[\\s\\S]*?\\*\\/|\\/\\/.*', 'g'),
    ""
  );

  // Step 3: Detect and replace incomplete URLs with a placeholder
  const withCompleteUrls = withoutComments.replace(
    new RegExp('"website":\\s*"https:([^",}]*)', 'g'),
    '"website": "https://example.com"'
  );

  // Step 4: Trim whitespace
  return withCompleteUrls.trim();
};

// Validate the JSON structure
export const validateJSON = (jsonString: string): void => {
  try {
    const parsed = JSON.parse(jsonString);
    if (typeof parsed !== "object" || parsed === null) {
      throw new Error("Response is not a valid JSON object or array");
    }
  } catch (error) {
    console.error(
      "JSON Validation failed. Invalid JSON string:",
      jsonString.slice(0, 500),
    );
    throw new Error(
      `Invalid JSON response: ${error instanceof Error ? error.message : "Unknown error"}`,
    );
  }
};

// Clean up any non-printable characters
export const cleanJSON = (jsonString: string): string => {
  // Remove control characters (ASCII 0 to 31)
  const cleanedString = jsonString.replace(/[\x00-\x1F]/g, "");

  // Remove non-printable characters
  return cleanedString.replace(/[^\x20-\x7E]/g, "");
};
Enter fullscreen mode Exit fullscreen mode

Why did I need all this? Well, Gemini had some... interesting habits:

  1. Sometimes wrapping responses in markdown code blocks
  2. Occasionally including helpful comments (which broke the JSON)
  3. Returning incomplete URLs
  4. Adding mysterious control characters
  5. And my personal favorite: sneaking in emoji (those non-printable characters had to go)

Using these utilities together:

async function generateTripPlan(tripRequest: TripRequest) {
  try {
    const result = await model.generateContent(createPrompt(/* ... */));
    const text = result.response.text();

    // Clean up the response
    const cleanedResponse = cleanLLMJsonResponse(text);
    const sanitizedJSON = cleanJSON(cleanedResponse);

    // Validate before parsing
    validateJSON(sanitizedJSON);

    return JSON.parse(sanitizedJSON);
  } catch (error) {
    console.error("Failed to generate valid trip plan:", error);
    throw new Error("Failed to generate trip plan");
  }
}
Enter fullscreen mode Exit fullscreen mode

What I Actually Learned

  1. Start with strict types from the beginning
  2. Be explicit about formats
  3. The more specific the prompt constraints, the better
  4. Always sanitize and validate AI responses
  5. Geolocation data should have been there from the start
  6. Rate limiting is important
  7. Regex is your best friend when dealing with AI responses
  8. Never trust an AI to format URLs correctly

Next up: the frontend implementation, or as I like to call it, "Making Wooster Look Presentable for Company" (and this time with proper TypeScript interfaces from day one).


This article was originally published on my blog. Follow me there for more content about AI integration, TypeScript, and full-stack development.

Top comments (0)