feat: Add support for Ingestion and Retrieval of Knowledge Bases (#9088)
* refactor: Standardize import statements and improve code readability across components - Updated import statements to use consistent single quotes. - Refactored various components to enhance readability and maintainability. - Adjusted folder and file handling logic in the sidebar and file manager components. - Introduced a new tabbed interface for the files page to separate files and knowledge bases, improving user experience. * [autofix.ci] apply automated fixes * feat: Introduce new Files and Knowledge Bases page with tabbed interface - Added a new FilesPage component to manage file uploads and organization. - Implemented a tabbed interface to separate Files and Knowledge Bases for improved user experience. - Created FilesTab and KnowledgeBasesTab components for handling respective functionalities. - Refactored routing to accommodate the new structure and updated import statements for consistency. - Removed the old filesPage component to streamline the codebase. * Create knowledgebase_utils.py * Push initial ingest component * [autofix.ci] apply automated fixes * Create initial KB Ingestion component * [autofix.ci] apply automated fixes * Fix ruff check on utility functions * [autofix.ci] apply automated fixes * Some quick fixes * Update kb_ingest.py * [autofix.ci] apply automated fixes * First version of retrieval component * [autofix.ci] apply automated fixes * Update icon * Update kb_retrieval.py * [autofix.ci] apply automated fixes * Add knowledge bases feature with API integration and UI components * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Refactor imports and update routing paths for assets and main page components. Adjust tab handling in the assets page to reflect URL changes and improve user navigation experience. * [autofix.ci] apply automated fixes * Add CreateKnowledgeBaseButton, KnowledgeBaseEmptyState, and KnowledgeBaseSelectionOverlay components. Refactor KnowledgeBasesTab to utilize new components and improve UI for knowledge base management. Introduce utility functions for formatting numbers and average chunk sizes. * [autofix.ci] apply automated fixes * PoV: Add Parquet data retrieval to KBRetrievalComponent (#9097) * Add Parquet data retrieval to KBRetrievalComponent Introduces a new output to KBRetrievalComponent for returning knowledge base data by reading Parquet files. Updates dependencies to include fastparquet for Parquet support. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * Fix some ruff issues * [autofix.ci] apply automated fixes * feat: refactor file management and knowledge base components - Replaced the existing assetsPage with a new filesPage to better organize file management functionalities. - Introduced KnowledgePage to handle knowledge base operations, integrating KnowledgeBasesTab for displaying and managing knowledge bases. - Added various components for file and knowledge base management, including CreateKnowledgeBaseButton, KnowledgeBaseEmptyState, and drag-and-drop functionality. - Updated routing and imports to reflect the new structure and ensure consistency across the application. - Enhanced user experience with improved UI elements and state management for file selection and operations. * feat: implement delete confirmation modal for knowledge base deletion - Added a DeleteConfirmationModal component to confirm deletion actions. - Integrated the modal into the KnowledgeBasesTab for handling knowledge base deletions. - Updated column definitions to include a delete button for each knowledge base. - Enhanced user experience by ensuring deletion actions require confirmation. - Adjusted styles for the knowledge base table to improve checkbox visibility. * feat: enhance knowledge base metadata with embedding model detection - Added `embedding_model` field to `KnowledgeBaseInfo` for improved metadata tracking. - Implemented `detect_embedding_model` function to extract embedding model information from configuration files. - Updated `get_kb_metadata` to prioritize metadata extraction from `embedding_metadata.json`, falling back to detection if necessary. - Modified `KBIngestionComponent` to save embedding model metadata during ingestion. - Adjusted frontend components to display embedding model information in knowledge base queries and tables. * refactor: clean up tooltip and value getter comments in knowledge base columns - Removed redundant comments in the `knowledgeBaseColumns.tsx` file to enhance code clarity. - Simplified the tooltip and value getter functions for embedding model display. * [autofix.ci] apply automated fixes * refactor: simplify KnowledgeBaseSelectionOverlay component - Removed the unused onExport prop and its associated functionality. - Cleaned up code formatting for consistency and readability. - Updated success message strings to use single quotes for uniformity. * feat: implement bulk and single deletion for knowledge bases - Added `BulkDeleteRequest` model to handle bulk deletion requests. - Implemented `delete_knowledge_base` endpoint for single knowledge base deletion. - Created `delete_knowledge_bases_bulk` endpoint for deleting multiple knowledge bases at once. - Introduced `useDeleteKnowledgeBase` and `useDeleteKnowledgeBases` hooks for frontend integration. - Updated `KnowledgeBaseSelectionOverlay` and `KnowledgeBasesTab` components to utilize new deletion functionality with user feedback on success and error handling. * Initial support for vector search * feat: add KnowledgeBaseDrawer component for enhanced knowledge base details - Introduced `KnowledgeBaseDrawer` component to display detailed information about selected knowledge bases. - Integrated mock data for source files and linked flows, with a layout for displaying descriptions and embedding models. - Updated `KnowledgeBasesTab` to handle row clicks and open the drawer with relevant knowledge base data. - Enhanced `KnowledgePage` to manage drawer state and selected knowledge base, improving user interaction and experience. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Fix ruff checks * Update knowledge_bases.py * feat: update mock data and enhance drawer functionality in KnowledgeBase components - Replaced mock data in `KnowledgeBaseDrawer` with more descriptive placeholders. - Added a reference to the drawer in `KnowledgePage` for improved click handling. - Implemented logic to close the drawer when clicking outside, except for table row clicks. - Enhanced row click handling to toggle drawer state based on current visibility. * [autofix.ci] apply automated fixes * Append scores column to rows * refactor: improve knowledge base deletion and UI components - Updated `useDeleteKnowledgeBase` and `useDeleteKnowledgeBases` to enhance parameter naming for clarity. - Removed the `CreateKnowledgeBaseButton` component and its references to streamline the UI. - Simplified the `KnowledgeBaseDrawer` and `KnowledgeBasesTab` components by removing mock data and improving state management. - Enhanced the `KnowledgeBaseSelectionOverlay` to better handle bulk deletions and selection states. - Refactored various components for consistent styling and improved readability. * refactor: standardize import statements and improve code readability in SideBarFoldersButtonsComponent - Updated import statements to use consistent single quotes. - Refactored various function calls and state management for improved clarity. - Enhanced folder handling logic and UI interactions for better user experience. * feat: Add encryption for API keys in KB ingest and retrieval (#9129) Add encryption for API keys in KB ingest and retrieval Introduces secure storage of embedding model API keys by encrypting them during knowledge base ingestion and decrypting them during retrieval. Refactors metadata handling to include encrypted API keys, updates retrieval to support decryption and dynamic embedder construction, and improves logging for key operations. Removes legacy embedding client code in retrieval in favor of a provider-based approach. * [autofix.ci] apply automated fixes * Fix import of auth utils * Allow appending to existing knowledge base * [autofix.ci] apply automated fixes * Update kb_ingest.py * Update kb_ingest.py * feat: enhance table component with editable Vectorize column functionality - Implemented logic to determine editability of the Vectorize column based on other row values. - Added checks to refresh grid cells upon changes to the Vectorize column. - Updated TableAutoCellRender to conditionally disable editing based on Vectorize column state. * New ingestion creation dialog * [autofix.ci] apply automated fixes * Clean up the creation process for KB * [autofix.ci] apply automated fixes * Clean up names and descriptions * Update kb_retrieval.py * chroma retrieval * [autofix.ci] apply automated fixes * Further KB cleanup * refactor: update KB ingestion component and enhance NodeDialog functionality - Restored SecretStrInput for API key in KB ingestion component. - Modified NodeDialog to handle new value format and added support for additional properties. - Introduced custom hooks for managing global variable states in InputGlobalComponent. - Improved dropdown component styling and interaction. - Cleaned up input component code for better readability and maintainability. * Hash the text as id * [autofix.ci] apply automated fixes * Update kb_retrieval.py * [autofix.ci] apply automated fixes * Make sure to write out the source parquet * Remove unneeded old code * Add ability to block duplicate ingestion chunks * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Rename retrieval component * Better refresh mechanism for the retrieve * Clean up some unused functionality * Update kb_ingest.py * Fix dropdown component logic to include checks for refresh button and dialog inputs * Test the API key before saving knowledge * [autofix.ci] apply automated fixes * Allow storing updated api keys if provided at ingest time * Add Knowledge Bases component and enhance Knowledge Base Empty State - Introduced a new JSON configuration for Knowledge Bases, defining nodes and edges for data processing. - Enhanced the KnowledgeBaseEmptyState component to include a button for creating a knowledge base template. - Updated KnowledgeBasesTab to handle template creation, integrating flow management and navigation features. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Update Knowledge Bases.json * Update Knowledge Bases configuration and enhance UI components - Updated the code hash in the Knowledge Bases JSON configuration. - Modified the KnowledgeBaseEmptyState component to change the button icon and text from "Try Knowledge Base Template" to "Create Knowledge". - Cleared the options for the knowledge base selection dropdowns to ensure they reflect the current state of available knowledge bases. * [autofix.ci] apply automated fixes * Implement feature flag for Knowledge Bases functionality - Added FEATURE_FLAGS.knowledge_bases to control the visibility of knowledge base components in the API and UI. - Updated the router to conditionally include the knowledge bases router based on the feature flag. - Modified KBIngestionComponent and KBRetrievalComponent to hide if the knowledge bases feature is disabled. - Enhanced the initial setup to skip loading knowledge base starter projects when the feature is disabled. - Updated frontend routes and sidebar components to conditionally render knowledge base options based on the feature flag. - Adjusted API queries to return an empty array if the knowledge bases feature is disabled. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Refactor Knowledge Bases feature flag implementation - Removed the FEATURE_FLAGS.knowledge_bases flag from backend components and frontend routes. - Updated the API and UI to always include knowledge base components, simplifying the codebase. - Adjusted the frontend feature flags to set ENABLE_KNOWLEDGE_BASES to false, ensuring knowledge base features are not displayed. - Cleaned up related components and routes to reflect the removal of the feature flag, enhancing maintainability. * revert * [autofix.ci] apply automated fixes * Remove Knowledge Bases JSON configuration and clean up KnowledgeBasesTab component by eliminating unused imports and template creation functionality. * [autofix.ci] apply automated fixes * Enhance routing structure by adding admin and login routes with protected access. Refactor flow routes for improved organization and clarity. * added template back * Use chroma for stats computation * Fix ruff issue * [autofix.ci] apply automated fixes * Update Knowledge Bases.json * Update Knowledge Bases.json * Rename to just knowledge * feat: enhance Jest configuration and add new tests for Knowledge Base components - Updated jest.config.js to include a new setup file and refined test matching patterns. - Introduced jest.setup.js for mocking globals and Vite-specific syntax. - Added tests for KnowledgeBaseDrawer, KnowledgeBaseEmptyState, KnowledgeBaseSelectionOverlay, KnowledgeBasesTab, and KnowledgePage components. - Created utility functions for testing and mock data for knowledge bases. - Implemented tests for utility functions related to knowledge base formatting. * [autofix.ci] apply automated fixes * refactor: reorganize imports and clean up console log in Dropdown component - Moved and re-imported necessary dependencies for better structure. - Removed unnecessary console log statement to clean up the code. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * feat: add success callback for knowledge base creation in NodeDialog component - Introduced a new success callback to handle knowledge base creation notifications. - Enhanced dialog closing logic with a delay for Astra database tracking. - Reorganized imports for better structure. * refactor: update table component to handle single-toggle columns - Renamed functions and variables to improve clarity regarding single-toggle columns (Vectorize and Identifier). - Updated logic to ensure proper editability checks for single-toggle columns. - Adjusted related components to reflect changes in column handling and rendering. * [autofix.ci] apply automated fixes * feat: Add unit tests for KBIngestionComponent (#9246) * [autofix.ci] apply automated fixes * fix: remove unnecessary drawer open state change in KnowledgePage * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Remove kb_info output from KBIngestionComponent (#9275) * [autofix.ci] apply automated fixes * Update Knowledge Bases.json * Use settings service for knowledge base directory Replaces the hardcoded knowledge base directory path with a value from the settings service. This improves configurability and centralizes directory management. * Fix knowledge bases mypy issue * test: Update file page tests for consistency and clarity - Changed expected title text from "My Files" to "Files" for accuracy. - Removed unnecessary parentheses in arrow functions for cleaner syntax. - Updated test assertions to ensure visibility checks are clear and consistent. - Improved readability by standardizing the formatting of test cases. * test: Update expected title in file upload component test for accuracy - Changed expected title text from "My Files" to "Files" to reflect the correct page title. * [autofix.ci] apply automated fixes * Fix tests on backend * Update kb_ingest.py * [autofix.ci] apply automated fixes * Switch to two templates for KB * Update names and descs * [autofix.ci] apply automated fixes * Rename templates * [autofix.ci] apply automated fixes --------- Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
This commit is contained in:
parent
faff2015c4
commit
e68f6a405a
53 changed files with 7475 additions and 607 deletions
0
src/backend/tests/unit/base/data/__init__.py
Normal file
0
src/backend/tests/unit/base/data/__init__.py
Normal file
458
src/backend/tests/unit/base/data/test_kb_utils.py
Normal file
458
src/backend/tests/unit/base/data/test_kb_utils.py
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import pytest
|
||||
from langflow.base.data.kb_utils import compute_bm25, compute_tfidf
|
||||
|
||||
|
||||
class TestKBUtils:
|
||||
"""Test suite for knowledge base utility functions."""
|
||||
|
||||
# Test data for TF-IDF and BM25 tests
|
||||
@pytest.fixture
|
||||
def sample_documents(self):
|
||||
"""Sample documents for testing."""
|
||||
return ["the cat sat on the mat", "the dog ran in the park", "cats and dogs are pets", "birds fly in the sky"]
|
||||
|
||||
@pytest.fixture
|
||||
def query_terms(self):
|
||||
"""Sample query terms for testing."""
|
||||
return ["cat", "dog"]
|
||||
|
||||
@pytest.fixture
|
||||
def empty_documents(self):
|
||||
"""Empty documents for edge case testing."""
|
||||
return ["", "", ""]
|
||||
|
||||
@pytest.fixture
|
||||
def single_document(self):
|
||||
"""Single document for testing."""
|
||||
return ["hello world"]
|
||||
|
||||
def test_compute_tfidf_basic(self, sample_documents, query_terms):
|
||||
"""Test basic TF-IDF computation."""
|
||||
scores = compute_tfidf(sample_documents, query_terms)
|
||||
|
||||
# Should return a score for each document
|
||||
assert len(scores) == len(sample_documents)
|
||||
|
||||
# All scores should be floats
|
||||
assert all(isinstance(score, float) for score in scores)
|
||||
|
||||
# First document contains "cat", should have non-zero score
|
||||
assert scores[0] > 0.0
|
||||
|
||||
# Second document contains "dog", should have non-zero score
|
||||
assert scores[1] > 0.0
|
||||
|
||||
# Third document contains both "cats" and "dogs", but case-insensitive matching should work
|
||||
# Note: "cats" != "cat" exactly, so this tests the term matching behavior
|
||||
assert scores[2] >= 0.0
|
||||
|
||||
# Fourth document contains neither term, should have zero score
|
||||
assert scores[3] == 0.0
|
||||
|
||||
def test_compute_tfidf_case_insensitive(self):
|
||||
"""Test that TF-IDF computation is case insensitive."""
|
||||
documents = ["The CAT sat", "the dog RAN", "CATS and DOGS"]
|
||||
query_terms = ["cat", "DOG"]
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
# First document should match "cat" (case insensitive)
|
||||
assert scores[0] > 0.0
|
||||
|
||||
# Second document should match "dog" (case insensitive)
|
||||
assert scores[1] > 0.0
|
||||
|
||||
def test_compute_tfidf_empty_documents(self, empty_documents, query_terms):
|
||||
"""Test TF-IDF with empty documents."""
|
||||
scores = compute_tfidf(empty_documents, query_terms)
|
||||
|
||||
# Should return scores for all documents
|
||||
assert len(scores) == len(empty_documents)
|
||||
|
||||
# All scores should be zero since documents are empty
|
||||
assert all(score == 0.0 for score in scores)
|
||||
|
||||
def test_compute_tfidf_empty_query_terms(self, sample_documents):
|
||||
"""Test TF-IDF with empty query terms."""
|
||||
scores = compute_tfidf(sample_documents, [])
|
||||
|
||||
# Should return scores for all documents
|
||||
assert len(scores) == len(sample_documents)
|
||||
|
||||
# All scores should be zero since no query terms
|
||||
assert all(score == 0.0 for score in scores)
|
||||
|
||||
def test_compute_tfidf_single_document(self, single_document):
|
||||
"""Test TF-IDF with single document."""
|
||||
query_terms = ["hello", "world"]
|
||||
scores = compute_tfidf(single_document, query_terms)
|
||||
|
||||
assert len(scores) == 1
|
||||
# With only one document, IDF = log(1/1) = 0, so TF-IDF score is always 0
|
||||
# This is correct mathematical behavior - TF-IDF is designed to discriminate between documents
|
||||
assert scores[0] == 0.0
|
||||
|
||||
def test_compute_tfidf_two_documents_positive_scores(self):
|
||||
"""Test TF-IDF with two documents to ensure positive scores are possible."""
|
||||
documents = ["hello world", "goodbye earth"]
|
||||
query_terms = ["hello", "world"]
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
assert len(scores) == 2
|
||||
# First document contains both terms, should have positive score
|
||||
assert scores[0] > 0.0
|
||||
# Second document contains neither term, should have zero score
|
||||
assert scores[1] == 0.0
|
||||
|
||||
def test_compute_tfidf_no_documents(self):
|
||||
"""Test TF-IDF with no documents."""
|
||||
scores = compute_tfidf([], ["cat", "dog"])
|
||||
|
||||
assert scores == []
|
||||
|
||||
def test_compute_tfidf_term_frequency_calculation(self):
|
||||
"""Test TF-IDF term frequency calculation."""
|
||||
# Documents with different term frequencies for the same term
|
||||
documents = ["rare word text", "rare rare word", "other content"]
|
||||
query_terms = ["rare"]
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
# "rare" appears in documents 0 and 1, but with different frequencies
|
||||
# Document 1 has higher TF (2/3 vs 1/3), so should score higher
|
||||
assert scores[0] > 0.0 # Contains "rare" once
|
||||
assert scores[1] > scores[0] # Contains "rare" twice, should score higher
|
||||
assert scores[2] == 0.0 # Doesn't contain "rare"
|
||||
|
||||
def test_compute_tfidf_idf_calculation(self):
|
||||
"""Test TF-IDF inverse document frequency calculation."""
|
||||
# "rare" appears in only one document, "common" appears in both
|
||||
documents = ["rare term", "common term", "common word"]
|
||||
query_terms = ["rare", "common"]
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
# First document should have higher score due to rare term having higher IDF
|
||||
assert scores[0] > scores[1] # rare term gets higher IDF
|
||||
assert scores[0] > scores[2]
|
||||
|
||||
def test_compute_bm25_basic(self, sample_documents, query_terms):
|
||||
"""Test basic BM25 computation."""
|
||||
scores = compute_bm25(sample_documents, query_terms)
|
||||
|
||||
# Should return a score for each document
|
||||
assert len(scores) == len(sample_documents)
|
||||
|
||||
# All scores should be floats
|
||||
assert all(isinstance(score, float) for score in scores)
|
||||
|
||||
# First document contains "cat", should have non-zero score
|
||||
assert scores[0] > 0.0
|
||||
|
||||
# Second document contains "dog", should have non-zero score
|
||||
assert scores[1] > 0.0
|
||||
|
||||
# Fourth document contains neither term, should have zero score
|
||||
assert scores[3] == 0.0
|
||||
|
||||
def test_compute_bm25_parameters(self, sample_documents, query_terms):
|
||||
"""Test BM25 with different k1 and b parameters."""
|
||||
# Test with default parameters
|
||||
scores_default = compute_bm25(sample_documents, query_terms)
|
||||
|
||||
# Test with different k1
|
||||
scores_k1 = compute_bm25(sample_documents, query_terms, k1=2.0)
|
||||
|
||||
# Test with different b
|
||||
scores_b = compute_bm25(sample_documents, query_terms, b=0.5)
|
||||
|
||||
# Test with both different
|
||||
scores_both = compute_bm25(sample_documents, query_terms, k1=2.0, b=0.5)
|
||||
|
||||
# All should return valid scores
|
||||
assert len(scores_default) == len(sample_documents)
|
||||
assert len(scores_k1) == len(sample_documents)
|
||||
assert len(scores_b) == len(sample_documents)
|
||||
assert len(scores_both) == len(sample_documents)
|
||||
|
||||
# Scores should be different with different parameters
|
||||
assert scores_default != scores_k1
|
||||
assert scores_default != scores_b
|
||||
|
||||
def test_compute_bm25_case_insensitive(self):
|
||||
"""Test that BM25 computation is case insensitive."""
|
||||
documents = ["The CAT sat", "the dog RAN", "CATS and DOGS"]
|
||||
query_terms = ["cat", "DOG"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# First document should match "cat" (case insensitive)
|
||||
assert scores[0] > 0.0
|
||||
|
||||
# Second document should match "dog" (case insensitive)
|
||||
assert scores[1] > 0.0
|
||||
|
||||
def test_compute_bm25_empty_documents(self, empty_documents, query_terms):
|
||||
"""Test BM25 with empty documents."""
|
||||
scores = compute_bm25(empty_documents, query_terms)
|
||||
|
||||
# Should return scores for all documents
|
||||
assert len(scores) == len(empty_documents)
|
||||
|
||||
# All scores should be zero since documents are empty
|
||||
assert all(score == 0.0 for score in scores)
|
||||
|
||||
def test_compute_bm25_empty_query_terms(self, sample_documents):
|
||||
"""Test BM25 with empty query terms."""
|
||||
scores = compute_bm25(sample_documents, [])
|
||||
|
||||
# Should return scores for all documents
|
||||
assert len(scores) == len(sample_documents)
|
||||
|
||||
# All scores should be zero since no query terms
|
||||
assert all(score == 0.0 for score in scores)
|
||||
|
||||
def test_compute_bm25_single_document(self, single_document):
|
||||
"""Test BM25 with single document."""
|
||||
query_terms = ["hello", "world"]
|
||||
scores = compute_bm25(single_document, query_terms)
|
||||
|
||||
assert len(scores) == 1
|
||||
# With only one document, IDF = log(1/1) = 0, so BM25 score is always 0
|
||||
# This is correct mathematical behavior - both TF-IDF and BM25 are designed to discriminate between documents
|
||||
assert scores[0] == 0.0
|
||||
|
||||
def test_compute_bm25_two_documents_positive_scores(self):
|
||||
"""Test BM25 with two documents to ensure positive scores are possible."""
|
||||
documents = ["hello world", "goodbye earth"]
|
||||
query_terms = ["hello", "world"]
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
assert len(scores) == 2
|
||||
# First document contains both terms, should have positive score
|
||||
assert scores[0] > 0.0
|
||||
# Second document contains neither term, should have zero score
|
||||
assert scores[1] == 0.0
|
||||
|
||||
def test_compute_bm25_no_documents(self):
|
||||
"""Test BM25 with no documents."""
|
||||
scores = compute_bm25([], ["cat", "dog"])
|
||||
|
||||
assert scores == []
|
||||
|
||||
def test_compute_bm25_document_length_normalization(self):
|
||||
"""Test BM25 document length normalization."""
|
||||
# Test with documents where some terms appear in subset of documents
|
||||
documents = [
|
||||
"cat unique1", # Short document with unique term
|
||||
"cat dog bird mouse elephant tiger lion bear wolf unique2", # Long document with unique term
|
||||
"other content", # Document without query terms
|
||||
]
|
||||
query_terms = ["unique1", "unique2"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# Documents with unique terms should have positive scores
|
||||
assert scores[0] > 0.0 # Contains "unique1"
|
||||
assert scores[1] > 0.0 # Contains "unique2"
|
||||
assert scores[2] == 0.0 # Contains neither term
|
||||
|
||||
# Document length normalization affects scores
|
||||
assert len(scores) == 3
|
||||
|
||||
def test_compute_bm25_term_frequency_saturation(self):
|
||||
"""Test BM25 term frequency saturation behavior."""
|
||||
# Test with documents where term frequencies can be meaningfully compared
|
||||
documents = [
|
||||
"rare word text", # TF = 1 for "rare"
|
||||
"rare rare word", # TF = 2 for "rare"
|
||||
"rare rare rare rare rare word", # TF = 5 for "rare"
|
||||
"other content", # No "rare" term
|
||||
]
|
||||
query_terms = ["rare"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# Documents with the term should have positive scores
|
||||
assert scores[0] > 0.0 # TF=1
|
||||
assert scores[1] > 0.0 # TF=2
|
||||
assert scores[2] > 0.0 # TF=5
|
||||
assert scores[3] == 0.0 # TF=0
|
||||
|
||||
# Scores should increase with term frequency, but with diminishing returns
|
||||
assert scores[1] > scores[0] # TF=2 > TF=1
|
||||
assert scores[2] > scores[1] # TF=5 > TF=2
|
||||
|
||||
# Check that increases demonstrate saturation effect
|
||||
increase_1_to_2 = scores[1] - scores[0]
|
||||
increase_2_to_5 = scores[2] - scores[1]
|
||||
assert increase_1_to_2 > 0
|
||||
assert increase_2_to_5 > 0
|
||||
|
||||
def test_compute_bm25_idf_calculation(self):
|
||||
"""Test BM25 inverse document frequency calculation."""
|
||||
# "rare" appears in only one document, "common" appears in multiple
|
||||
documents = ["rare term", "common term", "common word"]
|
||||
query_terms = ["rare", "common"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# First document should have higher score due to rare term having higher IDF
|
||||
assert scores[0] > scores[1] # rare term gets higher IDF
|
||||
assert scores[0] > scores[2]
|
||||
|
||||
def test_compute_bm25_zero_parameters(self, sample_documents, query_terms):
|
||||
"""Test BM25 with edge case parameters."""
|
||||
# Test with k1=0 (no term frequency scaling)
|
||||
scores_k1_zero = compute_bm25(sample_documents, query_terms, k1=0.0)
|
||||
assert len(scores_k1_zero) == len(sample_documents)
|
||||
|
||||
# Test with b=0 (no document length normalization)
|
||||
scores_b_zero = compute_bm25(sample_documents, query_terms, b=0.0)
|
||||
assert len(scores_b_zero) == len(sample_documents)
|
||||
|
||||
# Test with b=1 (full document length normalization)
|
||||
scores_b_one = compute_bm25(sample_documents, query_terms, b=1.0)
|
||||
assert len(scores_b_one) == len(sample_documents)
|
||||
|
||||
def test_tfidf_vs_bm25_comparison(self, sample_documents, query_terms):
|
||||
"""Test that TF-IDF and BM25 produce different but related scores."""
|
||||
tfidf_scores = compute_tfidf(sample_documents, query_terms)
|
||||
bm25_scores = compute_bm25(sample_documents, query_terms)
|
||||
|
||||
# Both should return same number of scores
|
||||
assert len(tfidf_scores) == len(bm25_scores) == len(sample_documents)
|
||||
|
||||
# For documents that match, both should be positive
|
||||
for i in range(len(sample_documents)):
|
||||
if tfidf_scores[i] > 0:
|
||||
assert bm25_scores[i] > 0, f"Document {i} has TF-IDF score but zero BM25 score"
|
||||
if bm25_scores[i] > 0:
|
||||
assert tfidf_scores[i] > 0, f"Document {i} has BM25 score but zero TF-IDF score"
|
||||
|
||||
def test_compute_tfidf_special_characters(self):
|
||||
"""Test TF-IDF with documents containing special characters."""
|
||||
documents = ["hello, world!", "world... hello?", "no match here"]
|
||||
query_terms = ["hello", "world"]
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
# Should handle punctuation and still match terms
|
||||
assert len(scores) == 3
|
||||
# Note: Current implementation does simple split(), so punctuation stays attached
|
||||
# This tests the current behavior - may need updating if tokenization improves
|
||||
|
||||
def test_compute_bm25_special_characters(self):
|
||||
"""Test BM25 with documents containing special characters."""
|
||||
documents = ["hello, world!", "world... hello?", "no match here"]
|
||||
query_terms = ["hello", "world"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# Should handle punctuation and still match terms
|
||||
assert len(scores) == 3
|
||||
# Same tokenization behavior as TF-IDF
|
||||
|
||||
def test_compute_tfidf_whitespace_handling(self):
|
||||
"""Test TF-IDF with various whitespace scenarios."""
|
||||
documents = [
|
||||
" hello world ", # Extra spaces
|
||||
"\thello\tworld\t", # Tabs
|
||||
"hello\nworld", # Newlines
|
||||
"", # Empty string
|
||||
]
|
||||
query_terms = ["hello", "world"]
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
assert len(scores) == 4
|
||||
# First three should have positive scores (they contain the terms)
|
||||
assert scores[0] > 0.0
|
||||
assert scores[1] > 0.0
|
||||
assert scores[2] > 0.0
|
||||
# Last should be zero (empty document)
|
||||
assert scores[3] == 0.0
|
||||
|
||||
def test_compute_bm25_whitespace_handling(self):
|
||||
"""Test BM25 with various whitespace scenarios."""
|
||||
documents = [
|
||||
" hello world ", # Extra spaces
|
||||
"\thello\tworld\t", # Tabs
|
||||
"hello\nworld", # Newlines
|
||||
"", # Empty string
|
||||
]
|
||||
query_terms = ["hello", "world"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
assert len(scores) == 4
|
||||
# First three should have positive scores (they contain the terms)
|
||||
assert scores[0] > 0.0
|
||||
assert scores[1] > 0.0
|
||||
assert scores[2] > 0.0
|
||||
# Last should be zero (empty document)
|
||||
assert scores[3] == 0.0
|
||||
|
||||
def test_compute_tfidf_mathematical_properties(self):
|
||||
"""Test mathematical properties of TF-IDF scores."""
|
||||
documents = ["cat dog", "cat", "dog"]
|
||||
query_terms = ["cat"]
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
# All scores should be non-negative
|
||||
assert all(score >= 0.0 for score in scores)
|
||||
|
||||
# Documents containing the term should have positive scores
|
||||
assert scores[0] > 0.0 # contains "cat"
|
||||
assert scores[1] > 0.0 # contains "cat"
|
||||
assert scores[2] == 0.0 # doesn't contain "cat"
|
||||
|
||||
def test_compute_bm25_mathematical_properties(self):
|
||||
"""Test mathematical properties of BM25 scores."""
|
||||
documents = ["cat dog", "cat", "dog"]
|
||||
query_terms = ["cat"]
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# All scores should be non-negative
|
||||
assert all(score >= 0.0 for score in scores)
|
||||
|
||||
# Documents containing the term should have positive scores
|
||||
assert scores[0] > 0.0 # contains "cat"
|
||||
assert scores[1] > 0.0 # contains "cat"
|
||||
assert scores[2] == 0.0 # doesn't contain "cat"
|
||||
|
||||
def test_compute_tfidf_duplicate_terms_in_query(self):
|
||||
"""Test TF-IDF with duplicate terms in query."""
|
||||
documents = ["cat dog bird", "cat cat dog", "bird bird bird"]
|
||||
query_terms = ["cat", "cat", "dog"] # "cat" appears twice
|
||||
|
||||
scores = compute_tfidf(documents, query_terms)
|
||||
|
||||
# Should handle duplicate query terms gracefully
|
||||
assert len(scores) == 3
|
||||
assert all(isinstance(score, float) for score in scores)
|
||||
|
||||
# First two documents should have positive scores
|
||||
assert scores[0] > 0.0
|
||||
assert scores[1] > 0.0
|
||||
# Third document only contains "bird", so should have zero score
|
||||
assert scores[2] == 0.0
|
||||
|
||||
def test_compute_bm25_duplicate_terms_in_query(self):
|
||||
"""Test BM25 with duplicate terms in query."""
|
||||
documents = ["cat dog bird", "cat cat dog", "bird bird bird"]
|
||||
query_terms = ["cat", "cat", "dog"] # "cat" appears twice
|
||||
|
||||
scores = compute_bm25(documents, query_terms)
|
||||
|
||||
# Should handle duplicate query terms gracefully
|
||||
assert len(scores) == 3
|
||||
assert all(isinstance(score, float) for score in scores)
|
||||
|
||||
# First two documents should have positive scores
|
||||
assert scores[0] > 0.0
|
||||
assert scores[1] > 0.0
|
||||
# Third document only contains "bird", so should have zero score
|
||||
assert scores[2] == 0.0
|
||||
392
src/backend/tests/unit/components/data/test_kb_ingest.py
Normal file
392
src/backend/tests/unit/components/data/test_kb_ingest.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from langflow.components.data.kb_ingest import KBIngestionComponent
|
||||
from langflow.schema.data import Data
|
||||
|
||||
from tests.base import ComponentTestBaseWithoutClient
|
||||
|
||||
|
||||
class TestKBIngestionComponent(ComponentTestBaseWithoutClient):
|
||||
@pytest.fixture
|
||||
def component_class(self):
|
||||
"""Return the component class to test."""
|
||||
return KBIngestionComponent
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_knowledge_base_path(self, tmp_path):
|
||||
"""Mock the knowledge base root path directly."""
|
||||
with patch("langflow.components.data.kb_ingest.KNOWLEDGE_BASES_ROOT_PATH", tmp_path):
|
||||
yield
|
||||
|
||||
@pytest.fixture
|
||||
def default_kwargs(self, tmp_path):
|
||||
"""Return default kwargs for component instantiation."""
|
||||
# Create a sample DataFrame
|
||||
data_df = pd.DataFrame(
|
||||
{"text": ["Sample text 1", "Sample text 2"], "title": ["Title 1", "Title 2"], "category": ["cat1", "cat2"]}
|
||||
)
|
||||
|
||||
# Create column configuration
|
||||
column_config = [
|
||||
{"column_name": "text", "vectorize": True, "identifier": False},
|
||||
{"column_name": "title", "vectorize": False, "identifier": False},
|
||||
{"column_name": "category", "vectorize": False, "identifier": True},
|
||||
]
|
||||
|
||||
# Create knowledge base directory
|
||||
kb_name = "test_kb"
|
||||
kb_path = tmp_path / kb_name
|
||||
kb_path.mkdir(exist_ok=True)
|
||||
|
||||
# Create embedding metadata file
|
||||
metadata = {
|
||||
"embedding_provider": "HuggingFace",
|
||||
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"api_key": None,
|
||||
"api_key_used": False,
|
||||
"chunk_size": 1000,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
(kb_path / "embedding_metadata.json").write_text(json.dumps(metadata))
|
||||
|
||||
return {
|
||||
"knowledge_base": kb_name,
|
||||
"input_df": data_df,
|
||||
"column_config": column_config,
|
||||
"chunk_size": 1000,
|
||||
"kb_root_path": str(tmp_path),
|
||||
"api_key": None,
|
||||
"allow_duplicates": False,
|
||||
"silent_errors": False,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def file_names_mapping(self):
|
||||
"""Return file names mapping for version testing."""
|
||||
# This is a new component, so it doesn't exist in older versions
|
||||
return []
|
||||
|
||||
def test_validate_column_config_valid(self, component_class, default_kwargs):
|
||||
"""Test column configuration validation with valid config."""
|
||||
component = component_class(**default_kwargs)
|
||||
data_df = default_kwargs["input_df"]
|
||||
|
||||
config_list = component._validate_column_config(data_df)
|
||||
|
||||
assert len(config_list) == 3
|
||||
assert config_list[0]["column_name"] == "text"
|
||||
assert config_list[0]["vectorize"] is True
|
||||
|
||||
def test_validate_column_config_invalid_column(self, component_class, default_kwargs):
|
||||
"""Test column configuration validation with invalid column name."""
|
||||
# Modify column config to include non-existent column
|
||||
invalid_config = [{"column_name": "nonexistent", "vectorize": True, "identifier": False}]
|
||||
default_kwargs["column_config"] = invalid_config
|
||||
|
||||
component = component_class(**default_kwargs)
|
||||
data_df = default_kwargs["input_df"]
|
||||
|
||||
with pytest.raises(ValueError, match="Column 'nonexistent' not found in DataFrame"):
|
||||
component._validate_column_config(data_df)
|
||||
|
||||
def test_validate_column_config_silent_errors(self, component_class, default_kwargs):
|
||||
"""Test column configuration validation with silent errors enabled."""
|
||||
# Modify column config to include non-existent column
|
||||
invalid_config = [{"column_name": "nonexistent", "vectorize": True, "identifier": False}]
|
||||
default_kwargs["column_config"] = invalid_config
|
||||
default_kwargs["silent_errors"] = True
|
||||
|
||||
component = component_class(**default_kwargs)
|
||||
data_df = default_kwargs["input_df"]
|
||||
|
||||
# Should not raise exception with silent_errors=True
|
||||
config_list = component._validate_column_config(data_df)
|
||||
assert isinstance(config_list, list)
|
||||
|
||||
def test_get_embedding_provider(self, component_class, default_kwargs):
|
||||
"""Test embedding provider detection."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Test OpenAI provider
|
||||
assert component._get_embedding_provider("text-embedding-ada-002") == "OpenAI"
|
||||
|
||||
# Test HuggingFace provider
|
||||
assert component._get_embedding_provider("sentence-transformers/all-MiniLM-L6-v2") == "HuggingFace"
|
||||
|
||||
# Test Cohere provider
|
||||
assert component._get_embedding_provider("embed-english-v3.0") == "Cohere"
|
||||
|
||||
# Test custom provider
|
||||
assert component._get_embedding_provider("custom-model") == "Custom"
|
||||
|
||||
@patch("langchain_huggingface.HuggingFaceEmbeddings")
|
||||
def test_build_embeddings_huggingface(self, mock_hf_embeddings, component_class, default_kwargs):
|
||||
"""Test building HuggingFace embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_hf_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings("sentence-transformers/all-MiniLM-L6-v2", None)
|
||||
|
||||
mock_hf_embeddings.assert_called_once_with(model="sentence-transformers/all-MiniLM-L6-v2")
|
||||
assert result == mock_embeddings
|
||||
|
||||
@patch("langchain_openai.OpenAIEmbeddings")
|
||||
def test_build_embeddings_openai(self, mock_openai_embeddings, component_class, default_kwargs):
|
||||
"""Test building OpenAI embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_openai_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings("text-embedding-ada-002", "test-api-key")
|
||||
|
||||
mock_openai_embeddings.assert_called_once_with(
|
||||
model="text-embedding-ada-002", api_key="test-api-key", chunk_size=1000
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_openai_no_key(self, component_class, default_kwargs):
|
||||
"""Test building OpenAI embeddings without API key raises error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with pytest.raises(ValueError, match="OpenAI API key is required"):
|
||||
component._build_embeddings("text-embedding-ada-002", None)
|
||||
|
||||
@patch("langchain_cohere.CohereEmbeddings")
|
||||
def test_build_embeddings_cohere(self, mock_cohere_embeddings, component_class, default_kwargs):
|
||||
"""Test building Cohere embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_cohere_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings("embed-english-v3.0", "test-api-key")
|
||||
|
||||
mock_cohere_embeddings.assert_called_once_with(model="embed-english-v3.0", cohere_api_key="test-api-key")
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_cohere_no_key(self, component_class, default_kwargs):
|
||||
"""Test building Cohere embeddings without API key raises error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with pytest.raises(ValueError, match="Cohere API key is required"):
|
||||
component._build_embeddings("embed-english-v3.0", None)
|
||||
|
||||
def test_build_embeddings_custom_not_supported(self, component_class, default_kwargs):
|
||||
"""Test building custom embeddings raises NotImplementedError."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Custom embedding models not yet supported"):
|
||||
component._build_embeddings("custom-model", "test-key")
|
||||
|
||||
@patch("langflow.components.data.kb_ingest.get_settings_service")
|
||||
@patch("langflow.components.data.kb_ingest.encrypt_api_key")
|
||||
def test_build_embedding_metadata(self, mock_encrypt, mock_get_settings, component_class, default_kwargs):
|
||||
"""Test building embedding metadata."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_get_settings.return_value = mock_settings
|
||||
mock_encrypt.return_value = "encrypted_key"
|
||||
|
||||
metadata = component._build_embedding_metadata("sentence-transformers/all-MiniLM-L6-v2", "test-key")
|
||||
|
||||
assert metadata["embedding_provider"] == "HuggingFace"
|
||||
assert metadata["embedding_model"] == "sentence-transformers/all-MiniLM-L6-v2"
|
||||
assert metadata["api_key"] == "encrypted_key"
|
||||
assert metadata["api_key_used"] is True
|
||||
assert metadata["chunk_size"] == 1000
|
||||
assert "created_at" in metadata
|
||||
|
||||
def test_build_column_metadata(self, component_class, default_kwargs):
|
||||
"""Test building column metadata."""
|
||||
component = component_class(**default_kwargs)
|
||||
data_df = default_kwargs["input_df"]
|
||||
config_list = default_kwargs["column_config"]
|
||||
|
||||
metadata = component._build_column_metadata(config_list, data_df)
|
||||
|
||||
assert metadata["total_columns"] == 3
|
||||
assert metadata["mapped_columns"] == 3
|
||||
assert metadata["unmapped_columns"] == 0
|
||||
assert len(metadata["columns"]) == 3
|
||||
assert "text" in metadata["summary"]["vectorized_columns"]
|
||||
assert "category" in metadata["summary"]["identifier_columns"]
|
||||
|
||||
def test_convert_df_to_data_objects(self, component_class, default_kwargs):
|
||||
"""Test converting DataFrame to Data objects."""
|
||||
component = component_class(**default_kwargs)
|
||||
data_df = default_kwargs["input_df"]
|
||||
config_list = default_kwargs["column_config"]
|
||||
|
||||
# Mock Chroma to avoid actual vector store operations
|
||||
with patch("langflow.components.data.kb_ingest.Chroma") as mock_chroma:
|
||||
mock_chroma_instance = MagicMock()
|
||||
mock_chroma_instance.get.return_value = {"metadatas": []}
|
||||
mock_chroma.return_value = mock_chroma_instance
|
||||
|
||||
data_objects = component._convert_df_to_data_objects(data_df, config_list)
|
||||
|
||||
assert len(data_objects) == 2
|
||||
assert all(isinstance(obj, Data) for obj in data_objects)
|
||||
|
||||
# Check first data object
|
||||
first_obj = data_objects[0]
|
||||
assert "text" in first_obj.data
|
||||
assert "title" in first_obj.data
|
||||
assert "category" in first_obj.data
|
||||
assert "_id" in first_obj.data
|
||||
|
||||
def test_convert_df_to_data_objects_no_duplicates(self, component_class, default_kwargs):
|
||||
"""Test converting DataFrame to Data objects with duplicate prevention."""
|
||||
default_kwargs["allow_duplicates"] = False
|
||||
component = component_class(**default_kwargs)
|
||||
data_df = default_kwargs["input_df"]
|
||||
config_list = default_kwargs["column_config"]
|
||||
|
||||
# Mock Chroma with existing hash
|
||||
with patch("langflow.components.data.kb_ingest.Chroma") as mock_chroma:
|
||||
# Simulate existing document with same hash
|
||||
existing_hash = "some_existing_hash"
|
||||
mock_chroma_instance = MagicMock()
|
||||
mock_chroma_instance.get.return_value = {"metadatas": [{"_id": existing_hash}]}
|
||||
mock_chroma.return_value = mock_chroma_instance
|
||||
|
||||
# Mock hashlib to return the existing hash for first row
|
||||
with patch("langflow.components.data.kb_ingest.hashlib.sha256") as mock_hash:
|
||||
mock_hash_obj = MagicMock()
|
||||
mock_hash_obj.hexdigest.side_effect = [existing_hash, "different_hash"]
|
||||
mock_hash.return_value = mock_hash_obj
|
||||
|
||||
data_objects = component._convert_df_to_data_objects(data_df, config_list)
|
||||
|
||||
# Should only return one object (second row) since first is duplicate
|
||||
assert len(data_objects) == 1
|
||||
|
||||
def test_is_valid_collection_name(self, component_class, default_kwargs):
|
||||
"""Test collection name validation."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Valid names
|
||||
assert component.is_valid_collection_name("valid_name") is True
|
||||
assert component.is_valid_collection_name("valid-name") is True
|
||||
assert component.is_valid_collection_name("ValidName123") is True
|
||||
|
||||
# Invalid names
|
||||
assert component.is_valid_collection_name("ab") is False # Too short
|
||||
assert component.is_valid_collection_name("a" * 64) is False # Too long
|
||||
assert component.is_valid_collection_name("_invalid") is False # Starts with underscore
|
||||
assert component.is_valid_collection_name("invalid_") is False # Ends with underscore
|
||||
assert component.is_valid_collection_name("invalid@name") is False # Invalid character
|
||||
|
||||
@patch("langflow.components.data.kb_ingest.json.loads")
|
||||
@patch("langflow.components.data.kb_ingest.decrypt_api_key")
|
||||
def test_build_kb_info_success(self, mock_decrypt, mock_json_loads, component_class, default_kwargs):
|
||||
"""Test successful KB info building."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Mock metadata loading
|
||||
mock_json_loads.return_value = {
|
||||
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"api_key": "encrypted_key",
|
||||
}
|
||||
mock_decrypt.return_value = "decrypted_key"
|
||||
|
||||
# Mock vector store creation
|
||||
with patch.object(component, "_create_vector_store"), patch.object(component, "_save_kb_files"):
|
||||
result = component.build_kb_info()
|
||||
|
||||
assert isinstance(result, Data)
|
||||
assert "kb_id" in result.data
|
||||
assert "kb_name" in result.data
|
||||
assert "rows" in result.data
|
||||
assert result.data["rows"] == 2
|
||||
|
||||
def test_build_kb_info_with_silent_errors(self, component_class, default_kwargs):
|
||||
"""Test KB info building with silent errors enabled."""
|
||||
default_kwargs["silent_errors"] = True
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Remove the metadata file to cause an error
|
||||
kb_path = Path(default_kwargs["kb_root_path"]) / default_kwargs["knowledge_base"]
|
||||
metadata_file = kb_path / "embedding_metadata.json"
|
||||
if metadata_file.exists():
|
||||
metadata_file.unlink()
|
||||
|
||||
# Should not raise exception with silent_errors=True
|
||||
result = component.build_kb_info()
|
||||
assert isinstance(result, Data)
|
||||
assert "error" in result.data
|
||||
|
||||
def test_get_knowledge_bases(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test getting list of knowledge bases."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Create additional test directories
|
||||
(tmp_path / "kb1").mkdir()
|
||||
(tmp_path / "kb2").mkdir()
|
||||
(tmp_path / ".hidden").mkdir() # Should be ignored
|
||||
|
||||
kb_list = component._get_knowledge_bases()
|
||||
|
||||
assert "test_kb" in kb_list
|
||||
assert "kb1" in kb_list
|
||||
assert "kb2" in kb_list
|
||||
assert ".hidden" not in kb_list
|
||||
|
||||
@patch("langflow.components.data.kb_ingest.Path.exists")
|
||||
def test_get_knowledge_bases_no_path(self, mock_exists, component_class, default_kwargs):
|
||||
"""Test getting knowledge bases when path doesn't exist."""
|
||||
component = component_class(**default_kwargs)
|
||||
mock_exists.return_value = False
|
||||
|
||||
kb_list = component._get_knowledge_bases()
|
||||
assert kb_list == []
|
||||
|
||||
def test_update_build_config_new_kb(self, component_class, default_kwargs):
|
||||
"""Test updating build config for new knowledge base creation."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
build_config = {"knowledge_base": {"value": None, "options": []}}
|
||||
|
||||
field_value = {
|
||||
"01_new_kb_name": "new_test_kb",
|
||||
"02_embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"03_api_key": None,
|
||||
}
|
||||
|
||||
# Mock embedding validation
|
||||
with (
|
||||
patch.object(component, "_build_embeddings") as mock_build_emb,
|
||||
patch.object(component, "_save_embedding_metadata"),
|
||||
patch.object(component, "_get_knowledge_bases") as mock_get_kbs,
|
||||
):
|
||||
mock_embeddings = MagicMock()
|
||||
mock_embeddings.embed_query.return_value = [0.1, 0.2, 0.3]
|
||||
mock_build_emb.return_value = mock_embeddings
|
||||
mock_get_kbs.return_value = ["new_test_kb"]
|
||||
|
||||
result = component.update_build_config(build_config, field_value, "knowledge_base")
|
||||
|
||||
assert result["knowledge_base"]["value"] == "new_test_kb"
|
||||
assert "new_test_kb" in result["knowledge_base"]["options"]
|
||||
|
||||
def test_update_build_config_invalid_kb_name(self, component_class, default_kwargs):
|
||||
"""Test updating build config with invalid KB name."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
build_config = {"knowledge_base": {"value": None, "options": []}}
|
||||
field_value = {
|
||||
"01_new_kb_name": "invalid@name", # Invalid character
|
||||
"02_embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"03_api_key": None,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid knowledge base name"):
|
||||
component.update_build_config(build_config, field_value, "knowledge_base")
|
||||
368
src/backend/tests/unit/components/data/test_kb_retrieval.py
Normal file
368
src/backend/tests/unit/components/data/test_kb_retrieval.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import contextlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langflow.components.data.kb_retrieval import KBRetrievalComponent
|
||||
|
||||
from tests.base import ComponentTestBaseWithoutClient
|
||||
|
||||
|
||||
class TestKBRetrievalComponent(ComponentTestBaseWithoutClient):
|
||||
@pytest.fixture
|
||||
def component_class(self):
|
||||
"""Return the component class to test."""
|
||||
return KBRetrievalComponent
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_knowledge_base_path(self, tmp_path):
|
||||
"""Mock the knowledge base root path directly."""
|
||||
with patch("langflow.components.data.kb_retrieval.KNOWLEDGE_BASES_ROOT_PATH", tmp_path):
|
||||
yield
|
||||
|
||||
@pytest.fixture
|
||||
def default_kwargs(self, tmp_path):
|
||||
"""Return default kwargs for component instantiation."""
|
||||
# Create knowledge base directory structure
|
||||
kb_name = "test_kb"
|
||||
kb_path = tmp_path / kb_name
|
||||
kb_path.mkdir(exist_ok=True)
|
||||
|
||||
# Create embedding metadata file
|
||||
metadata = {
|
||||
"embedding_provider": "HuggingFace",
|
||||
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"api_key": None,
|
||||
"api_key_used": False,
|
||||
"chunk_size": 1000,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
(kb_path / "embedding_metadata.json").write_text(json.dumps(metadata))
|
||||
|
||||
return {
|
||||
"knowledge_base": kb_name,
|
||||
"kb_root_path": str(tmp_path),
|
||||
"api_key": None,
|
||||
"search_query": "",
|
||||
"top_k": 5,
|
||||
"include_embeddings": True,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def file_names_mapping(self):
|
||||
"""Return file names mapping for version testing."""
|
||||
# This is a new component, so it doesn't exist in older versions
|
||||
return []
|
||||
|
||||
def test_get_knowledge_bases(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test getting list of knowledge bases."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Create additional test directories
|
||||
(tmp_path / "kb1").mkdir()
|
||||
(tmp_path / "kb2").mkdir()
|
||||
(tmp_path / ".hidden").mkdir() # Should be ignored
|
||||
|
||||
kb_list = component._get_knowledge_bases()
|
||||
|
||||
assert "test_kb" in kb_list
|
||||
assert "kb1" in kb_list
|
||||
assert "kb2" in kb_list
|
||||
assert ".hidden" not in kb_list
|
||||
|
||||
@patch("langflow.components.data.kb_retrieval.Path.exists")
|
||||
def test_get_knowledge_bases_no_path(self, mock_exists, component_class, default_kwargs):
|
||||
"""Test getting knowledge bases when path doesn't exist."""
|
||||
component = component_class(**default_kwargs)
|
||||
mock_exists.return_value = False
|
||||
|
||||
kb_list = component._get_knowledge_bases()
|
||||
assert kb_list == []
|
||||
|
||||
def test_update_build_config(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test updating build configuration."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Create additional KB directories
|
||||
(tmp_path / "kb1").mkdir()
|
||||
(tmp_path / "kb2").mkdir()
|
||||
|
||||
build_config = {"knowledge_base": {"value": "test_kb", "options": []}}
|
||||
|
||||
result = component.update_build_config(build_config, None, "knowledge_base")
|
||||
|
||||
assert "test_kb" in result["knowledge_base"]["options"]
|
||||
assert "kb1" in result["knowledge_base"]["options"]
|
||||
assert "kb2" in result["knowledge_base"]["options"]
|
||||
|
||||
def test_update_build_config_invalid_kb(self, component_class, default_kwargs):
|
||||
"""Test updating build config when selected KB is not available."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
build_config = {"knowledge_base": {"value": "nonexistent_kb", "options": ["test_kb"]}}
|
||||
|
||||
result = component.update_build_config(build_config, None, "knowledge_base")
|
||||
|
||||
assert result["knowledge_base"]["value"] is None
|
||||
|
||||
def test_get_kb_metadata_success(self, component_class, default_kwargs):
|
||||
"""Test successful metadata loading."""
|
||||
component = component_class(**default_kwargs)
|
||||
kb_path = Path(default_kwargs["kb_root_path"]) / default_kwargs["knowledge_base"]
|
||||
|
||||
with patch("langflow.components.data.kb_retrieval.decrypt_api_key") as mock_decrypt:
|
||||
mock_decrypt.return_value = "decrypted_key"
|
||||
|
||||
metadata = component._get_kb_metadata(kb_path)
|
||||
|
||||
assert metadata["embedding_provider"] == "HuggingFace"
|
||||
assert metadata["embedding_model"] == "sentence-transformers/all-MiniLM-L6-v2"
|
||||
assert "chunk_size" in metadata
|
||||
|
||||
def test_get_kb_metadata_no_file(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test metadata loading when file doesn't exist."""
|
||||
component = component_class(**default_kwargs)
|
||||
nonexistent_path = tmp_path / "nonexistent"
|
||||
nonexistent_path.mkdir()
|
||||
|
||||
metadata = component._get_kb_metadata(nonexistent_path)
|
||||
|
||||
assert metadata == {}
|
||||
|
||||
def test_get_kb_metadata_json_error(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test metadata loading with invalid JSON."""
|
||||
component = component_class(**default_kwargs)
|
||||
kb_path = tmp_path / "invalid_json_kb"
|
||||
kb_path.mkdir()
|
||||
|
||||
# Create invalid JSON file
|
||||
(kb_path / "embedding_metadata.json").write_text("invalid json content")
|
||||
|
||||
metadata = component._get_kb_metadata(kb_path)
|
||||
|
||||
assert metadata == {}
|
||||
|
||||
def test_get_kb_metadata_decrypt_error(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test metadata loading with decryption error."""
|
||||
component = component_class(**default_kwargs)
|
||||
kb_path = tmp_path / "decrypt_error_kb"
|
||||
kb_path.mkdir()
|
||||
|
||||
# Create metadata with encrypted key
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": "encrypted_key",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
(kb_path / "embedding_metadata.json").write_text(json.dumps(metadata))
|
||||
|
||||
with patch("langflow.components.data.kb_retrieval.decrypt_api_key") as mock_decrypt:
|
||||
mock_decrypt.side_effect = ValueError("Decryption failed")
|
||||
|
||||
result = component._get_kb_metadata(kb_path)
|
||||
|
||||
assert result["api_key"] is None
|
||||
|
||||
@patch("langchain_huggingface.HuggingFaceEmbeddings")
|
||||
def test_build_embeddings_huggingface(self, mock_hf_embeddings, component_class, default_kwargs):
|
||||
"""Test building HuggingFace embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "HuggingFace",
|
||||
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_hf_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata)
|
||||
|
||||
mock_hf_embeddings.assert_called_once_with(model="sentence-transformers/all-MiniLM-L6-v2")
|
||||
assert result == mock_embeddings
|
||||
|
||||
@patch("langchain_openai.OpenAIEmbeddings")
|
||||
def test_build_embeddings_openai(self, mock_openai_embeddings, component_class, default_kwargs):
|
||||
"""Test building OpenAI embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": "test-api-key",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_openai_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata)
|
||||
|
||||
mock_openai_embeddings.assert_called_once_with(
|
||||
model="text-embedding-ada-002", api_key="test-api-key", chunk_size=1000
|
||||
)
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_openai_no_key(self, component_class, default_kwargs):
|
||||
"""Test building OpenAI embeddings without API key raises error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": None,
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="OpenAI API key is required"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
@patch("langchain_cohere.CohereEmbeddings")
|
||||
def test_build_embeddings_cohere(self, mock_cohere_embeddings, component_class, default_kwargs):
|
||||
"""Test building Cohere embeddings."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "Cohere",
|
||||
"embedding_model": "embed-english-v3.0",
|
||||
"api_key": "test-api-key",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
mock_embeddings = MagicMock()
|
||||
mock_cohere_embeddings.return_value = mock_embeddings
|
||||
|
||||
result = component._build_embeddings(metadata)
|
||||
|
||||
mock_cohere_embeddings.assert_called_once_with(model="embed-english-v3.0", cohere_api_key="test-api-key")
|
||||
assert result == mock_embeddings
|
||||
|
||||
def test_build_embeddings_cohere_no_key(self, component_class, default_kwargs):
|
||||
"""Test building Cohere embeddings without API key raises error."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "Cohere",
|
||||
"embedding_model": "embed-english-v3.0",
|
||||
"api_key": None,
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Cohere API key is required"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
def test_build_embeddings_custom_not_supported(self, component_class, default_kwargs):
|
||||
"""Test building custom embeddings raises NotImplementedError."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {"embedding_provider": "Custom", "embedding_model": "custom-model", "api_key": "test-key"}
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Custom embedding models not yet supported"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
def test_build_embeddings_unsupported_provider(self, component_class, default_kwargs):
|
||||
"""Test building embeddings with unsupported provider raises NotImplementedError."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {"embedding_provider": "UnsupportedProvider", "embedding_model": "some-model", "api_key": "test-key"}
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Embedding provider 'UnsupportedProvider' is not supported"):
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
def test_build_embeddings_with_user_api_key(self, component_class, default_kwargs):
|
||||
"""Test that user-provided API key overrides stored one."""
|
||||
# Create a mock secret input
|
||||
|
||||
mock_secret = MagicMock()
|
||||
mock_secret.get_secret_value.return_value = "user-provided-key"
|
||||
|
||||
default_kwargs["api_key"] = mock_secret
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
metadata = {
|
||||
"embedding_provider": "OpenAI",
|
||||
"embedding_model": "text-embedding-ada-002",
|
||||
"api_key": "stored-key",
|
||||
"chunk_size": 1000,
|
||||
}
|
||||
|
||||
with patch("langchain_openai.OpenAIEmbeddings") as mock_openai:
|
||||
mock_embeddings = MagicMock()
|
||||
mock_openai.return_value = mock_embeddings
|
||||
|
||||
component._build_embeddings(metadata)
|
||||
|
||||
mock_openai.assert_called_once_with(
|
||||
model="text-embedding-ada-002", api_key="user-provided-key", chunk_size=1000
|
||||
)
|
||||
|
||||
def test_get_chroma_kb_data_no_metadata(self, component_class, default_kwargs, tmp_path):
|
||||
"""Test retrieving data when metadata is missing."""
|
||||
# Remove metadata file
|
||||
kb_path = tmp_path / default_kwargs["knowledge_base"]
|
||||
metadata_file = kb_path / "embedding_metadata.json"
|
||||
if metadata_file.exists():
|
||||
metadata_file.unlink()
|
||||
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
with pytest.raises(ValueError, match="Metadata not found for knowledge base"):
|
||||
component.get_chroma_kb_data()
|
||||
|
||||
def test_get_chroma_kb_data_path_construction(self, component_class, default_kwargs):
|
||||
"""Test that get_chroma_kb_data constructs the correct paths."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Test that the component correctly builds the KB path
|
||||
|
||||
assert component.kb_root_path == default_kwargs["kb_root_path"]
|
||||
assert component.knowledge_base == default_kwargs["knowledge_base"]
|
||||
|
||||
# Test that paths are correctly expanded
|
||||
expanded_path = Path(component.kb_root_path).expanduser()
|
||||
assert expanded_path.exists() # tmp_path should exist
|
||||
|
||||
# Verify method exists with correct parameters
|
||||
assert hasattr(component, "get_chroma_kb_data")
|
||||
assert hasattr(component, "search_query")
|
||||
assert hasattr(component, "top_k")
|
||||
assert hasattr(component, "include_embeddings")
|
||||
|
||||
def test_get_chroma_kb_data_method_exists(self, component_class, default_kwargs):
|
||||
"""Test that get_chroma_kb_data method exists and can be called."""
|
||||
component = component_class(**default_kwargs)
|
||||
|
||||
# Just verify the method exists and has the right signature
|
||||
assert hasattr(component, "get_chroma_kb_data"), "Component should have get_chroma_kb_data method"
|
||||
|
||||
# Mock all external calls to avoid integration issues
|
||||
with (
|
||||
patch.object(component, "_get_kb_metadata") as mock_get_metadata,
|
||||
patch.object(component, "_build_embeddings") as mock_build_embeddings,
|
||||
patch("langchain_chroma.Chroma"),
|
||||
):
|
||||
mock_get_metadata.return_value = {"embedding_provider": "HuggingFace", "embedding_model": "test-model"}
|
||||
mock_build_embeddings.return_value = MagicMock()
|
||||
|
||||
# This is a unit test focused on the component's internal logic
|
||||
with contextlib.suppress(Exception):
|
||||
component.get_chroma_kb_data()
|
||||
|
||||
# Verify internal methods were called
|
||||
mock_get_metadata.assert_called_once()
|
||||
mock_build_embeddings.assert_called_once()
|
||||
|
||||
def test_include_embeddings_parameter(self, component_class, default_kwargs):
|
||||
"""Test that include_embeddings parameter is properly set."""
|
||||
# Test with embeddings enabled
|
||||
default_kwargs["include_embeddings"] = True
|
||||
component = component_class(**default_kwargs)
|
||||
assert component.include_embeddings is True
|
||||
|
||||
# Test with embeddings disabled
|
||||
default_kwargs["include_embeddings"] = False
|
||||
component = component_class(**default_kwargs)
|
||||
assert component.include_embeddings is False
|
||||
Loading…
Add table
Add a link
Reference in a new issue