|
| 1 | +""" |
| 2 | +Vektori + Pipecat — Voice Agent with Persistent Memory |
| 3 | +======================================================= |
| 4 | +
|
| 5 | +A production-ready FastAPI WebSocket server that wires a real-time voice |
| 6 | +pipeline with Vektori long-term memory. |
| 7 | +
|
| 8 | +Each spoken turn: |
| 9 | + 1. Deepgram transcribes the audio stream to text. |
| 10 | + 2. VektoriMemoryProcessor searches the user's memory and injects relevant |
| 11 | + facts / episodes into the LLM system prompt (before inference). |
| 12 | + 3. GPT-4o-mini replies. |
| 13 | + 4. VektoriStorageProcessor stores the exchange for future recall. |
| 14 | + 5. ElevenLabs synthesises the reply to speech. |
| 15 | +
|
| 16 | +Quick-start |
| 17 | +----------- |
| 18 | + pip install "vektori[pipecat]" |
| 19 | + pip install "pipecat-ai[deepgram,elevenlabs,openai,silero]" |
| 20 | +
|
| 21 | + export OPENAI_API_KEY=... |
| 22 | + export DEEPGRAM_API_KEY=... |
| 23 | + export ELEVENLABS_API_KEY=... |
| 24 | + export ELEVENLABS_VOICE_ID=... # e.g. 21m00Tcm4TlvDq8ikWAM (Rachel) |
| 25 | +
|
| 26 | + uvicorn examples.pipecat_voice_agent:app --host 0.0.0.0 --port 8765 |
| 27 | +
|
| 28 | +Connect from any Pipecat-compatible WebSocket client (browser, iOS, Android) |
| 29 | +pointing at ws://localhost:8765/ws/{user_id}. |
| 30 | +
|
| 31 | +Alternative STT / TTS |
| 32 | +---------------------- |
| 33 | +Swap ``DeepgramSTTService`` for ``WhisperSTTService`` (local) or |
| 34 | +``AssemblyAISTTService``. |
| 35 | +
|
| 36 | +Swap ``ElevenLabsTTSService`` for ``CartesiaTTSService``, |
| 37 | +``PlayHTTTSService``, or ``OpenAITTSService``. |
| 38 | +
|
| 39 | +Everything else — Vektori wiring, pipeline shape, session management — |
| 40 | +stays the same. |
| 41 | +
|
| 42 | +Architecture notes |
| 43 | +------------------ |
| 44 | +* ``VektoriMemoryProcessor`` sits between the user context aggregator and the |
| 45 | + LLM. It intercepts ``OpenAILLMContextFrame``, calls ``vektori.search()``, |
| 46 | + and rewrites the system message in-place before the LLM sees it. |
| 47 | +
|
| 48 | +* ``VektoriStorageProcessor`` sits after the LLM. It buffers the user |
| 49 | + transcription and the streamed LLM reply, then calls ``vektori.add()`` |
| 50 | + once per completed turn (non-blocking via ``asyncio.ensure_future``). |
| 51 | +
|
| 52 | +* One ``Vektori`` instance per WebSocket connection — each connection gets its |
| 53 | + own async resources and is closed when the client disconnects. |
| 54 | +
|
| 55 | +* ``session_id`` encodes the connection timestamp so consecutive sessions for |
| 56 | + the same user are kept distinct in Vektori's L2 sentence graph. |
| 57 | +""" |
| 58 | + |
| 59 | +import asyncio |
| 60 | +import logging |
| 61 | +import os |
| 62 | +import time |
| 63 | +from contextlib import asynccontextmanager |
| 64 | + |
| 65 | +from fastapi import FastAPI, WebSocket |
| 66 | +from fastapi.middleware.cors import CORSMiddleware |
| 67 | +from pipecat.audio.vad.silero import SileroVADAnalyzer |
| 68 | +from pipecat.frames.frames import EndFrame |
| 69 | +from pipecat.pipeline.pipeline import Pipeline |
| 70 | +from pipecat.pipeline.runner import PipelineRunner |
| 71 | +from pipecat.pipeline.task import PipelineParams, PipelineTask |
| 72 | +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext |
| 73 | +from pipecat.services.deepgram.stt import DeepgramSTTService |
| 74 | +from pipecat.services.elevenlabs.tts import ElevenLabsTTSService |
| 75 | +from pipecat.services.openai.llm import OpenAILLMService |
| 76 | +from pipecat.transports.network.fastapi_websocket import ( |
| 77 | + FastAPIWebsocketParams, |
| 78 | + FastAPIWebsocketTransport, |
| 79 | +) |
| 80 | + |
| 81 | +from vektori import Vektori |
| 82 | +from vektori.integrations.pipecat import VektoriMemoryProcessor, VektoriStorageProcessor |
| 83 | + |
| 84 | +# --------------------------------------------------------------------------- |
| 85 | +# Logging |
| 86 | +# --------------------------------------------------------------------------- |
| 87 | + |
| 88 | +logging.basicConfig(level=logging.INFO) |
| 89 | +logger = logging.getLogger(__name__) |
| 90 | + |
| 91 | +# --------------------------------------------------------------------------- |
| 92 | +# Configuration — read from environment |
| 93 | +# --------------------------------------------------------------------------- |
| 94 | + |
| 95 | +OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] |
| 96 | +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") |
| 97 | + |
| 98 | +DEEPGRAM_API_KEY = os.environ["DEEPGRAM_API_KEY"] |
| 99 | + |
| 100 | +ELEVENLABS_API_KEY = os.environ["ELEVENLABS_API_KEY"] |
| 101 | +ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM") |
| 102 | + |
| 103 | +# Vektori — default uses local SQLite + OpenAI embeddings |
| 104 | +VEKTORI_EMBEDDING_MODEL = os.getenv("VEKTORI_EMBEDDING_MODEL", "openai:text-embedding-3-small") |
| 105 | +VEKTORI_EXTRACTION_MODEL = os.getenv("VEKTORI_EXTRACTION_MODEL", f"openai:{OPENAI_MODEL}") |
| 106 | + |
| 107 | +# Memory retrieval tuning for voice |
| 108 | +# l1 = facts + episodes + source sentences — the right balance for voice |
| 109 | +# top_k = 5 keeps the injected context short so TTS stays snappy |
| 110 | +VEKTORI_DEPTH = os.getenv("VEKTORI_DEPTH", "l1") |
| 111 | +VEKTORI_TOP_K = int(os.getenv("VEKTORI_TOP_K", "5")) |
| 112 | + |
| 113 | +SYSTEM_PROMPT = """\ |
| 114 | +You are a helpful, friendly voice assistant. Keep your answers concise and |
| 115 | +conversational — you are speaking out loud, not writing. Avoid bullet lists, |
| 116 | +code blocks, or markdown; plain prose only. |
| 117 | +
|
| 118 | +If the memory context below contains relevant facts about the user, use them |
| 119 | +naturally in your reply without explicitly quoting them.\ |
| 120 | +""" |
| 121 | + |
| 122 | +# --------------------------------------------------------------------------- |
| 123 | +# App lifecycle |
| 124 | +# --------------------------------------------------------------------------- |
| 125 | + |
| 126 | + |
| 127 | +@asynccontextmanager |
| 128 | +async def lifespan(app: FastAPI): |
| 129 | + logger.info("Voice agent server starting up") |
| 130 | + yield |
| 131 | + logger.info("Voice agent server shut down") |
| 132 | + |
| 133 | + |
| 134 | +app = FastAPI(title="Vektori Voice Agent", lifespan=lifespan) |
| 135 | + |
| 136 | +app.add_middleware( |
| 137 | + CORSMiddleware, |
| 138 | + allow_origins=["*"], |
| 139 | + allow_methods=["*"], |
| 140 | + allow_headers=["*"], |
| 141 | +) |
| 142 | + |
| 143 | +# --------------------------------------------------------------------------- |
| 144 | +# WebSocket endpoint — one Pipecat pipeline per connection |
| 145 | +# --------------------------------------------------------------------------- |
| 146 | + |
| 147 | + |
| 148 | +@app.websocket("/ws/{user_id}") |
| 149 | +async def voice_endpoint(websocket: WebSocket, user_id: str): |
| 150 | + """ |
| 151 | + Connect a Pipecat-compatible WebSocket client. |
| 152 | +
|
| 153 | + URL: ws://host:port/ws/{user_id} |
| 154 | +
|
| 155 | + Each connection spawns an independent pipeline. The ``user_id`` path |
| 156 | + parameter maps directly to Vektori's user namespace — all memories stored |
| 157 | + in this session are searchable in future sessions for the same user. |
| 158 | + """ |
| 159 | + session_id = f"pipecat-{user_id}-{int(time.time())}" |
| 160 | + logger.info("New voice session: user=%s session=%s", user_id, session_id) |
| 161 | + |
| 162 | + # ------------------------------------------------------------------ |
| 163 | + # Transport (WebSocket + VAD) |
| 164 | + # ------------------------------------------------------------------ |
| 165 | + transport = FastAPIWebsocketTransport( |
| 166 | + websocket=websocket, |
| 167 | + params=FastAPIWebsocketParams( |
| 168 | + audio_in_enabled=True, |
| 169 | + audio_out_enabled=True, |
| 170 | + vad_enabled=True, |
| 171 | + vad_analyzer=SileroVADAnalyzer(), |
| 172 | + vad_audio_passthrough=True, |
| 173 | + ), |
| 174 | + ) |
| 175 | + |
| 176 | + # ------------------------------------------------------------------ |
| 177 | + # AI services |
| 178 | + # ------------------------------------------------------------------ |
| 179 | + stt = DeepgramSTTService(api_key=DEEPGRAM_API_KEY) |
| 180 | + llm = OpenAILLMService(api_key=OPENAI_API_KEY, model=OPENAI_MODEL) |
| 181 | + tts = ElevenLabsTTSService( |
| 182 | + api_key=ELEVENLABS_API_KEY, |
| 183 | + voice_id=ELEVENLABS_VOICE_ID, |
| 184 | + ) |
| 185 | + |
| 186 | + # ------------------------------------------------------------------ |
| 187 | + # LLM context (shared object — both processors hold a reference) |
| 188 | + # ------------------------------------------------------------------ |
| 189 | + context = OpenAILLMContext( |
| 190 | + messages=[{"role": "system", "content": SYSTEM_PROMPT}] |
| 191 | + ) |
| 192 | + ctx_agg = llm.create_context_aggregator(context) |
| 193 | + |
| 194 | + # ------------------------------------------------------------------ |
| 195 | + # Vektori memory |
| 196 | + # ------------------------------------------------------------------ |
| 197 | + vektori = Vektori( |
| 198 | + embedding_model=VEKTORI_EMBEDDING_MODEL, |
| 199 | + extraction_model=VEKTORI_EXTRACTION_MODEL, |
| 200 | + ) |
| 201 | + |
| 202 | + vektori_memory = VektoriMemoryProcessor( |
| 203 | + vektori=vektori, |
| 204 | + user_id=user_id, |
| 205 | + base_system_prompt=SYSTEM_PROMPT, |
| 206 | + depth=VEKTORI_DEPTH, |
| 207 | + top_k=VEKTORI_TOP_K, |
| 208 | + session_id=session_id, |
| 209 | + ) |
| 210 | + |
| 211 | + vektori_storage = VektoriStorageProcessor( |
| 212 | + vektori=vektori, |
| 213 | + user_id=user_id, |
| 214 | + session_id=session_id, |
| 215 | + context=context, # shared reference for fallback reads |
| 216 | + ) |
| 217 | + |
| 218 | + # ------------------------------------------------------------------ |
| 219 | + # Pipeline |
| 220 | + # ------------------------------------------------------------------ |
| 221 | + pipeline = Pipeline( |
| 222 | + [ |
| 223 | + transport.input(), # audio in (WebSocket frames → AudioRawFrame) |
| 224 | + stt, # Deepgram → TranscriptionFrame |
| 225 | + ctx_agg.user(), # TranscriptionFrame → OpenAILLMContextFrame |
| 226 | + vektori_memory, # inject memory into system prompt |
| 227 | + llm, # OpenAILLMContextFrame → TextFrame stream |
| 228 | + vektori_storage, # buffer user + assistant text, store on end |
| 229 | + tts, # TextFrame → AudioRawFrame |
| 230 | + transport.output(), # audio out (AudioRawFrame → WebSocket frames) |
| 231 | + ctx_agg.assistant(), # accumulate assistant reply into context |
| 232 | + ] |
| 233 | + ) |
| 234 | + |
| 235 | + task = PipelineTask( |
| 236 | + pipeline, |
| 237 | + params=PipelineParams(allow_interruptions=True), |
| 238 | + ) |
| 239 | + |
| 240 | + # ------------------------------------------------------------------ |
| 241 | + # Lifecycle handlers |
| 242 | + # ------------------------------------------------------------------ |
| 243 | + |
| 244 | + @transport.event_handler("on_client_connected") |
| 245 | + async def on_connected(t, client): |
| 246 | + logger.info("Client connected: user=%s", user_id) |
| 247 | + |
| 248 | + @transport.event_handler("on_client_disconnected") |
| 249 | + async def on_disconnected(t, client): |
| 250 | + logger.info("Client disconnected: user=%s — closing session", user_id) |
| 251 | + await task.queue_frames([EndFrame()]) |
| 252 | + await vektori.close() |
| 253 | + |
| 254 | + # ------------------------------------------------------------------ |
| 255 | + # Run |
| 256 | + # ------------------------------------------------------------------ |
| 257 | + runner = PipelineRunner() |
| 258 | + await runner.run(task) |
| 259 | + |
| 260 | + |
| 261 | +# --------------------------------------------------------------------------- |
| 262 | +# Healthcheck |
| 263 | +# --------------------------------------------------------------------------- |
| 264 | + |
| 265 | + |
| 266 | +@app.get("/health") |
| 267 | +async def health(): |
| 268 | + return {"status": "ok"} |
| 269 | + |
| 270 | + |
| 271 | +# --------------------------------------------------------------------------- |
| 272 | +# Entry-point for direct execution |
| 273 | +# --------------------------------------------------------------------------- |
| 274 | + |
| 275 | +if __name__ == "__main__": |
| 276 | + import uvicorn |
| 277 | + |
| 278 | + uvicorn.run(app, host="0.0.0.0", port=8765) |
0 commit comments