Skip to content

Commit f6f46b3

Browse files
committed
docs(examples): add travel-concierge voice demo (cloud catalog + live session)
A voice travel concierge that answers from two Moss indexes at once: - a pre-loaded catalog (long-term, shared across every call) - a live session that captures what the traveler says on this call Each turn it recalls stated preferences from the session and recommends trips from the catalog. Facts are distilled from each turn before they are stored, so the session holds clean preferences rather than raw questions. The web UI shows both indexes side by side, lighting up per turn with per-query latency. Includes agent.py, seed_index.py, a Next.js web UI, README, and a demo script.
1 parent f5323a1 commit f6f46b3

24 files changed

Lines changed: 4211 additions & 0 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# LiveKit (local dev defaults — keep as-is)
2+
LIVEKIT_URL=ws://localhost:7880
3+
LIVEKIT_API_KEY=devkey
4+
LIVEKIT_API_SECRET=secret
5+
6+
# Moss
7+
MOSS_PROJECT_ID=Your moss project id here
8+
MOSS_PROJECT_KEY=Your moss project key here
9+
TRAVEL_CATALOG_INDEX=demo-travel-catalog
10+
11+
# Providers (OpenAI = LLM, Deepgram = STT, Cartesia = TTS)
12+
OPENAI_API_KEY=Your openai api key here
13+
DEEPGRAM_API_KEY=Your deepgram api key here
14+
CARTESIA_API_KEY=Your cartesia api key here
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.14
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Demo Script — Travel Concierge (cloud catalog + live session)
2+
3+
~90s. Shows Moss answering from **two indexes in one call**: a pre-loaded catalog and a
4+
live session that remembers what you say. Watch both panels on the right.
5+
6+
## Before you record
7+
- [ ] `python seed_index.py` (seeds the catalog) · `python agent.py dev` · `livekit-server --dev` · web at localhost:3000
8+
9+
## 1 · Frame it (0:00–0:12)
10+
> "This concierge knows a catalog of trips — that's loaded ahead of time. But it also
11+
> remembers everything I say on the call. Two Moss indexes, live, side by side. Watch."
12+
13+
**[Click Start planning. The agent greets you.]**
14+
15+
## 2 · Tell it about the trip (0:12–0:40)
16+
budget's around two thousand five hundred dollars a person,
17+
> we love beaches, and we want to travel the first week of December."
18+
19+
**[Point at the "This call · live session" panel filling up as you talk.]**
20+
> "Watch the live session. It's pulling the facts out of what I say — a family of four,
21+
> the budget, the dates — and remembering each one. In memory, in milliseconds."
22+
23+
## 3 · Recall (0:40–0:58)
24+
> "Wait, what did I say my budget was?"
25+
26+
**[The session panel lights up; the agent answers from what you said.]**
27+
> "It pulled that straight from this conversation — not the catalog. And notice the question
28+
> itself doesn't get stored, only the facts do."
29+
30+
## 4 · Recommend (0:58–1:20)
31+
> "So where should we go?"
32+
33+
**[Both panels light: catalog hits + your session prefs.]**
34+
> "Now it's using both — my preferences from the session *and* the catalog it already had —
35+
> to recommend somewhere that actually fits. Beach, family, December, in budget."
36+
37+
## 5 · Why it matters (1:20–1:35)
38+
> "That's long-term knowledge and short-term memory in the same call — one pre-loaded index,
39+
> one live session, both queried in milliseconds, on-device. Open source at
40+
> github.com/usemoss/moss."
41+
42+
---
43+
44+
## Say-these preferences (each is distilled to a fact in the session)
45+
- "Family of four, budget about $2,500 per person."
46+
- "We love beaches and warm weather."
47+
- "Traveling the first week of December."
48+
49+
> Only facts land in the session. Questions like "what did I say my budget was?" and
50+
> "where should we go?" are recalled against, but never stored.
51+
52+
## Recall / recommend prompts
53+
- "What did I say my budget was?" → session
54+
- "Which destinations do you have?" → catalog
55+
- "Where should we go?" → both (expect Tulum or Costa Rica for beach + family + Dec)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Travel Concierge — pre-loaded catalog + live session
2+
3+
A voice travel concierge that answers from **two Moss indexes at once**:
4+
5+
- a **pre-loaded catalog** (long-term, shared across every call)
6+
- a **live session** that captures what you say on *this* call (short-term, in-memory)
7+
8+
Each turn it recalls your stated preferences (session) and recommends trips (catalog). The
9+
web UI shows both indexes side by side, lighting up per turn.
10+
11+
```
12+
Browser (web/) ⟷ LiveKit room ⟷ agent.py (STT → LLM → TTS) ⟷ Moss (catalog + session)
13+
```
14+
15+
## What you need
16+
- A [Moss](https://moss.dev) account · [LiveKit](https://livekit.io) (local) · OpenAI (LLM),
17+
Deepgram (STT), Cartesia (TTS) keys · Python 3.14+ (`uv`) and Node 18+.
18+
19+
## Setup
20+
```bash
21+
uv sync
22+
cp .env.example .env # fill in Moss + provider keys
23+
python agent.py download-files
24+
```
25+
26+
## 1. Seed the catalog (the cloud index)
27+
```bash
28+
python seed_index.py
29+
```
30+
The live session is built at runtime by the agent — nothing to seed there.
31+
32+
## 2. Run (three terminals)
33+
```bash
34+
livekit-server --dev
35+
python agent.py dev
36+
cd web && npm install && cp .env.local.example .env.local && npm run dev # localhost:3000
37+
```
38+
39+
Click **Start planning**, then talk: tell it your budget, dates, and who's coming, ask it
40+
to recall them, and ask for a recommendation.
41+
42+
## How it works
43+
Per turn, `agent.py`:
44+
1. queries the **live session** (recall what you've said),
45+
2. queries the **pre-loaded catalog** (matching trips),
46+
3. injects both into the model, then
47+
4. **distills your turn into facts and stores only those** in the session, so later turns
48+
recall clean preferences — not questions or filler.
49+
50+
Both result sets are published on the `moss.retrieval` data channel; the UI renders
51+
**Catalog (cloud)** and **This call (session)**. See [`DEMO_SCRIPT.md`](./DEMO_SCRIPT.md).
52+
53+
## Resources
54+
- [Docs — Sessions](https://docs.moss.dev/docs/integrate/sessions)
55+
- [GitHub](https://github.com/usemoss/moss)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import asyncio
2+
import json
3+
import logging
4+
import os
5+
import time
6+
from datetime import datetime
7+
8+
from dotenv import load_dotenv
9+
from openai import AsyncOpenAI
10+
from livekit import rtc
11+
from livekit.plugins import openai, deepgram, silero, cartesia
12+
from livekit.plugins.turn_detector.multilingual import MultilingualModel
13+
from livekit.agents import (
14+
JobContext,
15+
WorkerOptions,
16+
cli,
17+
ChatContext,
18+
ChatMessage,
19+
Agent,
20+
AgentSession,
21+
)
22+
23+
from moss import MossClient, DocumentInfo, QueryOptions
24+
25+
load_dotenv()
26+
27+
MOSS_PROJECT_ID = os.getenv("MOSS_PROJECT_ID")
28+
MOSS_PROJECT_KEY = os.getenv("MOSS_PROJECT_KEY")
29+
# Long-term, pre-loaded knowledge shared across every call.
30+
CATALOG_INDEX = os.getenv("TRAVEL_CATALOG_INDEX", "demo-travel-catalog")
31+
32+
# Turns the traveler's raw speech into clean, standalone facts before we store them.
33+
# Questions, recall requests, and small talk yield no facts, so they never hit the session.
34+
FACT_EXTRACT_PROMPT = """You pull durable traveler preferences out of one thing the traveler just said on a trip-planning call.
35+
36+
Return JSON: {"facts": ["...", "..."]}.
37+
38+
A fact is a short, standalone statement of something true about the traveler or their trip:
39+
party size, budget, dates, interests, must-haves, or destinations they like or dislike.
40+
41+
Rules:
42+
- Only include preferences actually stated in this utterance.
43+
- Split multiple preferences into separate facts.
44+
- Drop filler and normalize (e.g. "our budget's around, uh, twenty five hundred a person" -> "Budget is about $2,500 per person").
45+
- Keep each fact under about 8 words.
46+
- Return {"facts": []} for questions, recall requests, or small talk (e.g. "what did I say my budget was?", "so where should we go?")."""
47+
48+
logging.basicConfig(level=logging.INFO)
49+
logger = logging.getLogger("moss-travel")
50+
51+
52+
def _docs(result):
53+
return [
54+
{"id": getattr(d, "id", None), "text": d.text, "score": float(getattr(d, "score", 0.0))}
55+
for d in (result.docs if result and result.docs else [])
56+
]
57+
58+
59+
class TravelConciergeAgent(Agent):
60+
"""Answers from two Moss indexes at once: a pre-loaded catalog (long-term)
61+
and a live session that captures what the traveler says on THIS call."""
62+
63+
def __init__(self, moss_client: MossClient, session_index, room: rtc.Room):
64+
super().__init__(
65+
instructions="""
66+
You are a warm, upbeat travel concierge on a voice call with one traveler.
67+
Each turn you're given two kinds of context:
68+
1. Trip options from our catalog.
69+
2. What the traveler has told you earlier in THIS call (their preferences).
70+
Use both: remember what they've said, and recommend trips from the catalog
71+
that fit. If they ask you to recall something they mentioned, answer from the
72+
facts in that context. Keep replies short and natural for voice. Never mention
73+
indexes, sessions, catalogs, or how you look things up.
74+
"""
75+
)
76+
self.moss = moss_client
77+
self.session_index = session_index
78+
self.room = room
79+
self.turn = 0
80+
# Small, fast model used only to distill the traveler's speech into facts.
81+
self._extractor = AsyncOpenAI()
82+
83+
async def _publish(self, query, catalog, session, catalog_ms, session_ms):
84+
payload = {
85+
"query": query,
86+
"catalog": _docs(catalog),
87+
"session": _docs(session),
88+
"catalog_ms": round(catalog_ms, 2),
89+
"session_ms": round(session_ms, 2),
90+
}
91+
try:
92+
await self.room.local_participant.publish_data(
93+
json.dumps(payload).encode("utf-8"), reliable=True, topic="moss.retrieval"
94+
)
95+
except Exception as e:
96+
logger.warning(f"Failed to publish retrieval data: {e}")
97+
98+
async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage) -> None:
99+
query = new_message.text_content
100+
logger.info(f"Traveler: {query}")
101+
try:
102+
# 1. Recall prior turns from the live session (short-term memory).
103+
t = time.perf_counter()
104+
session_results = await self.session_index.query(query, QueryOptions(top_k=3))
105+
session_ms = (time.perf_counter() - t) * 1000.0
106+
107+
# 2. Look up matching trips in the pre-loaded catalog (long-term knowledge).
108+
t = time.perf_counter()
109+
catalog_results = await self.moss.query(CATALOG_INDEX, query, QueryOptions(top_k=3))
110+
catalog_ms = (time.perf_counter() - t) * 1000.0
111+
112+
# 3. Show both in the UI.
113+
await self._publish(query, catalog_results, session_results, catalog_ms, session_ms)
114+
115+
# 4. Inject both into the model's context, clearly labeled.
116+
blocks = []
117+
if catalog_results.docs:
118+
blocks.append("Trip options from our catalog:\n" + "\n".join(f"- {d.text}" for d in catalog_results.docs))
119+
if session_results.docs:
120+
blocks.append("Facts the traveler shared earlier in this call:\n" + "\n".join(f"- {d.text}" for d in session_results.docs))
121+
if blocks:
122+
turn_ctx.add_message(role="system", content="\n\n".join(blocks) + "\n\nUse this to help the traveler.")
123+
124+
# 5. Distill this turn into facts and store only those in the live session, in the
125+
# background so it never delays the reply. Questions/recall add nothing.
126+
asyncio.create_task(self._remember_facts(query))
127+
except Exception as e:
128+
logger.error(f"Moss lookup failed: {e}", exc_info=True)
129+
130+
await super().on_user_turn_completed(turn_ctx, new_message)
131+
132+
async def _extract_facts(self, text: str) -> list[str]:
133+
"""Pull clean, standalone facts out of one traveler utterance. [] if it states none."""
134+
try:
135+
resp = await self._extractor.chat.completions.create(
136+
model="gpt-4o-mini",
137+
temperature=0,
138+
response_format={"type": "json_object"},
139+
messages=[
140+
{"role": "system", "content": FACT_EXTRACT_PROMPT},
141+
{"role": "user", "content": text},
142+
],
143+
)
144+
data = json.loads(resp.choices[0].message.content or "{}")
145+
return [f.strip() for f in data.get("facts", []) if isinstance(f, str) and f.strip()]
146+
except Exception as e:
147+
logger.warning(f"Fact extraction failed: {e}")
148+
return []
149+
150+
async def _remember_facts(self, text: str) -> None:
151+
for fact in await self._extract_facts(text):
152+
self.turn += 1
153+
try:
154+
await self.session_index.add_docs(
155+
[DocumentInfo(id=f"fact-{self.turn}", text=fact, metadata={"role": "traveler"})]
156+
)
157+
logger.info(f"Remembered: {fact}")
158+
except Exception as e:
159+
logger.warning(f"Failed to store fact: {e}")
160+
161+
162+
async def entrypoint(ctx: JobContext):
163+
await ctx.connect()
164+
165+
client = MossClient(project_id=MOSS_PROJECT_ID, project_key=MOSS_PROJECT_KEY)
166+
167+
# Long-term: the pre-loaded catalog, shared across all calls.
168+
try:
169+
await client.load_index(CATALOG_INDEX)
170+
logger.info(f"Loaded catalog index: {CATALOG_INDEX}")
171+
except Exception as e:
172+
logger.warning(f"Catalog not found ({e}). Run seed_index.py first.")
173+
174+
# Short-term: a fresh, empty session just for this call.
175+
session_name = f"trip-session-{datetime.now():%Y%m%d-%H%M%S}"
176+
session_index = await client.session(session_name)
177+
logger.info(f"Opened live session: {session_name}")
178+
179+
agent = TravelConciergeAgent(client, session_index, ctx.room)
180+
181+
session = AgentSession(
182+
stt=deepgram.STT(model="nova-2", language="en-US"),
183+
llm=openai.LLM(model="gpt-4o-mini"),
184+
tts=cartesia.TTS(model="sonic-turbo", voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc"),
185+
vad=silero.VAD.load(min_silence_duration=0.5, activation_threshold=0.6),
186+
turn_handling={
187+
"turn_detection": MultilingualModel(),
188+
"endpointing": {"min_delay": 0.5, "max_delay": 1.5},
189+
},
190+
)
191+
192+
await session.start(agent=agent, room=ctx.room)
193+
await session.say(
194+
"Hi! I'm your travel concierge. Tell me about the trip you're dreaming of and I'll find something.",
195+
allow_interruptions=True,
196+
)
197+
198+
199+
if __name__ == "__main__":
200+
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
{ "id": "amalfi", "text": "Amalfi Coast, Italy — romantic cliffside villages, boat trips, and long seafood lunches. Best April to June or September. Around $3,500 per person for a week. Great for couples and honeymoons." },
3+
{ "id": "tulum", "text": "Tulum, Mexico — white-sand beaches, cenotes, and Mayan ruins. Warm and dry November to April. About $2,200 per person. Works for both families and couples." },
4+
{ "id": "kyoto", "text": "Kyoto, Japan — temples, gardens, and food. Cherry blossoms in spring, red foliage in autumn. Roughly $3,000 per person. A calm, cultural trip." },
5+
{ "id": "banff", "text": "Banff, Canada — turquoise lakes, hiking, and mountain gondolas. Best June to September. Around $2,600 per person. Excellent for active families." },
6+
{ "id": "santorini", "text": "Santorini, Greece — whitewashed cliffs, sunsets, and volcanic beaches. Ideal May to September. About $3,200 per person. A romantic favorite." },
7+
{ "id": "lisbon", "text": "Lisbon, Portugal — hilly old town, pastéis de nata, and day trips to Sintra. Mild most of the year. A budget-friendly city break at about $1,800 per person." },
8+
{ "id": "costa-rica", "text": "Manuel Antonio, Costa Rica — rainforest meets the beach, with sloths and zip-lines. Dry season December to April. Around $2,400 per person. Big hit with families." },
9+
{ "id": "iceland", "text": "Iceland Ring Road — waterfalls, geysers, and black-sand beaches. Northern lights in winter, midnight sun in summer. About $3,300 per person. A nature road trip." },
10+
{ "id": "bali", "text": "Bali, Indonesia — beaches, rice terraces, and wellness retreats. Dry season April to October. A mid-budget escape around $1,900 per person." },
11+
{ "id": "marrakech", "text": "Marrakech, Morocco — souks, riads, and desert excursions. Best in spring or autumn. Around $2,000 per person. Vibrant and full of color." },
12+
{ "id": "maui", "text": "Maui, Hawaii — beaches, the road to Hana, and snorkeling. Good year-round. A splurge at roughly $3,800 per person. Family-friendly." },
13+
{ "id": "prague", "text": "Prague, Czechia — medieval old town, castles, and river walks. Lovely in spring and autumn. A budget city trip around $1,700 per person." },
14+
{ "id": "queenstown", "text": "Queenstown, New Zealand — lakes, peaks, and adventure sports. Best November to April. About $3,400 per person. For thrill-seekers and hikers." },
15+
{ "id": "cape-town", "text": "Cape Town, South Africa — Table Mountain, beaches, and wine country. Best November to March. Around $2,700 per person. Scenic and varied." }
16+
]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[project]
2+
name = "travel-concierge"
3+
version = "0.1.0"
4+
description = "Moss travel concierge — pre-loaded catalog + live session voice agent"
5+
readme = "README.md"
6+
requires-python = ">=3.14"
7+
dependencies = [
8+
"python-dotenv>=1.0.0",
9+
"moss>=1.1.1",
10+
"livekit-agents[openai,deepgram,silero,turn-detector,cartesia]>=1.0",
11+
]

0 commit comments

Comments
 (0)