Ask for a flight, a policy, or your trip in plain language. Trip Talk sorts the request into one lane, answers the read-only ones directly, and — for anything that spends money or can't be undone — stages it and waits for your approval before acting.
Built with LangGraph (state + a real DAG), LangChain (Claude for classify/extract/recommend/ answer, BM25 for RAG), and LangSmith (tracing + a scored eval). Runs offline zero-config; add keys for real Claude + live traces.
cd travel-concierge
uv venv .venv --python 3.12 && uv pip install -e .
uv run streamlit run src/triptalk/ui/app.pygraph TD
START([start]) --> redact[🔒 redact PII] --> classify[🧭 classify · 5 lanes]
classify -->|flights/lookup/cancel/change| extract[🧩 extract slots]
classify -->|policy| retrieve[📚 RAG · BM25]
classify -->|escalate| escalate[🙋 human]
extract -->|missing info| clarify[❓ ask one question]
extract -->|flights / change| search[✈️ search_flights] --> recommend[⭐ recommend]
extract -->|lookup| lookup[🪪 lookup_booking]
extract -->|cancel| propose
extract -->|approved| commit
recommend -->|wants to book / change| propose[📝 stage action]
recommend -->|just looking| compose
propose --> budget[🛡️ budget check]
budget -->|ok| compose[✍️ answer]
budget -->|over cap| escalate
commit[✅ commit book/cancel] --> compose
clarify --> compose
retrieve --> compose
lookup --> compose
escalate --> compose
compose --> guardrail[🛡️ guardrail] --> respond[📤 finalize] --> END([end])
It's a DAG, not a tree — two nodes have multiple parents:
propose← a named booking (extract) or a pick coming out ofsearch→recommend.escalate← a direct request (classify) or an over-budget booking (budget_check).
That second one is the deliberate cross-over: you go down the book path, and a business rule (over the trip cap) diverts you into the human path mid-flow.
The lanes are jobs, not tool calls ("find" and "book" are one job). The line that matters:
| Read jobs (answer directly) | Write jobs (stage → approve → commit) |
|---|---|
| 🔎 search flights · 📚 ask a policy · 🪪 look up a trip |
All three writes funnel through the same propose → commit gate — a reusable mechanism, not a
booking hack. The LLM proposes; a human approves; code commits. No model output can reach a
booking tool. change is the proof the gate generalizes: it reuses the exact search → recommend → propose → budget → commit path, adds a lane but no new node, and its Basic-Economy case is
blocked by the RAG change-rule before it can stage.
| Requirement | Where |
|---|---|
| LangGraph state + control flow | graph.py (DAG + routers), state.py |
| Tools (stubbed) | kb/flights.py search_flights · kb/bookings.py book_flight / lookup_booking / cancel_booking |
| LangChain model calls | classifier.py — ChatAnthropic classify + structured slot extraction; recommend/compose reason with Claude |
| LangChain RAG | nodes.py retrieve — BM25Retriever over kb/policies.yaml |
| LangSmith traces | auto-traced when LANGCHAIN_* is set (config.py) |
| Eval dataset + expected | data/golden.yaml (16 cases) · local eval.py · LangSmith langsmith_eval.py |
| PII redaction / guardrails (nice-to-have) | redact + guardrail nodes; the approval gate + budget rule |
16 labeled messages, scored on three axes:
- routing — right lane? (the LLM's judgment) — keyword baseline 81%, real Claude 100%
- grounding — policy answers cite the right doc? (RAG)
- gate — writes staged / over-budget flagged? 100% — it's deterministic code, not the model
python -m triptalk.eval # local scorer (offline, free)
python -m triptalk.langsmith_eval # upload dataset + run a scored LangSmith experiment- Lanes are jobs, not tool calls. "Find" and "book" are one journey; splitting them is brittle ("book the cheapest flight" is both). The router picks the job; where you are inside it comes from state.
- Gate the writes, not the reads. Irreversible/paid actions get
propose → approve → commit; everything else answers. Command/query separation, made structural. - One approval mechanism shared by book and cancel — so it reads as a pattern, not a special case.
- The DAG cross-over is honest. Real flows aren't happy-path trees; an over-budget booking should divert to a human, and the graph shows exactly that edge.
- Mock-first. Every node runs offline; keys upgrade classify/extract/recommend/answer to Claude and turn on tracing — no wiring changes.
- Streaming the recommendation and the policy answer token-by-token to the UI.
- Hotels + multi-city (a second tool set through the same gate), and real cross-session memory (a LangGraph checkpointer + a persistent store) so a change can span turns.
- A hybrid retriever (BM25 + embeddings) evaluated on a labeled relevance set, and broadening PII redaction from regex to an NER model (it catches emails/cards/phones today, not free-form names).
- Docker + a Makefile for one-command setup.
src/triptalk/
state.py — shared state (slots, approved flag, pending_action, over_budget)
graph.py — the DAG: routers, the shared gate, the cross-over, mermaid export
nodes.py — redact · classify · extract · clarify · search · recommend · retrieve
· lookup · propose · budget_check · commit · escalate · compose · guardrail
classifier.py — Claude classify + slot extract (+ tunable prompt versions, keyword fallback)
eval.py — local golden-set scorer (routing / grounding / gate)
langsmith_eval.py— dataset upload + evaluate() experiment
kb/ — policies.yaml (RAG) · flights.py (search stub) · bookings.py (DB stub)
ui/app.py — the departure-board demo (Try it · Accuracy · Safety · How it works)
data/golden.yaml — the eval dataset