Merged in feature/saas-hybrid-smart-chunking (pull request #760)

Feature/saas hybrid smart chunking

* WIP: Initial work on SaaS HSC integration

* clean up hsc funcs script

* fix merging dict issue

* Merge remote-tracking branch 'origin/main' into feature/saas-hybrid-smart-chunking

* added changes as per feedback comments

* add tests and remove old smart chunking funcs

* add more tests


Approved-by: Katon Minhas
This commit is contained in:
Siddhant Medar
2025-11-05 15:20:33 +00:00
committed by Katon Minhas
parent 992e891388
commit 55643b5a04
15 changed files with 5016 additions and 3099 deletions
+2938 -2370
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -107,6 +107,8 @@ ENABLE_RUNTIME_ROTATION = False # Set to True to enable multi-runtime failover
FIELDS = get_arg_value("fields", "all") # Valid: one_to_one, one_to_n, all
FIELD_JSON_PATH = "src/prompts/investment_prompts.json"
RAG_RETRIEVAL_QUESTIONS_PATH = "src/prompts/rag_retrieval_questions.json"
# Client-specific args
STATE = [s.strip() for s in get_arg_value("state", "None").upper().split("-") if s]
CLIENT = [s.strip() for s in get_arg_value("client", "None").split("-") if s]
@@ -9,6 +9,7 @@ import src.investment.one_to_one_funcs as one_to_one_funcs
import src.investment.postprocess as postprocess
import src.investment.postprocessing_funcs as postprocessing_funcs
import src.investment.preprocess as preprocess
import src.investment.hybrid_smart_chunking_funcs as hybrid_smart_chunking_funcs
import src.investment.row_funcs as row_funcs
import src.investment.tin_npi_funcs as tin_npi_funcs
import src.utils.io_utils as io_utils
@@ -132,24 +133,32 @@ def run_one_to_one_prompts(
one_to_one_results, one_to_one_fields = tin_npi_funcs.run_provider_info_fields(
contract_text, one_to_one_fields, text_dict, filename
)
################## RUN SMART CHUNKED PROMPTS ##################
smart_chunked_answers_dict, one_to_one_fields = one_to_one_funcs.run_smart_chunked_fields(
one_to_one_fields, constants, contract_text, filename
################## RUN HYBRID SMART CHUNKED PROMPTS ##################
# RAG function loads retrieval questions internally and matches with investment_prompts.json
hybrid_smart_chunked_answers_dict = hybrid_smart_chunking_funcs.run_hybrid_smart_chunked_fields(
one_to_one_fields, constants, contract_text, filename, text_dict
)
################## RUN FULL CONTEXT PROMPTS ##################
full_context_answers_dict = one_to_one_funcs.run_full_context_fields(
one_to_one_fields, contract_text, constants, filename
)
################## COMBINE ANSWERS ################
one_to_one_results.update(smart_chunked_answers_dict)
one_to_one_results.update(full_context_answers_dict)
# Prefer HSC answers over full_context when HSC value is not N/A
for key, value in full_context_answers_dict.items():
one_to_one_results[key] = value
for key, value in hybrid_smart_chunked_answers_dict.items():
if not string_utils.is_empty(value):
one_to_one_results[key] = value
elif key not in one_to_one_results:
# Add HSC's N/A if field doesn't exist yet
one_to_one_results[key] = value
################## ADD AD FIELDS ################
one_to_one_results = one_to_one_funcs.get_aarete_derived_dates(one_to_one_results, text_dict, filename)
################## Crosswalk Fields ##################
one_to_one_results = aarete_derived.get_crosswalk_fields(
[one_to_one_results], constants
@@ -0,0 +1,322 @@
"""
RAG-Based Field Extraction Functions
This module provides RAG (Retrieval-Augmented Generation) based field extraction
for contract processing using hybrid retrieval (BM25 + semantic search) with reranking.
"""
import gc
import logging
import re
from typing import List, Tuple, Dict
from langchain.schema import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
import src.utils.llm_utils as llm_utils
import src.utils.string_utils as string_utils
import src.investment.preprocessing_funcs as preprocessing_funcs
import src.investment.hybrid_smart_chunking_preprocessing as hybrid_smart_chunking_preprocessing
from constants.constants import Constants
from constants.delimiters import Delimiter
from src import config
from src.prompts import prompt_templates
from src.prompts.fieldset import FieldSet, Field
from src.investment.one_to_one_funcs import check_and_update_effective_date
from src.utils.rag_utils import (
RAGConfig,
HSC_CONFIG,
get_bedrock_client,
get_reranker_client,
get_embeddings,
create_vectorstore,
create_hybrid_retriever,
rerank_documents,
)
def clip_context_by_tokens(context: str, max_tokens: int = 150000) -> str:
"""
Clip context text if it exceeds max_tokens.
Uses closed formula: char_count / 3.5 for Claude tokenization.
Args:
context: Text to potentially clip
max_tokens: Maximum token limit (default 150k for Claude 200k window)
Returns:
Clipped or original context text
"""
if not context:
return ""
# Estimate tokens: char_count / 3.5 (accurate for Claude)
token_count = int(len(context) / 3.5)
# Only clip if exceeds max_tokens
if token_count > max_tokens:
approx_chars = int(max_tokens * 3.5)
clipped_context = context[:approx_chars]
logging.info(f"Context clipped from ~{token_count} to ~{max_tokens} tokens")
return clipped_context
else:
logging.info(f"Context size ~{token_count} tokens - no clipping needed")
return context
def prepare_documents_for_chunking(
sections: dict, filename: str = "contract"
) -> List[Document]:
"""Prepare contract sections as documents for chunking."""
documents = []
for section, section_data in sections.items():
section_header = section
page_numbers = section_data["pages"]
section_text = section_data["content"]
doc = Document(
page_content=section_text,
metadata={
"section_header": section_header,
"section_length": len(section_text),
"pages": page_numbers,
"filename": filename,
},
)
documents.append(doc)
return documents
def format_chunks_for_llm(text_dict: Dict, docs: List[Document]) -> str:
"""Format retrieved documents for LLM context."""
if not docs:
return ""
pages = set()
for doc in docs:
if "pages" in doc.metadata:
pages.update(doc.metadata["pages"])
pages = set(map(str, pages))
# Filter out None values and pages that don't exist in text_dict
context = "\n\n".join(
[
text_dict[page]
for page in pages
if page in text_dict and text_dict[page] is not None
]
)
return clip_context_by_tokens(context)
def add_page_numbers_to_chunks(sections):
prev = None
updated_sections = {}
for section, section_data in sections.items():
matches = re.findall(r"Start of Page No\.?\s*=\s*(\d+)", section_data)
prev_last_page = int(prev[-1]) if prev else None
matches = [int(m) for m in matches]
all_matches = (
matches + [prev_last_page] if prev_last_page is not None else matches
)
all_matches = sorted(set(all_matches))
updated_sections[section] = {"content": section_data, "pages": all_matches}
prev = all_matches
return updated_sections
def preprocessing_pipeline(contract_text):
cleaned_text = preprocessing_funcs.clean_newlines(contract_text)
cleaned_text = preprocessing_funcs.clean_law_symbols(cleaned_text)
cleaned_text = re.sub(
r"^(.*?)(Start of Page No\.?\s*=\s*1)", r"\2", cleaned_text, flags=re.DOTALL
)
sections, original_content, line_ending, verification_result = (
hybrid_smart_chunking_preprocessing.split_contract_text_into_sections(
cleaned_text
)
)
is_preserved = verification_result[0] # get status
if not is_preserved:
logging.info(
"Failed to preserve content during section split, reverting to full contract"
)
sections = {}
chunk_size = 9000 # keep bigger chunks to have enough coverage as it would be split further by text splitter
for i in range(0, len(cleaned_text), chunk_size):
section_name = f"Section {i // chunk_size}"
sections[section_name] = cleaned_text[i : i + chunk_size]
sections_with_page_numbers = add_page_numbers_to_chunks(sections)
return sections_with_page_numbers, is_preserved
def run_hybrid_smart_chunked_fields(
one_to_one_fields: FieldSet,
constants,
contract_text: str,
filename: str,
text_dict: dict,
rag_config: RAGConfig = HSC_CONFIG,
) -> dict[str, str]:
"""Process fields using RAG: hybrid retrieval (BM25+semantic) + reranking.
Uses retrieval questions from the field's retrieval_question attribute for chunk retrieval,
then actual LLM prompts from the field's prompt attribute for field extraction.
"""
logging.info(f"Starting RAG extraction for {filename}")
# Collect fields that have retrieval questions defined
# Only process fields that have the retrieval_question attribute set
fields_to_process = [
field for field in one_to_one_fields.fields
if field.retrieval_question is not None
]
if not fields_to_process:
logging.info(f"No fields with retrieval questions found | Filename: {filename}")
return {}
try:
# Setup: clients, splitter, chunks
reranker = get_reranker_client(rag_config.RERANKER)
embeddings = get_embeddings(
rag_config.EMBEDDING_MODEL_ID, rag_config.BEDROCK_REGION
)
splitter = RecursiveCharacterTextSplitter(
chunk_size=rag_config.CHUNK_SIZE,
chunk_overlap=rag_config.CHUNK_OVERLAP,
add_start_index=True,
strip_whitespace=True,
separators=["\n\n", "\n", " ", ""],
)
sections, is_preserved = preprocessing_pipeline(contract_text)
docs = prepare_documents_for_chunking(sections, filename)
chunks = splitter.split_documents(docs)
del docs
gc.collect()
if not chunks:
return {}
# Build retriever
vectorstore = create_vectorstore(chunks, embeddings)
retriever = create_hybrid_retriever(chunks, vectorstore, rag_config)
# Process fields
answers_dict = {}
for field in fields_to_process:
try:
# Get retrieval question from field attribute
retrieval_query = field.retrieval_question
if not retrieval_query:
continue
# Retrieve + rerank using retrieval question
retrieved = retriever.invoke(retrieval_query)[
: rag_config.ENSEMBLE_TOP_K
]
reranked = rerank_documents(
retrieval_query,
retrieved,
reranker,
rag_config.RERANKER_TOP_K,
rag_config.RERANKER,
)
# Build context and use actual LLM prompt for extraction
context = format_chunks_for_llm(text_dict, reranked)
if len(context) == 0:
# Fallback: mark field for full_context processing
field.field_type = "full_context"
logging.warning(
f"No context retrieved for {field.field_name}, marking as full_context"
)
continue
llm_prompt = field.get_prompt(
constants
) # Actual extraction prompt from investment_prompts.json
prompt = prompt_templates.ONE_TO_ONE_SINGLE_FIELD_TEMPLATE(
context, llm_prompt
)
logging.info(f"Field: {field.field_name}")
logging.info(f"Retrieval question: {retrieval_query}")
logging.info(f"LLM prompt: {llm_prompt}")
# LLM call
raw = llm_utils.invoke_claude(
prompt, "sonnet_latest", filename, max_tokens=8192
)
parsed = string_utils.extract_text_from_delimiters(
raw, Delimiter.PIPE
)
answers_dict[field.field_name] = parsed if parsed else "N/A"
if answers_dict[field.field_name] == "N/A":
field.field_type = "full_context"
logging.warning(
f"Obtained N/A for {field.field_name}, marking as full_context"
)
except Exception as e:
# Mark field for full_context retry and preserve error info
field.field_type = "full_context"
logging.error(
f"Error on {field.field_name}: {type(e).__name__}: {str(e)}, marking as full_context"
)
answers_dict[field.field_name] = "N/A"
del retriever, vectorstore, chunks
gc.collect()
logging.info(f"RAG extraction done: {len(answers_dict)} fields")
answers_dict = check_and_update_effective_date(
answers_dict, text_dict, filename
)
# even after running the vision api, if the effective date is still N/A, we need to run the prompt again with full context
if "AARETE_DERIVED_EFFECTIVE_DT" in answers_dict and string_utils.is_empty(
answers_dict.get("AARETE_DERIVED_EFFECTIVE_DT")
):
# if the effective date is N/A, we need to run the prompt again with full context
one_to_one_fields.update_field_attr(
field_name="AARETE_DERIVED_EFFECTIVE_DT",
attr="field_type",
value="full_context",
)
# if the termination date is N/A, we need to run the prompt again with full context
if "TERMINATION_DT" in answers_dict and string_utils.is_empty(
answers_dict.get("TERMINATION_DT")
):
# if the termination date is N/A, we need to run the prompt again with full context
one_to_one_fields.update_field_attr(
field_name="TERMINATION_DT",
attr="field_type",
value="full_context",
)
# Normalize PROVIDER_STATE and PAYER_STATE to standardized two-letter abbreviation
for state_field in ["PROVIDER_STATE", "PAYER_STATE"]:
if state_field in answers_dict:
answers_dict[state_field] = (
string_utils.normalize_state_to_abbreviation(
answers_dict[state_field]
)
)
return answers_dict
except Exception as e:
logging.error(f"RAG pipeline failed: {e}")
return {}
@@ -0,0 +1,304 @@
import re
def split_contract_text_into_sections(text_content):
"""
Splits a text document into sections based on common heading patterns,
ensuring 100% text preservation by tracking every single character.
Args:
text_content (str): The text content to split into sections
Returns:
tuple: (sections_dict, original_content, line_ending, verification_result)
- sections_dict: Dictionary with section titles as keys and content as values
- original_content: The original text content (for verification)
- line_ending: The line ending type detected ('\n', '\r\n', or '')
- verification_result: Tuple (is_preserved, message) indicating if content was preserved
"""
original_content = text_content
# Split into lines but preserve the original line endings
if "\r\n" in original_content:
raw_lines = original_content.split("\r\n")
line_ending = "\r\n"
elif "\n" in original_content:
raw_lines = original_content.split("\n")
line_ending = "\n"
else:
raw_lines = [original_content]
line_ending = ""
# --- Define all heading patterns ---
main_arabic_heading_pattern = re.compile(
r"^\s*(\d{1})\.?\s+([A-Za-z0-9\s&\/-]+?)\.?\s*$", re.IGNORECASE
)
sub_arabic_heading_pattern = re.compile(
r"^\s*(\d+(?:\.\d+)+)\s+([A-Za-z0-9\s&\/-]+?)\.?\s*$", re.IGNORECASE
)
main_roman_heading_pattern = re.compile(
r"^\s*([IVXLCDM]+)\.?\s+([A-Za-z0-9\s&\/-]+?)\.?\s*$", re.IGNORECASE
)
keyword_colon_pattern = re.compile(r"^\s*(RECITALS|AGREEMENT):\s*$", re.IGNORECASE)
standalone_keyword_pattern = re.compile(
r"^\s*(RECITALS|AGREEMENT)\s*$", re.IGNORECASE
)
article_roman_pattern = re.compile(
r"^\s*ARTICLE\s+([IVXLCDM]+)\.?\s+([A-Za-z0-9\s&\/-]+?)\.?\s*$", re.IGNORECASE
)
exhibit_pattern = re.compile(r"^\s*EXHIBIT\s+([A-Z])(?:\s+(.*))?$", re.IGNORECASE)
attachment_pattern = re.compile(
r"^\s*Attachment\s+([A-Za-z0-9\s&\/.-]+?)\s*$", re.IGNORECASE
)
in_witness_whereof_pattern = re.compile(
r"^\s*IN\s+WITNESS\s+WHEREOF,?$", re.IGNORECASE
)
signatory_name_pattern = re.compile(r"^\s*([A-Z][A-Z0-9\s&\/.-]+)$")
specific_signatory_pattern = re.compile(r"^\s*(PROVIDER|ICM):\s*$", re.IGNORECASE)
# --- Identify all potential heading lines ---
heading_events = []
i = 0
while i < len(raw_lines):
line_stripped = raw_lines[i].strip()
next_line_raw = raw_lines[i + 1] if i + 1 < len(raw_lines) else ""
next_line_stripped = next_line_raw.strip()
current_heading_title = None
heading_start_line = i
heading_end_line = i
lines_consumed = 1
# Check for multi-line heading prefixes
is_numeric_prefix_only = re.fullmatch(r"^\s*(\d{1})\.?\s*$", line_stripped)
is_roman_prefix_only = re.fullmatch(r"^\s*([IVXLCDM]+)\.?\s*$", line_stripped)
is_exhibit_prefix_only = re.fullmatch(
r"^\s*EXHIBIT\s+([A-Z])\s*$", line_stripped, re.IGNORECASE
)
# Check if next line is a primary heading
primary_heading_patterns = [
sub_arabic_heading_pattern,
main_arabic_heading_pattern,
main_roman_heading_pattern,
keyword_colon_pattern,
standalone_keyword_pattern,
article_roman_pattern,
exhibit_pattern,
attachment_pattern,
in_witness_whereof_pattern,
specific_signatory_pattern,
]
is_next_line_a_primary_heading = any(
pattern.match(next_line_stripped) for pattern in primary_heading_patterns
)
# Check if next line is a signatory block
if (
signatory_name_pattern.match(next_line_stripped)
and len(next_line_stripped.split()) > 1
and i + 2 < len(raw_lines)
):
line_after_next_stripped = raw_lines[i + 2].strip()
if re.match(
r"^\s*By:", line_after_next_stripped, re.IGNORECASE
) or re.match(r"^\s*Name:", line_after_next_stripped, re.IGNORECASE):
is_next_line_a_primary_heading = True
# Multi-line heading combination logic
if (
(is_numeric_prefix_only or is_roman_prefix_only)
and next_line_stripped
and not is_next_line_a_primary_heading
):
current_heading_title = f"{line_stripped} {next_line_stripped}"
heading_end_line = i + 1
lines_consumed = 2
elif (
is_exhibit_prefix_only
and next_line_stripped
and not is_next_line_a_primary_heading
):
current_heading_title = f"{line_stripped} {next_line_stripped}"
heading_end_line = i + 1
lines_consumed = 2
else:
# Single line heading checks
if article_roman_pattern.match(line_stripped):
match = article_roman_pattern.match(line_stripped)
current_heading_title = (
f"ARTICLE {match.group(1)} {match.group(2).strip().rstrip('.')}"
)
elif keyword_colon_pattern.match(line_stripped):
match = keyword_colon_pattern.match(line_stripped)
current_heading_title = match.group(1).strip().upper()
elif standalone_keyword_pattern.match(line_stripped):
match = standalone_keyword_pattern.match(line_stripped)
current_heading_title = match.group(1).strip().upper()
elif exhibit_pattern.match(line_stripped):
match = exhibit_pattern.match(line_stripped)
exhibit_title = f"EXHIBIT {match.group(1).strip()}"
if match.group(2):
exhibit_title += f" {match.group(2).strip()}"
current_heading_title = exhibit_title
elif attachment_pattern.match(line_stripped):
match = attachment_pattern.match(line_stripped)
current_heading_title = f"Attachment {match.group(1).strip()}"
elif in_witness_whereof_pattern.match(line_stripped):
current_heading_title = "IN WITNESS WHEREOF"
elif sub_arabic_heading_pattern.match(line_stripped):
match = sub_arabic_heading_pattern.match(line_stripped)
current_heading_title = (
f"{match.group(1)} {match.group(2).strip().rstrip('.')}"
)
elif main_arabic_heading_pattern.match(line_stripped):
match = main_arabic_heading_pattern.match(line_stripped)
current_heading_title = (
f"{match.group(1)} {match.group(2).strip().rstrip('.')}"
)
elif main_roman_heading_pattern.match(line_stripped):
match = main_roman_heading_pattern.match(line_stripped)
current_heading_title = (
f"{match.group(1)} {match.group(2).strip().rstrip('.')}"
)
elif specific_signatory_pattern.match(line_stripped):
match = specific_signatory_pattern.match(line_stripped)
current_heading_title = match.group(1).strip().upper()
else:
# Check for signatory block
if (
signatory_name_pattern.match(line_stripped)
and len(line_stripped.split()) > 1
):
for j in range(1, min(5, len(raw_lines) - i)):
next_line = raw_lines[i + j].strip()
if re.match(r"^\s*By:", next_line, re.IGNORECASE) or re.match(
r"^\s*Name:", next_line, re.IGNORECASE
):
current_heading_title = line_stripped
break
if current_heading_title:
heading_events.append(
(heading_start_line, heading_end_line, current_heading_title)
)
i += lines_consumed
# Sort heading events by their start line index
heading_events.sort(key=lambda x: x[0])
# --- SECTION EXTRACTION ---
# This ensures every single line is accounted for and handles duplicate section titles
sections = {}
section_counter = {} # Track occurrences of each title
current_start_line = 0
current_section_title = "Preamble"
for start_line, end_line, title in heading_events:
# Add content before this heading to current section
if current_start_line < start_line:
section_lines = raw_lines[current_start_line:start_line]
section_content = line_ending.join(section_lines)
if section_content: # Only add non-empty sections
sections[current_section_title] = section_content
# Make section title unique if we've seen it before
if title in section_counter:
section_counter[title] += 1
unique_title = f"{title} ({section_counter[title]})"
else:
section_counter[title] = 1
unique_title = title
# Start new section with the heading itself
current_section_title = unique_title
current_start_line = start_line
# Add the final section (from last heading to end of file)
if current_start_line < len(raw_lines):
section_lines = raw_lines[current_start_line:]
section_content = line_ending.join(section_lines)
if section_content:
sections[current_section_title] = section_content
elif not sections and raw_lines:
# Edge case: no headings found
sections["Preamble"] = line_ending.join(raw_lines)
# Verify preservation
verification_result = verify_preservation(original_content, sections, line_ending)
return sections, original_content, line_ending, verification_result
def verify_preservation(original_content, sections, line_ending):
"""
Verification that ensures 100% text preservation.
Returns:
tuple: (is_preserved, message) where is_preserved is True if content is perfectly preserved
"""
# Reconstruct the content exactly as it was, preserving order
reconstructed_content = line_ending.join(sections.values())
# Character-by-character comparison
if original_content == reconstructed_content:
return True, "Perfect preservation: All content preserved exactly."
# Find the exact difference
original_len = len(original_content)
reconstructed_len = len(reconstructed_content)
# Find first difference
min_len = min(original_len, reconstructed_len)
for i in range(min_len):
if original_content[i] != reconstructed_content[i]:
context_start = max(0, i - 50)
context_end = min(original_len, i + 50)
return False, (
f"First difference at position {i}:\n"
f"Original: {repr(original_content[context_start:context_end])}\n"
f"Reconstructed: {repr(reconstructed_content[context_start:context_end])}"
)
# Same content but different lengths
if original_len != reconstructed_len:
shorter, longer = (
(original_content, reconstructed_content)
if original_len < reconstructed_len
else (reconstructed_content, original_content)
)
return (
False,
f"Length mismatch. Extra content in longer version: {repr(longer[min_len:min_len+50])}",
)
return False, "Unknown difference detected."
def get_section_info(sections):
"""
Helper function to get information about the extracted sections.
Returns:
dict: Information about sections including counts and character counts
"""
info = {
"total_sections": len(sections),
"section_details": {},
"total_characters": 0,
}
for title, content in sections.items():
char_count = len(content)
info["section_details"][title] = {
"character_count": char_count,
"line_count": content.count("\n") + 1 if content else 0,
}
info["total_characters"] += char_count
return info
@@ -11,11 +11,6 @@ from constants.constants import Constants
from constants.delimiters import Delimiter
from src import config
from src.investment import postprocessing_funcs
from src.investment.smart_chunking_funcs import (
field_context,
group_fields,
stitch_chunks,
)
from src.investment.vision_funcs import get_image_array_based_answer
from src.prompts.fieldset import Field, FieldSet
@@ -63,72 +58,6 @@ def extract_global_lesser_of(contract_text: str, filename: str) -> dict:
or "N/A"
}
def run_smart_chunked_fields(
one_to_one_fields: FieldSet,
constants,
contract_text: str,
filename: str,
) -> tuple[dict[str, str], FieldSet]:
"""Process fields that require smart chunking and return the extracted answers.
Args:
one_to_one_fields (FieldSet): FieldSet containing the fields to be processed.
contract_text (str): Full text of contract.
filename (str): Name of the file being processed.
Raises: ValueError: If the field group has no defined fields.
Returns:
dict[str, str]: Dictionary mapping field names to extracted answers.
FieldSet: FieldSet containing the 1:1 fields
"""
one_to_one_fields, splits = field_context(
one_to_one_fields, constants, contract_text, filename
)
smart_chunked_fields = one_to_one_fields.filter(
field_type="smart_chunked"
) # Only get the ones that are still smart_chunked after getting context
groups = group_fields(smart_chunked_fields)
# create new field groups and corresponding chunks
new_field_groups = {}
one_to_one_chunks = {}
for idx, group in enumerate(groups, 1):
# convert dictionary of fields to list of fields
group_list = list(group)
new_field_groups[f"Group {idx}"] = {"fields": group_list}
# combine chunks of fields of one group into one list
retrieved_text_list = [field.retrieved_text for field in group_list]
# Flatten list of lists into one list
retrieved_text_list = [
item for internal_list in retrieved_text_list for item in internal_list
]
retrieved_text_list = sorted(
set(
item
for sublist in retrieved_text_list
for item in (sublist if isinstance(sublist, (list, set)) else [sublist])
)
)
# join relevant chunks of all fields of the group into one text
one_to_one_chunks[f"Group {idx}"] = stitch_chunks(retrieved_text_list)
field_groups = [field_group for field_group in new_field_groups.keys()]
answers_dict = {}
for field_group in field_groups:
answers_dict = prompt_calls.prompt_smart_chunked(answers_dict, new_field_groups, field_group, one_to_one_chunks, filename)
one_to_one_fields = pass_smart_chunked_to_full_context(answers_dict, one_to_one_fields)
return answers_dict, one_to_one_fields
def pass_smart_chunked_to_full_context(answers_dict, one_to_one_fields):
# even after running the vision api, if the effective date is still N/A, we need to run the prompt again with full context
if "EFFECTIVE_DT" in answers_dict and string_utils.is_empty(
@@ -1,6 +1,5 @@
import logging
import src.investment.smart_chunking_funcs as smart_chunking_funcs
import src.investment.table_funcs as table_funcs
from src import config
from src.investment import preprocessing_funcs
@@ -1,505 +0,0 @@
"""
Smart Chunking Functions for Contract Processing
This module provides intelligent text chunking and semantic search capabilities
for contract processing. It includes:
- Lightweight in-memory vectorstore operations using FAISS
- Multiple chunking strategies (hierarchical, OR, AND, regex)
- Semantic and keyword-based chunk retrieval
- Optimized for ephemeral, per-contract vectorstore usage
Key Components:
- field_context(): Main function for semantic chunk retrieval using FAISS
- group_fields(): Groups fields based on retrieved text overlap
- Various chunk_*() functions: Different keyword matching strategies
Performance Notes:
- Uses FAISS for fast, in-memory similarity search
- No persistent storage - vectorstores are created and destroyed per contract
- Designed for concurrent processing without resource leaks
- Much lower memory footprint than persistent vectorstore solutions
"""
import json
import os
import re
from collections import defaultdict
from itertools import combinations
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import nltk
from constants.constants import Constants
from langchain_aws.embeddings.bedrock import BedrockEmbeddings
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_text_splitters import RecursiveCharacterTextSplitter
from nltk.tokenize import word_tokenize
from src import config
from src.prompts.fieldset import FieldSet
# Append path of NLTK data directory (fieldExtraction/nltk_data)
nltk.data.path.append(
os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "nltk_data"
)
)
# Setup bedrock
embeddings_model = BedrockEmbeddings(
client=config.BEDROCK_RUNTIME, model_id="amazon.titan-embed-text-v1"
)
# ===================================================================
# CONFIGURATION CONSTANTS
# ===================================================================
# Text chunking parameters
CHUNK_PREFIX_LENGTH = 11 # "chunk 001: " length
CHUNK_OVERLAP = 500
CHUNK_SIZE = 2000
TOP_K = 8
# Regular expressions
DATE_PATTERN = r"(?i)\bday of\b|\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b|\b(?:\d{1,2}[\/\.-]\d{1,2}[\/\.-](?:\d{2}|\d{4})|\d{4}[\/\.-]\d{1,2}[\/\.-]\d{1,2}|\d{8}|\d{2}\d{2}|\d{4}[\/\.-]\d{1,2}[\/\.-]\d{1,2}|\d{4}[\.]\d{1,2}[\.]\d{1,2})\b"
def parse_chunk(chunk: str) -> tuple[int, str]:
"""Extract chunk number and content from a chunk string
Args:
chunk (str): Text chunk, prefixed with "Chunk NNN: "
Returns:
tuple[int, str]: Tuple of (chunk_number, content)
"""
if not chunk.startswith("chunk ") or len(chunk) < CHUNK_PREFIX_LENGTH:
raise ValueError("Invalid chunk format")
try:
chunk_number = int(chunk[6:9]) # Extract chunk number from "chunk NNN: "
except ValueError:
raise ValueError("Invalid chunk number format")
content = chunk[CHUNK_PREFIX_LENGTH:]
return chunk_number, content
def find_overlap(prev_chunk, next_chunk, min_overlap=3):
"""
Finds the maximum overlap between the end of prev_chunk and the start of next_chunk.
Returns the number of overlapping characters.
"""
max_overlap = min(len(prev_chunk), len(next_chunk))
for i in range(max_overlap, min_overlap - 1, -1):
if prev_chunk[-i:] == next_chunk[:i]:
return i
return 0 # No overlap found
def stitch_chunks(chunks: list[str], overlap_size: int = CHUNK_OVERLAP) -> str:
"""Stitch together a list of chunks, with an overlap of `overlap_size` characters
Args:
chunks (list[str]): List of chunk strings, each prefixed with "chunk NNN: "
overlap_size (int, optional): Overlap size between chunks. Defaults to CHUNK_OVERLAP.
Returns:
str: Combined text with properly handled overlaps
"""
if not chunks:
return ""
if overlap_size < 0:
raise ValueError("Overlap size must be non-negative")
# Parse and sort chunks
parsed_chunks = [parse_chunk(chunk) for chunk in chunks]
sorted_chunks = sorted(parsed_chunks, key=lambda x: x[0])
# Start with the first chunk's content
result = sorted_chunks[0][1]
# Process subsequent chunks
for i in range(1, len(sorted_chunks)):
current_chunk = sorted_chunks[i][1]
# if chunks are contiguous
if sorted_chunks[i][0] == sorted_chunks[i - 1][0] + 1:
# Check for overlap
overlap = find_overlap(result, current_chunk)
if (
overlap > 0 and len(result) >= overlap and len(current_chunk) >= overlap
): # if overlap is enabled and there is enough text to overlap
result = result[:-overlap] + current_chunk
else:
result += "\n" + current_chunk
else: # if chunks are not contiguous, just append them
result += "\n" + current_chunk
return result
def field_context(
one_to_one_fields: FieldSet, constants: Constants, contract_text: str, filename: str
) -> Tuple[FieldSet, List[str]]:
"""Lightweight field context extraction using FAISS (in-memory only)."""
# Convert contract_text to lowercase for case-insensitive processing
contract_text = contract_text.lower()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP
)
splits = text_splitter.split_text(contract_text)
# add chunk number starting from 1
splits = [
"chunk " + str(idx + 1).zfill(3) + ": " + str(split)
for idx, split in enumerate(splits)
]
# Create lightweight FAISS vectorstore (in-memory only)
vectorstore = FAISS.from_texts(texts=splits, embedding=embeddings_model)
vectorstore_retriever = vectorstore.as_retriever(search_kwargs={"k": TOP_K})
keyword_retriever = BM25Retriever.from_texts(
splits, k=TOP_K, preprocess_func=word_tokenize
)
for field in one_to_one_fields.fields:
if field.field_type == "smart_chunked":
questions = {}
questions[field.field_name] = field.get_prompt(constants)
question = json.dumps(questions)
# Convert field keywords to lowercase for case-insensitive processing
field_keywords = [keyword.lower() for keyword in field.keywords]
# get relevant chunks based on semantic search and keyword search and merge them
semantic_relevant_docs = vectorstore_retriever.invoke(question)
keyword_relevant_docs = keyword_retriever.invoke(" ;".join(field.keywords))
semantic_relevant_chunks = [
doc.page_content for doc in semantic_relevant_docs
]
keyword_relevant_chunks = [
doc.page_content for doc in keyword_relevant_docs
]
if field.field_name == "CONTRACT_TITLE":
keyword_relevant_chunks = keyword_relevant_chunks + splits[:5]
field.retrieved_text = sorted(
list(set(semantic_relevant_chunks + keyword_relevant_chunks))
)
# Fallback: if no context or all context, treat as full_context
if len(field.retrieved_text) == 0 or len(field.retrieved_text) == len(
splits
):
field.field_type = "full_context"
return one_to_one_fields, splits
# Function to group fields based on overlap threshold
def group_fields(field_set: FieldSet, threshold=3) -> List[Set]:
"""
Groups fields in the given FieldSet based on the overlap of their retrieved text.
Args:
field_set (FieldSet): A FieldSet object containing fields to be grouped.
threshold (int, optional): The minimum overlap required to group fields together. Defaults to 3.
Returns:
List[Set[Field]]: A list of sets, where each set contains fields that have sufficient overlap.
"""
overlap_matrix = {
(f1, f2): len(set(f1.retrieved_text) & set(f2.retrieved_text))
for f1, f2 in combinations(field_set.fields, 2)
if (overlap := len(set(f1.retrieved_text) & set(f2.retrieved_text)))
>= threshold
}
groups = []
visited = set() # store field objects, not names
for field in field_set.fields:
if field not in visited: # check if field is already in a group
group = {field}
stack = [field]
while stack:
curr = stack.pop()
visited.add(curr) # add field to visited set
for (f1, f2), overlap in overlap_matrix.items():
if f1 == curr and f2 not in visited and overlap >= threshold:
group.add(f2)
stack.append(f2)
elif f2 == curr and f1 not in visited and overlap >= threshold:
group.add(f1)
stack.append(f1)
if group: # only add non-empty groups
groups.append(group)
return groups
def chunk_hierarchical(
text_dict: dict[str, str],
keywords: list[str],
regex: str = None,
case_sensitive: bool = False,
) -> list[str]:
"""Look for the keywords, one-by-one, and take the first that satisfies the keyword.
We pad responsive pages with a one-page buffer (before and after) when possible.
Args:
text_dict (dict[str, str]): A dictionary keyed by string page number and valued
by string page contents
keywords (list[str]): A list of string keywords
case_sensitive(bool): A flag for converting all keywords and text to lower case,
in order to ignore case altogether.
Returns:
list[str]: The sorted list of string page numbers responsive to the first
keyword found
"""
if not case_sensitive:
keywords = [keyword.lower() for keyword in keywords]
text_dict = {key: value.lower() for key, value in text_dict.items()}
page_list = []
for keyword in keywords:
if len(page_list) == 0:
for page in text_dict.keys():
if keyword in text_dict[page]:
if str(int(page) - 1) in text_dict.keys():
page_list.append(str(int(page) - 1))
page_list.append(page)
if str(int(page) + 1) in text_dict.keys():
page_list.append(str(int(page) + 1))
page_list_sorted = sorted([int(page_str) for page_str in set(page_list)])
page_list_sorted_str = list(map(str, page_list_sorted))
return page_list_sorted_str
def chunk_or(
text_dict: dict[str, str],
keywords: list[str],
regex: str = None,
case_sensitive: bool = False,
) -> list[str]:
"""Look for any of the keywords and add any pages with any keywords to the chunk.
We pad responsive pages with a one-page buffer (before and after) when possible.
Args:
text_dict (dict[str, str]): A dictionary keyed by string page number and valued
by string page contents
keywords (list[str]): A list of string keywords
case_sensitive(bool): A flag for converting all keywords and text to lower case,
in order to ignore case altogether.
Returns:
list[str]: The sorted list of string page numbers responsive to the first
keyword found
"""
if not case_sensitive:
keywords = [keyword.lower() for keyword in keywords]
text_dict = {key: value.lower() for key, value in text_dict.items()}
page_list = []
for page in text_dict.keys():
for keyword in keywords:
if keyword in text_dict[page]:
# Add buffer of previous and next page, if we're not at the beginning nor end
# If we are at the beginning (or the end) just take the next (or previous) page
if str(int(page) - 1) in text_dict.keys():
page_list.append(str(int(page) - 1))
page_list.append(page)
if str(int(page) + 1) in text_dict.keys():
page_list.append(str(int(page) + 1))
page_list_sorted = sorted([int(page_str) for page_str in set(page_list)])
page_list_sorted_str = list(map(str, page_list_sorted))
return page_list_sorted_str
# And: run each keyword and include a page if all are present
def chunk_and(
text_dict: dict[str, str],
keywords: list[str],
regex: str = None,
case_sensitive: bool = False,
) -> list[str]:
"""Look for all of the keywords and add any pages with all keywords to the chunk.
We pad responsive pages with a one-page buffer (before and after) when possible.
Args:
text_dict (dict[str, str]): A dictionary keyed by string page number and valued
by string page contents
keywords (list[str]): A list of string keywords
case_sensitive(bool): A flag for converting all keywords and text to lower case,
in order to ignore case altogether.
Returns:
list[str]: The sorted list of string page numbers responsive to the first
keyword found
"""
if not case_sensitive:
keywords = [keyword.lower() for keyword in keywords]
text_dict = {key: value.lower() for key, value in text_dict.items()}
page_list = []
for page in text_dict.keys():
if all([keyword in text_dict[page] for keyword in keywords]):
# Add buffer of previous and next page, if we're not at the beginning nor end
# If we are at the beginning (or the end) just take the next (or previous) page
if str(int(page) - 1) in text_dict.keys():
page_list.append(str(int(page) - 1))
page_list.append(page)
if str(int(page) + 1) in text_dict.keys():
page_list.append(str(int(page) + 1))
page_list_sorted = sorted([int(page_str) for page_str in set(page_list)])
page_list_sorted_str = list(map(str, page_list_sorted))
return page_list_sorted_str
# Regex: Check for a regex and include a page if the regex matches within the page
def chunk_regex(
text_dict: dict[str, str], keywords: list[str], regex: str, case_sensitive: bool
) -> list[str]:
"""Look for regex match and add any pages with regex match to the chunk.
We pad responsive pages with a one-page buffer (before and after) when possible.
Args:
text_dict (dict[str, str]): A dictionary keyed by string page number and valued
by string page contents
regex (str): A regex expression
Returns:
list[str]: The sorted list of string page numbers responsive to the first
keyword found
"""
page_list = []
for page in text_dict.keys():
if re.search(regex, text_dict[page]):
# Add buffer of previous and next page, if we're not at the beginning nor end
# If we are at the beginning (or the end) just take the next (or previous) page
if str(int(page) - 1) in text_dict.keys():
page_list.append(str(int(page) - 1))
page_list.append(page)
if str(int(page) + 1) in text_dict.keys():
page_list.append(str(int(page) + 1))
page_list_sorted = sorted([int(page_str) for page_str in set(page_list)])
page_list_sorted_str = list(map(str, page_list_sorted))
return page_list_sorted_str
def keyword_search(
text_dict: dict[str, str],
keywords: list[str],
case_sensitive: bool = False,
regex: bool = False,
) -> dict[str, list]:
"""Look for keyword/regex match and add any pages with keyword/regex match to the chunk.
Args:
text_dict (dict[str, str]): A dictionary keyed by string page number and valued
by string page contents
keywords (list[str]): A list of string keywords/regex
case_sensitive(bool): A flag for converting all keywords and text to lower case,
in order to ignore case altogether.
regex (bool): A flag to identify if the passed keyword list is regex list or string list
Returns:
list[str]: The dictionary containing keyword as keys and sorted list of string page numbers responsive to the
keyword found
"""
if not case_sensitive and not regex:
keywords = [keyword.lower() for keyword in keywords]
text_dict = {key: value.lower() for key, value in text_dict.items()}
page_dict = {}
for keyword in keywords:
page_dict[keyword] = []
for page in text_dict.keys():
if (not regex and keyword in text_dict[page]) or (
regex and re.search(keyword, text_dict[page])
):
page_dict[keyword].append(page)
page_dict_sorted = {}
for keyword in page_dict:
page_dict_sorted[keyword] = sorted(
[int(page_str) for page_str in set(page_dict[keyword])]
)
return page_dict_sorted
def smart_chunk_one_to_one(
text_dict: dict, contract_text: str, one_to_one_fields: FieldSet
) -> dict:
"""Performs a keyword search on textract outputs to retrieve relevant chunks for a given field.
Args:
text_dict (dict): Dictionary keyed by string page-number and valued by actual textract output page
contract_text(str): Contract Text
Raises:
ValueError: When the `key_dict` from keyword_mappings has a methodology that is not implemented.
Current options are `or` and `hierarchy`.
Returns:
dict: A dictionary keyed by field (defined by the keys in keyword_mappings) valued by
corresponding chunks of text (input strings delimited by newlines).
"""
smart_chunk_fields = one_to_one_fields.filter(field_type="smart_chunked")
chunks = defaultdict(str)
for field in smart_chunk_fields.fields:
# Determine chunking function based on methodology
if field.methodology == "or":
chunk_func = chunk_or
elif field.methodology == "hierarchy":
chunk_func = chunk_hierarchical
elif field.methodology == "and":
chunk_func = chunk_and
elif field.methodology == "regex":
chunk_func = chunk_regex
else:
chunk_func = None
if chunk_func:
page_list = chunk_func(
text_dict, field.keywords, field.regex_pattern, field.case_sensitive
)
keyword_page_dict = keyword_search(
text_dict, field.keywords, field.case_sensitive, field.regex_pattern
)
else:
page_list = list(text_dict.keys())
keyword_page_dict = {"All pages": list(text_dict.keys())}
raise ValueError(
"methodology must be one of 'hierarchy', 'or', 'and', 'regex', 'include_exclude' - using entire contract as chunk"
)
if len(page_list) == len(text_dict.keys()):
chunks[field.field_name] = contract_text
else:
chunks[field.field_name] = "\n".join(
[text_dict[str(page_num)] for page_num in page_list]
)
chunks[field.field_name + "_pages"] = keyword_page_dict
chunks[field.field_name + "_methodology"] = field.methodology
chunks[field.field_name + "_case"] = field.case_sensitive
return chunks
+6
View File
@@ -75,6 +75,12 @@ class Field:
if "breakout_template" in field_dict
else None
)
# RAG Retrieval Attributes
self.retrieval_question = (
field_dict["retrieval_question"]
if "retrieval_question" in field_dict
else None
)
def __repr__(self):
"""Returns a string representation of the Field object."""
File diff suppressed because one or more lines are too long
+169
View File
@@ -0,0 +1,169 @@
"""
RAG (Retrieval-Augmented Generation) Utilities
This module provides configuration and utility functions for RAG-based
field extraction using hybrid retrieval (BM25 + semantic search) with reranking.
"""
import json
import logging
from dataclasses import dataclass
from typing import Any, List, Dict
import boto3
from langchain.retrievers import EnsembleRetriever
from langchain.schema import Document
from langchain_aws import BedrockEmbeddings
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_community.vectorstores.utils import DistanceStrategy
@dataclass
class RAGConfig:
"""Configuration for RAG pipeline"""
CHUNK_SIZE: int = 2000
CHUNK_OVERLAP: int = 200
SEMANTIC_TOP_K: int = 35
KEYWORD_TOP_K: int = 25
ENSEMBLE_TOP_K: int = 40
RERANKER_TOP_K: int = 15
BM25_WEIGHT: float = 0.40
VECTOR_WEIGHT: float = 0.60
RERANKER: str = "amazon" # "cohere" or "amazon"
EMBEDDING_MODEL_ID: str = "amazon.titan-embed-text-v2:0"
BEDROCK_REGION: str = "us-east-1"
# Default configuration instance
HSC_CONFIG = RAGConfig()
# Reranker model configurations
RERANKER_CONFIGS = {
"amazon": {
"model_id": "amazon.rerank-v1:0",
"name": "Amazon Rerank 1.0",
"region": "us-west-2",
},
"cohere": {
"model_id": "cohere.rerank-v3-5:0",
"name": "Cohere Rerank 3.5",
"region": "us-east-1",
},
}
# Initialize AWS Bedrock clients at module level
_bedrock_client = None
_reranker_clients = {} # Cache per region
_embeddings = None
def get_bedrock_client(region_name: str = HSC_CONFIG.BEDROCK_REGION):
"""Get or create the Bedrock client."""
global _bedrock_client
if _bedrock_client is None:
_bedrock_client = boto3.client("bedrock-runtime", region_name=region_name)
return _bedrock_client
def get_reranker_client(model: str = HSC_CONFIG.RERANKER):
"""Get or create the reranker client for specific model."""
global _reranker_clients
cfg = RERANKER_CONFIGS.get(model, RERANKER_CONFIGS["amazon"])
region = cfg["region"]
if region not in _reranker_clients:
_reranker_clients[region] = boto3.client("bedrock-runtime", region_name=region)
return _reranker_clients[region]
def get_embeddings(
model_id: str = HSC_CONFIG.EMBEDDING_MODEL_ID,
region_name: str = HSC_CONFIG.BEDROCK_REGION,
):
"""Get or create the embeddings model."""
global _embeddings
if _embeddings is None:
client = get_bedrock_client(region_name)
_embeddings = BedrockEmbeddings(model_id=model_id, client=client)
return _embeddings
def create_vectorstore(chunks: List[Document], embeddings: BedrockEmbeddings) -> FAISS:
"""Create FAISS vectorstore from chunks."""
return FAISS.from_documents(
chunks, embeddings, distance_strategy=DistanceStrategy.COSINE
)
def create_hybrid_retriever(
chunks: List[Document], vectorstore: FAISS, cfg: RAGConfig = HSC_CONFIG
) -> EnsembleRetriever:
"""Create hybrid BM25 + semantic retriever."""
bm25 = BM25Retriever.from_documents(chunks, k1=1.6, b=0.75)
bm25.k = cfg.KEYWORD_TOP_K
vector = vectorstore.as_retriever(
search_type="similarity", search_kwargs={"k": cfg.SEMANTIC_TOP_K}
)
return EnsembleRetriever(
retrievers=[vector, bm25], weights=[cfg.VECTOR_WEIGHT, cfg.BM25_WEIGHT]
)
def rerank_documents(
query: str,
docs: List[Document],
client: Any,
top_k: int = HSC_CONFIG.RERANKER_TOP_K,
model: str = HSC_CONFIG.RERANKER,
) -> List[Document]:
"""Rerank documents using AWS Bedrock (Cohere/Amazon)."""
if not docs:
return docs
try:
cfg = RERANKER_CONFIGS.get(model, RERANKER_CONFIGS["cohere"])
payload: Dict[str, Any] = (
{"query": query, "documents": [{"text": d.page_content} for d in docs]}
if model == "amazon"
else {
"query": query,
"documents": [d.page_content for d in docs],
"top_n": min(top_k, len(docs)),
"api_version": 2,
}
)
result = json.loads(
client.invoke_model(modelId=cfg["model_id"], body=json.dumps(payload))[
"body"
].read()
)
scored = sorted(
[
{"score": item["relevance_score"], "doc": docs[item["index"]]}
for item in result["results"]
],
key=lambda x: x["score"],
reverse=True,
)[:top_k]
return [
Document(
page_content=item["doc"].page_content,
metadata={
**item["doc"].metadata,
"rerank_score": item["score"],
"rerank_model": cfg["name"],
},
)
for item in scored
]
except Exception as e:
logging.error(
f"Reranking failed: {e} | Selecting {top_k} from base retrieved docs"
)
return docs[:top_k]
@@ -0,0 +1,450 @@
"""
Unit tests for src.investment.hybrid_smart_chunking_funcs module
Tests hybrid smart chunking functions including:
- Context clipping by token count
- Document preparation for chunking
- Chunk formatting for LLM
- Page number extraction
- Preprocessing pipeline
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from langchain.schema import Document
from src.investment.hybrid_smart_chunking_funcs import (
clip_context_by_tokens,
prepare_documents_for_chunking,
format_chunks_for_llm,
add_page_numbers_to_chunks,
preprocessing_pipeline,
)
class TestClipContextByTokens:
"""Test clip_context_by_tokens function"""
def test_context_under_limit(self):
"""Test context that doesn't need clipping"""
context = "Short text" * 100 # ~1100 chars = ~314 tokens
max_tokens = 1000
result = clip_context_by_tokens(context, max_tokens)
assert result == context
assert len(result) == len(context)
def test_context_over_limit(self):
"""Test context that needs clipping"""
context = "A" * 350000 # ~100k tokens
max_tokens = 10000 # Clip to 10k tokens
result = clip_context_by_tokens(context, max_tokens)
# Should be clipped to approximately 10k tokens (35k chars)
assert len(result) < len(context)
assert len(result) <= int(max_tokens * 3.5)
def test_empty_context(self):
"""Test empty context"""
result = clip_context_by_tokens("")
assert result == ""
def test_none_context(self):
"""Test None context"""
result = clip_context_by_tokens(None)
assert result == ""
def test_exact_limit(self):
"""Test context exactly at the limit"""
max_tokens = 1000
context = "A" * int(max_tokens * 3.5) # Exactly at limit
result = clip_context_by_tokens(context, max_tokens)
assert result == context
def test_custom_max_tokens(self):
"""Test with custom max_tokens parameter"""
context = "A" * 100000
max_tokens = 5000
result = clip_context_by_tokens(context, max_tokens)
estimated_tokens = int(len(result) / 3.5)
assert estimated_tokens <= max_tokens
def test_token_estimation_accuracy(self):
"""Test that token estimation formula is applied correctly"""
# 3500 chars should be ~1000 tokens
context = "A" * 3500
max_tokens = 1000
result = clip_context_by_tokens(context, max_tokens)
# Should not clip since it's right at the limit
assert len(result) == 3500
class TestPrepareDocumentsForChunking:
"""Test prepare_documents_for_chunking function"""
def test_single_section(self):
"""Test preparing a single section"""
sections = {
"Section 1": {
"content": "Section content here",
"pages": [1, 2]
}
}
filename = "test_contract.pdf"
docs = prepare_documents_for_chunking(sections, filename)
assert len(docs) == 1
assert docs[0].page_content == "Section content here"
assert docs[0].metadata["section_header"] == "Section 1"
assert docs[0].metadata["pages"] == [1, 2]
assert docs[0].metadata["filename"] == filename
def test_multiple_sections(self):
"""Test preparing multiple sections"""
sections = {
"Section 1": {"content": "Content 1", "pages": [1]},
"Section 2": {"content": "Content 2", "pages": [2, 3]},
"Section 3": {"content": "Content 3", "pages": [4]}
}
docs = prepare_documents_for_chunking(sections)
assert len(docs) == 3
assert all(isinstance(doc, Document) for doc in docs)
def test_empty_sections(self):
"""Test with empty sections dictionary"""
sections = {}
docs = prepare_documents_for_chunking(sections)
assert docs == []
def test_section_metadata(self):
"""Test that metadata is correctly set"""
sections = {
"Test Section": {
"content": "This is test content",
"pages": [5, 6, 7]
}
}
filename = "contract.pdf"
docs = prepare_documents_for_chunking(sections, filename)
metadata = docs[0].metadata
assert metadata["section_header"] == "Test Section"
assert metadata["section_length"] == len("This is test content")
assert metadata["pages"] == [5, 6, 7]
assert metadata["filename"] == filename
def test_default_filename(self):
"""Test default filename when not provided"""
sections = {
"Section": {"content": "Content", "pages": [1]}
}
docs = prepare_documents_for_chunking(sections)
assert docs[0].metadata["filename"] == "contract"
class TestFormatChunksForLLM:
"""Test format_chunks_for_llm function"""
def test_empty_docs(self):
"""Test with empty document list"""
text_dict = {"1": "Page 1 content"}
result = format_chunks_for_llm(text_dict, [])
assert result == ""
def test_single_document(self):
"""Test with single document"""
text_dict = {
"1": "Page 1 content",
"2": "Page 2 content"
}
docs = [
Document(
page_content="chunk1",
metadata={"pages": [1]}
)
]
result = format_chunks_for_llm(text_dict, docs)
assert "Page 1 content" in result
assert "Page 2 content" not in result
def test_multiple_documents_different_pages(self):
"""Test with documents from different pages"""
text_dict = {
"1": "Page 1 content",
"2": "Page 2 content",
"3": "Page 3 content"
}
docs = [
Document(page_content="chunk1", metadata={"pages": [1]}),
Document(page_content="chunk2", metadata={"pages": [3]})
]
result = format_chunks_for_llm(text_dict, docs)
assert "Page 1 content" in result
assert "Page 3 content" in result
assert "Page 2 content" not in result
def test_overlapping_pages(self):
"""Test with documents that reference overlapping pages"""
text_dict = {
"1": "Page 1 content",
"2": "Page 2 content"
}
docs = [
Document(page_content="chunk1", metadata={"pages": [1, 2]}),
Document(page_content="chunk2", metadata={"pages": [2]})
]
result = format_chunks_for_llm(text_dict, docs)
# Both pages should appear only once (set deduplication)
assert "Page 1 content" in result
assert "Page 2 content" in result
def test_missing_page_in_text_dict(self):
"""Test when document references page not in text_dict"""
text_dict = {
"1": "Page 1 content"
}
docs = [
Document(page_content="chunk", metadata={"pages": [1, 99]})
]
result = format_chunks_for_llm(text_dict, docs)
# Should only include page 1, skip page 99
assert "Page 1 content" in result
def test_none_values_in_text_dict(self):
"""Test handling of None values in text_dict"""
text_dict = {
"1": "Page 1 content",
"2": None,
"3": "Page 3 content"
}
docs = [
Document(page_content="chunk", metadata={"pages": [1, 2, 3]})
]
result = format_chunks_for_llm(text_dict, docs)
# Should skip None value for page 2
assert "Page 1 content" in result
assert "Page 3 content" in result
def test_document_without_pages_metadata(self):
"""Test document without pages in metadata"""
text_dict = {"1": "Page 1 content"}
docs = [
Document(page_content="chunk", metadata={})
]
result = format_chunks_for_llm(text_dict, docs)
# Should return empty or handle gracefully
assert isinstance(result, str)
@patch('src.investment.hybrid_smart_chunking_funcs.clip_context_by_tokens')
def test_calls_clip_context(self, mock_clip):
"""Test that format_chunks_for_llm calls clip_context_by_tokens"""
mock_clip.return_value = "clipped context"
text_dict = {"1": "Page 1 content"}
docs = [Document(page_content="chunk", metadata={"pages": [1]})]
result = format_chunks_for_llm(text_dict, docs)
mock_clip.assert_called_once()
assert result == "clipped context"
def test_integer_page_numbers(self):
"""Test handling of integer page numbers (converted to strings)"""
text_dict = {
"1": "Page 1 content",
"2": "Page 2 content"
}
docs = [
Document(page_content="chunk", metadata={"pages": [1, 2]}) # Integers
]
result = format_chunks_for_llm(text_dict, docs)
assert "Page 1 content" in result
assert "Page 2 content" in result
class TestAddPageNumbersToChunks:
"""Test add_page_numbers_to_chunks function"""
def test_single_section_with_page_markers(self):
"""Test extracting page numbers from section with markers"""
sections = {
"Section 1": "Start of Page No. = 1\nContent here\nStart of Page No. = 2\nMore content"
}
result = add_page_numbers_to_chunks(sections)
assert "Section 1" in result
assert result["Section 1"]["content"] == sections["Section 1"]
assert 1 in result["Section 1"]["pages"]
assert 2 in result["Section 1"]["pages"]
def test_multiple_sections(self):
"""Test multiple sections with page markers"""
sections = {
"Section 1": "Start of Page No. = 5\nContent",
"Section 2": "Start of Page No. = 6\nMore content\nStart of Page No. = 7\nEven more"
}
result = add_page_numbers_to_chunks(sections)
assert 5 in result["Section 1"]["pages"]
assert 6 in result["Section 2"]["pages"]
assert 7 in result["Section 2"]["pages"]
def test_section_without_markers(self):
"""Test section without page markers"""
sections = {
"Section 1": "Start of Page No. = 1\nContent",
"Section 2": "Content without markers"
}
result = add_page_numbers_to_chunks(sections)
# Section 2 should inherit last page from Section 1
assert result["Section 2"]["pages"] == [1]
def test_empty_sections(self):
"""Test with empty sections dictionary"""
sections = {}
result = add_page_numbers_to_chunks(sections)
assert result == {}
def test_page_number_deduplication(self):
"""Test that duplicate page numbers are removed"""
sections = {
"Section 1": "Start of Page No. = 3\nContent\nStart of Page No. = 3\nMore"
}
result = add_page_numbers_to_chunks(sections)
# Should have unique page numbers
assert result["Section 1"]["pages"].count(3) == 1
def test_page_numbers_are_sorted(self):
"""Test that page numbers are sorted"""
sections = {
"Section 1": "Start of Page No. = 5\nStart of Page No. = 2\nStart of Page No. = 8"
}
result = add_page_numbers_to_chunks(sections)
pages = result["Section 1"]["pages"]
assert pages == sorted(pages)
def test_alternative_marker_format(self):
"""Test alternative page marker format without period"""
sections = {
"Section 1": "Start of Page No = 10\nContent"
}
result = add_page_numbers_to_chunks(sections)
assert 10 in result["Section 1"]["pages"]
class TestPreprocessingPipeline:
"""Test preprocessing_pipeline function"""
@patch('src.investment.hybrid_smart_chunking_funcs.hybrid_smart_chunking_preprocessing.split_contract_text_into_sections')
@patch('src.investment.hybrid_smart_chunking_funcs.add_page_numbers_to_chunks')
def test_pipeline_calls_functions(self, mock_add_pages, mock_split):
"""Test that pipeline calls the necessary functions"""
contract_text = "Test contract text"
# Mock split_contract_text_into_sections return
mock_sections = {"Section 1": "content"}
mock_verification = (True, "Perfect preservation")
mock_split.return_value = (mock_sections, contract_text, "\n", mock_verification)
# Mock add_page_numbers_to_chunks return
mock_sections_with_pages = {
"Section 1": {"content": "content", "pages": [1]}
}
mock_add_pages.return_value = mock_sections_with_pages
sections, is_preserved = preprocessing_pipeline(contract_text)
mock_split.assert_called_once_with(contract_text)
mock_add_pages.assert_called_once()
assert is_preserved is True
assert sections == mock_sections_with_pages
@patch('src.investment.hybrid_smart_chunking_funcs.hybrid_smart_chunking_preprocessing.split_contract_text_into_sections')
@patch('src.investment.hybrid_smart_chunking_funcs.add_page_numbers_to_chunks')
def test_pipeline_returns_preservation_status(self, mock_add_pages, mock_split):
"""Test that pipeline returns preservation status"""
# Test successful preservation
mock_split.return_value = ({}, "text", "\n", (True, "Success"))
mock_add_pages.return_value = {}
sections, is_preserved = preprocessing_pipeline("text")
assert is_preserved is True
# Test failed preservation
mock_split.return_value = ({}, "text", "\n", (False, "Failed"))
mock_add_pages.return_value = {}
sections, is_preserved = preprocessing_pipeline("text")
assert is_preserved is False
@patch('src.investment.hybrid_smart_chunking_funcs.hybrid_smart_chunking_preprocessing.split_contract_text_into_sections')
@patch('src.investment.hybrid_smart_chunking_funcs.add_page_numbers_to_chunks')
def test_pipeline_with_real_text(self, mock_add_pages, mock_split):
"""Test pipeline with realistic contract text"""
contract_text = """1 Definitions
Definition content
2 Terms
Terms content"""
mock_sections = {
"1 Definitions": "Definition content",
"2 Terms": "Terms content"
}
mock_split.return_value = (mock_sections, contract_text, "\n", (True, "OK"))
mock_sections_with_pages = {
"1 Definitions": {"content": "Definition content", "pages": [1]},
"2 Terms": {"content": "Terms content", "pages": [2]}
}
mock_add_pages.return_value = mock_sections_with_pages
sections, is_preserved = preprocessing_pipeline(contract_text)
assert len(sections) == 2
assert "1 Definitions" in sections
assert "2 Terms" in sections
assert is_preserved is True
@@ -0,0 +1,353 @@
"""
Unit tests for src.investment.hybrid_smart_chunking_preprocessing module
Tests contract text preprocessing and section splitting functionality including:
- Section identification based on various heading patterns
- Text preservation verification
- Section metadata extraction
"""
import pytest
from src.investment.hybrid_smart_chunking_preprocessing import (
split_contract_text_into_sections,
verify_preservation,
get_section_info,
)
class TestSplitContractTextIntoSections:
"""Test split_contract_text_into_sections function"""
def test_simple_text_no_headings(self):
"""Test text with no recognizable headings"""
text = "This is a simple contract text.\nWith no headings at all."
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "Preamble" in sections
assert sections["Preamble"] == text
assert original == text
assert line_ending == "\n"
assert verification[0] is True
def test_arabic_numbered_headings(self):
"""Test single-digit arabic numbered headings (1. Title)"""
text = "Preamble text\n1 Definitions\nDefinition content\n2 Terms\nTerms content"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "Preamble" in sections
assert "1 Definitions" in sections
assert "2 Terms" in sections
assert verification[0] is True
def test_sub_arabic_numbered_headings(self):
"""Test multi-level arabic numbered headings (1.1, 1.2, etc.)"""
text = "1.1 First Section\nContent A\n1.2 Second Section\nContent B"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "1.1 First Section" in sections
assert "1.2 Second Section" in sections
assert verification[0] is True
def test_roman_numeral_headings(self):
"""Test roman numeral headings (I. Title, II. Title)"""
text = "I Introduction\nIntro content\nII Background\nBackground content"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "I Introduction" in sections
assert "II Background" in sections
assert verification[0] is True
def test_article_headings(self):
"""Test ARTICLE headings (ARTICLE I Title)"""
text = "ARTICLE I Definitions\nDef content\nARTICLE II Terms\nTerms content"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "ARTICLE I Definitions" in sections
assert "ARTICLE II Terms" in sections
assert verification[0] is True
def test_exhibit_headings(self):
"""Test EXHIBIT headings (EXHIBIT A, EXHIBIT B Title)"""
text = "Main text\nEXHIBIT A\nExhibit A content\nEXHIBIT B Schedule\nExhibit B content"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "EXHIBIT A" in sections
assert "EXHIBIT B Schedule" in sections
assert verification[0] is True
def test_attachment_headings(self):
"""Test Attachment headings"""
text = "Contract text\nAttachment 1\nAttachment content"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "Attachment 1" in sections
assert verification[0] is True
def test_recitals_and_agreement_keywords(self):
"""Test RECITALS and AGREEMENT keyword headings"""
text = "RECITALS:\nRecital text\nAGREEMENT\nAgreement text"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "RECITALS" in sections
assert "AGREEMENT" in sections
assert verification[0] is True
def test_in_witness_whereof_heading(self):
"""Test IN WITNESS WHEREOF heading"""
text = "Contract body\nIN WITNESS WHEREOF\nSignature section"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "IN WITNESS WHEREOF" in sections
assert verification[0] is True
def test_signatory_headings(self):
"""Test signatory block headings (PROVIDER:, ICM:)"""
text = "Agreement text\nPROVIDER:\nBy: John Doe\nICM:\nBy: Jane Smith"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "PROVIDER" in sections or "ICM" in sections
assert verification[0] is True
def test_multi_line_headings(self):
"""Test headings that span multiple lines"""
text = "1\nDefinitions and Terms\nContent here\n2\nOther Section\nMore content"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
# Should combine multi-line headings
assert any("Definitions" in key for key in sections.keys())
assert verification[0] is True
def test_duplicate_section_titles(self):
"""Test handling of duplicate section titles"""
text = "1 Terms\nFirst terms\n1 Terms\nSecond terms"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
# Should have unique keys (second occurrence gets suffix)
assert "1 Terms" in sections
assert "1 Terms (2)" in sections
assert verification[0] is True
def test_windows_line_endings(self):
"""Test text with Windows line endings (\\r\\n)"""
text = "Section 1\r\nContent A\r\nSection 2\r\nContent B"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert line_ending == "\r\n"
assert verification[0] is True
def test_unix_line_endings(self):
"""Test text with Unix line endings (\\n)"""
text = "Section 1\nContent A\nSection 2\nContent B"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert line_ending == "\n"
assert verification[0] is True
def test_no_line_endings(self):
"""Test text with no line endings"""
text = "Single line of text with no breaks"
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert line_ending == ""
assert "Preamble" in sections
assert verification[0] is True
def test_empty_text(self):
"""Test empty text input"""
text = ""
sections, original, line_ending, verification = split_contract_text_into_sections(text)
# Should handle empty text gracefully
assert isinstance(sections, dict)
assert original == ""
def test_mixed_heading_types(self):
"""Test document with multiple types of headings"""
text = """RECITALS:
Whereas clause
1 Definitions
Term definitions
ARTICLE I Scope
Scope content
EXHIBIT A
Exhibit content"""
sections, original, line_ending, verification = split_contract_text_into_sections(text)
assert "RECITALS" in sections
assert "1 Definitions" in sections
assert "ARTICLE I Scope" in sections
# EXHIBIT A might combine with next line, so check for both possibilities
assert "EXHIBIT A" in sections or "EXHIBIT A Exhibit content" in sections
assert verification[0] is True
class TestVerifyPreservation:
"""Test verify_preservation function"""
def test_perfect_preservation(self):
"""Test when content is perfectly preserved"""
original = "Line 1\nLine 2\nLine 3"
sections = {"Section1": "Line 1\nLine 2", "Section2": "Line 3"}
line_ending = "\n"
is_preserved, message = verify_preservation(original, sections, line_ending)
assert is_preserved is True
assert "Perfect preservation" in message
def test_content_mismatch(self):
"""Test when content differs"""
original = "Line 1\nLine 2\nLine 3"
sections = {"Section1": "Line 1\nLine X"} # Different content
line_ending = "\n"
is_preserved, message = verify_preservation(original, sections, line_ending)
assert is_preserved is False
assert "First difference at position" in message or "Length mismatch" in message
def test_length_mismatch(self):
"""Test when reconstructed content has different length"""
original = "Short text"
sections = {"Section1": "Much longer text than original"}
line_ending = "\n"
is_preserved, message = verify_preservation(original, sections, line_ending)
assert is_preserved is False
def test_empty_sections(self):
"""Test with empty sections dict"""
original = "Some content"
sections = {}
line_ending = "\n"
is_preserved, message = verify_preservation(original, sections, line_ending)
assert is_preserved is False
def test_windows_line_endings_preservation(self):
"""Test preservation with Windows line endings"""
original = "Line 1\r\nLine 2\r\nLine 3"
sections = {"Section1": "Line 1\r\nLine 2", "Section2": "Line 3"}
line_ending = "\r\n"
is_preserved, message = verify_preservation(original, sections, line_ending)
assert is_preserved is True
class TestGetSectionInfo:
"""Test get_section_info function"""
def test_basic_section_info(self):
"""Test extracting basic section information"""
sections = {
"Section 1": "This is section one content",
"Section 2": "This is section two\nWith multiple lines"
}
info = get_section_info(sections)
assert info["total_sections"] == 2
assert "Section 1" in info["section_details"]
assert "Section 2" in info["section_details"]
assert info["section_details"]["Section 1"]["character_count"] == 27
assert info["section_details"]["Section 2"]["line_count"] == 2
def test_empty_sections(self):
"""Test with empty sections dict"""
sections = {}
info = get_section_info(sections)
assert info["total_sections"] == 0
assert info["section_details"] == {}
assert info["total_characters"] == 0
def test_section_with_empty_content(self):
"""Test section with empty string content"""
sections = {"Empty Section": ""}
info = get_section_info(sections)
assert info["total_sections"] == 1
assert info["section_details"]["Empty Section"]["character_count"] == 0
def test_total_character_count(self):
"""Test total character count calculation"""
sections = {
"Section 1": "12345", # 5 chars
"Section 2": "67890", # 5 chars
"Section 3": "ABCDE" # 5 chars
}
info = get_section_info(sections)
assert info["total_characters"] == 15
def test_line_count_calculation(self):
"""Test line count is calculated correctly"""
sections = {
"Single Line": "One line only",
"Multi Line": "Line 1\nLine 2\nLine 3"
}
info = get_section_info(sections)
assert info["section_details"]["Single Line"]["line_count"] == 1
assert info["section_details"]["Multi Line"]["line_count"] == 3
class TestIntegration:
"""Integration tests for the full preprocessing workflow"""
def test_realistic_contract_structure(self):
"""Test with a realistic contract structure"""
contract_text = """PROVIDER AGREEMENT
RECITALS:
This Agreement is made between the parties.
AGREEMENT
1 Definitions
For purposes of this Agreement, the following terms shall have the meanings set forth below.
1.1 Provider
Provider means the healthcare provider.
1.2 Services
Services means healthcare services.
2 Terms and Conditions
The parties agree to the following terms.
EXHIBIT A Payment Schedule
Payment details here.
IN WITNESS WHEREOF
The parties have executed this Agreement.
PROVIDER:
By: _______________
Name: John Doe
"""
sections, original, line_ending, verification = split_contract_text_into_sections(contract_text)
# Verify major sections are identified
assert "RECITALS" in sections
assert "AGREEMENT" in sections
assert any("Definitions" in key for key in sections.keys())
assert "EXHIBIT A Payment Schedule" in sections
assert "IN WITNESS WHEREOF" in sections
# Verify preservation
assert verification[0] is True
# Verify section info
info = get_section_info(sections)
assert info["total_sections"] > 0
# Total characters should match (allow for slight differences in section joining)
assert abs(info["total_characters"] - len(contract_text)) < 20
+332
View File
@@ -0,0 +1,332 @@
"""
Unit tests for src.utils.rag_utils module
Tests RAG (Retrieval-Augmented Generation) utility functions including:
- RAGConfig configuration
- Client initialization and caching
- Vectorstore creation
- Hybrid retriever creation
- Document reranking
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from langchain.schema import Document
from src.utils.rag_utils import (
RAGConfig,
HSC_CONFIG,
RERANKER_CONFIGS,
get_bedrock_client,
get_reranker_client,
get_embeddings,
create_vectorstore,
create_hybrid_retriever,
rerank_documents,
)
class TestRAGConfig:
"""Test RAGConfig dataclass"""
def test_default_config(self):
"""Test that HSC_CONFIG has expected default values"""
assert HSC_CONFIG.CHUNK_SIZE == 2000
assert HSC_CONFIG.CHUNK_OVERLAP == 200
assert HSC_CONFIG.SEMANTIC_TOP_K == 35
assert HSC_CONFIG.KEYWORD_TOP_K == 25
assert HSC_CONFIG.ENSEMBLE_TOP_K == 40
assert HSC_CONFIG.RERANKER_TOP_K == 15
assert HSC_CONFIG.BM25_WEIGHT == 0.40
assert HSC_CONFIG.VECTOR_WEIGHT == 0.60
assert HSC_CONFIG.RERANKER == "amazon"
assert HSC_CONFIG.EMBEDDING_MODEL_ID == "amazon.titan-embed-text-v2:0"
assert HSC_CONFIG.BEDROCK_REGION == "us-east-1"
def test_custom_config(self):
"""Test creating custom RAGConfig"""
custom_config = RAGConfig(
CHUNK_SIZE=1000,
CHUNK_OVERLAP=100,
RERANKER="cohere"
)
assert custom_config.CHUNK_SIZE == 1000
assert custom_config.CHUNK_OVERLAP == 100
assert custom_config.RERANKER == "cohere"
# Defaults should still apply
assert custom_config.SEMANTIC_TOP_K == 35
def test_reranker_configs(self):
"""Test RERANKER_CONFIGS dictionary structure"""
assert "amazon" in RERANKER_CONFIGS
assert "cohere" in RERANKER_CONFIGS
# Amazon config
assert RERANKER_CONFIGS["amazon"]["model_id"] == "amazon.rerank-v1:0"
assert RERANKER_CONFIGS["amazon"]["name"] == "Amazon Rerank 1.0"
assert RERANKER_CONFIGS["amazon"]["region"] == "us-west-2"
# Cohere config
assert RERANKER_CONFIGS["cohere"]["model_id"] == "cohere.rerank-v3-5:0"
assert RERANKER_CONFIGS["cohere"]["name"] == "Cohere Rerank 3.5"
assert RERANKER_CONFIGS["cohere"]["region"] == "us-east-1"
class TestClientFunctions:
"""Test client initialization and caching functions"""
@patch('src.utils.rag_utils.boto3.client')
def test_get_bedrock_client(self, mock_boto_client):
"""Test Bedrock client creation and caching"""
# Reset module-level cache
import src.utils.rag_utils as rag_utils
rag_utils._bedrock_client = None
mock_client = Mock()
mock_boto_client.return_value = mock_client
# First call should create client
client1 = get_bedrock_client()
assert client1 == mock_client
mock_boto_client.assert_called_once_with("bedrock-runtime", region_name="us-east-1")
# Second call should return cached client
client2 = get_bedrock_client()
assert client2 == mock_client
assert mock_boto_client.call_count == 1 # Should not create new client
@patch('src.utils.rag_utils.boto3.client')
def test_get_reranker_client_amazon(self, mock_boto_client):
"""Test Amazon reranker client creation"""
# Reset module-level cache
import src.utils.rag_utils as rag_utils
rag_utils._reranker_clients = {}
mock_client = Mock()
mock_boto_client.return_value = mock_client
client = get_reranker_client("amazon")
assert client == mock_client
mock_boto_client.assert_called_with("bedrock-runtime", region_name="us-west-2")
@patch('src.utils.rag_utils.boto3.client')
def test_get_reranker_client_cohere(self, mock_boto_client):
"""Test Cohere reranker client creation"""
# Reset module-level cache
import src.utils.rag_utils as rag_utils
rag_utils._reranker_clients = {}
mock_client = Mock()
mock_boto_client.return_value = mock_client
client = get_reranker_client("cohere")
assert client == mock_client
mock_boto_client.assert_called_with("bedrock-runtime", region_name="us-east-1")
@patch('src.utils.rag_utils.get_bedrock_client')
@patch('src.utils.rag_utils.BedrockEmbeddings')
def test_get_embeddings(self, mock_embeddings_class, mock_get_client):
"""Test embeddings model creation and caching"""
# Reset module-level cache
import src.utils.rag_utils as rag_utils
rag_utils._embeddings = None
mock_client = Mock()
mock_get_client.return_value = mock_client
mock_embeddings = Mock()
mock_embeddings_class.return_value = mock_embeddings
# First call should create embeddings
embeddings1 = get_embeddings()
assert embeddings1 == mock_embeddings
mock_embeddings_class.assert_called_once_with(
model_id="amazon.titan-embed-text-v2:0",
client=mock_client
)
# Second call should return cached embeddings
embeddings2 = get_embeddings()
assert embeddings2 == mock_embeddings
assert mock_embeddings_class.call_count == 1
class TestVectorstoreFunctions:
"""Test vectorstore and retriever creation functions"""
@patch('src.utils.rag_utils.FAISS.from_documents')
def test_create_vectorstore(self, mock_faiss):
"""Test FAISS vectorstore creation"""
mock_vectorstore = Mock()
mock_faiss.return_value = mock_vectorstore
mock_embeddings = Mock()
chunks = [
Document(page_content="test content 1"),
Document(page_content="test content 2")
]
result = create_vectorstore(chunks, mock_embeddings)
assert result == mock_vectorstore
mock_faiss.assert_called_once()
# Verify chunks and embeddings were passed
call_args = mock_faiss.call_args
assert call_args[0][0] == chunks
assert call_args[0][1] == mock_embeddings
@patch('src.utils.rag_utils.BM25Retriever.from_documents')
@patch('src.utils.rag_utils.EnsembleRetriever')
def test_create_hybrid_retriever(self, mock_ensemble, mock_bm25):
"""Test hybrid retriever creation with BM25 and semantic search"""
# Setup mocks
mock_bm25_retriever = Mock()
mock_bm25.return_value = mock_bm25_retriever
mock_ensemble_retriever = Mock()
mock_ensemble.return_value = mock_ensemble_retriever
mock_vectorstore = Mock()
mock_vector_retriever = Mock()
mock_vectorstore.as_retriever.return_value = mock_vector_retriever
chunks = [Document(page_content="test")]
config = RAGConfig()
result = create_hybrid_retriever(chunks, mock_vectorstore, config)
assert result == mock_ensemble_retriever
# Verify BM25 was created
mock_bm25.assert_called_once()
# Verify vectorstore retriever was created
mock_vectorstore.as_retriever.assert_called_once()
# Verify ensemble was created with correct weights
call_args = mock_ensemble.call_args[1]
assert call_args['weights'] == [config.VECTOR_WEIGHT, config.BM25_WEIGHT]
class TestRerankDocuments:
"""Test document reranking functionality"""
def test_rerank_empty_docs(self):
"""Test reranking with empty document list"""
result = rerank_documents("query", [], Mock())
assert result == []
@patch('src.utils.rag_utils.json.loads')
def test_rerank_amazon_model(self, mock_json_loads):
"""Test reranking with Amazon model"""
# Setup
mock_client = Mock()
mock_response = {
"body": Mock(read=Mock(return_value=b'{}'))
}
mock_client.invoke_model.return_value = mock_response
mock_json_loads.return_value = {
"results": [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.7}
]
}
docs = [
Document(page_content="doc1", metadata={"source": "test"}),
Document(page_content="doc2", metadata={"source": "test"})
]
result = rerank_documents(
query="test query",
docs=docs,
client=mock_client,
top_k=2,
model="amazon"
)
# Verify results
assert len(result) == 2
assert result[0].page_content == "doc1"
assert result[0].metadata["rerank_score"] == 0.9
assert result[1].page_content == "doc2"
assert result[1].metadata["rerank_score"] == 0.7
@patch('src.utils.rag_utils.json.loads')
def test_rerank_cohere_model(self, mock_json_loads):
"""Test reranking with Cohere model"""
mock_client = Mock()
mock_response = {
"body": Mock(read=Mock(return_value=b'{}'))
}
mock_client.invoke_model.return_value = mock_response
mock_json_loads.return_value = {
"results": [
{"index": 1, "relevance_score": 0.8},
{"index": 0, "relevance_score": 0.6}
]
}
docs = [
Document(page_content="doc1"),
Document(page_content="doc2")
]
result = rerank_documents(
query="test query",
docs=docs,
client=mock_client,
top_k=2,
model="cohere"
)
# Verify reranked order (doc2 should be first due to higher score)
assert len(result) == 2
assert result[0].page_content == "doc2"
assert result[1].page_content == "doc1"
@patch('src.utils.rag_utils.json.loads', side_effect=Exception("API Error"))
@patch('src.utils.rag_utils.logging')
def test_rerank_error_handling(self, mock_logging, mock_json_loads):
"""Test that reranking failures return top_k documents"""
docs = [
Document(page_content=f"doc{i}") for i in range(10)
]
mock_client = Mock()
result = rerank_documents(
query="test",
docs=docs,
client=mock_client,
top_k=3,
model="amazon"
)
# Should return first top_k docs on error
assert len(result) == 3
assert result[0].page_content == "doc0"
# Verify error was logged
mock_logging.error.assert_called_once()
def test_rerank_top_k_limit(self):
"""Test that reranking respects top_k limit"""
mock_client = Mock()
mock_response = {
"body": Mock(read=Mock(return_value=b'{"results": [{"index": 0, "relevance_score": 0.9}, {"index": 1, "relevance_score": 0.8}, {"index": 2, "relevance_score": 0.7}]}'))
}
mock_client.invoke_model.return_value = mock_response
docs = [Document(page_content=f"doc{i}") for i in range(3)]
with patch('src.utils.rag_utils.json.loads') as mock_json:
mock_json.return_value = {
"results": [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.8},
{"index": 2, "relevance_score": 0.7}
]
}
result = rerank_documents(
query="test",
docs=docs,
client=mock_client,
top_k=2,
model="amazon"
)
assert len(result) == 2
@@ -1,72 +0,0 @@
import pytest
from src.investment.smart_chunking_funcs import parse_chunk, stitch_chunks
def test_parse_chunk_valid():
chunk = "chunk 001: This is a test chunk."
chunk_number, content = parse_chunk(chunk)
assert chunk_number == 1
assert content == "This is a test chunk."
def test_parse_chunk_valid_with_large_number():
chunk = "chunk 123: Another test chunk with a larger number."
chunk_number, content = parse_chunk(chunk)
assert chunk_number == 123
assert content == "Another test chunk with a larger number."
def test_parse_chunk_empty_content():
chunk = "chunk 005: "
chunk_number, content = parse_chunk(chunk)
assert chunk_number == 5
assert content == ""
def test_parse_chunk_invalid_format():
chunk = "invalid chunk format"
with pytest.raises(ValueError):
parse_chunk(chunk)
def test_parse_chunk_short_prefix():
chunk = "chunk 01: Short prefix"
with pytest.raises(ValueError):
parse_chunk(chunk)
def test_stitch_chunks_empty_list():
assert stitch_chunks([]) == ""
def test_stitch_chunks_single_chunk():
assert stitch_chunks(["chunk 001: hello world"]) == "hello world"
def test_stitch_chunks_two_chunks():
chunks = ["chunk 001: hello world", "chunk 002: world and more"]
assert stitch_chunks(chunks, overlap_size=5) == "hello world and more"
def test_stitch_chunks_out_of_order():
chunks = ["chunk 002: world", "chunk 001: hello"]
assert stitch_chunks(chunks, overlap_size=0) in ["helloworld", "hello\nworld"]
def test_stitch_noncontiguous_chunks():
chunks = ["chunk 001: hello", "chunk 003: world"]
assert stitch_chunks(chunks, overlap_size=10) in [
"helloworld",
"hello\nworld",
] # the overlap size will be ignored in the noncontiguous case
def test_stitch_long_overlap(): # not enough text to overlap, so we just append them
chunks = ["chunk 001: hello", "chunk 002: world"]
assert stitch_chunks(chunks, overlap_size=10) in ["helloworld", "hello\nworld"]
def test_stitch_chunks_negative_overlap():
chunks = ["chunk 001: hello", "chunk 002: world"]
with pytest.raises(ValueError):
stitch_chunks(chunks, overlap_size=-1)