KUSHT07 commited on
Commit
3618b44
·
verified ·
1 Parent(s): 13ea6d8

Upload 16 files

Browse files
Files changed (16) hide show
  1. README.md +145 -13
  2. app.py +23 -0
  3. build_rag_index.py +15 -0
  4. chat.py +64 -0
  5. chunking.py +19 -0
  6. config.py +60 -0
  7. knowledge.md +17 -0
  8. knowledge/career.md +21 -0
  9. knowledge/identity.md +23 -0
  10. knowledge/technical.md +27 -0
  11. prompts.py +47 -0
  12. rag.py +133 -0
  13. requirements.txt +6 -0
  14. tools.py +75 -0
  15. ui.py +119 -0
  16. voice.py +35 -0
README.md CHANGED
@@ -1,13 +1,145 @@
1
- ---
2
- title: Twin
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: green
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![Digital Twin Voice flow chart](docs/Flow-chart.png)
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