Spaces:
Runtime error
fix: Address code review security and quality issues
Browse filesSecurity fixes:
- Add path traversal protection via document filter allowlist
- Add input length validation (MAX_QUERY_LENGTH=10000)
- Add Gradio rate limiting (queue with max_size=20, concurrency=2)
Code quality improvements:
- Replace sys.exit() with FileNotFoundError in vectorstore.py
- Fix lazy loading retry loop in qa_chain.py using sentinel pattern
- Add error logging for silent failures in retrievers.py
- Optimize O(n*m) to O(n) document matching in hybrid search
- Make EMBEDDING_MODEL and RERANKER_MODEL configurable via env vars
- Add proper exports to ui/__init__.py
- Remove unused dependencies (streamlit, pandas, plotly)
Test updates:
- Update tests for new exception types and function signatures
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- config.py +9 -3
- qa_chain.py +11 -2
- requirements.txt +5 -1
- retrievers.py +27 -30
- tests/test_empty_vectorstore.py +1 -1
- tests/test_handlers.py +3 -2
- tests/test_input_validation.py +10 -10
- tests/test_qa_chain.py +2 -1
- tests/test_ui_app.py +1 -1
- tests/test_vectorstore_edge_cases.py +3 -1
- tests/test_vectorstore_remaining.py +3 -1
- ui/__init__.py +10 -0
- ui/app.py +2 -1
- ui/handlers.py +31 -8
- vectorstore.py +5 -6
|
@@ -6,12 +6,16 @@ import os
|
|
| 6 |
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 7 |
|
| 8 |
# Environment-based configuration
|
| 9 |
-
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY"
|
| 10 |
OPENAI_URL = os.getenv("OPENAI_BASE_URL", "http://127.0.0.1:8080")
|
| 11 |
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "dummy")
|
| 12 |
CHROMA_PATH = os.getenv("CHROMA_PATH", "vectorstore")
|
| 13 |
PDF_PATH = os.getenv("PDF_PATH", "pdf")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# RAG configuration
|
| 16 |
RETRIEVER_K = 3 # Number of final documents to return
|
| 17 |
RETRIEVER_FETCH_K = 10 # Number of candidates to fetch for MMR
|
|
@@ -25,7 +29,7 @@ RERANK_INITIAL_K = 20 # Retrieve more candidates before re-ranking
|
|
| 25 |
RERANK_TOP_K = RETRIEVER_K # Final number after re-ranking
|
| 26 |
|
| 27 |
# Embedding model configuration
|
| 28 |
-
EMBEDDING_MODEL_NAME = "BAAI/bge-small-en-v1.5"
|
| 29 |
EMBEDDING_DEVICE = "cpu"
|
| 30 |
|
| 31 |
# Text splitter configuration
|
|
@@ -33,7 +37,7 @@ CHUNK_SIZE = 512
|
|
| 33 |
CHUNK_OVERLAP = 128
|
| 34 |
|
| 35 |
# Reranker model
|
| 36 |
-
RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 37 |
|
| 38 |
# RAG prompt template
|
| 39 |
RAG_PROMPT_TEMPLATE = """Answer the question naturally and conversationally based on the provided context. Be direct and informative - if the answer is in the context, state it clearly without unnecessary formal structure or sections. Write as if you're explaining to a colleague.
|
|
@@ -48,3 +52,5 @@ Previous conversation:
|
|
| 48 |
|
| 49 |
Answer:"""
|
| 50 |
|
|
|
|
|
|
|
|
|
| 6 |
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 7 |
|
| 8 |
# Environment-based configuration
|
| 9 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "not-needed") # Placeholder for local llama-server
|
| 10 |
OPENAI_URL = os.getenv("OPENAI_BASE_URL", "http://127.0.0.1:8080")
|
| 11 |
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "dummy")
|
| 12 |
CHROMA_PATH = os.getenv("CHROMA_PATH", "vectorstore")
|
| 13 |
PDF_PATH = os.getenv("PDF_PATH", "pdf")
|
| 14 |
|
| 15 |
+
# Input validation
|
| 16 |
+
MAX_QUERY_LENGTH = int(os.getenv("MAX_QUERY_LENGTH", "10000"))
|
| 17 |
+
ALLOWED_SEARCH_TYPES = {"mmr", "similarity", "hybrid"}
|
| 18 |
+
|
| 19 |
# RAG configuration
|
| 20 |
RETRIEVER_K = 3 # Number of final documents to return
|
| 21 |
RETRIEVER_FETCH_K = 10 # Number of candidates to fetch for MMR
|
|
|
|
| 29 |
RERANK_TOP_K = RETRIEVER_K # Final number after re-ranking
|
| 30 |
|
| 31 |
# Embedding model configuration
|
| 32 |
+
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5")
|
| 33 |
EMBEDDING_DEVICE = "cpu"
|
| 34 |
|
| 35 |
# Text splitter configuration
|
|
|
|
| 37 |
CHUNK_OVERLAP = 128
|
| 38 |
|
| 39 |
# Reranker model
|
| 40 |
+
RERANKER_MODEL = os.getenv("RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2")
|
| 41 |
|
| 42 |
# RAG prompt template
|
| 43 |
RAG_PROMPT_TEMPLATE = """Answer the question naturally and conversationally based on the provided context. Be direct and informative - if the answer is in the context, state it clearly without unnecessary formal structure or sections. Write as if you're explaining to a colleague.
|
|
|
|
| 52 |
|
| 53 |
Answer:"""
|
| 54 |
|
| 55 |
+
|
| 56 |
+
|
|
@@ -1,10 +1,16 @@
|
|
| 1 |
"""Question-answering chain with RAG capabilities."""
|
| 2 |
|
|
|
|
| 3 |
import os
|
| 4 |
|
| 5 |
from langchain_core.prompts import ChatPromptTemplate
|
| 6 |
from sentence_transformers import CrossEncoder
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from config import (
|
| 9 |
HYBRID_ALPHA_DEFAULT,
|
| 10 |
MMR_LAMBDA,
|
|
@@ -62,8 +68,11 @@ class QAChainWrapper:
|
|
| 62 |
|
| 63 |
self._reranker = CrossEncoder(RERANKER_MODEL)
|
| 64 |
except Exception as e:
|
| 65 |
-
|
| 66 |
-
self._reranker =
|
|
|
|
|
|
|
|
|
|
| 67 |
return self._reranker
|
| 68 |
|
| 69 |
def rewrite_query(self, question, chat_history=None):
|
|
|
|
| 1 |
"""Question-answering chain with RAG capabilities."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
import os
|
| 5 |
|
| 6 |
from langchain_core.prompts import ChatPromptTemplate
|
| 7 |
from sentence_transformers import CrossEncoder
|
| 8 |
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# Sentinel to indicate reranker load failed (prevent retry loop)
|
| 12 |
+
_RERANKER_LOAD_FAILED = object()
|
| 13 |
+
|
| 14 |
from config import (
|
| 15 |
HYBRID_ALPHA_DEFAULT,
|
| 16 |
MMR_LAMBDA,
|
|
|
|
| 68 |
|
| 69 |
self._reranker = CrossEncoder(RERANKER_MODEL)
|
| 70 |
except Exception as e:
|
| 71 |
+
logger.warning(f"Could not load cross-encoder: {e}")
|
| 72 |
+
self._reranker = _RERANKER_LOAD_FAILED
|
| 73 |
+
|
| 74 |
+
if self._reranker is _RERANKER_LOAD_FAILED:
|
| 75 |
+
return None
|
| 76 |
return self._reranker
|
| 77 |
|
| 78 |
def rewrite_query(self, question, chat_history=None):
|
|
@@ -4,9 +4,13 @@ langchain-huggingface>=0.1.0
|
|
| 4 |
langchain-chroma>=0.1.0
|
| 5 |
langchain-text-splitters>=0.3.0
|
| 6 |
langchain-community>=0.3.0
|
| 7 |
-
sentence-transformers
|
| 8 |
torch
|
| 9 |
transformers
|
| 10 |
chromadb
|
| 11 |
pypdf
|
| 12 |
gradio>=5.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
langchain-chroma>=0.1.0
|
| 5 |
langchain-text-splitters>=0.3.0
|
| 6 |
langchain-community>=0.3.0
|
| 7 |
+
sentence-transformers>=2.2.0
|
| 8 |
torch
|
| 9 |
transformers
|
| 10 |
chromadb
|
| 11 |
pypdf
|
| 12 |
gradio>=5.0.0
|
| 13 |
+
rank-bm25>=0.2.2
|
| 14 |
+
pytest>=7.4.0
|
| 15 |
+
pytest-cov>=4.1.0
|
| 16 |
+
pytest-mock>=3.11.0
|
|
@@ -1,8 +1,11 @@
|
|
| 1 |
"""Retrieval strategies for document search."""
|
| 2 |
|
|
|
|
| 3 |
import re
|
| 4 |
|
| 5 |
from langchain_core.documents import Document
|
|
|
|
|
|
|
| 6 |
from rank_bm25 import BM25Okapi
|
| 7 |
|
| 8 |
from config import (
|
|
@@ -117,7 +120,8 @@ class HybridRetriever:
|
|
| 117 |
semantic_docs = self._vectorstore.similarity_search_with_score(
|
| 118 |
query, k=semantic_k, filter=metadata_filter if metadata_filter else None
|
| 119 |
)
|
| 120 |
-
except Exception:
|
|
|
|
| 121 |
semantic_docs = []
|
| 122 |
|
| 123 |
# Keyword search (BM25)
|
|
@@ -157,6 +161,9 @@ class HybridRetriever:
|
|
| 157 |
semantic_score = 1.0 / (1.0 + distance) if distance >= 0 else 1.0
|
| 158 |
doc_scores[key] = {"doc": doc, "semantic": semantic_score, "keyword": 0.0}
|
| 159 |
|
|
|
|
|
|
|
|
|
|
| 160 |
# Add keyword scores - match BM25 documents with semantic documents
|
| 161 |
# Only include documents that match the filter
|
| 162 |
for i, bm25_doc in enumerate(self._documents):
|
|
@@ -165,36 +172,26 @@ class HybridRetriever:
|
|
| 165 |
continue
|
| 166 |
|
| 167 |
key = doc_key(bm25_doc)
|
| 168 |
-
if
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
for sem_doc, _ in semantic_docs:
|
| 172 |
-
if (
|
| 173 |
-
bm25_doc.page_content[:100] == sem_doc.page_content[:100]
|
| 174 |
-
and bm25_doc.metadata.get("page") == sem_doc.metadata.get("page")
|
| 175 |
-
and bm25_doc.metadata.get("source")
|
| 176 |
-
== sem_doc.metadata.get("source")
|
| 177 |
-
):
|
| 178 |
-
# Found a match - add keyword score to existing entry
|
| 179 |
-
sem_key = doc_key(sem_doc)
|
| 180 |
-
if sem_key in doc_scores:
|
| 181 |
-
doc_scores[sem_key]["keyword"] = (
|
| 182 |
-
bm25_scores[i] if i < len(bm25_scores) else 0.0
|
| 183 |
-
)
|
| 184 |
-
matched = True
|
| 185 |
-
break
|
| 186 |
-
if not matched:
|
| 187 |
-
# New document from BM25 only (but matches filter)
|
| 188 |
-
doc_scores[key] = {
|
| 189 |
-
"doc": bm25_doc,
|
| 190 |
-
"semantic": 0.0,
|
| 191 |
-
"keyword": bm25_scores[i] if i < len(bm25_scores) else 0.0,
|
| 192 |
-
}
|
| 193 |
-
else:
|
| 194 |
# Update keyword score for existing entry
|
| 195 |
-
doc_scores[key]["keyword"] =
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
# Fuse scores
|
| 200 |
fused_results = []
|
|
|
|
| 1 |
"""Retrieval strategies for document search."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
import re
|
| 5 |
|
| 6 |
from langchain_core.documents import Document
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
from rank_bm25 import BM25Okapi
|
| 10 |
|
| 11 |
from config import (
|
|
|
|
| 120 |
semantic_docs = self._vectorstore.similarity_search_with_score(
|
| 121 |
query, k=semantic_k, filter=metadata_filter if metadata_filter else None
|
| 122 |
)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.warning(f"Semantic search failed, falling back to keyword only: {e}")
|
| 125 |
semantic_docs = []
|
| 126 |
|
| 127 |
# Keyword search (BM25)
|
|
|
|
| 161 |
semantic_score = 1.0 / (1.0 + distance) if distance >= 0 else 1.0
|
| 162 |
doc_scores[key] = {"doc": doc, "semantic": semantic_score, "keyword": 0.0}
|
| 163 |
|
| 164 |
+
# Build lookup set for O(1) matching instead of O(n*m)
|
| 165 |
+
semantic_keys = {doc_key(doc) for doc, _ in semantic_docs}
|
| 166 |
+
|
| 167 |
# Add keyword scores - match BM25 documents with semantic documents
|
| 168 |
# Only include documents that match the filter
|
| 169 |
for i, bm25_doc in enumerate(self._documents):
|
|
|
|
| 172 |
continue
|
| 173 |
|
| 174 |
key = doc_key(bm25_doc)
|
| 175 |
+
keyword_score = bm25_scores[i] if i < len(bm25_scores) else 0.0
|
| 176 |
+
|
| 177 |
+
if key in doc_scores:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
# Update keyword score for existing entry
|
| 179 |
+
doc_scores[key]["keyword"] = keyword_score
|
| 180 |
+
elif key in semantic_keys:
|
| 181 |
+
# Match found in semantic results - should already be in doc_scores
|
| 182 |
+
# This handles edge case of key mismatch
|
| 183 |
+
doc_scores[key] = {
|
| 184 |
+
"doc": bm25_doc,
|
| 185 |
+
"semantic": 0.0,
|
| 186 |
+
"keyword": keyword_score,
|
| 187 |
+
}
|
| 188 |
+
else:
|
| 189 |
+
# New document from BM25 only (but matches filter)
|
| 190 |
+
doc_scores[key] = {
|
| 191 |
+
"doc": bm25_doc,
|
| 192 |
+
"semantic": 0.0,
|
| 193 |
+
"keyword": keyword_score,
|
| 194 |
+
}
|
| 195 |
|
| 196 |
# Fuse scores
|
| 197 |
fused_results = []
|
|
@@ -178,7 +178,7 @@ def test_create_new_vectorstore_no_pdfs(mock_get_pdfs, mock_chroma):
|
|
| 178 |
|
| 179 |
mock_get_pdfs.return_value = [] # No PDF files
|
| 180 |
|
| 181 |
-
with pytest.raises(
|
| 182 |
create_new_vectorstore(MagicMock())
|
| 183 |
|
| 184 |
|
|
|
|
| 178 |
|
| 179 |
mock_get_pdfs.return_value = [] # No PDF files
|
| 180 |
|
| 181 |
+
with pytest.raises(FileNotFoundError):
|
| 182 |
create_new_vectorstore(MagicMock())
|
| 183 |
|
| 184 |
|
|
@@ -103,7 +103,8 @@ def test_create_stream_chat_response_with_filter(mock_vectorstore):
|
|
| 103 |
}
|
| 104 |
]
|
| 105 |
|
| 106 |
-
|
|
|
|
| 107 |
list(
|
| 108 |
stream_fn(
|
| 109 |
"test question",
|
|
@@ -114,7 +115,7 @@ def test_create_stream_chat_response_with_filter(mock_vectorstore):
|
|
| 114 |
)
|
| 115 |
)
|
| 116 |
|
| 117 |
-
# Check that filter was passed
|
| 118 |
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 119 |
assert "filter" in call_args
|
| 120 |
|
|
|
|
| 103 |
}
|
| 104 |
]
|
| 105 |
|
| 106 |
+
# Pass available_sources to allow the filter to be validated
|
| 107 |
+
stream_fn = create_stream_chat_response(mock_qa_chain, available_sources=["test.pdf"])
|
| 108 |
list(
|
| 109 |
stream_fn(
|
| 110 |
"test question",
|
|
|
|
| 115 |
)
|
| 116 |
)
|
| 117 |
|
| 118 |
+
# Check that filter was passed (only works with valid source)
|
| 119 |
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 120 |
assert "filter" in call_args
|
| 121 |
|
|
@@ -59,14 +59,13 @@ def test_empty_whitespace_query():
|
|
| 59 |
results = list(respond_fn("", [], False, "mmr", "All Documents", False, False, 70))
|
| 60 |
assert results[0][0] == "" # Should return empty immediately
|
| 61 |
|
| 62 |
-
# Whitespace only
|
| 63 |
results = list(respond_fn(" ", [], False, "mmr", "All Documents", False, False, 70))
|
| 64 |
-
|
| 65 |
-
assert len(results) > 0
|
| 66 |
|
| 67 |
-
# Newlines only
|
| 68 |
results = list(respond_fn("\n\n\n", [], False, "mmr", "All Documents", False, False, 70))
|
| 69 |
-
assert
|
| 70 |
|
| 71 |
|
| 72 |
@patch("qa_chain.create_llm")
|
|
@@ -146,24 +145,25 @@ def test_invalid_document_filter():
|
|
| 146 |
}
|
| 147 |
]
|
| 148 |
|
| 149 |
-
|
|
|
|
| 150 |
|
| 151 |
-
# Invalid filter (document that doesn't exist)
|
| 152 |
results = list(
|
| 153 |
stream_fn(
|
| 154 |
"test question",
|
| 155 |
[],
|
| 156 |
"RAG",
|
| 157 |
-
doc_filter="nonexistent.pdf", # Invalid filter
|
| 158 |
search_type="mmr",
|
| 159 |
)
|
| 160 |
)
|
| 161 |
|
| 162 |
# Should handle invalid filter gracefully
|
| 163 |
assert len(results) > 0
|
| 164 |
-
#
|
| 165 |
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 166 |
-
assert "filter" in call_args
|
| 167 |
|
| 168 |
|
| 169 |
def test_malformed_chat_history():
|
|
|
|
| 59 |
results = list(respond_fn("", [], False, "mmr", "All Documents", False, False, 70))
|
| 60 |
assert results[0][0] == "" # Should return empty immediately
|
| 61 |
|
| 62 |
+
# Whitespace only - now also returns empty (treated as empty after strip)
|
| 63 |
results = list(respond_fn(" ", [], False, "mmr", "All Documents", False, False, 70))
|
| 64 |
+
assert results[0][0] == "" # Should return empty immediately
|
|
|
|
| 65 |
|
| 66 |
+
# Newlines only - also returns empty
|
| 67 |
results = list(respond_fn("\n\n\n", [], False, "mmr", "All Documents", False, False, 70))
|
| 68 |
+
assert results[0][0] == "" # Should return empty immediately
|
| 69 |
|
| 70 |
|
| 71 |
@patch("qa_chain.create_llm")
|
|
|
|
| 145 |
}
|
| 146 |
]
|
| 147 |
|
| 148 |
+
# Pass valid sources - nonexistent.pdf is NOT in the list
|
| 149 |
+
stream_fn = create_stream_chat_response(mock_qa_chain, available_sources=["valid.pdf"])
|
| 150 |
|
| 151 |
+
# Invalid filter (document that doesn't exist in available_sources)
|
| 152 |
results = list(
|
| 153 |
stream_fn(
|
| 154 |
"test question",
|
| 155 |
[],
|
| 156 |
"RAG",
|
| 157 |
+
doc_filter="nonexistent.pdf", # Invalid filter - not in available_sources
|
| 158 |
search_type="mmr",
|
| 159 |
)
|
| 160 |
)
|
| 161 |
|
| 162 |
# Should handle invalid filter gracefully
|
| 163 |
assert len(results) > 0
|
| 164 |
+
# Invalid filter should be ignored (no filter passed to chain)
|
| 165 |
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 166 |
+
assert "filter" not in call_args # Filter is NOT passed for invalid sources
|
| 167 |
|
| 168 |
|
| 169 |
def test_malformed_chat_history():
|
|
@@ -53,7 +53,8 @@ def test_get_reranker_failure(mock_cross_encoder, qa_chain_wrapper):
|
|
| 53 |
|
| 54 |
result = qa_chain_wrapper._get_reranker()
|
| 55 |
assert result is None
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
@patch("qa_chain.create_llm")
|
|
|
|
| 53 |
|
| 54 |
result = qa_chain_wrapper._get_reranker()
|
| 55 |
assert result is None
|
| 56 |
+
# _reranker is set to sentinel _RERANKER_LOAD_FAILED, not None
|
| 57 |
+
assert qa_chain_wrapper._reranker is not None
|
| 58 |
|
| 59 |
|
| 60 |
@patch("qa_chain.create_llm")
|
|
@@ -138,7 +138,7 @@ def test_create_app(
|
|
| 138 |
assert app == mock_components["demo"]
|
| 139 |
mock_initialize.assert_called_once()
|
| 140 |
mock_create_components.assert_called_once()
|
| 141 |
-
mock_create_stream.assert_called_once_with(mock_qa_chain)
|
| 142 |
mock_create_respond.assert_called_once_with(mock_stream_fn)
|
| 143 |
|
| 144 |
# Check event handlers were attached
|
|
|
|
| 138 |
assert app == mock_components["demo"]
|
| 139 |
mock_initialize.assert_called_once()
|
| 140 |
mock_create_components.assert_called_once()
|
| 141 |
+
mock_create_stream.assert_called_once_with(mock_qa_chain, ["test1.pdf", "test2.pdf"])
|
| 142 |
mock_create_respond.assert_called_once_with(mock_stream_fn)
|
| 143 |
|
| 144 |
# Check event handlers were attached
|
|
@@ -15,7 +15,7 @@ def test_handle_existing_vectorstore_no_pdfs(mock_chroma, mock_get_pdfs):
|
|
| 15 |
mock_chroma.return_value = mock_vectorstore
|
| 16 |
mock_get_pdfs.return_value = []
|
| 17 |
|
| 18 |
-
with pytest.raises(
|
| 19 |
handle_existing_vectorstore(MagicMock())
|
| 20 |
|
| 21 |
|
|
@@ -49,3 +49,5 @@ def test_handle_existing_vectorstore_no_metadatas(mock_chroma, mock_get_pdfs):
|
|
| 49 |
assert result == mock_vectorstore
|
| 50 |
mock_update.assert_called_once()
|
| 51 |
|
|
|
|
|
|
|
|
|
| 15 |
mock_chroma.return_value = mock_vectorstore
|
| 16 |
mock_get_pdfs.return_value = []
|
| 17 |
|
| 18 |
+
with pytest.raises(FileNotFoundError):
|
| 19 |
handle_existing_vectorstore(MagicMock())
|
| 20 |
|
| 21 |
|
|
|
|
| 49 |
assert result == mock_vectorstore
|
| 50 |
mock_update.assert_called_once()
|
| 51 |
|
| 52 |
+
|
| 53 |
+
|
|
@@ -14,6 +14,8 @@ def test_create_new_vectorstore_no_pdfs(mock_chroma, mock_loader, mock_get_pdfs)
|
|
| 14 |
"""Test create_new_vectorstore with no PDF files (lines 157-159)."""
|
| 15 |
mock_get_pdfs.return_value = []
|
| 16 |
|
| 17 |
-
with pytest.raises(
|
| 18 |
create_new_vectorstore(MagicMock())
|
| 19 |
|
|
|
|
|
|
|
|
|
| 14 |
"""Test create_new_vectorstore with no PDF files (lines 157-159)."""
|
| 15 |
mock_get_pdfs.return_value = []
|
| 16 |
|
| 17 |
+
with pytest.raises(FileNotFoundError):
|
| 18 |
create_new_vectorstore(MagicMock())
|
| 19 |
|
| 20 |
+
|
| 21 |
+
|
|
@@ -1,2 +1,12 @@
|
|
| 1 |
"""UI components for the Gradio application."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""UI components for the Gradio application."""
|
| 2 |
|
| 3 |
+
from .app import create_app
|
| 4 |
+
from .components import create_ui_components
|
| 5 |
+
from .handlers import create_respond_handler, create_stream_chat_response
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"create_app",
|
| 9 |
+
"create_respond_handler",
|
| 10 |
+
"create_stream_chat_response",
|
| 11 |
+
"create_ui_components",
|
| 12 |
+
]
|
|
@@ -55,7 +55,7 @@ def create_app():
|
|
| 55 |
demo = components["demo"]
|
| 56 |
|
| 57 |
# Create handlers
|
| 58 |
-
stream_chat_response_fn = create_stream_chat_response(qa_chain)
|
| 59 |
respond_fn = create_respond_handler(stream_chat_response_fn)
|
| 60 |
|
| 61 |
# Event handlers - must be within Blocks context
|
|
@@ -136,5 +136,6 @@ def create_app():
|
|
| 136 |
|
| 137 |
if __name__ == "__main__":
|
| 138 |
app = create_app()
|
|
|
|
| 139 |
app.launch(share=False, server_port=7860)
|
| 140 |
|
|
|
|
| 55 |
demo = components["demo"]
|
| 56 |
|
| 57 |
# Create handlers
|
| 58 |
+
stream_chat_response_fn = create_stream_chat_response(qa_chain, available_sources)
|
| 59 |
respond_fn = create_respond_handler(stream_chat_response_fn)
|
| 60 |
|
| 61 |
# Event handlers - must be within Blocks context
|
|
|
|
| 136 |
|
| 137 |
if __name__ == "__main__":
|
| 138 |
app = create_app()
|
| 139 |
+
app.queue(max_size=20, default_concurrency_limit=2)
|
| 140 |
app.launch(share=False, server_port=7860)
|
| 141 |
|
|
@@ -4,22 +4,42 @@ import os
|
|
| 4 |
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
-
from config import PDF_PATH
|
| 8 |
from models import create_llm
|
| 9 |
from qa_chain import QAChainWrapper
|
| 10 |
from utils import format_context_with_highlight, messages_to_tuples
|
| 11 |
from langchain_core.messages import HumanMessage, SystemMessage
|
| 12 |
|
| 13 |
|
| 14 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""Create a stream_chat_response function bound to the QA chain.
|
| 16 |
|
| 17 |
Args:
|
| 18 |
qa_chain: QAChainWrapper instance
|
|
|
|
| 19 |
|
| 20 |
Returns:
|
| 21 |
Function that streams chat responses
|
| 22 |
"""
|
|
|
|
| 23 |
|
| 24 |
def stream_chat_response(
|
| 25 |
message,
|
|
@@ -55,12 +75,11 @@ def create_stream_chat_response(qa_chain: QAChainWrapper):
|
|
| 55 |
hybrid_scores = None
|
| 56 |
|
| 57 |
if query_type == "RAG":
|
| 58 |
-
# Build filter if document is selected
|
| 59 |
metadata_filter = None
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
metadata_filter = {"source": {"$eq": full_source_path}}
|
| 64 |
|
| 65 |
stream_input = {
|
| 66 |
"question": message,
|
|
@@ -143,10 +162,14 @@ def create_respond_handler(stream_chat_response_fn):
|
|
| 143 |
Yields:
|
| 144 |
tuple: (cleared_msg, history, context, rag_state, doc_filter)
|
| 145 |
"""
|
| 146 |
-
if not message:
|
| 147 |
yield "", chat_history, "", is_rag_enabled, selected_doc
|
| 148 |
return
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
query_type = "RAG" if is_rag_enabled else "Vanilla LLM"
|
| 151 |
new_history = list(chat_history)
|
| 152 |
|
|
|
|
| 4 |
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
+
from config import MAX_QUERY_LENGTH, PDF_PATH
|
| 8 |
from models import create_llm
|
| 9 |
from qa_chain import QAChainWrapper
|
| 10 |
from utils import format_context_with_highlight, messages_to_tuples
|
| 11 |
from langchain_core.messages import HumanMessage, SystemMessage
|
| 12 |
|
| 13 |
|
| 14 |
+
def validate_doc_filter(doc_filter, available_sources):
|
| 15 |
+
"""Validate document filter against allowed sources to prevent path traversal.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
doc_filter: User-provided document filter value
|
| 19 |
+
available_sources: Set/list of valid source filenames
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
Full path if valid, None otherwise
|
| 23 |
+
"""
|
| 24 |
+
if not doc_filter or doc_filter == "All Documents":
|
| 25 |
+
return None
|
| 26 |
+
# Only allow filenames that exist in our indexed sources
|
| 27 |
+
if doc_filter not in available_sources:
|
| 28 |
+
return None # Invalid filter, ignore
|
| 29 |
+
return os.path.join(PDF_PATH, doc_filter)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def create_stream_chat_response(qa_chain: QAChainWrapper, available_sources=None):
|
| 33 |
"""Create a stream_chat_response function bound to the QA chain.
|
| 34 |
|
| 35 |
Args:
|
| 36 |
qa_chain: QAChainWrapper instance
|
| 37 |
+
available_sources: List of valid document source filenames
|
| 38 |
|
| 39 |
Returns:
|
| 40 |
Function that streams chat responses
|
| 41 |
"""
|
| 42 |
+
sources_set = set(available_sources) if available_sources else set()
|
| 43 |
|
| 44 |
def stream_chat_response(
|
| 45 |
message,
|
|
|
|
| 75 |
hybrid_scores = None
|
| 76 |
|
| 77 |
if query_type == "RAG":
|
| 78 |
+
# Build filter if document is selected (with path traversal protection)
|
| 79 |
metadata_filter = None
|
| 80 |
+
validated_path = validate_doc_filter(doc_filter, sources_set)
|
| 81 |
+
if validated_path:
|
| 82 |
+
metadata_filter = {"source": {"$eq": validated_path}}
|
|
|
|
| 83 |
|
| 84 |
stream_input = {
|
| 85 |
"question": message,
|
|
|
|
| 162 |
Yields:
|
| 163 |
tuple: (cleared_msg, history, context, rag_state, doc_filter)
|
| 164 |
"""
|
| 165 |
+
if not message or not message.strip():
|
| 166 |
yield "", chat_history, "", is_rag_enabled, selected_doc
|
| 167 |
return
|
| 168 |
|
| 169 |
+
# Truncate extremely long inputs to prevent resource exhaustion
|
| 170 |
+
if len(message) > MAX_QUERY_LENGTH:
|
| 171 |
+
message = message[:MAX_QUERY_LENGTH]
|
| 172 |
+
|
| 173 |
query_type = "RAG" if is_rag_enabled else "Vanilla LLM"
|
| 174 |
new_history = list(chat_history)
|
| 175 |
|
|
@@ -2,7 +2,6 @@
|
|
| 2 |
|
| 3 |
import glob
|
| 4 |
import os
|
| 5 |
-
import sys
|
| 6 |
|
| 7 |
from langchain_chroma import Chroma
|
| 8 |
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
|
|
@@ -95,8 +94,7 @@ def handle_existing_vectorstore(embeddings):
|
|
| 95 |
|
| 96 |
current_pdfs = get_pdf_files()
|
| 97 |
if not current_pdfs:
|
| 98 |
-
|
| 99 |
-
sys.exit(1)
|
| 100 |
|
| 101 |
collection = vectorstore.get()
|
| 102 |
if not collection or not collection.get("metadatas"):
|
|
@@ -154,9 +152,10 @@ def create_new_vectorstore(embeddings):
|
|
| 154 |
print("Creating new Chroma database...")
|
| 155 |
pdf_files = get_pdf_files()
|
| 156 |
if not pdf_files:
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
| 160 |
|
| 161 |
print(f"Found {len(pdf_files)} PDF files to process...")
|
| 162 |
print("(This may take a while as documents need to be processed and embedded)")
|
|
|
|
| 2 |
|
| 3 |
import glob
|
| 4 |
import os
|
|
|
|
| 5 |
|
| 6 |
from langchain_chroma import Chroma
|
| 7 |
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
|
|
|
|
| 94 |
|
| 95 |
current_pdfs = get_pdf_files()
|
| 96 |
if not current_pdfs:
|
| 97 |
+
raise FileNotFoundError("No PDF files found in directory.")
|
|
|
|
| 98 |
|
| 99 |
collection = vectorstore.get()
|
| 100 |
if not collection or not collection.get("metadatas"):
|
|
|
|
| 152 |
print("Creating new Chroma database...")
|
| 153 |
pdf_files = get_pdf_files()
|
| 154 |
if not pdf_files:
|
| 155 |
+
raise FileNotFoundError(
|
| 156 |
+
f"No PDF files found in '{PDF_PATH}' directory. "
|
| 157 |
+
f"Please add PDF files and run again."
|
| 158 |
+
)
|
| 159 |
|
| 160 |
print(f"Found {len(pdf_files)} PDF files to process...")
|
| 161 |
print("(This may take a while as documents need to be processed and embedded)")
|