An AI-powered travel assistant for discovering Swiss destinations, hotels, and activities. Ask questions in plain language—"recommend a cozy ski town" or "add Zermatt to my wishlist"—and the assistant understands your intent, not just your keywords.
Built with Micronaut 5, LangChain4j, Oracle AI Database, and GraalVM Native Image.
When a user asks a question, the app embeds the query using OpenAI's
text-embedding-3-small model, then uses Micronaut Data repositories to run
Oracle AI Database vector similarity search. For location-aware requests, it
combines that with Oracle Spatial radius filters over seeded Swiss destination
coordinates. The LLM decides which tools to call (search, nearby search,
wishlist, etc.), and LangChain4j handles execution and message routing. A
bounded chat history is stored in Oracle by conversation ID so follow-up
requests retain their context across application restarts.
Nearby search resolves location names from the seeded destination catalog, not an external geocoder. The current anchors are Zermatt, Interlaken, Lucerne, Lausanne, St. Moritz, Lugano, and Zurich.
On startup, Flyway runs database migrations and loads destinations, hotels, and
activities. The DataInitializer then generates and persists vector embeddings
for all entries, enabling semantic search from the first request.
The current application uses exact vector search. Oracle calculates cosine distance for every row that satisfies the SQL filters, orders those rows by distance, and returns the closest five. This is appropriate for the small demo catalog: it is simple, deterministic, and returns the exact nearest results.
Flyway owns the schema and intentionally does not create a vector index, so the demo does not use IVF or HNSW indexing.
For a large catalog, add an Oracle IVF or HNSW vector index and use an approximate top-K query when exact scans no longer meet the required latency or throughput. Approximate search examines a smaller candidate space and can be substantially faster, at the cost of possibly not returning every true nearest neighbor. The decision should be based on measurements of latency, throughput, recall, index memory, and update cost rather than on a fixed row-count rule.
See Oracle's vector-index guidelines
and VECTOR_DISTANCE documentation.
The default setup uses Oracle Autonomous Database via a TLS connection:
export OPENAI_API_KEY='<openai-api-key>'
export ORACLE_JDBC_URL='<oracle-jdbc-url>'
export DB_PASSWORD='<database-password>'DB_USERNAME defaults to ADMIN. Micronaut's
DATASOURCES_DEFAULT_URL, DATASOURCES_DEFAULT_USERNAME, and
DATASOURCES_DEFAULT_PASSWORD variables can be used instead and take
precedence.
Start Phoenix separately so it remains available if the database is switched:
podman machine start
podman-compose up -d phoenixBuild the Native Image executable once:
./mvnw clean package -Dpackaging=native-image -DskipTestsRun it against ADB:
./target/swiss-travel-advisorOpen http://localhost:8080 for the chat interface. The
JSON API is available under /api.
The same executable works with both ADB and local Oracle. The database URL, credentials, OpenAI key, and model are runtime configuration; a separate local build is not required.
If the VS Code database viewer is unavailable, use the bundled SQLcl command
through the project's small read-only explorer. It uses the saved
MT ADB Alina Production connection without putting its password in shell
history:
# List tables in MICRONAUTADMIN.
./scripts/explore-db-list-tables.sh
# Show up to 20 rows from HOTELS in a terminal table.
./scripts/explore-db-hotels.sh
# Show up to 10 wishlist rows.
./scripts/explore-db-wishlist.sh 10The preview automatically hides vector embeddings, binary values, and Oracle
object columns so wide AI and spatial fields do not overwhelm the terminal.
The wishlist preview resolves each internal item_id to its destination,
hotel, or activity name and displays useful catalog details.
For another travel table, use ./scripts/explore-db.sh TABLE [ROW_COUNT].
Set DBTOOLS_CONNECTION to use a differently named saved SQL Developer
connection, or DB_SCHEMA to browse another schema.
With HTTPie:
http POST http://localhost:8080/api/chat message="recommend best ski resorts"
http POST http://localhost:8080/api/chat message="find quiet lakeside hotels near Lucerne under 250 CHF"
http POST http://localhost:8080/api/chat message="show scenic activities within 40 km of Interlaken"
http POST http://localhost:8080/api/chat message="show best activities in Zurich"Or with curl:
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "I want to visit a peaceful mountain resort"}'The JSON response contains both conversationId and message. Pass the same ID
on a follow-up request:
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: application/json" \
-d '{
"conversationId": "<conversation-id-from-the-first-response>",
"message": "Which of those is the most affordable?"
}'Omit conversationId to start a new conversation. Conversation IDs are UUIDs,
and the most recent 20 messages in each conversation are retained. Use the same
conversation ID when adding or retrieving items from that conversation's
wishlist.
Use a request that combines meaning with a hard location constraint:
Find a quiet lakeside destination within 50 km of Lucerne.
For nearby destination searches, DestinationRepository runs one explicit
Micronaut Data @Query that applies Oracle Spatial's radius predicate and then
orders the remaining rows with Oracle vector cosine distance. It returns a
DestinationSearchResult projection rather than the complete database entity.
The projection includes the raw cosine distance, and the tool output labels it
clearly: lower is closer. It is a ranking signal, not a confidence percentage
or a guarantee that the recommendation is correct.
For a reliable on-stage view, open the corresponding TOOL span in Phoenix.
Its output shows each destination and cosine distance even if the assistant
chooses not to repeat the numeric value in its prose.
Run the fast unit tests first; these do not require Oracle or an OpenAI key:
./mvnw testThe hybrid query has a separate opt-in integration test because an in-memory database cannot verify Oracle's vector and spatial functions. To run it against local Oracle Database Free:
podman machine start
podman-compose up -d oracle
# Wait until this prints "healthy".
podman inspect --format '{{.State.Health.Status}}' swiss-travel-oracle
export ORACLE_INTEGRATION_TEST=true
export ORACLE_JDBC_URL='jdbc:oracle:thin:@//localhost:1521/FREEPDB1'
export DB_USERNAME='TRAVEL'
export DB_PASSWORD="${LOCAL_ORACLE_PASSWORD:-LocalDemoPassword1}"
./mvnw clean test -Dtest=OracleHybridSearchITThe test inserts two temporary 1,536-dimensional vectors, proves that the spatial predicate includes them, verifies that cosine distance orders the exact match first, checks projection mapping, and removes the temporary rows. To run against ADB instead, keep the same test command and supply the ADB JDBC URL and credentials already used by the application.
Stop the application with Ctrl-C, then run:
./scripts/run-with-local-oracle.shThe script starts only Oracle Database Free using
gvenzl/oracle-free:23-faststart, waits until it is healthy, configures the
local TRAVEL user, and launches the same Native Image executable. Phoenix is
left running. The local connection does not require a wallet.
Flyway creates and seeds the same schema used with ADB. The oracle-data volume
retains the database and generated embeddings between runs. The local-only demo
password can be overridden with LOCAL_ORACLE_PASSWORD.
Stop the application with Ctrl-C, then stop the remaining demo containers:
./scripts/stop-demo-containers.shThe script preserves the containers, downloaded images, Phoenix traces, and local database data for the next run.
Tracing is enabled by default for the demo. Open
http://localhost:6006 and select the
swiss-travel-advisor project after sending a chat request.
A chat turn appears as an AGENT span with nested LLM, TOOL, EMBEDDING,
and RETRIEVER spans. This makes it possible to inspect model latency, tool
selection, vector and spatial retrieval, prompts, responses, token usage, and
errors in one trace. Conversation IDs are propagated as session.id, so
multi-turn conversations also appear in the Sessions tab.
Retriever spans include the ranked Oracle results using OpenInference
retrieval.documents attributes. Each document carries its catalog ID,
display content, cosine-similarity score, rank, and raw cosine distance.
New assistant responses also show thumbs-up/down controls; feedback is
published to the corresponding AGENT span as a Phoenix HUMAN annotation.
Generate a curated set of traces with:
./scripts/generate-demo-traces.shUse DEMO_ROUNDS=3 for a denser metrics dashboard. The script requires curl
and jq.
Prompt and response content is captured by default to make the demo useful for debugging. Disable content capture while retaining operational telemetry with:
AI_TRACING_INCLUDE_CONTENT=false ./target/swiss-travel-advisorSwissTravelAssistant— LangChain4j@AiServicefor conversation and tool orchestrationTravelTools— semantic search, nearby search, and wishlist@Toolmethods- Micronaut Data repositories — Oracle vector
Nearqueries and Oracle Spatial radius queries EmbeddingServiceandDataInitializer— embedding generation and backfillOracleChatMemoryStore— bounded conversation history stored as JSON in Oracle
For development, the application can also be run on the JVM with:
./mvnw mn:run