Upload 16 files
Browse files- README.md +145 -13
- app.py +23 -0
- build_rag_index.py +15 -0
- chat.py +64 -0
- chunking.py +19 -0
- config.py +60 -0
- knowledge.md +17 -0
- knowledge/career.md +21 -0
- knowledge/identity.md +23 -0
- knowledge/technical.md +27 -0
- prompts.py +47 -0
- rag.py +133 -0
- requirements.txt +6 -0
- tools.py +75 -0
- ui.py +119 -0
- voice.py +35 -0
README.md
CHANGED
|
@@ -1,13 +1,145 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Twin
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.18.0
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Kush Digital Twin
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.18.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Digital Twin MultiModal (Voice and Text)
|
| 13 |
+
|
| 14 |
+
A multimodal digital twin chatbot with **text** and **voice** input, powered by OpenAI (LLM + RAG + tool calling), Deepgram (speech-to-text and text-to-speech), ChromaDB (vector retrieval), and Gradio (web UI).
|
| 15 |
+
|
| 16 |
+
Converted from the `digital-twin.ipynb` notebook in the AI Engineering course.
|
| 17 |
+
|
| 18 |
+
## Architecture
|
| 19 |
+
|
| 20 |
+

|
| 21 |
+
|
| 22 |
+
**High-level flow:**
|
| 23 |
+
|
| 24 |
+
1. **Text path** — user types → embed query → ChromaDB retrieval → OpenAI chat (with tools) → reply in chat
|
| 25 |
+
2. **Voice path** — user records → Deepgram STT → same RAG + chat pipeline → Deepgram TTS → autoplay reply audio
|
| 26 |
+
|
| 27 |
+
**RAG pipeline:**
|
| 28 |
+
|
| 29 |
+
1. Knowledge lives in `knowledge/*.md` (identity, career, technical stack)
|
| 30 |
+
2. `chunking.py` splits documents with overlap at sentence/paragraph boundaries
|
| 31 |
+
3. OpenAI `text-embedding-3-small` embeds each chunk
|
| 32 |
+
4. Vectors are stored in a local ChromaDB collection (`chroma_db_twin/`)
|
| 33 |
+
5. Each user message retrieves the top-N similar chunks and injects them as **Context** in the system prompt
|
| 34 |
+
|
| 35 |
+
**Tools available to the LLM:**
|
| 36 |
+
|
| 37 |
+
- `send_notification` — Pushover alert to your phone (optional)
|
| 38 |
+
- `roll_dice` — simulated dice roll
|
| 39 |
+
|
| 40 |
+
**Dynamic context:** keywords in the user's message (`2011`, `dishes`, `sports`, `vacation`) inject extra persona context from `knowledge.md`.
|
| 41 |
+
|
| 42 |
+
## Prerequisites
|
| 43 |
+
|
| 44 |
+
- Python 3.10+
|
| 45 |
+
- API keys:
|
| 46 |
+
- [OpenAI](https://platform.openai.com/api-keys) (required)
|
| 47 |
+
- [Deepgram](https://console.deepgram.com/) (required)
|
| 48 |
+
- [Pushover](https://pushover.net/) (optional, for notification tool)
|
| 49 |
+
|
| 50 |
+
## Quick start
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
# Clone or cd into this directory
|
| 54 |
+
cd digital-twin-voice
|
| 55 |
+
|
| 56 |
+
# Create a virtual environment (recommended)
|
| 57 |
+
python -m venv .venv
|
| 58 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 59 |
+
|
| 60 |
+
# Install dependencies
|
| 61 |
+
pip install -r requirements.txt
|
| 62 |
+
|
| 63 |
+
# Configure environment
|
| 64 |
+
cp .env.example .env
|
| 65 |
+
# Edit .env and add your API keys
|
| 66 |
+
|
| 67 |
+
# Build the vector index (also runs automatically on first app launch if empty)
|
| 68 |
+
python build_rag_index.py
|
| 69 |
+
|
| 70 |
+
# Run the app
|
| 71 |
+
python app.py
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
Gradio opens at `http://127.0.0.1:7860` (port may vary). Use the text box or microphone to chat.
|
| 75 |
+
|
| 76 |
+
## Deploy to Hugging Face Spaces
|
| 77 |
+
|
| 78 |
+
This repo is ready to deploy as a [Gradio Space](https://huggingface.co/docs/hub/spaces-sdks-gradio). The YAML header at the top of this README configures the Space (`sdk: gradio`, `sdk_version: 6.18.0`, `app_file: app.py`).
|
| 79 |
+
|
| 80 |
+
1. Create a new Space on Hugging Face and choose **Gradio** as the SDK.
|
| 81 |
+
2. Push this repository (or connect your GitHub repo).
|
| 82 |
+
3. In **Settings → Variables and secrets**, add:
|
| 83 |
+
- `OPENAI_API_KEY` (required — chat + RAG embeddings)
|
| 84 |
+
- `DEEPGRAM_API_KEY` (required for voice input/output)
|
| 85 |
+
- Optional: `PUSHOVER_USER`, `PUSHOVER_TOKEN`, model overrides from `.env.example`
|
| 86 |
+
4. On first load, the app builds the ChromaDB index from `knowledge/*.md` (uses OpenAI embeddings). Cold starts on free Spaces may take ~30s.
|
| 87 |
+
|
| 88 |
+
For faster restarts, enable [Persistent Storage](https://huggingface.co/docs/hub/spaces-storage) and set `CHROMA_PATH=/data/chroma_db_twin`, then run `python build_rag_index.py` once in the Space terminal.
|
| 89 |
+
|
| 90 |
+
## Project layout
|
| 91 |
+
|
| 92 |
+
```
|
| 93 |
+
digital-twin-voice/
|
| 94 |
+
├── app.py # Entry point
|
| 95 |
+
├── build_rag_index.py # Rebuild ChromaDB from knowledge files
|
| 96 |
+
├── chunking.py # Text chunking with overlap
|
| 97 |
+
├── rag.py # Embeddings, ChromaDB, retrieval
|
| 98 |
+
├── config.py # Environment variables and API clients
|
| 99 |
+
├── knowledge/ # Source documents for RAG
|
| 100 |
+
│ ├── identity.md
|
| 101 |
+
│ ├── career.md
|
| 102 |
+
│ └── technical.md
|
| 103 |
+
├── knowledge.md # Keyword topic triggers only
|
| 104 |
+
├── prompts.py # System prompt + topic loading
|
| 105 |
+
├── tools.py # Pushover + dice tools, tool-call handler
|
| 106 |
+
├── chat.py # OpenAI chat loop with RAG + tool calling
|
| 107 |
+
├── voice.py # Deepgram STT / TTS
|
| 108 |
+
├── ui.py # Gradio interface
|
| 109 |
+
├── requirements.txt
|
| 110 |
+
├── .env.example
|
| 111 |
+
└── docs/
|
| 112 |
+
├── Flow-chart.png
|
| 113 |
+
└── digital-twin-voice-flow.excalidraw
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
## Customization
|
| 117 |
+
|
| 118 |
+
- **Persona facts:** edit files in `knowledge/`, then run `python build_rag_index.py`
|
| 119 |
+
- **Topic keywords:** edit `knowledge.md` under `## Topics`
|
| 120 |
+
- **Chunking:** adjust `RAG_CHUNK_SIZE` and `RAG_CHUNK_OVERLAP` in `.env`
|
| 121 |
+
- **Retrieval depth:** set `RAG_N_RESULTS` in `.env` (default: 3)
|
| 122 |
+
- **Tools:** add functions in `tools.py` and register them in `TOOLS`
|
| 123 |
+
- **Models:** set `OPENAI_MODEL`, `EMBEDDING_MODEL`, `DEEPGRAM_STT_MODEL`, and `DEEPGRAM_TTS_MODEL` in `.env`
|
| 124 |
+
|
| 125 |
+
## Environment variables
|
| 126 |
+
|
| 127 |
+
| Variable | Required | Description |
|
| 128 |
+
|----------|----------|-------------|
|
| 129 |
+
| `OPENAI_API_KEY` | Yes | OpenAI API key |
|
| 130 |
+
| `DEEPGRAM_API_KEY` | Yes | Deepgram API key |
|
| 131 |
+
| `OPENAI_MODEL` | No | Chat model (default: `gpt-4.1-mini`) |
|
| 132 |
+
| `EMBEDDING_MODEL` | No | Embedding model (default: `text-embedding-3-small`) |
|
| 133 |
+
| `RAG_N_RESULTS` | No | Chunks retrieved per query (default: `3`) |
|
| 134 |
+
| `RAG_CHUNK_SIZE` | No | Chunk size in characters (default: `500`) |
|
| 135 |
+
| `RAG_CHUNK_OVERLAP` | No | Overlap between chunks (default: `50`) |
|
| 136 |
+
| `RAG_DEBUG` | No | Print retrieved chunk sources to console |
|
| 137 |
+
| `CHROMA_PATH` | No | ChromaDB directory (default: `chroma_db_twin`) |
|
| 138 |
+
| `DEEPGRAM_STT_MODEL` | No | Speech-to-text model (default: `nova-3`) |
|
| 139 |
+
| `DEEPGRAM_TTS_MODEL` | No | Text-to-speech model (default: `aura-2-thalia-en`) |
|
| 140 |
+
| `PUSHOVER_USER` | No | Pushover user key (for notification tool) |
|
| 141 |
+
| `PUSHOVER_TOKEN` | No | Pushover app token (for notification tool) |
|
| 142 |
+
|
| 143 |
+
## License
|
| 144 |
+
|
| 145 |
+
Educational project from the AI Engineering course.
|
app.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Kush Digital Twin — multimodal voice + text chat with RAG."""
|
| 2 |
+
|
| 3 |
+
from config import ensure_clients, ensure_openai_client
|
| 4 |
+
from rag import ensure_index
|
| 5 |
+
from ui import CSS, create_demo
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def startup() -> None:
|
| 9 |
+
"""Warm up the RAG index (OpenAI only — voice keys load on first mic use)."""
|
| 10 |
+
ensure_openai_client()
|
| 11 |
+
ensure_index()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
demo = create_demo(on_load=startup)
|
| 15 |
+
|
| 16 |
+
# Hugging Face Spaces looks for `demo`, `app`, or `interface`.
|
| 17 |
+
app = demo
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
if __name__ == "__main__":
|
| 21 |
+
ensure_clients()
|
| 22 |
+
startup()
|
| 23 |
+
demo.launch(inbrowser=True, css=CSS)
|
build_rag_index.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build or rebuild the ChromaDB vector index from knowledge/*.md files."""
|
| 2 |
+
|
| 3 |
+
from config import ensure_openai_client
|
| 4 |
+
from rag import build_index, get_collection
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main() -> None:
|
| 8 |
+
ensure_openai_client()
|
| 9 |
+
before = get_collection().count()
|
| 10 |
+
count = build_index(force_rebuild=True)
|
| 11 |
+
print(f"RAG index rebuilt: {before} -> {count} chunks in {get_collection().name}")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
main()
|
chat.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import config
|
| 2 |
+
from prompts import SYSTEM_MESSAGE, TOPIC_CONTEXT
|
| 3 |
+
from rag import retrieve
|
| 4 |
+
from tools import TOOLS, handle_tool_call
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def content_to_text(content) -> str:
|
| 8 |
+
"""Gradio messages may store content as a string or a list of parts."""
|
| 9 |
+
if isinstance(content, str):
|
| 10 |
+
return content
|
| 11 |
+
if isinstance(content, list):
|
| 12 |
+
parts = []
|
| 13 |
+
for part in content:
|
| 14 |
+
if isinstance(part, dict):
|
| 15 |
+
parts.append(part.get("text") or part.get("content") or "")
|
| 16 |
+
else:
|
| 17 |
+
parts.append(str(part))
|
| 18 |
+
return " ".join(parts).strip()
|
| 19 |
+
return str(content)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_system_prompt(latest_user_message: str) -> str:
|
| 23 |
+
system = SYSTEM_MESSAGE
|
| 24 |
+
|
| 25 |
+
context, metadatas = retrieve(latest_user_message)
|
| 26 |
+
if context:
|
| 27 |
+
system += f"\n\nContext:\n\n{context}"
|
| 28 |
+
|
| 29 |
+
if config.RAG_DEBUG and metadatas:
|
| 30 |
+
print("retrieved chunks:")
|
| 31 |
+
for meta in metadatas:
|
| 32 |
+
print(f" {meta['source']} — chunk {meta['chunk_index']}")
|
| 33 |
+
|
| 34 |
+
lowered = latest_user_message.lower()
|
| 35 |
+
for keyword, extra in TOPIC_CONTEXT.items():
|
| 36 |
+
if keyword in lowered:
|
| 37 |
+
system += f"\n\n{extra}"
|
| 38 |
+
return system
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def response_ai(history: list[dict]) -> str:
|
| 42 |
+
"""history: list of {role, content} chat messages; returns the assistant reply text."""
|
| 43 |
+
config.ensure_clients()
|
| 44 |
+
client = config.ensure_openai_client()
|
| 45 |
+
msgs = [{"role": m["role"], "content": content_to_text(m["content"])} for m in history]
|
| 46 |
+
system = build_system_prompt(msgs[-1]["content"])
|
| 47 |
+
messages = [{"role": "system", "content": system}] + msgs
|
| 48 |
+
|
| 49 |
+
reply = client.chat.completions.create(
|
| 50 |
+
model=config.OPENAI_MODEL,
|
| 51 |
+
messages=messages,
|
| 52 |
+
tools=TOOLS,
|
| 53 |
+
).choices[0].message
|
| 54 |
+
|
| 55 |
+
while reply.tool_calls:
|
| 56 |
+
messages.append(reply)
|
| 57 |
+
messages.extend(handle_tool_call(reply.tool_calls))
|
| 58 |
+
reply = client.chat.completions.create(
|
| 59 |
+
model=config.OPENAI_MODEL,
|
| 60 |
+
messages=messages,
|
| 61 |
+
tools=TOOLS,
|
| 62 |
+
).choices[0].message
|
| 63 |
+
|
| 64 |
+
return reply.content
|
chunking.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
|
| 2 |
+
"""Split text into overlapping chunks, preferring breaks at paragraph or sentence boundaries."""
|
| 3 |
+
chunks: list[str] = []
|
| 4 |
+
start = 0
|
| 5 |
+
while start < len(text):
|
| 6 |
+
end = min(start + chunk_size, len(text))
|
| 7 |
+
if end == len(text):
|
| 8 |
+
chunks.append(text[start:])
|
| 9 |
+
break
|
| 10 |
+
halfway = start + (end - start) // 2
|
| 11 |
+
cut = end
|
| 12 |
+
for sep in ("\n\n", "\n", ".", "!", "?", " "):
|
| 13 |
+
i = text.rfind(sep, halfway, end)
|
| 14 |
+
if i != -1:
|
| 15 |
+
cut = i + len(sep)
|
| 16 |
+
break
|
| 17 |
+
chunks.append(text[start:cut])
|
| 18 |
+
start = cut - overlap
|
| 19 |
+
return chunks
|
config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from openai import OpenAI
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).parent
|
| 10 |
+
|
| 11 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 12 |
+
DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
| 13 |
+
PUSHOVER_USER = os.getenv("PUSHOVER_USER")
|
| 14 |
+
PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN")
|
| 15 |
+
|
| 16 |
+
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
|
| 17 |
+
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
| 18 |
+
DEEPGRAM_STT_MODEL = os.getenv("DEEPGRAM_STT_MODEL", "nova-3")
|
| 19 |
+
DEEPGRAM_TTS_MODEL = os.getenv("DEEPGRAM_TTS_MODEL", "aura-2-thalia-en")
|
| 20 |
+
|
| 21 |
+
RAG_N_RESULTS = int(os.getenv("RAG_N_RESULTS", "3"))
|
| 22 |
+
RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", "500"))
|
| 23 |
+
RAG_CHUNK_OVERLAP = int(os.getenv("RAG_CHUNK_OVERLAP", "50"))
|
| 24 |
+
RAG_DEBUG = os.getenv("RAG_DEBUG", "").lower() in ("1", "true", "yes")
|
| 25 |
+
|
| 26 |
+
_default_chroma = "chroma_db_twin"
|
| 27 |
+
if os.getenv("SPACE_ID") and not os.getenv("CHROMA_PATH"):
|
| 28 |
+
_default_chroma = "/tmp/chroma_db_twin"
|
| 29 |
+
CHROMA_PATH = Path(os.getenv("CHROMA_PATH", _default_chroma))
|
| 30 |
+
if not CHROMA_PATH.is_absolute():
|
| 31 |
+
CHROMA_PATH = ROOT / CHROMA_PATH
|
| 32 |
+
|
| 33 |
+
openai_client: OpenAI | None = None
|
| 34 |
+
deepgram_client = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def ensure_openai_client() -> OpenAI:
|
| 38 |
+
global openai_client
|
| 39 |
+
if OPENAI_API_KEY is None:
|
| 40 |
+
raise RuntimeError("OPENAI_API_KEY is missing. Copy .env.example to .env and add your key.")
|
| 41 |
+
if openai_client is None:
|
| 42 |
+
openai_client = OpenAI(api_key=OPENAI_API_KEY)
|
| 43 |
+
return openai_client
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def ensure_deepgram_client():
|
| 47 |
+
global deepgram_client
|
| 48 |
+
if DEEPGRAM_API_KEY is None:
|
| 49 |
+
raise RuntimeError("DEEPGRAM_API_KEY is missing. Copy .env.example to .env and add your key.")
|
| 50 |
+
if deepgram_client is None:
|
| 51 |
+
from deepgram import DeepgramClient
|
| 52 |
+
|
| 53 |
+
deepgram_client = DeepgramClient(api_key=DEEPGRAM_API_KEY)
|
| 54 |
+
return deepgram_client
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def ensure_clients() -> None:
|
| 58 |
+
"""Validate env vars and initialize API clients (called at app startup)."""
|
| 59 |
+
ensure_openai_client()
|
| 60 |
+
ensure_deepgram_client()
|
knowledge.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Persona topic triggers
|
| 2 |
+
|
| 3 |
+
Keyword-based context is injected when a user's message contains the keyword (case-insensitive). Full persona facts live in `knowledge/*.md` and are retrieved via RAG at query time.
|
| 4 |
+
|
| 5 |
+
## Topics
|
| 6 |
+
|
| 7 |
+
### 2011
|
| 8 |
+
***In 2011, Kush was in high school in his early teens, focused on academics and extracurricular activities. Even then, he showed a growing interest in technology and programming.***
|
| 9 |
+
|
| 10 |
+
### dishes
|
| 11 |
+
***Kush is a big fan of Italian cuisine — pasta, pizza, and gelato. He enjoys experimenting with cooking at home and trying new recipes.***
|
| 12 |
+
|
| 13 |
+
### sports
|
| 14 |
+
***Kush enjoys playing and watching badminton. He has followed the sport since his youth and still tracks major leagues and international tournaments.***
|
| 15 |
+
|
| 16 |
+
### vacation
|
| 17 |
+
***Kush loves tropical vacations — sun, beaches, and local culture — and uses them to unplug and come back refreshed.***
|
knowledge/career.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Career history (most recent first)
|
| 2 |
+
|
| 3 |
+
**Symcor, Toronto** (Jan 2023–Sept 2024)
|
| 4 |
+
At Symcor, Kush worked on a cheque image processing platform built in TypeScript and NestJS, integrated with Azure Cognitive Services for OCR and document understanding. The platform processed roughly 40,000 cheques per day, requiring reliable throughput, error handling, and audit trails for financial compliance.
|
| 5 |
+
|
| 6 |
+
He also built an analyst review dashboard in Next.js with Recharts, Redux, and Shadcn/Tailwind, backed by Supabase SSR with row-level security. Around 200 analysts used the tooling daily across three separate review applications. He integrated Anthropic SDKs into analyst workflows, which cut manual document review time by roughly 40%. The team operated in an NX monorepo with GitLab CI/CD, and affected-only builds reduced pipeline time by about 55%.
|
| 7 |
+
|
| 8 |
+
**Scotiabank, Toronto** (Aug–Dec 2022)
|
| 9 |
+
During an earlier stint at Scotiabank, Kush built a cashback rewards dashboard in React and TypeScript with Recharts and Redux, serving more than 4 million cardholders. He developed the supporting Node.js, Express, and Postgres API with roughly 85% test coverage via Jest. He also integrated the OpenAI SDK for rewards transaction classification, automating about 70% of manual tagging work that analysts had previously done by hand.
|
| 10 |
+
|
| 11 |
+
**IBM, Toronto** (July 2021–July 2022)
|
| 12 |
+
At IBM, Kush helped build CIBC's open banking platform in Next.js and TypeScript, reaching over 6 million digital banking customers. He worked on Watson NLU chatbot flows spanning more than 90 intents for CIBC and Sun Life, and built an Elasticsearch-backed document management system handling 400,000+ documents for Lifeworks. He contributed a reusable Tailwind and Radix component library adopted across three internal products, and deployed services on Red Hat OpenShift Kubernetes with pod autoscaling for production workloads.
|
| 13 |
+
|
| 14 |
+
**MeazureUp, Toronto** (Jan–July 2021)
|
| 15 |
+
At MeazureUp, Kush worked on a restaurant audit and checklist platform deployed to more than 400 franchise locations nationwide. He built a Bull and Redis mass email scheduler that handled 80,000+ sends per week, with attachments and templates stored in AWS S3.
|
| 16 |
+
|
| 17 |
+
**Sightline Innovation, Toronto** (July 2019–Dec 2020)
|
| 18 |
+
At Sightline Innovation, Kush developed a precision agriculture IoT sensor platform with D3 dashboards monitoring 300+ field sensors. He introduced Elasticsearch and Redis caching that cut repeated query time by roughly 65%, making real-time field data usable for agronomists and operations teams.
|
| 19 |
+
|
| 20 |
+
**Jumper.ai, Toronto** (June–Dec 2016)
|
| 21 |
+
Early in his career at Jumper.ai, Kush built a Dialogflow payment chatbot with Node.js and Express webhook handlers, persisting conversation state and payment metadata in Firebase Firestore. This was his first production experience shipping conversational interfaces tied to real financial transactions.
|
knowledge/identity.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Identity
|
| 2 |
+
|
| 3 |
+
Kushagra Trivedi holds a Masters in Applied Computer Science from the University of Winnipeg, completed between 2017 and 2019. His graduate research focused on overlapping community detection in social networks, using Voronoi diagrams and tolerance rough sets under an unsupervised learning framework — work that sits at the intersection of graph theory, machine learning, and network analysis.
|
| 4 |
+
|
| 5 |
+
He is based in Toronto, Canada, where he has built most of his professional career in fintech, banking, and enterprise software. During his master's program, he was awarded the Queen Elizabeth II Diamond Jubilee Scholarship, recognizing academic excellence and community contribution. He is also a published researcher in graph-based community detection, with peer-reviewed work that informed his later interest in data-heavy systems and analytics platforms.
|
| 6 |
+
|
| 7 |
+
## Current role
|
| 8 |
+
|
| 9 |
+
Kush is a Senior Software Developer at Scotiabank, a role he has held since October 2024. He is building a pre-authorized payment service in TypeScript and Node.js, backed by TypeORM, Postgres, and a distributed Redis cache. The service handles roughly 80,000 daily transactions, and his performance work reduced end-to-end latency from around 500ms to roughly 40ms on critical paths.
|
| 10 |
+
|
| 11 |
+
On the frontend, he works in Next.js with React Server Components, Zustand for state, Tailwind and Radix UI for the design system, and SSE streaming for live transaction and status updates. Azure integrations include Blob Storage for document and artifact storage, Service Bus for async messaging between services, and Key Vault for secrets and certificate management.
|
| 12 |
+
|
| 13 |
+
He also built an internal LLM service layer on top of the OpenAI SDK, designed for provider-agnostic switching, structured JSON output, and token-cost instrumentation so teams can track and control AI spend. He has led design reviews across the organization, and TypeScript patterns he established have been adopted by four downstream teams building on the same platform.
|
| 14 |
+
|
| 15 |
+
## Personal context
|
| 16 |
+
|
| 17 |
+
In 2011, Kush was in high school in his early teens, focused on academics and extracurricular activities. Even then, he showed a growing interest in technology and programming — tinkering with computers, exploring how software worked, and gravitating toward problem-solving that would later shape his career in computer science and software engineering.
|
| 18 |
+
|
| 19 |
+
Kush is a big fan of Italian cuisine. He especially enjoys pasta, pizza, and gelato, and he likes exploring authentic trattorias when he travels. At home, he experiments with cooking — trying new recipes, refining sauces and doughs, and treating the kitchen as a creative outlet away from the screen.
|
| 20 |
+
|
| 21 |
+
Badminton is a long-standing passion. Kush has played and watched the sport since his youth, and he still follows major leagues and international tournaments. It is both exercise and a way to stay connected to a competitive sport he grew up with.
|
| 22 |
+
|
| 23 |
+
When he takes time off, Kush gravitates toward tropical destinations — places with sun, beaches, and rich local culture. He values the chance to unplug, swim, explore new food and scenery, and come back to work refreshed rather than burned out.
|
knowledge/technical.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Core technical stack
|
| 2 |
+
|
| 3 |
+
Languages: TypeScript is the primary language across most production work — used for strict typing, shared contracts between frontend and backend, and maintainability across large teams. JavaScript remains in play for scripts, legacy integrations, and rapid prototyping. Python is used for data processing, ML-adjacent work, and lightweight APIs with Flask or FastAPI. Bash covers automation, deployment scripts, and CI pipeline glue. SQL is used daily for Postgres queries, schema design, migrations, and performance tuning.
|
| 4 |
+
|
| 5 |
+
Frontend: React is the default UI layer, often paired with Next.js for routing, SSR, and React Server Components. State is managed with Zustand for lightweight local and global state, and Redux where predictable action flows and middleware matter at scale. Styling uses Tailwind for utility-first layout, Radix UI and Shadcn for accessible primitives and consistent design systems. Data visualization leans on Recharts for standard charts, D3 for custom interactive visuals, and Chart.js where simpler chart needs fit.
|
| 6 |
+
|
| 7 |
+
Backend: Node.js is the backbone for most services — NestJS for structured, modular APIs with dependency injection, Express for lean microservices and integrations. Python backends use Flask for smaller services and FastAPI for typed, async-friendly REST APIs. REST is the standard API style across teams, with clear versioning, error contracts, and OpenAPI documentation where applicable.
|
| 8 |
+
|
| 9 |
+
Databases: Postgres is the primary relational store for transactional workloads, using TypeORM or raw SQL depending on complexity. MongoDB appears where flexible document schemas help. Elasticsearch powers full-text search, analytics, and log-heavy query patterns. Redis handles caching, pub/sub, session storage, and job queues in distributed setups.
|
| 10 |
+
|
| 11 |
+
DevOps: Azure is heavily used — Blob Storage, Service Bus, Key Vault, and Azure Cognitive Services. Docker containerizes services for consistent dev/prod parity. Kubernetes and Red Hat OpenShift manage orchestration, autoscaling, and multi-environment deployments. GCP and AWS appear for specific integrations such as S3 and cloud-native ML. GitHub and GitLab CI/CD automate build, test, and deploy pipelines; Grafana monitors metrics and alerts in production.
|
| 12 |
+
|
| 13 |
+
AI/LLM: OpenAI SDK and Anthropic SDK for production LLM calls — structured outputs, streaming, token tracking, and provider switching. Claude Code, MCP, and Copilot support developer workflows, agent tooling, and IDE-assisted development.
|
| 14 |
+
|
| 15 |
+
Certs: Red Hat EX180 (containers and Kubernetes) and EX080 (DevOps practices) — validates hands-on container and pipeline work.
|
| 16 |
+
|
| 17 |
+
## What you know well
|
| 18 |
+
|
| 19 |
+
- Production LLM integration in financial workflows: wiring OpenAI and Anthropic into payment classification, document review, and analyst tooling with guardrails, structured JSON responses, cost instrumentation, and fallback when models fail or hit rate limits.
|
| 20 |
+
|
| 21 |
+
- Modern frontend architecture: React Server Components for reducing client bundle size and improving time-to-first-byte, SSE streaming for live updates without polling, and real-time dashboards that stay responsive under heavy data refresh.
|
| 22 |
+
|
| 23 |
+
- Building for scale: Redis and distributed caching to cut latency on hot paths, horizontal scaling patterns, database indexing and query optimization, and CI/CD pipelines with affected-only builds and monorepo tooling to keep feedback loops fast across large codebases.
|
| 24 |
+
|
| 25 |
+
- Financial domain expertise: pre-authorized payments and high-volume transaction processing, cheque image processing and OCR pipelines, open banking APIs and consent flows, and rewards classification and cashback dashboards — all with compliance and audit expectations in mind.
|
| 26 |
+
|
| 27 |
+
- TypeScript architecture and multi-team API design: shared types across services, module boundaries that downstream teams can adopt without breaking changes, and design reviews that establish patterns for error handling, logging, and auth replicated across four or more consuming teams.
|
prompts.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import warnings
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
KNOWLEDGE_PATH = Path(__file__).parent / "knowledge.md"
|
| 5 |
+
TOPICS_HEADER = "## Topics"
|
| 6 |
+
|
| 7 |
+
SYSTEM_MESSAGE = """You are a digital twin of Kushagra Trivedi. When people talk to you, you respond AS Kush — in first person, using his voice, personality, and knowledge.
|
| 8 |
+
Important: do not make things up. If you don't know an answer, say you don't know. The only factual information available to you is what's in this system message and any retrieved Context below. You cannot get any more facts about Kushagra from the internet or make them up.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _load_topics() -> dict[str, str]:
|
| 13 |
+
if not KNOWLEDGE_PATH.is_file():
|
| 14 |
+
warnings.warn(
|
| 15 |
+
f"knowledge.md not found at {KNOWLEDGE_PATH}; topic keyword context will be empty.",
|
| 16 |
+
stacklevel=2,
|
| 17 |
+
)
|
| 18 |
+
return {}
|
| 19 |
+
|
| 20 |
+
text = KNOWLEDGE_PATH.read_text(encoding="utf-8")
|
| 21 |
+
if TOPICS_HEADER not in text:
|
| 22 |
+
return {}
|
| 23 |
+
_, topics_section = text.split(TOPICS_HEADER, 1)
|
| 24 |
+
return _parse_topics(topics_section)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _parse_topics(section: str) -> dict[str, str]:
|
| 28 |
+
topics: dict[str, str] = {}
|
| 29 |
+
current_key: str | None = None
|
| 30 |
+
current_lines: list[str] = []
|
| 31 |
+
|
| 32 |
+
for line in section.splitlines():
|
| 33 |
+
if line.startswith("### "):
|
| 34 |
+
if current_key:
|
| 35 |
+
topics[current_key] = "\n".join(current_lines).strip()
|
| 36 |
+
current_key = line[4:].strip()
|
| 37 |
+
current_lines = []
|
| 38 |
+
elif current_key is not None:
|
| 39 |
+
current_lines.append(line)
|
| 40 |
+
|
| 41 |
+
if current_key:
|
| 42 |
+
topics[current_key] = "\n".join(current_lines).strip()
|
| 43 |
+
|
| 44 |
+
return topics
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
TOPIC_CONTEXT = _load_topics()
|
rag.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChromaDB-backed RAG: chunk knowledge files, embed, retrieve."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import chromadb
|
| 9 |
+
|
| 10 |
+
import config
|
| 11 |
+
from chunking import chunk_text
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).parent
|
| 14 |
+
KNOWLEDGE_DIR = ROOT / "knowledge"
|
| 15 |
+
COLLECTION_NAME = "kush_memo"
|
| 16 |
+
|
| 17 |
+
DOCUMENTS = [
|
| 18 |
+
{"file": "identity.md", "source": "Identity and Personal Context"},
|
| 19 |
+
{"file": "career.md", "source": "Career History"},
|
| 20 |
+
{"file": "technical.md", "source": "Technical Stack"},
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
_chroma_client: chromadb.ClientAPI | None = None
|
| 24 |
+
_collection: chromadb.Collection | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _get_chroma_client() -> chromadb.ClientAPI:
|
| 28 |
+
global _chroma_client
|
| 29 |
+
if _chroma_client is None:
|
| 30 |
+
config.CHROMA_PATH.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
_chroma_client = chromadb.PersistentClient(path=str(config.CHROMA_PATH))
|
| 32 |
+
return _chroma_client
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_collection() -> chromadb.Collection:
|
| 36 |
+
global _collection
|
| 37 |
+
if _collection is None:
|
| 38 |
+
_collection = _get_chroma_client().get_or_create_collection(COLLECTION_NAME)
|
| 39 |
+
return _collection
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _load_documents() -> list[dict[str, str]]:
|
| 43 |
+
docs: list[dict[str, str]] = []
|
| 44 |
+
for spec in DOCUMENTS:
|
| 45 |
+
path = KNOWLEDGE_DIR / spec["file"]
|
| 46 |
+
if not path.is_file():
|
| 47 |
+
raise FileNotFoundError(f"Knowledge file not found: {path}")
|
| 48 |
+
docs.append({"text": path.read_text(encoding="utf-8"), "source": spec["source"]})
|
| 49 |
+
return docs
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _chunk_documents(documents: list[dict[str, str]]) -> tuple[list[str], list[str], list[dict]]:
|
| 53 |
+
chunks: list[str] = []
|
| 54 |
+
ids: list[str] = []
|
| 55 |
+
metadatas: list[dict] = []
|
| 56 |
+
|
| 57 |
+
for doc in documents:
|
| 58 |
+
doc_chunks = chunk_text(
|
| 59 |
+
doc["text"],
|
| 60 |
+
chunk_size=config.RAG_CHUNK_SIZE,
|
| 61 |
+
overlap=config.RAG_CHUNK_OVERLAP,
|
| 62 |
+
)
|
| 63 |
+
ids.extend(str(uuid.uuid4()) for _ in doc_chunks)
|
| 64 |
+
metadatas.extend(
|
| 65 |
+
{"source": doc["source"], "chunk_index": i} for i in range(len(doc_chunks))
|
| 66 |
+
)
|
| 67 |
+
chunks.extend(doc_chunks)
|
| 68 |
+
|
| 69 |
+
return chunks, ids, metadatas
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def embed_texts(texts: list[str]) -> list[list[float]]:
|
| 73 |
+
client = config.ensure_openai_client()
|
| 74 |
+
response = client.embeddings.create(
|
| 75 |
+
input=texts,
|
| 76 |
+
model=config.EMBEDDING_MODEL,
|
| 77 |
+
)
|
| 78 |
+
return [item.embedding for item in response.data]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_index(*, force_rebuild: bool = False) -> int:
|
| 82 |
+
"""Index knowledge files into ChromaDB. Returns number of chunks stored."""
|
| 83 |
+
config.ensure_openai_client()
|
| 84 |
+
collection = get_collection()
|
| 85 |
+
|
| 86 |
+
if force_rebuild:
|
| 87 |
+
existing = collection.get()["ids"]
|
| 88 |
+
if existing:
|
| 89 |
+
collection.delete(ids=existing)
|
| 90 |
+
|
| 91 |
+
if collection.count() > 0 and not force_rebuild:
|
| 92 |
+
return collection.count()
|
| 93 |
+
|
| 94 |
+
documents = _load_documents()
|
| 95 |
+
chunks, ids, metadatas = _chunk_documents(documents)
|
| 96 |
+
if not chunks:
|
| 97 |
+
return 0
|
| 98 |
+
|
| 99 |
+
embeddings = embed_texts(chunks)
|
| 100 |
+
collection.add(
|
| 101 |
+
ids=ids,
|
| 102 |
+
documents=chunks,
|
| 103 |
+
embeddings=embeddings,
|
| 104 |
+
metadatas=metadatas,
|
| 105 |
+
)
|
| 106 |
+
return len(chunks)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def ensure_index() -> None:
|
| 110 |
+
"""Build the vector index when the collection is empty."""
|
| 111 |
+
if get_collection().count() == 0:
|
| 112 |
+
count = build_index()
|
| 113 |
+
print(f"Built RAG index with {count} chunks.")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def retrieve(query: str, n_results: int | None = None) -> tuple[str, list[dict]]:
|
| 117 |
+
"""Return joined context text and retrieval metadata for a user query."""
|
| 118 |
+
config.ensure_openai_client()
|
| 119 |
+
collection = get_collection()
|
| 120 |
+
if collection.count() == 0:
|
| 121 |
+
return "", []
|
| 122 |
+
|
| 123 |
+
n = n_results if n_results is not None else config.RAG_N_RESULTS
|
| 124 |
+
query_embedding = embed_texts([query])[0]
|
| 125 |
+
results = collection.query(
|
| 126 |
+
query_embeddings=[query_embedding],
|
| 127 |
+
n_results=min(n, collection.count()),
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
documents = results["documents"][0]
|
| 131 |
+
metadatas = results["metadatas"][0]
|
| 132 |
+
context = "\n\n".join(documents)
|
| 133 |
+
return context, metadatas
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=1.0.0
|
| 2 |
+
python-dotenv>=1.0.0
|
| 3 |
+
gradio>=6.0.0,<7.0.0
|
| 4 |
+
deepgram-sdk>=4.0.0
|
| 5 |
+
requests>=2.31.0
|
| 6 |
+
chromadb>=0.5.0
|
tools.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
|
| 4 |
+
import requests
|
| 5 |
+
|
| 6 |
+
from config import PUSHOVER_TOKEN, PUSHOVER_USER
|
| 7 |
+
|
| 8 |
+
PUSHOVER_URL = "https://api.pushover.net/1/messages.json"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def send_notification(message: str) -> None:
|
| 12 |
+
if not PUSHOVER_USER or not PUSHOVER_TOKEN:
|
| 13 |
+
raise RuntimeError("PUSHOVER_USER and PUSHOVER_TOKEN must be set to use notifications.")
|
| 14 |
+
payload = {"user": PUSHOVER_USER, "token": PUSHOVER_TOKEN, "message": message}
|
| 15 |
+
requests.post(PUSHOVER_URL, data=payload, timeout=10)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def dice_roll() -> int:
|
| 19 |
+
return random.randint(1, 6)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
SEND_NOTIFICATION_FUNCTION = {
|
| 23 |
+
"name": "send_notification",
|
| 24 |
+
"description": (
|
| 25 |
+
"Send a notification to real version of you (Kush) phone via Pushover. "
|
| 26 |
+
"Use this to alert Kush of important events, completed tasks, time-sensitive information, etc."
|
| 27 |
+
),
|
| 28 |
+
"parameters": {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"properties": {
|
| 31 |
+
"message": {
|
| 32 |
+
"type": "string",
|
| 33 |
+
"description": "The message to send",
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"required": ["message"],
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
ROLL_DICE_FUNCTION = {
|
| 41 |
+
"name": "roll_dice",
|
| 42 |
+
"description": "Roll a dice and return the result",
|
| 43 |
+
"parameters": {
|
| 44 |
+
"type": "object",
|
| 45 |
+
"properties": {},
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
TOOLS = [
|
| 50 |
+
{"type": "function", "function": SEND_NOTIFICATION_FUNCTION},
|
| 51 |
+
{"type": "function", "function": ROLL_DICE_FUNCTION},
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def handle_tool_call(tool_calls):
|
| 56 |
+
tool_results = []
|
| 57 |
+
for tool_call in tool_calls:
|
| 58 |
+
function_name = tool_call.function.name
|
| 59 |
+
args = json.loads(tool_call.function.arguments)
|
| 60 |
+
|
| 61 |
+
if function_name == "send_notification":
|
| 62 |
+
send_notification(args["message"])
|
| 63 |
+
content = f"Notification sent: {args['message']}"
|
| 64 |
+
elif function_name == "roll_dice":
|
| 65 |
+
result = dice_roll()
|
| 66 |
+
content = f"Dice rolled: {result}"
|
| 67 |
+
else:
|
| 68 |
+
content = f"Unknown function: {function_name}"
|
| 69 |
+
|
| 70 |
+
tool_results.append({
|
| 71 |
+
"role": "tool",
|
| 72 |
+
"content": content,
|
| 73 |
+
"tool_call_id": tool_call.id,
|
| 74 |
+
})
|
| 75 |
+
return tool_results
|
ui.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
from chat import response_ai
|
| 4 |
+
from voice import speak_text, transcribe_audio
|
| 5 |
+
|
| 6 |
+
CSS = """
|
| 7 |
+
#record-box { background: rgb(39, 39, 42) !important; border-radius: 8px !important; padding: 8px !important; }
|
| 8 |
+
#record-box #mic { width: 100% !important; background: transparent !important; }
|
| 9 |
+
#record-box #mic .wrap, #record-box #mic .form, #record-box #mic .empty,
|
| 10 |
+
#record-box #mic .audio-container { background: transparent !important; }
|
| 11 |
+
#record-box #mic .empty, #record-box #mic .audio-container { min-height: 0 !important; }
|
| 12 |
+
#reply-audio { position: absolute !important; width: 1px !important; height: 1px !important;
|
| 13 |
+
overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; }
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
RESET_AUDIO_JS = """
|
| 17 |
+
() => {
|
| 18 |
+
const app = document.querySelector('gradio-app');
|
| 19 |
+
const root = (app && app.shadowRoot) ? app.shadowRoot : document;
|
| 20 |
+
const c = root.querySelector('#reply-audio');
|
| 21 |
+
if (!c) return;
|
| 22 |
+
const m = c.querySelector('audio, video');
|
| 23 |
+
if (!m) return;
|
| 24 |
+
const play = () => { try { m.currentTime = 0; } catch (e) {} const p = m.play(); if (p && p.catch) p.catch(() => {}); };
|
| 25 |
+
if (m.readyState >= 2) play(); else m.addEventListener('loadeddata', play, { once: true });
|
| 26 |
+
}
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def chat_message(role: str, text: str) -> dict:
|
| 31 |
+
"""Gradio 6 messages format with structured content blocks."""
|
| 32 |
+
return {"role": role, "content": [{"type": "text", "text": text}]}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def voice_transcribe(audio, history):
|
| 36 |
+
"""Step 1: transcribe speech, show it as the user message, and clear the mic."""
|
| 37 |
+
history = history or []
|
| 38 |
+
if audio is None:
|
| 39 |
+
return history, None
|
| 40 |
+
try:
|
| 41 |
+
transcript = transcribe_audio(audio)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
return history + [chat_message("assistant", f"Error: {e}")], None
|
| 44 |
+
if not transcript:
|
| 45 |
+
return history, None
|
| 46 |
+
return history + [chat_message("user", transcript)], None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def voice_reply(history):
|
| 50 |
+
"""Step 2: LLM reply + autoplayed speech."""
|
| 51 |
+
history = history or []
|
| 52 |
+
if not history or history[-1]["role"] != "user":
|
| 53 |
+
yield history, None
|
| 54 |
+
return
|
| 55 |
+
try:
|
| 56 |
+
reply = response_ai(history)
|
| 57 |
+
audio_path = speak_text(reply)
|
| 58 |
+
except Exception as e:
|
| 59 |
+
yield history + [chat_message("assistant", f"Error: {e}")], None
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
yield history + [chat_message("assistant", reply)], audio_path
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def add_user_message(message, history):
|
| 66 |
+
"""Step 1: show the user message and clear the textbox immediately."""
|
| 67 |
+
history = history or []
|
| 68 |
+
if not message or not message.strip():
|
| 69 |
+
return history, message
|
| 70 |
+
return history + [chat_message("user", message.strip())], ""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def bot_reply(history):
|
| 74 |
+
"""Step 2: generate the assistant reply in chat only (no TTS)."""
|
| 75 |
+
history = history or []
|
| 76 |
+
if not history or history[-1]["role"] != "user":
|
| 77 |
+
yield history
|
| 78 |
+
return
|
| 79 |
+
try:
|
| 80 |
+
reply = response_ai(history)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
yield history + [chat_message("assistant", f"Error: {e}")]
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
yield history + [chat_message("assistant", reply)]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def create_demo(on_load=None) -> gr.Blocks:
|
| 89 |
+
with gr.Blocks(title="Kush Digital Twin — Voice") as demo:
|
| 90 |
+
gr.Markdown("# MultiModal Chat")
|
| 91 |
+
chatbot = gr.Chatbot(show_label=False, height=420, autoscroll=True)
|
| 92 |
+
text_in = gr.Textbox(show_label=False, placeholder="Type a message…", container=False)
|
| 93 |
+
with gr.Column(elem_id="record-box"):
|
| 94 |
+
mic = gr.Audio(
|
| 95 |
+
sources=["microphone"],
|
| 96 |
+
type="filepath",
|
| 97 |
+
show_label=False,
|
| 98 |
+
container=False,
|
| 99 |
+
elem_id="mic",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
audio_out = gr.Audio(autoplay=True, interactive=False, elem_id="reply-audio", buttons=[])
|
| 103 |
+
|
| 104 |
+
mic.stop_recording(
|
| 105 |
+
voice_transcribe, [mic, chatbot], [chatbot, mic], queue=False,
|
| 106 |
+
).then(voice_reply, chatbot, [chatbot, audio_out])
|
| 107 |
+
|
| 108 |
+
text_in.submit(
|
| 109 |
+
add_user_message, [text_in, chatbot], [chatbot, text_in], queue=False,
|
| 110 |
+
).then(bot_reply, chatbot, chatbot)
|
| 111 |
+
|
| 112 |
+
audio_out.change(None, None, None, js=RESET_AUDIO_JS)
|
| 113 |
+
|
| 114 |
+
if on_load is not None:
|
| 115 |
+
demo.load(on_load, None, None, queue=False)
|
| 116 |
+
|
| 117 |
+
demo.queue(default_concurrency_limit=1)
|
| 118 |
+
demo._deprecated_css = CSS
|
| 119 |
+
return demo
|
voice.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
import config
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def transcribe_audio(audio_path: str) -> str:
|
| 8 |
+
"""Speech-to-text with Deepgram Nova."""
|
| 9 |
+
config.ensure_clients()
|
| 10 |
+
with open(audio_path, "rb") as f:
|
| 11 |
+
audio_bytes = f.read()
|
| 12 |
+
response = config.deepgram_client.listen.v1.media.transcribe_file(
|
| 13 |
+
request=audio_bytes,
|
| 14 |
+
model=config.DEEPGRAM_STT_MODEL,
|
| 15 |
+
smart_format=True,
|
| 16 |
+
punctuate=True,
|
| 17 |
+
)
|
| 18 |
+
return response.results.channels[0].alternatives[0].transcript.strip()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def speak_text(text: str) -> str:
|
| 22 |
+
"""Text-to-speech with Deepgram Aura; returns a unique WAV path (helps autoplay)."""
|
| 23 |
+
config.ensure_clients()
|
| 24 |
+
text = text[:2000]
|
| 25 |
+
stream = config.deepgram_client.speak.v1.audio.generate(
|
| 26 |
+
text=text,
|
| 27 |
+
model=config.DEEPGRAM_TTS_MODEL,
|
| 28 |
+
encoding="linear16",
|
| 29 |
+
container="wav",
|
| 30 |
+
)
|
| 31 |
+
audio_bytes = b"".join(stream)
|
| 32 |
+
out = tempfile.NamedTemporaryFile(delete=False, suffix=f"_reply_{time.time_ns()}.wav")
|
| 33 |
+
out.write(audio_bytes)
|
| 34 |
+
out.close()
|
| 35 |
+
return out.name
|