import os
from openai import OpenAI
import gradio as gr
import uuid
import chromadb
from pprint import pprint
import json
import requests
import random
from huggingface_hub import hf_hub_download
#-------------------------------
#Setup
#-------------------------------
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if OPENAI_API_KEY is None:
raise Exception("API key is missing")
client = OpenAI()
MODEL = "gpt-4.1-mini"
HF_TOKEN=os.getenv("HF_TOKEN")
#-------------------------------
#Documents
#-------------------------------
doc_files={
"Professional" : "context_professional.txt",
"Personal": "context_personal.txt",
"Languages": "context_lang.txt",
"Food": "context_food.txt",
"Education": "context_educ.txt",
"Certifications and Skills": "context_certs_and_skills.txt",
"About the digital twin": "context_dt.txt"
}
CACHE_DIR = "cache_docs"
os.makedirs(CACHE_DIR, exist_ok=True)
documents=[]
for source_name, filename in doc_files.items():
local_path = os.path.join(CACHE_DIR, filename)
# 1. Check cache first
if os.path.exists(local_path):
print(f"Using cached file: {filename}")
path = local_path
path=hf_hub_download(
repo_id="datarachel/profile",
filename=filename,
repo_type="dataset",
token=HF_TOKEN
)
# 2. Save into cache folder for future runs
with open(path, "r") as src, open(local_path, "w") as dst:
dst.write(src.read())
# 3. Load into memory
with open(local_path, "r") as f:
text = f.read()
documents.append({
"text": text,
"source": source_name
})
print(f"Loaded:{source_name}")
#-------------------------------
#Chunking Function
#-------------------------------
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
# If the remaining text fits in one chunk, we're done.
if end >= len(text):
chunks.append(text[start:])
break
window = text[start:end]
midpoint = chunk_size // 2
# Try each boundary type from most to least preferred.
cut = None
for boundary in ["\n\n", "\n", ". ", "! ", "? ", " "]:
pos = window.rfind(boundary, midpoint)
if pos != -1:
cut = pos + len(boundary) # cut after the boundary characters
break
if cut is None:
cut = chunk_size # no boundary found; hard cut at the limit
chunks.append(text[start : start + cut])
# Next chunk starts `overlap` characters before the cut point.
next_start = start + cut - overlap
# Ensure we always move forward to avoid infinite loops.
start = max(next_start, start + 1)
return chunks
#-------------------------------
#RAG: Chunk, Embed & Store in ChromaDB
#-------------------------------
chunks = []
ids = []
metadatas = []
for doc in documents:
#Prepare the lists
chunks_ = chunk_text(doc["text"], 300, 30)
ids_ = [str(uuid.uuid4()) for _ in range(len(chunks_))]
metadatas_ = [{"source": doc["source"], "chunk_index": i} for i in range(len(chunks_))]
#Add to main lists
chunks.extend(chunks_)
ids.extend(ids_)
metadatas.extend(metadatas_)
#print for logs
print(f"Created {len(chunks)} chunks:\n")
for i, chunk in enumerate(chunks):
print(f"--- chunk {i+1} | {len(chunk)} chars --- (ID: {ids[i]}, Source: {metadatas[i]['source']}, Index: {metadatas[i]['chunk_index']}):")
print(chunk)
print()
#Generate embeddings for all chunks
response = client.embeddings.create(
model = "text-embedding-3-small",
input = chunks
)
embeddings = [item.embedding for item in response.data]
#Verify embeddings for logs
print(f"Generated {len(embeddings)} embeddings")
print(f"Each embedding has {len(embeddings[0])} dimensions")
#initialise ChromaDB client (persistent storage)
chroma_client = chromadb.PersistentClient(path="./chroma_db_twin")
#Alternative: initalise ChromaDB client (in-memory storage)
#chroma_client = chromadb.Client()
#Get or Create + Empty the collection before adding new data (for testing purposes)
collection = chroma_client.get_or_create_collection(name="RP_digital_twin")
if collection.get()["ids"]:
collection.delete(collection.get()["ids"])
#Adding data to ChromaDB
collection.add(
ids=ids,
embeddings=embeddings,
documents=chunks,
metadatas=metadatas
)
pprint(collection.get())
#-------------------------------
#Tools
#-------------------------------
tools = []
#Pushover
pushover_user = os.getenv("PUSHOVER_USER")
pushover_token = os.getenv("PUSHOVER_TOKEN")
pushover_url = "https://api.pushover.net/1/messages.json"
#Create send notification function
def send_notification(message: str):
if pushover_user is None or pushover_token is None: #Handling of missing credentials
return "Notification failed: Pushover not configured."
payload = {"user": pushover_user, "token": pushover_token, "message": message}
requests.post(pushover_url, data=payload)
return f"Notification sent: {message}"
#Describe Pushover as an LLM Tool
send_notification_function = {
"name": "send_notification",
"description": "Sends a push notification to real-world version of you via Pushover on mobile. Use this when:\
1) the user needs to alert the real-world version version of you, \
2) the user wants to get in touch, hire, or collaborate. Ask them for their name and contact details first, then send this information over to\
real-world Rachel in a notification. Follow this up requesting how real world Rachel might be able to help them.\
3) you don't know the answer to a question that the user has asked about Rachel - send automatically without asking. Include the question so that she\
can add this information later.",
"parameters": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "The notification message to send to the user's device"}
},
"required": ["message"]
}
}
# Add Pushover to the list of tools for the LLM
tools.append({"type": "function", "function":send_notification_function})
#Simulates rolling a single six-sided die and returns the result
def dice_roll():
result = random.randint(1,6)
return result
#Describe dice roll function for the LLM
roll_dice_function = {
"name": "dice_roll",
"description": "Simulates rolling a six-sided die and returns the result. Use this when the user wants to roll a die for games, decisions, or random number generation.",
"parameters": {
"type": "object",
"properties": {},
"required": []
},
}
#add function to the list tools available to LLM
tools.append({"type": "function", "function":roll_dice_function})
#-------------------------------
#Tool Handler
#-------------------------------
def handle_tool_call(tool_calls):
tool_results = []
for tool_call in tool_calls:
function_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
#print(f"Calling function {function_name}") #for future debugging
#Route to the appropriate function based on function_name
if function_name == "send_notification":
content = send_notification(args["message"])
elif function_name == "dice_roll":
content = f"Rolled: {dice_roll()}"
else:
content = f"Unknown function: {function_name}"
tool_call_result = {
"role": "tool",
"content": content,
"tool_call_id": tool_call.id
}
tool_results.append(tool_call_result)
#print("Final message:", message)
return tool_results
#-------------------------------
#System Message
#-------------------------------
system_message = """ You are a digital twin of Rachel Phang that\
answers questions based on the provided context. When people talk to you, \
you respond as Rachel - in first person, using her voice and knowledge. \
Speak with a dynamic, helpful, confident and competent, yet slightly sassy, tone.\
Please answer as completely and as exhaustively, yet conscisely and elegantly, as possible.\
Use a British or UK-friendly tone and spellings.\
If someone wishes to converse with you in a different language, feel free to continue the conversation in that language.\
IMPORTANT: If you don't know the answer based on the context, say you don't know. Always use all available information to provide \
the best answer possible, but please absolutely do not make anything up. \
The only factual information available to you is what is in this system message.\
You cannot get any more facts about Rachel from the internet or make them up.\
IMPORTANT: Whenever you don't know something about Rachel, ALWAYS use the send_notification tool to alert the real Rachel.\
Do this automatically without asking or alerting the user.
- Return exactly the sections requested, in the requested order.
- If the prompt defines a preamble, analysis block, or working section, do not treat it as extra output.
- Apply length limits only to the section they are intended for.
- If a format is required (JSON, Markdown, SQL, XML), output only that format.
- Prefer concise, information-dense writing.
- Avoid repeating the user's request.
- Keep progress updates brief.
- Do not shorten the answer so aggressively that required evidence, reasoning, or completion checks are omitted.
"""
#-------------------------------
#Main Response Function
#-------------------------------
def respond_ai(message,history):
#RAG: Embed the query using the same model we used for the chunks to ensure compatability
response = client.embeddings.create(
model = "text-embedding-3-small",
input = [message]
#input = [test_query, test_query2]
)
query_embedding=response.data[0].embedding
#RAG: Search ChromaDB
results = collection.query(
query_embeddings=query_embedding,
n_results=10
)
#RAG: Stitch retrieved chunks together to create the context for the response
context = "\n---\n".join(results["documents"][0])
#Print logs for debugging
print("\n==============================================\n")
print(f"User message:\n{message}\n")
print(f"***Retrieved Chunks:")
for a, b in zip(results["documents"][0], results["metadatas"][0]):
print("--------------------------------------")
print(f"Document: {b['source']} --Chunk {b['chunk_index']}>>\n{a}\n")
#Update system message with context (for this conversation turn)
system_message_enhanced = system_message + "\n\nContext:\n" + context
#Logs for debugging
#print("\n==============================================\n")
#print("***User message:\n", message)
#print("\n***Context this turn:\n", system_message_enhanced)
#Verify retrieval works - print which chunks were retrieved and their content
#pprint(results)
#print(f"Query: {message}\n")
#print("Retrieved Chunks:")
#for a, b, c in zip(results["documents"][0], results["metadatas"][0], results["distances"][0]):
# print(f"Chunk distance {c} \n Chunk {b['chunk_index']}:\n{a}\n")
#Build messages for this turn
messages = [{"role": "system", "content": system_message_enhanced}] + history + [{"role": "user", "content": message}]
#Call LLM
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools
)
message = response.choices[0].message
while message.tool_calls:
pprint(message.tool_calls)
tool_result = handle_tool_call(message.tool_calls)
messages.append(message)
messages.extend(tool_result)
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools
)
message = response.choices[0].message
#Note: Maybe consider adding protection from infinite consecutive tool calling
if message.content:
return(message.content)
else:
return ""
#-------------------------------
#Launch Gradio
#-------------------------------
gr.ChatInterface(
fn=respond_ai,
title="Digital Rachel Phang",
chatbot=gr.Chatbot(avatar_images=(None, "rachel.jpg"), height=600),
description="Chat with an AI version of Rachel Phang. Ask about her experience, projects, or just say hi. You can also ask her to roll one or more dice!",
examples=["Tell me about yourself", "Help me make a decision (roll dice if close)", "Collaborate/reach out to real Rachel", "...or just start chatting below!"]
).launch()