Skip to content

← Case studiesAI-powered travel planning platform

A multi-agent travel platform for destination discovery, itinerary generation and personalized trip planning: RAG-based destination Q&A over structured travel data, with agent workflows for preference collection, day-by-day planning, budgeting and contextual guidance.

Role

Backend & agent systems engineer

Timeline

2024

01Overview & problem

Trip planning is a research problem wearing a shopping-cart UI. Travelers bounce between blogs, maps, review sites and spreadsheets, and generic LLM chat gives confident itineraries full of closed attractions and imaginary travel times because it plans from memory, not from data.

Tourtoise approaches it as a systems problem: structured destination data in PostgreSQL, semantic retrieval over that data with pgvector, and a pipeline of specialized agents — preference collection, planning, budgeting and guidance — each with a narrow job and an explicit contract. The output is a day-by-day itinerary that is grounded in the destination corpus and honest about the traveler's budget.

Who it affected

Travelers planning unfamiliar trips — especially with constraints like pace, interests and hard budget caps that no static template can respect.

Why it mattered

An itinerary is a promise about the real world. Grounding every suggestion in retrieved destination data — and validating it against a budget agent — is what separates a planning tool from a fiction generator.

10Media gallery

02Constraints & goals

Grounding

Retrieval-first answers

No destination facts from model memory

Structure

Typed itinerary output

Days, items, costs — not free text

Budget

Hard caps respected

Budget agent validates before persist

Pipeline

Async generation

Multi-minute planning runs off the request path

Corpus

Structured + semantic

Tags/filters + vector similarity (hybrid)

Team

Solo

Maximize leverage per moving part

03System architecture

100%
RESTplan tripretrievegeneratepersistTraveler WebDjango APIauth · tripsADK Multi-Agent Corepref · planner · budget · guideRAG LayerPostgreSQL + pgvectorGeminiItinerarydays · items · budget
Request path, multi-agent core, retrieval layer and generation.
  • The orchestrator is a state machine, not a loop: each stage validates its input contract before running, so a bad preference object fails fast at the boundary.

  • Agents share a typed TripState object (preferences → draft → priced draft → enriched plan) instead of passing raw prompt strings.

  • Retrieval is hybrid: structured filters (country, tags, season) narrow the candidate set before vector similarity ranks within it.

  • Diagram is illustrative — it reflects the design, not an exact deployment map.

04Data model & database design

100%
1 : N1 : 11 : N1 : N1 : N1 : N1 : 1users[PK] iduuid[IDX] emailcitextnametextcreated_attimestamptztrips[PK] iduuid[FK] user_iduuidtitletextdestination_querytextstart_datedate[IDX] statusenumtrip_preferences[PK] iduuid[FK] trip_iduuidpaceenumintereststext[]budget_totalnumerictravelersintdestinations[PK] iduuid[IDX] nametext[IDX] countrytexttagstext[]summarytextdestination_chunks[PK] iduuid[FK] destination_iduuidcontenttext[VEC] embeddingvector(768)[IDX] sourcetextbudgets[PK] iduuid[FK] trip_iduuidplanned_totalnumericactual_totalnumericcurrencychar(3)itinerary_days[PK] iduuid[FK] trip_iduuid[IDX] day_numberintdatedatesummarytextitinerary_items[PK] iduuid[FK] day_iduuid[FK] destination_iduuidtime_slotenumtitletextcost_estnumeric
Trips, preferences, the destination corpus and the generated itinerary.

Why this schema

The schema separates the corpus (destinations, chunks) from the artifact (trips → days → items). The planner writes a typed itinerary — not a text blob — so days and items can be edited, re-priced and re-generated individually. Preferences and budgets are 1:1 side tables because they're pipeline stage outputs: each agent's contract is literally a table.

  • itinerary_items.destination_id is nullable — transit days and rest days are first-class plan items.

  • GiST indexes on tags/text[] support the structured half of hybrid retrieval before vector ranking.

  • cost_est lives on items so the budget agent validates line-by-line, and re-planning a single day doesn't re-price the trip.

09Tech stack

Layers as swimlanes, tools as nodes. Hover or tap a tool to pin it — its connections trace across layers, with the why in the side panel.
stack.yaml5 layers · 8 tools
L1API
L2AI
L3Data
L4Infra
L5Observability

05Engineering decisions

Decision

Four narrow agents with typed handoffs, sequenced by an orchestrator state machine.

Alternatives considered

  • Single mega-prompt

    Cheap to build; degrades unpredictably — preferences, planning and pricing all fight for the same context window.

  • Free agent swarm

    Flexible but non-deterministic routing makes trips hard to reproduce and debug.

Why this one

Narrow prompts are testable prompts. Each stage has one job, a typed input contract and a schema-validated output — failures localize to a stage instead of a vibe.

Decision

Structured filters first (country, tags, season), vector similarity second, rerank after.

Alternatives considered

  • Pure vector search

    Great for prose; weak for facts — 'beach in Cox's Bazar under 5000 BDT/day' is half filter, half semantics.

  • Keyword-only (tsvector)

    Precise but brittle to phrasing; misses 'romantic getaway' ≈ 'honeymoon'.

Why this one

Destination data is already structured — using filters before similarity keeps ranking honest and cheap, and vector search only has to do the part it's actually good at.

Decision

Async Celery job with staged progress updates (preference → planning → budgeting → guiding).

Alternatives considered

  • Synchronous request

    Simple UX-wise; a multi-minute agent run would hold HTTP workers and time out proxies.

  • Websocket streaming pipeline

    Best perceived latency; significant transport + resume complexity for a solo project.

Why this one

Planning takes minutes, not milliseconds. Async keeps it robust, retryable and horizontally scalable — and staged progress keeps the UX honest without full streaming infra.

07Challenges & solutions

01

Retrieval accuracy on structured travel facts

Pure vector similarity returned atmospherically-similar but factually-wrong destinations (wrong country, wrong season) — the model then confidently planned around them.

Hybrid retrieval: hard structured filters before similarity, plus a rerank pass over the candidate set.

retrieval.sql — after
-- ✅ filters first, similarity within candidates, rerank after
WITH candidates AS (
  SELECT d.id, d.name
  FROM destinations d
  WHERE d.country = :country
    AND d.tags && :interests      -- structured half
    AND d.season_ok(@:travel_month)
)
SELECT c.content, c.embedding <=> :query_vec AS dist  -- semantic half
FROM destination_chunks c
JOIN candidates cd ON cd.id = c.destination_id
ORDER BY dist
LIMIT 24;   -- → rerank(candidates, keep=8) in Python

Factually impossible plans stopped appearing; similarity only ranks inside a candidate set the schema already blessed.

02

Day-to-day coherence across agents

Agents drafting days independently produced overlapping visits, impossible transit hops and repeated restaurants — each day was locally fine and globally incoherent.

A shared typed TripState threaded through every agent, with schema validation at stage boundaries.

pipeline.py — after
class TripState(BaseModel):
    prefs: Preferences
    visited: set[str] = set()
    budget_left: Decimal

    def lock(self, poi_id: str, cost: Decimal) -> bool:
        if poi_id in self.visited or cost > self.budget_left:
            return False
        self.visited.add(poi_id)
        self.budget_left -= cost
        return True

# ✅ one state object, validated at every handoff
async def plan_trip(trip):
    state = TripState(prefs=await pref_agent.interview(trip))
    for day in range(trip.length):
        await planner_agent.draft_day(trip, day, state)  # shares state
    TripState.model_validate(state)  # contract check

Duplication and budget drift became structurally impossible instead of prompt-suggested-against. [METRIC: itinerary acceptance rate — add real number]

03

Budget drift between stages

The planner added 'just one more' stop per day; by day five the cumulative cost quietly exceeded the cap the traveler set.

A dedicated budget stage that validates every item's cost before persist, with swap suggestions instead of silent trimming.

budget.py — after
# ✅ budget is a guardrail, checked before persist
async def validate_budget(trip, items) -> Budget:
    total = sum(i.cost_est for i in items)
    if total > trip.prefs.budget_total * OVERSHOOT:
        swaps = await propose_swaps(items, deficit=total - trip.prefs.budget_total)
        raise BudgetExceeded(swaps=swaps)
    return Budget(planned_total=total, currency=trip.currency)

Over-budget plans are rejected at the boundary and come back with actionable swaps — trust survives contact with arithmetic.

08Results & impact

0

specialist agents in the pipeline

0

planning stages with typed contracts

[METRIC: generation time]

median trip generation time

[METRIC: precision %]

retrieval precision on facts

Itineraries passing budget validation — before / after guardrail

ILLUSTRATIVE
Before — prompt-only budget55 % of drafts
After — validated guardrail100 % of drafts

// With the guardrail, drafts that overshoot are rejected and re-proposed instead of persisted. Replace with measured numbers.

  • // Numbers marked [METRIC] are placeholders — swap in measured values before publishing.

  • Next case study

    Multi-tenant agentic AI chatbot SaaS