MyDigitalTwin / app.py
areefmohammad's picture
Upload app.py
1d9478f verified
Raw
History Blame Contribute Delete
38 kB
"""
╔══════════════════════════════════════════════════════════════════╗
β•‘ 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()