From 94d907b8b978a0cfc1b12cbb90b009e08fb046c3 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Mon, 3 Jul 2023 11:11:23 +0530
Subject: [PATCH 01/36] add WIP for preparatory refactor
---
knowledge_gpt/{utils => }/QA.py | 2 +-
knowledge_gpt/{utils => core}/__init__.py | 0
knowledge_gpt/core/chunking.py | 32 ++++++++
knowledge_gpt/core/embedding.py | 25 ++++++
knowledge_gpt/core/parsing.py | 73 ++++++++++++++++++
knowledge_gpt/{ => core}/prompts.py | 0
knowledge_gpt/core/qa.py | 47 ++++++++++++
knowledge_gpt/main.py | 94 ++++++++++-------------
knowledge_gpt/{utils/UI.py => ui.py} | 6 +-
tests/test_utils.py | 2 +-
10 files changed, 225 insertions(+), 56 deletions(-)
rename knowledge_gpt/{utils => }/QA.py (98%)
rename knowledge_gpt/{utils => core}/__init__.py (100%)
create mode 100644 knowledge_gpt/core/chunking.py
create mode 100644 knowledge_gpt/core/embedding.py
create mode 100644 knowledge_gpt/core/parsing.py
rename knowledge_gpt/{ => core}/prompts.py (100%)
create mode 100644 knowledge_gpt/core/qa.py
rename knowledge_gpt/{utils/UI.py => ui.py} (72%)
diff --git a/knowledge_gpt/utils/QA.py b/knowledge_gpt/QA.py
similarity index 98%
rename from knowledge_gpt/utils/QA.py
rename to knowledge_gpt/QA.py
index 1cc8e835..32b68286 100644
--- a/knowledge_gpt/utils/QA.py
+++ b/knowledge_gpt/QA.py
@@ -14,7 +14,7 @@
from pypdf import PdfReader
from langchain.embeddings import OpenAIEmbeddings
-from knowledge_gpt.prompts import STUFF_PROMPT
+from knowledge_gpt.core.prompts import STUFF_PROMPT
from hashlib import md5
diff --git a/knowledge_gpt/utils/__init__.py b/knowledge_gpt/core/__init__.py
similarity index 100%
rename from knowledge_gpt/utils/__init__.py
rename to knowledge_gpt/core/__init__.py
diff --git a/knowledge_gpt/core/chunking.py b/knowledge_gpt/core/chunking.py
new file mode 100644
index 00000000..f12cfd0d
--- /dev/null
+++ b/knowledge_gpt/core/chunking.py
@@ -0,0 +1,32 @@
+from langchain.docstore.document import Document
+from langchain.text_splitter import RecursiveCharacterTextSplitter
+from knowledge_gpt.core.parsing import File
+
+
+def chunk_file(file: File, chunk_size: int, chunk_overlap: int = 0) -> File:
+ """Chunks each document in a file into smaller documents"""
+
+ # split each document into chunks
+ chunked_docs = []
+ for doc in file.docs:
+ text_splitter = RecursiveCharacterTextSplitter(
+ chunk_size=chunk_size,
+ separators=["\n\n", "\n", ".", "!", "?", ",", " ", ""],
+ chunk_overlap=chunk_overlap,
+ )
+
+ chunks = text_splitter.split_text(doc.page_content)
+
+ for i, chunk in enumerate(chunks):
+ doc = Document(
+ page_content=chunk,
+ metadata={
+ "page": doc.metadata.get("page", 1),
+ "chunk": i,
+ "source": f"{doc.metadata.get('page', 1)}-{i}",
+ },
+ )
+ chunked_docs.append(doc)
+
+ file.docs = chunked_docs
+ return file
diff --git a/knowledge_gpt/core/embedding.py b/knowledge_gpt/core/embedding.py
new file mode 100644
index 00000000..acec74be
--- /dev/null
+++ b/knowledge_gpt/core/embedding.py
@@ -0,0 +1,25 @@
+from langchain.vectorstores import VectorStore
+from knowledge_gpt.core.parsing import File
+from langchain.vectorstores.faiss import FAISS
+from langchain.embeddings import OpenAIEmbeddings
+from typing import Literal
+
+
+def embed_docs(
+ file: File, embeddings: Literal["openai"], vector_store: Literal["faiss"]
+) -> VectorStore:
+ """Embeds a File and returns the vector store"""
+ if embeddings == "openai":
+ embedding_model = OpenAIEmbeddings() # type: ignore
+ else:
+ raise NotImplementedError
+
+ if vector_store == "faiss":
+ index = FAISS.from_documents(
+ documents=file.docs,
+ embedding=embedding_model,
+ )
+ else:
+ raise NotImplementedError
+
+ return index
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
new file mode 100644
index 00000000..c88cfadf
--- /dev/null
+++ b/knowledge_gpt/core/parsing.py
@@ -0,0 +1,73 @@
+import re
+from io import BytesIO
+from typing import List
+
+import docx2txt
+from langchain.docstore.document import Document
+from pypdf import PdfReader
+from pydantic import BaseModel
+
+
+class File(BaseModel):
+ """Represents an uploaded file comprised of Documents"""
+
+ name: str
+ metadata: dict[str, str | float | int] = {}
+ docs: List[Document] = []
+
+
+def parse_docx(file: BytesIO) -> str:
+ text = docx2txt.process(file)
+ # Remove multiple newlines
+ text = re.sub(r"\n\s*\n", "\n\n", text)
+ return text
+
+
+def parse_pdf(file: BytesIO) -> List[str]:
+ pdf = PdfReader(file)
+ output = []
+ for page in pdf.pages:
+ text = page.extract_text()
+ # Merge hyphenated words
+ text = re.sub(r"(\w+)-\n(\w+)", r"\1\2", text)
+ # Fix newlines in the middle of sentences
+ text = re.sub(r"(? str:
+ text = file.read().decode("utf-8")
+ # Remove multiple newlines
+ text = re.sub(r"\n\s*\n", "\n\n", text)
+ return text
+
+
+def to_file(uploaded_file: BytesIO) -> File:
+ """Parses an uploaded file and returns a File object with Documents"""
+ docs = []
+ file = File(name=uploaded_file.name)
+
+ if uploaded_file.name.endswith(".pdf"):
+ texts = parse_pdf(uploaded_file)
+ for i, text in enumerate(texts):
+ doc = Document(page_content=text)
+ doc.metadata["page"] = i + 1
+ docs.append(doc)
+
+ elif uploaded_file.name.endswith(".docx"):
+ # No page numbers for docx
+ text = parse_docx(uploaded_file)
+ docs = [Document(page_content=text)]
+
+ elif uploaded_file.name.endswith(".txt"):
+ # No page numbers for txt
+ text = parse_txt(uploaded_file)
+ docs = [Document(page_content=text)]
+
+ file.docs = docs
+ return file
diff --git a/knowledge_gpt/prompts.py b/knowledge_gpt/core/prompts.py
similarity index 100%
rename from knowledge_gpt/prompts.py
rename to knowledge_gpt/core/prompts.py
diff --git a/knowledge_gpt/core/qa.py b/knowledge_gpt/core/qa.py
new file mode 100644
index 00000000..79614629
--- /dev/null
+++ b/knowledge_gpt/core/qa.py
@@ -0,0 +1,47 @@
+from typing import Any, Dict, List, Tuple
+from knowledge_gpt.core.parsing import File
+from langchain.chains.qa_with_sources import load_qa_with_sources_chain
+from knowledge_gpt.core.prompts import STUFF_PROMPT
+from langchain.docstore.document import Document
+from typing import Literal
+from langchain.chat_models import ChatOpenAI
+from langchain.vectorstores import VectorStore
+
+
+def get_answer(
+ query: str, model: Literal["openai"], index: VectorStore
+) -> Tuple[Dict[str, Any], List[Document]]:
+ """Gets an answer to a question from a file."""
+
+ if model == "openai":
+ _model = ChatOpenAI(temperature=0) # type: ignore
+ else:
+ raise NotImplementedError
+
+ # Get the answer
+ chain = load_qa_with_sources_chain(
+ llm=_model,
+ chain_type="stuff",
+ prompt=STUFF_PROMPT,
+ )
+
+ relevant_docs = index.similarity_search(query, k=5)
+
+ answer = chain(
+ {"input_documents": relevant_docs, "question": query}, return_only_outputs=True
+ )
+ return answer, relevant_docs
+
+
+def get_sources(answer: Dict[str, Any], file: File) -> List[Document]:
+ """Gets the source documents for an answer."""
+
+ # Get sources for the answer
+ source_keys = [s for s in answer["output_text"].split("SOURCES: ")[-1].split(", ")]
+
+ source_docs = []
+ for doc in file.docs:
+ if doc.metadata["source"] in source_keys:
+ source_docs.append(doc)
+
+ return source_docs
diff --git a/knowledge_gpt/main.py b/knowledge_gpt/main.py
index 18a0ee8b..2bb64548 100644
--- a/knowledge_gpt/main.py
+++ b/knowledge_gpt/main.py
@@ -1,20 +1,13 @@
import streamlit as st
-from openai.error import OpenAIError
from knowledge_gpt.components.sidebar import sidebar
-from knowledge_gpt.utils.QA import (
- embed_docs,
- get_answer,
- get_sources,
- parse_file,
- text_to_docs,
-)
-
-from knowledge_gpt.utils.UI import wrap_text_in_html, is_valid
+from knowledge_gpt.ui import wrap_doc_in_html, is_valid
-def clear_submit():
- st.session_state["submit"] = False
+from knowledge_gpt.core.parsing import to_file
+from knowledge_gpt.core.chunking import chunk_file
+from knowledge_gpt.core.embedding import embed_docs
+from knowledge_gpt.core.qa import get_answer, get_sources
st.set_page_config(page_title="KnowledgeGPT", page_icon="📖", layout="wide")
@@ -26,58 +19,55 @@ def clear_submit():
"Upload a pdf, docx, or txt file",
type=["pdf", "docx", "txt"],
help="Scanned documents are not supported yet!",
- on_change=clear_submit,
)
-index = None
-texts = None
-if uploaded_file is not None:
- texts = parse_file(uploaded_file)
- docs = text_to_docs(texts)
+if not uploaded_file:
+ st.stop()
+
+
+file = to_file(uploaded_file)
+file = chunk_file(file, chunk_size=800, chunk_overlap=0)
- try:
- with st.spinner("Indexing document... This may take a while⏳"):
- index = embed_docs(docs)
- except OpenAIError as e:
- st.error(e._message)
-query = st.text_area("Ask a question about the document", on_change=clear_submit)
+with st.spinner("Indexing document... This may take a while⏳"):
+ index = embed_docs(file=file, embeddings="openai", vector_store="faiss")
+
+with st.form(key="qa_form"):
+ query = st.text_area("Ask a question about the document")
+ submit = st.form_submit_button("Submit")
+
with st.expander("Advanced Options"):
show_all_chunks = st.checkbox("Show all chunks retrieved from vector search")
show_full_doc = st.checkbox("Show parsed contents of the document")
-if show_full_doc and texts:
+if not is_valid(index, query):
+ st.stop()
+
+if show_full_doc:
with st.expander("Document"):
# Hack to get around st.markdown rendering LaTeX
- st.markdown(f"
{wrap_text_in_html(texts)}
", unsafe_allow_html=True)
+ st.markdown(f"{wrap_doc_in_html(file.docs)}
", unsafe_allow_html=True)
-button = st.button("Submit")
-if button or st.session_state.get("submit"):
- if not is_valid(index, query):
- st.stop()
- st.session_state["submit"] = True
+if submit:
# Output Columns
answer_col, sources_col = st.columns(2)
- sources = index.similarity_search(query, k=5) # type: ignore
-
- try:
- answer = get_answer(sources, query)
- if not show_all_chunks:
- # Get the sources for the answer
- sources = get_sources(answer, sources)
-
- with answer_col:
- st.markdown("#### Answer")
- st.markdown(answer["output_text"].split("SOURCES: ")[0])
-
- with sources_col:
- st.markdown("#### Sources")
- for source in sources:
- st.markdown(source.page_content)
- st.markdown(source.metadata["source"])
- st.markdown("---")
-
- except OpenAIError as e:
- st.error(e._message)
+
+ answer, relevant_docs = get_answer(query, model="openai", index=index)
+ if not show_all_chunks:
+ # Get the sources for the answer
+ sources = get_sources(answer, file)
+ else:
+ sources = relevant_docs
+
+ with answer_col:
+ st.markdown("#### Answer")
+ st.markdown(answer["output_text"].split("SOURCES: ")[0])
+
+ with sources_col:
+ st.markdown("#### Sources")
+ for source in sources:
+ st.markdown(source.page_content)
+ st.markdown(source.metadata["source"])
+ st.markdown("---")
diff --git a/knowledge_gpt/utils/UI.py b/knowledge_gpt/ui.py
similarity index 72%
rename from knowledge_gpt/utils/UI.py
rename to knowledge_gpt/ui.py
index 5635272e..33447474 100644
--- a/knowledge_gpt/utils/UI.py
+++ b/knowledge_gpt/ui.py
@@ -1,9 +1,11 @@
from typing import List
import streamlit as st
+from langchain.docstore.document import Document
-def wrap_text_in_html(text: str | List[str]) -> str:
- """Wraps each text block separated by newlines in tags"""
+def wrap_doc_in_html(docs: List[Document]) -> str:
+ """Wraps each page in document separated by newlines in
tags"""
+ text = [doc.page_content for doc in docs]
if isinstance(text, list):
# Add horizontal rules between pages
text = "\n
\n".join(text)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6636adb4..dbb4e0ee 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,6 @@
from langchain.docstore.document import Document
-from knowledge_gpt.utils.QA import get_sources
+from knowledge_gpt.QA import get_sources
def test_get_sources():
From 826aaf48f08ab8a664ef030241f4137a1eb4cc1f Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Mon, 3 Jul 2023 11:33:03 +0530
Subject: [PATCH 02/36] pass API key as kwargs to models
---
knowledge_gpt/components/sidebar.py | 7 +------
knowledge_gpt/core/embedding.py | 9 ++++++---
knowledge_gpt/core/qa.py | 4 ++--
knowledge_gpt/main.py | 23 +++++++++++++++++++++--
4 files changed, 30 insertions(+), 13 deletions(-)
diff --git a/knowledge_gpt/components/sidebar.py b/knowledge_gpt/components/sidebar.py
index 22ba156e..8461d029 100644
--- a/knowledge_gpt/components/sidebar.py
+++ b/knowledge_gpt/components/sidebar.py
@@ -7,10 +7,6 @@
load_dotenv()
-def set_openai_api_key(api_key: str):
- st.session_state["OPENAI_API_KEY"] = api_key
-
-
def sidebar():
with st.sidebar:
st.markdown(
@@ -28,8 +24,7 @@ def sidebar():
or st.session_state.get("OPENAI_API_KEY", ""),
)
- if api_key_input:
- set_openai_api_key(api_key_input)
+ st.session_state["OPENAI_API_KEY"] = api_key_input
st.markdown("---")
st.markdown("# About")
diff --git a/knowledge_gpt/core/embedding.py b/knowledge_gpt/core/embedding.py
index acec74be..39f2217c 100644
--- a/knowledge_gpt/core/embedding.py
+++ b/knowledge_gpt/core/embedding.py
@@ -2,15 +2,18 @@
from knowledge_gpt.core.parsing import File
from langchain.vectorstores.faiss import FAISS
from langchain.embeddings import OpenAIEmbeddings
-from typing import Literal
+from typing import Literal, Any
def embed_docs(
- file: File, embeddings: Literal["openai"], vector_store: Literal["faiss"]
+ file: File,
+ embeddings: Literal["openai"],
+ vector_store: Literal["faiss"],
+ **kwargs: Any
) -> VectorStore:
"""Embeds a File and returns the vector store"""
if embeddings == "openai":
- embedding_model = OpenAIEmbeddings() # type: ignore
+ embedding_model = OpenAIEmbeddings(**kwargs) # type: ignore
else:
raise NotImplementedError
diff --git a/knowledge_gpt/core/qa.py b/knowledge_gpt/core/qa.py
index 79614629..f2d9bad7 100644
--- a/knowledge_gpt/core/qa.py
+++ b/knowledge_gpt/core/qa.py
@@ -9,12 +9,12 @@
def get_answer(
- query: str, model: Literal["openai"], index: VectorStore
+ query: str, model: Literal["openai"], index: VectorStore, **kwargs: Any
) -> Tuple[Dict[str, Any], List[Document]]:
"""Gets an answer to a question from a file."""
if model == "openai":
- _model = ChatOpenAI(temperature=0) # type: ignore
+ _model = ChatOpenAI(**kwargs) # type: ignore
else:
raise NotImplementedError
diff --git a/knowledge_gpt/main.py b/knowledge_gpt/main.py
index 2bb64548..357d755f 100644
--- a/knowledge_gpt/main.py
+++ b/knowledge_gpt/main.py
@@ -13,6 +13,14 @@
st.set_page_config(page_title="KnowledgeGPT", page_icon="📖", layout="wide")
st.header("📖KnowledgeGPT")
+openai_api_key = st.session_state.get("OPENAI_API_KEY")
+
+if not openai_api_key:
+ st.warning(
+ "Enter your OpenAI API key in the sidebar. You can get a key at"
+ " https://platform.openai.com/account/api-keys."
+ )
+
sidebar()
uploaded_file = st.file_uploader(
@@ -30,7 +38,12 @@
with st.spinner("Indexing document... This may take a while⏳"):
- index = embed_docs(file=file, embeddings="openai", vector_store="faiss")
+ index = embed_docs(
+ file=file,
+ embeddings="openai",
+ vector_store="faiss",
+ openai_api_key=openai_api_key,
+ )
with st.form(key="qa_form"):
query = st.text_area("Ask a question about the document")
@@ -54,7 +67,13 @@
# Output Columns
answer_col, sources_col = st.columns(2)
- answer, relevant_docs = get_answer(query, model="openai", index=index)
+ answer, relevant_docs = get_answer(
+ query,
+ model="openai",
+ index=index,
+ openai_api_key=openai_api_key,
+ temperature=0,
+ )
if not show_all_chunks:
# Get the sources for the answer
sources = get_sources(answer, file)
From d777ca14a73ea9e973fa39462631f9b7ab81e7b8 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Mon, 3 Jul 2023 12:12:21 +0530
Subject: [PATCH 03/36] fix api key warning when key is set via env vars
---
knowledge_gpt/main.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/knowledge_gpt/main.py b/knowledge_gpt/main.py
index 357d755f..2c2b4788 100644
--- a/knowledge_gpt/main.py
+++ b/knowledge_gpt/main.py
@@ -13,15 +13,17 @@
st.set_page_config(page_title="KnowledgeGPT", page_icon="📖", layout="wide")
st.header("📖KnowledgeGPT")
+sidebar()
+
openai_api_key = st.session_state.get("OPENAI_API_KEY")
+
if not openai_api_key:
st.warning(
"Enter your OpenAI API key in the sidebar. You can get a key at"
" https://platform.openai.com/account/api-keys."
)
-sidebar()
uploaded_file = st.file_uploader(
"Upload a pdf, docx, or txt file",
From 8ef4a6de1282db926a4ce63ac3f982b962e76a75 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Mon, 3 Jul 2023 14:33:51 +0530
Subject: [PATCH 04/36] purge obsolete code post-refactor
---
knowledge_gpt/QA.py | 158 --------------------------------------------
1 file changed, 158 deletions(-)
delete mode 100644 knowledge_gpt/QA.py
diff --git a/knowledge_gpt/QA.py b/knowledge_gpt/QA.py
deleted file mode 100644
index 32b68286..00000000
--- a/knowledge_gpt/QA.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import re
-from io import BytesIO
-from typing import Any, Dict, List
-
-import docx2txt
-import streamlit as st
-from langchain.chains.qa_with_sources import load_qa_with_sources_chain
-from langchain.docstore.document import Document
-from langchain.chat_models import ChatOpenAI
-from langchain.text_splitter import RecursiveCharacterTextSplitter
-from langchain.vectorstores import VectorStore
-from langchain.vectorstores.faiss import FAISS
-from openai.error import AuthenticationError
-from pypdf import PdfReader
-
-from langchain.embeddings import OpenAIEmbeddings
-from knowledge_gpt.core.prompts import STUFF_PROMPT
-
-from hashlib import md5
-
-
-def hash_func(doc: Document) -> str:
- """Hash function for caching Documents"""
- return md5(doc.page_content.encode("utf-8")).hexdigest()
-
-
-@st.cache_data()
-def parse_docx(file: BytesIO) -> str:
- text = docx2txt.process(file)
- # Remove multiple newlines
- text = re.sub(r"\n\s*\n", "\n\n", text)
- return text
-
-
-@st.cache_data()
-def parse_pdf(file: BytesIO) -> List[str]:
- pdf = PdfReader(file)
- output = []
- for page in pdf.pages:
- text = page.extract_text()
- # Merge hyphenated words
- text = re.sub(r"(\w+)-\n(\w+)", r"\1\2", text)
- # Fix newlines in the middle of sentences
- text = re.sub(r"(? str:
- text = file.read().decode("utf-8")
- # Remove multiple newlines
- text = re.sub(r"\n\s*\n", "\n\n", text)
- return text
-
-
-@st.cache_data()
-def text_to_docs(text: str | List[str]) -> List[Document]:
- """Converts a string or list of strings to a list of Documents
- with metadata."""
- if isinstance(text, str):
- # Take a single string as one page
- text = [text]
- page_docs = [Document(page_content=page) for page in text]
-
- # Add page numbers as metadata
- for i, doc in enumerate(page_docs):
- doc.metadata["page"] = i + 1
-
- # Split pages into chunks
- doc_chunks = []
-
- for doc in page_docs:
- text_splitter = RecursiveCharacterTextSplitter(
- chunk_size=800,
- separators=["\n\n", "\n", ".", "!", "?", ",", " ", ""],
- chunk_overlap=0,
- )
- chunks = text_splitter.split_text(doc.page_content)
- for i, chunk in enumerate(chunks):
- doc = Document(
- page_content=chunk, metadata={"page": doc.metadata["page"], "chunk": i}
- )
- # Add sources a metadata
- doc.metadata["source"] = f"{doc.metadata['page']}-{doc.metadata['chunk']}"
- doc_chunks.append(doc)
- return doc_chunks
-
-
-@st.cache_data()
-def parse_file(file: BytesIO) -> str | List[str]:
- """Parses a file and returns a list of Documents."""
- if file.name.endswith(".pdf"):
- return parse_pdf(file)
- elif file.name.endswith(".docx"):
- return parse_docx(file)
- elif file.name.endswith(".txt"):
- return parse_txt(file)
- else:
- raise ValueError("File type not supported!")
-
-
-@st.cache_data(show_spinner=False, hash_funcs={Document: hash_func})
-def embed_docs(docs: List[Document]) -> VectorStore:
- """Embeds a list of Documents and returns a FAISS index"""
-
- if not st.session_state.get("OPENAI_API_KEY"):
- raise AuthenticationError(
- "Enter your OpenAI API key in the sidebar. You can get a key at"
- " https://platform.openai.com/account/api-keys."
- )
- else:
- # Embed the chunks
- embeddings = OpenAIEmbeddings(
- openai_api_key=st.session_state.get("OPENAI_API_KEY"),
- ) # type: ignore
-
- index = FAISS.from_documents(docs, embeddings)
-
- return index
-
-
-@st.cache_data(show_spinner=False, hash_funcs={Document: hash_func})
-def get_answer(docs: List[Document], query: str) -> Dict[str, Any]:
- """Gets an answer to a question from a list of Documents."""
-
- # Get the answer
- chain = load_qa_with_sources_chain(
- ChatOpenAI(
- temperature=0, openai_api_key=st.session_state.get("OPENAI_API_KEY")
- ), # type: ignore
- chain_type="stuff",
- prompt=STUFF_PROMPT,
- )
-
- answer = chain(
- {"input_documents": docs, "question": query}, return_only_outputs=True
- )
- return answer
-
-
-@st.cache_data(show_spinner=False, hash_funcs={Document: hash_func})
-def get_sources(answer: Dict[str, Any], docs: List[Document]) -> List[Document]:
- """Gets the source documents for an answer."""
-
- # Get sources for the answer
- source_keys = [s for s in answer["output_text"].split("SOURCES: ")[-1].split(", ")]
-
- source_docs = []
- for doc in docs:
- if doc.metadata["source"] in source_keys:
- source_docs.append(doc)
-
- return source_docs
From 8fabbba5e88dd4a70844f7bf5cfeaf17ea947d86 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Mon, 3 Jul 2023 15:45:54 +0530
Subject: [PATCH 05/36] check input validation after submitting
---
knowledge_gpt/main.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/knowledge_gpt/main.py b/knowledge_gpt/main.py
index 2c2b4788..4d705fdf 100644
--- a/knowledge_gpt/main.py
+++ b/knowledge_gpt/main.py
@@ -56,8 +56,6 @@
show_all_chunks = st.checkbox("Show all chunks retrieved from vector search")
show_full_doc = st.checkbox("Show parsed contents of the document")
-if not is_valid(index, query):
- st.stop()
if show_full_doc:
with st.expander("Document"):
@@ -66,6 +64,9 @@
if submit:
+ if not is_valid(index, query):
+ st.stop()
+
# Output Columns
answer_col, sources_col = st.columns(2)
From 71490334c2d5da9cef6b902201b37577889d4928 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Mon, 3 Jul 2023 15:53:56 +0530
Subject: [PATCH 06/36] reimplement caching
---
knowledge_gpt/core/cached.py | 29 +++++++++++++++++++++++++++++
knowledge_gpt/core/parsing.py | 6 +++++-
knowledge_gpt/core/qa.py | 8 +++++---
knowledge_gpt/main.py | 14 +++++++++-----
4 files changed, 48 insertions(+), 9 deletions(-)
create mode 100644 knowledge_gpt/core/cached.py
diff --git a/knowledge_gpt/core/cached.py b/knowledge_gpt/core/cached.py
new file mode 100644
index 00000000..32de10e9
--- /dev/null
+++ b/knowledge_gpt/core/cached.py
@@ -0,0 +1,29 @@
+"""Add caching decorators to expensive functions"""
+
+import streamlit as st
+
+from knowledge_gpt.core.parsing import to_file
+from knowledge_gpt.core.chunking import chunk_file
+from knowledge_gpt.core.embedding import embed_docs
+from knowledge_gpt.core.qa import get_answer, get_sources
+from knowledge_gpt.core.parsing import File
+
+
+def file_hash_func(file: File) -> str:
+ """Get a unique hash for a file"""
+ return file.id
+
+
+to_file = st.cache_data(show_spinner=False)(to_file)
+chunk_file = st.cache_data(show_spinner=False, hash_funcs={File: file_hash_func})(
+ chunk_file
+)
+embed_docs = st.cache_data(show_spinner=False, hash_funcs={File: file_hash_func})(
+ embed_docs
+)
+get_answer = st.cache_data(show_spinner=False, hash_funcs={File: file_hash_func})(
+ get_answer
+)
+get_sources = st.cache_data(show_spinner=False, hash_funcs={File: file_hash_func})(
+ get_sources
+)
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
index c88cfadf..79735eca 100644
--- a/knowledge_gpt/core/parsing.py
+++ b/knowledge_gpt/core/parsing.py
@@ -6,12 +6,14 @@
from langchain.docstore.document import Document
from pypdf import PdfReader
from pydantic import BaseModel
+from hashlib import md5
class File(BaseModel):
"""Represents an uploaded file comprised of Documents"""
name: str
+ id: str # unique hash of the file
metadata: dict[str, str | float | int] = {}
docs: List[Document] = []
@@ -50,7 +52,9 @@ def parse_txt(file: BytesIO) -> str:
def to_file(uploaded_file: BytesIO) -> File:
"""Parses an uploaded file and returns a File object with Documents"""
docs = []
- file = File(name=uploaded_file.name)
+ id = md5(uploaded_file.read()).hexdigest()
+ uploaded_file.seek(0)
+ file = File(name=uploaded_file.name, id=id)
if uploaded_file.name.endswith(".pdf"):
texts = parse_pdf(uploaded_file)
diff --git a/knowledge_gpt/core/qa.py b/knowledge_gpt/core/qa.py
index f2d9bad7..7d35424e 100644
--- a/knowledge_gpt/core/qa.py
+++ b/knowledge_gpt/core/qa.py
@@ -9,9 +9,11 @@
def get_answer(
- query: str, model: Literal["openai"], index: VectorStore, **kwargs: Any
+ query: str, model: Literal["openai"], _index: VectorStore, file: File, **kwargs: Any
) -> Tuple[Dict[str, Any], List[Document]]:
- """Gets an answer to a question from a file."""
+ """Gets an answer to a question from a file.
+ Even though the file argument is not used, it is needed to cache the function.
+ """
if model == "openai":
_model = ChatOpenAI(**kwargs) # type: ignore
@@ -25,7 +27,7 @@ def get_answer(
prompt=STUFF_PROMPT,
)
- relevant_docs = index.similarity_search(query, k=5)
+ relevant_docs = _index.similarity_search(query, k=5)
answer = chain(
{"input_documents": relevant_docs, "question": query}, return_only_outputs=True
diff --git a/knowledge_gpt/main.py b/knowledge_gpt/main.py
index 4d705fdf..27d49044 100644
--- a/knowledge_gpt/main.py
+++ b/knowledge_gpt/main.py
@@ -4,10 +4,13 @@
from knowledge_gpt.ui import wrap_doc_in_html, is_valid
-from knowledge_gpt.core.parsing import to_file
-from knowledge_gpt.core.chunking import chunk_file
-from knowledge_gpt.core.embedding import embed_docs
-from knowledge_gpt.core.qa import get_answer, get_sources
+from knowledge_gpt.core.cached import (
+ to_file,
+ embed_docs,
+ get_answer,
+ get_sources,
+ chunk_file,
+)
st.set_page_config(page_title="KnowledgeGPT", page_icon="📖", layout="wide")
@@ -73,7 +76,8 @@
answer, relevant_docs = get_answer(
query,
model="openai",
- index=index,
+ _index=index,
+ file=file,
openai_api_key=openai_api_key,
temperature=0,
)
From bf401c9cfedb0c856c529015070e37a34c20d72a Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 09:20:55 +0530
Subject: [PATCH 07/36] remove regex from parsing logic
---
knowledge_gpt/core/parsing.py | 12 ------------
1 file changed, 12 deletions(-)
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
index 79735eca..773d8365 100644
--- a/knowledge_gpt/core/parsing.py
+++ b/knowledge_gpt/core/parsing.py
@@ -1,4 +1,3 @@
-import re
from io import BytesIO
from typing import List
@@ -20,8 +19,6 @@ class File(BaseModel):
def parse_docx(file: BytesIO) -> str:
text = docx2txt.process(file)
- # Remove multiple newlines
- text = re.sub(r"\n\s*\n", "\n\n", text)
return text
@@ -30,13 +27,6 @@ def parse_pdf(file: BytesIO) -> List[str]:
output = []
for page in pdf.pages:
text = page.extract_text()
- # Merge hyphenated words
- text = re.sub(r"(\w+)-\n(\w+)", r"\1\2", text)
- # Fix newlines in the middle of sentences
- text = re.sub(r"(? List[str]:
def parse_txt(file: BytesIO) -> str:
text = file.read().decode("utf-8")
- # Remove multiple newlines
- text = re.sub(r"\n\s*\n", "\n\n", text)
return text
From d7ad43b10634b211214ac9a2c94fa13972e6d045 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 10:40:03 +0530
Subject: [PATCH 08/36] refactor parsing methods
---
knowledge_gpt/core/parsing.py | 90 ++++++++++++++++++-----------------
1 file changed, 46 insertions(+), 44 deletions(-)
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
index 773d8365..2ab9edec 100644
--- a/knowledge_gpt/core/parsing.py
+++ b/knowledge_gpt/core/parsing.py
@@ -1,65 +1,67 @@
from io import BytesIO
-from typing import List
+from typing import List, Any
import docx2txt
from langchain.docstore.document import Document
from pypdf import PdfReader
-from pydantic import BaseModel
from hashlib import md5
+from dataclasses import dataclass
+from abc import abstractmethod
-class File(BaseModel):
+
+@dataclass(frozen=True)
+class File:
"""Represents an uploaded file comprised of Documents"""
name: str
id: str # unique hash of the file
- metadata: dict[str, str | float | int] = {}
+ metadata: dict[str, Any] = {}
docs: List[Document] = []
-
-def parse_docx(file: BytesIO) -> str:
- text = docx2txt.process(file)
- return text
-
-
-def parse_pdf(file: BytesIO) -> List[str]:
- pdf = PdfReader(file)
- output = []
- for page in pdf.pages:
- text = page.extract_text()
- output.append(text)
-
- return output
+ @classmethod
+ @abstractmethod
+ def from_bytes(cls, file: BytesIO) -> "File":
+ """Creates a File from a BytesIO object"""
-def parse_txt(file: BytesIO) -> str:
- text = file.read().decode("utf-8")
- return text
+class DocxFile(File):
+ @classmethod
+ def from_bytes(cls, file: BytesIO) -> "DocxFile":
+ text = docx2txt.process(file)
+ doc = Document(page_content=text)
+ return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=[doc])
-def to_file(uploaded_file: BytesIO) -> File:
- """Parses an uploaded file and returns a File object with Documents"""
- docs = []
- id = md5(uploaded_file.read()).hexdigest()
- uploaded_file.seek(0)
- file = File(name=uploaded_file.name, id=id)
-
- if uploaded_file.name.endswith(".pdf"):
- texts = parse_pdf(uploaded_file)
- for i, text in enumerate(texts):
+class PdfFile(File):
+ @classmethod
+ def from_bytes(cls, file: BytesIO) -> "PdfFile":
+ pdf = PdfReader(file)
+ docs = []
+ for i, page in enumerate(pdf.pages):
+ text = page.extract_text()
doc = Document(page_content=text)
doc.metadata["page"] = i + 1
docs.append(doc)
-
- elif uploaded_file.name.endswith(".docx"):
- # No page numbers for docx
- text = parse_docx(uploaded_file)
- docs = [Document(page_content=text)]
-
- elif uploaded_file.name.endswith(".txt"):
- # No page numbers for txt
- text = parse_txt(uploaded_file)
- docs = [Document(page_content=text)]
-
- file.docs = docs
- return file
+ return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=docs)
+
+
+class TxtFile(File):
+ @classmethod
+ def from_bytes(cls, file: BytesIO) -> "TxtFile":
+ text = file.read().decode("utf-8")
+ file.seek(0)
+ doc = Document(page_content=text)
+ return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=[doc])
+
+
+def read_file(file: BytesIO) -> File:
+ """Reads an uploaded file and returns a File object"""
+ if file.name.endswith(".docx"):
+ return DocxFile.from_bytes(file)
+ elif file.name.endswith(".pdf"):
+ return PdfFile.from_bytes(file)
+ elif file.name.endswith(".txt"):
+ return TxtFile.from_bytes(file)
+ else:
+ raise NotImplementedError
From 39d11e735a0c4a4ab04df9d2433c78bbbc42bbdb Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 10:49:24 +0530
Subject: [PATCH 09/36] update .gitignore
---
.gitignore | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index f233bd5f..ab0e45ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
# Local data
-data/local_data/
+resources/local_data/
# Prototyping notebooks
notebooks/
From c5fc3fdeb631cb35f84719ac32df296bc6887723 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 10:50:00 +0530
Subject: [PATCH 10/36] rename directory data to resources
---
{data => resources}/employment_agreement.pdf | Bin
{data => resources}/paul_graham_essay.docx | Bin
{data => resources}/paul_graham_essay.pdf | Bin
{data => resources}/paul_graham_essay.txt | 0
{data => resources}/questions.md | 0
5 files changed, 0 insertions(+), 0 deletions(-)
rename {data => resources}/employment_agreement.pdf (100%)
rename {data => resources}/paul_graham_essay.docx (100%)
rename {data => resources}/paul_graham_essay.pdf (100%)
rename {data => resources}/paul_graham_essay.txt (100%)
rename {data => resources}/questions.md (100%)
diff --git a/data/employment_agreement.pdf b/resources/employment_agreement.pdf
similarity index 100%
rename from data/employment_agreement.pdf
rename to resources/employment_agreement.pdf
diff --git a/data/paul_graham_essay.docx b/resources/paul_graham_essay.docx
similarity index 100%
rename from data/paul_graham_essay.docx
rename to resources/paul_graham_essay.docx
diff --git a/data/paul_graham_essay.pdf b/resources/paul_graham_essay.pdf
similarity index 100%
rename from data/paul_graham_essay.pdf
rename to resources/paul_graham_essay.pdf
diff --git a/data/paul_graham_essay.txt b/resources/paul_graham_essay.txt
similarity index 100%
rename from data/paul_graham_essay.txt
rename to resources/paul_graham_essay.txt
diff --git a/data/questions.md b/resources/questions.md
similarity index 100%
rename from data/questions.md
rename to resources/questions.md
From 3cf8d984db6d9af089c9486bba17fee218fe8dc7 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 11:15:36 +0530
Subject: [PATCH 11/36] use explicit constructor instead of dataclass
---
knowledge_gpt/core/parsing.py | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
index 2ab9edec..accaa539 100644
--- a/knowledge_gpt/core/parsing.py
+++ b/knowledge_gpt/core/parsing.py
@@ -1,23 +1,28 @@
from io import BytesIO
-from typing import List, Any
+from typing import List, Any, Optional
import docx2txt
from langchain.docstore.document import Document
from pypdf import PdfReader
from hashlib import md5
-from dataclasses import dataclass
-from abc import abstractmethod
+from abc import abstractmethod, ABC
-@dataclass(frozen=True)
-class File:
+class File(ABC):
"""Represents an uploaded file comprised of Documents"""
- name: str
- id: str # unique hash of the file
- metadata: dict[str, Any] = {}
- docs: List[Document] = []
+ def __init__(
+ self,
+ name: str,
+ id: str,
+ metadata: Optional[dict[str, Any]] = None,
+ docs: Optional[List[Document]] = None,
+ ):
+ self.name = name
+ self.id = id
+ self.metadata = metadata or {}
+ self.docs = docs or []
@classmethod
@abstractmethod
From 77aa71858e17695dd8b8d14c9fd7dbbc6b836ecc Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 11:35:34 +0530
Subject: [PATCH 12/36] split whitespace from pages
---
knowledge_gpt/core/parsing.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
index accaa539..177e37bc 100644
--- a/knowledge_gpt/core/parsing.py
+++ b/knowledge_gpt/core/parsing.py
@@ -34,7 +34,7 @@ class DocxFile(File):
@classmethod
def from_bytes(cls, file: BytesIO) -> "DocxFile":
text = docx2txt.process(file)
- doc = Document(page_content=text)
+ doc = Document(page_content=text.strip())
return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=[doc])
@@ -45,7 +45,7 @@ def from_bytes(cls, file: BytesIO) -> "PdfFile":
docs = []
for i, page in enumerate(pdf.pages):
text = page.extract_text()
- doc = Document(page_content=text)
+ doc = Document(page_content=text.strip())
doc.metadata["page"] = i + 1
docs.append(doc)
return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=docs)
@@ -56,7 +56,7 @@ class TxtFile(File):
def from_bytes(cls, file: BytesIO) -> "TxtFile":
text = file.read().decode("utf-8")
file.seek(0)
- doc = Document(page_content=text)
+ doc = Document(page_content=text.strip())
return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=[doc])
From d0544e97e2e90ac1af78d510da82e73b99f017cd Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 11:51:46 +0530
Subject: [PATCH 13/36] strip consecutive newlines from parsed text
---
knowledge_gpt/core/parsing.py | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/knowledge_gpt/core/parsing.py b/knowledge_gpt/core/parsing.py
index 177e37bc..eaa87f1a 100644
--- a/knowledge_gpt/core/parsing.py
+++ b/knowledge_gpt/core/parsing.py
@@ -1,5 +1,6 @@
from io import BytesIO
from typing import List, Any, Optional
+import re
import docx2txt
from langchain.docstore.document import Document
@@ -30,10 +31,18 @@ def from_bytes(cls, file: BytesIO) -> "File":
"""Creates a File from a BytesIO object"""
+def strip_consecutive_newlines(text: str) -> str:
+ """Strips consecutive newlines from a string
+ possibly with whitespace in between
+ """
+ return re.sub(r"\s*\n\s*", "\n", text)
+
+
class DocxFile(File):
@classmethod
def from_bytes(cls, file: BytesIO) -> "DocxFile":
text = docx2txt.process(file)
+ text = strip_consecutive_newlines(text)
doc = Document(page_content=text.strip())
return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=[doc])
@@ -45,6 +54,7 @@ def from_bytes(cls, file: BytesIO) -> "PdfFile":
docs = []
for i, page in enumerate(pdf.pages):
text = page.extract_text()
+ text = strip_consecutive_newlines(text)
doc = Document(page_content=text.strip())
doc.metadata["page"] = i + 1
docs.append(doc)
@@ -55,6 +65,7 @@ class TxtFile(File):
@classmethod
def from_bytes(cls, file: BytesIO) -> "TxtFile":
text = file.read().decode("utf-8")
+ text = strip_consecutive_newlines(text)
file.seek(0)
doc = Document(page_content=text.strip())
return cls(name=file.name, id=md5(file.read()).hexdigest(), docs=[doc])
From 84f5c3b0503ba9c98e324061549ac7fb2706ef61 Mon Sep 17 00:00:00 2001
From: mmz-001 <70096033+mmz-001@users.noreply.github.com>
Date: Tue, 4 Jul 2023 11:52:27 +0530
Subject: [PATCH 14/36] add tests for parsing functions
---
resources/paul_graham_essay.docx | Bin 49207 -> 0 bytes
resources/samples/test_hello.docx | Bin 0 -> 12502 bytes
resources/samples/test_hello.pdf | Bin 0 -> 362861 bytes
resources/samples/test_hello.txt | 1 +
resources/samples/test_hello_multi.docx | Bin 0 -> 12916 bytes
resources/samples/test_hello_multi.pdf | Bin 0 -> 369402 bytes
tests/test_parsing.py | 127 ++++++++++++++++++++++++
7 files changed, 128 insertions(+)
delete mode 100644 resources/paul_graham_essay.docx
create mode 100644 resources/samples/test_hello.docx
create mode 100644 resources/samples/test_hello.pdf
create mode 100644 resources/samples/test_hello.txt
create mode 100644 resources/samples/test_hello_multi.docx
create mode 100644 resources/samples/test_hello_multi.pdf
create mode 100644 tests/test_parsing.py
diff --git a/resources/paul_graham_essay.docx b/resources/paul_graham_essay.docx
deleted file mode 100644
index 54249bfb890bc5ba6aedf1c431905f87d19290f4..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 49207
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 |