-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
359 lines (292 loc) · 14.8 KB
/
Copy pathapp.py
File metadata and controls
359 lines (292 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
import os
from dotenv import load_dotenv
from openai import OpenAI
import gradio as gr
import uuid
import chromadb
from pprint import pprint
import json
import requests
import random
#---------------------------------------------------
# Setup
#---------------------------------------------------
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if OPENAI_API_KEY is None:
raise Exception("API key is missing")
client = OpenAI()
#---------------------------------------------------
# Documents
#---------------------------------------------------
document_overview = """
Sherlock Holmes is a consulting detective based at 221B Baker Street in London, England.
He is famous for his exceptional logical reasoning, his ability to adopt almost any disguise,
and his use of forensic science to solve difficult cases.
He operates in partnership with his close friend and biographer, Dr. John H. Watson, who
records and publishes many of his adventures.
Other background details:
1881: Holmes and Watson first meet and agree to share rooms at 221B Baker Street.
1891-1894: The Great Hiatus (following the confrontation with Professor Moriarty at Reichenbach Falls).
1903: Holmes retires to the Sussex Downs to take up beekeeping and study philosophy.
What drives him: The love of mental stimulation. He despises the dull routine of existence
and craves mental exaltation. Solving complex, seemingly impossible mysteries is his life's work.
He believes in looking at the details that others overlook—"You see, but you do not observe."
His approach: Scientific and analytical. He relies on observation, deduction, and knowledge of
chemistry, anatomy, and sensational literature. He has written several monographs, including one
on the distinction between 140 forms of tobacco ash, and another on the tracing of footsteps.
Communication style: Extremely sharp, intellectual, and direct. He can be polite yet distant,
and displays an intense focus. He does not suffer fools gladly, but has deep respect for those
with genuine intellect or desperate need.
Interesting facts:
- He is an expert violinist, often playing to think through a difficult case.
- He is a master of disguise, frequently transforming his appearance so completely that even Watson cannot recognize him.
- He has a fondness for strong Turkish coffee and pipes, using smoking as a way to facilitate deep concentration during a "three-pipe problem".
- He holds a dim view of popular romance, preferring cold, clinical facts.
"""
document_education = """Cambridge University / Oxford University
Course of Study: Chemistry, Anatomy, and Natural Sciences
1872 – 1876
Activities and societies: Fencing, Boxing, and Violin performance
A highly independent student who focused exclusively on subjects of utility to his future career as the world's first consulting detective. Avoided the traditional curriculum to pursue deep, specialized research in organic chemistry, poisons, bone structure, and physical anatomy. Developed early methods of forensic investigation and bloodstain analysis.
Bart's Hospital (St. Bartholomew's)
Chemical Laboratory Researcher
1877 – 1881
Spent years conducting chemical experiments in the laboratory of St. Bartholomew's Hospital. Here, he perfected a test for detecting blood stains (the Holmes test), which is sensitive to one part in a million and superior to any existing guaiacum test. This work laid the chemical foundation for his future forensic investigations."""
document_professional_experience = """Consulting Detective Agency
Founder & Principal Detective
221B Baker Street, London
1881 - 1903 · 22 yrs
Global / London
Established the world's only consulting detective practice. When Scotland Yard detectives (such as Inspectors Lestrade and Gregson) or private clients find themselves out of their depth, they consult Holmes.
• For Official Investigators: Provided forensic analysis, logic-based reconstructions, and identification of key suspects to help Scotland Yard solve seemingly unsolvable crimes.
• For Private Clients: Investigated delicate family matters, high-profile thefts, and disappearances, maintaining absolute discretion and securing justice when the law fell short.
Key Solved Cases:
- A Study in Scarlet (1881): Solved the murders of Enoch Drebber and Joseph Stangerson, identifying the cabman Jefferson Hope as the killer using tracking and poison analysis.
- The Sign of Four (1888): Recovered the Agra treasure and solved the murder of Bartholomew Sholto, tracing Jonathan Small and Tonga via the dog Toby.
- The Hound of the Baskervilles (1889): Investigated the mysterious death of Sir Charles Baskerville on Dartmoor, exposing Jack Stapleton's plot involving a chemically-painted hound.
- The Adventure of the Speckled Band (1883): Saved Helen Stoner from her stepfather Dr. Grimesby Roylott, deducing the deadly use of a swamp adder.
The Great Hiatus (Reichenbach Falls aftermath)
Independent Explorer & Investigator
Global Travel
May 1891 - Apr 1894 · 3 yrs
Travelled extensively under the pseudonym Sigerson, a Norwegian explorer.
• Derailed criminal conspiracies in Tibet, visiting Lhasa and meeting the Dalai Lama.
• Conducted research in Persia, visited Mecca, and reported on a chemical research project to the French Academy of Sciences in Montpellier.
• Returned to London in 1894 to dismantle the remaining elements of Moriarty's syndicate under Colonel Sebastian Moran (The Adventure of the Empty House)."""
#---------------------------------------------------
# Chunking Function
#---------------------------------------------------
def split_text_into_chunks(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
BOUNDARIES = ["\n\n", "\n", ". ", "? ", "! ", " "]
def find_natural_boundary(start: int, end: int) -> int:
midpoint = start + (chunk_size // 2)
for boundary in BOUNDARIES:
pos = text.rfind(boundary, midpoint, end)
if pos != -1:
return pos + len(boundary)
return end
chunks = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
if end < len(text):
end = find_natural_boundary(start, end)
chunks.append(text[start:end])
if end >= len(text):
break
start = max(start + 1, end - overlap)
return chunks
#---------------------------------------------------
# RAG: Chunk, Embed & Store in ChromaDB
#---------------------------------------------------
documents = [
{"text": document_overview, "source": "Overview"},
{"text": document_education, "source": "Education"},
{"text": document_professional_experience, "source": "Professional Experience"}
]
chunks = []
ids = []
metadatas = []
for doc in documents:
#Prepare the lists
chunks_ = split_text_into_chunks(doc["text"], chunk_size=300, overlap=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} (ID: {ids[i]}, Source: {metadatas[i]['source']}, Index: {metadatas[i]['chunk_index']}, Length: {len(chunk)}):")
print(chunk)
print()
#Generate embeddings
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")
#intialize ChromaDB client (persistent storage)
chroma_client = chromadb.PersistentClient(path="./chroma_db_twin")
#Alternative: intialize 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="sherlock_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_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 potential missing crednetials
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 notification to the real Sherlock Holmes (via Dr. Watson). Use this when: \
1) Someone wants to hire Sherlock Holmes for a case - ask for their name, location, and case details first, then send the notification. \
2) You don't know the answer to a question about Sherlock - send AUTOMATICALLY without asking, including the question so it can be investigated.",
"parameters": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "The notificaiton 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
def dice_roll():
result = random.randint(1,6)
return result
#Describe function for the LLM
roll_dice_function = {
"name": "dice_roll",
"description": "Simulates rolling a single 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 list of tools of 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()}"
#elif function_name == "insert_function_name_3":
# content = insert_function_name_3(args["message"])
#....
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)
return tool_results
#---------------------------------------------------
# System Message
#---------------------------------------------------
system_message = """You are a digital twin of Sherlock Holmes. When people talk to you,
you respond AS Sherlock Holmes — in first person, using his signature voice, deductive reasoning, and knowledge.
Important: do not make things up. If you don't know an answer, say you don't know.
The only factual information available to you is what's in this system message and the retrieved context.
You cannot get any more facts about Sherlock Holmes from the internet or make them up.
IMPORTANT: Whenever you don't know something about Sherlock Holmes,
ALWAYS use the send_notification tool to alert the real Sherlock Holmes — do this automatically without asking the user.
"""
#---------------------------------------------------
# Main Response Function
#---------------------------------------------------
def respond_ai(message, history):
#RAG: Embed the query using the same model we used for the chunks to ensure compatibility
response = client.embeddings.create(
model = "text-embedding-3-small",
input = [message]
)
query_embedding = response.data[0].embedding
#RAG: Search ChromaDB
results = collection.query(
query_embeddings=[query_embedding],
n_results=3
)
#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("***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
#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="gpt-4.1-mini",
messages=messages,
tools=tools
)
message = response.choices[0].message
#Check if model wants to call a tool
while message.tool_calls:
pprint (message.tool_calls)
tool_result = handle_tool_call(message.tool_calls) #whole list of tool calls on purpose
messages.append(message)
messages.extend(tool_result) #Changed from append() to extend() when we switched to multiple tool call handling
response = client.chat.completions.create(
model="gpt-4.1-mini",
messages=messages,
tools=tools
)
message = response.choices[0].message
#Note: maybe consider adding protection from infinite consecutive tool calling
return(message.content)
#---------------------------------------------------
# Launch Gradio
#---------------------------------------------------
gr.ChatInterface(
fn=respond_ai,
title="Sherlock's Digital Twin",
chatbot=gr.Chatbot(avatar_images=(None, "sherlock.png")),
description="Chat with an AI version of Sherlock Holmes, the world's greatest consulting detective. Ask about his methods, cases, or request his assistance on a mystery.",
examples=["Tell me some interesting facts about you?", "Key Cases solved?", "Which university did you graduate from?"]
).launch()