kamkol commited on
Commit
9fa16f1
ยท
1 Parent(s): 2eb4533

Develop the streamlit app

Browse files
.dockerignore CHANGED
@@ -43,4 +43,7 @@ ENV/
43
  notebook_version/
44
 
45
  # Ignore PDF data files (will be mounted at runtime)
46
- data/*.pdf
 
 
 
 
43
  notebook_version/
44
 
45
  # Ignore PDF data files (will be mounted at runtime)
46
+ data/*.pdf
47
+
48
+ # Pre-processed data (will be generated inside the container)
49
+ processed_data/
.gitignore CHANGED
@@ -44,5 +44,9 @@ env/
44
  # Mac OS
45
  .DS_Store
46
 
47
- # Local data
48
- /data/*.pdf
 
 
 
 
 
44
  # Mac OS
45
  .DS_Store
46
 
47
+ # Local data - keep PDFs private and never commit them
48
+ /data/*.pdf
49
+ /data/**/*.pdf
50
+
51
+ # Do NOT ignore processed_data anymore - we want to commit this
52
+ # /processed_data/
Dockerfile CHANGED
@@ -14,18 +14,42 @@ COPY requirements.txt .
14
  # Install Python dependencies
15
  RUN pip install --no-cache-dir -r requirements.txt
16
 
17
- # Copy application code
18
  COPY . .
19
 
20
- # Create data directory for PDFs
21
  RUN mkdir -p data
22
 
23
- # Expose port for Chainlit
24
- EXPOSE 8000
25
 
26
- # Set environment variables
27
- ENV PYTHONPATH=/app
28
- ENV OPENAI_API_KEY=${OPENAI_API_KEY}
 
 
 
 
 
29
 
30
- # Command to run the application
31
- CMD ["chainlit", "run", "app.py", "--port", "8000", "--host", "0.0.0.0"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  # Install Python dependencies
15
  RUN pip install --no-cache-dir -r requirements.txt
16
 
17
+ # Copy the rest of the application code
18
  COPY . .
19
 
20
+ # Create data directory for PDFs (if not already created)
21
  RUN mkdir -p data
22
 
23
+ # Create directory for pre-processed data
24
+ RUN mkdir -p processed_data
25
 
26
+ # Configure Streamlit to run in headless mode (no welcome screen)
27
+ RUN mkdir -p /root/.streamlit && \
28
+ echo '[general]' > /root/.streamlit/config.toml && \
29
+ echo 'email = ""' >> /root/.streamlit/config.toml && \
30
+ echo 'showWarningOnDirectExecution = false' >> /root/.streamlit/config.toml && \
31
+ echo '' >> /root/.streamlit/config.toml && \
32
+ echo '[server]' >> /root/.streamlit/config.toml && \
33
+ echo 'headless = true' >> /root/.streamlit/config.toml
34
 
35
+ # Set environment variables
36
+ ENV HOST=0.0.0.0
37
+ ENV PORT=8000
38
+
39
+ # Allow both Chainlit and Streamlit apps to run (use environment variable to choose)
40
+ ENV APP_MODE=streamlit
41
+
42
+ # Create the entrypoint script
43
+ RUN echo '#!/bin/bash' > /app/entrypoint.sh && \
44
+ echo 'if [ "$APP_MODE" = "chainlit" ]; then' >> /app/entrypoint.sh && \
45
+ echo ' chainlit run app.py --host $HOST --port $PORT' >> /app/entrypoint.sh && \
46
+ echo 'else' >> /app/entrypoint.sh && \
47
+ echo ' streamlit run streamlit_app.py --server.address $HOST --server.port $PORT' >> /app/entrypoint.sh && \
48
+ echo 'fi' >> /app/entrypoint.sh && \
49
+ chmod +x /app/entrypoint.sh
50
+
51
+ # Expose the port
52
+ EXPOSE $PORT
53
+
54
+ # Run the application
55
+ ENTRYPOINT ["/app/entrypoint.sh"]
README.md CHANGED
@@ -8,6 +8,8 @@ An application that helps answer questions about AB Testing using a collection o
8
  - **Query Rephrasing**: Improves retrieval by rephrasing your query for better results
9
  - **Source References**: Shows the exact document sources used to answer your question
10
  - **Streaming Interface**: See the response as it's being generated
 
 
11
 
12
  ## Prerequisites
13
 
@@ -52,12 +54,17 @@ An application that helps answer questions about AB Testing using a collection o
52
  # Copy your AB Testing PDFs to the data directory
53
  ```
54
 
55
- 7. Run the application
 
 
 
 
 
56
  ```bash
57
  chainlit run app.py
58
  ```
59
 
60
- 8. Open your browser to `http://localhost:8000`
61
 
62
  ### Using Docker
63
 
@@ -83,28 +90,65 @@ An application that helps answer questions about AB Testing using a collection o
83
  # Copy your AB Testing PDFs to the data directory
84
  ```
85
 
86
- 5. Build and run with Docker Compose
87
  ```bash
88
  docker-compose up -d
89
  ```
90
 
91
  6. Open your browser to `http://localhost:8000`
92
 
93
- ## Deploying to Hugging Face Spaces
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- 1. Create a new Hugging Face Space with Docker deployment
96
- 2. Set the following environment variables in your Space settings:
97
  - `OPENAI_API_KEY`: Your OpenAI API key
98
- 3. Push your code to the Space's repository
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  ## How It Works
101
 
102
- 1. The application loads and processes PDF documents using LangChain's document loaders
103
- 2. Documents are chunked and embedded using OpenAI's embedding model
104
- 3. Qdrant vector store is used for semantic search
105
- 4. When a user asks a question:
106
  - The query is rephrased to be more specific for better retrieval
107
- - Relevant document chunks are retrieved
108
  - OpenAI's GPT model generates an answer based on the retrieved context
109
  - Source references are tracked and displayed alongside the answer
110
 
 
8
  - **Query Rephrasing**: Improves retrieval by rephrasing your query for better results
9
  - **Source References**: Shows the exact document sources used to answer your question
10
  - **Streaming Interface**: See the response as it's being generated
11
+ - **Pre-processed Data**: Faster startup with pre-processed document embeddings
12
+ - **Privacy Preserving**: Deploy with pre-processed data only, keeping source PDFs private
13
 
14
  ## Prerequisites
15
 
 
54
  # Copy your AB Testing PDFs to the data directory
55
  ```
56
 
57
+ 7. Pre-process the PDF files (this step significantly speeds up application startup)
58
+ ```bash
59
+ python scripts/preprocess_data.py
60
+ ```
61
+
62
+ 8. Run the application
63
  ```bash
64
  chainlit run app.py
65
  ```
66
 
67
+ 9. Open your browser to `http://localhost:8000`
68
 
69
  ### Using Docker
70
 
 
90
  # Copy your AB Testing PDFs to the data directory
91
  ```
92
 
93
+ 5. Build and run with Docker Compose (the container will automatically pre-process PDFs at startup)
94
  ```bash
95
  docker-compose up -d
96
  ```
97
 
98
  6. Open your browser to `http://localhost:8000`
99
 
100
+ ## Deploying to Hugging Face Spaces (Privacy-Preserving Method)
101
+
102
+ This method allows you to deploy your app without exposing your source PDF files:
103
+
104
+ 1. Pre-process your PDFs locally:
105
+ ```bash
106
+ # Ensure PDFs are in the data directory
107
+ python scripts/preprocess_data.py
108
+ ```
109
+
110
+ 2. Commit only the pre-processed data (not the PDFs) to your repository:
111
+ ```bash
112
+ # PDFs are excluded in .gitignore already
113
+ git add processed_data app.py requirements.txt Dockerfile scripts/docker-entrypoint.sh
114
+ git commit -m "Add pre-processed data for deployment"
115
+ ```
116
+
117
+ 3. Create a new Hugging Face Space with Docker deployment
118
 
119
+ 4. Set the following environment variables in your Space settings:
 
120
  - `OPENAI_API_KEY`: Your OpenAI API key
121
+
122
+ 5. Push your code to the Space's repository:
123
+ ```bash
124
+ git push huggingface main
125
+ ```
126
+
127
+ The advantage of this approach is that:
128
+ - Your original PDF files remain private and are never uploaded
129
+ - Only the pre-processed embeddings and chunks are deployed
130
+ - The app will work immediately without needing to process PDFs at runtime
131
+
132
+ ## Pre-processing Explained
133
+
134
+ The pre-processing step:
135
+ 1. Loads PDF files from the `data` directory
136
+ 2. Splits documents into chunks with correct page references
137
+ 3. Generates embeddings for each chunk using OpenAI's embedding model
138
+ 4. Saves both document chunks and the vector database to disk
139
+
140
+ This approach has several advantages:
141
+ - The app starts up much faster since it doesn't need to process PDFs at runtime
142
+ - You don't need to upload large PDF files to Hugging Face
143
+ - The vector database is persisted, providing consistent performance
144
+ - You can deploy without exposing your source materials
145
 
146
  ## How It Works
147
 
148
+ 1. The application loads pre-processed document chunks and vector store from disk
149
+ 2. When a user asks a question:
 
 
150
  - The query is rephrased to be more specific for better retrieval
151
+ - Relevant document chunks are retrieved from the vector store
152
  - OpenAI's GPT model generates an answer based on the retrieved context
153
  - Source references are tracked and displayed alongside the answer
154
 
app.log ADDED
@@ -0,0 +1 @@
 
 
1
+ 2025-04-29 21:20:25 - Loaded .env file
app.py CHANGED
@@ -1,149 +1,106 @@
1
  import os
 
2
  import chainlit as cl
3
- from chainlit.playground.providers.openai import ChatOpenAI
4
  from chainlit.element import Text
5
  from langchain_core.prompts import ChatPromptTemplate
6
  from langchain.schema.output_parser import StrOutputParser
7
- from langchain_community.document_loaders import DirectoryLoader
8
- from langchain_community.document_loaders import PyPDFLoader
9
  from langchain_openai.embeddings import OpenAIEmbeddings
10
- from langchain_community.vectorstores import Qdrant
11
- from langchain.text_splitter import RecursiveCharacterTextSplitter
12
- from langchain_core.runnables import RunnablePassthrough
13
- from langchain_core.messages import AIMessage, HumanMessage
14
- from operator import itemgetter
15
- from collections import defaultdict
16
- from langchain_core.documents import Document
17
- import tiktoken
18
- import re
19
  import functools
 
 
 
20
 
21
  # Configure OpenAI API key from environment variable
22
  if not os.environ.get("OPENAI_API_KEY"):
23
  os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY_BACKUP", "")
24
 
25
- # Document loading and processing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  @cl.cache
27
- def load_and_process_documents():
28
- # Load all PDF documents (each page as a separate document)
29
- path = "data/"
30
- loader = DirectoryLoader(path, glob="*.pdf", loader_cls=PyPDFLoader)
31
- all_docs = loader.load()
 
 
 
 
 
32
 
33
- # Create a mapping of merged document chunks back to original pages
34
- page_map = {}
35
- current_index = 0
36
 
37
- # For source tracking, we'll store page information before merging
38
- docs_by_source = defaultdict(list)
39
 
40
- # Group documents by their source file
41
- for doc in all_docs:
42
- source = doc.metadata.get("source", "")
43
- docs_by_source[source].append(doc)
44
 
45
- # Merge pages from the same PDF but track page ranges
46
- merged_docs = []
47
- for source, source_docs in docs_by_source.items():
48
- # Sort by page number if available
49
- source_docs.sort(key=lambda x: x.metadata.get("page", 0))
50
-
51
- # Merge the content
52
- merged_content = ""
53
- page_ranges = []
54
- current_pos = 0
55
 
56
- for doc in source_docs:
57
- # Get the page number (1-indexed for human readability)
58
- page_num = doc.metadata.get("page", 0) + 1
59
-
60
- # Add a separator between pages for clarity
61
- if merged_content:
62
- merged_content += "\n\n"
63
-
64
- # Record where this page's content starts in the merged document
65
- start_pos = len(merged_content)
66
- merged_content += doc.page_content
67
- end_pos = len(merged_content)
68
-
69
- # Store the mapping of character ranges to original page numbers
70
- page_ranges.append({
71
- "start": start_pos,
72
- "end": end_pos,
73
- "page": page_num,
74
- "source": source
75
- })
76
 
77
- # Create merged metadata that includes page mapping information
78
- merged_metadata = {
79
- "source": source,
80
- "title": source.split("/")[-1],
81
- "page_count": len(source_docs),
82
- "merged": True,
83
- "page_ranges": page_ranges # Store the page ranges for later reference
84
- }
85
 
86
- # Create a new document with the merged content
87
- merged_doc = Document(page_content=merged_content, metadata=merged_metadata)
88
- merged_docs.append(merged_doc)
89
-
90
- # tiktoken_len counts tokens (not characters) using the gpt-4o-mini tokenizer
91
- def tiktoken_len(text):
92
- tokens = tiktoken.encoding_for_model("gpt-4o-mini").encode(
93
- text,
94
- )
95
- return len(tokens)
96
 
97
- # Process splits to add page info based on character position
98
- def add_page_info_to_splits(splits):
99
- for split in splits:
100
- # Get the start position of this chunk
101
- start_pos = split.metadata.get("start_index", 0)
102
- end_pos = start_pos + len(split.page_content)
103
-
104
- # Find which page this chunk belongs to
105
- if "page_ranges" in split.metadata:
106
- for page_range in split.metadata["page_ranges"]:
107
- # If chunk significantly overlaps with this page range
108
- if (start_pos <= page_range["end"] and
109
- end_pos >= page_range["start"]):
110
- # Use this page number
111
- split.metadata["page"] = page_range["page"]
112
- break
113
- return splits
114
-
115
- # Split the text with start index tracking
116
- text_splitter = RecursiveCharacterTextSplitter(
117
- chunk_size=300,
118
- chunk_overlap=50,
119
- length_function=tiktoken_len,
120
- add_start_index=True
121
  )
122
 
123
- # Split and then process to add page information
124
- raw_splits = text_splitter.split_documents(merged_docs)
125
- split_chunks = add_page_info_to_splits(raw_splits)
126
-
127
- return split_chunks
128
 
129
- # Load and process documents at startup
130
- docs = load_and_process_documents()
131
-
132
- # Set up embeddings and vector store
133
- @cl.cache
134
- def setup_vector_store():
135
- embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
136
- vectorstore = Qdrant.from_documents(
137
- docs,
138
- embedding_model,
139
- location=":memory:",
140
- collection_name="kohavi_ab_testing_pdf_collection",
141
- )
142
- return vectorstore
143
-
144
- # Setup vector store at startup
145
- vectorstore = setup_vector_store()
146
- retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
147
 
148
  # Define prompts
149
  RAG_PROMPT = """
@@ -260,13 +217,14 @@ async def on_message(message: cl.Message):
260
 
261
  # Stream the response
262
  final_answer = await chat_model.astream(
263
- [
264
- HumanMessage(
265
- content=rag_prompt.format(
 
266
  context=retrieval_result.get("context", ""),
267
  question=query
268
  )
269
- )
270
  ],
271
  callbacks=[
272
  cl.AsyncLangchainCallbackHandler(
@@ -281,6 +239,9 @@ async def on_message(message: cl.Message):
281
  source_elements = []
282
  for i, source in enumerate(sources):
283
  title = source.get("title", "Unknown")
 
 
 
284
  page = source.get("page", "Unknown")
285
 
286
  source_elements.append(
 
1
  import os
2
+ import pickle
3
  import chainlit as cl
4
+ from langchain_openai.chat_models import ChatOpenAI
5
  from chainlit.element import Text
6
  from langchain_core.prompts import ChatPromptTemplate
7
  from langchain.schema.output_parser import StrOutputParser
 
 
8
  from langchain_openai.embeddings import OpenAIEmbeddings
9
+ from pathlib import Path
 
 
 
 
 
 
 
 
10
  import functools
11
+ from qdrant_client import QdrantClient
12
+ from langchain_core.vectorstores import VectorStoreRetriever
13
+ from langchain_core.documents import Document
14
 
15
  # Configure OpenAI API key from environment variable
16
  if not os.environ.get("OPENAI_API_KEY"):
17
  os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY_BACKUP", "")
18
 
19
+ # Paths to pre-processed data
20
+ PROCESSED_DATA_DIR = Path("processed_data")
21
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
22
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
23
+
24
+ # Check if pre-processed data exists
25
+ def check_preprocessed_data():
26
+ """Check if pre-processed data exists and is readable."""
27
+ if not PROCESSED_DATA_DIR.exists():
28
+ raise FileNotFoundError(f"Processed data directory not found: {PROCESSED_DATA_DIR}. Please run scripts/preprocess_data.py first.")
29
+
30
+ if not CHUNKS_FILE.exists():
31
+ raise FileNotFoundError(f"Document chunks file not found: {CHUNKS_FILE}. Please run scripts/preprocess_data.py first.")
32
+
33
+ if not QDRANT_DIR.exists():
34
+ raise FileNotFoundError(f"Vector store directory not found: {QDRANT_DIR}. Please run scripts/preprocess_data.py first.")
35
+
36
+ # Load pre-processed document chunks
37
  @cl.cache
38
+ def load_document_chunks():
39
+ """Load pre-processed document chunks from disk."""
40
+ with open(CHUNKS_FILE, 'rb') as f:
41
+ return pickle.load(f)
42
+
43
+ # Load and create a custom retriever for Qdrant
44
+ @cl.cache
45
+ def load_qdrant_retriever():
46
+ """Load Qdrant client and create a retriever."""
47
+ embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
48
 
49
+ # Load the document chunks for metadata access
50
+ chunks = load_document_chunks()
 
51
 
52
+ # Create a dictionary mapping IDs to documents for quick lookup
53
+ docs_by_id = {i: doc for i, doc in enumerate(chunks)}
54
 
55
+ # Initialize Qdrant client
56
+ client = QdrantClient(path=str(QDRANT_DIR))
 
 
57
 
58
+ # Create a custom retriever function
59
+ def retrieve_docs(query, k=5):
60
+ # Get query embedding
61
+ query_embedding = embedding_model.embed_query(query)
 
 
 
 
 
 
62
 
63
+ # Search Qdrant
64
+ results = client.search(
65
+ collection_name="kohavi_ab_testing_pdf_collection",
66
+ query_vector=query_embedding,
67
+ limit=k
68
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ # Convert results to documents
71
+ documents = []
72
+ for result in results:
73
+ doc_id = result.id
74
+ if doc_id in docs_by_id:
75
+ documents.append(docs_by_id[doc_id])
 
 
76
 
77
+ return documents
 
 
 
 
 
 
 
 
 
78
 
79
+ # Create a VectorStoreRetriever from the custom retriever function
80
+ retriever = VectorStoreRetriever(
81
+ vectorstore=None, # Not using a standard vectorstore
82
+ search_type="similarity",
83
+ search_kwargs={"k": 5},
84
+ retriever_fn=retrieve_docs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  )
86
 
87
+ return retriever
 
 
 
 
88
 
89
+ # Check for pre-processed data at startup
90
+ try:
91
+ check_preprocessed_data()
92
+ # Load document chunks
93
+ docs = load_document_chunks()
94
+ # Load retriever
95
+ retriever = load_qdrant_retriever()
96
+ print(f"Successfully loaded pre-processed data with {len(docs)} document chunks")
97
+ except FileNotFoundError as e:
98
+ # If pre-processed data doesn't exist, print a helpful error message
99
+ print(f"ERROR: {e}")
100
+ print("\nTo pre-process your data, run:")
101
+ print(" python scripts/preprocess_data.py")
102
+ # Raise the error to prevent the app from starting without pre-processed data
103
+ raise
 
 
 
104
 
105
  # Define prompts
106
  RAG_PROMPT = """
 
217
 
218
  # Stream the response
219
  final_answer = await chat_model.astream(
220
+ messages=[
221
+ {
222
+ "role": "user",
223
+ "content": rag_prompt.format(
224
  context=retrieval_result.get("context", ""),
225
  question=query
226
  )
227
+ }
228
  ],
229
  callbacks=[
230
  cl.AsyncLangchainCallbackHandler(
 
239
  source_elements = []
240
  for i, source in enumerate(sources):
241
  title = source.get("title", "Unknown")
242
+ # Remove the .pdf extension if present
243
+ if title.lower().endswith('.pdf'):
244
+ title = title[:-4] # Remove the last 4 characters (.pdf)
245
  page = source.get("page", "Unknown")
246
 
247
  source_elements.append(
app_debug.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pickle
3
+ import sys
4
+ import chainlit as cl
5
+ from langchain_openai.chat_models import ChatOpenAI
6
+ from chainlit.element import Text
7
+ from langchain_core.prompts import ChatPromptTemplate
8
+ from langchain.schema.output_parser import StrOutputParser
9
+ from langchain_openai.embeddings import OpenAIEmbeddings
10
+ from pathlib import Path
11
+ import functools
12
+ from qdrant_client import QdrantClient
13
+ from langchain_core.vectorstores import VectorStoreRetriever
14
+ from langchain_core.documents import Document
15
+
16
+ print("Starting app_debug.py execution...")
17
+
18
+ # Configure OpenAI API key from environment variable
19
+ if not os.environ.get("OPENAI_API_KEY"):
20
+ os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY_BACKUP", "")
21
+ print("Configured OpenAI API key")
22
+
23
+ # Paths to pre-processed data
24
+ PROCESSED_DATA_DIR = Path("processed_data")
25
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
26
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
27
+ print(f"Configured paths: {PROCESSED_DATA_DIR}, {CHUNKS_FILE}, {QDRANT_DIR}")
28
+
29
+ # Check if pre-processed data exists
30
+ def check_preprocessed_data():
31
+ """Check if pre-processed data exists and is readable."""
32
+ print("Checking preprocessed data...")
33
+ if not PROCESSED_DATA_DIR.exists():
34
+ raise FileNotFoundError(f"Processed data directory not found: {PROCESSED_DATA_DIR}. Please run scripts/preprocess_data.py first.")
35
+
36
+ if not CHUNKS_FILE.exists():
37
+ raise FileNotFoundError(f"Document chunks file not found: {CHUNKS_FILE}. Please run scripts/preprocess_data.py first.")
38
+
39
+ if not QDRANT_DIR.exists():
40
+ raise FileNotFoundError(f"Vector store directory not found: {QDRANT_DIR}. Please run scripts/preprocess_data.py first.")
41
+ print("Preprocessed data checks passed")
42
+
43
+ # Load pre-processed document chunks
44
+ @cl.cache
45
+ def load_document_chunks():
46
+ """Load pre-processed document chunks from disk."""
47
+ print("Loading document chunks...")
48
+ with open(CHUNKS_FILE, 'rb') as f:
49
+ chunks = pickle.load(f)
50
+ print(f"Loaded {len(chunks)} document chunks")
51
+ return chunks
52
+
53
+ # Load and create a custom retriever for Qdrant
54
+ @cl.cache
55
+ def load_qdrant_retriever():
56
+ """Load Qdrant client and create a retriever."""
57
+ print("Creating Qdrant retriever...")
58
+ embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
59
+ print("Created embedding model")
60
+
61
+ # Load the document chunks for metadata access
62
+ chunks = load_document_chunks()
63
+
64
+ # Create a dictionary mapping IDs to documents for quick lookup
65
+ docs_by_id = {i: doc for i, doc in enumerate(chunks)}
66
+ print(f"Created mapping of {len(docs_by_id)} documents")
67
+
68
+ # Initialize Qdrant client
69
+ print(f"Initializing Qdrant client at {QDRANT_DIR}...")
70
+ client = QdrantClient(path=str(QDRANT_DIR))
71
+ print("Initialized Qdrant client")
72
+
73
+ # Create a custom retriever function
74
+ def retrieve_docs(query, k=5):
75
+ print(f"Retrieving documents for query: {query[:30]}...")
76
+ # Get query embedding
77
+ query_embedding = embedding_model.embed_query(query)
78
+
79
+ # Search Qdrant
80
+ results = client.search(
81
+ collection_name="kohavi_ab_testing_pdf_collection",
82
+ query_vector=query_embedding,
83
+ limit=k
84
+ )
85
+
86
+ # Convert results to documents
87
+ documents = []
88
+ for result in results:
89
+ doc_id = result.id
90
+ if doc_id in docs_by_id:
91
+ documents.append(docs_by_id[doc_id])
92
+ print(f"Retrieved {len(documents)} documents")
93
+ return documents
94
+
95
+ # Create a VectorStoreRetriever from the custom retriever function
96
+ retriever = VectorStoreRetriever(
97
+ vectorstore=None, # Not using a standard vectorstore
98
+ search_type="similarity",
99
+ search_kwargs={"k": 5},
100
+ retriever_fn=retrieve_docs
101
+ )
102
+ print("Created retriever")
103
+ return retriever
104
+
105
+ # Check for pre-processed data at startup
106
+ try:
107
+ print("Checking for pre-processed data...")
108
+ check_preprocessed_data()
109
+ # Load document chunks
110
+ print("Loading document chunks...")
111
+ docs = load_document_chunks()
112
+ # Load retriever
113
+ print("Loading retriever...")
114
+ retriever = load_qdrant_retriever()
115
+ print(f"Successfully loaded pre-processed data with {len(docs)} document chunks")
116
+ except FileNotFoundError as e:
117
+ # If pre-processed data doesn't exist, print a helpful error message
118
+ print(f"ERROR: {e}")
119
+ print("\nTo pre-process your data, run:")
120
+ print(" python scripts/preprocess_data.py")
121
+ # Raise the error to prevent the app from starting without pre-processed data
122
+ raise
123
+
124
+ # Define prompts
125
+ RAG_PROMPT = """
126
+ CONTEXT:
127
+ {context}
128
+
129
+ QUERY:
130
+ {question}
131
+
132
+ You are a helpful assistant. Use the available context to answer the question. Do not use your own knowledge! If you cannot answer the question based on the context, you must say "I don't know".
133
+ """
134
+
135
+ REPHRASE_QUERY_PROMPT = """
136
+ QUERY:
137
+ {question}
138
+
139
+ You are a helpful assistant. Rephrase the provided query to be more specific and to the point in order to improve retrieval in our RAG pipeline about AB Testing.
140
+ """
141
+
142
+ rag_prompt = ChatPromptTemplate.from_template(RAG_PROMPT)
143
+ rephrase_query_prompt = ChatPromptTemplate.from_template(REPHRASE_QUERY_PROMPT)
144
+ print("Defined prompts")
145
+
146
+ # Setup chat model
147
+ def get_openai_chat_model():
148
+ print("Creating OpenAI chat model...")
149
+ model = ChatOpenAI(
150
+ model="gpt-4-turbo",
151
+ temperature=0,
152
+ streaming=True,
153
+ )
154
+ print("Created OpenAI chat model")
155
+ return model
156
+
157
+ # Setup rephrased retrieval chain
158
+ def setup_rephrased_retriever_chain():
159
+ print("Setting up rephrased retriever chain...")
160
+ chat_model = get_openai_chat_model()
161
+
162
+ def retrieve_and_format_documents(query):
163
+ print(f"Retrieving documents for: {query[:30]}...")
164
+ rephrased_query_chain = rephrase_query_prompt | chat_model | StrOutputParser()
165
+
166
+ @cl.step(name="Rephrasing query for better retrieval")
167
+ async def rephrase_query():
168
+ print("Rephrasing query...")
169
+ rephrased_query = await cl.make_async(rephrased_query_chain.invoke)({"question": query})
170
+ print(f"Rephrased query: {rephrased_query[:30]}...")
171
+ await cl.Message(content=f"Rephrased query: {rephrased_query}").send()
172
+ return rephrased_query
173
+
174
+ rephrased_query_result = cl.run_sync(rephrase_query())
175
+ print(f"Received rephrased query: {rephrased_query_result[:30]}...")
176
+
177
+ # Get relevant documents based on rephrased query
178
+ docs = retriever.get_relevant_documents(rephrased_query_result)
179
+ print(f"Retrieved {len(docs)} documents")
180
+
181
+ # Extract sources from the documents for later display
182
+ sources = []
183
+ for doc in docs:
184
+ source_path = doc.metadata.get("source", "")
185
+ filename = source_path.split("/")[-1] if "/" in source_path else source_path
186
+
187
+ sources.append({
188
+ "title": filename,
189
+ "page": doc.metadata.get("page", "unknown"),
190
+ })
191
+
192
+ # Format documents into a string for the context
193
+ formatted_docs = "\n\n".join([doc.page_content for doc in docs])
194
+ print(f"Formatted {len(docs)} documents")
195
+
196
+ return {"context": formatted_docs, "sources": sources, "question": query}
197
+
198
+ print("Created retrieve_and_format_documents function")
199
+ return retrieve_and_format_documents
200
+
201
+ print("Defined setup functions")
202
+
203
+ @cl.on_chat_start
204
+ async def on_chat_start():
205
+ print("on_chat_start called")
206
+ # Initialize the chat model and chain in the user session
207
+ chat_model = get_openai_chat_model()
208
+ retriever_chain = setup_rephrased_retriever_chain()
209
+
210
+ # Store in user session
211
+ cl.user_session.set("chat_model", chat_model)
212
+ cl.user_session.set("retriever_chain", retriever_chain)
213
+ print("Stored chat model and retriever chain in user session")
214
+
215
+ welcome_message = """
216
+ # ๐Ÿ“Š AB Testing RAG Agent
217
+
218
+ This agent can answer questions about AB Testing using a collection of PDF documents.
219
+
220
+ **Examples of questions you can ask:**
221
+ - What is AB Testing?
222
+ - How do I interpret p-values in experiments?
223
+ - What are the best practices for running experiments with low traffic?
224
+ - How should I choose metrics for my experiments?
225
+ """
226
+
227
+ await cl.Message(content=welcome_message).send()
228
+ print("Sent welcome message")
229
+
230
+ @cl.on_message
231
+ async def on_message(message: cl.Message):
232
+ print(f"on_message called with content: {message.content[:30]}...")
233
+ # Get the chat model and retriever chain from user session
234
+ chat_model = cl.user_session.get("chat_model")
235
+ retriever_chain = cl.user_session.get("retriever_chain")
236
+ print("Retrieved chat model and retriever chain from user session")
237
+
238
+ query = message.content
239
+
240
+ # Step 1: Retrieve documents and extract sources
241
+ @cl.step(name="Retrieving relevant documents")
242
+ async def retrieve_docs():
243
+ print("retrieve_docs step started")
244
+ result = await cl.make_async(retriever_chain)(query)
245
+ print("retrieve_docs step completed")
246
+ return result
247
+
248
+ retrieval_result = await retrieve_docs()
249
+ print("Retrieved documents and extracted sources")
250
+
251
+ # Store sources for later display
252
+ sources = retrieval_result.get("sources", [])
253
+
254
+ # Step 2: Generate response with the retrieved context
255
+ msg = cl.Message(content="")
256
+ await msg.send()
257
+ print("Sent empty message to be streamed to")
258
+
259
+ # Stream the response
260
+ final_answer = await chat_model.astream(
261
+ messages=[
262
+ {
263
+ "role": "user",
264
+ "content": rag_prompt.format(
265
+ context=retrieval_result.get("context", ""),
266
+ question=query
267
+ )
268
+ }
269
+ ],
270
+ callbacks=[
271
+ cl.AsyncLangchainCallbackHandler(
272
+ stream_to_message=msg,
273
+ append_to_message=True,
274
+ )
275
+ ],
276
+ )
277
+ print("Streamed response from OpenAI")
278
+
279
+ # Display sources
280
+ if sources:
281
+ source_elements = []
282
+ for i, source in enumerate(sources):
283
+ title = source.get("title", "Unknown")
284
+ # Remove the .pdf extension if present
285
+ if title.lower().endswith('.pdf'):
286
+ title = title[:-4] # Remove the last 4 characters (.pdf)
287
+ page = source.get("page", "Unknown")
288
+
289
+ source_elements.append(
290
+ Text(
291
+ name=f"Source {i+1}",
292
+ content=f"{title}, Page: {page}",
293
+ display="inline",
294
+ )
295
+ )
296
+
297
+ await msg.elements.extend(source_elements)
298
+ await msg.update()
299
+ print("Updated message with source elements")
300
+
301
+ print("Defined event handlers")
302
+ print("Ready to start Chainlit app")
debug_app.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pickle
3
+ import sys
4
+ from pathlib import Path
5
+ from qdrant_client import QdrantClient
6
+
7
+ # Paths to pre-processed data
8
+ PROCESSED_DATA_DIR = Path("processed_data")
9
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
10
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
11
+
12
+ def main():
13
+ print("Starting debug process...")
14
+
15
+ # Step 1: Check if directories and files exist
16
+ print(f"Checking if processed_data directory exists: {PROCESSED_DATA_DIR.exists()}")
17
+ print(f"Checking if document_chunks.pkl exists: {CHUNKS_FILE.exists()}")
18
+ print(f"Checking if qdrant_vectorstore directory exists: {QDRANT_DIR.exists()}")
19
+
20
+ # Step 2: Try to load document chunks
21
+ try:
22
+ print("Attempting to load document chunks...")
23
+ with open(CHUNKS_FILE, 'rb') as f:
24
+ chunks = pickle.load(f)
25
+ print(f"Successfully loaded {len(chunks)} document chunks")
26
+ except Exception as e:
27
+ print(f"Error loading document chunks: {e}")
28
+ return
29
+
30
+ # Step 3: Try to initialize Qdrant client
31
+ try:
32
+ print(f"Attempting to initialize Qdrant client at {QDRANT_DIR}...")
33
+ client = QdrantClient(path=str(QDRANT_DIR))
34
+
35
+ # Try to list collections
36
+ collections = client.get_collections()
37
+ print(f"Available collections: {collections}")
38
+
39
+ # Try to get collection info
40
+ try:
41
+ collection_info = client.get_collection("kohavi_ab_testing_pdf_collection")
42
+ print(f"Collection info: {collection_info}")
43
+ except Exception as e:
44
+ print(f"Error getting collection info: {e}")
45
+
46
+ except Exception as e:
47
+ print(f"Error initializing Qdrant client: {e}")
48
+ return
49
+
50
+ print("Debug process completed successfully!")
51
+
52
+ if __name__ == "__main__":
53
+ main()
debug_output.log ADDED
@@ -0,0 +1 @@
 
 
1
+ 2025-04-29 21:22:54 - Loaded .env file
debug_start.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pickle
3
+ import sys
4
+ from pathlib import Path
5
+ from qdrant_client import QdrantClient
6
+ from langchain_openai.embeddings import OpenAIEmbeddings
7
+ from dotenv import load_dotenv
8
+
9
+ # Load .env file for OpenAI API key
10
+ load_dotenv()
11
+ print("Loaded .env file")
12
+
13
+ # Paths to pre-processed data
14
+ PROCESSED_DATA_DIR = Path("processed_data")
15
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
16
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
17
+
18
+ def main():
19
+ print("Starting debug process...")
20
+
21
+ # Step 1: Check if directories and files exist
22
+ print(f"Checking if processed_data directory exists: {PROCESSED_DATA_DIR.exists()}")
23
+ print(f"Checking if document_chunks.pkl exists: {CHUNKS_FILE.exists()}")
24
+ print(f"Checking if qdrant_vectorstore directory exists: {QDRANT_DIR.exists()}")
25
+
26
+ # Step 2: Try to load document chunks
27
+ try:
28
+ print("Attempting to load document chunks...")
29
+ with open(CHUNKS_FILE, 'rb') as f:
30
+ chunks = pickle.load(f)
31
+ print(f"Successfully loaded {len(chunks)} document chunks")
32
+ except Exception as e:
33
+ print(f"Error loading document chunks: {e}")
34
+ return
35
+
36
+ # Step 3: Try to initialize OpenAI embeddings
37
+ try:
38
+ print("Initializing OpenAI embeddings...")
39
+ embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
40
+ print("Successfully initialized OpenAI embeddings")
41
+
42
+ # Test with a simple query
43
+ test_embedding = embedding_model.embed_query("This is a test")
44
+ print(f"Successfully created test embedding with length {len(test_embedding)}")
45
+ except Exception as e:
46
+ print(f"Error initializing OpenAI embeddings: {e}")
47
+ return
48
+
49
+ # Step 4: Try to initialize Qdrant client
50
+ try:
51
+ print("Initializing Qdrant client...")
52
+ client = QdrantClient(path=str(QDRANT_DIR))
53
+
54
+ # List collections
55
+ collections = client.get_collections()
56
+ print(f"Available collections: {collections}")
57
+
58
+ # Get collection info
59
+ collection_name = "kohavi_ab_testing_pdf_collection"
60
+ collection_info = client.get_collection(collection_name)
61
+ print(f"Collection info: {collection_info}")
62
+
63
+ # Test a simple search
64
+ print("Testing search functionality...")
65
+ query_embedding = embedding_model.embed_query("What is AB testing?")
66
+ search_results = client.search(
67
+ collection_name=collection_name,
68
+ query_vector=query_embedding,
69
+ limit=2
70
+ )
71
+ print(f"Successfully retrieved {len(search_results)} search results")
72
+
73
+ # Display a sample result
74
+ if search_results:
75
+ print("First result ID:", search_results[0].id)
76
+ print("First result score:", search_results[0].score)
77
+ print("First result payload text (first 100 chars):", search_results[0].payload["text"][:100])
78
+
79
+ except Exception as e:
80
+ print(f"Error with Qdrant operations: {e}")
81
+ return
82
+
83
+ print("Debug process completed successfully!")
84
+
85
+ if __name__ == "__main__":
86
+ print("Script execution started")
87
+ main()
88
+ print("Script execution finished")
docker-compose.yml CHANGED
@@ -10,6 +10,7 @@ services:
10
  - "8000:8000"
11
  volumes:
12
  - ./data:/app/data
 
13
  - ./.env:/app/.env
14
  environment:
15
  - OPENAI_API_KEY=${OPENAI_API_KEY}
 
10
  - "8000:8000"
11
  volumes:
12
  - ./data:/app/data
13
+ - ./processed_data:/app/processed_data
14
  - ./.env:/app/.env
15
  environment:
16
  - OPENAI_API_KEY=${OPENAI_API_KEY}
huggingface-space.yml CHANGED
@@ -1,11 +1,22 @@
1
  ---
2
- title: AB Testing RAG Agent
3
  emoji: ๐Ÿ“Š
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  license: mit
 
 
 
 
 
 
 
 
 
 
 
9
  app_port: 8000
10
  ---
11
 
 
1
  ---
2
+ title: AB Testing RAG
3
  emoji: ๐Ÿ“Š
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
+
10
+ # Environment variables (fill these in on Hugging Face)
11
+ # OPENAI_API_KEY: Your OpenAI API Key
12
+
13
+ # Configuration for Dockerfile
14
+ dockerfile:
15
+ # Use environment variables to specify which app to run
16
+ env:
17
+ APP_MODE: streamlit # Options: streamlit or chainlit
18
+
19
+ # Streamlit app configuration
20
  app_port: 8000
21
  ---
22
 
minimal_app.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import chainlit as cl
3
+ from dotenv import load_dotenv
4
+
5
+ # Load environment variables
6
+ load_dotenv()
7
+ print("Loaded .env file")
8
+
9
+ @cl.on_chat_start
10
+ async def start():
11
+ print("Chat started")
12
+ await cl.Message(content="Hello! I am a minimal AB Testing RAG agent.").send()
13
+ print("Welcome message sent")
14
+
15
+ @cl.on_message
16
+ async def main(message):
17
+ print(f"Received message: {message.content[:30]}...")
18
+ await cl.Message(content=f"You asked: {message.content}\n\nThis is a minimal test response.").send()
19
+ print("Response sent")
notebook_version/.DS_Store CHANGED
Binary files a/notebook_version/.DS_Store and b/notebook_version/.DS_Store differ
 
requirements.txt CHANGED
@@ -10,4 +10,6 @@ tiktoken==0.5.2
10
  python-dotenv==1.0.1
11
  unstructured==0.12.5
12
  pypdf==3.17.4
13
- numpy==1.26.3
 
 
 
10
  python-dotenv==1.0.1
11
  unstructured==0.12.5
12
  pypdf==3.17.4
13
+ numpy==1.26.3
14
+ streamlit==1.32.0
15
+ requests==2.31.0
run_log.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 2025-04-29 21:21:30 - Loaded .env file
scripts/docker-entrypoint.sh ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Function to check if pre-processed data exists
5
+ check_preprocessed_data() {
6
+ if [ -d "/app/processed_data/qdrant_vectorstore" ] && [ -f "/app/processed_data/document_chunks.pkl" ]; then
7
+ return 0 # Data exists
8
+ else
9
+ return 1 # Data doesn't exist
10
+ fi
11
+ }
12
+
13
+ # Check if pre-processed data already exists
14
+ if check_preprocessed_data; then
15
+ echo "Pre-processed data already exists and will be used"
16
+ echo "No need to process PDF files as processed data is already available"
17
+ else
18
+ # Count PDF files in data directory
19
+ pdf_count=$(find /app/data -name "*.pdf" | wc -l)
20
+ echo "Found $pdf_count PDF files in data directory"
21
+
22
+ # If there are PDF files but no pre-processed data, run pre-processing
23
+ if [ "$pdf_count" -gt 0 ]; then
24
+ echo "Pre-processing PDF files..."
25
+ python /app/scripts/preprocess_data.py
26
+ echo "Pre-processing complete"
27
+ else
28
+ echo "WARNING: No PDF files found in data directory and no pre-processed data exists"
29
+ echo "The application will likely fail to start"
30
+ fi
31
+ fi
32
+
33
+ # Execute the provided command (usually starting the Chainlit app)
34
+ exec "$@"
scripts/prepare_for_deployment.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Prepares the repository for deployment to Hugging Face Spaces.
4
+
5
+ This script:
6
+ 1. Verifies that pre-processed data exists
7
+ 2. Checks that PDF files aren't included in the repository
8
+ 3. Lists the files that will be included in the deployment
9
+ 4. Provides instructions for deploying to Hugging Face
10
+
11
+ Run this after successfully pre-processing your data with preprocess_data.py
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+ import shutil
18
+
19
+ PROCESSED_DATA_DIR = Path("processed_data")
20
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
21
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
22
+ DATA_DIR = Path("data")
23
+
24
+ def check_preprocessed_data():
25
+ """Check if pre-processed data exists and is ready for deployment."""
26
+ print("\n=== Checking for pre-processed data ===")
27
+
28
+ if not PROCESSED_DATA_DIR.exists():
29
+ print(f"โŒ ERROR: Processed data directory not found: {PROCESSED_DATA_DIR}")
30
+ print(" Please run scripts/preprocess_data.py first.")
31
+ return False
32
+
33
+ if not CHUNKS_FILE.exists():
34
+ print(f"โŒ ERROR: Document chunks file not found: {CHUNKS_FILE}")
35
+ print(" Please run scripts/preprocess_data.py first.")
36
+ return False
37
+
38
+ if not QDRANT_DIR.exists():
39
+ print(f"โŒ ERROR: Vector store directory not found: {QDRANT_DIR}")
40
+ print(" Please run scripts/preprocess_data.py first.")
41
+ return False
42
+
43
+ print(f"โœ… Found processed data directory: {PROCESSED_DATA_DIR}")
44
+ print(f"โœ… Found document chunks file: {CHUNKS_FILE}")
45
+ print(f"โœ… Found vector store directory: {QDRANT_DIR}")
46
+
47
+ # Check if vector store actually has content
48
+ qdrant_files = list(QDRANT_DIR.glob("**/*"))
49
+ if len(qdrant_files) < 5: # Arbitrary threshold for a minimum number of files
50
+ print(f"โš ๏ธ WARNING: Vector store directory might be empty or incomplete.")
51
+ print(f" Only found {len(qdrant_files)} files in {QDRANT_DIR}")
52
+ else:
53
+ print(f"โœ… Vector store directory contains {len(qdrant_files)} files/directories")
54
+
55
+ return True
56
+
57
+ def check_for_pdf_files():
58
+ """Check that PDF files aren't included in the data directory."""
59
+ print("\n=== Checking for PDF files ===")
60
+
61
+ pdf_files = list(DATA_DIR.glob("**/*.pdf"))
62
+
63
+ if pdf_files:
64
+ print(f"โš ๏ธ WARNING: Found {len(pdf_files)} PDF files in the data directory.")
65
+ print(" These files will NOT be committed to the repository if you follow the instructions below.")
66
+ print(" PDFs in the data directory are excluded in .gitignore.")
67
+ for pdf in pdf_files[:5]: # Show first 5 PDFs only
68
+ print(f" - {pdf}")
69
+ if len(pdf_files) > 5:
70
+ print(f" - ... and {len(pdf_files) - 5} more")
71
+ else:
72
+ print("โœ… No PDF files found in the data directory - good!")
73
+
74
+ return True
75
+
76
+ def list_deployment_files():
77
+ """List the essential files that will be included in the deployment."""
78
+ print("\n=== Files to include in deployment ===")
79
+
80
+ essential_files = [
81
+ "app.py",
82
+ "requirements.txt",
83
+ "Dockerfile",
84
+ "docker-compose.yml",
85
+ "chainlit.md",
86
+ "chainlit.json",
87
+ "README.md",
88
+ "scripts/docker-entrypoint.sh",
89
+ ".dockerignore",
90
+ "processed_data/",
91
+ ]
92
+
93
+ print("The following files and directories should be included in your deployment:")
94
+ for file in essential_files:
95
+ file_path = Path(file)
96
+ if file_path.exists() or (file.endswith('/') and Path(file.rstrip('/')).exists()):
97
+ print(f"โœ… {file}")
98
+ else:
99
+ print(f"โŒ {file} - Not found!")
100
+
101
+ return True
102
+
103
+ def provide_deployment_instructions():
104
+ """Provide instructions for deploying to Hugging Face."""
105
+ print("\n=== Deployment Instructions ===")
106
+
107
+ print("""
108
+ To deploy to Hugging Face Spaces:
109
+
110
+ 1. Ensure you have the Hugging Face CLI installed:
111
+ pip install huggingface_hub
112
+
113
+ 2. Log in to Hugging Face:
114
+ huggingface-cli login
115
+
116
+ 3. Create a new Hugging Face Space with Docker deployment from the Hugging Face website
117
+
118
+ 4. Add your repository as a remote:
119
+ git remote add huggingface https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
120
+
121
+ 5. Stage the necessary files (do NOT include PDF files):
122
+ git add app.py requirements.txt Dockerfile docker-compose.yml chainlit.md chainlit.json README.md scripts/docker-entrypoint.sh .dockerignore processed_data/
123
+
124
+ 6. Commit the changes:
125
+ git commit -m "Prepare for Hugging Face deployment with pre-processed data"
126
+
127
+ 7. Push to Hugging Face:
128
+ git push huggingface main
129
+
130
+ 8. Set your OpenAI API key in the Hugging Face Space settings
131
+ """)
132
+
133
+ return True
134
+
135
+ def main():
136
+ """Main entry point of the script."""
137
+ print("=" * 80)
138
+ print("PREPARING FOR HUGGING FACE DEPLOYMENT")
139
+ print("=" * 80)
140
+
141
+ checks = [
142
+ check_preprocessed_data,
143
+ check_for_pdf_files,
144
+ list_deployment_files,
145
+ provide_deployment_instructions
146
+ ]
147
+
148
+ all_passed = True
149
+ for check in checks:
150
+ if not check():
151
+ all_passed = False
152
+
153
+ if all_passed:
154
+ print("\nโœ… All checks passed! Your repository is ready for deployment to Hugging Face Spaces.")
155
+ print(" Follow the deployment instructions above to deploy your application.")
156
+ else:
157
+ print("\nโŒ Some checks failed. Please fix the issues before deploying.")
158
+ sys.exit(1)
159
+
160
+ if __name__ == "__main__":
161
+ main()
scripts/preprocess_data.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pre-processes PDF files in the data directory, creating and saving:
4
+ 1. Document chunks with metadata
5
+ 2. Vector embeddings
6
+
7
+ This allows the app to load pre-processed data directly instead of processing
8
+ PDFs at runtime, making the app start faster and eliminating the need to
9
+ upload PDFs to Hugging Face.
10
+ """
11
+
12
+ import os
13
+ import pickle
14
+ import tiktoken
15
+ from pathlib import Path
16
+ from collections import defaultdict
17
+ import json
18
+
19
+ # Import required LangChain modules
20
+ from langchain_community.document_loaders import DirectoryLoader
21
+ from langchain_community.document_loaders import PyPDFLoader
22
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
23
+ from langchain_openai.embeddings import OpenAIEmbeddings
24
+ from langchain_qdrant import Qdrant
25
+ from langchain_core.documents import Document
26
+ from qdrant_client import QdrantClient
27
+ from qdrant_client.models import Distance, VectorParams, PointStruct
28
+
29
+ # Set OpenAI API key
30
+ from dotenv import load_dotenv
31
+ load_dotenv()
32
+
33
+ # Ensure OpenAI API key is available
34
+ if not os.environ.get("OPENAI_API_KEY"):
35
+ raise EnvironmentError("OPENAI_API_KEY environment variable not found. Please set it in your .env file.")
36
+
37
+ # Create directories to store pre-processed data
38
+ PROCESSED_DATA_DIR = Path("processed_data")
39
+ PROCESSED_DATA_DIR.mkdir(exist_ok=True)
40
+
41
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
42
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
43
+
44
+ def load_and_process_documents():
45
+ """
46
+ Load PDF documents, merge them by source, and split into chunks.
47
+ This is the same process used in the notebook and app.py.
48
+ """
49
+ print("Loading PDF documents...")
50
+ path = "data/"
51
+ loader = DirectoryLoader(path, glob="*.pdf", loader_cls=PyPDFLoader)
52
+ all_docs = loader.load()
53
+ print(f"Loaded {len(all_docs)} pages from PDF files")
54
+
55
+ # Create a mapping of merged document chunks back to original pages
56
+ page_map = {}
57
+ current_index = 0
58
+
59
+ # For source tracking, we'll store page information before merging
60
+ docs_by_source = defaultdict(list)
61
+
62
+ # Group documents by their source file
63
+ for doc in all_docs:
64
+ source = doc.metadata.get("source", "")
65
+ docs_by_source[source].append(doc)
66
+
67
+ # Merge pages from the same PDF but track page ranges
68
+ merged_docs = []
69
+ for source, source_docs in docs_by_source.items():
70
+ # Sort by page number if available
71
+ source_docs.sort(key=lambda x: x.metadata.get("page", 0))
72
+
73
+ # Merge the content
74
+ merged_content = ""
75
+ page_ranges = []
76
+ current_pos = 0
77
+
78
+ for doc in source_docs:
79
+ # Get the page number (1-indexed for human readability)
80
+ page_num = doc.metadata.get("page", 0) + 1
81
+
82
+ # Add a separator between pages for clarity
83
+ if merged_content:
84
+ merged_content += "\n\n"
85
+
86
+ # Record where this page's content starts in the merged document
87
+ start_pos = len(merged_content)
88
+ merged_content += doc.page_content
89
+ end_pos = len(merged_content)
90
+
91
+ # Store the mapping of character ranges to original page numbers
92
+ page_ranges.append({
93
+ "start": start_pos,
94
+ "end": end_pos,
95
+ "page": page_num,
96
+ "source": source
97
+ })
98
+
99
+ # Create merged metadata that includes page mapping information
100
+ merged_metadata = {
101
+ "source": source,
102
+ "title": source.split("/")[-1],
103
+ "page_count": len(source_docs),
104
+ "merged": True,
105
+ "page_ranges": page_ranges # Store the page ranges for later reference
106
+ }
107
+
108
+ # Create a new document with the merged content
109
+ merged_doc = Document(page_content=merged_content, metadata=merged_metadata)
110
+ merged_docs.append(merged_doc)
111
+
112
+ print(f"Created {len(merged_docs)} merged documents")
113
+
114
+ # tiktoken_len counts tokens (not characters) using the gpt-4o-mini tokenizer
115
+ def tiktoken_len(text):
116
+ tokens = tiktoken.encoding_for_model("gpt-4o-mini").encode(
117
+ text,
118
+ )
119
+ return len(tokens)
120
+
121
+ # Process splits to add page info based on character position
122
+ def add_page_info_to_splits(splits):
123
+ for split in splits:
124
+ # Get the start position of this chunk
125
+ start_pos = split.metadata.get("start_index", 0)
126
+ end_pos = start_pos + len(split.page_content)
127
+
128
+ # Find which page this chunk belongs to
129
+ if "page_ranges" in split.metadata:
130
+ for page_range in split.metadata["page_ranges"]:
131
+ # If chunk significantly overlaps with this page range
132
+ if (start_pos <= page_range["end"] and
133
+ end_pos >= page_range["start"]):
134
+ # Use this page number
135
+ split.metadata["page"] = page_range["page"]
136
+ break
137
+ return splits
138
+
139
+ # Split the text with start index tracking
140
+ print("Splitting documents into chunks...")
141
+ text_splitter = RecursiveCharacterTextSplitter(
142
+ chunk_size=300,
143
+ chunk_overlap=50,
144
+ length_function=tiktoken_len,
145
+ add_start_index=True
146
+ )
147
+
148
+ # Split and then process to add page information
149
+ raw_splits = text_splitter.split_documents(merged_docs)
150
+ split_chunks = add_page_info_to_splits(raw_splits)
151
+ print(f"Created {len(split_chunks)} document chunks")
152
+
153
+ return split_chunks
154
+
155
+ def create_and_save_vectorstore(chunks):
156
+ """
157
+ Create a vector store from document chunks and save it to disk.
158
+ """
159
+ print("Creating embeddings and vector store...")
160
+ embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
161
+
162
+ # Extract text and metadata for separate processing
163
+ texts = [doc.page_content for doc in chunks]
164
+ metadatas = [doc.metadata for doc in chunks]
165
+
166
+ # Ensure the directory exists
167
+ QDRANT_DIR.mkdir(exist_ok=True, parents=True)
168
+
169
+ # Create a local Qdrant client
170
+ client = QdrantClient(path=str(QDRANT_DIR))
171
+
172
+ # Get the embedding dimension
173
+ sample_embedding = embedding_model.embed_query("Sample text")
174
+
175
+ # Create the collection if it doesn't exist
176
+ collection_name = "kohavi_ab_testing_pdf_collection"
177
+ try:
178
+ collection_info = client.get_collection(collection_name)
179
+ print(f"Collection {collection_name} already exists")
180
+ except Exception:
181
+ # Collection doesn't exist, create it
182
+ print(f"Creating collection {collection_name}")
183
+ client.create_collection(
184
+ collection_name=collection_name,
185
+ vectors_config=VectorParams(
186
+ size=len(sample_embedding),
187
+ distance=Distance.COSINE
188
+ )
189
+ )
190
+
191
+ # Process in batches to avoid memory issues
192
+ batch_size = 100
193
+ print(f"Processing {len(texts)} documents in batches of {batch_size}")
194
+
195
+ for i in range(0, len(texts), batch_size):
196
+ batch_texts = texts[i:i+batch_size]
197
+ batch_metadatas = metadatas[i:i+batch_size]
198
+
199
+ print(f"Processing batch {i//batch_size + 1}/{(len(texts) + batch_size - 1)//batch_size}")
200
+
201
+ # Get embeddings for this batch
202
+ embeddings = embedding_model.embed_documents(batch_texts)
203
+
204
+ # Create points for this batch
205
+ points = []
206
+ for j, (text, embedding, metadata) in enumerate(zip(batch_texts, embeddings, batch_metadatas)):
207
+ points.append(PointStruct(
208
+ id=i + j,
209
+ vector=embedding,
210
+ payload={
211
+ "text": text,
212
+ "metadata": metadata
213
+ }
214
+ ))
215
+
216
+ # Upsert points into the collection
217
+ client.upsert(
218
+ collection_name=collection_name,
219
+ points=points
220
+ )
221
+
222
+ print(f"Vector store created and saved to {QDRANT_DIR}")
223
+ return True
224
+
225
+ def main():
226
+ # Load and process documents
227
+ print("Starting pre-processing of PDF files...")
228
+ chunks = load_and_process_documents()
229
+
230
+ # Save chunks to disk
231
+ print(f"Saving {len(chunks)} document chunks to {CHUNKS_FILE}...")
232
+ with open(CHUNKS_FILE, 'wb') as f:
233
+ pickle.dump(chunks, f)
234
+ print(f"Chunks saved to {CHUNKS_FILE}")
235
+
236
+ # Create and save vector store
237
+ success = create_and_save_vectorstore(chunks)
238
+
239
+ if success:
240
+ print("Pre-processing complete! The application can now use these pre-processed files.")
241
+ print(f"- Document chunks: {CHUNKS_FILE}")
242
+ print(f"- Vector store: {QDRANT_DIR}")
243
+ else:
244
+ print("Error creating vector store. Please check the logs.")
245
+
246
+ if __name__ == "__main__":
247
+ main()
streamlit_app.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pickle
3
+ import streamlit as st
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+ from langchain_openai.chat_models import ChatOpenAI
7
+ from langchain_openai.embeddings import OpenAIEmbeddings
8
+ from langchain_core.prompts import ChatPromptTemplate
9
+ from qdrant_client import QdrantClient
10
+ from langchain_core.documents import Document
11
+ from langchain.agents import AgentExecutor, create_openai_tools_agent
12
+ from langchain_core.tools import tool
13
+ from langchain.agents.format_scratchpad.openai_tools import format_to_openai_tool_messages
14
+ from langchain_core.messages import AIMessage, HumanMessage
15
+ import requests
16
+ import json
17
+ from langchain_core.output_parsers import StrOutputParser
18
+
19
+ # Global variable to store ArXiv sources
20
+ ARXIV_SOURCES = []
21
+
22
+ # Load environment variables
23
+ load_dotenv()
24
+ print("Loaded .env file")
25
+
26
+ # Configure OpenAI API key from environment variable
27
+ if not os.environ.get("OPENAI_API_KEY"):
28
+ os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY_BACKUP", "")
29
+
30
+ # Paths to pre-processed data
31
+ PROCESSED_DATA_DIR = Path("processed_data")
32
+ CHUNKS_FILE = PROCESSED_DATA_DIR / "document_chunks.pkl"
33
+ QDRANT_DIR = PROCESSED_DATA_DIR / "qdrant_vectorstore"
34
+
35
+ # Define prompts
36
+ INITIAL_RAG_PROMPT = """
37
+ CONTEXT:
38
+ {context}
39
+
40
+ QUERY:
41
+ {question}
42
+
43
+ You are a helpful assistant with expertise in AB Testing. Use the available context to answer the question. Do not use your own knowledge! If you cannot answer the question based on the context, you must say "I don't know, but I can try a different approach if you'd like."
44
+ """
45
+
46
+ EVALUATE_RESPONSE_PROMPT = """
47
+ QUERY:
48
+ {question}
49
+
50
+ RESPONSE:
51
+ {response}
52
+
53
+ Evaluate if the response sufficiently answers the query based on the following criteria:
54
+ 1. Relevance: Does the response directly address the query topic?
55
+ 2. Completeness: Does the response fully answer all aspects of the query?
56
+ 3. Accuracy: Is the information provided factually correct and helpful?
57
+
58
+ Return only "SUFFICIENT" if the response meets all criteria, or "INSUFFICIENT" if the response needs improvement.
59
+ """
60
+
61
+ REPHRASE_QUERY_PROMPT = """
62
+ QUERY:
63
+ {question}
64
+
65
+ You are a helpful assistant. Rephrase the provided query to be more specific and to the point in order to improve retrieval in our RAG pipeline about AB Testing.
66
+ """
67
+
68
+ AGENT_PROMPT = """
69
+ You are an expert AB Testing assistant. Your job is to provide helpful, accurate information about AB Testing topics.
70
+
71
+ You have access to several tools:
72
+ 1. You can search for relevant documents in the database using search_documents - use this for general AB testing questions
73
+ 2. You can rephrase a query to get better search results using search_with_rephrased_query - use this when initial searches don't yield good results
74
+ 3. You can search ArXiv for academic papers using search_arxiv - use this for:
75
+ a) Specific academic papers, their authors, or publications
76
+ b) As a fallback when other tools don't yield satisfactory results
77
+ c) Technical questions that might be better answered with academic research
78
+
79
+ When the user asks about specific papers, authors of papers, or academic publications, you should IMMEDIATELY use the search_arxiv tool rather than the document search tools.
80
+
81
+ For general AB testing questions, follow this process:
82
+ 1. First try search_documents
83
+ 2. If that doesn't provide good information, try search_with_rephrased_query
84
+ 3. If still insufficient, try search_arxiv as a final resource before giving up
85
+
86
+ Use these tools to provide the best possible answer.
87
+ """
88
+
89
+ @st.cache_resource
90
+ def load_document_chunks():
91
+ """Load pre-processed document chunks from disk."""
92
+ with open(CHUNKS_FILE, 'rb') as f:
93
+ return pickle.load(f)
94
+
95
+ @st.cache_resource
96
+ def get_chat_model():
97
+ """Get the chat model for initial RAG."""
98
+ return ChatOpenAI(
99
+ model="gpt-4.1-mini",
100
+ temperature=0,
101
+ )
102
+
103
+ @st.cache_resource
104
+ def get_agent_model():
105
+ """Get the more powerful model for agent and evaluation."""
106
+ return ChatOpenAI(
107
+ model="gpt-4.1",
108
+ temperature=0,
109
+ )
110
+
111
+ @st.cache_resource
112
+ def get_embedding_model():
113
+ """Get the embedding model."""
114
+ return OpenAIEmbeddings(model="text-embedding-3-small")
115
+
116
+ @st.cache_resource
117
+ def setup_qdrant_client():
118
+ """Set up the Qdrant client."""
119
+ return QdrantClient(path=str(QDRANT_DIR))
120
+
121
+ def retrieve_documents(query, k=5):
122
+ """Retrieve relevant documents for a query."""
123
+ # Get models and data
124
+ embedding_model = get_embedding_model()
125
+ chunks = load_document_chunks()
126
+ client = setup_qdrant_client()
127
+
128
+ # Create a mapping of IDs to documents
129
+ docs_by_id = {i: doc for i, doc in enumerate(chunks)}
130
+
131
+ # Get query embedding
132
+ query_embedding = embedding_model.embed_query(query)
133
+
134
+ # Search Qdrant
135
+ results = client.search(
136
+ collection_name="kohavi_ab_testing_pdf_collection",
137
+ query_vector=query_embedding,
138
+ limit=k
139
+ )
140
+
141
+ # Convert results to documents
142
+ documents = []
143
+ sources_dict = {} # Use a dictionary to track unique sources by file+page
144
+
145
+ print(f"Retrieved {len(results)} search results")
146
+
147
+ for result in results:
148
+ doc_id = result.id
149
+ if doc_id in docs_by_id:
150
+ doc = docs_by_id[doc_id]
151
+ documents.append(doc)
152
+
153
+ # Debug the metadata
154
+ print(f"Document metadata: {doc.metadata}")
155
+
156
+ # Extract source info
157
+ source_path = doc.metadata.get("source", "")
158
+ filename = source_path.split("/")[-1] if "/" in source_path else source_path
159
+
160
+ # Remove .pdf extension if present
161
+ if filename.lower().endswith('.pdf'):
162
+ filename = filename[:-4]
163
+
164
+ # Default to the full filename if we can't extract a title
165
+ if not filename:
166
+ filename = "Unknown Source"
167
+
168
+ # Get page number, use a default if not available
169
+ page = doc.metadata.get("page", "unknown")
170
+
171
+ # All PDF sources in data directory are by Ron Kohavi, so add his name as prefix
172
+ title = f"Ron Kohavi: {filename}"
173
+
174
+ # Create a unique key for this source based on filename and page
175
+ source_key = f"{filename}_{page}"
176
+
177
+ # Only add to sources if we haven't seen this exact source (same file, same page) before
178
+ if source_key not in sources_dict:
179
+ sources_dict[source_key] = {
180
+ "title": title,
181
+ "page": page,
182
+ "score": float(result.score),
183
+ "type": "pdf"
184
+ }
185
+ print(f"Added source: {title}, Page: {page}")
186
+ else:
187
+ print(f"Skipping duplicate source: {title}, Page: {page}")
188
+
189
+ # Convert the dictionary of unique sources back to a list
190
+ sources = list(sources_dict.values())
191
+
192
+ print(f"Returning {len(documents)} documents with {len(sources)} unique sources")
193
+ return documents, sources
194
+
195
+ def rephrase_query(query):
196
+ """Rephrase the query to improve retrieval."""
197
+ chat_model = get_chat_model()
198
+ prompt = ChatPromptTemplate.from_template(REPHRASE_QUERY_PROMPT)
199
+ messages = prompt.format_messages(question=query)
200
+ response = chat_model.invoke(messages)
201
+ return response.content
202
+
203
+ def generate_answer(context, question):
204
+ """Generate an answer using the context and question."""
205
+ chat_model = get_chat_model()
206
+ prompt = ChatPromptTemplate.from_template(INITIAL_RAG_PROMPT)
207
+ messages = prompt.format_messages(context=context, question=question)
208
+ response = chat_model.invoke(messages)
209
+ return response.content
210
+
211
+ def evaluate_response(question, response):
212
+ """Evaluate if the response is sufficient."""
213
+ # Check if this is likely a question about academic papers or authors
214
+ paper_keywords = ["author", "paper", "publication", "published", "journal", "conference", "researchers", "wrote"]
215
+
216
+ is_paper_query = any(keyword in question.lower() for keyword in paper_keywords) and ("'" in question or '"' in question)
217
+
218
+ # If it's a paper query and the response indicates lack of knowledge, mark as insufficient
219
+ if is_paper_query and ("don't know" in response.lower() or "cannot " in response.lower() or "i don't have" in response.lower()):
220
+ print("Paper-specific query detected with insufficient answer, sending to agent")
221
+ return False
222
+
223
+ # Otherwise, use the LLM evaluation
224
+ agent_model = get_agent_model()
225
+ prompt = ChatPromptTemplate.from_template(EVALUATE_RESPONSE_PROMPT)
226
+ messages = prompt.format_messages(question=question, response=response)
227
+ result = agent_model.invoke(messages)
228
+ return "SUFFICIENT" in result.content
229
+
230
+ @tool
231
+ def search_documents(query: str) -> str:
232
+ """Search for relevant documents in the AB Testing database."""
233
+ documents, _ = retrieve_documents(query)
234
+ if not documents:
235
+ return "No relevant documents found"
236
+ return "\n\n".join([doc.page_content for doc in documents])
237
+
238
+ @tool
239
+ def search_with_rephrased_query(query: str) -> str:
240
+ """Rephrase the query and then search for relevant documents."""
241
+ rephrased = rephrase_query(query)
242
+ documents, _ = retrieve_documents(rephrased)
243
+ if not documents:
244
+ return "No relevant documents found even with rephrased query"
245
+ return "\n\n".join([doc.page_content for doc in documents])
246
+
247
+ @tool
248
+ def search_arxiv(query: str) -> str:
249
+ """Search ArXiv for academic papers related to the query."""
250
+ global ARXIV_SOURCES
251
+ ARXIV_SOURCES = [] # Reset sources for new search
252
+
253
+ try:
254
+ # Check if the query is looking for a specific paper by title
255
+ if "paper" in query.lower() and ("title" in query.lower() or "called" in query.lower() or "named" in query.lower() or "'" in query or '"' in query):
256
+ # Try to extract paper title from quotes if present
257
+ import re
258
+ title_match = re.search(r'[\'"]([^\'"]+)[\'"]', query)
259
+
260
+ if title_match:
261
+ paper_title = title_match.group(1)
262
+ # Use title-specific search with exact match
263
+ formatted_query = f'ti:"{paper_title}"'
264
+ else:
265
+ # Fall back to general search but optimize for title
266
+ formatted_query = query.replace(' ', '+')
267
+ formatted_query = f'all:{formatted_query}'
268
+ else:
269
+ # General query
270
+ formatted_query = query.replace(' ', '+')
271
+ formatted_query = f'all:{formatted_query}'
272
+
273
+ print(f"Searching ArXiv with query: {formatted_query}")
274
+ url = f"http://export.arxiv.org/api/query?search_query={formatted_query}&start=0&max_results=5"
275
+
276
+ response = requests.get(url)
277
+ if response.status_code != 200:
278
+ return "Error fetching data from ArXiv"
279
+
280
+ # Parse response
281
+ import xml.etree.ElementTree as ET
282
+ root = ET.fromstring(response.text)
283
+
284
+ results = []
285
+ ns = {'atom': 'http://www.w3.org/2005/Atom'}
286
+
287
+ # Count total entries
288
+ total_entries = len(root.findall('atom:entry', ns))
289
+ print(f"Found {total_entries} papers on ArXiv")
290
+
291
+ # Clear previous sources and add new ones
292
+ ARXIV_SOURCES.clear()
293
+
294
+ for entry in root.findall('atom:entry', ns):
295
+ title = entry.find('atom:title', ns).text
296
+ authors = [author.find('atom:name', ns).text for author in entry.findall('atom:author', ns)]
297
+ summary = entry.find('atom:summary', ns).text
298
+ link = entry.find('atom:id', ns).text
299
+
300
+ # Add to global sources list
301
+ ARXIV_SOURCES.append({
302
+ "title": title,
303
+ "authors": ", ".join(authors),
304
+ "type": "arxiv"
305
+ })
306
+
307
+ results.append({
308
+ "title": title,
309
+ "authors": ", ".join(authors),
310
+ "summary": summary,
311
+ "link": link
312
+ })
313
+
314
+ if not results:
315
+ return "No papers found on ArXiv matching the query"
316
+
317
+ # Format results as text
318
+ text_results = []
319
+ for i, paper in enumerate(results):
320
+ text_results.append(f"Paper {i+1}:\nTitle: {paper['title']}\nAuthors: {paper['authors']}\nSummary: {paper['summary'][:300]}...\nLink: {paper['link']}\n")
321
+
322
+ return "\n".join(text_results)
323
+ except Exception as e:
324
+ return f"Error searching ArXiv: {str(e)}"
325
+
326
+ def setup_agent():
327
+ """Set up the agent with tools."""
328
+ agent_model = get_agent_model()
329
+ tools = [search_documents, search_with_rephrased_query, search_arxiv]
330
+ prompt = ChatPromptTemplate.from_messages([
331
+ ("system", AGENT_PROMPT),
332
+ ("human", "{input}"),
333
+ ("ai", "{agent_scratchpad}")
334
+ ])
335
+
336
+ agent = create_openai_tools_agent(agent_model, tools, prompt)
337
+ executor = AgentExecutor(
338
+ agent=agent,
339
+ tools=tools,
340
+ verbose=True,
341
+ handle_parsing_errors=True
342
+ )
343
+
344
+ return executor
345
+
346
+ # Streamlit UI
347
+ st.set_page_config(
348
+ page_title="AB Testing RAG Agent",
349
+ page_icon="๐Ÿ“Š",
350
+ layout="wide"
351
+ )
352
+
353
+ st.title("๐Ÿ“Š AB Testing RAG Agent")
354
+ st.markdown("""
355
+ This specialized agent can answer questions about A/B Testing using a collection of Ron Kohavi's work. If it can't fully answer your A/B Testing questions using this collection, it will then automatically search Arxiv. Let's begin!
356
+ """)
357
+
358
+ # Initialize chat history
359
+ if "messages" not in st.session_state:
360
+ st.session_state.messages = []
361
+
362
+ # Display chat history
363
+ for message in st.session_state.messages:
364
+ with st.chat_message(message["role"]):
365
+ st.markdown(message["content"])
366
+
367
+ # Display sources if available
368
+ if "sources" in message and message["sources"]:
369
+ st.markdown("#### Sources")
370
+ for i, source in enumerate(message["sources"]):
371
+ title = source.get("title", "Unknown")
372
+
373
+ # Display differently based on source type
374
+ if source.get("type") == "arxiv":
375
+ authors = source.get("authors", "Unknown authors")
376
+ st.markdown(f"**{i+1}. {title}**\nAuthors: {authors}")
377
+ else:
378
+ # PDF source with page number
379
+ page = source.get("page", "Unknown")
380
+ st.markdown(f"**{i+1}. {title}** (Page: {page})")
381
+
382
+ # Input for new question
383
+ query = st.chat_input("Ask a question about A/B Testing")
384
+
385
+ if query:
386
+ # Add user message to chat history
387
+ st.session_state.messages.append({"role": "user", "content": query})
388
+
389
+ # Display user message
390
+ with st.chat_message("user"):
391
+ st.markdown(query)
392
+
393
+ # Display assistant response
394
+ with st.chat_message("assistant"):
395
+ message_placeholder = st.empty()
396
+
397
+ with st.status("Processing your query...", expanded=True) as status:
398
+ # Try initial RAG approach first (matching original architecture)
399
+ st.write("Attempting initial search...")
400
+ documents, sources = retrieve_documents(query)
401
+
402
+ # If no documents found, try rephrasing right away
403
+ if not documents:
404
+ st.write("No relevant documents found, trying with rephrased query...")
405
+ rephrased_query = rephrase_query(query)
406
+ st.write(f"Rephrased query: {rephrased_query}")
407
+ documents, sources = retrieve_documents(rephrased_query)
408
+
409
+ # Format documents into a string for context
410
+ context = "\n\n".join([doc.page_content for doc in documents])
411
+ st.write(f"Retrieved {len(documents)} documents")
412
+
413
+ # If we have context, try the initial RAG approach
414
+ if context:
415
+ st.write("Generating initial answer...")
416
+ initial_answer = generate_answer(context, query)
417
+
418
+ # Evaluate if the initial answer is sufficient
419
+ st.write("Evaluating answer quality...")
420
+ is_sufficient = evaluate_response(query, initial_answer)
421
+
422
+ if is_sufficient:
423
+ # If the initial answer is good, use it
424
+ st.write("Initial answer is sufficient")
425
+ answer = initial_answer
426
+ else:
427
+ # If not sufficient, use the agent with tools
428
+ st.write("Initial answer needs improvement, using advanced tools...")
429
+ agent = setup_agent()
430
+ agent_response = agent.invoke({"input": query})
431
+ answer = agent_response["output"]
432
+
433
+ # Check if the agent used ArXiv and has sources
434
+ if ARXIV_SOURCES:
435
+ st.write(f"Found {len(ARXIV_SOURCES)} ArXiv sources")
436
+ # Replace sources with ArXiv sources
437
+ sources = ARXIV_SOURCES
438
+ else:
439
+ # If no context at all, use the agent directly
440
+ st.write("No relevant documents found, using advanced tools...")
441
+ agent = setup_agent()
442
+ agent_response = agent.invoke({"input": query})
443
+ answer = agent_response["output"]
444
+
445
+ # Check if ArXiv sources are available
446
+ if ARXIV_SOURCES:
447
+ st.write(f"Found {len(ARXIV_SOURCES)} ArXiv sources")
448
+ # Replace sources with ArXiv sources
449
+ sources = ARXIV_SOURCES
450
+
451
+ status.update(label="Completed!", state="complete", expanded=False)
452
+
453
+ # Display answer
454
+ message_placeholder.markdown(answer)
455
+
456
+ # Display sources directly in the message (if available)
457
+ if sources:
458
+ st.markdown("#### Sources")
459
+ for i, source in enumerate(sources):
460
+ title = source.get("title", "Unknown")
461
+
462
+ # Display differently based on source type
463
+ if source.get("type") == "arxiv":
464
+ authors = source.get("authors", "Unknown authors")
465
+ st.markdown(f"**{i+1}. {title}**\nAuthors: {authors}")
466
+ else:
467
+ # PDF source with page number
468
+ page = source.get("page", "Unknown")
469
+ st.markdown(f"**{i+1}. {title}** (Page: {page})")
470
+
471
+ # Add assistant message to chat history with sources
472
+ st.session_state.messages.append({
473
+ "role": "assistant",
474
+ "content": answer,
475
+ "sources": sources if sources else []
476
+ })