← Case studiesAI-powered travel planning platform
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
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
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
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
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.
-- ✅ 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 PythonFactually impossible plans stopped appearing; similarity only ranks inside a candidate set the schema already blessed.
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.
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 checkDuplication and budget drift became structurally impossible instead of prompt-suggested-against. [METRIC: itinerary acceptance rate — add real number]
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 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
specialist agents in the pipeline
planning stages with typed contracts
median trip generation time
retrieval precision on facts
Itineraries passing budget validation — before / after guardrail
ILLUSTRATIVE// 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