Merged in feature/rework-table-handling (pull request #575)
Feature/rework table handling * test: add cases for configuration boundary * test: add table post-processing tests (cases to ensure preservation of post-table text and metadata during table operations) * test: enhance table handling tests for small and large tables, ensuring proper suffixing and splitting behavior * Fix and clarify intent in comments * fix: enhance handling of post-table text when combining tables * refactor: remove commented-out code in clean_tables function for clarity * test: update post-table text preservation assertions and add debug tracing * refactor: remove unused remove_table_from_page function and its test * refactor: enhance combine method documentation and clarify split_by_size_with_smart_tables logic * Merge remote-tracking branch 'origin/main' into feature/rework-table-handling * refactor: reduce large table threshold from 1000 to 50 rows for better table splitting * Add simple splitting * refactor: uncomment dynamic answer retrieval in run_one_to_n_prompts for improved functionality * Merge remote-tracking branch 'origin/main' into feature/rework-table-handling * refactor: remove unused table handling functions and related constants for cleaner code * refactor: remove unused test functions in preparation for new tests * refactor: streamline continuation table handling by utilizing existing flags * refactor: update table combination logic to include all tables from the main page * refactor: enhance table handling to automatically resolve column mismatches, respect row limits, and improve unit/integration test coverage * rename clean_tables_simple to clean_tables * Update docstrings * refactor: rename table handling functions * Fix indentation and add tests * isort, black * Merge remote-tracking branch 'origin/main' into feature/rework-table-handling Approved-by: Katon Minhas
This commit is contained in:
@@ -253,8 +253,7 @@ TABLE_THRESHOLD = 2
|
||||
COMPLEX_OUTPUT_PATH = "complex_flag_output"
|
||||
COMPLEX_OUTPUT_FILENAME = f"{TODAY}_complex_flag_test.csv"
|
||||
|
||||
TABLE_ROW_LIMIT = get_arg_value("table_row_limit", 10)
|
||||
|
||||
TABLE_ROW_LIMIT = 10
|
||||
|
||||
######################################## EC2 ########################################
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ def run_one_to_n_prompts(filename, exhibit_dict, all_exhibit_headers, all_datase
|
||||
reimbursement_level_fields = FieldSet(relationship="one_to_n", field_type="reimbursement_level", file_path=FIELD_JSON_PATH)
|
||||
|
||||
################## GET REIMBURSEMENT TIN/NPI ##################
|
||||
tin_npi_answers, reimbursement_level_fields = reimbursement_tin_npi(exhibit_text, reimbursement_level_fields, filename)
|
||||
# tin_npi_answers, reimbursement_level_fields = reimbursement_tin_npi(exhibit_text, reimbursement_level_fields, filename)
|
||||
|
||||
################## GET EXHIBIT-LEVEL ANSWERS ##################
|
||||
exhibit_level_answers = one_to_n_funcs.get_exhibit_level_answers(exhibit_text, filename)
|
||||
@@ -172,7 +172,7 @@ def run_one_to_n_prompts(filename, exhibit_dict, all_exhibit_headers, all_datase
|
||||
reimbursement_level_answers = reimbursement_level(exhibit_text, filename, reimbursement_level_fields, all_dataset, exhibit_page, seen_pairs) # Return list of dictionaries
|
||||
|
||||
################# COMBINE ANSWERS ##################
|
||||
full_answer_dict = combine_one_to_n_answers(exhibit_level_answers, reimbursement_level_answers, tin_npi_answers)
|
||||
full_answer_dict = combine_one_to_n_answers(exhibit_level_answers, reimbursement_level_answers, tin_npi_answers={})
|
||||
one_to_n_results += full_answer_dict
|
||||
|
||||
################## Crosswalk Fields ##################
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import src.investment.table_funcs as table_funcs
|
||||
from src import config, keywords, preprocessing_funcs
|
||||
import src.investment.smart_chunking_funcs as smart_chunking_funcs
|
||||
from src import keywords, preprocessing_funcs
|
||||
import src.prompts.investment_prompts as investment_prompts
|
||||
from src.utils import llm_utils, string_utils
|
||||
from src.enums.delimiters import Delimiter
|
||||
import logging
|
||||
|
||||
import src.investment.smart_chunking_funcs as smart_chunking_funcs
|
||||
import src.investment.table_funcs as table_funcs
|
||||
import src.prompts.investment_prompts as investment_prompts
|
||||
from src import config, keywords, preprocessing_funcs
|
||||
from src.enums.delimiters import Delimiter
|
||||
from src.utils import llm_utils, string_utils
|
||||
|
||||
|
||||
def clean_text(contract_text):
|
||||
|
||||
contract_text = preprocessing_funcs.remove_page_indicators(contract_text)
|
||||
@@ -19,8 +20,8 @@ def split_text(contract_text):
|
||||
"""
|
||||
Splits the contract text into separate pages and filters pages needing quick review.
|
||||
|
||||
This function processes the input contract text by splitting it into individual pages
|
||||
and then categorizes these pages. Pages representing a Quick Review or Cover Page section are separated from
|
||||
This function processes the input contract text by splitting it into individual pages
|
||||
and then categorizes these pages. Pages representing a Quick Review or Cover Page section are separated from
|
||||
the main text.
|
||||
|
||||
Parameters:
|
||||
@@ -33,9 +34,9 @@ def split_text(contract_text):
|
||||
- int: The total number of pages in the contract text.
|
||||
|
||||
Notes:
|
||||
- This function utilizes `preprocessing_funcs.split_text` to divide the contract text
|
||||
- This function utilizes `preprocessing_funcs.split_text` to divide the contract text
|
||||
into a dictionary with page numbers as keys.
|
||||
- It uses `preprocessing_funcs.filter_quick_review` to separate out pages that need quick
|
||||
- It uses `preprocessing_funcs.filter_quick_review` to separate out pages that need quick
|
||||
review from the main text dictionary.
|
||||
"""
|
||||
text_dict = preprocessing_funcs.split_text(
|
||||
@@ -59,12 +60,21 @@ def one_to_n_exhibit_chunking(text_dict, filename) -> tuple[dict, dict]:
|
||||
- dict: A dictionary where keys are exhibit page numbers and values are the corresponding exhibit text chunks.
|
||||
- dict: A dictionary where keys are exhibit page numbers and values are the exhibit headers.
|
||||
"""
|
||||
exhibit_pages, all_exhibit_headers = preprocessing_funcs.get_exhibit_pages(text_dict, filename)
|
||||
exhibit_pages, all_exhibit_headers = preprocessing_funcs.link_exhibit_pages(all_exhibit_headers, filename)
|
||||
exhibit_chunk_mapping = preprocessing_funcs.chunk_by_exhibit(text_dict, exhibit_pages)
|
||||
exhibit_dict = preprocessing_funcs.get_exhibit_dict(text_dict, exhibit_chunk_mapping)
|
||||
exhibit_pages, all_exhibit_headers = preprocessing_funcs.get_exhibit_pages(
|
||||
text_dict, filename
|
||||
)
|
||||
exhibit_pages, all_exhibit_headers = preprocessing_funcs.link_exhibit_pages(
|
||||
all_exhibit_headers, filename
|
||||
)
|
||||
exhibit_chunk_mapping = preprocessing_funcs.chunk_by_exhibit(
|
||||
text_dict, exhibit_pages
|
||||
)
|
||||
exhibit_dict = preprocessing_funcs.get_exhibit_dict(
|
||||
text_dict, exhibit_chunk_mapping
|
||||
)
|
||||
return exhibit_dict, all_exhibit_headers
|
||||
|
||||
|
||||
def one_to_one_smart_chunking(text_dict, contract_text, one_to_one_fields):
|
||||
"""
|
||||
In current state, this function is a wrapper for smart_chunk_ac. Call any new smart-chunking-related preprocessing funcs here
|
||||
@@ -77,24 +87,44 @@ def one_to_one_smart_chunking(text_dict, contract_text, one_to_one_fields):
|
||||
Returns:
|
||||
dict: ac_chunks
|
||||
"""
|
||||
ac_chunks = smart_chunking_funcs.smart_chunk_one_to_one(text_dict, contract_text, one_to_one_fields)
|
||||
ac_chunks = smart_chunking_funcs.smart_chunk_one_to_one(
|
||||
text_dict, contract_text, one_to_one_fields
|
||||
)
|
||||
return ac_chunks
|
||||
|
||||
|
||||
def clean_tables(text_dict: dict[str, str], filename):
|
||||
|
||||
# Get Table Info
|
||||
def clean_tables(text_dict: dict[str, str], filename: str) -> dict[str, str]:
|
||||
"""
|
||||
Process tables with simplified logic: combine continuations, then split by row count.
|
||||
|
||||
Args:
|
||||
text_dict (dict[str, str]): A dictionary where keys are page numbers and values are the text on those pages.
|
||||
filename (str): The name of the file being processed.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: A dictionary with the processed text for each page, with tables split when necessary.
|
||||
"""
|
||||
logging.info(f"Starting simple table processing for {filename}")
|
||||
|
||||
# 1. Extract table info
|
||||
table_dict = table_funcs.get_table_info(text_dict)
|
||||
logging.info(f"Found tables on pages: {list(table_dict.keys())}")
|
||||
|
||||
# Combine continuous tables
|
||||
text_dict_combined = table_funcs.combine_continuous_tables(text_dict, table_dict)
|
||||
# 2. Combine continuous tables (simple version)
|
||||
text_dict_combined = table_funcs.combine_continuous_tables(
|
||||
text_dict=text_dict,
|
||||
table_dict=table_dict,
|
||||
)
|
||||
logging.info(f"After combination, pages: {list(text_dict_combined.keys())}")
|
||||
|
||||
# Split tables
|
||||
table_dict = table_funcs.get_table_info(text_dict_combined)
|
||||
text_dict_split = table_funcs.split_long_tables(text_dict_combined, table_dict, config.TABLE_ROW_LIMIT)
|
||||
# 3. Split tables by row count only
|
||||
text_dict_final = table_funcs.split_tables_by_rows(
|
||||
text_dict=text_dict_combined,
|
||||
row_limit=config.TABLE_ROW_LIMIT,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Final pages after simple table processing: {list(text_dict_final.keys())}"
|
||||
)
|
||||
|
||||
# Align and Format
|
||||
table_dict = table_funcs.get_table_info(text_dict_split)
|
||||
text_dict_final = table_funcs.align_and_format_tables(text_dict_split, table_dict, filename)
|
||||
|
||||
return text_dict_final
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ast
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Pattern, Tuple, Union
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import src.utils.llm_utils as llm_utils
|
||||
import src.utils.string_utils as string_utils
|
||||
from src import config
|
||||
@@ -21,73 +23,101 @@ CONTINUATION_PAGE_PATTERN: Pattern[str] = re.compile(
|
||||
START_PAGE_NUM_PATTERN: str = r"\n\d+\n?"
|
||||
CONTINUATION_PAGE_NUM_PATTERN: str = r"\d+\n"
|
||||
DICTIONARY_PATTERN: str = r"\{.*\}"
|
||||
LIST_PATTERN: str = r"\[.*\]"
|
||||
|
||||
# Markers for table start and end
|
||||
START_MARKER: str = "-------Table Start--------"
|
||||
END_MARKER: str = "-------Table End--------"
|
||||
|
||||
|
||||
def manual_align(page_text, page_tables):
|
||||
def convert_str_to_dataframe(text: str, is_continuation: bool = False) -> pd.DataFrame:
|
||||
"""
|
||||
Align tables in the page text manually.
|
||||
Converts a string representation of table data to a pandas DataFrame.
|
||||
|
||||
Supports both old dict format (for backward compatibility) and new list-of-rows format.
|
||||
|
||||
For list format, treats the first row as column headers and the rest as data rows.
|
||||
For dict format, assumes the keys are column names and values are lists of column data.
|
||||
|
||||
Args:
|
||||
page_text (str): The text of the page.
|
||||
page_tables (List[Table]): A list of Table objects.
|
||||
|
||||
Returns:
|
||||
str: The page text with tables aligned
|
||||
"""
|
||||
page_text = page_text[0:page_text.find(START_MARKER)]
|
||||
page_clean = page_text.replace("\n", " ") # Clean tables
|
||||
|
||||
for table in page_tables:
|
||||
header_text = table.header
|
||||
header_matches = list(re.finditer(re.escape(header_text), page_clean))
|
||||
if len(header_matches) == 1:
|
||||
page_text = "\n".join([page_text[0:header_matches[0].span()[0]],
|
||||
header_text,
|
||||
START_MARKER,
|
||||
str(table.table),
|
||||
END_MARKER,
|
||||
page_text[header_matches[0].span()[1]:]])
|
||||
else:
|
||||
return False
|
||||
return page_text
|
||||
|
||||
|
||||
def align_and_format_tables(text_dict, table_dict, filename):
|
||||
"""
|
||||
Manual or prompt-based alignment of tables in the text_dict.
|
||||
|
||||
Args:
|
||||
text_dict (Dict[str, str]): A dictionary with keys as page numbers and values as page text.
|
||||
table_dict (Dict[str, List[Table]]): A dictionary with keys as page numbers and values as a list of Table objects.
|
||||
filename (str): The name of the file being processed.
|
||||
text (str): The input string containing table data.
|
||||
is_continuation (bool): Whether this is a continuation table (no header). Defaults to False.
|
||||
If True, don't treat first row as headers.
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: A dictionary with keys as page numbers and values as page text with tables aligned
|
||||
and formatted.
|
||||
pd.DataFrame: A DataFrame representation of the table data with string dtype.
|
||||
"""
|
||||
for page_num, page_text in text_dict.items():
|
||||
|
||||
if "Table Start" in page_text and string_utils.contains_reimbursement(
|
||||
page_text
|
||||
):
|
||||
aligned_page = False
|
||||
# Only attempt manual if 1 table on page
|
||||
if page_text.count("Table Start") == 1:
|
||||
aligned_page = manual_align(page_text, table_dict[page_num])
|
||||
|
||||
# If more than 1 table on page, or if manual_align failed
|
||||
if not aligned_page:
|
||||
prompt = preprocessing_prompts.ALIGN_TABLES(page_text)
|
||||
aligned_page = llm_utils.invoke_claude(
|
||||
prompt, config.MODEL_ID_CLAUDE35_SONNET, filename, 8192
|
||||
|
||||
# Try to parse as list first (new format)
|
||||
list_match = re.search(LIST_PATTERN, text, re.DOTALL)
|
||||
if list_match:
|
||||
list_str = list_match.group(0)
|
||||
try:
|
||||
parsed_data = ast.literal_eval(list_str)
|
||||
if isinstance(parsed_data, list):
|
||||
if not parsed_data:
|
||||
return pd.DataFrame()
|
||||
|
||||
# Validate that all elements are lists (handle malformed data)
|
||||
validated_rows = []
|
||||
for row in parsed_data:
|
||||
if isinstance(row, list):
|
||||
validated_rows.append(
|
||||
[str(cell) for cell in row]
|
||||
) # Convert all to strings
|
||||
else:
|
||||
# Skip malformed rows or convert single values to single-item lists
|
||||
if isinstance(row, str):
|
||||
validated_rows.append([row])
|
||||
# Skip other types entirely
|
||||
|
||||
if not validated_rows:
|
||||
return pd.DataFrame()
|
||||
|
||||
# Handle variable row lengths by padding with empty strings
|
||||
max_cols = (
|
||||
max(len(row) for row in validated_rows) if validated_rows else 0
|
||||
)
|
||||
|
||||
text_dict[page_num] = aligned_page
|
||||
return text_dict
|
||||
padded_rows = [
|
||||
row + [""] * (max_cols - len(row)) for row in validated_rows
|
||||
]
|
||||
|
||||
if is_continuation:
|
||||
# CONTINUATION: all rows are data, use generic column names
|
||||
columns = [f"col_{i}" for i in range(max_cols)]
|
||||
return pd.DataFrame(padded_rows, columns=columns, dtype=str)
|
||||
else:
|
||||
# REGULAR: treat first row as headers
|
||||
if len(padded_rows) > 1:
|
||||
headers = padded_rows[0]
|
||||
data_rows = padded_rows[1:]
|
||||
return pd.DataFrame(data_rows, columns=headers, dtype=str)
|
||||
elif len(padded_rows) == 1:
|
||||
# Only one row - treat as headers with no data
|
||||
headers = padded_rows[0]
|
||||
return pd.DataFrame(columns=headers, dtype=str)
|
||||
else:
|
||||
# Empty list - return empty DataFrame
|
||||
return pd.DataFrame()
|
||||
except (SyntaxError, ValueError):
|
||||
print(f"Invalid list format: {list_str}")
|
||||
|
||||
# Fall back to dict format (old format) - but warn about deprecation
|
||||
dict_match = re.search(DICTIONARY_PATTERN, text)
|
||||
if dict_match:
|
||||
dict_str = dict_match.group(0)
|
||||
try:
|
||||
parsed_data = ast.literal_eval(dict_str)
|
||||
if isinstance(parsed_data, dict):
|
||||
logging.warning(
|
||||
"Warning: old table (dict) format detected. Please update to new list-of-rows format by rerunning textract on this file."
|
||||
)
|
||||
# No need to set the headers explicitly, DataFrame will use keys as column names
|
||||
return pd.DataFrame(parsed_data, dtype=str)
|
||||
except (SyntaxError, ValueError):
|
||||
print(f"Invalid dictionary format: {dict_str}")
|
||||
|
||||
return pd.DataFrame() # Return empty DataFrame if no valid format found
|
||||
|
||||
|
||||
def convert_str_to_dict(text: str) -> Dict[str, Any]:
|
||||
@@ -116,58 +146,268 @@ def convert_str_to_dict(text: str) -> Dict[str, Any]:
|
||||
|
||||
class Table:
|
||||
"""
|
||||
A class to represent a table extracted from a page.
|
||||
Represents a table extracted from a document page.
|
||||
|
||||
This class handles table data parsing, combination, and splitting operations
|
||||
for tables found in OCR-processed documents.
|
||||
|
||||
Attributes:
|
||||
page_num (str): The page number the table is from.
|
||||
metadata (str): The metadata of the table.
|
||||
header (str): The header of the table.
|
||||
table (Dict[str, Any]): The table data as a dictionary.
|
||||
start_index (int): The starting index of the table in the page text.
|
||||
end_index (int): The ending index of the table in the page text.
|
||||
|
||||
Methods:
|
||||
combine(other: "Table"): Combine another table into this one, keeping only the original keys.
|
||||
split(row_limit: int) -> List["Table"]: Split the table into multiple tables each with at most row_limit rows.
|
||||
page_num (str): The page number where the table is located.
|
||||
metadata (str): Text appearing before the table on the page.
|
||||
header (str): The table's title/header text.
|
||||
table (pd.DataFrame): The table data with string dtype.
|
||||
start_index (int): Starting position of the table in the page text.
|
||||
end_index (int): Ending position of the table in the page text.
|
||||
post_table_text (str): Text appearing after the table on the page.
|
||||
is_continuation (bool): Whether this table continues from a previous page.
|
||||
original_pages (List[str]): Pages this table spans (for tracking combinations).
|
||||
|
||||
Key Methods:
|
||||
combine(other): Combine another table into this one with column alignment.
|
||||
split(row_limit): Split into multiple tables based on row count.
|
||||
to_list_format(): Convert to list-of-lists format for serialization.
|
||||
"""
|
||||
def __init__(self, page_num, metadata, header, table, start_index, end_index):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_num: str,
|
||||
metadata: str,
|
||||
header: str,
|
||||
table_data: Union[List[List[str]], pd.DataFrame, Dict[str, Any]],
|
||||
start_index: int,
|
||||
end_index: int,
|
||||
post_table_text: str = "",
|
||||
is_continuation: bool = False,
|
||||
original_pages: Union[
|
||||
List[str], None
|
||||
] = None, # Track which pages this table spans
|
||||
):
|
||||
self.page_num = page_num
|
||||
self.metadata = metadata
|
||||
self.header = header
|
||||
self.table = convert_str_to_dict(table)
|
||||
self.start_index = start_index
|
||||
self.end_index = end_index
|
||||
|
||||
self.is_continuation = is_continuation
|
||||
self.post_table_text: str = post_table_text # Text after the table in the page
|
||||
self.original_pages = original_pages or [
|
||||
page_num
|
||||
] # if `original_pages` is None, initialize with current page
|
||||
|
||||
# Handle a few different input types
|
||||
if isinstance(table_data, str):
|
||||
self.table = convert_str_to_dataframe(
|
||||
table_data, is_continuation=is_continuation
|
||||
)
|
||||
elif isinstance(table_data, pd.DataFrame):
|
||||
self.table = table_data.astype(str) # Ensure DataFrame is of string dtype
|
||||
elif isinstance(table_data, list):
|
||||
# Direct list - check if continuation table
|
||||
if is_continuation:
|
||||
logging.debug(f"Detected continuation table on page {page_num}.")
|
||||
# All rows are data rows, no headers
|
||||
max_cols = max(len(row) for row in table_data) if table_data else 0
|
||||
padded_rows = [row + [""] * (max_cols - len(row)) for row in table_data]
|
||||
columns = [f"col_{i}" for i in range(max_cols)]
|
||||
self.table = pd.DataFrame(padded_rows, columns=columns, dtype=str)
|
||||
else:
|
||||
# Non-continuation table - treat first row as headers
|
||||
if not table_data:
|
||||
self.table = pd.DataFrame()
|
||||
else:
|
||||
# Handle variable row lengths by padding with empty strings
|
||||
max_cols = max(len(row) for row in table_data) if table_data else 0
|
||||
padded_rows = [
|
||||
row + [""] * (max_cols - len(row)) for row in table_data
|
||||
]
|
||||
|
||||
if len(padded_rows) > 1:
|
||||
headers = padded_rows[0]
|
||||
data_rows = padded_rows[1:]
|
||||
self.table = pd.DataFrame(data_rows, columns=headers, dtype=str)
|
||||
elif len(padded_rows) == 1:
|
||||
# Only one row - treat as headers with no data
|
||||
headers = padded_rows[0]
|
||||
self.table = pd.DataFrame(columns=headers, dtype=str)
|
||||
else:
|
||||
# Empty list - return empty DataFrame
|
||||
self.table = pd.DataFrame()
|
||||
|
||||
elif isinstance(table_data, dict): # Old dict format - warn and convert
|
||||
logging.warning(
|
||||
"Warning: old table (dict) format detected. Please update to new list-of-rows format by rerunning textract on this file."
|
||||
)
|
||||
self.table = pd.DataFrame(table_data, dtype=str)
|
||||
else:
|
||||
self.table = (
|
||||
pd.DataFrame()
|
||||
) # Default to empty DataFrame if no valid format found
|
||||
|
||||
def get_page_span(self) -> int:
|
||||
"""
|
||||
Get the number of pages this table spans.
|
||||
|
||||
Returns:
|
||||
int: The number of pages this table spans.
|
||||
"""
|
||||
return len(set(self.original_pages))
|
||||
|
||||
def add_page_to_span(self, page_num: str):
|
||||
"""
|
||||
Add a page number to the original pages this table spans.
|
||||
|
||||
Args:
|
||||
page_num (str): The page number to add.
|
||||
"""
|
||||
if page_num not in self.original_pages:
|
||||
self.original_pages.append(page_num)
|
||||
|
||||
def to_list_format(self) -> List[List[str]]:
|
||||
"""
|
||||
Convert the DataFrame back to list-of-rows format for serialization, with headers
|
||||
as the first row.
|
||||
|
||||
Returns:
|
||||
List[List[str]]: The table data as a list of lists.
|
||||
"""
|
||||
if self.table.empty:
|
||||
return []
|
||||
|
||||
headers = self.table.columns.tolist()
|
||||
data_rows = self.table.values.tolist()
|
||||
|
||||
# If continuation table with generic column names, don't include headers
|
||||
if self.is_continuation and all(col.startswith("col_") for col in headers):
|
||||
return data_rows # Just the data, no headers
|
||||
|
||||
# For all other cases (regular tables), include headers
|
||||
return [headers] + data_rows
|
||||
|
||||
def combine(self, other: "Table"):
|
||||
"""Combine another table into this one, keeping only the original keys."""
|
||||
if len(self.table) != len(other.table):
|
||||
raise ValueError("Tables must have the same number of columns to combine.")
|
||||
"""
|
||||
Combine another table into this one with intelligent column handling.
|
||||
|
||||
other_values = list(other.table.values()) # Get values from other table in order
|
||||
self_keys = list(self.table.keys()) # Original keys order
|
||||
Handles common OCR data issues like column count mismatches by automatically
|
||||
padding or truncating the continuation table to match the main table.
|
||||
|
||||
Business Rules:
|
||||
- Empty tables: Handled gracefully, preserving post-table text
|
||||
- Column mismatches: Continuation table adjusted to match main table
|
||||
- Continuation tables: Column names aligned to main table headers
|
||||
- Post-table text: Accumulated from both tables
|
||||
|
||||
Args:
|
||||
other: The table to combine into this one (consumed after operation).
|
||||
|
||||
Note:
|
||||
This method modifies the current table in-place. Column count mismatches
|
||||
are resolved by padding short rows or truncating long rows in the
|
||||
continuation table.
|
||||
"""
|
||||
|
||||
# Handle empty tables
|
||||
if other.table.empty:
|
||||
# if other table is empty, just update post_table_text if needed
|
||||
if other.post_table_text:
|
||||
self.post_table_text = other.post_table_text
|
||||
return
|
||||
|
||||
if self.table.empty:
|
||||
# If this table is empty, just copy the other table
|
||||
self.table = other.table.copy()
|
||||
if other.post_table_text:
|
||||
self.post_table_text = other.post_table_text
|
||||
return
|
||||
|
||||
# Handle column count mismatches
|
||||
if self.table.shape[1] != other.table.shape[1]:
|
||||
main_cols = self.table.shape[1]
|
||||
other_cols = other.table.shape[1]
|
||||
|
||||
logging.warning(
|
||||
f"Column count mismatch when combining tables: main table has {main_cols} columns, "
|
||||
f"continuation table has {other_cols} columns. Adjusting continuation table to match."
|
||||
)
|
||||
|
||||
# Create a copy to modify
|
||||
other_table_adjusted = other.table.copy()
|
||||
|
||||
if other_cols < main_cols:
|
||||
# Pad with empty columns
|
||||
for i in range(main_cols - other_cols):
|
||||
col_name = f"col_{other_cols + i}"
|
||||
# Ensure unique column name
|
||||
while col_name in other_table_adjusted.columns:
|
||||
col_name = f"{col_name}_padded"
|
||||
other_table_adjusted[col_name] = "" # Add empty column
|
||||
else:
|
||||
# Truncate to match main table column count
|
||||
other_table_adjusted = other_table_adjusted.iloc[:, :main_cols]
|
||||
|
||||
# Ensure column names match (this will be our "other" table for the rest of the method)
|
||||
other_table_adjusted.columns = self.table.columns
|
||||
|
||||
# Combine the adjusted table
|
||||
self.table = pd.concat(
|
||||
[self.table, other_table_adjusted], ignore_index=True
|
||||
)
|
||||
else:
|
||||
# Column counts match - proceed with normal logic
|
||||
# If other table has generic column names (continuation table), rename them
|
||||
if other.is_continuation or (
|
||||
list(other.table.columns) != list(self.table.columns)
|
||||
):
|
||||
other_renamed = other.table.copy()
|
||||
other_renamed.columns = self.table.columns # Use our column names
|
||||
self.table = pd.concat([self.table, other_renamed], ignore_index=True)
|
||||
else:
|
||||
self.table = pd.concat([self.table, other.table], ignore_index=True)
|
||||
|
||||
if other.post_table_text.strip(): # Only if the other table has post_table_text
|
||||
if self.post_table_text.strip():
|
||||
if not other.post_table_text.strip() == self.post_table_text.strip():
|
||||
# If both tables have post_table_text AND they're not identical, append the other table's text
|
||||
self.post_table_text = (
|
||||
f"{self.post_table_text}\n{other.post_table_text}"
|
||||
)
|
||||
else:
|
||||
# Only the other table has post_table_text, so just set it
|
||||
self.post_table_text = other.post_table_text
|
||||
|
||||
for i, key in enumerate(self_keys):
|
||||
self.table[key].extend(other_values[i]) # Append values in order
|
||||
|
||||
def split(self, row_limit: int) -> List["Table"]:
|
||||
"""Split the table into multiple tables each with at most row_limit rows."""
|
||||
if len(self.table) <= row_limit:
|
||||
return [self]
|
||||
|
||||
tables = []
|
||||
keys = list(self.table.keys())
|
||||
num_rows = len(self.table[keys[0]])
|
||||
for start_row in range(0, num_rows, row_limit):
|
||||
end_row = min(start_row + row_limit, num_rows)
|
||||
new_table_data = {key: self.table[key][start_row:end_row] for key in keys}
|
||||
new_table_str = str(new_table_data)
|
||||
tables.append(Table(self.page_num, self.metadata, self.header, new_table_str, self.start_index, self.end_index))
|
||||
for start_row in range(0, len(self.table), row_limit):
|
||||
end_row = min(start_row + row_limit, len(self.table))
|
||||
subset_df = self.table.iloc[start_row:end_row].copy()
|
||||
|
||||
# Create new table with DataFrame directly
|
||||
new_table = Table(
|
||||
page_num=self.page_num,
|
||||
metadata=self.metadata,
|
||||
header=self.header,
|
||||
table_data=subset_df,
|
||||
start_index=self.start_index, # Keep original start index
|
||||
end_index=self.end_index, # Keep original end index
|
||||
)
|
||||
tables.append(new_table)
|
||||
|
||||
if tables:
|
||||
# Only the LAST split table gets the post-table text
|
||||
tables[-1].post_table_text = self.post_table_text
|
||||
|
||||
return tables
|
||||
|
||||
def get_table_info(text_dict):
|
||||
|
||||
|
||||
def get_table_info(text_dict: Dict[str, str]) -> Dict[str, List[Table]]:
|
||||
"""
|
||||
Extracts all of the Table objects from the text_dict.
|
||||
|
||||
Args:
|
||||
text_dict (Dict[str, str]): A dictionary with keys as page numbers and values as page text.
|
||||
|
||||
|
||||
Returns:
|
||||
Dict[str, List[Table]]: A dictionary with keys as page numbers and values as a list of Table objects.
|
||||
"""
|
||||
@@ -189,143 +429,270 @@ def get_table_info(text_dict):
|
||||
metadata = page_text[:first_table_start_index].strip()
|
||||
|
||||
header_start = start_index + len(START_MARKER)
|
||||
header_end = page_text.find("{", header_start)
|
||||
header = page_text[header_start:header_end].strip()
|
||||
table = page_text[header_end:end_index].strip()
|
||||
|
||||
tables.append(Table(page_num, metadata, header, table, start_index, end_index + len(END_MARKER)))
|
||||
# Look for either [ or { to find start of table data
|
||||
list_start = page_text.find("[", header_start)
|
||||
dict_start = page_text.find("{", header_start)
|
||||
|
||||
if list_start != -1 and (dict_start == -1 or list_start < dict_start):
|
||||
# New list format
|
||||
# If list starts first, we assume it's a list of rows
|
||||
header = page_text[header_start:list_start].strip()
|
||||
table_data = page_text[list_start:end_index].strip()
|
||||
|
||||
is_continuation = (
|
||||
header == "" # No header text
|
||||
and len(page_text[header_start:list_start].strip())
|
||||
== 0 # Nothing between marker and `[`
|
||||
)
|
||||
elif dict_start != -1:
|
||||
# Old dict format
|
||||
# If dict starts first, we assume it's a dictionary
|
||||
header = page_text[header_start:dict_start].strip()
|
||||
table_data = page_text[dict_start:end_index].strip()
|
||||
|
||||
is_continuation = header == ""
|
||||
else:
|
||||
# No table data found
|
||||
break
|
||||
|
||||
tables.append(
|
||||
Table(
|
||||
page_num,
|
||||
metadata,
|
||||
header,
|
||||
table_data,
|
||||
start_index,
|
||||
end_index + len(END_MARKER),
|
||||
is_continuation=is_continuation,
|
||||
)
|
||||
)
|
||||
|
||||
start_index = end_index + len(END_MARKER)
|
||||
|
||||
if tables:
|
||||
last_table_end = tables[-1].end_index
|
||||
# If there's text after the last table, store it as post-table text
|
||||
post_table_text = page_text[last_table_end:].strip()
|
||||
|
||||
# Store post-table text in the last table
|
||||
tables[-1].post_table_text = post_table_text
|
||||
table_dict[page_num] = tables
|
||||
return table_dict
|
||||
|
||||
|
||||
def recreate_page(page_table_list):
|
||||
def recreate_page(page_table_list: List[Table]) -> str:
|
||||
"""
|
||||
Puts together the tables in the page_table_list to recreate the page text.
|
||||
|
||||
Args:
|
||||
page_table_list (List[Table]): A list of Table objects.
|
||||
|
||||
|
||||
Returns:
|
||||
str: The text of the page with the tables combined.
|
||||
"""
|
||||
page_text = ""
|
||||
for i in range(len(page_table_list)):
|
||||
t = page_table_list[i]
|
||||
table_as_list = t.to_list_format()
|
||||
if i == 0:
|
||||
page_text += "\n".join([t.metadata, START_MARKER, t.header, str(t.table), END_MARKER])
|
||||
page_text += "\n".join(
|
||||
[t.metadata, START_MARKER, t.header, str(table_as_list), END_MARKER]
|
||||
)
|
||||
else:
|
||||
page_text += "\n".join(["\n", START_MARKER, t.header, str(t.table), END_MARKER])
|
||||
page_text += "\n".join(
|
||||
["\n", START_MARKER, t.header, str(table_as_list), END_MARKER]
|
||||
)
|
||||
|
||||
# Add post-table text if it exists
|
||||
if t.post_table_text.strip():
|
||||
page_text += "\n" + t.post_table_text.strip()
|
||||
return page_text
|
||||
|
||||
def remove_table_from_page(page_text: str, t: Table):
|
||||
"""
|
||||
Remove the table from the page text.
|
||||
|
||||
def split_large_table_with_headers(
|
||||
table: Table, row_limit: int = config.TABLE_ROW_LIMIT
|
||||
) -> List[str]:
|
||||
"""Split a large table into smaller chunks while preserving headers.
|
||||
|
||||
Args:
|
||||
page_text (str): The text of the page.
|
||||
t (Table): The table to remove.
|
||||
|
||||
table (Table): The table to split.
|
||||
row_limit (int): The row limit to use. By default uses config.TABLE_ROW_LIMIT.
|
||||
|
||||
Returns:
|
||||
str: The page text with the table removed.
|
||||
List[str]: A list of text chunks representing the split table.
|
||||
"""
|
||||
# Remove the table from the page text
|
||||
table_text = page_text[t.start_index:t.end_index]
|
||||
page_text = page_text.replace(table_text, "").strip()
|
||||
return page_text
|
||||
# Use existing table splitting logic
|
||||
split_tables = split_table_by_rows(table, row_limit)
|
||||
|
||||
def combine_continuous_tables(text_dict, table_dict):
|
||||
"""
|
||||
Combine tables that are split across multiple pages.
|
||||
chunks = []
|
||||
for i, split_table in enumerate(split_tables):
|
||||
chunk_parts = []
|
||||
|
||||
# add metadata if it exists
|
||||
if split_table.metadata:
|
||||
chunk_parts.append(split_table.metadata)
|
||||
|
||||
# Add tables structure
|
||||
chunk_parts.extend(
|
||||
[
|
||||
START_MARKER,
|
||||
split_table.header, # Header replicated in each chunk
|
||||
str(split_table.to_list_format()), # Convert table to list format
|
||||
END_MARKER,
|
||||
]
|
||||
)
|
||||
|
||||
# Add post-table text only to the last chunk
|
||||
if i == len(split_tables) - 1 and table.post_table_text:
|
||||
chunk_parts.append(table.post_table_text)
|
||||
|
||||
chunks.append("\n".join(chunk_parts).strip())
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def combine_continuous_tables(
|
||||
text_dict: Dict[str, str], table_dict: Dict[str, List[Table]]
|
||||
) -> Dict[str, str]:
|
||||
"""Combine continuous tables across pages.
|
||||
Args:
|
||||
text_dict (Dict[str, str]): A dictionary with keys as page numbers and values as page text.
|
||||
table_dict (Dict[str, List[Table]]): A dictionary with keys as page numbers and values as a list of Table objects.
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: A dictionary with keys as page numbers and values as page text with tables combined if they are split
|
||||
across multiple pages.
|
||||
Dict[str, str]: Updated text_dict with combined tables.
|
||||
"""
|
||||
text_dict_combined = {}
|
||||
page_nums = sorted(text_dict.keys(), key=int) # Ensure page numbers are sorted
|
||||
pages_to_remove = set()
|
||||
updated_text_dict = text_dict.copy()
|
||||
|
||||
i = 0
|
||||
while i < len(page_nums):
|
||||
page_num = page_nums[i]
|
||||
current_page_tables = table_dict[page_num]
|
||||
|
||||
# If no table on page, continue
|
||||
if len(current_page_tables) == 0:
|
||||
text_dict_combined[page_num] = text_dict[page_num]
|
||||
i += 1
|
||||
continue
|
||||
|
||||
combined_tables = current_page_tables[:]
|
||||
next_page_num = str(int(page_num) + 1)
|
||||
|
||||
while next_page_num in table_dict:
|
||||
next_page_tables = table_dict[next_page_num]
|
||||
if not next_page_tables or not string_utils.is_empty(next_page_tables[0].header) or len(combined_tables[-1].table.keys()) != len(next_page_tables[0].table.keys()): # Edge Case: could also add criteria that metadata is empty as well
|
||||
break
|
||||
|
||||
combined_tables[-1].combine(next_page_tables[0]) # Combine tables
|
||||
text_dict[next_page_num] = remove_table_from_page(text_dict[next_page_num], next_page_tables[0])
|
||||
combined_tables.extend(next_page_tables[1:]) # Add remaining tables from next page
|
||||
|
||||
i += 1
|
||||
next_page_num = str(int(next_page_num) + 1)
|
||||
|
||||
new_page_text = recreate_page(combined_tables)
|
||||
text_dict_combined[page_num] = new_page_text
|
||||
i += 1
|
||||
|
||||
return text_dict_combined
|
||||
|
||||
|
||||
def split_long_tables(text_dict, table_dict, row_limit):
|
||||
"""
|
||||
Split tables in the text_dict into multiple tables if they exceed the row limit.
|
||||
|
||||
Args:
|
||||
text_dict (Dict[str, str]): A dictionary with keys as page numbers and values as page text.
|
||||
table_dict (Dict[str, List[Table]]): A dictionary with keys as page numbers and values as a list of Table objects.
|
||||
row_limit (int): The maximum number of rows a table can have.
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: A dictionary with keys as page numbers and values as page text with tables split if they exceed the row
|
||||
limit.
|
||||
"""
|
||||
new_text_dict = {}
|
||||
for page_num, page_text in text_dict.items():
|
||||
tables = table_dict[page_num]
|
||||
if not tables:
|
||||
new_text_dict[page_num] = page_text
|
||||
if page_num in pages_to_remove:
|
||||
continue
|
||||
|
||||
subpage_index = 0
|
||||
for table in tables:
|
||||
if table.table and len(table.table[list(table.table.keys())[0]]) > row_limit:
|
||||
split_tables = table.split(row_limit)
|
||||
for split_table in split_tables:
|
||||
new_page_num = f"{page_num}.{subpage_index}"
|
||||
new_text_dict[new_page_num] = "\n".join([split_table.metadata, START_MARKER, split_table.header, str(split_table.table), END_MARKER])
|
||||
subpage_index += 1
|
||||
if page_num not in table_dict or not table_dict[page_num]:
|
||||
# No tables on this page, nothing to combine
|
||||
continue
|
||||
|
||||
# Use the existing is_continuation flag from the table object
|
||||
first_table = table_dict[page_num][0]
|
||||
|
||||
if first_table.is_continuation:
|
||||
# This is a continuation table, find the main table to combine with
|
||||
main_table_page = None
|
||||
for prev_page in sorted(
|
||||
[p for p in table_dict.keys() if int(p) < int(page_num)],
|
||||
key=string_utils.page_key_sort,
|
||||
reverse=True,
|
||||
):
|
||||
if prev_page in table_dict and table_dict[prev_page]:
|
||||
main_table_page = prev_page
|
||||
break
|
||||
|
||||
if main_table_page and main_table_page not in pages_to_remove:
|
||||
# Combine the tables
|
||||
main_table = table_dict[main_table_page][
|
||||
-1
|
||||
] # Last table on the main page
|
||||
continuation_table = table_dict[page_num][
|
||||
0
|
||||
] # First table on current page
|
||||
main_table.combine(continuation_table)
|
||||
|
||||
# Update main page with ALL tables from that page
|
||||
updated_text_dict[main_table_page] = recreate_page(
|
||||
table_dict[main_table_page]
|
||||
)
|
||||
|
||||
# Mark continuation page for removal
|
||||
pages_to_remove.add(page_num)
|
||||
# Remove continuation pages (we combined them into the main pages)
|
||||
for page_num in pages_to_remove:
|
||||
updated_text_dict.pop(page_num, None)
|
||||
|
||||
return updated_text_dict
|
||||
|
||||
|
||||
def split_tables_by_rows(text_dict: Dict[str, str], row_limit: int) -> Dict[str, str]:
|
||||
"""Split tables that exceed the row limit, add .0 suffix to pages with tables
|
||||
|
||||
Args:
|
||||
text_dict (Dict[str, str]): A dictionary with keys as page numbers and values as page text.
|
||||
row_limit (int): The maximum number of rows allowed in a single table.
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: Updated text_dict with split tables and .0 suffixes for table pages
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for page_num, page_text in text_dict.items():
|
||||
# get tables on this page
|
||||
page_table_dict = get_table_info({page_num: page_text})
|
||||
|
||||
if page_num in page_table_dict and page_table_dict[page_num]:
|
||||
# This page has tables
|
||||
table = page_table_dict[page_num][0]
|
||||
|
||||
if len(table.table) > row_limit:
|
||||
# Large table - split it
|
||||
logging.info(
|
||||
f"Splitting table on page {page_num}: {len(table.table)} rows > row_limit ({row_limit})"
|
||||
)
|
||||
split_chunks = split_large_table_with_headers(table, row_limit)
|
||||
for i, chunk in enumerate(split_chunks):
|
||||
# Use .0, .1, .2 suffixes for split tables
|
||||
result[f"{page_num}.{i}"] = chunk.strip()
|
||||
else:
|
||||
new_page_num = f"{page_num}.{subpage_index}"
|
||||
new_text_dict[new_page_num] = "\n".join([table.metadata, START_MARKER, table.header, str(table.table), END_MARKER])
|
||||
subpage_index += 1
|
||||
|
||||
return new_text_dict
|
||||
|
||||
|
||||
# Small table - keep it intact
|
||||
logging.info(
|
||||
f"Keeping table on page {page_num}: {len(table.table)} rows <= row_limit ({row_limit})"
|
||||
)
|
||||
result[f"{page_num}.0"] = page_text
|
||||
else:
|
||||
# No tables on this page, keep it as is
|
||||
result[page_num] = page_text
|
||||
return result
|
||||
|
||||
|
||||
def split_table_by_rows(table: Table, row_limit: int) -> List[Table]:
|
||||
"""Split a single table into multiple tables if it exceeds the row limit.
|
||||
|
||||
Args:
|
||||
table (Table): The table to split.
|
||||
row_limit (int): The maximum number of rows allowed in a single table.
|
||||
|
||||
Returns:
|
||||
List[Table]: A list of tables, each with a maximum of row_limit rows.
|
||||
"""
|
||||
df = table.table
|
||||
total_rows = len(df)
|
||||
if total_rows <= row_limit:
|
||||
return [table]
|
||||
|
||||
split_tables = []
|
||||
for start_idx in range(0, total_rows, row_limit):
|
||||
end_idx = min(start_idx + row_limit, total_rows)
|
||||
|
||||
# Create subset DataFrame
|
||||
subset_df = df.iloc[start_idx:end_idx].copy()
|
||||
|
||||
# Create new table object
|
||||
split_table = Table(
|
||||
page_num=table.page_num,
|
||||
metadata=table.metadata,
|
||||
header=table.header,
|
||||
table_data=subset_df,
|
||||
start_index=table.start_index, # Keep original start index
|
||||
end_index=table.end_index, # Keep original end index
|
||||
post_table_text="", # Only last split gets post-table text
|
||||
is_continuation=False,
|
||||
original_pages=table.original_pages.copy(),
|
||||
)
|
||||
|
||||
split_tables.append(split_table)
|
||||
|
||||
# Add post-table text to the last split
|
||||
if split_tables:
|
||||
split_tables[-1].post_table_text = table.post_table_text
|
||||
|
||||
return split_tables
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.investment.preprocess import clean_tables
|
||||
|
||||
|
||||
class TestPreprocessingIntegration(unittest.TestCase):
|
||||
|
||||
def test_clean_tables_simple_end_to_end(self):
|
||||
"""Test the complete simplified table cleaning pipeline."""
|
||||
text_dict = {
|
||||
"1": "Metadata\n-------Table Start--------\nMain Table\n[['A', 'B']] + [[f'Row{i}', str(i)] for i in range(1, 8)]\n-------Table End--------",
|
||||
"2": "-------Table Start--------\n\n[['Row8', '8'], ['Row9', '9']]\n-------Table End--------", # Continuation
|
||||
"3": "Text without tables",
|
||||
}
|
||||
|
||||
# This should: 1) Combine continuations, 2) Split by row count
|
||||
result = clean_tables(text_dict, "test_file.txt")
|
||||
|
||||
# Should have combined page 2 into page 1, then split the long table
|
||||
self.assertNotIn("2", result) # Continuation removed
|
||||
self.assertIn("3", result) # Non-table page preserved
|
||||
# Should have split pages like "1.0", "1.1", etc.
|
||||
|
||||
def test_clean_tables_small_table_gets_dot_zero_suffix(self):
|
||||
"""Test that pages with small tables get .0 suffix but aren't split."""
|
||||
text_dict = {
|
||||
"15": "Rate Table\n-------Table Start--------\nRate Table\n[['HCPC', 'Rate'], ['T1019', '$5.00']]\n-------Table End--------"
|
||||
}
|
||||
|
||||
result = clean_tables(text_dict, "test_file.pdf")
|
||||
|
||||
# Should get .0 suffix because it contains tables
|
||||
self.assertIn("15.0", result)
|
||||
self.assertNotIn("15", result)
|
||||
|
||||
# Content should be preserved
|
||||
self.assertIn("T1019", result["15.0"])
|
||||
|
||||
# Should not be split (small table)
|
||||
chunk_keys = [k for k in result.keys() if k.startswith("15.")]
|
||||
self.assertEqual(len(chunk_keys), 1)
|
||||
|
||||
def test_clean_tables_continuation_gets_combined_and_dot_zero(self):
|
||||
"""Test that continuations get combined and result gets .0 suffix."""
|
||||
text_dict = {
|
||||
"1": "Rate Table\n-------Table Start--------\nRate Table Header\n[['HCPC', 'Rate'], ['T1019', '$5.00']]\n-------Table End--------",
|
||||
"2": "-------Table Start--------\n\n[['T1021', '$4.00']]\n-------Table End--------", # Continuation
|
||||
}
|
||||
|
||||
result = clean_tables(text_dict, "test_file.pdf")
|
||||
|
||||
# Should combine and get .0 suffix since contains tables
|
||||
self.assertIn("1.0", result)
|
||||
self.assertNotIn("1", result)
|
||||
self.assertNotIn("2", result) # Absorbed into Page 1
|
||||
|
||||
# Verify combined content
|
||||
self.assertIn("T1019", result["1.0"])
|
||||
self.assertIn("T1021", result["1.0"])
|
||||
|
||||
def test_clean_tables_large_table_row_based_splitting(self):
|
||||
"""Test that large tables get split based on row count."""
|
||||
# Create a table with many rows that will exceed TABLE_ROW_LIMIT
|
||||
large_table_rows = [["HCPC", "Rate"]] + [
|
||||
[f"T{i:04d}", f"${i}.00"] for i in range(60)
|
||||
] # 60 data rows
|
||||
text_dict = {
|
||||
"31": f"EXHIBIT F\n-------Table Start--------\nPCAP Rates\n{large_table_rows}\n-------Table End--------\nPost-table text"
|
||||
}
|
||||
|
||||
result = clean_tables(text_dict, "test_file.pdf")
|
||||
|
||||
# Should create multiple chunks due to row limit (default 50)
|
||||
chunk_keys = [k for k in result.keys() if k.startswith("31.")]
|
||||
self.assertGreater(
|
||||
len(chunk_keys),
|
||||
1,
|
||||
f"Expected multiple chunks for 60 rows, got: {chunk_keys}",
|
||||
)
|
||||
|
||||
# Original page should be gone
|
||||
self.assertNotIn("31", result)
|
||||
|
||||
# All table data should be preserved somewhere
|
||||
all_content = "".join(result.values())
|
||||
self.assertIn("T0000", all_content)
|
||||
self.assertIn("T0059", all_content)
|
||||
self.assertIn("EXHIBIT F", all_content)
|
||||
|
||||
# Post-table text should be preserved on last chunk
|
||||
last_chunk = max(chunk_keys)
|
||||
self.assertIn("Post-table text", result[last_chunk])
|
||||
|
||||
def test_clean_tables_no_tables_unchanged(self):
|
||||
"""Test that pages without tables remain unchanged."""
|
||||
text_dict = {
|
||||
"5": "This is just text content with no tables.",
|
||||
"6": "More text content without any table markers.",
|
||||
}
|
||||
|
||||
result = clean_tables(text_dict, "test_file.pdf")
|
||||
|
||||
# Should remain unchanged (no .0 suffix)
|
||||
assert "5" in result
|
||||
assert "6" in result
|
||||
assert "5.0" not in result
|
||||
assert "6.0" not in result
|
||||
|
||||
# Content should be identical
|
||||
assert result["5"] == text_dict["5"]
|
||||
assert result["6"] == text_dict["6"]
|
||||
|
||||
def test_clean_tables_multiple_tables_same_page(self):
|
||||
"""Test that multiple tables on same page get .0 suffix but stay together."""
|
||||
text_dict = {
|
||||
"1": "Page metadata\n-------Table Start--------\nFirst Table\n[['Col1', 'Col2'], ['A', 'B']]\n-------Table End--------\n\nSome text\n-------Table Start--------\nSecond Table\n[['X', 'Y'], ['1', '2']]\n-------Table End--------"
|
||||
}
|
||||
|
||||
result = clean_tables(text_dict, "test_file.pdf")
|
||||
|
||||
# Should get .0 suffix because it contains tables
|
||||
assert "1.0" in result
|
||||
assert "1" not in result
|
||||
|
||||
# Both tables should be present in single chunk
|
||||
assert "First Table" in result["1.0"]
|
||||
assert "Second Table" in result["1.0"]
|
||||
@@ -1,49 +1,859 @@
|
||||
import logging
|
||||
import unittest
|
||||
from src.investment.table_funcs import Table, get_table_info, recreate_page, remove_table_from_page, combine_continuous_tables, split_long_tables
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
from sympy import content
|
||||
|
||||
from src.investment.table_funcs import (Table, combine_continuous_tables,
|
||||
convert_str_to_dataframe,
|
||||
get_table_info, recreate_page,
|
||||
split_large_table_with_headers,
|
||||
split_tables_by_rows)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
class TestTableFunctions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sample_text_dict = {
|
||||
"1": "Some metadata\n-------Table Start--------\nTable 1\n{'A': ['1', '2'], 'B': ['3', '4']}\n-------Table End--------",
|
||||
"2": "More metadata\n-------Table Start--------\n\n{'A': ['5', '6'], 'B': ['7', '8']}\n-------Table End--------"
|
||||
# New list-of-rows format
|
||||
self.sample_text_dict_new = {
|
||||
"1": "Some metadata\n-------Table Start--------\nTable 1\n[['A', 'B'], ['1', '3'], ['2', '4']]\n-------Table End--------",
|
||||
"2": "More metadata\n-------Table Start--------\n\n[['100', '200'], ['5', '7'], ['6', '8']]\n-------Table End--------",
|
||||
}
|
||||
# Old dict format for backward compatibility testing
|
||||
self.sample_text_dict_old = {
|
||||
"1": "Some metadata\n-------Table Start--------\nTable 1\n{'A': ['1', '2'], 'B': ['3', '4']}\n-------Table End--------",
|
||||
"2": "More metadata\n-------Table Start--------\n\n{'A': ['5', '6'], 'B': ['7', '8']}\n-------Table End--------",
|
||||
}
|
||||
# Multiple tables on one page
|
||||
self.multi_table_text = {
|
||||
"1": "Page metadata\n-------Table Start--------\nFirst Table\n[['Col1', 'Col2'], ['A', 'B'], ['C', 'D']]\n-------Table End--------\n\nSome text\n-------Table Start--------\nSecond Table\n[['X', 'Y'], ['1', '2']]\n-------Table End--------"
|
||||
}
|
||||
# Long table for splitting tests
|
||||
self.long_table_text = {
|
||||
"1": "Long table metadata\n-------Table Start--------\nLong Table\n[['Name', 'Value'], ['Row1', '1'], ['Row2', '2'], ['Row3', '3'], ['Row4', '4'], ['Row5', '5']]\n-------Table End--------"
|
||||
}
|
||||
|
||||
self.sample_table_dict = get_table_info(self.sample_text_dict)
|
||||
|
||||
def test_get_table_info(self):
|
||||
self.assertIn("1", self.sample_table_dict)
|
||||
self.assertEqual(len(self.sample_table_dict["1"]), 1)
|
||||
self.assertEqual(self.sample_table_dict["1"][0].header, "Table 1")
|
||||
|
||||
def test_convert_str_to_dataframe_new_format(self):
|
||||
"""Test conversion from new list-of-rows format."""
|
||||
table_str = "[['A', 'B'], ['1', '3'], ['2', '4']]"
|
||||
df = convert_str_to_dataframe(table_str)
|
||||
|
||||
self.assertIsInstance(df, pd.DataFrame)
|
||||
self.assertEqual(df.shape, (2, 2)) # 2 rows, 2 columns
|
||||
self.assertEqual(df.columns[0], "A")
|
||||
self.assertEqual(df.iloc[0, 0], "1")
|
||||
self.assertEqual(df.iloc[1, 0], "2")
|
||||
self.assertEqual(df.iloc[0, 1], "3")
|
||||
self.assertTrue(
|
||||
df.dtypes.eq("object").all()
|
||||
) # All columns should be of type str (object)
|
||||
|
||||
def test_convert_str_to_dataframe_old_format(self):
|
||||
"""Test conversion from old dict format with deprecation warning."""
|
||||
table_str = "{'A': ['1', '2'], 'B': ['3', '4']}"
|
||||
with self.assertLogs(level="INFO") as log:
|
||||
df = convert_str_to_dataframe(table_str)
|
||||
|
||||
# Should warn about old format
|
||||
self.assertTrue(
|
||||
any("old table (dict) format detected" in message for message in log.output)
|
||||
)
|
||||
|
||||
self.assertIsInstance(df, pd.DataFrame)
|
||||
self.assertEqual(df.shape, (2, 2)) # 2 rows, 2 columns
|
||||
self.assertTrue(
|
||||
df.dtypes.eq("object").all()
|
||||
) # All columns should be of type str (object)
|
||||
self.assertEqual(df.iloc[0, 0], "1")
|
||||
self.assertEqual(df.iloc[0, 1], "3")
|
||||
self.assertEqual(df.iloc[1, 0], "2")
|
||||
self.assertEqual(df.iloc[1, 1], "4")
|
||||
|
||||
def test_convert_str_to_dataframe_invalid_format(self):
|
||||
"""Test handling of invalid formats."""
|
||||
invalid_str = "not a valid table format"
|
||||
df = convert_str_to_dataframe(invalid_str)
|
||||
|
||||
self.assertIsInstance(df, pd.DataFrame)
|
||||
self.assertTrue(df.empty)
|
||||
|
||||
def test_table_init_new_format(self):
|
||||
"""Test Table initialization with new list-of-rows format."""
|
||||
table_data = [["A", "B"], ["1", "3"], ["2", "4"]]
|
||||
table = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=table_data,
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
|
||||
self.assertEqual(table.page_num, "1")
|
||||
self.assertEqual(table.metadata, "metadata")
|
||||
self.assertEqual(table.header, "Table 1")
|
||||
self.assertEqual(table.table.shape, (2, 2)) # 2 rows, 2 columns
|
||||
self.assertEqual(list(table.table.columns), ["A", "B"])
|
||||
self.assertEqual(table.table.iloc[0, 0], "1")
|
||||
|
||||
def test_table_init_dataframe(self):
|
||||
"""Test Table initialization with a DataFrame."""
|
||||
table_data = pd.DataFrame([["A", "B"], ["1", "3"]], dtype=str)
|
||||
table = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=table_data,
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
self.assertEqual(table.table.shape, (2, 2)) # 2 rows, 2 columns
|
||||
self.assertTrue(
|
||||
table.table.dtypes.eq("object").all()
|
||||
) # All columns should be of type str (object)
|
||||
|
||||
def test_table_init_column_mismatch_padding(self):
|
||||
"""Test table initialization handles column count mismatches by padding."""
|
||||
# Headers have 3 columns, but data rows have different counts
|
||||
table_data = [
|
||||
["A", "B", "C"], # 3 columns (header)
|
||||
["1", "2"], # 2 columns - should be padded
|
||||
["3", "4", "5", "6"], # 4 columns - determines max_cols = 4
|
||||
["7"], # 1 column - should be padded
|
||||
]
|
||||
table = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Test Table",
|
||||
table_data=table_data,
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
|
||||
# Should have 4 columns (max from all rows) and 3 data rows
|
||||
self.assertEqual(table.table.shape, (3, 4))
|
||||
self.assertEqual(
|
||||
list(table.table.columns), ["A", "B", "C", ""]
|
||||
) # Header padded too
|
||||
|
||||
# Check first row (padded to 4 columns)
|
||||
self.assertEqual(table.table.iloc[0, 0], "1")
|
||||
self.assertEqual(table.table.iloc[0, 1], "2")
|
||||
self.assertEqual(table.table.iloc[0, 2], "") # Padded
|
||||
self.assertEqual(table.table.iloc[0, 3], "") # Padded
|
||||
|
||||
# Check second row (kept at 4 columns)
|
||||
self.assertEqual(table.table.iloc[1, 0], "3")
|
||||
self.assertEqual(table.table.iloc[1, 1], "4")
|
||||
self.assertEqual(table.table.iloc[1, 2], "5")
|
||||
self.assertEqual(table.table.iloc[1, 3], "6") # 4th column preserved
|
||||
|
||||
# Check third row (heavily padded)
|
||||
self.assertEqual(table.table.iloc[2, 0], "7")
|
||||
self.assertEqual(table.table.iloc[2, 1], "") # Padded
|
||||
self.assertEqual(table.table.iloc[2, 2], "") # Padded
|
||||
self.assertEqual(table.table.iloc[2, 3], "") # Padded
|
||||
|
||||
def test_table_init_continuation_column_handling(self):
|
||||
"""Test continuation table handles column mismatches with generic column names."""
|
||||
# Continuation table - no header row, just data with varying lengths
|
||||
table_data = [
|
||||
["1", "2"], # 2 columns
|
||||
["3", "4", "5"], # 3 columns
|
||||
["6"], # 1 column
|
||||
]
|
||||
table = Table(
|
||||
page_num="2",
|
||||
metadata="",
|
||||
header="",
|
||||
table_data=table_data,
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
is_continuation=True,
|
||||
)
|
||||
|
||||
# Should use max column count (3) and generate generic column names
|
||||
self.assertEqual(table.table.shape, (3, 3))
|
||||
self.assertEqual(list(table.table.columns), ["col_0", "col_1", "col_2"])
|
||||
|
||||
# Check padding/truncation
|
||||
self.assertEqual(table.table.iloc[0, 0], "1")
|
||||
self.assertEqual(table.table.iloc[0, 1], "2")
|
||||
self.assertEqual(table.table.iloc[0, 2], "") # Padded
|
||||
|
||||
self.assertEqual(table.table.iloc[1, 0], "3")
|
||||
self.assertEqual(table.table.iloc[1, 1], "4")
|
||||
self.assertEqual(table.table.iloc[1, 2], "5")
|
||||
|
||||
self.assertEqual(table.table.iloc[2, 0], "6")
|
||||
self.assertEqual(table.table.iloc[2, 1], "") # Padded
|
||||
self.assertEqual(table.table.iloc[2, 2], "") # Padded
|
||||
|
||||
def test_combine_continuous_tables_simple(self):
|
||||
"""Test the simplified continuous table combination logic."""
|
||||
# Create test data with continuation table
|
||||
text_dict = {
|
||||
"1": "Metadata 1\n-------Table Start--------\nMain Table\n[['Name', 'Value'], ['A', '1'], ['B', '2']]\n-------Table End--------",
|
||||
"2": "-------Table Start--------\n\n[['C', '3'], ['D', '4']]\n-------Table End--------", # Continuation (empty header)
|
||||
"3": "Metadata 3\n-------Table Start--------\nAnother Table\n[['X', 'Y'], ['Z', '5']]\n-------Table End--------",
|
||||
}
|
||||
|
||||
# Debug: Let's see what get_table_info thinks about these tables
|
||||
table_dict = get_table_info(text_dict)
|
||||
print(f"Table dict keys: {list(table_dict.keys())}")
|
||||
for page_num, tables in table_dict.items():
|
||||
for i, table in enumerate(tables):
|
||||
print(
|
||||
f"Page {page_num}, Table {i}: is_continuation={table.is_continuation}, header='{table.header}'"
|
||||
)
|
||||
|
||||
result = combine_continuous_tables(text_dict, table_dict)
|
||||
print(f"Result keys: {list(result.keys())}")
|
||||
|
||||
# Page 2 should be removed (combined into page 1)
|
||||
self.assertNotIn("2", result)
|
||||
self.assertIn("1", result)
|
||||
self.assertIn("3", result)
|
||||
|
||||
# Page 1 should now contain the combined table
|
||||
combined_table_dict = get_table_info({"1": result["1"]})
|
||||
combined_table = combined_table_dict["1"][0]
|
||||
|
||||
# Should have 4 rows total (2 from main + 2 from continuation)
|
||||
self.assertEqual(combined_table.table.shape, (4, 2))
|
||||
self.assertEqual(combined_table.table.iloc[0, 0], "A") # First table data
|
||||
self.assertEqual(combined_table.table.iloc[2, 0], "C") # Continuation data
|
||||
self.assertEqual(combined_table.table.iloc[3, 0], "D") # Continuation data
|
||||
|
||||
def test_combine_continuous_tables_multiple_tables_per_page(self):
|
||||
"""Test that continuation tables combine with the LAST table on previous page, not the first."""
|
||||
# Page 1 has TWO tables: Table A (complete) and Table B (continues on page 2)
|
||||
text_dict = {
|
||||
"1": (
|
||||
"Metadata 1\n"
|
||||
"-------Table Start--------\nTable A (Complete)\n[['Col1', 'Col2'], ['A1', 'A2'], ['A3', 'A4']]\n-------Table End--------\n"
|
||||
"Some text between tables\n"
|
||||
"-------Table Start--------\nTable B (Continues)\n[['Name', 'Value'], ['B1', '1'], ['B2', '2']]\n-------Table End--------"
|
||||
),
|
||||
"2": (
|
||||
"-------Table Start--------\n\n[['B3', '3'], ['B4', '4']]\n-------Table End--------" # Continuation of Table B
|
||||
),
|
||||
}
|
||||
|
||||
table_dict = get_table_info(text_dict)
|
||||
|
||||
# Verify setup: Page 1 should have 2 tables, Page 2 should have 1 continuation table
|
||||
self.assertEqual(len(table_dict["1"]), 2)
|
||||
self.assertEqual(len(table_dict["2"]), 1)
|
||||
self.assertFalse(
|
||||
table_dict["1"][0].is_continuation
|
||||
) # Table A - not continuation
|
||||
self.assertFalse(
|
||||
table_dict["1"][1].is_continuation
|
||||
) # Table B - not continuation
|
||||
self.assertTrue(
|
||||
table_dict["2"][0].is_continuation
|
||||
) # Table B continuation - is continuation
|
||||
|
||||
# Get original row counts before combining
|
||||
table_a_original_rows = table_dict["1"][0].table.shape[0] # Should be 2 rows
|
||||
table_b_original_rows = table_dict["1"][1].table.shape[0] # Should be 2 rows
|
||||
continuation_rows = table_dict["2"][0].table.shape[0] # Should be 2 rows
|
||||
|
||||
# Run the combination
|
||||
result = combine_continuous_tables(text_dict, table_dict)
|
||||
|
||||
# Page 2 should be removed (combined into page 1)
|
||||
self.assertNotIn("2", result)
|
||||
self.assertIn("1", result)
|
||||
|
||||
# Parse the combined result to verify tables
|
||||
combined_table_dict = get_table_info({"1": result["1"]})
|
||||
combined_tables = combined_table_dict["1"]
|
||||
|
||||
# Should still have 2 tables on page 1
|
||||
self.assertEqual(len(combined_tables), 2)
|
||||
|
||||
# Table A (first table) should be unchanged
|
||||
table_a_after = combined_tables[0]
|
||||
self.assertEqual(
|
||||
table_a_after.table.shape[0], table_a_original_rows
|
||||
) # Same row count
|
||||
self.assertEqual(table_a_after.header, "Table A (Complete)")
|
||||
self.assertEqual(
|
||||
table_a_after.table.iloc[0, 0], "A1"
|
||||
) # Original data preserved
|
||||
|
||||
# Table B (second table) should now include the continuation data
|
||||
table_b_after = combined_tables[1]
|
||||
expected_combined_rows = table_b_original_rows + continuation_rows # 2 + 2 = 4
|
||||
self.assertEqual(table_b_after.table.shape[0], expected_combined_rows)
|
||||
self.assertEqual(table_b_after.header, "Table B (Continues)")
|
||||
|
||||
# Verify Table B has both original and continuation data
|
||||
self.assertEqual(table_b_after.table.iloc[0, 0], "B1") # Original data
|
||||
self.assertEqual(table_b_after.table.iloc[1, 0], "B2") # Original data
|
||||
self.assertEqual(table_b_after.table.iloc[2, 0], "B3") # Continuation data
|
||||
self.assertEqual(table_b_after.table.iloc[3, 0], "B4") # Continuation data
|
||||
|
||||
def test_combine_continuous_tables_wrong_combination_scenario(self):
|
||||
"""Test edge case: what if we accidentally used [0] instead of [-1]?"""
|
||||
# This test documents what WOULD happen with the wrong indexing
|
||||
# Page 1: Table A + Table B (continues)
|
||||
# Page 2: Table B continuation
|
||||
# If we used [0], continuation would wrongly combine with Table A
|
||||
|
||||
text_dict = {
|
||||
"1": (
|
||||
"-------Table Start--------\nTable A\n[['X', 'Y'], ['X1', 'Y1']]\n-------Table End--------\n"
|
||||
"-------Table Start--------\nTable B\n[['Name', 'Value'], ['B1', '1']]\n-------Table End--------"
|
||||
),
|
||||
"2": "-------Table Start--------\n\n[['B2', '2']]\n-------Table End--------", # Should combine with Table B, not Table A
|
||||
}
|
||||
|
||||
table_dict = get_table_info(text_dict)
|
||||
result = combine_continuous_tables(text_dict, table_dict)
|
||||
|
||||
# Get the result and verify correct combination
|
||||
combined_table_dict = get_table_info({"1": result["1"]})
|
||||
|
||||
# Table A (index 0) should have 1 row still - NOT combined with continuation
|
||||
table_a = combined_table_dict["1"][0]
|
||||
self.assertEqual(table_a.table.shape[0], 1) # Still just 1 row
|
||||
self.assertEqual(table_a.table.iloc[0, 0], "X1") # Original data only
|
||||
|
||||
# Table B (index 1, which is -1) should have 2 rows - correctly combined
|
||||
table_b = combined_table_dict["1"][1]
|
||||
self.assertEqual(table_b.table.shape[0], 2) # 1 original + 1 continuation = 2
|
||||
self.assertEqual(table_b.table.iloc[0, 0], "B1") # Original
|
||||
self.assertEqual(table_b.table.iloc[1, 0], "B2") # Continuation
|
||||
|
||||
def test_table_combine_same_header(self):
|
||||
"""Test combining two tables with the same header."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=[["Column 1", "Column 2"], ["A", "B"], ["1", "2"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
table2 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 2",
|
||||
table_data=[["Column 1", "Column 2"], ["C", "D"], ["3", "4"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
|
||||
original_shape = table1.table.shape
|
||||
table1.combine(table2)
|
||||
self.assertEqual(
|
||||
table1.table.shape[0], original_shape[0] + table2.table.shape[0]
|
||||
) # Rows should be combined
|
||||
self.assertEqual(table1.table.shape, (4, 2)) # 4 rows (2 + 2 rows), 2 columns
|
||||
self.assertEqual(table1.table.iloc[0, 0], "A")
|
||||
self.assertEqual(
|
||||
table1.table.iloc[2, 0], "C"
|
||||
) # Check if second table's data is appended correctly
|
||||
self.assertEqual(table1.table.iloc[3, 1], "4") # Last row of second table
|
||||
|
||||
def test_table_combine_different_headers(self):
|
||||
"""Test combining two tables with different headers (the second table is assumed to be a continuation in this case).
|
||||
We will pass in table_data as STRING for both we use convert_str_to_dataframe and
|
||||
check that `is_continuation` is getting instantiated correctly.
|
||||
"""
|
||||
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data="[['Column 1', 'Column 2'], ['A', 'B'], ['1', '2']]",
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
|
||||
# Continuation table with empty header
|
||||
table2 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="",
|
||||
table_data="[['C', 'D'], ['3', '4'], ['E', 'F']]", # Pass as string!
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
is_continuation=True, # Mark as continuation
|
||||
)
|
||||
|
||||
original_shape = table1.table.shape
|
||||
table1.combine(table2)
|
||||
|
||||
print(table1.table)
|
||||
self.assertEqual(
|
||||
table1.table.shape[0], original_shape[0] + table2.table.shape[0]
|
||||
) # Rows should be combined
|
||||
self.assertEqual(table1.table.shape, (5, 2)) # 5 rows (2 + 3 rows), 2 columns
|
||||
self.assertEqual(table1.table.iloc[0, 0], "A")
|
||||
self.assertEqual(
|
||||
table1.table.iloc[2, 0], "C"
|
||||
) # Check if second table's data is appended correctly
|
||||
self.assertEqual(table1.table.iloc[3, 1], "4") # Last row of second table
|
||||
self.assertEqual(
|
||||
list(table1.table.columns), ["Column 1", "Column 2"]
|
||||
) # Should keep the first table's header
|
||||
|
||||
def test_table_combine_different_columns(self):
|
||||
"""Test that combining tables with different column counts handles mismatches gracefully."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=[["A", "B"], ["1", "2"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
table2 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 2",
|
||||
table_data=[["C", "D", "E"], ["3", "4", "5"]], # 3 columns vs 2 columns
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
|
||||
# Should not raise an error anymore - should handle column mismatch
|
||||
table1.combine(table2)
|
||||
|
||||
# Verify the result
|
||||
# Should have 2 rows total (1 from each table)
|
||||
self.assertEqual(table1.table.shape[0], 2)
|
||||
|
||||
# Should have 2 columns (from the main table)
|
||||
self.assertEqual(table1.table.shape[1], 2)
|
||||
|
||||
# Column names should match the main table
|
||||
self.assertEqual(list(table1.table.columns), ["A", "B"])
|
||||
|
||||
# First row should be from table1
|
||||
self.assertEqual(table1.table.iloc[0, 0], "1")
|
||||
self.assertEqual(table1.table.iloc[0, 1], "2")
|
||||
|
||||
# Second row should be from table2, but truncated to 2 columns
|
||||
self.assertEqual(table1.table.iloc[1, 0], "3")
|
||||
self.assertEqual(table1.table.iloc[1, 1], "4")
|
||||
# The "5" from column "E" should be truncated/lost
|
||||
|
||||
def test_table_combine_column_padding(self):
|
||||
"""Test that combining tables pads shorter tables with empty columns."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=[["A", "B", "C"], ["1", "2", "3"]], # 3 columns
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
table2 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 2",
|
||||
table_data=[["X"], ["4"]], # Only 1 column
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
|
||||
table1.combine(table2)
|
||||
|
||||
# Should have 2 rows total
|
||||
self.assertEqual(table1.table.shape[0], 2)
|
||||
|
||||
# Should have 3 columns (from the main table)
|
||||
self.assertEqual(table1.table.shape[1], 3)
|
||||
|
||||
# Column names should match the main table
|
||||
self.assertEqual(list(table1.table.columns), ["A", "B", "C"])
|
||||
|
||||
# First row should be unchanged
|
||||
self.assertEqual(table1.table.iloc[0, 0], "1")
|
||||
self.assertEqual(table1.table.iloc[0, 1], "2")
|
||||
self.assertEqual(table1.table.iloc[0, 2], "3")
|
||||
|
||||
# Second row should be from table2, padded with empty strings
|
||||
self.assertEqual(table1.table.iloc[1, 0], "4")
|
||||
self.assertEqual(table1.table.iloc[1, 1], "") # Padded
|
||||
self.assertEqual(table1.table.iloc[1, 2], "") # Padded
|
||||
|
||||
def test_table_combine_empty_tables(self):
|
||||
"""Test combining with empty tables."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="meta",
|
||||
header="Table 1",
|
||||
table_data=[["A", "B"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
empty_table = Table(
|
||||
page_num="2",
|
||||
metadata="meta",
|
||||
header="",
|
||||
table_data=[],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
is_continuation=True,
|
||||
)
|
||||
|
||||
original_shape = table1.table.shape
|
||||
table1.combine(empty_table)
|
||||
self.assertEqual(table1.table.shape, original_shape) # Should remain unchanged
|
||||
|
||||
def test_convert_str_to_dataframe_malformed_data(self):
|
||||
"""Test handling of malformed table data."""
|
||||
malformed_inputs = [
|
||||
"[['A', 'B'], ['1']]", # Inconsistent row lengths
|
||||
"[['A'], [], ['B']]", # Empty rows
|
||||
"[[]]", # Empty nested list
|
||||
"['not', 'nested']", # Not nested list
|
||||
]
|
||||
|
||||
for malformed_input in malformed_inputs:
|
||||
with self.subTest(input=malformed_input):
|
||||
df = convert_str_to_dataframe(malformed_input)
|
||||
self.assertIsInstance(df, pd.DataFrame)
|
||||
|
||||
def test_table_post_table_text_preservation(self):
|
||||
"""Test that post-table text is properly preserved through operations."""
|
||||
text_dict = {
|
||||
"1": "Metadata\n-------Table Start--------\nTable\n[['A', 'B'], ['1', '2']]\n-------Table End--------\nImportant footnote\nSignature line"
|
||||
}
|
||||
|
||||
table_dict = get_table_info(text_dict)
|
||||
table = table_dict["1"][0]
|
||||
|
||||
# Should capture post-table text
|
||||
self.assertIn("Important footnote", table.post_table_text)
|
||||
self.assertIn("Signature line", table.post_table_text)
|
||||
|
||||
def test_split_large_table_preserves_metadata_and_post_text(self):
|
||||
"""Test that table splitting preserves all important information."""
|
||||
table = Table(
|
||||
page_num="31",
|
||||
metadata="EXHIBIT F",
|
||||
header="Rate Table",
|
||||
table_data=[["HCPC", "Rate"]]
|
||||
+ [[f"T{i:04d}", f"${i}.00"] for i in range(15)],
|
||||
start_index=100,
|
||||
end_index=2000,
|
||||
post_table_text="*Rates effective 2024",
|
||||
)
|
||||
|
||||
with patch("src.config.TABLE_ROW_LIMIT", 5):
|
||||
chunks = split_large_table_with_headers(table)
|
||||
|
||||
# All chunks should have metadata and header
|
||||
for chunk in chunks:
|
||||
self.assertIn("EXHIBIT F", chunk)
|
||||
self.assertIn("Rate Table", chunk)
|
||||
|
||||
# Only last chunk should have post-table text
|
||||
self.assertIn("*Rates effective 2024", chunks[-1])
|
||||
for chunk in chunks[:-1]:
|
||||
self.assertNotIn("*Rates effective 2024", chunk)
|
||||
|
||||
def test_table_split(self):
|
||||
"""Test splitting a table."""
|
||||
long_data = [["Name", "Value"]] + [
|
||||
[f"Row{i}", str(i)] for i in range(1, 6)
|
||||
] # This table will have 5 rows
|
||||
long_table = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Long Table",
|
||||
table_data=long_data,
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
split_tables = long_table.split(row_limit=2)
|
||||
|
||||
self.assertEqual(
|
||||
len(split_tables), 3
|
||||
) # Should split into 3 tables (5 rows split by 2 = 3)
|
||||
self.assertEqual(
|
||||
split_tables[0].table.shape, (2, 2)
|
||||
) # First split should have 2 rows
|
||||
self.assertEqual(
|
||||
split_tables[1].table.shape, (2, 2)
|
||||
) # Second split should have 2 rows
|
||||
self.assertEqual(
|
||||
split_tables[2].table.shape, (1, 2)
|
||||
) # Last split should have 1 row
|
||||
|
||||
# Check that original metadata is preserved
|
||||
for split_table in split_tables:
|
||||
self.assertEqual(split_table.metadata, "metadata")
|
||||
self.assertEqual(split_table.page_num, "1")
|
||||
self.assertEqual(split_table.header, "Long Table")
|
||||
|
||||
def test_table_split_no_split_needed(self):
|
||||
"""Test splitting a table that does not need splitting. It should return the original table."""
|
||||
table = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=[["A", "B"], ["1", "2"], ["3", "4"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
split_tables = table.split(row_limit=10) # Row limit larger than table size
|
||||
self.assertEqual(len(split_tables), 1) # Should return the original table
|
||||
self.assertEqual(
|
||||
split_tables[0].table.shape, (2, 2)
|
||||
) # Should be the same shape as original
|
||||
self.assertEqual(split_tables[0], table) # Should be the same object
|
||||
|
||||
def test_table_to_list_format(self):
|
||||
"""Test conversion back to list format."""
|
||||
table_data = [["A", "B"], ["1", "3"], ["2", "4"]]
|
||||
table = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Table 1",
|
||||
table_data=table_data,
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
)
|
||||
list_format = table.to_list_format()
|
||||
|
||||
self.assertEqual(list_format, table_data)
|
||||
self.assertIsInstance(list_format, list)
|
||||
self.assertIsInstance(list_format[0], list) # Should be a list of lists
|
||||
|
||||
def test_table_info_new_format(self):
|
||||
"""Test getting table info from new list-of-rows format."""
|
||||
table_dict = get_table_info(self.sample_text_dict_new)
|
||||
|
||||
self.assertIn("1", table_dict)
|
||||
self.assertEqual(len(table_dict["1"]), 1)
|
||||
self.assertEqual(table_dict["1"][0].header, "Table 1")
|
||||
self.assertEqual(table_dict["1"][0].metadata, "Some metadata")
|
||||
self.assertEqual(
|
||||
table_dict["1"][0].table.shape, (2, 2)
|
||||
) # 2 rows, 2 columns in first table
|
||||
self.assertEqual(table_dict["2"][0].header, "") # Second table has no header
|
||||
self.assertEqual(
|
||||
table_dict["2"][0].table.shape, (3, 2)
|
||||
) # 3 rows, 2 columns in second table (empty header means all data rows)
|
||||
|
||||
def test_table_info_old_format(self):
|
||||
"""Test getting table info from old dict format."""
|
||||
with self.assertLogs(level="INFO") as log: # Expect a deprecation warning
|
||||
table_dict = get_table_info(self.sample_text_dict_old)
|
||||
|
||||
self.assertIn("1", table_dict)
|
||||
self.assertEqual(len(table_dict["1"]), 1)
|
||||
self.assertEqual(table_dict["1"][0].header, "Table 1")
|
||||
self.assertEqual(table_dict["1"][0].metadata, "Some metadata")
|
||||
self.assertEqual(table_dict["1"][0].table.shape, (2, 2))
|
||||
|
||||
def test_get_table_info_multiple_tables(self):
|
||||
"""Test getting table info from a page with multiple tables."""
|
||||
table_dict = get_table_info(self.multi_table_text)
|
||||
|
||||
self.assertIn("1", table_dict)
|
||||
self.assertEqual(len(table_dict["1"]), 2)
|
||||
self.assertEqual(table_dict["1"][0].header, "First Table")
|
||||
|
||||
def test_recreate_page(self):
|
||||
page_tables = self.sample_table_dict["1"]
|
||||
"""Test recreating page text from tables."""
|
||||
table_dict = get_table_info(self.sample_text_dict_new)
|
||||
page_tables = table_dict["1"]
|
||||
recreated_text = recreate_page(page_tables)
|
||||
|
||||
self.assertIn("Table 1", recreated_text)
|
||||
self.assertIn("-------Table Start--------", recreated_text)
|
||||
self.assertIn("-------Table End--------", recreated_text)
|
||||
|
||||
def test_remove_table_from_page(self):
|
||||
table = self.sample_table_dict["1"][0]
|
||||
updated_text = remove_table_from_page(self.sample_text_dict["1"], table)
|
||||
self.assertNotIn("Table 1", updated_text)
|
||||
self.assertNotIn("-------Table Start--------", updated_text)
|
||||
|
||||
def test_combine_continuous_tables(self):
|
||||
combined_text_dict = combine_continuous_tables(self.sample_text_dict, self.sample_table_dict)
|
||||
self.assertIn("1", combined_text_dict)
|
||||
self.assertNotIn("2", combined_text_dict)
|
||||
self.assertIn("5", combined_text_dict["1"]) # Ensuring second table is merged
|
||||
|
||||
def test_split_long_tables(self):
|
||||
long_table_dict = {
|
||||
"1": "Some metadata\n-------Table Start--------\nTable 1\n{'A': ['1', '2', '3', '4', '5'], 'B': ['6', '7', '8', '9', '10']}\n-------Table End--------"
|
||||
self.assertIn("Some metadata", recreated_text)
|
||||
|
||||
def test_split_tables_by_row_count(self):
|
||||
"""Test splitting tables that exceed row limit and adding .0 suffix."""
|
||||
# Create a long table that will need splitting
|
||||
long_table_data = [["Name", "Value"]] + [
|
||||
[f"Row{i}", str(i)] for i in range(1, 8)
|
||||
] # 7 data rows
|
||||
text_dict = {
|
||||
"5": f"Table metadata\n-------Table Start--------\nLong Table\n{long_table_data}\n-------Table End--------\nPost-table text"
|
||||
}
|
||||
table_dict = get_table_info(long_table_dict)
|
||||
split_text_dict = split_long_tables(long_table_dict, table_dict, row_limit=2)
|
||||
self.assertGreater(len(split_text_dict), 1)
|
||||
for key, text in split_text_dict.items():
|
||||
self.assertIn("-------Table Start--------", text)
|
||||
self.assertIn("-------Table End--------", text)
|
||||
|
||||
result = split_tables_by_rows(text_dict, row_limit=3)
|
||||
|
||||
# Should create multiple pages with .0, .1, .2 suffixes
|
||||
expected_pages = ["5.0", "5.1", "5.2"]
|
||||
for page in expected_pages:
|
||||
self.assertIn(page, result)
|
||||
|
||||
# Original page should not exist
|
||||
self.assertNotIn("5", result)
|
||||
|
||||
# Last page should have post-table text
|
||||
self.assertIn("Post-table text", result["5.2"])
|
||||
|
||||
def test_split_tables_by_row_count_no_split_needed(self):
|
||||
"""Test that small tables get .0 suffix but aren't split."""
|
||||
text_dict = {
|
||||
"3": "Metadata\n-------Table Start--------\nSmall Table\n[['A', 'B'], ['1', '2']]\n-------Table End--------"
|
||||
}
|
||||
|
||||
result = split_tables_by_rows(text_dict, row_limit=10)
|
||||
|
||||
# Should have .0 suffix but no additional splits
|
||||
self.assertIn("3.0", result)
|
||||
self.assertNotIn("3", result)
|
||||
self.assertNotIn("3.1", result)
|
||||
|
||||
def test_split_tables_by_row_count_no_tables(self):
|
||||
"""Test that pages without tables keep original page numbers."""
|
||||
text_dict = {
|
||||
"1": "Just some regular text without any tables.",
|
||||
"2": "More text, no tables here either.",
|
||||
}
|
||||
|
||||
result = split_tables_by_rows(text_dict, row_limit=5)
|
||||
|
||||
# Pages without tables should keep original numbers
|
||||
self.assertIn("1", result)
|
||||
self.assertIn("2", result)
|
||||
self.assertNotIn("1.0", result)
|
||||
self.assertNotIn("2.0", result)
|
||||
|
||||
def test_combine_continuous_tables_no_continuation(self):
|
||||
"""Test that non-continuation tables are not combined."""
|
||||
text_dict = {
|
||||
"1": "Metadata 1\n-------Table Start--------\nTable 1\n[['A', 'B'], ['1', '2']]\n-------Table End--------",
|
||||
"2": "Metadata 2\n-------Table Start--------\nTable 2\n[['C', 'D'], ['3', '4']]\n-------Table End--------",
|
||||
}
|
||||
|
||||
table_dict = get_table_info(text_dict)
|
||||
result = combine_continuous_tables(text_dict, table_dict)
|
||||
|
||||
# Both pages should remain (no continuation tables to combine)
|
||||
self.assertIn("1", result)
|
||||
self.assertIn("2", result)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_table_combine_post_table_text_accumulation(self):
|
||||
"""Test that post-table text is properly accumulated when combining tables."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="meta",
|
||||
header="Main Table",
|
||||
table_data=[["A", "B"], ["1", "2"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
post_table_text="First footnote",
|
||||
)
|
||||
|
||||
table2 = Table(
|
||||
page_num="2",
|
||||
metadata="",
|
||||
header="",
|
||||
table_data=[["3", "4"]],
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
is_continuation=True,
|
||||
post_table_text="Second footnote",
|
||||
)
|
||||
|
||||
table1.combine(table2)
|
||||
|
||||
# Both footnotes should be preserved
|
||||
self.assertIn("First footnote", table1.post_table_text)
|
||||
self.assertIn("Second footnote", table1.post_table_text)
|
||||
|
||||
def test_table_combine_column_mismatch_preserves_post_table_text(self):
|
||||
"""Test that post-table text is preserved when combining tables with column mismatches."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Main Table",
|
||||
table_data=[["A", "B"], ["1", "2"], ["x", "y"]], # Header + 2 data rows
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
post_table_text="Main table footnote",
|
||||
)
|
||||
|
||||
table2 = Table(
|
||||
page_num="2",
|
||||
metadata="",
|
||||
header="",
|
||||
table_data=[["3", "4", "5"]], # Just 1 data row (3 columns - mismatch!)
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
is_continuation=True,
|
||||
post_table_text="Continuation footnote",
|
||||
)
|
||||
|
||||
table1.combine(table2)
|
||||
|
||||
# Verify both post-table texts are preserved despite column mismatch
|
||||
self.assertIn("Main table footnote", table1.post_table_text)
|
||||
self.assertIn("Continuation footnote", table1.post_table_text)
|
||||
|
||||
# Verify the table combination worked correctly
|
||||
self.assertEqual(
|
||||
table1.table.shape[0], 3
|
||||
) # 2 original + 1 continuation = 3 total rows
|
||||
self.assertEqual(table1.table.shape[1], 2) # 2 columns (from main table)
|
||||
|
||||
# Verify data integrity
|
||||
self.assertEqual(table1.table.iloc[0, 0], "1") # Original data row 1
|
||||
self.assertEqual(table1.table.iloc[1, 0], "x") # Original data row 2
|
||||
self.assertEqual(table1.table.iloc[2, 0], "3") # Continuation data (truncated)
|
||||
|
||||
def test_table_combine_column_mismatch_padding_preserves_post_table_text(self):
|
||||
"""Test post-table text preservation when padding columns."""
|
||||
table1 = Table(
|
||||
page_num="1",
|
||||
metadata="metadata",
|
||||
header="Main Table",
|
||||
table_data=[
|
||||
["A", "B", "C"],
|
||||
["1", "2", "3"],
|
||||
["x", "y", "z"],
|
||||
], # Header + 2 data rows
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
post_table_text="Main footnote",
|
||||
)
|
||||
|
||||
table2 = Table(
|
||||
page_num="2",
|
||||
metadata="",
|
||||
header="",
|
||||
table_data=[["4"]], # Just 1 data row (1 column - needs padding)
|
||||
start_index=0,
|
||||
end_index=100,
|
||||
is_continuation=True,
|
||||
post_table_text="Padded footnote",
|
||||
)
|
||||
|
||||
table1.combine(table2)
|
||||
|
||||
# Both footnotes should be preserved even with column padding
|
||||
self.assertIn("Main footnote", table1.post_table_text)
|
||||
self.assertIn("Padded footnote", table1.post_table_text)
|
||||
|
||||
# Verify padding worked
|
||||
self.assertEqual(
|
||||
table1.table.shape, (3, 3)
|
||||
) # 2 original + 1 continuation = 3 rows, 3 columns
|
||||
self.assertEqual(table1.table.iloc[0, 0], "1") # Original data row 1
|
||||
self.assertEqual(table1.table.iloc[1, 0], "x") # Original data row 2
|
||||
self.assertEqual(table1.table.iloc[2, 0], "4") # Continuation data
|
||||
self.assertEqual(table1.table.iloc[2, 1], "") # Padded
|
||||
self.assertEqual(table1.table.iloc[2, 2], "") # Padded
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user