2024-10-28 23:39:36 +00:00
import json
2025-12-17 19:05:09 +00:00
import ast
2025-08-27 16:37:48 +00:00
import logging
2024-10-28 23:39:36 +00:00
import re
2025-04-25 20:28:52 +00:00
import traceback
2024-12-05 22:46:41 +00:00
import warnings
2025-04-25 20:28:52 +00:00
from datetime import datetime
2025-09-30 20:56:02 +00:00
from typing import Literal , overload
2025-01-06 15:39:29 +00:00
import numpy as np
import pandas as pd
2026-01-26 16:52:55 +00:00
from src . constants . delimiters import Delimiter
from src . constants . regex_patterns import (
BACKTICK_PATTERN ,
PIPE_PATTERN ,
TRIPLE_BACKTICK_PATTERN ,
2026-02-18 17:17:30 +00:00
DBA_PATTERNS ,
2026-01-26 16:52:55 +00:00
)
2024-10-28 23:39:36 +00:00
2026-02-18 17:17:30 +00:00
LEGAL_SUFFIXES = {
" inc " ,
" inc. " ,
" incorporated " ,
" corp " ,
" corporation " ,
" llc " ,
" l.l.c " ,
" ltd " ,
" limited " ,
" co " ,
" company " ,
" affiliates " ,
}
STOPWORDS = { " of " , " the " , " and " , " its " }
US_STATES = [
" alabama " ,
" alaska " ,
" arizona " ,
" arkansas " ,
" california " ,
" colorado " ,
" connecticut " ,
" delaware " ,
" florida " ,
" georgia " ,
" hawaii " ,
" idaho " ,
" illinois " ,
" indiana " ,
" iowa " ,
" kansas " ,
" kentucky " ,
" louisiana " ,
" maine " ,
" maryland " ,
" massachusetts " ,
" michigan " ,
" minnesota " ,
" mississippi " ,
" missouri " ,
" montana " ,
" nebraska " ,
" nevada " ,
" new hampshire " ,
" new jersey " ,
" new mexico " ,
" new york " ,
" north carolina " ,
" north dakota " ,
" ohio " ,
" oklahoma " ,
" oregon " ,
" pennsylvania " ,
" rhode island " ,
" south carolina " ,
" south dakota " ,
" tennessee " ,
" texas " ,
" utah " ,
" vermont " ,
" virginia " ,
" washington " ,
" west virginia " ,
" wisconsin " ,
" wyoming " ,
]
2024-10-28 23:39:36 +00:00
2024-12-30 21:41:21 +00:00
def extract_text_from_delimiters (
raw_output : str , delimiter : Delimiter , match_index : int = - 1
) - > str :
2024-12-05 22:46:41 +00:00
"""
Extracts a match enclosed in specified delimiters from the raw output.
This function searches for text enclosed by a specified delimiter in the given raw output string.
It returns the match specified by the `which_match` index. If no match is found, it returns " N/A " .
- raw_output (str): The raw output string to search within.
- which_match (int, optional): The index of the match to return. Defaults to -1, which returns the last match. If the index is out of range, the last match is returned.
- str: The extracted text or " N/A " if no match is found.
2024-12-30 21:41:21 +00:00
2024-12-05 22:46:41 +00:00
Raises:
- TypeError: If `raw_output` is not a string or `delimiter` is not a Delimiter enum value.
- ValueError: If an unsupported delimiter is provided.
Returns:
- str: The extracted answer or " N/A " if no match is found.
2026-02-02 16:44:29 -06:00
.. deprecated:: 2026.02
Using Delimiter.PIPE with this function is deprecated and will be removed
in a future version. Prompts should return JSON format instead of
pipe-delimited strings. Use `json_utils.parse_json_list()` or
`json_utils.parse_json_dict()` for parsing JSON responses.
2024-12-05 22:46:41 +00:00
"""
if not isinstance ( raw_output , str ) :
2024-12-30 21:41:21 +00:00
raise TypeError (
" Expected a string for raw_output, got {0} . " . format (
type ( raw_output ) . __name__
)
)
2024-12-05 22:46:41 +00:00
if not isinstance ( delimiter , Delimiter ) :
2024-12-30 21:41:21 +00:00
raise TypeError (
" Expected a Delimiter enum value, got {0} . " . format ( type ( delimiter ) . __name__ )
)
2024-12-05 22:46:41 +00:00
# Define a pattern based on the delimiter type
if delimiter == Delimiter . PIPE :
2026-02-02 16:44:29 -06:00
warnings . warn (
" Delimiter.PIPE is deprecated for LLM output parsing. "
" Prompts should return JSON format instead. "
" Use json_utils.parse_json_list() or json_utils.parse_json_dict() instead. " ,
DeprecationWarning ,
stacklevel = 2 ,
)
2024-12-05 22:46:41 +00:00
pattern = PIPE_PATTERN
elif delimiter == Delimiter . BACKTICK :
pattern = BACKTICK_PATTERN
elif delimiter == Delimiter . TRIPLE_BACKTICK :
pattern = TRIPLE_BACKTICK_PATTERN
else :
raise ValueError ( " Unsupported delimiter. Use one of the Delimiter enum values. " )
2024-12-30 21:41:21 +00:00
2024-12-05 22:46:41 +00:00
# Find all matches based on the pattern
matches = re . findall ( pattern = pattern , string = raw_output )
2024-12-30 21:41:21 +00:00
2024-12-05 22:46:41 +00:00
if len ( matches ) == 0 :
return " N/A "
2024-12-30 21:41:21 +00:00
2024-12-05 22:46:41 +00:00
# Adjust for negative indices
if match_index < 0 :
match_index + = len ( matches )
2024-12-30 21:41:21 +00:00
2024-12-05 22:46:41 +00:00
if not 0 < = match_index < len ( matches ) :
warnings . warn ( " Index out of range. Returning the last match by default. " )
match_index = - 1
2024-12-30 21:41:21 +00:00
2024-12-05 22:46:41 +00:00
return matches [ match_index ]
2025-07-07 21:42:49 +00:00
def page_key_sort ( page_key : str ) - > tuple [ int , float | str ] :
2025-04-25 20:28:52 +00:00
""" Sorts page keys, prioritizing numeric keys first.
Args:
page_key (str): The page key to sort.
Returns:
2025-06-09 21:49:45 +00:00
tuple[int, float|str]: A tuple where the first element is 0 for numeric keys and 1 for non-numeric keys, and the second element is the numeric value for numeric keys or the original string for non-numeric keys.
2025-04-25 20:28:52 +00:00
Example:
>>> page_key_sort( " 1 " )
2025-06-09 21:49:45 +00:00
(0, 1.0)
2025-04-25 20:28:52 +00:00
>>> page_key_sort( " A " )
(1, " A " )
2025-07-07 21:42:49 +00:00
2025-04-25 20:28:52 +00:00
Example Usage with mixed types:
>>> L = [ " 10 " , " 2 " , " A " , " 1 " ]
>>> sort(L, key=page_key_sort)
>>> print(L)
[ ' 1 ' , ' 2 ' , ' 10 ' , ' A ' ]
2025-07-07 21:42:49 +00:00
2025-04-25 20:28:52 +00:00
"""
try :
2025-06-09 21:49:45 +00:00
return ( 0 , float ( page_key ) ) # Numeric pages first, in numerical order
2025-04-25 20:28:52 +00:00
except ValueError :
return ( 1 , str ( page_key ) ) # Non-numeric pages after, in alphabetical order
2025-01-09 17:59:28 +00:00
def json_parsing_search ( response_text : str , field_list : list [ str ] ) - > dict [ str , str ] :
"""
Parses a JSON-like string to extract values for fields.
This function attempts to clean and format the input string to resemble a valid JSON structure.
It then searches for the specified fields within the string and extracts their corresponding values.
Parameters:
response_text (str): The JSON-like string to parse.
field_list (list[str]): A list of field names to search for in the response_text.
2024-12-05 22:46:41 +00:00
2025-01-09 17:59:28 +00:00
Returns:
dict[str, str]: A dictionary where keys are field names and values are the extracted values from the response_text.
"""
2024-10-28 23:39:36 +00:00
try :
if response_text . split ( " { " , 1 ) [ 1 ] . strip ( ) [ 0 ] == ' " ' :
response_text = " { " + response_text . split ( " { " , 1 ) [ 1 ]
else :
response_text = " { " + response_text
except :
response_text = " { " + response_text
if len ( response_text . split ( " } " , 1 ) ) > 1 :
if response_text . rsplit ( " } " , 1 ) [ 0 ] . strip ( ) [ - 1 ] == ' " ' :
response_text = response_text . rsplit ( " } " , 1 ) [ 0 ] + " } "
elif response_text . rsplit ( " } " , 1 ) [ 0 ] . strip ( ) [ - 1 ] == " } " :
response_text = response_text . rsplit ( " } " , 1 ) [ 0 ]
else :
response_text = response_text . rstrip ( " , " ) + " } "
2025-07-07 21:42:49 +00:00
2024-10-28 23:39:36 +00:00
field_l = [ ]
answer_l = [ ]
position_dict = { }
2025-07-07 21:42:49 +00:00
2024-10-28 23:39:36 +00:00
for f in field_list :
location = response_text . find ( ' " ' + f + ' " ' )
if location != - 1 :
position_dict [ location ] = f
2025-07-07 21:42:49 +00:00
2024-10-28 23:39:36 +00:00
field_list = list ( dict ( sorted ( position_dict . items ( ) ) ) . values ( ) )
2025-07-07 21:42:49 +00:00
2024-10-28 23:39:36 +00:00
for f in field_list :
if f in response_text :
field_l . append ( f )
value = response_text . split ( ' " ' + f + ' " ' ) [ 0 ]
response_text = response_text . split ( ' " ' + f + ' " ' ) [ 1 ]
if f != field_list [ 0 ] and f != field_list [ - 1 ] :
answer_l . append (
value . strip ( " \n " )
. strip ( ' " ' )
. strip ( " : " )
. strip ( " " )
. strip ( " \n " )
. strip ( " " )
. strip ( " , " )
. strip ( ' " ' )
)
elif f == field_list [ - 1 ] :
answer_l . append (
value . strip ( " \n " )
. strip ( ' " ' )
. strip ( " : " )
. strip ( " " )
. strip ( " \n " )
. strip ( " " )
. strip ( " , " )
. strip ( ' " ' )
)
answer_l . append (
response_text . strip ( " \n " )
. strip ( ' " ' )
. strip ( " : " )
. strip ( " " )
. strip ( " } " )
. strip ( " \n " )
. strip ( " " )
. strip ( ' " ' )
)
return dict ( zip ( field_l , answer_l ) )
2025-01-06 15:39:29 +00:00
2025-11-03 20:45:58 +00:00
def universal_json_load ( s : str ) :
2025-04-22 21:48:45 +00:00
"""
2025-11-03 20:45:58 +00:00
Extract and parse all highest-level valid JSON objects from a string.
Returns the last valid top-level JSON object found (dict, list, etc).
Raises ValueError if no valid JSON objects are found. Does not clean up syntax.
2026-02-02 16:44:29 -06:00
.. deprecated:: 2026.02
This function is deprecated and will be removed in a future version.
Use `json_utils.parse_json_dict()` for dictionary outputs or
`json_utils.parse_json_list()` for list outputs instead.
The new parsers provide type-specific parsing with better error handling.
2025-11-03 20:45:58 +00:00
"""
2026-02-02 16:44:29 -06:00
warnings . warn (
" universal_json_load is deprecated and will be removed in a future version. "
" Use json_utils.parse_json_dict() or json_utils.parse_json_list() instead. " ,
DeprecationWarning ,
stacklevel = 2 ,
)
2025-11-03 20:45:58 +00:00
if not isinstance ( s , str ) :
raise TypeError ( f " Expected string input, got { type ( s ) . __name__ } " )
found = [ ]
i = 0
while i < len ( s ) :
2026-01-26 16:52:55 +00:00
if s [ i ] in " { [ " :
2025-11-03 20:45:58 +00:00
start = i
stack = [ s [ i ] ]
2026-01-05 18:34:18 +00:00
j = i + 1
while j < len ( s ) :
# Skip over string literals to avoid counting brackets inside strings
if s [ j ] == ' " ' :
j + = 1
while j < len ( s ) :
2026-01-26 16:52:55 +00:00
if s [ j ] == " \\ " and j + 1 < len ( s ) :
2026-01-05 18:34:18 +00:00
j + = 2 # Skip escaped character
continue
if s [ j ] == ' " ' :
break
j + = 1
2026-01-26 16:52:55 +00:00
elif s [ j ] in " { [ " :
2025-11-03 20:45:58 +00:00
stack . append ( s [ j ] )
2026-01-26 16:52:55 +00:00
elif s [ j ] in " }] " :
2025-11-03 20:45:58 +00:00
if not stack :
break
open_bracket = stack . pop ( )
2026-01-26 16:52:55 +00:00
if ( open_bracket == " { " and s [ j ] != " } " ) or (
open_bracket == " [ " and s [ j ] != " ] "
) :
2025-11-03 20:45:58 +00:00
break
if not stack :
2026-01-26 16:52:55 +00:00
candidate = s [ start : j + 1 ]
2025-11-03 20:45:58 +00:00
# Only consider top-level objects (not nested)
# Check if start is not inside another object
# This is guaranteed by stack being empty only at top-level
try :
obj = json . loads ( candidate )
found . append ( obj )
except Exception :
pass
i = j # Move past this top-level object
break
2026-01-05 18:34:18 +00:00
j + = 1
2025-11-03 20:45:58 +00:00
else :
pass
i + = 1
if found :
return found [ - 1 ]
raise ValueError ( f " No valid JSON objects found in string: { s [ : 100 ] } ... " )
2025-01-27 19:39:23 +00:00
2025-07-07 21:42:49 +00:00
reimbursement_strings = [ # These are for `method='keyword'`
2025-01-24 22:46:59 +00:00
" % " ,
" $ " ,
" percent " ,
2025-07-07 21:42:49 +00:00
" billed charges " ,
2025-11-03 20:45:58 +00:00
" compensation " ,
" reimbursement " ,
2026-01-26 16:52:55 +00:00
" fee schedule " ,
2025-01-24 22:46:59 +00:00
]
reimb_regex = r " (?<![$ % ])(?: \ $ \ d+| \ d+[$ % ])(?![$ % ]) "
2025-07-07 21:42:49 +00:00
2025-03-06 21:42:43 +00:00
# TODO: check should this always be checking just page 1? Bc in file_processing and table_utils it is but in preprocessing_funcs it's checking specific pages
2025-01-27 19:39:23 +00:00
def contains_reimbursement ( text , page = " 1 " , method = " keyword " ) :
2025-01-24 22:46:59 +00:00
"""
Checks if the given text contains any reimbursement-related keywords or patterns.
Args:
text (str or dict): The text to check. If a dictionary is provided, it should have page numbers as keys.
page (str, optional): The page number to check in the dictionary. Defaults to " 1 " .
method (str, optional): The method to use for checking. Options are " keyword "
and " regex " . Defaults to " keyword " .
Returns:
bool: True if any reimbursement-related keyword or pattern is found, False otherwise.
"""
if method == " keyword " :
if isinstance ( text , dict ) :
text_to_check = text . get ( page , " " ) . lower ( )
elif isinstance ( text , str ) :
text_to_check = text . lower ( )
else :
2025-08-27 16:37:48 +00:00
logging . warning ( " contains_reimbursement - Invalid data type " )
2025-01-24 22:46:59 +00:00
return False
return any ( keyword in text_to_check for keyword in reimbursement_strings )
elif method == " regex " :
return bool ( re . search ( reimb_regex , text ) )
2025-01-06 15:39:29 +00:00
else :
2025-01-24 22:46:59 +00:00
raise ValueError ( " Invalid method. Choose ' keyword ' or ' regex ' . " )
2025-01-06 15:39:29 +00:00
2025-07-07 21:42:49 +00:00
def count_reimbursements_in_exhibit ( exhibit_text : str ) - > int : # JUST by regex
2025-01-24 22:46:59 +00:00
""" Counts reimbursements in an exhibit text. Reimbursements are detected by a regex
search as defined by `reimb_regex`.
Args:
exhibit_text (str): Input exhibit text
Returns:
int: Number of reimbursements detected
"""
return len ( re . findall ( reimb_regex , exhibit_text ) )
2025-01-06 15:39:29 +00:00
2025-07-07 21:42:49 +00:00
2025-08-13 14:51:46 +00:00
# Overloads for type checking
@overload
def is_empty ( value : str , pd_mask : bool = True ) - > bool : . . .
@overload
def is_empty ( value : list , pd_mask : bool = True ) - > bool : . . .
@overload
def is_empty ( value : pd . Series , pd_mask : Literal [ True ] = True ) - > pd . Series : . . .
@overload
def is_empty ( value : pd . Series , pd_mask : Literal [ False ] ) - > bool : . . .
2025-07-07 21:42:49 +00:00
def is_empty ( value : str | list | pd . Series , pd_mask : bool = True ) - > bool | pd . Series :
2025-01-27 19:39:23 +00:00
"""
Checks if a value is considered empty or invalid.
Args:
value: The value to check.
2025-04-16 14:52:37 +00:00
Can be a string, list, or pandas Series.
2025-07-07 21:42:49 +00:00
pd_mask (bool): Only relevant for Series inputs.
If True, returns a mask for empty values in a pandas Series.
2025-04-16 14:52:37 +00:00
Otherwise, the function returns True iff the entire Series is empty.
2025-01-27 19:39:23 +00:00
Returns:
2025-06-26 21:26:27 +00:00
bool | pd.Series:
- When the input is a string, returns True if the value is empty or invalid, False otherwise.
- When the input is a list, returns True if the list is empty or all elements are empty/invalid.
- When the input is a pandas Series:
- If `pd_mask` is True, returns a boolean mask indicating which elements are empty or invalid.
- If `pd_mask` is False, returns True iff all elements are empty/invalid, or if the entire Series is empty.
2025-01-27 19:39:23 +00:00
"""
2025-04-16 14:52:37 +00:00
empty_values = [ None , " " , " N/A " , " NA " , " null " , " none " , " NaN " , np . nan , " nan " , " None " ]
2025-07-07 21:42:49 +00:00
2025-02-21 14:44:36 +00:00
if isinstance ( value , list ) : # Handle list inputs
return not value or all ( is_empty ( v ) for v in value )
2025-07-07 21:42:49 +00:00
2025-04-16 14:52:37 +00:00
# if it's a pd.Series, return the mask (True for empty values)
if isinstance ( value , pd . Series ) :
2025-08-13 14:51:46 +00:00
if pd_mask :
return (
value . isna ( )
| ( value . isin ( empty_values ) )
| ( value . astype ( str ) . str . strip ( ) == " " )
| (
value . astype ( str )
. str . lower ( )
. str . strip ( )
. isin ( [ " n/a " , " na " , " null " , " none " , " nan " ] )
)
)
else :
2025-11-03 20:45:58 +00:00
# If the Series is empty, it's considered empty
if value . empty :
return True
# Use the string-version check for each element in the Series
return all ( is_empty ( str ( v ) ) for v in value )
2025-04-16 14:52:37 +00:00
2025-01-06 15:39:29 +00:00
if pd . isna ( value ) :
return True
2025-08-13 14:51:46 +00:00
if isinstance ( value , str ) :
if not value or value . isspace ( ) :
return True
lower_stripped = value . lower ( ) . strip ( )
2026-03-17 18:10:57 +00:00
# Treat serialized placeholder-only lists such as ['N/A'], ["N/A"], or [] as empty.
if lower_stripped . startswith ( " [ " ) and lower_stripped . endswith ( " ] " ) :
parsed_value = None
try :
parsed_value = json . loads ( value )
except ( json . JSONDecodeError , TypeError , ValueError ) :
try :
parsed_value = ast . literal_eval ( value )
except ( ValueError , SyntaxError ) :
parsed_value = None
if isinstance ( parsed_value , list ) :
return not parsed_value or all ( is_empty ( item ) for item in parsed_value )
2026-01-26 16:52:55 +00:00
if lower_stripped in [
" n/a " ,
" na " ,
" null " ,
" none " ,
" nan " ,
" no_identifiers_found " ,
] :
2025-08-13 14:51:46 +00:00
return True
return value in empty_values
2025-01-24 22:46:59 +00:00
2025-01-10 17:24:44 +00:00
2025-03-04 22:08:16 +00:00
def datetime_str ( ) :
return datetime . now ( ) . strftime ( " [ % Y- % m- %d % H: % M: % S] " )
2025-04-25 20:28:52 +00:00
2025-07-07 21:42:49 +00:00
2025-04-25 20:28:52 +00:00
def extract_signature_page ( text_dict : dict , filename : str ) - > dict :
""" Extracts the signature page(s) from a contract text dictionary and returns it as a dictionary.
2025-07-07 21:42:49 +00:00
First tries to match the textract marker " this page has N signature(s) " , then falls back to looking
2025-04-25 20:28:52 +00:00
for the word " signature " if no matches are found. " this page has N signature(s) " is Textract ' s marker for signatures
Args:
text_dict (dict): A dictionary containing text data, organized by pages.
filename (str): The name of the file being processed.
Returns:
dict: A dictionary containing the extracted signature page(s).
"""
signature_pages = { }
# This regex pattern matches the Textract marker for signatures
# It will look for both numeric and a few textual representations of numbers (one-ten)
2025-07-07 21:42:49 +00:00
textract_pattern = re . compile (
r " this page has (?: \ d+|one|two|three|four|five|six|seven|eight|nine|ten) signature " ,
re . IGNORECASE ,
)
2025-11-26 17:21:44 +00:00
keywords = [
" signature and information " ,
" signature authorization " ,
" in witness whereof " ,
]
2025-04-25 20:28:52 +00:00
# First pass: Try to match the full textract pattern
for page_num , page_text in text_dict . items ( ) :
if textract_pattern . search ( page_text ) :
signature_pages [ page_num ] = page_text
2025-07-07 21:42:49 +00:00
2025-05-07 18:21:09 +00:00
# If no pages found with textract pattern, try with keyword
2025-04-25 20:28:52 +00:00
if not signature_pages :
2025-11-26 17:21:44 +00:00
# Iterate through each page in the text dictionary and check for keywords
for page_num , page_text in text_dict . items ( ) :
2025-05-07 18:21:09 +00:00
lower_text = page_text . lower ( )
2025-11-26 17:21:44 +00:00
if any ( keyword in lower_text for keyword in keywords ) :
signature_pages [ page_num ] = page_text
2025-04-25 20:28:52 +00:00
2025-11-26 17:21:44 +00:00
# If no signature pages found, try checking for the word "signature page follows" and return the next page only if it exists
if not signature_pages :
for page_num , page_text in text_dict . items ( ) :
if " signature page follows " in page_text . lower ( ) :
next_page_num = str ( int ( page_num ) + 1 )
if next_page_num in text_dict :
signature_pages [ next_page_num ] = text_dict [ next_page_num ]
2026-01-26 16:52:55 +00:00
2025-04-25 20:28:52 +00:00
return signature_pages
2025-05-07 18:21:09 +00:00
def extract_effective_date_pages ( text_dict : dict , filename : str ) - > dict :
"""
Extracts the effective date page(s) from a contract text dictionary and returns it as a dictionary.
2025-07-07 21:42:49 +00:00
This function identifies pages that either contain the Textract marker
" this page has N signature(s) " or the keyword ' effective date: ' .
Additionally, it ensures that the first page (page 1) is always included
2025-05-07 18:21:09 +00:00
in the result, as it often contains effective date.
Args:
text_dict (dict): A dictionary containing text data, organized by pages.
filename (str): The name of the file being processed.
Returns:
2025-07-07 21:42:49 +00:00
dict: A dictionary containing the extracted effective date page(s),
2025-05-07 18:21:09 +00:00
where keys are page numbers and values are the corresponding page text.
"""
effective_date_pages = { }
# Regex pattern to match the Textract marker for signature pages which often has effective dates
# This pattern accounts for both numeric and textual representations of numbers (one-ten).
2025-07-07 21:42:49 +00:00
textract_pattern = re . compile (
r " this page has (?: \ d+|one|two|three|four|five|six|seven|eight|nine|ten) signature " ,
re . IGNORECASE ,
)
2025-05-07 18:21:09 +00:00
# Iterate through each page in the text dictionary.
for page_num , page_text in text_dict . items ( ) :
# page_num = int(float(page_num))
# Check if the page contains the Textract marker or the keyword 'effective date:'.
2025-07-07 21:42:49 +00:00
if textract_pattern . search ( page_text ) or " effective date: " in page_text . lower ( ) :
2025-05-07 18:21:09 +00:00
effective_date_pages [ page_num ] = page_text
# Always include the first page (page 1) as it often contains effective date.
2025-06-02 13:04:52 +00:00
elif page_num == 1 or page_num == " 1 " or page_num == " 1.0 " :
2025-05-07 18:21:09 +00:00
effective_date_pages [ page_num ] = page_text
return effective_date_pages
2025-07-07 21:42:49 +00:00
def normalize_state_to_abbreviation ( state_value : str ) - > str :
""" Convert state names or abbreviations to standardized two-letter abbreviations.
Handles various formats of state names and abbreviations, including full names,
common abbreviations, and variations. Returns the standardized two-letter abbreviation
or the original value if no match is found.
Args:
state_value (str): State name or abbreviation from extraction
Returns:
str: Two-letter state abbreviation or original value if no match found
"""
if not state_value or state_value in [ " N/A " , " UNKNOWN " ] :
return state_value
# Dictionary mapping full state names to abbreviations
state_mapping = {
" alabama " : " AL " ,
" alaska " : " AK " ,
" arizona " : " AZ " ,
" arkansas " : " AR " ,
" california " : " CA " ,
" colorado " : " CO " ,
" connecticut " : " CT " ,
" delaware " : " DE " ,
" florida " : " FL " ,
" georgia " : " GA " ,
" hawaii " : " HI " ,
" idaho " : " ID " ,
" illinois " : " IL " ,
" indiana " : " IN " ,
" iowa " : " IA " ,
" kansas " : " KS " ,
" kentucky " : " KY " ,
" louisiana " : " LA " ,
" maine " : " ME " ,
" maryland " : " MD " ,
" massachusetts " : " MA " ,
" michigan " : " MI " ,
" minnesota " : " MN " ,
" mississippi " : " MS " ,
" missouri " : " MO " ,
" montana " : " MT " ,
" nebraska " : " NE " ,
" nevada " : " NV " ,
" new hampshire " : " NH " ,
" new jersey " : " NJ " ,
" new mexico " : " NM " ,
" new york " : " NY " ,
" north carolina " : " NC " ,
" north dakota " : " ND " ,
" ohio " : " OH " ,
" oklahoma " : " OK " ,
" oregon " : " OR " ,
" pennsylvania " : " PA " ,
" rhode island " : " RI " ,
" south carolina " : " SC " ,
" south dakota " : " SD " ,
" tennessee " : " TN " ,
" texas " : " TX " ,
" utah " : " UT " ,
" vermont " : " VT " ,
" virginia " : " VA " ,
" washington " : " WA " ,
" west virginia " : " WV " ,
" wisconsin " : " WI " ,
" wyoming " : " WY " ,
" district of columbia " : " DC " ,
}
# Clean and normalize the input
cleaned_state = state_value . strip ( ) . lower ( )
# Check if it's already a valid 2-letter abbreviation
if len ( cleaned_state ) == 2 and cleaned_state . upper ( ) in state_mapping . values ( ) :
return cleaned_state . upper ( )
# Check if it matches a full state name
if cleaned_state in state_mapping :
return state_mapping [ cleaned_state ]
# Handle common variations
variations = {
" calif " : " CA " ,
" cal " : " CA " ,
" fla " : " FL " ,
" florida " : " FL " ,
" ny " : " NY " ,
" n.y. " : " NY " ,
" tx " : " TX " ,
" tex " : " TX " ,
" penn " : " PA " ,
" pa " : " PA " ,
" mass " : " MA " ,
" massachusetts " : " MA " ,
" wash " : " WA " ,
" washington state " : " WA " ,
" d.c. " : " DC " ,
" dc " : " DC " ,
" washington dc " : " DC " ,
}
if cleaned_state in variations :
return variations [ cleaned_state ]
# If no match found, return original value
return state_value
2025-07-25 14:10:06 +00:00
2025-07-29 22:05:51 +00:00
2025-07-25 14:10:06 +00:00
def flatten_to_strings ( items ) - > list [ str ] :
""" Helper function to flatten and stringify any nested structure
Args:
items (Any): The input items; can be a list, tuple, or single value
Returns:
list[str]: A flattened list of stringified items
"""
result = [ ]
if isinstance ( items , ( list , tuple ) ) :
for item in items :
if isinstance ( item , ( list , tuple ) ) :
result . extend ( flatten_to_strings ( item ) )
else :
result . append ( str ( item ) . strip ( ) )
else :
result . append ( str ( items ) . strip ( ) )
2025-07-29 22:05:51 +00:00
return result
2025-11-19 19:02:45 +00:00
2026-01-26 16:52:55 +00:00
2025-11-19 19:02:45 +00:00
def parse_state_field_to_list ( raw_value ) :
"""
Convert PROVIDER_STATE raw input into a clean list of state strings.
Accepts:
- list → returned as-is
- JSON list string → parsed to list
- comma-separated string → split to list
- single string → wrapped in list
- other types → converted to string → wrapped in list
Returns:
list[str]: Clean list of state strings
"""
if raw_value is None :
return [ ]
# Case 1: Already a list
if isinstance ( raw_value , list ) :
return raw_value
# Case 2: String input
if isinstance ( raw_value , str ) :
stripped = raw_value . strip ( )
# JSON list string
if stripped . startswith ( " [ " ) and stripped . endswith ( " ] " ) :
try :
parsed = json . loads ( stripped )
if isinstance ( parsed , list ) :
return parsed
else :
return [ parsed ]
except Exception as e :
2026-01-26 16:52:55 +00:00
logging . debug (
f " Failed JSON parse for state list: { e } . Falling back to comma split. "
)
2025-11-19 19:02:45 +00:00
# fallback: treat as comma-separated
# Comma-separated list
if " , " in stripped :
return [ s . strip ( ) for s in stripped . split ( " , " ) if s . strip ( ) ]
# Single state string
return [ stripped ]
# Case 3: Any other type (int, dict, etc.)
try :
return [ str ( raw_value ) . strip ( ) ]
except Exception :
logging . error ( f " Could not convert value to string: { raw_value !r} " )
return [ ]
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
def normalize_state_field ( answers_dict : dict , field_name : str = " PROVIDER_STATE " ) :
2025-11-19 19:02:45 +00:00
if field_name not in answers_dict or not answers_dict [ field_name ] :
answers_dict [ field_name ] = [ ]
return answers_dict
raw_value = answers_dict [ field_name ]
# ---- Step 1: Parse into list ----
2025-12-17 19:05:09 +00:00
state_list = parse_raw_state_value ( raw_value )
2025-11-19 19:02:45 +00:00
# ---- Step 2: Normalize each state ----
normalized_states = [ ]
for state in state_list :
try :
normalized_states . append ( normalize_state_to_abbreviation ( state ) )
except Exception as e :
2026-01-26 16:52:55 +00:00
logging . warning (
f " Failed to normalize state ' { state } ' : { e } . Keeping original. "
)
2025-11-19 19:02:45 +00:00
normalized_states . append ( state )
answers_dict [ field_name ] = normalized_states
return answers_dict
2025-12-17 19:05:09 +00:00
def parse_raw_state_value ( raw_value ) :
"""
Parses various input formats into a flat list of state strings.
Handles: strings, lists, nested lists, and various delimiters.
"""
if not raw_value :
return [ ]
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
# If it's a string that looks like a list, try to parse it
if isinstance ( raw_value , str ) :
stripped = raw_value . strip ( )
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
# Try to parse as Python literal (handles "[['NC', 'WA'], 'CA|UT']")
2026-01-26 16:52:55 +00:00
if stripped . startswith ( " [ " ) :
2025-12-17 19:05:09 +00:00
try :
parsed = ast . literal_eval ( stripped )
return parse_raw_state_value ( parsed ) # Recursive call with parsed list
except ( ValueError , SyntaxError ) :
pass # Not valid Python literal, continue with string parsing
2026-01-26 16:52:55 +00:00
2026-01-05 18:34:18 +00:00
# Regular string splitting on delimiters (not whitespace - state names can have spaces like "West Virginia")
2026-01-26 16:52:55 +00:00
states = re . split ( r " [|,;]+ " , stripped )
2025-12-17 19:05:09 +00:00
return [ s . strip ( ) for s in states if s . strip ( ) ]
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
if isinstance ( raw_value , list ) :
result = [ ]
for item in raw_value :
result . extend ( parse_raw_state_value ( item ) ) # Recursive call
return result
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
return [ str ( raw_value ) ]
def normalize_to_json_list ( val ) :
"""
Normalizes a value to a JSON list format.
Handles: pipe-delimited strings, string representations of Python lists,
actual lists with pipe-delimited items, and single values.
2026-01-26 16:52:55 +00:00
"""
2025-12-17 19:05:09 +00:00
# Handle None and empty values first
if is_empty ( val ) :
return val
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
# Handle pandas NA/NaN - check for scalar first
try :
if pd . isna ( val ) :
return val
except ( ValueError , TypeError ) :
# pd.isna fails on arrays/lists, which is fine - we'll handle them below
pass
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
if isinstance ( val , str ) :
# Check if it's a string representation of a Python list (e.g., "['item1', 'item2']")
if val . startswith ( " [ " ) and val . endswith ( " ] " ) :
try :
# Try JSON parse first
parsed = json . loads ( val )
except json . JSONDecodeError :
# If JSON fails, try ast.literal_eval for Python list syntax
try :
parsed = ast . literal_eval ( val )
except ( ValueError , SyntaxError ) :
return val
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
if isinstance ( parsed , list ) :
expanded = [ ]
for item in parsed :
if isinstance ( item , str ) and " | " in item :
expanded . extend ( item . split ( " | " ) )
else :
expanded . append ( item )
result = json . dumps ( expanded )
return result
return val
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
# Pipe-delimited format - convert to JSON list
if " | " in val :
result = json . dumps ( val . split ( " | " ) )
return result
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
# Single value - convert to JSON list
result = json . dumps ( [ val ] )
return result
2026-01-26 16:52:55 +00:00
2025-12-17 19:05:09 +00:00
if isinstance ( val , list ) :
# Check if list contains pipe-delimited strings and expand them
expanded = [ ]
for item in val :
if isinstance ( item , str ) and " | " in item :
expanded . extend ( item . split ( " | " ) )
else :
expanded . append ( item )
result = json . dumps ( expanded )
return result
2026-01-26 16:52:55 +00:00
return val
2026-02-18 17:17:30 +00:00
def normalize_text ( text : str ) - > str :
"""
Normalize a text string by applying common preprocessing steps.
This function:
- Converts all characters to lowercase
- Removes punctuation and special characters
- Replaces multiple whitespace characters with a single space
- Strips leading and trailing whitespace
Args:
text (str): The input text to normalize.
Returns:
str: The normalized text.
"""
# Convert the text to lowercase for case-insensitive processing
text = text . lower ( )
# Replace all characters that are not letters, numbers, or whitespace with a space
text = re . sub ( r " [^ \ w \ s] " , " " , text )
# Replace multiple whitespace characters with a single space
# and remove leading/trailing spaces
text = re . sub ( r " \ s+ " , " " , text ) . strip ( )
return text
def remove_legal_suffixes ( text : str ) - > str :
"""
Remove legal entity suffixes from a text string.
This function splits the input text into tokens (words) and
removes any tokens that match known legal suffixes
(e.g., " llc " , " inc " , " corp " ).
Args:
text (str): The input text containing a business name.
Returns:
str: The text with legal suffixes removed.
"""
# Split the text into individual words
tokens = text . split ( )
# Filter out any tokens that are legal suffixes
tokens = [ t for t in tokens if t not in LEGAL_SUFFIXES ]
# Rejoin the remaining tokens into a single string
return " " . join ( tokens )
2026-02-23 18:01:21 +00:00
def extract_dba_name ( text : str ) - > tuple [ str | None , str | None ] :
2026-02-18 17:17:30 +00:00
"""
Extract a DBA (Doing Business As) name from a text string.
The function checks the input text against a list of predefined
DBA patterns (e.g., " dba " , " doing business as " ). If a pattern is found,
it returns the text that appears after the pattern.
Ex:
Input: NEW YORK STATE CATHOLIC HEALTH PLAN, INC. d/b/a FIDELIS CARE NEW YORK
Output: FIDELIS CARE NEW YORK
Args:
text (str): The input text that may contain a DBA reference.
Returns:
tuple[str | None, str | None]: A tuple containing:
- The text before the DBA pattern (potentially the legal name)
- The text after the DBA pattern (the extracted DBA name)
If no DBA pattern is found, both elements of the tuple will be None.
"""
# Iterate through all known DBA patterns
for pattern in DBA_PATTERNS :
# Split the text using the current pattern (case-insensitive)
match = re . split ( pattern , text , flags = re . I )
# If the pattern was found, the split will produce more than one part
if len ( match ) > 1 :
# Return the text after the matched pattern, trimmed of whitespace
return match [ 0 ] . strip ( ) , match [ - 1 ] . strip ( )
# Return None if no DBA pattern is found
return None , None
def remove_states ( text : str ) - > str :
"""
Remove U.S. state names or abbreviations from a text string.
The function iterates through a list of U.S. states and removes
any exact (word-boundary matched) occurrences from the input text.
Extra whitespace created by removals is cleaned up before returning.
Remove state names when they appear as a regional suffix
(i.e., at the end of the string), but preserve them when
they are leading brand identifiers (e.g., ' Oklahoma Complete Health ' ).
Args:
text (str): The input text that may contain U.S. state names.
Returns:
str: The text with U.S. state references removed.
"""
tokens = text . split ( )
if not tokens :
return text
for state in US_STATES :
state_tokens = state . split ( )
# Check if state appears at END of string
if tokens [ - len ( state_tokens ) : ] == state_tokens :
# Do NOT remove if state is the first token
if tokens [ 0 ] == state_tokens [ 0 ] :
return text
2026-02-23 18:01:21 +00:00
# Remove state AND trailing "of" if present
2026-02-18 17:17:30 +00:00
new_tokens = tokens [ : - len ( state_tokens ) ]
2026-02-23 18:01:21 +00:00
if new_tokens and new_tokens [ - 1 ] == " of " :
new_tokens = new_tokens [ : - 1 ]
2026-02-18 17:17:30 +00:00
return " " . join ( new_tokens ) . strip ( )
# Also handle "of <state>" pattern anywhere in the string, as it often indicates a regional suffixe.g., "Health Plan of New York" should become "Health Plan"
# Remove each state using a case-insensitive regex match. It looks for the word "of" followed by the state name.
for state in US_STATES :
text = re . sub ( rf " \ bof \ s+ { state } \ b " , " " , text , flags = re . I )
# Normalize whitespace after removals
return re . sub ( r " \ s+ " , " " , text ) . strip ( )
def detect_state ( text : str ) - > str | None :
""" Return state name if found in text. """
for state in US_STATES :
if re . search ( rf " \ b { state } \ b " , text , re . I ) :
return state
return None
2026-03-05 16:14:45 +00:00
def is_filled ( x ) - > bool :
"""
Returns True if x contains a meaningful (non-empty) value.
Treats None, NaN, and strings like " " , " [] " , " nan " as empty.
Inverse of is_empty for scalar values, with additional handling for " [] " .
Args:
x: The value to check.
Returns:
bool: True if x is considered filled, False otherwise.
"""
return (
x is not None
and not ( isinstance ( x , float ) and pd . isna ( x ) )
and str ( x ) . strip ( ) not in ( " " , " [] " , " nan " )
)
def extract_nested_values ( val ) - > list [ str ] :
"""
Recursively extract leaf values from arbitrarily nested string representations.
Handles strings that represent nested lists (e.g. " [ ' a ' , [ ' b ' , ' c ' ]] " ) by
parsing with ast.literal_eval and recursing into any list items.
Args:
val: The value to extract from; will be converted to string.
Returns:
list[str]: Flat list of non-empty leaf string values.
"""
s = str ( val ) . strip ( )
try :
parsed = ast . literal_eval ( s )
except Exception :
return [ s ] if s else [ ]
if isinstance ( parsed , list ) :
results = [ ]
for item in parsed :
results . extend ( extract_nested_values ( str ( item ) ) )
return [ r for r in results if r . strip ( ) ]
elif isinstance ( parsed , str ) :
return extract_nested_values ( parsed ) if parsed != s else ( [ s ] if s else [ ] )
else :
return [ str ( parsed ) . strip ( ) ]
2026-02-18 17:17:30 +00:00
def token_signature ( text : str ) - > tuple :
"""
Generate a normalized token signature from a text string.
The function removes stopwords, sorts the remaining tokens,
and returns them as an immutable tuple. This signature can be
used for fast comparison or deduplication of similar texts.
Args:
text (str): The input text to tokenize.
Returns:
tuple: A sorted tuple of tokens excluding stopwords.
"""
# Split the text into tokens and remove stopwords
tokens = [ t for t in text . split ( ) if t not in STOPWORDS ]
# Sort tokens to ensure order-independent comparison
return tuple ( sorted ( tokens ) )
2026-03-05 19:57:18 +00:00
2026-03-06 20:40:20 +00:00
def remove_hyphens ( value ) :
"""
Remove hyphens from the input value.
Args:
value : The input might be list or string
Returns:
str: The cleaned value with hyphens removed.
"""
if value is None :
return " "
if isinstance ( value , str ) :
return value . replace ( " - " , " " )
elif isinstance ( value , list ) :
return [ v . replace ( " - " , " " ) if isinstance ( v , str ) else v for v in value ]
return value
2026-03-05 19:57:18 +00:00
def keyword_match ( text : str , keywords : list ) - > bool :
"""
Check if text contains any of the keywords (case-insensitive).
Args:
text: Text to search in
keywords: List of keywords to look for
Returns:
True if any keyword is found in text
"""
text_lower = text . lower ( )
for keyword in keywords :
if keyword . lower ( ) in text_lower :
return True
return False