Itinerary Generation — Engineering Internals

A full walkthrough of how a user trip goes from form submission to a saved itinerary record, including the AI model, tool calling, place resolution, hallucination detection, and geocoding.


Overview

User fills form
    → TripGeneratorForm (client component)
        → generateItinerary() server action
            → Load context (curated trips + hip places)
            → Extract uploaded file text
            → Phase 1: generateText() — scaffold (no tools, no place names)
                → title, overview, day titles/descriptions/locations
                → packing list, budget, tips
            → Phase 2: Promise.all() — enrich each day in parallel
                → generateText() per day with lookup_place tool
                    → AI calls lookup_place for each activity
                        → GooglePlacesCache lookup
                        → Google Places API fallback
                        → Cache write (awaited)
                    → AI writes activity detail informed by place data
            → Merge scaffold + enriched days
            → geocodeItinerary()
            → prisma.itinerary.create()
        → router.push() to itinerary detail page

1. User Input (form)

File: components/dashboard/trip-generator-form.tsx

The user fills in:

FieldTypeNotes
destinationstringFree text, e.g. "Patagonia"
tripTypestringComma-joined from sliders: "hiking,cultural"
tripFocusstringVerbose version: "Hiking: A lot, Cultural: Some"
durationnumberDays 1–30. Auto-set from date range if provided.
difficultyeasy | moderate | challenging
interestsstringOptional free-text
startDate / endDateyyyy-MM-dd stringsOptional
files{ url, filename, type }[]Uploaded via Vercel Blob

Trip balance sliders control both tripType (which types are active at all) and tripFocus (how much of each). A slider at 0 excludes that type entirely.

On submit the form calls generateItinerary(data) — a Next.js server action — and shows a GeneratingInterstitial loading screen while it runs.


2. Server Action Entry Point

File: app/actions/generate-itinerary.ts

Auth-gated: requires a logged-in session.

2a. Context loading

Two queries run in parallel to give the AI editorial context:

Curated trips (up to 5 published) — injected as examples of good trip content. Gives the AI a sense of HipTrip's editorial voice, destination coverage, and highlight style.

Hip places — editor-curated places matching the destination (by city or country), rated ≥ 3, up to 15. These are injected with explicit instructions to strongly prefer them when planning activities. This is how editors influence AI recommendations at scale.

2b. File text extraction

File: lib/extract-file-text.ts

If the user uploaded files they're fetched and converted to text:

File typeHandling
.txt, .mdFetched and read directly, truncated to 2,000 chars
.pdfFetched as buffer, parsed with pdf-parse, truncated to 2,000 chars
image/*Placeholder note: "travel inspiration image"
.doc, .docxPlaceholder note: "travel notes reference"

The combined text is appended at the end of the generation prompt so the AI can incorporate the user's own research.


3. AI Generation — Two phases

Model: gpt-5.4 via Vercel AI SDK (generateText + Output.object)

Files: lib/itinerary-tools.ts, app/actions/generate-itinerary.ts

Generation is deliberately split into two phases so that place lookups never happen in the same call that is writing creative content. This eliminates the hallucinated placeId problem entirely.

Phase 1 — Scaffold (no tools)

A single generateText call with no tools produces the trip skeleton:

{
  title: string
  overview: string
  days: Array<{
    day: number
    title: string
    description: string
    location: string    // geocoded after generation, must be a specific real place name
  }>
  packingList: string[]
  bestTimeToVisit: string
  budgetEstimate: string
  importantTips: string[]
}

No placeName, no placeId, no activities. The model concentrates entirely on trip arc, day pacing, and narrative quality. Because there are no tool calls, there is no step budget pressure and nothing to hallucinate a place ID from.

Phase 2 — Day enrichment (parallel, with tools)

Each day from the scaffold is enriched in a separate generateText call, all running in parallel via Promise.all. Each call:

  • Receives the day's scaffold context (title, description, location) and the destination
  • Has access to the lookup_place tool
  • Is instructed to call lookup_place for every activity with a real-world place before writing that activity's detail
  • Is bounded to 5 × 2 + 5 = 15 steps — comfortably enough for 5 activities with tool calls to spare
  • Produces: { activities[], accommodation, meals } for that day

Because each call only handles one day's worth of activities (~3–5), the step budget is never exhausted. Every place the model wants to include gets a lookup_place call, and the returned google_place_id is used directly.

What lookup_place does:

  1. Checks HipPlace for an exact name match — if found, marks is_editor_pick: true and returns editor notes and tags
  2. Calls lookupPlace(placeName + ", " + destination, { mode: "full" }) which:
    • Checks GooglePlacesCache for a fresh entry (< 24h old)
    • On miss: calls Google Places Text Search API with full field mask (rating, photos, reviews, opening hours, editorial summary, etc.)
    • Awaits the cache write before returning
  3. Returns to the model: google_place_id, name, address, rating, editorial summary, editor context

The model uses the returned editorial data to write informed activity descriptions, then sets placeId from the returned google_place_id.


5. Geocoding

File: lib/geocode.ts

After validation/repair, geocodeItinerary() runs on the content object (mutating in-place):

  1. Destination geocode — calls lookupPlace(destination, { mode: "geocode" }). Geocode mode hits a cheaper Google API field mask (id, name, location only — no photos/reviews). Stores destinationCoords and destinationPlaceId on the root content object.

  2. Day-level geocode — for each day.location string, calls lookupPlace(location + ", " + destination, { mode: "geocode", bias: destCoords }). The location bias nudges the Places API toward the destination region. Stores day.coords and day.placeId.

All day geocodes run in parallel via Promise.all.

These coordinates are used by the map view in the itinerary detail page.


6. Database Write

prisma.itinerary.create({
  data: {
    userId,
    title: generatedContent.title,
    destination,
    tripType,
    generatedContent: JSON.stringify(generatedContent),
    startDate,
    endDate,
  }
})

The entire itinerary object (including all day/activity data with verified place IDs and geocoords) is serialised as a JSON string into the generated_content text column. There is no separate activities table — it's a single document.


7. Paywall & Display

File: app/dashboard/itinerary/[id]/page.tsx

After generation the user is redirected to /dashboard/itinerary/:id. The page checks itinerary.isUnlocked:

  • Unlocked → renders full <ItineraryContent> component with all days, map, place details
  • Locked → renders a teaser (overview + day titles) + <UnlockPaywall> which gates the full content behind a Stripe purchase or credit spend

Key files reference

FileRole
components/dashboard/trip-generator-form.tsxUser-facing form, calls server action
app/actions/generate-itinerary.tsMain server action orchestrating the entire pipeline
lib/itinerary-tools.tslookup_place tool factory with verified-ID tracking
lib/google-places.tsCache read/write + Google Places API calls
lib/geocode.tsPost-generation coordinate resolution
lib/extract-file-text.tsConverts uploaded files to prompt text
prisma/schema.prismaItineraryStorage: generated_content text column
prisma/schema.prismaGooglePlacesCachePlace data cache keyed by google_place_id
prisma/schema.prismaHipPlaceEditor-curated places, influence AI recommendations

How editors influence generation

Editors have two levers that shape every AI-generated trip:

  1. Hip Places — added via /editor. Places with rating ≥ 3 matching the destination are injected into the prompt with a "STRONGLY PREFER" instruction. When the AI calls lookup_place for one of these, the tool returns is_editor_pick: true along with the editor's notes and tags, which the AI uses to enrich the activity.

  2. Curated Trips — published trips from /editor/trips are injected as editorial examples. They shape the AI's tone, structure, and understanding of what makes a good HipTrip itinerary.

The /editor/recommended-places page shows which places are actually appearing in user trips, making it easy to identify high-frequency unvetted spots worth adding as Hip Places.