Spaces:
Sleeping
Sleeping
| """ | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β 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() |