kcheng0816 commited on
Commit
efc7ea2
·
1 Parent(s): 9de6e3c

Checkin version 2

Browse files
Files changed (4) hide show
  1. README.md +11 -8
  2. app.py +408 -115
  3. pyproject.toml +1 -0
  4. uv.lock +14 -0
README.md CHANGED
@@ -12,16 +12,19 @@ short_description: Create an intelligent Bible study assistant that utilizes LL
12
 
13
  ## <h1 align="center" id="heading">An Agentic Bible Study Tool Built with LangChain and LangGraph</h1>
14
 
15
- Create an intelligent Bible study assistant that utilizes LLMs to enhance contextual understanding of scripture. This tool will enable users to pose questions, and the AI will provide answers grounded in the Bible, by accurately identifying and synthesizing information from relevant verses, chapters, and cross-references, promoting deeper comprehension and reducing misinterpretations.
 
 
 
 
 
16
 
17
 
18
- ### Phase I
19
- - Book of Genesis
20
- - Examples of questions:
21
- - How did GOD create the whole universe based on Genesis?
22
- - Why LORD God make man leave garden?
23
- - How did the Israelites, led by Jacob, end up in Egypt, and what role did Joseph play in their settlement there?
24
 
25
 
26
  ## Ship 🚢
27
- Check out the prototype at https://huggingface.co/spaces/kcheng0816/BibleStudy
 
 
 
 
 
12
 
13
  ## <h1 align="center" id="heading">An Agentic Bible Study Tool Built with LangChain and LangGraph</h1>
14
 
15
+ Welcome to the Bible Study Tool, an interactive platform designed to deepen your understanding of the Bible, with a special focus on the book of Genesis (Phase I). Powered by advanced AI technology, this tool offers a variety of features to enrich your study experience:
16
+
17
+ Ask Questions: Receive detailed answers about Genesis through an AI-driven retrieval system that pulls from a comprehensive database of Bible verses.
18
+ Internet Search: Broaden your perspective by exploring additional context and related topics from the web.
19
+ Quiz Mode: Challenge yourself with personalized quizzes on specific verse ranges—just type "start quiz on <verse range>" (e.g., "start quiz on Genesis 1:1-5") to get started.
20
+ Built with a user-friendly chat interface, this tool makes Bible study engaging and accessible for everyone, whether you’re a beginner or a seasoned scholar. Dive in and let the Bible Study Tool guide you on your journey!
21
 
22
 
 
 
 
 
 
 
23
 
24
 
25
  ## Ship 🚢
26
+ Check out the prototype at https://huggingface.co/spaces/kcheng0816/BibleStudy
27
+
28
+
29
+
30
+
app.py CHANGED
@@ -1,83 +1,174 @@
1
  import os
 
 
 
2
  from dotenv import load_dotenv
3
  import chainlit as cl
4
-
5
- import pandas as pd
6
- from langchain_community.vectorstores import FAISS
7
- from langchain_openai.embeddings import OpenAIEmbeddings
8
- from langchain_core.documents import Document
9
- from langchain_community.document_loaders import DirectoryLoader
10
- from langchain_community.document_loaders import BSHTMLLoader
11
- from langchain_text_splitters import RecursiveCharacterTextSplitter
12
  from langchain_huggingface import HuggingFaceEmbeddings
13
- from langchain_qdrant import QdrantVectorStore
14
  from qdrant_client import QdrantClient
15
- from qdrant_client.http.models import Distance, VectorParams
16
- from langchain.retrievers.contextual_compression import ContextualCompressionRetriever
17
- from langchain_cohere import CohereRerank
 
 
18
  from langchain.prompts import ChatPromptTemplate
19
- from langchain_openai import ChatOpenAI
20
- from langchain.chat_models import init_chat_model
21
- from langchain_core.rate_limiters import InMemoryRateLimiter
22
- from langgraph.graph import START, StateGraph, END
23
- from typing_extensions import List, TypedDict
24
- from langchain_core.documents import Document
25
- from langchain_core.messages import HumanMessage
26
  from langchain_core.tools import tool
27
- from langgraph.prebuilt import ToolNode
 
 
28
  from langchain_core.messages import AnyMessage
29
  from langgraph.graph.message import add_messages
30
  from typing import TypedDict, Annotated
31
- from langchain_core.documents import Document
 
 
32
 
33
- #Load API Keys
34
  load_dotenv()
 
 
35
 
36
- #Load downloaded html pages of the book Genesis in Bible
37
  path = "data/"
38
- loader = DirectoryLoader(path, glob="*.html")
39
- docs = loader.load()
40
-
41
- #Text Splitter
42
- text_splitter = RecursiveCharacterTextSplitter(
43
- chunk_size = 750,
44
- chunk_overlap = 100
45
- )
46
-
47
- split_documents = text_splitter.split_documents(docs)
48
- len(split_documents)
49
-
50
- #fine tuned embedding model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  huggingface_embeddings = HuggingFaceEmbeddings(model_name="kcheng0816/finetuned_arctic_genesis")
 
52
 
53
- #vector datastore
54
  client = QdrantClient(":memory:")
55
  client.create_collection(
56
- collection_name="genesis_bible",
57
- vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
58
  )
59
 
60
- vector_store = QdrantVectorStore(
61
- client=client,
62
- collection_name="genesis_bible",
63
- embedding=huggingface_embeddings,
64
- )
65
-
66
- _ = vector_store.add_documents(documents=split_documents)
67
-
68
- #Retrieve
69
- retriever = vector_store.as_retriever(search_kwargs={"k": 5})
70
-
 
 
 
 
 
71
 
72
- def retrieve_adjusted(state):
73
- compressor = CohereRerank(model="rerank-v3.5")
74
- compression_retriever = ContextualCompressionRetriever(
75
- base_compressor=compressor, base_retriever=retriever, search_kwargs={"k": 5}
76
- )
77
- retrieved_docs = compression_retriever.invoke(state["question"])
78
- return {"context" : retrieved_docs}
79
 
80
- #RAG prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  RAG_PROMPT = """\
82
  You are a helpful assistant who answers questions based on provided context. You must only use the provided context, and cannot use your own knowledge.
83
 
@@ -89,105 +180,307 @@ You are a helpful assistant who answers questions based on provided context. You
89
  """
90
  rag_prompt = ChatPromptTemplate.from_template(RAG_PROMPT)
91
 
 
 
 
92
 
93
- #llm for RAG
94
  rate_limiter = InMemoryRateLimiter(
95
- requests_per_second=1, # <-- make a request once every 1 seconds!!
96
- check_every_n_seconds=0.1, # Wake up every 100 ms to check whether allowed to make a request,
97
- max_bucket_size=10, # Controls the maximum burst size.
98
  )
99
- llm = init_chat_model("gpt-4o-mini", rate_limiter=rate_limiter)
100
 
 
 
 
 
101
 
102
- def generate(state):
103
- docs_content = "\n\n".join(doc.page_content for doc in state["context"])
104
- messages = rag_prompt.format_messages(question=state["question"], context=docs_content)
105
- response = llm.invoke(messages)
106
- return {"response" : response.content}
107
 
108
- #Build RAG graph
109
- class State(TypedDict):
110
- question: str
111
- context: List[Document]
112
- response: str
113
 
114
- graph_builder = StateGraph(State).add_sequence([retrieve_adjusted, generate])
115
- graph_builder.add_edge(START, "retrieve_adjusted")
116
- graph = graph_builder.compile()
 
117
 
 
 
 
118
 
119
  @tool
120
- def ai_rag_tool(question: str) -> str:
121
- """Useful for when you need to answer questions about Bible """
122
- response = graph.invoke({"question": question})
123
  return {
124
  "message": [HumanMessage(content=response["response"])],
125
- "context": response["context"]
126
  }
127
 
128
- tool_belt = [
129
- ai_rag_tool
130
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- #llm for agent reasoning
133
- llm = init_chat_model("gpt-4o", temperature=0, rate_limiter=rate_limiter)
134
- llm_with_tools = llm.bind_tools(tool_belt)
 
 
 
 
135
 
 
136
 
 
 
 
137
 
138
- #Build an agent graph
139
  class AgentState(TypedDict):
140
  messages: Annotated[list[AnyMessage], add_messages]
141
- context:List[Document]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  def call_mode(state):
145
- messages = state["messages"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  response = llm_with_tools.invoke(messages)
147
- return {
148
- "messages": [response],
149
- "context": state.get("context",[])
150
- }
151
 
152
  tool_node = ToolNode(tool_belt)
153
 
154
  def should_continue(state):
155
  last_message = state["messages"][-1]
156
-
157
  if last_message.tool_calls:
158
  return "action"
159
-
160
  return END
161
 
162
-
163
  uncompiled_graph = StateGraph(AgentState)
164
-
165
  uncompiled_graph.add_node("agent", call_mode)
166
  uncompiled_graph.add_node("action", tool_node)
167
-
168
  uncompiled_graph.set_entry_point("agent")
169
-
170
- uncompiled_graph.add_conditional_edges(
171
- "agent",
172
- should_continue
173
- )
174
-
175
  uncompiled_graph.add_edge("action", "agent")
176
-
177
- # Compile the graph.
178
  compiled_graph = uncompiled_graph.compile()
179
 
 
 
 
180
 
181
- #user interface
182
  @cl.on_chat_start
183
- async def on_chat_start():
184
- cl.user_session.set("graph", compiled_graph)
185
-
 
 
 
 
 
 
 
 
 
 
186
 
187
 
188
  @cl.on_message
189
- async def handle(message: cl.Message):
190
- graph = cl.user_session.get("graph")
191
- state = {"messages": [HumanMessage(content=message.content)]}
192
- response = await graph.ainvoke(state)
193
- await cl.Message(content=response["messages"][-1].content).send()
 
 
 
 
 
 
1
  import os
2
+ import re
3
+ import random
4
+ import uuid
5
  from dotenv import load_dotenv
6
  import chainlit as cl
7
+ from langchain.docstore.document import Document
8
+ from bs4 import BeautifulSoup
 
 
 
 
 
 
9
  from langchain_huggingface import HuggingFaceEmbeddings
 
10
  from qdrant_client import QdrantClient
11
+ from qdrant_client.http.models import VectorParams, Distance
12
+ from qdrant_client.http.models import PointStruct
13
+ from langchain.storage import LocalFileStore
14
+ from langchain.embeddings import CacheBackedEmbeddings
15
+ from qdrant_client.http.models import Filter, FieldCondition, MatchValue, MatchAny
16
  from langchain.prompts import ChatPromptTemplate
17
+ from langchain_core.runnables import RunnablePassthrough
18
+ from langchain_core.output_parsers import StrOutputParser
19
+ from langchain_core.runnables import RunnableLambda
20
+ from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, ToolMessage
 
 
 
21
  from langchain_core.tools import tool
22
+ from langchain_community.tools.tavily_search import TavilySearchResults
23
+ from functools import partial
24
+ from typing import Any, Callable, List, Optional, TypedDict, Union
25
  from langchain_core.messages import AnyMessage
26
  from langgraph.graph.message import add_messages
27
  from typing import TypedDict, Annotated
28
+ from langgraph.prebuilt import ToolNode
29
+ from langgraph.graph import StateGraph, END
30
+ import json
31
 
32
+ # Load API Keys
33
  load_dotenv()
34
+ os.environ["LANGCHAIN_PROJECT"] = f"AIE5- Bible Study Tool - {uuid.uuid4().hex[0:8]}"
35
+ os.environ["LANGCHAIN_TRACING_V2"] = "true"
36
 
 
37
  path = "data/"
38
+ book = "Genesis"
39
+ collection_name = "genesis_study"
40
+
41
+ # Load Genesis documents (unchanged from original)
42
+ def load_genesis_documents(path, book_name):
43
+ documents = []
44
+ for file in os.listdir(path):
45
+ if file.endswith(".html"):
46
+ file_path = os.path.join(path, file)
47
+ with open(file_path, "r", encoding="utf-8") as f:
48
+ soup = BeautifulSoup(f, "html.parser")
49
+ p_tags = soup.find_all("p", align="left")
50
+ for p_tag in p_tags:
51
+ verse_texts = [content.strip() for content in p_tag.contents
52
+ if isinstance(content, str) and content.strip()]
53
+ for verse in verse_texts:
54
+ match = re.match(r"\[(\d+):(\d+)\]\s*(.*)", verse)
55
+ if match:
56
+ chapter = int(match.group(1))
57
+ verse_num = int(match.group(2))
58
+ text = match.group(3)
59
+ doc = Document(
60
+ page_content=text,
61
+ metadata={"book": book_name, "chapter": chapter, "verse": verse_num}
62
+ )
63
+ documents.append(doc)
64
+ return documents
65
+
66
+ documents = load_genesis_documents(path, book)
67
+
68
+ # Initialize embeddings
69
  huggingface_embeddings = HuggingFaceEmbeddings(model_name="kcheng0816/finetuned_arctic_genesis")
70
+ dimension = len(huggingface_embeddings.embed_query("test"))
71
 
72
+ # Set up Qdrant client and collection
73
  client = QdrantClient(":memory:")
74
  client.create_collection(
75
+ collection_name=collection_name,
76
+ vectors_config=VectorParams(size=dimension, distance=Distance.COSINE)
77
  )
78
 
79
+ # Generate and upload embeddings
80
+ embeddings = huggingface_embeddings.embed_documents([doc.page_content for doc in documents])
81
+ points = [
82
+ PointStruct(
83
+ id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{doc.metadata['chapter']}_{doc.metadata['verse']}")),
84
+ vector=embedding,
85
+ payload={
86
+ "text": doc.page_content,
87
+ "book": doc.metadata["book"],
88
+ "chapter": doc.metadata["chapter"],
89
+ "verse": doc.metadata["verse"]
90
+ }
91
+ )
92
+ for embedding, doc in zip(embeddings, documents)
93
+ ]
94
+ client.upsert(collection_name=collection_name, points=points)
95
 
96
+ # Cached embedder
97
+ safe_namespace = "AIE5_BibleStudyTool"
98
+ store = LocalFileStore("./cache/")
99
+ cached_embedder = CacheBackedEmbeddings.from_bytes_store(
100
+ huggingface_embeddings, store, namespace=safe_namespace, batch_size=32
101
+ )
 
102
 
103
+ # Retrieval functions (unchanged from original)
104
+ def parse_verse_reference(ref: str):
105
+ match = re.match(r"(\w+(?:\s\w+)?)\s(\d+):([\d,-]+)", ref)
106
+ if not match:
107
+ return None
108
+ book, chapter, verse_part = match.groups()
109
+ chapter = int(chapter)
110
+ verses = []
111
+ for part in verse_part.split(','):
112
+ if '-' in part:
113
+ start, end = map(int, part.split('-'))
114
+ verses.extend(range(start, end + 1))
115
+ else:
116
+ verses.append(int(part))
117
+ return book, chapter, verses
118
+
119
+ def retrieve_verse_content(verse_range: str, client: QdrantClient):
120
+ parsed = parse_verse_reference(verse_range)
121
+ if not parsed:
122
+ return "Invalid verse range format."
123
+ book, chapter, verses = parsed
124
+ filter = Filter(
125
+ must=[
126
+ FieldCondition(key="book", match=MatchValue(value=book)),
127
+ FieldCondition(key="chapter", match=MatchValue(value=chapter)),
128
+ FieldCondition(key="verse", match=MatchAny(any=verses))
129
+ ]
130
+ )
131
+ search_result = client.scroll(
132
+ collection_name=collection_name,
133
+ scroll_filter=filter,
134
+ limit=len(verses)
135
+ )
136
+ if not search_result[0]:
137
+ return "No verses found for the specified range."
138
+ sorted_points = sorted(search_result[0], key=lambda p: p.payload["verse"])
139
+ docs = [
140
+ Document(
141
+ page_content=p.payload["text"],
142
+ metadata=p.payload
143
+ )
144
+ for p in sorted_points
145
+ ]
146
+ return docs
147
+
148
+ def retrieve_documents(question: str, collection_name: str, client: QdrantClient):
149
+ reference_match = re.search(r"(\w+)\s?(\d+):\s?([\d,-]+)", question)
150
+ if reference_match:
151
+ verse_range = reference_match.group(1) + ' ' + reference_match.group(2) + ':' + reference_match.group(3)
152
+ return retrieve_verse_content(verse_range, client)
153
+ else:
154
+ query_vector = cached_embedder.embed_query(question)
155
+ search_result = client.query_points(
156
+ collection_name=collection_name,
157
+ query=query_vector,
158
+ limit=5,
159
+ with_payload=True
160
+ ).points
161
+ if search_result:
162
+ return [
163
+ Document(
164
+ page_content=point.payload["text"],
165
+ metadata=point.payload
166
+ )
167
+ for point in search_result
168
+ ]
169
+ return "No relevant documents found."
170
+
171
+ # RAG setup (unchanged from original)
172
  RAG_PROMPT = """\
173
  You are a helpful assistant who answers questions based on provided context. You must only use the provided context, and cannot use your own knowledge.
174
 
 
180
  """
181
  rag_prompt = ChatPromptTemplate.from_template(RAG_PROMPT)
182
 
183
+ from langchain_openai import ChatOpenAI
184
+ from langchain.chat_models import init_chat_model
185
+ from langchain_core.rate_limiters import InMemoryRateLimiter
186
 
 
187
  rate_limiter = InMemoryRateLimiter(
188
+ requests_per_second=1,
189
+ check_every_n_seconds=0.1,
190
+ max_bucket_size=10,
191
  )
 
192
 
193
+ chat_model = init_chat_model("gpt-4o-mini", rate_limiter=rate_limiter)
194
+
195
+ def create_retriever_runnable(collection_name: str, client: QdrantClient) -> RunnableLambda:
196
+ return RunnableLambda(lambda question: retrieve_documents(question, collection_name, client))
197
 
198
+ retrieval_runnable = create_retriever_runnable(collection_name, client)
 
 
 
 
199
 
200
+ def format_docs(docs):
201
+ if isinstance(docs, str):
202
+ return docs
203
+ return "\n\n".join(f"Genesis {doc.metadata['chapter']}:{doc.metadata['verse']} - {doc.page_content}" for doc in docs)
 
204
 
205
+ rag_chain = (
206
+ {"context": retrieval_runnable | RunnableLambda(format_docs), "question": RunnablePassthrough()}
207
+ | RunnablePassthrough.assign(response=rag_prompt | chat_model | StrOutputParser())
208
+ )
209
 
210
+ # Tools
211
+ def format_contexts(docs):
212
+ return "\n\n".join(docs) if isinstance(docs, list) else docs
213
 
214
  @tool
215
+ def ai_rag_tool(question: str):
216
+ """Useful for when you need to answer questions about Bible"""
217
+ response = rag_chain.invoke(question)
218
  return {
219
  "message": [HumanMessage(content=response["response"])],
220
+ "context": format_contexts(response["context"])
221
  }
222
 
223
+ tavily_tool = TavilySearchResults(max_results=5)
224
+
225
+ def _generate_quiz_question(verse_range: str, client: QdrantClient):
226
+ docs = retrieve_verse_content(verse_range, client)
227
+ if isinstance(docs, str):
228
+ return {"error": docs}
229
+ verse_content = "\n".join(
230
+ f"{doc.metadata['book']} {doc.metadata['chapter']}:{doc.metadata['verse']} - {doc.page_content}"
231
+ for doc in docs
232
+ )
233
+ quiz_prompt = ChatPromptTemplate.from_template(
234
+ "Based on the following Bible verse(s), generate a multiple-choice quiz question with 4 options (A, B, C, D) "
235
+ "and indicate the correct answer:\n\n"
236
+ "{verse_content}\n\n"
237
+ "Format your response as follows:\n"
238
+ "Question: [Your question here]\n"
239
+ "A: [Option A]\n"
240
+ "B: [Option B]\n"
241
+ "C: [Option C]\n"
242
+ "D: [Option D]\n"
243
+ "Correct Answer: [Letter of correct answer]\n"
244
+ "Explanation: [Brief explanation of why the answer is correct]\n"
245
+ )
246
+ response = (quiz_prompt | chat_model).invoke({"verse_content": verse_content})
247
+ response_text = response.content.strip()
248
+ lines = response_text.split("\n")
249
+ question = ""
250
+ options = {}
251
+ correct_answer = ""
252
+ explanation = ""
253
+ for line in lines:
254
+ line = line.strip()
255
+ if line.startswith("Question:"):
256
+ question = line[len("Question:"):].strip()
257
+ elif line.startswith(("A:", "B:", "C:", "D:")):
258
+ key, value = line.split(":", 1)
259
+ options[key.strip()] = value.strip()
260
+ elif line.startswith("Correct Answer:"):
261
+ correct_answer = line[len("Correct Answer:"):].strip()
262
+ elif line.startswith("Explanation:"):
263
+ explanation = line[len("Explanation:"):].strip()
264
+ return {
265
+ "quiz_question": question,
266
+ "options": options,
267
+ "correct_answer": correct_answer,
268
+ "explanation": explanation,
269
+ "verse_range": verse_range,
270
+ "verse_content": verse_content
271
+ }
272
 
273
+ generate_quiz_question_tool = partial(_generate_quiz_question, client=client)
274
+
275
+ @tool
276
+ def generate_quiz_question(verse_range: str):
277
+ """Generate a quiz question based on the content of the specified verse range."""
278
+ quiz_data = generate_quiz_question_tool(verse_range)
279
+ return json.dumps(quiz_data)
280
 
281
+ tool_belt = [ai_rag_tool, tavily_tool, generate_quiz_question]
282
 
283
+ # LLM for agent reasoning
284
+ llm = init_chat_model("gpt-4o", temperature=0, rate_limiter=rate_limiter)
285
+ llm_with_tools = llm.bind_tools(tool_belt)
286
 
287
+ # Define the state
288
  class AgentState(TypedDict):
289
  messages: Annotated[list[AnyMessage], add_messages]
290
+ in_quiz: bool
291
+ quiz_question: Optional[dict]
292
+ verse_range: Optional[str]
293
+ quiz_score: int
294
+ quiz_total: int
295
+ waiting_for_answer: bool
296
+
297
+ # System message
298
+ system_message = SystemMessage(content="""You are a Bible study assistant. You can answer questions about the Bible, search the internet for related information, or generate quiz questions based on specific verse ranges.
299
+
300
+ - Use the 'ai_rag_tool' to answer questions about the Bible.
301
+ - Use the 'tavily_tool' to search the internet for additional information.
302
+ - Use the 'generate_quiz_question' tool when the user requests to start a quiz on a specific verse range, such as 'start quiz on Genesis 1:1-10'.
303
+
304
+ When the user requests a quiz, extract the verse range from their message and pass it to the 'generate_quiz_question' tool.""")
305
+
306
+
307
+ from typing import Optional
308
+ from typing_extensions import TypedDict
309
+ from langgraph.graph.message import AnyMessage, add_messages
310
+ from typing import Annotated
311
 
312
 
313
  def call_mode(state):
314
+ last_message = state["messages"][-1]
315
+
316
+ if state.get("in_quiz", False):
317
+ if state.get("waiting_for_answer", False):
318
+ # Process the user's answer
319
+ quiz_data = state["quiz_question"]
320
+ user_answer = last_message.content.strip().upper()
321
+ correct_answer = quiz_data["correct_answer"]
322
+ new_quiz_total = state["quiz_total"] + 1
323
+ if user_answer == correct_answer:
324
+ new_quiz_score = state["quiz_score"] + 1
325
+ feedback = f"Correct! {quiz_data['explanation']}"
326
+ else:
327
+ new_quiz_score = state["quiz_score"]
328
+ feedback = f"Incorrect. The correct answer is {correct_answer}. {quiz_data['explanation']}"
329
+ return {
330
+ "messages": [
331
+ AIMessage(content=feedback),
332
+ AIMessage(content="Would you like another question? Type 'Yes' to continue or 'No' to end the quiz.")
333
+ ],
334
+ "quiz_total": new_quiz_total,
335
+ "quiz_score": new_quiz_score,
336
+ "waiting_for_answer": False,
337
+ "quiz_question": state["quiz_question"],
338
+ "in_quiz": True,
339
+ "verse_range": state["verse_range"]
340
+ }
341
+ else:
342
+ # Handle the user's decision to continue or stop the quiz
343
+ user_input = last_message.content.strip().lower()
344
+ if user_input == "yes":
345
+ # Generate a new quiz question
346
+ verse_range = state["verse_range"]
347
+ quiz_data_str = generate_quiz_question(verse_range)
348
+ quiz_data = json.loads(quiz_data_str)
349
+ question = quiz_data["quiz_question"]
350
+ options = "\n".join([f"{k}: {v}" for k, v in quiz_data["options"].items()])
351
+ verse_content = quiz_data["verse_content"]
352
+ message_to_user = (
353
+ f"Based on the following verse(s):\n\n{verse_content}\n\n"
354
+ f"Here's your quiz question:\n\n{question}\n\n{options}\n\n"
355
+ "Please select your answer (A, B, C, or D)."
356
+ )
357
+ return {
358
+ "messages": [AIMessage(content=message_to_user)],
359
+ "quiz_question": quiz_data,
360
+ "waiting_for_answer": True,
361
+ "quiz_total": state["quiz_total"],
362
+ "quiz_score": state["quiz_score"],
363
+ "in_quiz": True,
364
+ "verse_range": state["verse_range"]
365
+ }
366
+ elif user_input == "no":
367
+ # End the quiz and provide a summary
368
+ score = state["quiz_score"]
369
+ total = state["quiz_total"]
370
+ continue_message = "Ask me anything about Genesis or type 'start quiz on <verse range>' (e.g., 'start quiz on Genesis 1:1-5') for a trivia challenge."
371
+ if total > 0:
372
+ percentage = (score / total) * 100
373
+ if percentage == 100:
374
+ feedback = "Excellent! You got all questions correct. Please continue your Bible study!"
375
+ elif percentage >= 80:
376
+ feedback = "Great job! You have a strong understanding. Please continue your Bible study!"
377
+ elif percentage >= 50:
378
+ feedback = "Good effort! Keep practicing to improve. Please continue your Bible study!"
379
+ else:
380
+ feedback = "Don’t worry, keep your Bible studying and you’ll get better!"
381
+ summary = f"You got {score} out of {total} questions correct. {feedback} \n\n {continue_message}"
382
+ else:
383
+ summary = "No questions were attempted."
384
+ return {
385
+ "messages": [AIMessage(content=summary)],
386
+ "in_quiz": False,
387
+ "quiz_question": None,
388
+ "verse_range": None,
389
+ "quiz_score": 0,
390
+ "quiz_total": 0,
391
+ "waiting_for_answer": False
392
+ }
393
+ else:
394
+ # Handle invalid input
395
+ return {
396
+ "messages": [AIMessage(content="Please type 'Yes' to continue or 'No' to end the quiz.")],
397
+ "quiz_total": state["quiz_total"],
398
+ "quiz_score": state["quiz_score"],
399
+ "waiting_for_answer": False,
400
+ "quiz_question": state["quiz_question"],
401
+ "in_quiz": True,
402
+ "verse_range": state["verse_range"]
403
+ }
404
+
405
+ # Handle starting the quiz or other tool calls
406
+ if len(state["messages"]) >= 2 and isinstance(last_message, ToolMessage):
407
+ prev_message = state["messages"][-2]
408
+ if isinstance(prev_message, AIMessage) and prev_message.tool_calls:
409
+ tool_call = prev_message.tool_calls[0]
410
+ if tool_call["name"] == "generate_quiz_question":
411
+ # Start the quiz
412
+ quiz_data_str = last_message.content
413
+ quiz_data = json.loads(quiz_data_str)
414
+ verse_range = quiz_data["verse_range"]
415
+ question = quiz_data["quiz_question"]
416
+ options = "\n".join([f"{k}: {v}" for k, v in quiz_data["options"].items()])
417
+ verse_content = quiz_data["verse_content"]
418
+ message_to_user = (
419
+ f"Based on the following verse(s):\n\n{verse_content}\n\n"
420
+ f"Here's your quiz question:\n\n{question}\n\n{options}\n\n"
421
+ "Please select your answer (A, B, C, or D)."
422
+ )
423
+ return {
424
+ "messages": [AIMessage(content=message_to_user)],
425
+ "in_quiz": True,
426
+ "verse_range": verse_range,
427
+ "quiz_score": 0,
428
+ "quiz_total": 0,
429
+ "quiz_question": quiz_data,
430
+ "waiting_for_answer": True
431
+ }
432
+
433
+ # Process regular questions or commands
434
+ messages = [system_message] + state["messages"]
435
  response = llm_with_tools.invoke(messages)
436
+ return {"messages": [response]}
437
+
 
 
438
 
439
  tool_node = ToolNode(tool_belt)
440
 
441
  def should_continue(state):
442
  last_message = state["messages"][-1]
 
443
  if last_message.tool_calls:
444
  return "action"
 
445
  return END
446
 
447
+ # Build the graph
448
  uncompiled_graph = StateGraph(AgentState)
 
449
  uncompiled_graph.add_node("agent", call_mode)
450
  uncompiled_graph.add_node("action", tool_node)
 
451
  uncompiled_graph.set_entry_point("agent")
452
+ uncompiled_graph.add_conditional_edges("agent", should_continue)
 
 
 
 
 
453
  uncompiled_graph.add_edge("action", "agent")
 
 
454
  compiled_graph = uncompiled_graph.compile()
455
 
456
+ # Chainlit integration
457
+ import chainlit as cl
458
+ from langchain_core.messages import SystemMessage
459
 
 
460
  @cl.on_chat_start
461
+ async def start():
462
+ system_message = SystemMessage(content="Welcome to the Bible Study Tool!")
463
+ initial_state = {
464
+ "messages": [system_message],
465
+ "in_quiz": False,
466
+ "quiz_question": None,
467
+ "verse_range": None,
468
+ "quiz_score": 0,
469
+ "quiz_total": 0,
470
+ "waiting_for_answer": False
471
+ }
472
+ cl.user_session.set("state", initial_state)
473
+ await cl.Message(content="Welcome to the Bible Study Tool! Ask me anything about Genesis or type 'start quiz on <verse range>' (e.g., 'start quiz on Genesis 1:1-5') for a trivia challenge.").send()
474
 
475
 
476
  @cl.on_message
477
+ async def main(message: cl.Message):
478
+ state = cl.user_session.get("state")
479
+ current_messages = len(state["messages"])
480
+ state["messages"].append(HumanMessage(content=message.content))
481
+ result = compiled_graph.invoke(state)
482
+ cl.user_session.set("state", result)
483
+ new_messages = result["messages"][current_messages + 1:]
484
+ for msg in new_messages:
485
+ if isinstance(msg, AIMessage):
486
+ await cl.Message(content=msg.content).send()
pyproject.toml CHANGED
@@ -17,4 +17,5 @@ dependencies = [
17
  "unstructured>=0.14.8",
18
  "langchain-huggingface>=0.1.2",
19
  "websockets>=15.0",
 
20
  ]
 
17
  "unstructured>=0.14.8",
18
  "langchain-huggingface>=0.1.2",
19
  "websockets>=15.0",
20
+ "rank-bm25>=0.2.2",
21
  ]
uv.lock CHANGED
@@ -224,6 +224,7 @@ dependencies = [
224
  { name = "langchain-qdrant" },
225
  { name = "langgraph" },
226
  { name = "pandas" },
 
227
  { name = "unstructured" },
228
  { name = "websockets" },
229
  ]
@@ -240,6 +241,7 @@ requires-dist = [
240
  { name = "langchain-qdrant", specifier = ">=0.2.0" },
241
  { name = "langgraph", specifier = ">=0.2.67" },
242
  { name = "pandas", specifier = ">=2.2.3" },
 
243
  { name = "unstructured", specifier = ">=0.14.8" },
244
  { name = "websockets", specifier = ">=15.0" },
245
  ]
@@ -2529,6 +2531,18 @@ wheels = [
2529
  { url = "https://files.pythonhosted.org/packages/5f/26/89ebaee5fcbd99bf1c0a627a9447b440118b2d31dea423d074cb0481be5c/qdrant_client-1.13.2-py3-none-any.whl", hash = "sha256:db97e759bd3f8d483a383984ba4c2a158eef56f2188d83df7771591d43de2201", size = 306637 },
2530
  ]
2531
 
 
 
 
 
 
 
 
 
 
 
 
 
2532
  [[package]]
2533
  name = "rapidfuzz"
2534
  version = "3.12.1"
 
224
  { name = "langchain-qdrant" },
225
  { name = "langgraph" },
226
  { name = "pandas" },
227
+ { name = "rank-bm25" },
228
  { name = "unstructured" },
229
  { name = "websockets" },
230
  ]
 
241
  { name = "langchain-qdrant", specifier = ">=0.2.0" },
242
  { name = "langgraph", specifier = ">=0.2.67" },
243
  { name = "pandas", specifier = ">=2.2.3" },
244
+ { name = "rank-bm25", specifier = ">=0.2.2" },
245
  { name = "unstructured", specifier = ">=0.14.8" },
246
  { name = "websockets", specifier = ">=15.0" },
247
  ]
 
2531
  { url = "https://files.pythonhosted.org/packages/5f/26/89ebaee5fcbd99bf1c0a627a9447b440118b2d31dea423d074cb0481be5c/qdrant_client-1.13.2-py3-none-any.whl", hash = "sha256:db97e759bd3f8d483a383984ba4c2a158eef56f2188d83df7771591d43de2201", size = 306637 },
2532
  ]
2533
 
2534
+ [[package]]
2535
+ name = "rank-bm25"
2536
+ version = "0.2.2"
2537
+ source = { registry = "https://pypi.org/simple" }
2538
+ dependencies = [
2539
+ { name = "numpy" },
2540
+ ]
2541
+ sdist = { url = "https://files.pythonhosted.org/packages/fc/0a/f9579384aa017d8b4c15613f86954b92a95a93d641cc849182467cf0bb3b/rank_bm25-0.2.2.tar.gz", hash = "sha256:096ccef76f8188563419aaf384a02f0ea459503fdf77901378d4fd9d87e5e51d", size = 8347 }
2542
+ wheels = [
2543
+ { url = "https://files.pythonhosted.org/packages/2a/21/f691fb2613100a62b3fa91e9988c991e9ca5b89ea31c0d3152a3210344f9/rank_bm25-0.2.2-py3-none-any.whl", hash = "sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae", size = 8584 },
2544
+ ]
2545
+
2546
  [[package]]
2547
  name = "rapidfuzz"
2548
  version = "3.12.1"