""" ╔══════════════════════════════════════════════════════════════════╗ ║ Mohammad Areef's Digital Twin — AI Chatbot ║ ║ RAG-powered assistant using OpenAI, ChromaDB, and Gradio ║ ╚══════════════════════════════════════════════════════════════════╝ """ # ── Standard library ────────────────────────────────────────────────────────── import json import os import random import uuid from pprint import pprint # ── Third-party ─────────────────────────────────────────────────────────────── import chromadb import gradio as gr import requests from huggingface_hub import hf_hub_download from openai import OpenAI # ══════════════════════════════════════════════════════════════════════════════ # CONFIGURATION # ══════════════════════════════════════════════════════════════════════════════ # ── OpenAI ──────────────────────────────────────────────────────────────────── OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") if not OPENAI_API_KEY: raise EnvironmentError( "Missing environment variable: OPENAI_API_KEY. " "Set it before running this script." ) # ── Hugging Face ────────────────────────────────────────────────────────────── HF_TOKEN = os.getenv("HF_TOKEN") HF_REPO_ID = "areefmohammad/MyDigitalTwinDS" # ── Pushover (push notifications) ───────────────────────────────────────────── PUSHOVER_USER = os.getenv("PUSHOVER_USER") PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN") PUSHOVER_URL = "https://api.pushover.net/1/messages.json" # ── Models ──────────────────────────────────────────────────────────────────── EMBEDDING_MODEL = "text-embedding-3-small" CHAT_MODEL = "gpt-4.1-mini" # ── ChromaDB ────────────────────────────────────────────────────────────────── CHROMA_PATH = "./chroma_db_DigitalTwin" CHROMA_COLLECTION = "My_DigitalTwin" # ── RAG chunking parameters ─────────────────────────────────────────────────── CHUNK_SIZE = 300 # Maximum characters per chunk CHUNK_OVERLAP = 30 # Overlap between consecutive chunks to preserve context RAG_TOP_K = 5 # Number of chunks to retrieve per query # ── Source documents to load from HF dataset ────────────────────────────────── # Keys become the "source" metadata tag stored in ChromaDB DOC_FILES: dict[str, str] = { "Overview": "Overview.txt", "Education": "Education.txt", "Experience": "Experience.txt", } # ── Initialise OpenAI client ────────────────────────────────────────────────── client = OpenAI(api_key=OPENAI_API_KEY) # ══════════════════════════════════════════════════════════════════════════════ # DOCUMENT LOADING # ══════════════════════════════════════════════════════════════════════════════ def load_documents_from_hf( doc_files: dict[str, str], repo_id: str, token: str | None, ) -> list[dict[str, str]]: """ Download text files from a Hugging Face dataset repo and return them as a list of {'text': ..., 'source': ...} dicts. Args: doc_files: Mapping of source label → filename in the HF repo. repo_id: HF dataset repository ID (e.g. 'user/repo'). token: HF access token (required for private repos). Returns: List of document dicts ready for chunking. """ documents = [] for source_name, filename in doc_files.items(): # Download file to local HF cache; returns the local file path local_path = hf_hub_download( repo_id=repo_id, filename=filename, repo_type="dataset", token=token, ) # Read the file as UTF-8 text with open(local_path, "r", encoding="utf-8") as fh: text = fh.read() documents.append({"text": text, "source": source_name}) print(f" ✔ Loaded: {source_name} ({len(text):,} chars)") return documents # ══════════════════════════════════════════════════════════════════════════════ # TEXT CHUNKING # ══════════════════════════════════════════════════════════════════════════════ # Delimiters tried in priority order when looking for a natural break point. # Each tuple is (delimiter_string, length_of_delimiter) so we can correctly # advance the cursor past the delimiter itself. _DELIMITERS: list[tuple[str, int]] = [ ("\n\n", 2), # Paragraph break — strongest boundary ("\n", 1), # Line break (". ", 2), # Sentence end (" ", 1), # Word boundary — weakest fallback ] def split_text_into_chunks( text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP, ) -> list[str]: """ Split *text* into overlapping chunks that respect natural language boundaries (paragraphs > lines > sentences > words). Strategy -------- 1. Project a target window of *chunk_size* characters. 2. Search backwards from the window's end for the nearest delimiter at or after the halfway point — this keeps chunks reasonably sized while avoiding mid-word/mid-sentence splits. 3. The next chunk starts *overlap* characters before the current chunk's end, then advances to the next clean boundary so that the overlap itself starts at a natural point. Args: text: The full document text to split. chunk_size: Target maximum characters per chunk. overlap: Number of characters to share between consecutive chunks. Returns: Ordered list of text chunk strings. """ chunks: list[str] = [] start = 0 while start < len(text): end = start + chunk_size if end < len(text): # Search backwards from `end` to find the best split point, # but not earlier than the halfway mark (keeps chunks long enough). halfway = start + chunk_size // 2 for delimiter, delim_len in _DELIMITERS: pos = text.rfind(delimiter, halfway, end) if pos != -1: end = pos + delim_len # include delimiter in this chunk break chunks.append(text[start:end]) if end >= len(text): break # reached the end of the document # ── Compute start of next chunk (with overlap) ───────────────────── next_start = end - overlap # Advance next_start to the first clean boundary inside the overlap # zone so chunks don't begin mid-word or mid-sentence. for delimiter, delim_len in _DELIMITERS: pos = text.find(delimiter, next_start, end) if pos != -1: next_start = pos + delim_len break # Guard against infinite loop: always advance by at least 1 character start = max(next_start, start + 1) return chunks # ══════════════════════════════════════════════════════════════════════════════ # RAG INDEXING (chunk → embed → store) # ══════════════════════════════════════════════════════════════════════════════ def build_rag_index( documents: list[dict[str, str]], collection: chromadb.Collection, ) -> None: """ Chunk every document, generate OpenAI embeddings, and upsert into the ChromaDB *collection*. Args: documents: List of {'text': ..., 'source': ...} dicts. collection: Target ChromaDB collection (already cleared by caller). """ all_chunks: list[str] = [] all_ids: list[str] = [] all_metadatas: list[dict] = [] # ── 1. Chunk all documents ───────────────────────────────────────────── print("\n── Chunking documents ───────────────────────────────────────────") for doc in documents: chunks = split_text_into_chunks(doc["text"]) ids = [str(uuid.uuid4()) for _ in chunks] metadatas = [ {"source": doc["source"], "chunk_index": i} for i in range(len(chunks)) ] all_chunks.extend(chunks) all_ids.extend(ids) all_metadatas.extend(metadatas) print(f" ✔ {doc['source']}: {len(chunks)} chunks") # ── 2. Debug: print every chunk ──────────────────────────────────────── print("\n── Chunk preview ────────────────────────────────────────────────") for i, (chunk, meta) in enumerate(zip(all_chunks, all_metadatas)): print( f" [{i+1:03d}] source={meta['source']} " f"idx={meta['chunk_index']} len={len(chunk)}\n" f" {chunk!r}\n" ) # ── 3. Generate embeddings in a single batched API call ──────────────── print("── Generating embeddings ────────────────────────────────────────") embed_response = client.embeddings.create( model=EMBEDDING_MODEL, input=all_chunks, # batch all chunks for efficiency ) embeddings = [item.embedding for item in embed_response.data] print(f" ✔ {len(embeddings)} embeddings × {len(embeddings[0])} dims") # ── 4. Store in ChromaDB ─────────────────────────────────────────────── print("── Storing in ChromaDB ──────────────────────────────────────────") collection.add( ids=all_ids, embeddings=embeddings, documents=all_chunks, metadatas=all_metadatas, ) print(f" ✔ Collection '{collection.name}' now has " f"{collection.count()} entries\n") # ══════════════════════════════════════════════════════════════════════════════ # TOOL DEFINITIONS # ══════════════════════════════════════════════════════════════════════════════ # ── send_notification ───────────────────────────────────────────────────────── # Track notification timestamps to detect Pushover rate limiting _notification_times: list[float] = [] def send_notification(message: str) -> str: """ Send a push notification to Areef's phone via Pushover. Returns a status string that is surfaced back to the LLM as the tool's result so it can acknowledge success or failure in its reply. Includes full logging and retry logic for rate-limit detection. """ import time if not PUSHOVER_TOKEN or not PUSHOVER_USER: print(" ✘ Notification failed: Pushover credentials not configured.") return "Notification failed: Pushover credentials not configured." # ── Log every attempt so we can spot rate-limiting in HF logs ───────── _notification_times.append(time.time()) attempt_num = len(_notification_times) print(f"\n{'─'*60}") print(f" NOTIFICATION ATTEMPT #{attempt_num}") print(f" Message : {message[:120]!r}") print(f" Time : {time.strftime('%H:%M:%S')}") payload = { "user": PUSHOVER_USER, "token": PUSHOVER_TOKEN, "message": message, } # ── First attempt ────────────────────────────────────────────────────── try: resp = requests.post(PUSHOVER_URL, data=payload, timeout=10) print(f" HTTP : {resp.status_code}") print(f" Body : {resp.text[:200]!r}") except Exception as e: print(f" ✘ Request exception: {e}") return f"Notification failed (exception): {e}" # ── Handle rate limit (429) with one automatic retry after 2s ───────── if resp.status_code == 429: print(f" ⚠ Rate limited by Pushover — waiting 2s then retrying...") time.sleep(2) try: resp = requests.post(PUSHOVER_URL, data=payload, timeout=10) print(f" Retry HTTP : {resp.status_code}") print(f" Retry Body : {resp.text[:200]!r}") except Exception as e: print(f" ✘ Retry exception: {e}") return f"Notification failed after retry (exception): {e}" if resp.status_code == 200: print(f" ✔ Notification #{attempt_num} sent successfully") print(f"{'─'*60}") return f"Notification sent successfully: {message}" print(f" ✘ Notification #{attempt_num} FAILED — HTTP {resp.status_code}") print(f"{'─'*60}") return f"Notification failed (HTTP {resp.status_code}): {resp.text[:100]}" SEND_NOTIFICATION_TOOL: dict = { "type": "function", "function": { "name": "send_notification", "description": ( "Sends a real-time push notification to Mohammad Areef's phone. " "Use this tool when:\n" " (1) Someone wants to get in touch, hire, or collaborate — " "first collect their name and contact details, then send " "Areef a notification with those details.\n" " (2) You don't know the answer to a question about Areef — " "send automatically (no need to ask the user first) and include " "the question so Areef can add the missing info later." ), "parameters": { "type": "object", "properties": { "message": { "type": "string", "description": "The notification message to deliver to Areef's device.", } }, "required": ["message"], }, }, } # ── dice_roll ───────────────────────────────────────────────────────────────── def dice_roll() -> int: """Simulate rolling two six-sided dice (result range: 2–12).""" return random.randint(2, 12) DICE_ROLL_TOOL: dict = { "type": "function", "function": { "name": "dice_roll", "description": ( "Simulates rolling two six-sided dice and returns the combined " "result (2–12). Use this for games, random decisions, or any " "request for a random number in that range." ), "parameters": { "type": "object", "properties": {}, "required": [], }, }, } # ── Tool registry ───────────────────────────────────────────────────────────── # Add new tools here; handle_tool_call routes by the "name" field above. TOOLS: list[dict] = [SEND_NOTIFICATION_TOOL, DICE_ROLL_TOOL] # ══════════════════════════════════════════════════════════════════════════════ # TOOL DISPATCH # ══════════════════════════════════════════════════════════════════════════════ def handle_tool_calls(tool_calls) -> list[dict]: """ Execute every tool call requested by the model and return results in the format expected by the OpenAI chat-completions API. Args: tool_calls: List of tool-call objects from the model response. Returns: List of {'role': 'tool', 'content': ..., 'tool_call_id': ...} dicts. """ results = [] for call in tool_calls: fn_name = call.function.name args = json.loads(call.function.arguments) # ── Route to the correct implementation ─────────────────────────── if fn_name == "send_notification": content = send_notification(args["message"]) elif fn_name == "dice_roll": content = f"Dice roll result: {dice_roll()}" else: # Unknown tool — surface a clear error so the model can handle it content = f"Error: unknown tool '{fn_name}'." results.append({ "role": "tool", "content": content, "tool_call_id": call.id, # links result to the originating call }) return results # ══════════════════════════════════════════════════════════════════════════════ # SYSTEM PROMPT # ══════════════════════════════════════════════════════════════════════════════ SYSTEM_MESSAGE = """\ You are the digital twin of Mohammad Areef Penukonda. Respond as Areef — in first person, using his voice, personality, and knowledge. ═══════════════════════════════════════════════ CORE RULES — NON-NEGOTIABLE ═══════════════════════════════════════════════ 1. FACTS ONLY — ZERO INVENTION • Every claim you make MUST be traceable to this system message or the context explicitly provided in the conversation. • Do NOT infer, extrapolate, assume, or "fill in the blanks." • Do NOT use any external knowledge, training data, or internet facts about any real person — including Areef himself. • If a fact is not explicitly stated here, it does not exist. 2. UNKNOWN INFORMATION — HONEST + NOTIFY • If asked anything not covered by this system message, respond: "I don't have that information available right now — but I've just notified Areef so he can add it." • Then IMMEDIATELY call send_notification — no confirmation needed. • Never guess, hedge with phrases like "I think…" or "probably…", or piece together an answer from partial clues. 3. STRICT PERSONA BOUNDARIES • Speak in first person as Areef. • Do NOT break character to explain you are an AI — stay in role, but never fabricate personal details to maintain the illusion. • If you are truly uncertain whether something is in-scope, default to Rule 2 (say you don't know + notify). 4. CONTACT & COLLABORATION REQUESTS • Before sending any outreach notification, collect: - The person's full name - Their preferred contact method (email / phone / LinkedIn / etc.) - A brief note on the purpose (hire / collaborate / connect / other) • Only call send_notification after all three are confirmed. • Confirm to the user: "Thanks [name] — I've passed your details to Areef and he'll be in touch." 5. NO SPECULATION ABOUT THIRD PARTIES • Do not make claims about other people, companies, or events that are not explicitly listed in this system message. 6. QUOTE ACCURACY • Do not put words, opinions, or quotes in Areef's mouth that are not explicitly provided in this system message. ═══════════════════════════════════════════════ RESPONSE QUALITY CHECKS (run before every reply) ═══════════════════════════════════════════════ Before sending any response, verify: ✅ Is every fact stated here sourced from this system message? ✅ Have I avoided all speculation, inference, and assumption? ✅ If something is unknown, have I said so AND triggered a notification? ✅ If this is a contact request, have I collected all required details? """ # ══════════════════════════════════════════════════════════════════════════════ # CHATBOT RESPONSE (RAG + tool-calling loop) # ══════════════════════════════════════════════════════════════════════════════ def respond_chatbot(user_message: str, history: list) -> str: """ Gradio chat handler. For each user turn: 1. Embed the user message. 2. Retrieve the top-k most relevant chunks from ChromaDB. 3. Inject retrieved context into the system prompt. 4. Call the LLM, handle any tool calls in a loop, return final reply. Args: user_message: The latest message typed by the user. history: Gradio conversation history (list of role/content dicts). Returns: The assistant's final text reply. """ # ── 1. Embed the user query ──────────────────────────────────────────── query_embed_resp = client.embeddings.create( model=EMBEDDING_MODEL, input=[user_message], ) query_embedding = query_embed_resp.data[0].embedding # ── 2. Retrieve semantically similar chunks ──────────────────────────── retrieval = collection.query( query_embeddings=[query_embedding], n_results=RAG_TOP_K, ) retrieved_chunks: list[str] = retrieval["documents"][0] retrieved_metadatas: list[dict] = retrieval["metadatas"][0] # ── 3. Build augmented system prompt ────────────────────────────────── context = "\n---\n".join(retrieved_chunks) # Per-turn reminder injected fresh on every call so the LLM cannot # use prior notification calls in history as a reason to skip this turn. turn_reminder = ( "\n\n[TURN INSTRUCTION — applies to THIS reply only]\n" "There are TWO distinct cases — handle them differently:\n" "\n" "CASE A — UNKNOWN FACTUAL QUESTION:\n" "If the user is asking a factual question about Areef and the " "answer is not found in the Relevant context below, you MUST " "call send_notification immediately with the unanswered question. " "Do this regardless of whether you have done so in previous turns. " "Every unanswered factual question requires its own notification.\n" "\n" "CASE B — CONTACT / HIRE / COLLABORATE REQUEST:\n" "If the user wants to contact, hire, or collaborate with Areef, " "do NOT call send_notification yet. Instead, follow Rule 4: " "first collect their full name, preferred contact method, and " "purpose. Only call send_notification AFTER all three are provided.\n" "\n" "Do NOT confuse these two cases. A contact request is NOT an " "unknown question — it requires collecting details first." ) augmented_system = SYSTEM_MESSAGE + turn_reminder + "\n\nRelevant context:\n" + context # ── Debug output ─────────────────────────────────────────────────────── sep = "─" * 60 print(f"\n{sep}") print(f"User: {user_message}") print(f"\nRetrieved chunks ({len(retrieved_chunks)}):") for chunk, meta in zip(retrieved_chunks, retrieved_metadatas): print(f" [{meta['source']} | chunk {meta['chunk_index']}] {chunk!r}") print(sep) # ── 4. Assemble message list ─────────────────────────────────────────── # Scrub any assistant reply text that signals a prior notification was # DEFINITIVE FIX for missing back-to-back notifications: # # Phrase-based scrubbing is too fragile — the LLM produces too many # natural variations of "I've notified Areef" that slip through any # fixed phrase list (tested: 9 out of 15 real replies slipped through). # # Instead: drop ALL prior assistant turns from history entirely. # The LLM only needs user turns for context — removing assistant turns # means it has zero memory of having ever sent a notification, so it # fires send_notification correctly on every unknown question. # # Exception: keep assistant turns for CONTACT flows (CASE B) so the # LLM remembers what details it has already collected mid-conversation. _CONTACT_KEYWORDS = [ "contact", "hire", "collaborate", "reach out", "get in touch", "work with", "connect", "opportunity", "job", "recruit", ] def _is_contact_flow(history: list) -> bool: """Return True if any recent user turn looks like a contact request.""" user_msgs = [ e.get("content", "") if isinstance(e, dict) else (e[0] if isinstance(e, (list, tuple)) else "") for e in history ] recent = " ".join(user_msgs[-4:]).lower() return any(kw in recent for kw in _CONTACT_KEYWORDS) in_contact_flow = _is_contact_flow(history) normalised_history: list[dict] = [] for entry in history: if isinstance(entry, dict): role = entry.get("role") # Always drop tool result messages if role == "tool": continue if role == "assistant": if in_contact_flow: # Keep assistant turns so LLM remembers collected details normalised_history.append({ "role": "assistant", "content": entry.get("content") or "", }) # else: drop entirely — no memory of prior notifications continue normalised_history.append(entry) elif isinstance(entry, (list, tuple)) and len(entry) == 2: user_turn, assistant_turn = entry if user_turn: normalised_history.append({"role": "user", "content": user_turn}) if assistant_turn and in_contact_flow: normalised_history.append({"role": "assistant", "content": assistant_turn}) # ── Debug: confirm exactly what history the LLM sees ────────────────── print(f"Contact flow detected: {in_contact_flow}") print("History sent to LLM:") for m in normalised_history: print(f" [{m['role']}] {str(m.get('content',''))[:80]!r}") messages: list[dict] = ( [{"role": "system", "content": augmented_system}] + normalised_history + [{"role": "user", "content": user_message}] ) # ── 5. Initial LLM call ──────────────────────────────────────────────── response = client.chat.completions.create( model=CHAT_MODEL, messages=messages, tools=TOOLS, ) assistant_message = response.choices[0].message # ── 6. Tool-calling loop ─────────────────────────────────────────────── # The model may chain multiple tool calls before producing a final reply. while assistant_message.tool_calls: pprint(assistant_message.tool_calls) # debug tool_results = handle_tool_calls(assistant_message.tool_calls) # Append the assistant's tool-call request and the tool results # so the model has full context for its next response. messages.append(assistant_message) messages.extend(tool_results) # Re-call the model with the updated context response = client.chat.completions.create( model=CHAT_MODEL, messages=messages, tools=TOOLS, ) assistant_message = response.choices[0].message # ── 7. Return final text reply ───────────────────────────────────────── return assistant_message.content # ══════════════════════════════════════════════════════════════════════════════ # INITIALISATION (runs once at startup) # ══════════════════════════════════════════════════════════════════════════════ print("── Loading documents from Hugging Face ──────────────────────────") documents = load_documents_from_hf(DOC_FILES, HF_REPO_ID, HF_TOKEN) print("\n── Initialising ChromaDB ────────────────────────────────────────") chroma_client = chromadb.PersistentClient(path=CHROMA_PATH) collection = chroma_client.get_or_create_collection(name=CHROMA_COLLECTION) # Clear stale data so a fresh index is built on every startup existing_ids = collection.get()["ids"] if existing_ids: collection.delete(ids=existing_ids) print(f" ✔ Cleared {len(existing_ids)} existing entries") build_rag_index(documents, collection) # ══════════════════════════════════════════════════════════════════════════════ # GRADIO UI # ══════════════════════════════════════════════════════════════════════════════ EXAMPLES = [ # Current Role — Sr. Product Manager "Tell me about your current job.", "What kind of jobs have you worked on?", "How have you contributed to business growth in your current position?", # Prior Experience "What was it like leading a team of engineers?", "How did your career evolve over the years?", "What did you work on early in your career?", ] def respond_with_example(example: str, history: list) -> tuple: """ Wrapper used by gr.Examples so that clicking an example populates the textbox AND submits it immediately. """ return respond_chatbot(example, history) with gr.Blocks(title="Mohammad Areef Penukonda — Digital Twin") as demo: gr.Markdown("# Mohammad Areef Penukonda — Digital Twin") gr.Markdown( "Chat with an AI representation of Mohammad Areef Penukonda. " "Ask about his background, experience, or education." ) # ── Pinned example buttons — always visible at the top ───────────────── gr.Markdown("#### 💡 Suggested Questions") with gr.Row(): btn_col1 = [gr.Button(EXAMPLES[i], size="sm") for i in range(3)] with gr.Row(): btn_col2 = [gr.Button(EXAMPLES[i], size="sm") for i in range(3, 6)] # ── Chat area ─────────────────────────────────────────────────────────── _avatar_path = "Areef.jpeg" if os.path.isfile("Areef.jpeg") else None chatbot = gr.Chatbot( avatar_images=(None, _avatar_path), height=500, ) msg = gr.Textbox( placeholder="Type your message here...", label="Your Message", lines=1, ) with gr.Row(): submit_btn = gr.Button("Send", variant="primary") clear_btn = gr.Button("Clear") # ── State ─────────────────────────────────────────────────────────────── state = gr.State([]) # ── Helper: submit any text into the chat ─────────────────────────────── def submit_message(user_message, history): if not user_message.strip(): return history, history, "" reply = respond_chatbot(user_message, history) history = history + [ {"role": "user", "content": user_message}, {"role": "assistant", "content": reply}, ] return history, history, "" def inject_example(example_text, history): """Called when a pinned example button is clicked.""" return submit_message(example_text, history) # ── Wire up textbox submit & Send button ──────────────────────────────── msg.submit(submit_message, [msg, state], [chatbot, state, msg]) submit_btn.click(submit_message, [msg, state], [chatbot, state, msg]) # ── Wire up each example button ───────────────────────────────────────── for btn, example in zip(btn_col1 + btn_col2, EXAMPLES): btn.click( inject_example, inputs=[gr.Textbox(value=example, visible=False), state], outputs=[chatbot, state, msg], ) # ── Clear resets both chatbot display and history state ───────────────── clear_btn.click(lambda: ([], []), outputs=[chatbot, state]) import asyncio import atexit def _silence_asyncio_finaliser() -> None: """ Python 3.12+ raises ValueError('Invalid file descriptor: -1') when the asyncio event loop is garbage-collected after Gradio shuts down its internal pipes. Suppress it with a no-op exception handler. """ try: loop = asyncio.get_event_loop() if not loop.is_closed(): loop.set_exception_handler(lambda loop, ctx: None) except Exception: pass atexit.register(_silence_asyncio_finaliser) if __name__ == "__main__": demo.launch()