top of page

Building AI Travel Planner with Ollama and MCP - Part 2

Prerequisite: This is a continuation of the blog Part 1: A Complete Guide to Creating a Multi-Agent Book Writing System


Making Sense of Messy JSON

    def extract_json_from_text(self, text: str) -> Dict:
        """Improved JSON extraction with multiple fallback strategies"""
        
        # Strategy 1: Find JSON between curly braces
        json_patterns = [
            r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}',  # Nested braces
            r'\{.*?\}',  # Simple braces
        ]
        
        for pattern in json_patterns:
            matches = re.findall(pattern, text, re.DOTALL)
            for match in matches:
                try:
                    parsed = json.loads(match)
                    if isinstance(parsed, dict) and any("Day" in str(k) for k in parsed.keys()):
                        print("✅ JSON extracted successfully")
                        return parsed
                except json.JSONDecodeError:
                    continue
        
        # Strategy 2: Try to find and fix common JSON issues
        json_match = re.search(r'\{.*\}', text, re.DOTALL)
        if json_match:
            json_text = json_match.group()
            
            # Common fixes
            fixes = [
                # Remove trailing commas
                (r',(\s*[}\]])', r'\1'),
                # Fix quotes
                (r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":'),
                # Fix missing quotes around values
                (r':\s*([^",\[\]{}\s][^",\[\]{}]*[^",\[\]{}\s])\s*([,}])', r': "\1"\2'),
            ]
            
            for pattern, replacement in fixes:
                json_text = re.sub(pattern, replacement, json_text)
            
            try:
                parsed = json.loads(json_text)
                if isinstance(parsed, dict):
                    print("✅ JSON extracted and fixed successfully")
                    return parsed
            except json.JSONDecodeError as e:
                print(f"⚠️ JSON fix attempt failed: {e}")
        
        # Strategy 3: Extract structured data manually
        print("🔧 Attempting manual structure extraction...")
        return self.extract_itinerary_structure(text)

Let’s break the method into bite-sized chunks and understand how it works — like a detective inspecting clues to find the clean data hiding beneath the noise.


🧪 Try Regular Expression-Based Extraction

json_patterns = [
    r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}',  # Nested braces
    r'\{.*?\}',  # Simple braces
]

These patterns look for sections of text that resemble JSON. Think of it like sifting through a cluttered drawer and trying to pull out items that at least look like receipts.


🎯 The AI gives us a bag of mixed nuts, and we are looking for only the almonds (i.e., valid JSON).


for pattern in json_patterns:
    matches = re.findall(pattern, text, re.DOTALL)

Here we go hunting for all matches using each pattern. re.DOTALL lets the dot . match newlines too — because JSON can span multiple lines.


💡 Trivia: Multiline JSON is the norm when the model writes long-form outputs.


for match in matches:
    try:
        parsed = json.loads(match)

Now we try to load each match with json.loads(). If it’s real JSON, this will work. If not? We catch it and move on.


        if isinstance(parsed, dict) and any("Day" in str(k) for k in parsed.keys()):
            print("✅ JSON extracted successfully")
            return parsed

We’re not just looking for any JSON. We want one that has keys like "Day 1", "Day 2" — the kind we would see in an itinerary. If we find that, we throw a little party and return it.


    except json.JSONDecodeError:
        continue

If a match can’t be parsed as JSON, we just skip it. No yelling. No crashing. Just keep swimming.


🧩 Pro Tip: Always code with forgiveness. Don’t expect perfect input from an LLM.


🛠 Try Fixing Broken JSON

json_match = re.search(r'\{.*\}', text, re.DOTALL)

Now we go for one big sweep — grab the first large JSON-looking block from the text.


if json_match:
    json_text = json_match.group()

If we got a match, we pull it out to start our fix-it work.


🔧 Gotcha: Sometimes models leave trailing commas or forget quotes. Time to be the janitor.


fixes = [
    (r',(\s*[}\]])', r'\1'),
    (r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":"),
    (r':\s*([^",\[\]{}\s][^",\[\]{}]*[^",\[\]{}\s])\s*([,}])', r': "\1"\2'),
]

Here’s where the magic happens. These regex-fix pairs help:

  • Remove trailing commas

  • Add missing quotes to keys

  • Add quotes to plain string values


for pattern, replacement in fixes:
    json_text = re.sub(pattern, replacement, json_text)

We apply all the above regex fixes to clean the dirty JSON string.


🧠 Pro Tip: If your JSON still breaks after this, it’s probably missing brackets or deeply malformed. Skip to the fallback.

try:
    parsed = json.loads(json_text)
    if isinstance(parsed, dict):
        print("✅ JSON extracted and fixed successfully")
        return parsed

If the cleanup worked and we have a dictionary, we return it. Another win.


except json.JSONDecodeError as e:
    print(f"⚠️ JSON fix attempt failed: {e}")

If parsing still fails, we let the user know but keep calm and carry on.


🚫 Reality Check: AI isn’t always predictable — your code should be.


🪄 Last Resort — Manual Extraction

print("🔧 Attempting manual structure extraction...")
return self.extract_itinerary_structure(text)

If all else fails, we let another method take the wheel — one that can read plain English and build a structured itinerary out of it.


🧭 If you can’t scrape the soup into order, try reading the recipe.


📅 Turning Raw Text into a Day-by-Day Itinerary

    def extract_itinerary_structure(self, text: str) -> Dict:
        """Extract itinerary structure manually from text"""
        itinerary = {}
        
        # Look for day patterns
        day_patterns = [
            r'Day\s*(\d+)[:\s]*\n*(.*?)(?=Day\s*\d+|$)',
            r'**Day\s*(\d+)**[:\s]*\n*(.*?)(?=\*\*Day\s*\d+|$)',
        ]
        
        for pattern in day_patterns:
            matches = re.findall(pattern, text, re.DOTALL | re.IGNORECASE)
            if matches:
                for day_num, content in matches:
                    day_key = f"Day {day_num}"
                    
                    # Extract morning, afternoon, evening
                    schedule = {}
                    
                    time_patterns = [
                        (r'morning[:\s]*([^\n]*)', 'morning'),
                        (r'afternoon[:\s]*([^\n]*)', 'afternoon'),
                        (r'evening[:\s]*([^\n]*)', 'evening'),
                    ]
                    
                    for time_pattern, time_key in time_patterns:
                        match = re.search(time_pattern, content, re.IGNORECASE)
                        if match:
                            schedule[time_key] = match.group(1).strip()
                    
                    # Fallback: create generic schedule
                    if not schedule:
                        lines = [line.strip() for line in content.split('\n') if line.strip()]
                        if len(lines) >= 3:
                            schedule = {
                                'morning': lines[0],
                                'afternoon': lines[1],
                                'evening': lines[2]
                            }
                        else:
                            schedule = {
                                'morning': f"Explore {text[:50]}...",
                                'afternoon': f"Continue exploration",
                                'evening': f"Dinner and relaxation"
                            }
                    
                    itinerary[day_key] = schedule
                
                if itinerary:
                    print(f"✅ Extracted {len(itinerary)} days manually")
                    return itinerary
        
        # Ultimate fallback
        print("🔄 Using fallback structure")
        return {
            "Day 1": {
                "morning": "City exploration and main attractions",
                "afternoon": "Museum visits and cultural sites", 
                "evening": "Local dining experience"
            }
        }

Let’s say you get a blob of text like this from your AI assistant:

Day 1: Visit the Eiffel Tower in the morning. Lunch by the Seine. Evening cruise.
Day 2: Morning at the Louvre. Afternoon street art walk. Evening jazz bar.

How do we turn that into a structured itinerary? That’s what extract_itinerary_structure does — it reads natural language and builds a friendly, formatted itinerary.


🔍 Look for Day Labels

day_patterns = [
    r'Day\s*(\d+)[:\s]*\n*(.*?)(?=Day\s*\d+|$)',
    r'\*\*Day\s*(\d+)\*\*[:\s]*\n*(.*?)(?=\*\*Day\s*\d+|$)',
]

These regular expressions scan for day headers like Day 1 or Day 1. They help break the text into day-by-day chunks.


Pro Tip: This works even if the AI mixes formatting styles.


🗂 Match Each Day and Its Content

for pattern in day_patterns:
    matches = re.findall(pattern, text, re.DOTALL | re.IGNORECASE)

We apply each pattern to pull out all day-number and content pairs. It’s like snapping each chapter from a travel diary.


for day_num, content in matches:
    day_key = f"Day {day_num}"
    schedule = {}

Here we prepare a label like Day 1 and an empty dictionary to store that day's schedule.


🕰 Look for Time-of-Day Hints

time_patterns = [
    (r'morning[:\s]*([^\n]*)', 'morning'),
    (r'afternoon[:\s]*([^\n]*)', 'afternoon'),
    (r'evening[:\s]*([^\n]*)', 'evening'),
]

We scan each day’s content for words like morning: or afternoon: followed by a short description.


🔍 Example: morning: Louvre Museum gets stored under the morning key.

for time_pattern, time_key in time_patterns:
    match = re.search(time_pattern, content, re.IGNORECASE)
    if match:
        schedule[time_key] = match.group(1).strip()

Each time segment gets filled in only if found — smart and flexible.


🔄 Handle Days with No Labels

if not schedule:
    lines = [line.strip() for line in content.split('\n') if line.strip()]

If we didn’t detect any time-of-day keywords, we break the day’s content into lines and clean them up.

if len(lines) >= 3:
    schedule = {
        'morning': lines[0],
        'afternoon': lines[1],
        'evening': lines[2]
    }

Three or more lines? Easy mapping: line 1 = morning, line 2 = afternoon, line 3 = evening.

else:
    schedule = {
        'morning': f"Explore {text[:50]}...",
        'afternoon': "Continue exploration",
        'evening': "Dinner and relaxation"
    }

Too few lines? We fall back to a generic plan — because no one likes a blank itinerary.

itinerary[day_key] = schedule

Once the schedule is built, we store it using its day key.


✅ Return the Final Itinerary

if itinerary:
    print(f"✅ Extracted {len(itinerary)} days manually")
    return itinerary

If we successfully extracted at least one day, we’re done. 🎉


🛟Absolute Fallback

print("🔄 Using fallback structure")
return {
    "Day 1": {
        "morning": "City exploration and main attractions",
        "afternoon": "Museum visits and cultural sites",
        "evening": "Local dining experience"
    }
}

If we couldn’t parse a thing, we return a default single-day plan to keep your app alive and user experience smooth.


🧠 Pro Tip: Fallbacks aren’t failures — they’re backups for real-world resilience.


📌 AI-Powered Research with Ollama – Discovering Your Dream Destination

    async def research_destination(self, city: str, interests: List[str] = None) -> Dict:
        """Research using direct Ollama calls"""
        if interests is None:
            interests = ["attractions", "dining", "activities"]
        
        research_results = {}
        
        for interest in interests:
            query = f"{city} {interest} recommendations guide"
            rag_results = await self.rag_system.search(query, k=3)
            
            if self.use_ollama:
                try:
                    print(f"🔍 AI researching {interest}...")
                    
                    # Direct LLM call - no CrewAI
                    context = "\n".join([f"- {r.get('title', '')}: {r.get('content', '')}" for r in rag_results])
                    
                    # Add uniqueness to each call
                    session_id = int(time.time() * 1000) % 10000
                    styles = ["hidden gems", "local favorites", "insider secrets", "unique experiences", "off-beaten-path"]
                    style = random.choice(styles)
                    
                    prompt = f"""You are a creative travel expert. Based on this information about {city} {interest}:

{context}

Provide 4-5 specific {style} recommendations for {city} {interest}. Focus on variety and unique details.
Session ID: {session_id}
Style: {style}

Format as a numbered list with brief descriptions."""
                    
                    # Direct Ollama call
                    ai_response = self.llm.invoke(prompt)
                    
                    research_results[interest] = {
                        "rag_data": rag_results,
                        "ai_analysis": ai_response.content,
                        "style": style,
                        "session": session_id
                    }
                    print(f"✅ AI research for {interest} complete (style: {style})")
                    
                except Exception as e:
                    print(f"⚠️ AI research failed for {interest}: {e}")
                    research_results[interest] = rag_results
            else:
                research_results[interest] = rag_results
        
        return research_results

Okay, now let’s go bigger: What if you want your app to not just organize existing info, but actually gather it? Like, ask the AI to dig up cool places in Paris or find hidden foodie gems in Kyoto?


That’s exactly what this research_destination method does. It's your personalized travel research assistant powered by Ollama, blending retrieval with generative flair.

Let’s unpack this step-by-step.


🌍 Default to the Usual Travel Interests

if interests is None:
    interests = ["attractions", "dining", "activities"]

If no specific categories are passed in, we assume the user wants the basics: sights to see, where to eat, and things to do. It's a sensible default for most trip plans.


🎒 Pro Tip: You can customize this list to include niches like "art galleries", "coffee spots", or "adventure sports".


📚 Loop Through Each Interest Category

for interest in interests:
    query = f"{city} {interest} recommendations guide"

For each interest (like dining or attractions), we create a query string combining the city name and interest. This mimics what a user might search on Google.


🔍 Example: If city = "Barcelona" and interest = "dining" → the query becomes "Barcelona dining recommendations guide".


📦 Run a RAG Search

rag_results = await self.rag_system.search(query, k=3)

This line taps into your Retrieval-Augmented Generation (RAG) system — think of it like giving the AI a stack of relevant articles or blog posts before it writes anything.


🧠 It’s like handing your travel agent three brochures to use for planning.


🤖 If Ollama Is Available, Bring in the AI

if self.use_ollama:
    try:
        print(f"🔍 AI researching {interest}...")

If we’re set up to use Ollama, we print a little log and continue to generate the response with the LLM.


🧾 Build the AI's Context

context = "
".join([
    f"- {r.get('title', '')}: {r.get('content', '')}" for r in rag_results
])

We build a prompt-ready string from the top 3 RAG results. Each one is formatted as a title and summary.


✏️ Example Line: - Top 10 Cafes in Rome: Try Caffe Greco, Roscioli, and Faro.


🎨 Add a Unique Style to the Response

session_id = int(time.time() * 1000) % 10000
styles = ["hidden gems", "local favorites", "insider secrets", "unique experiences", "off-beaten-path"]
style = random.choice(styles)

We randomly pick a tone or theme (like "hidden gems") and generate a unique session ID just to add variety and distinguish each prompt.


🎭 Pro Tip: Using different "styles" nudges the model to write more interesting and diverse content.


🧠 Construct the AI Prompt

prompt = f"""You are a creative travel expert..."""

This full prompt includes the context, the interest area, the selected style, and clear formatting instructions.

It ends with:

Format as a numbered list with brief descriptions.

Gotcha: Giving clear formatting instructions increases the chance the model returns structured, usable text.


💬 Get a Response from Ollama

ai_response = self.llm.invoke(prompt)

Now we invoke the AI model with our crafted prompt. The model will return something like:

1. La Boqueria Bites – Try tapas at this iconic market...
2. Bar Cañete – A classy, cozy spot for seafood and cava...

📦 Save the Research

research_results[interest] = {
    "rag_data": rag_results,
    "ai_analysis": ai_response.content,
    "style": style,
    "session": session_id
}

We store the AI response, the RAG data it used, the random style, and the session ID — giving us full traceability and reuse potential.


📚 Pro Tip: Saving the session style and ID lets you replay or version your research later.


🧯 Handle AI Failures Gracefully

except Exception as e:
    print(f"⚠️ AI research failed for {interest}: {e}")
    research_results[interest] = rag_results

If anything fails (e.g. timeout, prompt issues), we fall back to just using the raw RAG results.


🔁 Skip AI If Ollama Isn’t Set

else:
    research_results[interest] = rag_results

If we’re not using Ollama, we still return the RAG results so the app continues working — just with less creativity.


📤 Return the Dictionary

return research_results

Now we've got a neat dictionary where each interest (like "attractions") maps to a response loaded with RAG context and AI insights.


🧳 Mission Accomplished: You just taught your app how to research like a local expert.


🧠 AI-Based Itinerary Generation via Prompt Engineering

    async def _direct_ollama_itinerary(self, city: str, days: int, research_data: Dict) -> Dict:
        """Direct Ollama itinerary creation with improved JSON handling"""
        # Prepare context from file-based research
        context = f"Travel research for {city}:\n"
        for category, data in research_data.items():
            if isinstance(data, dict) and "ai_analysis" in data:
                context += f"\n{category.title()}:\n{data['ai_analysis']}\n"
            elif isinstance(data, list):
                for item in data:
                    context += f"\n{category.title()} - {item.get('title', '')}:\n{item.get('content', '')}\n"
        # Add uniqueness
        session_id = int(time.time() * 1000) % 10000
        themes = ["cultural discovery", "foodie adventure", "artistic exploration", "local immersion", "hidden treasures"]
        theme = random.choice(themes)
        
        # Improved prompt with better JSON structure guidance
        prompt = f"""Create a {days}-day {city} itinerary with theme: {theme}
Research context from local data files:
{context}
Session: {session_id}
Theme: {theme}
IMPORTANT: Return ONLY valid JSON with this exact structure:
{{
  "Day 1": {{
    "morning": "specific morning activity description",
    "afternoon": "unique afternoon experience description", 
    "evening": "memorable evening activity description"
  }},
  "Day 2": {{
    "morning": "different morning activity description",
    "afternoon": "creative afternoon option description",
    "evening": "distinctive evening choice description"
  }}
}}
Continue for all {days} days. 
- Use double quotes for all strings
- No trailing commas
- Keep descriptions concise but specific
- Focus on {theme} theme
- Make each day unique and interesting
- Use the research context provided
Return ONLY the JSON, no other text or explanation."""
        # Direct call with retry logic
        max_retries = 3
        for attempt in range(max_retries):
            try:
                log(f"[INFO] Attempt {attempt + 1}/{max_retries}")
                response = self.llm.invoke(prompt)
                # Extract and parse JSON
                itinerary = self.extract_json_from_text(response.content)
                
                # Validate the structure
                if isinstance(itinerary, dict) and len(itinerary) > 0:
                    # Ensure we have the right number of days
                    if len(itinerary) != days:
                        log(f"[WARN] Expected {days} days, got {len(itinerary)}, adjusting...")
                        itinerary = self._adjust_itinerary_days(itinerary, days)
                    log(f"[OK] AI itinerary created with theme: {theme}")
                    return itinerary
                else:
                    log(f"[WARN] Invalid itinerary structure on attempt {attempt + 1}")
            except Exception as e:
                log(f"[WARN] Attempt {attempt + 1} failed: {e}")
                if attempt == max_retries - 1:
                    log("[INFO] All attempts failed, falling back to rule-based")
                    break
        return await self._rule_based_itinerary(city, days, research_data)

This is like working with a brilliant but sometimes distracted artist. We give them all our research, a theme (like "foodie adventure"), and ask for a creative itinerary. But because artists can be unpredictable, we:

  • Give very specific instructions (like commissioning a painting with exact dimensions)

  • Try up to 3 times if the first attempt doesn't work

  • Have quality control to check the results

  • Fall back to a more structured approach if needed


📚 Assemble a Rich Context from Local Research

context = f"Travel research for {city}:\n"
for category, data in research_data.items():
    if isinstance(data, dict) and "ai_analysis" in data:
        context += f"\n{category.title()}:\n{data['ai_analysis']}\n"
    elif isinstance(data, list):
        for item in data:
            context += f"\n{category.title()} - {item.get('title', '')}:\n{item.get('content', '')}\n"
  • Builds a formatted string containing all available insights.

  • Supports both:

    • Summarized AI analysis (dict form).

    • Raw listings (list of places/events with title/content).

  • Resulting context is injected into the prompt to guide the LLM.


🎲 Add Variation and Theming

session_id = int(time.time() * 1000) % 10000
themes = ["cultural discovery", "foodie adventure", "artistic exploration", "local immersion", "hidden treasures"]
theme = random.choice(themes)
  • Generates a session-specific ID (to add uniqueness across requests).

  • Randomly selects a thematic experience style for the trip (e.g., food, culture).


🧾 Construct the Prompt with JSON Constraints

prompt = f"""Create a {days}-day {city} itinerary with theme: {theme}

Research context from local data files:
{context}

Session: {session_id}
Theme: {theme}

IMPORTANT: Return ONLY valid JSON with this exact structure:
...
"""
  • The prompt:

    • Injects the travel context

    • Specifies required fields (morning, afternoon, evening) per day

    • Provides strict JSON rules (no trailing commas, no extra output)

  • Instructions clearly enforce valid output format to minimize post-processing errors.


🔁 Robust LLM Call with Retry Logic

max_retries = 3
for attempt in range(max_retries):
    try:
        log(f"[INFO] Attempt {attempt + 1}/{max_retries}")
        response = self.llm.invoke(prompt)
  • Uses a retry loop (max 3 attempts) to ensure resilience against API or parsing failures.

  • Logs attempt count before invoking the model.


🧪 Parse, Validate, and Adjust the Itinerary

itinerary = self.extract_json_from_text(response.content)

if isinstance(itinerary, dict) and len(itinerary) > 0:
    if len(itinerary) != days:
        log(f"[WARN] Expected {days} days, got {len(itinerary)}, adjusting...")
        itinerary = self._adjust_itinerary_days(itinerary, days)

    log(f"[OK] AI itinerary created with theme: {theme}")
    return itinerary
  • Parses JSON from the response.

  • If the structure is valid but day count mismatches, adjusts the result using adjustitinerary_days.

  • Logs success before returning the result.


🛑Handle Failure After Retries

except Exception as e:
    log(f"[WARN] Attempt {attempt + 1} failed: {e}")
    if attempt == max_retries - 1:
        log("[INFO] All attempts failed, falling back to rule-based")
        break
return await self._rule_based_itinerary(city, days, research_data)
  • If all retries fail, logs the final failure.

  • Falls back to a local, rule-based itinerary generator.


🛠️ Match Output with Desired Length

    def _adjust_itinerary_days(self, itinerary: Dict, target_days: int) -> Dict:
        """Adjust itinerary to match target number of days"""
        current_days = len(itinerary)
        if current_days == target_days:
            return itinerary
        if current_days > target_days:
            # Trim excess days
            return {k: v for i, (k, v) in enumerate(itinerary.items()) if i < target_days}
        # Add missing days
        activities = [
            "Explore local neighborhoods",
            "Visit museums and galleries", 
            "Food market tour",
            "Walking tour of historic areas",
            "Local shopping experience",
            "Park and garden visits",
            "Cultural performance or show"
        ]
        for day_num in range(current_days + 1, target_days + 1):
            day_key = f"Day {day_num}"
            activity = activities[(day_num - 1) % len(activities)]
            itinerary[day_key] = {
                "morning": f"{activity} - morning exploration",
                "afternoon": f"{activity} - afternoon continuation",
                "evening": "Local dining and evening relaxation"
            }
        return itinerary

Ensures that the final itinerary contains exactly the number of days specified.


✂️ Case 1: Truncate If Too Many Days

if current_days > target_days:
    return {k: v for i, (k, v) in enumerate(itinerary.items()) if i < target_days}

Keeps only the first target_days entries using enumerate.


➕ Case 2: Fill in If Too Few Days

activities = [
    "Explore local neighborhoods",
    "Visit museums and galleries", 
    "Food market tour",
    "Walking tour of historic areas",
    "Local shopping experience",
    "Park and garden visits",
    "Cultural performance or show"
]

Defines a fallback list of generic but engaging activities.

for day_num in range(current_days + 1, target_days + 1):
    day_key = f"Day {day_num}"
    activity = activities[(day_num - 1) % len(activities)]
    
    itinerary[day_key] = {
        "morning": f"{activity} - morning exploration",
        "afternoon": f"{activity} - afternoon continuation",
        "evening": "Local dining and evening relaxation"
    }
  • Loops through missing days.

  • For each day:

    • Chooses an activity theme using modular indexing (to cycle).

    • Adds a morning/afternoon/evening plan using that theme.


Generating an Itinerary

    async def _rule_based_itinerary(self, city: str, days: int, research_data: Dict) -> Dict:
        """Rule-based itinerary using file-based data"""
        # Get city-specific data from files
        city_docs = self.rag_system.get_documents_by_city(city)
        
        # Extract activities from file data
        activities = []
        restaurants = []
        
        for doc in city_docs:
            if doc['category'] == 'attractions':
                # Extract individual attractions from content
                content = doc['content']
                sentences = content.split('. ')
                for sentence in sentences[:3]:  # Take first 3 activities
                    if len(sentence.strip()) > 20:
                        activities.append(sentence.strip())
            
            elif doc['category'] == 'dining':
                # Extract restaurants from content
                content = doc['content']
                sentences = content.split('. ')
                for sentence in sentences[:3]:  # Take first 3 restaurants
                    if len(sentence.strip()) > 20:
                        restaurants.append(sentence.strip())
        
        # Fallback activities if no file data
        if not activities:
            activities = [
                f"Explore main attractions in {city}",
                f"Visit museums and galleries in {city}",
                f"Walking tour of historic {city}",
                f"Local markets and shopping in {city}",
                f"Parks and gardens of {city}"
            ]
        
        if not restaurants:
            restaurants = [
                f"Traditional {city} dining experience",
                f"Local {city} café culture",
                f"Street food tour in {city}",
                f"Fine dining in {city}",
                f"Local {city} specialties"
            ]
        
        # Add randomization
        random.shuffle(activities)
        random.shuffle(restaurants)
        
        itinerary = {}
        for day in range(1, days + 1):
            itinerary[f"Day {day}"] = {
                "morning": activities[(day-1) % len(activities)],
                "afternoon": activities[(day-1+2) % len(activities)],
                "evening": restaurants[(day-1) % len(restaurants)]
            }
        
        return itinerary

This is our experienced travel agent who doesn't need fancy AI - they know the business inside and out. They:

  1. Extract activities from our data files

  2. Create sensible daily schedules

  3. Add some randomization so it's not too predictable

  4. Ensure every day has morning, afternoon, and evening plans


Even if everything else fails, this method always produces a usable itinerary. It's like having a dependable car when the sports car is in the shop!


📂 Retrieve Relevant City Documents

city_docs = self.rag_system.get_documents_by_city(city)

Pulls only those documents from the knowledge base (RAG system) that match the selected city.


🏞️ Extract Attractions and Dining Suggestions

activities = []
restaurants = []

Initializes two lists to store cleaned and structured content.

for doc in city_docs:
    if doc['category'] == 'attractions':
        content = doc['content']
        sentences = content.split('. ')
        for sentence in sentences[:3]:
            if len(sentence.strip()) > 20:
                activities.append(sentence.strip())
                
    elif doc['category'] == 'dining':
        content = doc['content']
        sentences = content.split('. ')
        for sentence in sentences[:3]:
            if len(sentence.strip()) > 20:
                restaurants.append(sentence.strip())
  • Splits each document into short sentences and picks up to 3 meaningful entries per category.

  • Filters out short or irrelevant content (< 20 characters).


🔄 Fallbacks for Sparse Data

if not activities:
    activities = [
        f"Explore main attractions in {city}",
        f"Visit museums and galleries in {city}",
        f"Walking tour of historic {city}",
        f"Local markets and shopping in {city}",
        f"Parks and gardens of {city}"
    ]
if not restaurants:
    restaurants = [
        f"Traditional {city} dining experience",
        f"Local {city} café culture",
        f"Street food tour in {city}",
        f"Fine dining in {city}",
        f"Local {city} specialties"
    ]

If no data was extracted, provides default content placeholders based on city name.


🎲 Shuffle for Variation

random.shuffle(activities)
random.shuffle(restaurants)

Randomizes order to make each itinerary appear unique across sessions.


📅 Construct Itinerary Dictionary

itinerary = {}
for day in range(1, days + 1):
    itinerary[f"Day {day}"] = {
        "morning": activities[(day-1) % len(activities)],
        "afternoon": activities[(day-1+2) % len(activities)],
        "evening": restaurants[(day-1) % len(restaurants)]
    }
  • Uses modulo to cycle through the shuffled data.

  • Ensures no IndexError even if fewer items than days.


🖋️ Formats Output Using Either LLM or Rules

    async def format_itinerary(self, city: str, itinerary: Dict, research_data: Dict = None) -> str:
        """Format with direct Ollama"""
        if self.use_ollama:
            try:
                log("AI formatting itinerary...")
                return await self._direct_ollama_format(city, itinerary, research_data)
            except Exception as e:
                log(f"AI formatting failed: {e}", "WARN")
        
        return await self._rule_based_format(city, itinerary, research_data)

Unified method to format any itinerary using either LLM-based or fallback rendering.

We've got all the planning done, but now we need to package it beautifully. This is like having a graphic designer who takes our rough notes and creates a gorgeous, professional travel guide.

if self.use_ollama:
    try:
        log("AI formatting itinerary...")
        return await self._direct_ollama_format(city, itinerary, research_data)
    except Exception as e:
        log(f"AI formatting failed: {e}", "WARN")

If AI formatting is enabled, attempts to call directollama_format. Logs failure if any.

return await self._rule_based_format(city, itinerary, research_data)

Defaults to fallback Markdown generator if AI is disabled or fails.


Generating Engaging Markdown via LLM

    async def _direct_ollama_format(self, city: str, itinerary: Dict, research_data: Dict = None) -> str:
        """Direct Ollama formatting"""
        itinerary_text = json.dumps(itinerary, indent=2)
        session_id = int(time.time() * 1000) % 10000
        
        prompt = f"""Format this {city} itinerary as professional, engaging Markdown:

{itinerary_text}

Session: {session_id}

Create:
1. Creative title for {len(itinerary)}-day {city} itinerary
2. Brief intro paragraph
3. Summary table: Day | Morning | Afternoon | Evening
4. Detailed daily sections with specific times
5. Travel tips section
6. Personal touches and local insights

Make it engaging and informative with personality!"""
        response = self.llm.invoke(prompt)
        return response.content

This hands our itinerary to a talented travel blogger who transforms our basic schedule into an engaging, personality-filled guide with:

  • Creative titles

  • Engaging introductions

  • Professional formatting

  • Personal touches and local insights


🧾 Prepare the Input Data

itinerary_text = json.dumps(itinerary, indent=2)
session_id = int(time.time() * 1000) % 10000
  • Converts the structured itinerary into human-readable JSON using indentation.

  • Creates a pseudo-random session ID using the current time. This adds uniqueness to each session, which can be helpful for logging, debugging, or differentiating multiple itinerary generations.


Craft the Prompt for the LLM

prompt = f"""Format this {city} itinerary as professional, engaging Markdown:
...
"""
  • Prompt includes:

    • Title

    • Intro paragraph

    • Table summary

    • Detailed timeline

    • Travel tips

    • Local flair

response = self.llm.invoke(prompt)
return response.content

This prompt is a complete set of instructions for the LLM. It tells the model to return a polished travel blog-like output containing:

  • A creative and catchy title for the itinerary

  • A warm introduction paragraph to set the tone

  • A summary table for quick viewing

  • Well-structured daily breakdowns with times of day

  • A dedicated travel tips section with actionable advice

  • Personal touches that feel human and relatable

The language used ensures the output is not generic or robotic, but rather full of personality and utility.


🧾 Markdown Output Without LLM

    async def _rule_based_format(self, city: str, itinerary: Dict, research_data: Dict = None) -> str:
        """Rule-based formatting with variety"""
        days = len(itinerary)
        session_id = int(time.time() * 1000) % 10000
        
        # Get available cities and categories for info
        available_cities = self.rag_system.get_cities()
        available_categories = self.rag_system.get_categories()
        
        markdown = f"# {days}-Day Travel Itinerary for {city.title()}\n\n"
        markdown += f"*Generated by Direct Ollama MCP Travel Planner with File-Based Data - Session {session_id}*\n\n"
        
        # Add data source info
        markdown += f"**Data Sources**: {len(self.rag_system.documents)} travel documents covering {len(available_cities)} cities\n"
        markdown += f"**Available Cities**: {', '.join(available_cities)}\n"
        markdown += f"**Categories**: {', '.join(available_categories)}\n\n"
        
        # Summary table
        markdown += "## Daily Summary\n\n"
        markdown += "| Day | Morning | Afternoon | Evening |\n"
        markdown += "|-----|---------|-----------|----------|\n"
        
        for day, schedule in itinerary.items():
            morning = schedule.get('morning', '')[:45] + "..." if len(schedule.get('morning', '')) > 45 else schedule.get('morning', '')
            afternoon = schedule.get('afternoon', '')[:45] + "..." if len(schedule.get('afternoon', '')) > 45 else schedule.get('afternoon', '')
            evening = schedule.get('evening', '')[:45] + "..." if len(schedule.get('evening', '')) > 45 else schedule.get('evening', '')
            
            markdown += f"| {day} | {morning} | {afternoon} | {evening} |\n"
        
        # Detailed plans
        markdown += "\n## Detailed Daily Plans\n\n"
        
        for day, schedule in itinerary.items():
            markdown += f"### {day}\n\n"
            markdown += f"**Morning (9:00 - 12:00)**\n{schedule.get('morning', '')}\n\n"
            markdown += f"**Afternoon (13:00 - 17:00)**\n{schedule.get('afternoon', '')}\n\n"
            markdown += f"**Evening (18:00 - 21:00)**\n{schedule.get('evening', '')}\n\n"
            markdown += "---\n\n"
        
        # Add tips
        tips = [
            "Research public transportation options for efficient travel",
            "Book popular attractions and restaurants in advance",
            "Carry local currency and a backup payment method",
            "Learn key phrases in the local language",
            "Check opening hours and seasonal closures",
            "Pack comfortable walking shoes",
            "Download offline maps and translation apps"
        ]
        
        markdown += "## Travel Tips\n\n"
        for tip in random.sample(tips, 4):
            markdown += f"- {tip}\n"
        
        markdown += f"\n*Enhanced with Direct Ollama AI and File-Based Data - {time.strftime('%Y-%m-%d %H:%M:%S')}*\n"
        
        return markdown

This is our reliable office manager who takes our itinerary and formats it into a clean, professional document using proven templates. It includes:

  • Summary tables (like a spreadsheet view)

  • Detailed daily sections (like a proper agenda)

  • Travel tips (like a helpful checklist)

  • Metadata about the planning process


Every output follows the same professional format - like having branded letterhead for our travel agency!


🗓️ Calculate Days and Session Metadata

days = len(itinerary)
session_id = int(time.time() * 1000) % 10000
  • Calculates the number of days by checking how many entries are in the itinerary.

  • Creates a pseudo-unique session ID using the current timestamp.

This ID is useful for debugging or traceability.


🏙️ Gather City & Category Context

available_cities = self.rag_system.get_cities()
available_categories = self.rag_system.get_categories()

Queries the RAG system for:

  • A list of available cities in the dataset.

  • A list of categories (e.g., dining, sights, entertainment).

This provides contextual metadata in the final output.


🧾 Start the Markdown Output

markdown = f"# {days}-Day Travel Itinerary for {city.title()}\n\n"
markdown += f"*Generated by Direct Ollama MCP Travel Planner with File-Based Data - Session {session_id}*\n\n"

Adds a header and subtext to the Markdown document. This helps users understand what tool generated the itinerary and links it to a session.


📂 Add Metadata for Transparency

markdown += f"**Data Sources**: {len(self.rag_system.documents)} travel documents covering {len(available_cities)} cities\n"
markdown += f"**Available Cities**: {', '.join(available_cities)}\n"
markdown += f"**Categories**: {', '.join(available_categories)}\n\n"

Lists the number of documents and other metadata to build user trust — they can see the breadth of data used.


📊 Create a Summary Table

markdown += "## Daily Summary\n\n"
markdown += "| Day | Morning | Afternoon | Evening |\n"
markdown += "|-----|---------|-----------|----------|\n"

Begins a simple Markdown table to give users a quick overview of their daily plans.


🔄 Populate the Summary Table with Truncated Content

for day, schedule in itinerary.items():
    morning = schedule.get('morning', '')[:45] + "..." if len(schedule.get('morning', '')) > 45 else schedule.get('morning', '')
    ...
    markdown += f"| {day} | {morning} | {afternoon} | {evening} |\n"
  • Truncates long text to keep the table readable.

  • Adds each day’s activities to the table.

This section gives a scannable, high-level view of the itinerary.


📅 Add Detailed Daily Sections

markdown += "\n## Detailed Daily Plans\n\n"

Adds a section for in-depth exploration of each day.

for day, schedule in itinerary.items():
    markdown += f"### {day}\n\n"
    markdown += f"**Morning (9:00 - 12:00)**\n{schedule.get('morning', '')}\n\n"
    ...

Each day is broken down into specific time blocks with user-friendly headings.A horizontal line (---) separates days for visual clarity.


💡 Add Helpful Travel Tips

tips = [
    "Research public transportation options...",
    ...
]

Defines a curated list of useful travel tips. Then randomly selects 4 to keep each itinerary fresh and varied:

for tip in random.sample(tips, 4):
    markdown += f"- {tip}\n"

This adds practical value and encourages safe, prepared travel.


🧾 Add Footer for Traceability

markdown += f"\n*Enhanced with Direct Ollama AI and File-Based Data - {time.strftime('%Y-%m-%d %H:%M:%S')}*\n"

A footer reminds users that this was generated by an AI-enhanced system, along with a timestamp.


Final Return

return markdown

Returns the complete Markdown string — clean, informative, and ready to publish.


🌍 Running the MCP Server for File-Based Travel Planning

In this section, we’ll set up the core engine of our AI-powered travel planner. This script connects everything: the RAG system, Direct Ollama AI agents, and FastMCP tools. It provides a seamless interface for itinerary generation, city research, and custom data management — all using local .txt files.


Global Initialization

rag_system = None
travel_agents = None

We begin by defining two global instances:

  • rag_system: Will hold our RAG interface to access structured file data.

  • travel_agents: Will serve as our planner, formatting engine, and AI orchestrator.

These are initially set to None and initialized during startup.


🚀 MCP Server Instance

mcp = FastMCP("Direct Ollama Travel Planner with File Data")

Here we create a FastMCP instance named "Direct Ollama Travel Planner with File Data". This MCP server will act as the main API server, where tools like itinerary generation and research will be registered.




Transform Your AI Workflows with Codersarts

Whether you're building intelligent systems with MCP, implementing RAG for smart information retrieval, or developing robust multi-agent architectures, the experts at Codersarts are here to support your vision. From academic prototypes to enterprise-grade solutions, we provide:


  • Custom RAG Implementation: Build retrieval-augmented generation systems tailored to your domain

  • MCP-Based Agent Systems: Design and deploy modular, coordinated AI agents with FastMCP

  • Semantic Search with FAISS: Implement efficient vector search for meaningful content discovery

  • End-to-End AI Development: From setup and orchestration to deployment and optimization


Do not let architectural complexity or tooling challenges slow down your progress. Partner with Codersarts and bring your next-generation AI systems to life.


Ready to get started? Visit Codersarts.com or connect with our team to discuss your MCP or RAG-based project.The future of modular, intelligent automation is here – let’s build it together!



Comments


bottom of page