2025-01-06 15:39:29 +00:00
import re
2025-05-29 22:33:12 +00:00
import logging
2025-01-06 15:39:29 +00:00
2025-05-07 16:18:02 +00:00
from src . prompts import investment_prompts
2025-01-08 21:59:47 +00:00
import src . utils . llm_utils as llm_utils
import src . utils . string_utils as string_utils
2025-01-27 19:39:23 +00:00
from src import config , keywords
from src . regex . regex_patterns import PIPE_PATTERN
2024-10-28 23:39:36 +00:00
2025-01-27 19:39:23 +00:00
from src import config , keywords
2025-01-17 17:15:32 +00:00
from src . enums . delimiters import Delimiter
2024-10-28 23:39:36 +00:00
2025-01-17 20:25:55 +00:00
def remove_page_indicators ( contract_text : str ) - > str :
""" Clean textract output by removing page number indicators in the form of " Page X of Y "
2024-10-28 23:39:36 +00:00
2025-01-17 20:25:55 +00:00
This function processes input text to remove lines that indicate page numbers (e.g. ' Page 1 of 10 ' )
2024-10-28 23:39:36 +00:00
Args:
contract_text (str): Raw text output from Textract to be cleaned
Returns:
str: cleaned text with newlines and page number indicators removed
"""
2025-01-17 17:15:32 +00:00
2025-03-19 22:11:27 +00:00
if contract_text :
cleaned_text = re . sub ( r " Page [0-9]+ of [0-9]+ \ n \ n " , " " , contract_text )
else :
cleaned_text = contract_text
2024-10-28 23:39:36 +00:00
return cleaned_text
2025-01-24 22:46:59 +00:00
# TODO: write unit tests
2024-10-28 23:39:36 +00:00
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.
"""
2025-01-24 22:46:59 +00:00
temp_list = text . split ( " Start of Page No. = " )
text_list = re . split ( r " Start of Page No. = [0-9]+ \ n " , text )
2024-10-28 23:39:36 +00:00
2025-01-24 22:46:59 +00:00
text_dict = { }
for i in range ( len ( text_list ) ) :
2025-04-25 19:35:10 +00:00
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
2024-10-28 23:39:36 +00:00
2025-01-24 22:46:59 +00:00
return { k : v for k , v in text_dict . items ( ) if k != " Document " }
2024-10-28 23:39:36 +00:00
def clean_law_symbols ( contract_text ) :
2025-05-07 18:21:09 +00:00
if not contract_text :
return contract_text
# First correction: Replace '$$' with '$'
2024-10-28 23:39:36 +00:00
contract_text = contract_text . replace ( " $$ " , " $ " )
2025-01-17 17:15:32 +00:00
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).
2024-10-28 23:39:36 +00:00
# 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
2024-11-27 15:08:04 +00:00
# ORIGINAL
def chunk_consecutive_og ( text_dict , exhibit_pages ) :
# If needed - extend page 1 to page 1 and 2, then cut chunking off after (edge case: compensation terms not found on first page of exhibit)
2025-01-08 21:59:47 +00:00
reimbursement_pages = [ page_num for page_num in text_dict . keys ( ) if string_utils . contains_reimbursement ( text_dict , page_num ) ]
2024-11-27 15:08:04 +00:00
page_dict = { }
current_exhibit = None
for page_num in text_dict . keys ( ) :
# Page is Reimbursement AND Exhibit
if page_num in reimbursement_pages and page_num in exhibit_pages :
current_exhibit = page_num
page_dict [ page_num ] = [ page_num ]
# Page is Reimbursement NOT Exhibit
elif page_num in reimbursement_pages and page_num not in exhibit_pages :
if current_exhibit :
page_dict [ current_exhibit ] . append ( page_num )
else :
page_dict [ page_num ] = [ page_num ]
# Page is Exhibit NOT Reimbursement
elif page_num in exhibit_pages and page_num not in reimbursement_pages :
current_exhibit = page_num
page_dict [ page_num ] = [ page_num ]
# Page is NOT Exhibit NOT Reimbursment
elif page_num not in exhibit_pages and page_num not in reimbursement_pages :
current_exhibit = None
page_dict [ page_num ] = [ page_num ]
final_dict = { page_num : ' ' for page_num in page_dict . keys ( ) }
for page_num in page_dict . keys ( ) :
for p in page_dict [ page_num ] :
final_dict [ page_num ] + = text_dict [ p ]
return final_dict
2024-10-28 23:39:36 +00:00
def chunk_consecutive ( text_dict , exhibit_pages ) :
exhibit_pages = set ( str ( page ) for page in exhibit_pages )
reimbursement_pages = {
page_num
for page_num in text_dict . keys ( )
2025-01-08 21:59:47 +00:00
if string_utils . contains_reimbursement ( text_dict , page_num )
2024-10-28 23:39:36 +00:00
}
page_dict = { }
current_chunk_start = None
last_exhibit = None
in_exhibit = False
def word_count ( text ) :
return len ( text . split ( ) )
for page_str in sorted ( text_dict . keys ( ) , key = int ) :
page_num = int ( page_str )
if page_str in exhibit_pages :
current_chunk_start = page_str
last_exhibit = page_str
in_exhibit = True
page_dict [ current_chunk_start ] = [ page_str ]
# print(f"found page {page_num} in exhibit, starting new chunk")
if page_str in reimbursement_pages :
if not in_exhibit or current_chunk_start is None :
# Check if this page has less than 200 words and should be added to the previous chunk
if current_chunk_start and word_count ( text_dict [ page_str ] ) < 200 :
page_dict [ current_chunk_start ] . append ( page_str )
# print(f"found page {page_num} in reimbursement with less than 200 words, adding to previous chunk")
else :
current_chunk_start = page_str
page_dict [ current_chunk_start ] = [ page_str ]
# print(f"found page {page_num} in reimbursement, starting new chunk")
else :
page_dict [ current_chunk_start ] . append ( page_str )
# print(f"found page {page_num} in reimbursement, adding to current chunk")
else :
if in_exhibit and current_chunk_start is not None :
page_dict [ current_chunk_start ] . append ( page_str )
# print(f"found non-reimbursement page {page_num} in exhibit, adding to current chunk")
else :
in_exhibit = False
# Check next page if it's non-reimbursement and not in exhibit
next_page_str = str ( page_num + 1 )
if (
next_page_str in text_dict
and next_page_str not in reimbursement_pages
and next_page_str not in exhibit_pages
) :
if current_chunk_start is not None :
page_dict [ current_chunk_start ] . append ( next_page_str )
# print(f'next page {next_page_str} was found to be a non-reimbursement and added')
# If we've moved past the last exhibit page, reset in_exhibit
if in_exhibit and int ( page_str ) > int ( last_exhibit ) :
in_exhibit = False
# Remove duplicates and sort page numbers in each chunk
for key in page_dict :
page_dict [ key ] = sorted ( list ( set ( page_dict [ key ] ) ) , key = int )
final_dict = { page_num : " " for page_num in page_dict . keys ( ) }
for page_num in page_dict . keys ( ) :
for p in page_dict [ page_num ] :
final_dict [ page_num ] + = text_dict [ p ]
# print("Final page_dict:", {k: v for k, v in page_dict.items()})
return final_dict
def filter_quick_review ( text_dict ) :
2024-11-22 15:30:01 +00:00
"""
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.
"""
2024-10-28 23:39:36 +00:00
return {
page_num : page_text
for page_num , page_text in text_dict . items ( )
if " QUICK REVIEW " not in page_text . upper ( )
and " TOP SHEET " not in page_text . upper ( )
and " COVER SHEET " not in page_text . upper ( )
2024-11-22 15:30:01 +00:00
} , {
page_num : page_text
for page_num , page_text in text_dict . items ( )
if " QUICK REVIEW " in page_text . upper ( )
or " TOP SHEET " in page_text . upper ( )
or " COVER SHEET " in page_text . upper ( )
2024-10-28 23:39:36 +00:00
}
2024-11-22 15:30:01 +00:00
2025-02-10 22:59:10 +00:00
def get_exhibit_pages ( text_dict : dict [ str , str ] , filename : str ) - > list [ str ] :
2025-05-28 16:57:23 +00:00
""" Extract beginning-of-exhibit pages from a contract using LLM-based detection.
This function identifies pages that start new exhibits by checking each page (or
subpage) for exhibit headers using an LLM prompt. It handles both regular pages
(e.g., " 5 " ) and subpages created by table splitting (e.g., " 5.1 " , " 5.2 " ).
Key behaviors:
- The first page of the contract is always included in the exhibit pages.
- For base pages with subpages, if ANY subpage contains an exhibit header,
the FIRST subpage is added to the exhibit list
- Each base page number is only processed once to avoid duplicates.
2025-02-10 22:59:10 +00:00
Args:
2025-05-28 16:57:23 +00:00
text_dict (dict[str, str]): Dictionary keyed by string-formatted page numbers and valued by page text
Page numbers may include subpages (e.g., " 5.1 " , " 5.2 " )
2025-02-10 22:59:10 +00:00
filename (str): Filename of the contract (for tracking purposes)
Returns:
2025-05-28 16:57:23 +00:00
list[str]: Ordered list of page numbers that start new exhibits. May include
subpage numbers (e.g., [ " 1 " , " 5.1 " , " 5.2 " ]) depending on table splitting.
Example:
Given pages [ " 1 " , " 2 " , " 5.0 " , " 5.1 " , " 5.2 " , " 7 " ] where " 5.1 " contains an exhibit header:
Returns [ " 1 " , " 5.1 " , " 7 " ] (assuming pages 1 and 7 also have exhibit headers)
2025-02-10 22:59:10 +00:00
"""
2025-01-06 21:08:56 +00:00
exhibit_pages = [ ]
2025-02-10 20:11:03 +00:00
first_page = True
2025-05-28 16:57:23 +00:00
# Sort pages to handle subpages properly (5.0, 5.1, 5.2, etc.)
# TODO: augment `string_utils.page_key_sort` to handle subpages
sorted_pages = sorted ( text_dict . keys ( ) , key = lambda x : (
int ( x . split ( ' . ' ) [ 0 ] ) ,
float ( x . split ( ' . ' ) [ 1 ] ) if ' . ' in x else 0 )
)
for page_num in sorted_pages :
2025-06-03 20:47:42 +00:00
2025-05-28 16:57:23 +00:00
# Always include the first page as an exhibit
if first_page :
2025-02-10 20:11:03 +00:00
exhibit_pages . append ( page_num )
2025-06-03 22:11:10 +00:00
is_exhibit = True
2025-02-10 20:11:03 +00:00
first_page = False
2025-02-10 22:59:10 +00:00
else :
2025-06-03 20:47:42 +00:00
if " . " not in page_num or " .0 " in page_num :
page_content = text_dict [ page_num ]
2025-05-28 16:57:23 +00:00
prompt = investment_prompts . EXHIBIT_CHECK ( page_content [ 0 : 200 ] )
claude_answer_raw = llm_utils . invoke_claude (
prompt , config . MODEL_ID_CLAUDE3_HAIKU , filename , max_tokens = 150
)
claude_answer_extracted = string_utils . extract_text_from_delimiters (
claude_answer_raw , Delimiter . PIPE
)
if " Y " in claude_answer_extracted :
2025-06-03 20:47:42 +00:00
exhibit_pages . append ( page_num )
2025-05-28 16:57:23 +00:00
is_exhibit = True
2025-06-03 20:47:42 +00:00
else :
is_exhibit = False
elif is_exhibit == True :
exhibit_pages . append ( page_num )
2025-01-06 21:08:56 +00:00
return exhibit_pages
2025-01-10 17:24:44 +00:00
def chunk_by_exhibit ( text_dict : dict ,
exhibit_pages : list
) - > dict :
"""
Organizes pages into groups based on their association with specific exhibits.
This function assigns each page number from the `text_dict` 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 in `text_dict` is itself in
`exhibit_pages`, it starts a new exhibit group.
Parameters:
text_dict (dict): A dictionary where keys are page numbers and values are page text
exhibit_pages (list): A list of page numbers that mark the beginning of a new
exhibit.
Returns:
dict: A dictionary mapping each page number in `text_dict` to its corresponding
exhibit identifier. The exhibit identifier is the page number of the first
page in that exhibit as listed in `exhibit_pages`. If there are pages before
the first `exhibit_page`, they are grouped under the exhibit identifier " 0 " .
"""
if len ( exhibit_pages ) == 0 :
return { key : key for key in text_dict . keys ( ) }
2025-06-09 21:49:45 +00:00
exhibit_chunk_mapping = { }
2025-01-10 17:24:44 +00:00
current_exhibit = " 0 "
for page_num in text_dict . keys ( ) :
if page_num in exhibit_pages :
current_exhibit = page_num
2025-06-09 21:49:45 +00:00
exhibit_chunk_mapping [ page_num ] = current_exhibit
2025-01-10 17:24:44 +00:00
else :
2025-06-09 21:49:45 +00:00
exhibit_chunk_mapping [ page_num ] = current_exhibit
return exhibit_chunk_mapping
def get_exhibit_dict ( text_dict : dict ,
exhibit_chunk_mapping : dict
) - > dict :
"""
Creates a dictionary of exhibit texts, concatenating pages with the same exhibit number,
but separating decimal pages into their own chunks.
Args:
text_dict (dict): Dictionary mapping page numbers to page text.
exhibit_chunk_mapping (dict): Dictionary mapping page numbers to exhibit numbers.
Returns:
dict: Dictionary mapping exhibit chunk keys to combined text. Format for keys:
" {exhibit} . {index} "
NOTE: If exhibit identifiers contain decimals (e.g., " 23.0 " ) output keys will
follow the same pattern (resulting in " 23.0.0 " , " 23.0.1 " , etc.)
Guidelines:
- Every exhibit chunk contains at most 1 table page in it (i.e. one page with a decimal)
- Tables are inserted in chronological order within the document flow.
- Multiple decimal pages are separated into individual chunks
- Each chunk gets a new key: " {exhibit} . {index} " (e.g. " 2.0 " , " 2.1 " , " 2.2 " , etc.)
Algorithm:
For each exhibit:
1. Identify base pages (no decimals) and table pages (with decimals).
2. For each decimal page, create a chunk containing:
- All base pages that came before the decimal page (in document order)
- The specific decimal page
- All base pages that came after the decimal page (in document order)
Example 1:
Input mapping { ' 1.0 ' : ' 1.0 ' , ' 2 ' : ' 2 ' , ' 3.0 ' : ' 2 ' , ' 3.1 ' : ' 2 ' , ' 3.2 ' : ' 2 ' , ' 4.0 ' : ' 2 ' }
- ' 1.0 ' : text from ' 1.0 ' (single page exhibit)
- ' 2.0 ' : text from ' 2 ' + text from ' 3.0 ' (base page 2, then table 3.0)
- ' 2.1 ' : text from ' 2 ' + text from ' 3.1 ' (base page 2, then table 3.1)
- ' 2.2 ' : text from ' 2 ' + text from ' 3.2 ' (base page 2, then table 3.2)
- ' 2.3 ' : text from ' 2 ' + text from ' 4.0 ' (base page 2, then table 4.0)
Example 2:
Input mapping { ' 1.0 ' : ' 1.0 ' , ' 2 ' : ' 2 ' , ' 3.0 ' : ' 2 ' , ' 3.1 ' : ' 2 ' , ' 3.2 ' : ' 2 ' , ' 4 ' : ' 2 ' , ' 5 ' : ' 2 ' }
- ' 1.0 ' : text from ' 1.0 '
- ' 2.0 ' : text from ' 2 ' + text from ' 3.0 ' + text from ' 4 ' + text from ' 5 '
- ' 2.1 ' : text from ' 2 ' + text from ' 3.1 ' + text from ' 4 ' + text from ' 5 '
- ' 2.2 ' : text from ' 2 ' + text from ' 3.2 ' + text from ' 4 ' + text from ' 5 '
- ' 2.3 ' : text from ' 2 ' + text from ' 4 ' + text from ' 5 '
Note: Tables 3.0, 3.1, and 3.2 appear between pages 2 and 4 in document flow.
Other cases:
- No decimal pages: creates single chunk for the exhibit.
- Single decimal page exhibit: uses original page as the chunk key.
"""
exhibit_dict = { }
# Group pages by exhibit
exhibit_pages = { }
for page , exhibit in exhibit_chunk_mapping . items ( ) :
if exhibit not in exhibit_pages :
exhibit_pages [ exhibit ] = [ ]
exhibit_pages [ exhibit ] . append ( page )
# Process each exhibit
for exhibit in exhibit_pages :
pages = sorted ( exhibit_pages [ exhibit ] , key = string_utils . page_key_sort )
base_pages = [ p for p in pages if ' . ' not in p ]
decimal_pages = [ p for p in pages if ' . ' in p ]
# Special case: single decimal page exhibit
if len ( decimal_pages ) == 1 and not base_pages :
exhibit_dict [ decimal_pages [ 0 ] ] = text_dict [ decimal_pages [ 0 ] ]
continue
# Case: No decimal pages - create single chunk with .0 suffix
if not decimal_pages :
exhibit_dict [ f " { exhibit } .0 " ] = " \n " . join ( text_dict [ p ] for p in pages )
continue
# Case: With decimal pages, create chunks for each decimal page
chunk_index = 0
for decimal_page in decimal_pages :
chunk_key = f " { exhibit } . { chunk_index } "
# Get decimal page base number for chronological positioning
decimal_base = int ( decimal_page . split ( ' . ' ) [ 0 ] )
# Build chunk with pages in chronological order
chunk_parts = [ ]
# Add base pages before decimal page
for base_page in base_pages :
if int ( base_page ) < = decimal_base :
chunk_parts . append ( text_dict [ base_page ] )
# Add the decimal page itself
chunk_parts . append ( text_dict [ decimal_page ] )
# Add base pages after decimal page
for base_page in base_pages :
if int ( base_page ) > decimal_base :
chunk_parts . append ( text_dict [ base_page ] )
exhibit_dict [ chunk_key ] = " \n " . join ( chunk_parts )
chunk_index + = 1
# Add final chunk with just base pages (if there are base pages after all decimal pages)
if base_pages and decimal_pages :
# Check if there are base pages that come after all decimal pages
max_decimal_base = max ( int ( dp . split ( ' . ' ) [ 0 ] ) for dp in decimal_pages )
remaining_base_pages = [ bp for bp in base_pages if int ( bp ) > max_decimal_base ]
if remaining_base_pages :
chunk_key = f " { exhibit } . { chunk_index } "
# Include ALL base pages in the final chunk
exhibit_dict [ chunk_key ] = " \n " . join ( text_dict [ bp ] for bp in base_pages )
2025-01-10 17:24:44 +00:00
return exhibit_dict
2025-06-09 21:49:45 +00:00
def get_exhibit_headers ( exhibit_dict , filename ) :
"""
Extracts the exhibit header using an LLM prompt.
2025-01-10 17:24:44 +00:00
2025-06-09 21:49:45 +00:00
Args:
exhibit_chunk (str): A chunk of exhibit text to analyze.
filename (str): The name of the file being processed.
Returns:
str: The extracted exhibit header.
"""
exhibit_header_dict = { }
base_pages = { }
for page_num , exhibit_chunk in exhibit_dict . items ( ) :
base_page_num = page_num . split ( ' . ' ) [ 0 ]
if base_page_num in base_pages . keys ( ) :
exhibit_header_dict [ page_num ] = base_pages [ base_page_num ]
else :
prompt = investment_prompts . EXHIBIT_HEADER ( exhibit_chunk [ 0 : 400 ] )
llm_answer_raw = llm_utils . invoke_claude (
prompt , config . MODEL_ID_CLAUDE35_SONNET , filename
)
llm_answer_final = string_utils . extract_text_from_delimiters (
llm_answer_raw , Delimiter . PIPE
)
exhibit_header_dict [ page_num ] = llm_answer_final
base_pages [ base_page_num ] = llm_answer_final
return exhibit_header_dict