digitaltwin / app.py
3v324v23's picture
Auto deploy from GitHub
90531ff
Raw
History Blame Contribute Delete
13.1 kB
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.
<output_contract>
- 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.
</output_contract>
<verbosity_controls>
- 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.
</verbosity_controls>
"""
#-------------------------------
#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()