90bed0d5f6
Feature/update caching * Enable prompt caching for CODE_LAST_CHECK, FILL_BILL_TYPE, DUAL_LOB_CHECK, and GROUPER_BREAKOUT - Add CODE_LAST_CHECK_INSTRUCTION() for service specificity classification - Add FILL_BILL_TYPE_INSTRUCTION() for bill type code determination - Add DUAL_LOB_CHECK_INSTRUCTION() for Medicare/Medicaid classification - Update code_funcs.py to use caching for CODE_LAST_CHECK, FILL_BILL_TYPE, GROUPER_BREAKOUT - Update postprocessing_funcs.py to use caching for DUAL_LOB_CHECK - Add new instructions to get_cacheable_instructions() for cache warming - Add unit tests for new instruction functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix postprocessing_funcs to remove invalid columns * Merge branch 'main' into feature/lesser-table-caching-refactor-hybrid * Apply black formatting to 11 files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix import paths in testbed files * Merge main into feature/update-caching * Add SPLIT_REIMB_DATES_INSTRUCTION for prompt caching * Merged main into feature/update-caching * Merge remote-tracking branch 'origin/main' into feature/update-caching * Merge branch 'feature/update-caching' of https://bitbucket.org/aarete/doczy.ai into feature/update-caching * Add CODE_* instruction functions for prompt caching * Merged main into feature/update-caching * Merged main into feature/update-caching * Maximize prompt caching by moving static content to instructions - Refactored 12+ prompt functions to move static rules/examples to _INSTRUCTION() - VALIDATE_REIMBURSEMENTS: Moved 45+ lines of validation rules to instruction - DATE_FIX: Moved conversion rules and examples to instruction - EXHIBIT_LEVEL, EXHIBIT_TITLE_MATCH, EXHIBIT_HEADER, EXHIBIT_LINKAGE: Moved rules - FEE_SCHEDULE_BREAKOUT, GROUPER_BREAKOUT: Moved extraction rules - CHECK_PROVIDER_NAME_MATCH: Moved matching rules to instruction - LOB_RELATIONSHIP: Consolidated split instructions - DERIVED_TERM_DATE: Moved calculation logic to instruction - DYNAMIC_PRIMARY: Created separate HEADER and TEXT instructions - Created new REIMB_DATES_ASSIGNMENT_INSTRUCTION with all date rules - Updated get_cacheable_instructions() with new instruction functions - Updated all callers in saas, clover, bcbs_promise pipelines This enables ~90% cost savings via Claude API prompt caching by ensuring static instruction text is cached and only dynamic content varies. * Remove Dynamic Primary Header * Standardize prompt caching across all LLM prompts for ~90% cost savings Move static content to _INSTRUCTION() functions for caching: - METHODOLOGY_BREAKOUT: 8 field definitions with resolved valid_values - FEE_SCHEDULE_BREAKOUT: 3 field definitions - GROUPER_BREAKOUT: 11 field definitions - OUTLIER_BREAKOUT: 8 field definitions - CODE_EXPLICIT: 8 field definitions - EXHIBIT_HEADER: static header markers list - CARVEOUT_CHECK: carveout and special case definitions - SPECIAL_CASE_ASSIGNMENT: task rules and output format Update callers to use cache=True, instruction, and usage_label params. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Apply black formatting to prompt_templates.py * Update unit tests for prompt caching changes - Fix function signatures in tests (METHODOLOGY_BREAKOUT, FEE_SCHEDULE_BREAKOUT, GROUPER_BREAKOUT, CODE_EXPLICIT) - Add tests for CARVEOUT_CHECK and EXHIBIT_HEADER prompts - Add TestInstructionFieldContent class to verify instruction functions contain field definitions - Add tests for CARVEOUT_CHECK_INSTRUCTION and CODE_EXPLICIT_INSTRUCTION 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Refactor instructions to load fields from investment_prompts.json and Constants Address PR review feedback: - Add _get_constants() helper for lazy Constants instantiation - Add _get_fields_text() helper to load fields from investment_prompts.json - METHODOLOGY_BREAKOUT_INSTRUCTION: Load fields via FieldSet - FEE_SCHEDULE_BREAKOUT_INSTRUCTION: Load fields via FieldSet - GROUPER_BREAKOUT_INSTRUCTION: Load fields via FieldSet - OUTLIER_BREAKOUT_INSTRUCTION: Load fields via FieldSet - EXHIBIT_HEADER_INSTRUCTION: Load markers from Constants.EXHIBIT_HEADER_MARKERS - CARVEOUT_CHECK_INSTRUCTION: Load carveouts from Constants.VALID_CARVEOUTS, special cases from investment_prompts.json - CODE_EXPLICIT_INSTRUCTION: Keep hardcoded (no code_explicit field_type in JSON) Update tests to match actual field names from investment_prompts.json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Merge main into feature/update-caching * Remove deprecated dynamic_primary_header * Update CODE_EXPLICIT_INSTRUCTION to load fields from investment_prompts.json Use field_type=code_primary_breakout to load code fields dynamically. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Merged main into feature/update-caching * Merge main into feature/update-caching * Merge branch 'feature/update-caching' of https://bitbucket.org/aarete/doczy.ai into feature/update-caching Approved-by: Katon Minhas
579 lines
23 KiB
Python
579 lines
23 KiB
Python
import logging
|
||
import re
|
||
from typing import TYPE_CHECKING, Optional
|
||
|
||
import src.utils.string_utils as string_utils
|
||
from src.pipelines.saas.prompts import prompt_calls
|
||
from src import config
|
||
|
||
if TYPE_CHECKING:
|
||
from src.pipelines.shared.extraction.page_funcs import Page
|
||
from src.pipelines.shared.extraction.exhibit_funcs import Exhibit
|
||
|
||
|
||
def remove_page_indicators(contract_text: str) -> str:
|
||
"""Clean textract output by removing page number indicators in the form of "Page X of Y"
|
||
|
||
This function processes input text to remove lines that indicate page numbers (e.g. 'Page 1 of 10')
|
||
|
||
Args:
|
||
contract_text (str): Raw text output from Textract to be cleaned
|
||
|
||
Returns:
|
||
str: cleaned text with newlines and page number indicators removed
|
||
"""
|
||
|
||
if contract_text:
|
||
cleaned_text = re.sub(r"Page [0-9]+ of [0-9]+\n\n", " ", contract_text)
|
||
else:
|
||
cleaned_text = contract_text
|
||
return cleaned_text
|
||
|
||
|
||
def split_text(text: str) -> dict[str, str]:
|
||
"""Split text on pages by the string `Start of Page No. = '
|
||
|
||
Args:
|
||
text (str): Raw text output from Textract to be split
|
||
|
||
Returns:
|
||
dict[str, str]: A dictionary, keyed by the string page number and valued by the page text.
|
||
"""
|
||
temp_list = text.split("Start of Page No. = ")
|
||
text_list = re.split(r"Start of Page No. = [0-9]+\n", text)
|
||
|
||
text_dict = {}
|
||
for i in range(len(text_list)):
|
||
if temp_list[i]:
|
||
text_dict[temp_list[i].split()[0]] = text_list[
|
||
i
|
||
] # splits on whitespace characters, which includes spaces, tabs, and newline characters
|
||
|
||
return {k: v for k, v in text_dict.items() if k != "Document"}
|
||
|
||
|
||
def clean_law_symbols(contract_text):
|
||
if not contract_text:
|
||
return contract_text
|
||
# First correction: Replace '$$' with '$'
|
||
contract_text = contract_text.replace("$$", "$")
|
||
|
||
contract_text = re.sub(
|
||
r"(U\.?S\.?C\.?) \$", r"\1§", contract_text
|
||
) # replaces $ with § when it follows abbreviations like "U.S.C." (United States Code).
|
||
contract_text = re.sub(
|
||
r"(C\.?F\.?R\.?) \$", r"\1§", contract_text
|
||
) # replaces $ with § when it follows abbreviations like "C.F.R." (Code of Federal Regulations).
|
||
|
||
# Second correction: Replace '$' with '§' when followed by a number with three decimal places
|
||
contract_text = re.sub(r"\$(?=\d+\.\d{3})", "§", contract_text)
|
||
|
||
return contract_text
|
||
|
||
|
||
def clean_newlines(contract_text: str) -> str:
|
||
"""Clean textract output by removing newlines and page number indicators.
|
||
|
||
This function processes input text to:
|
||
1. Remove all newline characters.
|
||
2. Remove lines that indicate page numbers (e.g. 'Page 1 of 10')
|
||
|
||
Args:
|
||
contract_text (str): Raw text output from Textract to be cleaned
|
||
|
||
Returns:
|
||
str: cleaned text with newlines and page number indicators removed
|
||
"""
|
||
cleaned_text = re.sub(r"(?<!\n)\n(?!\n)", " ", contract_text)
|
||
cleaned_text = re.sub(r"Page [0-9]+ of [0-9]+\n\n", " ", contract_text)
|
||
return cleaned_text
|
||
|
||
|
||
def filter_quick_review(text_dict):
|
||
"""
|
||
Cover Sheets (aka Top Sheets or Quick Review pages) are pages stapled to the front of the contract that contain manually written summaries of the contract's contents.
|
||
They are NOT legally binding documents, and because they are often manually filled out and hand-written, are more prone to have erroneous information than the rest of the contract.
|
||
For Doczy.AI, we should not pull any information from top sheets, except as a very last resort.
|
||
|
||
filter_quick_review() splits the input dictionary into two dictionaries, one containing the contract pages, the other containing the top sheet pages.
|
||
|
||
The function checks each page's text for the keywords "QUICK REVIEW", "TOP SHEET", and "COVER SHEET". It categorizes the text into two separate dictionaries: one for texts that do not contain any of these keywords, and another for texts that do.
|
||
|
||
Parameters:
|
||
text_dict (dict): A dictionary where the key is the page number and the value is the text of that page.
|
||
|
||
Returns:
|
||
tuple of two dicts:
|
||
- The first dictionary contains pages that do not have the specified keywords.
|
||
- The second dictionary includes pages that contain any of the specified keywords.
|
||
"""
|
||
keywords = [
|
||
"QUICK REVIEW",
|
||
"TOP SHEET",
|
||
"COVER SHEET",
|
||
"PROVIDER SUBMISSION FORM",
|
||
"CONTRACT REVIEW FORM",
|
||
]
|
||
regular_pages = {}
|
||
quick_review_pages = {}
|
||
|
||
for page_num, page_text in text_dict.items():
|
||
page_text_upper = page_text.upper()
|
||
if any(keyword in page_text_upper for keyword in keywords):
|
||
quick_review_pages[page_num] = page_text
|
||
else:
|
||
regular_pages[page_num] = page_text
|
||
|
||
return regular_pages, quick_review_pages
|
||
|
||
|
||
def get_exhibit_pages(
|
||
text_dict: Optional[dict[str, str]] = None,
|
||
pages_dict: Optional[dict[str, "Page"]] = None,
|
||
EXHIBIT_HEADER_MARKERS: Optional[list] = None,
|
||
filename: Optional[str] = None,
|
||
) -> tuple[list[str], dict[str, str]]:
|
||
"""
|
||
Identify exhibit pages from the text dictionary or pages dictionary.
|
||
|
||
This function processes the dictionary to identify pages that are considered exhibits.
|
||
It uses the first page as an exhibit and checks subsequent pages for exhibit headers.
|
||
If a page is identified as an exhibit, it is added to the exhibit pages list and its
|
||
header is stored in the exhibit header dictionary.
|
||
If a page does not have a header or is not an exhibit, it is skipped.
|
||
|
||
Args:
|
||
text_dict: A dictionary where keys are page numbers (as strings) and values are
|
||
the text of those pages (for backward compatibility)
|
||
pages_dict: A dictionary where keys are page numbers and values are Page objects
|
||
(preferred)
|
||
EXHIBIT_HEADER_MARKERS: List of markers to identify exhibit headers
|
||
filename: The name of the file being processed, used for logging and LLM invocation.
|
||
|
||
Returns:
|
||
tuple[list[str], dict[str, str]]: A tuple containing:
|
||
- A list of page numbers (as strings) that are identified as exhibit pages.
|
||
- A dictionary where keys are page numbers and values are the headers of those exhibits.
|
||
|
||
Note:
|
||
Either text_dict or pages_dict must be provided. If both are provided,
|
||
pages_dict takes precedence.
|
||
"""
|
||
if EXHIBIT_HEADER_MARKERS is None:
|
||
EXHIBIT_HEADER_MARKERS = []
|
||
if filename is None:
|
||
filename = ""
|
||
|
||
# Determine which dictionary to use
|
||
if pages_dict is not None:
|
||
page_dict = pages_dict
|
||
use_pages = True
|
||
elif text_dict is not None:
|
||
page_dict = text_dict
|
||
use_pages = False
|
||
else:
|
||
raise ValueError("Either text_dict or pages_dict must be provided")
|
||
|
||
sorted_pages = sorted(page_dict.keys(), key=string_utils.page_key_sort)
|
||
exhibit_pages = []
|
||
exhibit_header_dict = {}
|
||
|
||
for page_num in sorted_pages:
|
||
# Get page content - handle both Page objects and strings
|
||
if use_pages:
|
||
page_obj = page_dict[page_num]
|
||
page_content = page_obj.get_text()
|
||
else:
|
||
page_content = page_dict[page_num]
|
||
|
||
if "." not in page_num or ".0" in page_num:
|
||
# Note: Header markers are now included in EXHIBIT_HEADER_INSTRUCTION() for caching
|
||
exhibit_header = prompt_calls.prompt_exhibit_header(page_content, filename)
|
||
if "N/A" in exhibit_header:
|
||
is_exhibit = False
|
||
else:
|
||
exhibit_pages.append(page_num)
|
||
exhibit_header_dict[page_num] = exhibit_header
|
||
is_exhibit = True
|
||
elif is_exhibit == True:
|
||
exhibit_pages.append(page_num)
|
||
return exhibit_pages, exhibit_header_dict
|
||
|
||
|
||
def link_exhibit_pages(
|
||
all_exhibit_headers: dict[str, str],
|
||
text_dict: Optional[dict[str, str]] = None,
|
||
pages_dict: Optional[dict[str, "Page"]] = None,
|
||
filename: Optional[str] = None,
|
||
):
|
||
"""
|
||
Filters exhibit pages to include only those with meaningfully different headers.
|
||
|
||
This function processes exhibit headers sequentially and uses literal comparison and
|
||
LLM-based semantic comparison to determine when exhibits represent continuation pages
|
||
vs. distinct exhibits. Continuation pages are filtered out of the headers list and
|
||
mapping, and when used downstream are included in their parent exhibit's text chunk.
|
||
Links exhibit pages based on their headers. If the header of a page is identical
|
||
(either literally or semantically) to the previous page's header, it is not added
|
||
to the final list of exhibit pages.
|
||
This function will link contiguous header pages together, so that the final list of
|
||
exhibit pages will only contain pages that are meaningfully different from the
|
||
previous page. E.g. "1" with header {"Exhibit 1"} and "2" with header {"Continued
|
||
exhibit 1"} will be linked together, but "3" with header {"Exhibit 2"} will not be
|
||
linked to either of them.
|
||
|
||
Args:
|
||
all_exhibit_headers: A dictionary mapping page numbers (as strings) to their
|
||
exhibit headers. Expected to be in chronological order
|
||
text_dict: A dictionary mapping page numbers to their text content
|
||
(for backward compatibility)
|
||
pages_dict: A dictionary mapping page numbers to Page objects (preferred)
|
||
filename: The name of the file being processed, used for logging and LLM invocation.
|
||
|
||
Returns:
|
||
tuple[list[str], dict[str, str]]: A tuple containing:
|
||
- A list of page numbers (as strings) that are identified as exhibit pages.
|
||
- A dictionary where keys are page numbers and values are the headers of those exhibits.
|
||
|
||
Example:
|
||
Input: {"1": "Exhibit A", "2": "Exhibit A (continued)", "3": "Exhibit B"}
|
||
Output (["1", "3"], {"1": "Exhibit A", "3": "Exhibit B"})
|
||
|
||
Notes:
|
||
- First exhibit page is always included.
|
||
- Literal header comparison is performed first to optimize LLM usage and latency.
|
||
- Uses `EXHIBIT_LINKAGE` prompt for semantic similarity assessment
|
||
- Expects LLM to return "Y" or "N" enclosed in pipe delimiters
|
||
- Pages with non-distinct headers are excluded from the final results.
|
||
- Either text_dict or pages_dict must be provided (pages_dict takes precedence).
|
||
"""
|
||
if filename is None:
|
||
filename = ""
|
||
first_exhibit = True
|
||
final_exhibit_pages = []
|
||
final_exhibit_headers = {}
|
||
for page_num, page_header in all_exhibit_headers.items():
|
||
if first_exhibit:
|
||
exhibit_is_different = "N"
|
||
previous_header = page_header
|
||
final_exhibit_pages.append(page_num)
|
||
final_exhibit_headers[page_num] = page_header
|
||
first_exhibit = False
|
||
else:
|
||
# If literally identical, skip prompt (unless it has a table)
|
||
if page_header.upper().strip() == previous_header.upper().strip():
|
||
exhibit_is_different = "N"
|
||
elif string_utils.is_empty(previous_header):
|
||
exhibit_is_different = "Y"
|
||
previous_header = page_header
|
||
else:
|
||
exhibit_is_different = prompt_calls.prompt_exhibit_linkage(
|
||
page_header, previous_header, filename
|
||
)
|
||
previous_header = page_header
|
||
|
||
# If the prompt has identified that the current exhibit is meaningfully different than the previous
|
||
if "Y" in exhibit_is_different:
|
||
final_exhibit_pages.append(page_num)
|
||
final_exhibit_headers[page_num] = page_header
|
||
|
||
return final_exhibit_pages, final_exhibit_headers
|
||
|
||
|
||
def chunk_by_exhibit(
|
||
text_dict: Optional[dict[str, str]] = None,
|
||
pages_dict: Optional[dict[str, "Page"]] = None,
|
||
exhibit_pages: Optional[list] = None,
|
||
*args, # For backward compatibility with positional arguments
|
||
) -> dict:
|
||
"""
|
||
Organizes pages into groups based on their association with specific exhibits.
|
||
|
||
This function assigns each page number from the dictionary to an exhibit
|
||
based on the `exhibit_pages` list. Pages are grouped under the nearest preceding
|
||
page number in `exhibit_pages`. If a page number is itself in `exhibit_pages`,
|
||
it starts a new exhibit group.
|
||
|
||
Parameters:
|
||
text_dict: A dictionary where keys are page numbers and values are page text
|
||
(for backward compatibility)
|
||
pages_dict: A dictionary where keys are page numbers and values are Page objects
|
||
(preferred)
|
||
exhibit_pages: A list of page numbers that mark the beginning of a new exhibit.
|
||
|
||
Returns:
|
||
dict: A dictionary where keys are exhibit identifiers (page numbers from exhibit_pages)
|
||
and values are lists of page numbers that belong to that exhibit.
|
||
Example: {'1': ['1', '2'], '3': ['3', '4', '5']}
|
||
|
||
Note:
|
||
Either text_dict or pages_dict must be provided. If both are provided,
|
||
pages_dict takes precedence.
|
||
For backward compatibility: can be called as chunk_by_exhibit(text_dict, exhibit_pages)
|
||
"""
|
||
# Backward compatibility: detect when called with old positional signature
|
||
# Old signature: chunk_by_exhibit(text_dict, exhibit_pages)
|
||
# When called positionally, Python binds: text_dict=dict, pages_dict=list, exhibit_pages=None
|
||
if (
|
||
pages_dict is not None
|
||
and isinstance(pages_dict, list)
|
||
and exhibit_pages is None
|
||
):
|
||
# This is the old signature - pages_dict is actually exhibit_pages
|
||
exhibit_pages = pages_dict
|
||
pages_dict = None
|
||
|
||
if exhibit_pages is None:
|
||
exhibit_pages = []
|
||
|
||
# Determine which dictionary to use
|
||
if pages_dict is not None:
|
||
if not isinstance(pages_dict, dict):
|
||
raise TypeError(f"pages_dict must be a dict, got {type(pages_dict)}")
|
||
page_dict = pages_dict
|
||
elif text_dict is not None:
|
||
if not isinstance(text_dict, dict):
|
||
raise TypeError(f"text_dict must be a dict, got {type(text_dict)}")
|
||
page_dict = text_dict
|
||
else:
|
||
raise ValueError("Either text_dict or pages_dict must be provided")
|
||
|
||
if len(exhibit_pages) == 0:
|
||
return {key: [key] for key in page_dict.keys()}
|
||
|
||
exhibit_chunk_mapping = {}
|
||
current_exhibit = list(page_dict.keys())[0]
|
||
|
||
for page_num in page_dict.keys():
|
||
if page_num in exhibit_pages:
|
||
current_exhibit = page_num
|
||
# Initialize the list for this exhibit if not already present
|
||
if current_exhibit not in exhibit_chunk_mapping:
|
||
exhibit_chunk_mapping[current_exhibit] = []
|
||
|
||
# Add the page to the current exhibit's list
|
||
if current_exhibit not in exhibit_chunk_mapping:
|
||
exhibit_chunk_mapping[current_exhibit] = []
|
||
exhibit_chunk_mapping[current_exhibit].append(page_num)
|
||
|
||
return exhibit_chunk_mapping
|
||
|
||
|
||
def simplify_exhibit(
|
||
text_dict: Optional[dict[str, str]] = None,
|
||
pages_dict: Optional[dict[str, "Page"]] = None,
|
||
exhibit: Optional["Exhibit"] = None,
|
||
exhibit_page_nums: Optional[list[str]] = None,
|
||
current_page_num: Optional[str] = None,
|
||
) -> str:
|
||
"""
|
||
Simplify exhibit text by replacing table content with 'Table X' placeholders on all pages
|
||
except the current page being processed.
|
||
|
||
Args:
|
||
text_dict: Dictionary mapping page numbers to page text (for backward compatibility)
|
||
pages_dict: Dictionary mapping page numbers to Page objects (preferred)
|
||
exhibit: Exhibit object (preferred - uses exhibit.pages and exhibit.exhibit_page_nums)
|
||
exhibit_page_nums: List of page numbers that belong to this exhibit
|
||
(required if exhibit is not provided)
|
||
current_page_num: The current page being processed (will NOT be simplified)
|
||
(required if exhibit is not provided)
|
||
|
||
Returns:
|
||
str: The exhibit text with tables simplified on all pages except current_page_num
|
||
|
||
Note:
|
||
Either (exhibit) or (text_dict/pages_dict + exhibit_page_nums + current_page_num) must be provided.
|
||
If exhibit is provided, it takes precedence.
|
||
"""
|
||
# Check if any parameter that should be a dict is actually an Exhibit object
|
||
if text_dict is not None and hasattr(text_dict, "pages"):
|
||
# text_dict is actually an Exhibit object
|
||
exhibit = text_dict
|
||
text_dict = None
|
||
elif pages_dict is not None and hasattr(pages_dict, "pages"):
|
||
# pages_dict is actually an Exhibit object
|
||
exhibit = pages_dict
|
||
pages_dict = None
|
||
|
||
# Handle Exhibit object (preferred approach) - but only if it's actually an Exhibit
|
||
if exhibit is not None and hasattr(exhibit, "pages"):
|
||
if exhibit.pages is not None:
|
||
pages_dict = exhibit.pages
|
||
else:
|
||
# Fall back to text_dict if pages not available
|
||
text_dict = {
|
||
page_id: exhibit.get_page_text(page_id)
|
||
for page_id in exhibit.exhibit_page_nums
|
||
}
|
||
exhibit_page_nums = exhibit.exhibit_page_nums
|
||
# For current_page_num, we need to determine which base page the current_page_num belongs to
|
||
# If current_page_num is a sub-page like "27.1", the base page is "27"
|
||
if current_page_num is None:
|
||
# Try to infer from exhibit_page_nums - use first page as default
|
||
current_page_num = exhibit_page_nums[0] if exhibit_page_nums else ""
|
||
else:
|
||
# Extract base page if it's a sub-page
|
||
if "." in current_page_num and not current_page_num.startswith("."):
|
||
current_page_num = current_page_num.rsplit(".", 1)[0]
|
||
|
||
# Validate required parameters
|
||
if exhibit_page_nums is None:
|
||
raise ValueError(
|
||
"exhibit_page_nums must be provided if exhibit is not provided"
|
||
)
|
||
if current_page_num is None:
|
||
raise ValueError("current_page_num must be provided if exhibit is not provided")
|
||
|
||
# Determine which dictionary to use
|
||
use_pages = False
|
||
if pages_dict is not None:
|
||
page_dict = pages_dict
|
||
use_pages = True
|
||
elif text_dict is not None:
|
||
page_dict = text_dict
|
||
use_pages = False
|
||
else:
|
||
raise ValueError("Either text_dict, pages_dict, or exhibit must be provided")
|
||
|
||
def replace_tables_with_placeholder(
|
||
page_text: str, starting_table_num: int
|
||
) -> tuple[str, int]:
|
||
"""Replace table blocks with 'Table X' labels and return next table number."""
|
||
if not page_text:
|
||
return page_text, starting_table_num
|
||
|
||
start_marker = "-------Table Start--------"
|
||
end_marker = "-------Table End--------"
|
||
result = []
|
||
pos = 0
|
||
table_num = starting_table_num
|
||
|
||
while True:
|
||
start_idx = page_text.find(start_marker, pos)
|
||
if start_idx == -1:
|
||
# No more tables, append remaining text
|
||
result.append(page_text[pos:])
|
||
break
|
||
|
||
# Append text before table
|
||
result.append(page_text[pos:start_idx])
|
||
|
||
# Find end of table
|
||
end_idx = page_text.find(end_marker, start_idx + len(start_marker))
|
||
|
||
# Replace table with placeholder
|
||
result.append(f"Table {table_num}")
|
||
table_num += 1
|
||
|
||
# Move position past the table end marker
|
||
if end_idx != -1:
|
||
pos = end_idx + len(end_marker)
|
||
else:
|
||
# No end marker found, stop processing
|
||
break
|
||
|
||
return "".join(result), table_num
|
||
|
||
# Process each page/sub-page in the exhibit
|
||
simplified_exhibit_pages = []
|
||
table_counter = 1
|
||
|
||
# Extract base page from current_page_num if it's a sub-page
|
||
current_base_page = current_page_num
|
||
if "." in current_page_num and not current_page_num.startswith("."):
|
||
current_base_page = current_page_num.rsplit(".", 1)[0]
|
||
|
||
for page_id in exhibit_page_nums:
|
||
# Extract base page for comparison
|
||
base_page = page_id
|
||
if "." in page_id and not page_id.startswith("."):
|
||
base_page = page_id.rsplit(".", 1)[0]
|
||
|
||
# Get page text
|
||
if use_pages:
|
||
# Handle sub-pages: "27.1" means get sub-page "1" from page "27"
|
||
if "." in page_id and not page_id.startswith("."):
|
||
base_page_num, sub_page = page_id.rsplit(".", 1)
|
||
if base_page_num in page_dict:
|
||
page_text = page_dict[base_page_num].get_text(sub_page)
|
||
else:
|
||
page_text = ""
|
||
elif page_id in page_dict:
|
||
page_text = page_dict[page_id].get_text()
|
||
else:
|
||
page_text = ""
|
||
else:
|
||
page_text = page_dict.get(page_id, "")
|
||
|
||
# Check if this is the current page (match base page)
|
||
if base_page == current_base_page:
|
||
# Don't simplify the current page - keep it as-is
|
||
simplified_exhibit_pages.append(page_text)
|
||
# But still need to count tables to keep numbering consistent
|
||
if "-------Table Start--------" in page_text:
|
||
_, table_counter = replace_tables_with_placeholder(
|
||
page_text, table_counter
|
||
)
|
||
else:
|
||
# Simplify other pages by replacing tables with placeholders
|
||
if "-------Table Start--------" in page_text:
|
||
simplified_page, table_counter = replace_tables_with_placeholder(
|
||
page_text, table_counter
|
||
)
|
||
simplified_exhibit_pages.append(simplified_page)
|
||
else:
|
||
simplified_exhibit_pages.append(page_text)
|
||
|
||
return "\n".join(simplified_exhibit_pages)
|
||
|
||
|
||
def split_large_tables(
|
||
text_dict: dict[str, str],
|
||
max_cells: Optional[int] = None,
|
||
min_rows_to_split: Optional[int] = None,
|
||
) -> dict[str, "Page"]:
|
||
"""
|
||
Identify and split large tables into sub-pages.
|
||
|
||
This function converts a text dictionary to Page objects and splits
|
||
large tables based on cell count (rows × columns).
|
||
|
||
Args:
|
||
text_dict: Original page number to text mapping
|
||
max_cells: Maximum cells (rows × columns) per table sub-page.
|
||
If None, uses config.MAX_TABLE_CELLS_PER_SUBPAGE
|
||
min_rows_to_split: Minimum rows to trigger splitting.
|
||
If None, uses config.MIN_TABLE_ROWS_TO_SPLIT
|
||
|
||
Returns:
|
||
dict mapping page numbers to Page objects
|
||
"""
|
||
from src.pipelines.shared.extraction.page_funcs import Page
|
||
|
||
if not config.TABLE_SPLITTING_ENABLED:
|
||
# If table splitting is disabled, create Page objects without splitting
|
||
return {
|
||
page_num: Page(page_num, page_text)
|
||
for page_num, page_text in text_dict.items()
|
||
}
|
||
|
||
if max_cells is None:
|
||
max_cells = config.MAX_TABLE_CELLS_PER_SUBPAGE
|
||
if min_rows_to_split is None:
|
||
min_rows_to_split = config.MIN_TABLE_ROWS_TO_SPLIT
|
||
|
||
pages_dict = {}
|
||
|
||
for page_num, page_text in text_dict.items():
|
||
page = Page(page_num, page_text)
|
||
|
||
# Check if page has a table and if it needs splitting
|
||
if page.has_table():
|
||
# Split table if it exceeds thresholds
|
||
page.split_table(max_cells=max_cells, min_rows_to_split=min_rows_to_split)
|
||
|
||
pages_dict[page_num] = page
|
||
|
||
return pages_dict
|