Blog

Building with AI

Don't build a monolithic AI brain. Use the right tools, and build a manager.

"Show me houses downtown."

For decades, this simple human request has been software's nightmare. Traditional applications could only respond with rigid dropdowns: "Select city," "Choose neighborhood," "Enter exact address." The gap between how humans communicate and how software was annoyingly unbridgeable.

Not so much anymore. With Generative AI, we can finally get computers to understand what we mean, not just the raw mechanics of what we say. The question is no longer whether computers can bridge this gap, it's how to architect systems that leverage this breakthrough while keeping the precision and reliability that traditional software excels at.

I see a lot of AI users trying to leverage this fancy new tech as a sovereign, do-everything brain. This is a mistake. The answer in many cases isn't to replace our robust, specialized tools with an all-knowing AI. Instead, the most effective architecture uses the LLM as a translation layer. Its primary job is to manage the conversation, understand the user's fuzzy intent, and convert it into structured queries that our specialized tools can execute perfectly.

In this blog, we'll explore a real-estate use case to show what this looks like in the real world, because it perfectly illustrates the challenge: people think in terms of neighborhoods and landmarks, but databases need coordinates and precise boundaries.

Anti-Patterns: What NOT to Do

The "LLM does everything" approach is tempting but flawed. Here are a few examples.

Anti-Pattern #1: The Hallucinating Expert

Some might try to prompt the LLM to be a real estate expert with direct access to data.

def search_properties_bad(user_query: str) -> str:
    prompt = f"""
    You are a real estate expert with perfect knowledge of all properties.
    User asks: {user_query}

    Please list specific properties that match this request, including:
    - Exact addresses
    - Current prices
    - Property details
    - Availability status
    """

This fails because LLMs are not databases. They will hallucinate plausible-sounding addresses and prices for properties that don't exist. This is dangerously misleading.

Anti-Pattern #2: The Overloaded Prompt
def search_properties_worse(user_query: str) -> str:
    prompt = f"""
    You are a real estate database. Follow these rules:
    1. If user says "downtown", search coordinates 47.6062,-122.3321 radius 2km
    2. If user says "house", filter PropertyType = 'Single Family'
    3. If user says "under 800k", set MaxPrice = 800000
    4. If user says "near work", ask for work address first
    5. If no results, expand radius by 1km and try again
    6. If still no results, expand by 2km more
    ... [50 more rules]

    Query: {user_query}
    Execute this search and return JSON results.
    """

This fails because you're asking the LLM to be a program executor. It will inconsistently apply rules, forget edge cases, and fail at the kind of systematic, stateful logic that traditional code handles perfectly.

The LLM as the Translator

To build a robust AI-powered application, we must treat the LLM as an interpreter, not a monolith. Its role is to understand the nuances of a conversation and translate them into structured parameters that specialized tools can act upon.

Human Intent → LLM Translation → Specialized Tools → Reliable Results

This architecture plays to each component's strengths:

  • LLMs: Excel at understanding context, handling ambiguity, and extracting intent.
  • Specialized APIs: Excel at precision, real-time data, and deep domain expertise (like geocoding).
  • Traditional Databases & Code: Excel at structured queries, consistency, performance, and systematic logic.

This translation layer approach resolves the core tension in AI application development. We get the fluid, natural experience of conversational UI without sacrificing the reliability of traditional software.

Before: Users were forced to think like databases.

  • "Select city: Toronto"
  • "Select neighborhood: Downtown"
  • "Enter price range: 700000 to 800000"

Now: Software understands human communication.

  • "Show me houses downtown under 800k"
  • "What's available around the Blue Mountains?"
  • "Something near my work but quieter"

Our Approach: LLM translates intent → Specialized tools execute with precision → LLM formats results naturally.

Real-World Implementation

Enough talk, let's go through a concrete example!

Step 1: Intent Extraction

First, the LLM receives the raw query. Its task is to translate the user's natural language into a structured object. We guide and constrain this process using function calling with a strict Pydantic schema. This schema is the LLM's instruction manual.

from pydantic import BaseModel, Field
from typing import Optional, Literal

class SearchListingsParams(BaseModel):
    """Parameters for searching real estate listings."""

    location: str = Field(
        description="Location mentioned by user, extracted exactly as stated."
    )

    # Guide the LLM on how to categorize property types
    property_type_group: Optional[Literal["House", "Condo / Apartment"]] = Field(
        default=None,
        description="""Type of property. IMPORTANT: Always set this field when:
        - User mentions 'house', 'home', 'detached' -> set to 'House'
        - User mentions 'condo', 'apartment', 'unit' -> set to 'Apartment'"""
    )

    # Instruct the LLM on handling relative price language
    min_price: Optional[int] = Field(
        default=None,
        description="Minimum price. For 'around X', use X * 0.9. For 'over X', use X."
    )

    max_price: Optional[int] = Field(
        default=None,
        description="Maximum price. For 'around X', use X * 1.1. For 'under X', use X."
    )

For our user's query, the LLM uses this schema to produce the following structured output:

{
  "location": "downtown",
  "property_type_group": "House",
  "max_price": 800000
}

The LLM has successfully done its job. It translated fuzzy intent into a clean, machine-readable format.

Step 1b: Intent Clarification

What if the user's request is ambiguous? A traditional geocoder would fail with a query like "houses near the park." The LLM, however, can recognize this ambiguity and turn it into a conversation.

Before proceeding to the geocoding step, our application logic can check the extracted parameters for ambiguity. If location is a generic term like "downtown" or "the park" without a clear city or state context, the system doesn't call the tool. Instead, it uses the LLM to ask a clarifying question.

This conversational loop is something specialized tools simply can't do. It makes the application more resilient and user-friendly, handling a wider range of natural human inputs without forcing the user to start over. Once the user provides the missing information (e.g., "Toronto"), the system updates the parameters and confidently proceeds to the next phase.

Step 2: Geographic Grounding

Now, we hand off the structured output to specialized tools. The location: "downtown, toronto" string is passed to a standard geocoding service, like Google's Places API. This is a tool built and perfected over years to do one thing exceptionally well: resolve location strings into precise geographic data.

def get_location_bounds_and_type(location):
    """Enhanced version that returns both viewport and location type info"""
    base_url = "https://maps.googleapis.com/maps/api/place/textsearch/json"
    params = {"query": location}
    response = requests.get(base_url, params=params)
    if response.status_code == 200:
        results = response.json().get('results', [])
        if results:
            top_result = results[0]
            location_name = top_result.get('name')
            viewport = (
              top_result
                .get("geometry", {})
                .get("viewport", {})
            )
        return top_result, viewport, location_name

The geocoding API takes "downtown, toronto" and returns a precise bounding box. The LLM never had to memorize a single map; the specialized tool handled it instantly.

With a max_price, a property_type_group, and a geographic viewport, our traditional application code takes over. This is where we implement the systematic, reliable logic that LLMs are not suited for.

A great example is a cascading search strategy. We first search within the precise viewport. If we find nothing, we don't give up. Instead, we systematically broaden the search radius until we get results.

# First, attempt a search with the precise viewport from the geocoder
results = database_query(viewport=viewport, max_price=800000, type="House")
total_count = len(results)

# If no results, systematically expand the search radius
if total_count == 0 and viewport:
  logger.info("No results found in precise area. Expanding search radius...")
  for km in [1, 2, 5, 10]:
    logger.info(f"Trying expansion by {km}km")
    expanded_viewport = expand_viewport_by_km(viewport, km)
    results = database_query(viewport=expanded_viewport, ...)
    total_count = len(results)
    if total_count > 0:
      logger.info(f"Found {total_count} results after expanding by {km}km.")
      break

This fallback logic is deterministic, predictable, and handles edge cases gracefully, exactly what robust software is for.

Step 4: Human-Friendly Results

Finally, we package the results for the user. We don't just return a list of database records. We provide context, confirming what the system did. We feed the structured search results (e.g., a JSON object with 12 listings) back to the LLM with a final prompt: 'You are a friendly real estate assistant. Summarize these findings for the user, highlighting 1-2 key features of the top results.' This transforms raw data into a helpful, conversational response.

"Okay, I found 12 houses in Downtown Toronto under $800,000. The first one has a lovely, remodeled kitchen with..."

A Helpful, Universal Pattern

This approach resolves the core tension in AI application development. We get the fluid, natural experience of conversational UI without sacrificing the reliability of traditional software.

This pattern has implications far beyond real estate:

  • Financial Tech: Translating "Show me tech stocks that are less volatile than the NASDAQ" into precise market data API calls.
  • Travel: Understanding "I want a warm, cheap beach vacation in Europe in March" and converting it into structured flight, hotel, and weather API queries.
  • E-commerce: Interpreting "I need something like my last order but cheaper" into a complex query involving user history, product similarity, and price filters.

In summary: Let each part of your system do what it does best. Let the LLM translate messy human intent. Let specialized tools execute with precision. And let your application code handle the resilient business logic. By designing systems that play to these individual strengths, you can build applications that feel magical: understanding what users mean, not just what they say, while delivering the consistent, reliable results that build trust.

Next steps

Ready to talk about your next project?

1

Tell us more about your custom needs.

2

We’ll get back to you, really fast

3

Kick-off meeting

Let's Talk